max / goingson
- Co-Authored-By
- Claude Opus 4.8 <noreply@anthropic.com>
13 files changed,
+284 insertions,
-60 deletions
| @@ -5,6 +5,7 @@ | |||
| 5 | 5 | //! or via `mobile_entry_point` (iOS/Android). | |
| 6 | 6 | ||
| 7 | 7 | pub mod backup_scheduler; | |
| 8 | + | pub mod blob_gc; | |
| 8 | 9 | pub mod commands; | |
| 9 | 10 | pub mod email; | |
| 10 | 11 | pub mod email_sync_scheduler; | |
| @@ -314,6 +315,9 @@ | |||
| 314 | 315 | tauri::async_runtime::block_on(async move { | |
| 315 | 316 | let state = AppState::new(&app_handle).await | |
| 316 | 317 | .expect("Failed to initialize app state"); | |
| 318 | + | // Reclaim orphaned attachment blobs before the schedulers spawn | |
| 319 | + | // and before the UI can create attachments — race-free here. | |
| 320 | + | blob_gc::reconcile(&state.pool, &state.data_dir).await; | |
| 317 | 321 | app_handle.manage(Arc::new(state)); | |
| 318 | 322 | }); | |
| 319 | 323 |
| @@ -72,7 +72,7 @@ | |||
| 72 | 72 | Project, ParseableEnum, ProjectStatus, ProjectType, Recurrence, RecurrenceRule, MonthlySpec, | |
| 73 | 73 | SavedView, SortDirection, | |
| 74 | 74 | SyncAccount, | |
| 75 | - | SortField, Subtask, Task, TaskFilterQuery, TaskSortColumn, TaskStatus, TimeSession, | |
| 75 | + | PositiveMinutes, SortField, Subtask, Task, TaskFilterQuery, TaskSortColumn, TaskStatus, TimeSession, | |
| 76 | 76 | TimeTrackingSummary, UpdateEvent, UpdateProject, UpdateTask, User, | |
| 77 | 77 | ViewFilters, ViewType, WeeklyReview, | |
| 78 | 78 | format_file_size, mime_from_extension, |
| @@ -21,7 +21,7 @@ | |||
| 21 | 21 | use crate::models::{ | |
| 22 | 22 | Annotation, Attachment, Email, EmailAccount, EmailAuthType, EmailThread, Event, | |
| 23 | 23 | FolderSyncState, NewAttachment, NewEmail, NewEmailWithTracking, NewEvent, NewProject, | |
| 24 | - | NewSavedView, NewTask, Project, SavedView, Subtask, Task, TaskFilterQuery, TimeSession, | |
| 24 | + | NewSavedView, NewTask, PositiveMinutes, Project, SavedView, Subtask, Task, TaskFilterQuery, TimeSession, | |
| 25 | 25 | TimeTrackingSummary, UpdateTask, User, | |
| 26 | 26 | }; | |
| 27 | 27 | ||
| @@ -226,7 +226,10 @@ | |||
| 226 | 226 | async fn list_all_time_sessions(&self, user_id: UserId) -> Result<Vec<TimeSession>>; | |
| 227 | 227 | ||
| 228 | 228 | /// Logs a manual time entry (retroactive, no live timer). | |
| 229 | - | async fn log_manual_time(&self, task_id: TaskId, user_id: UserId, minutes: i32, date: DateTime<Utc>) -> Result<TimeSession>; | |
| 229 | + | /// | |
| 230 | + | /// `minutes` is a validated [`PositiveMinutes`], so the repository cannot be | |
| 231 | + | /// handed a zero/negative duration. | |
| 232 | + | async fn log_manual_time(&self, task_id: TaskId, user_id: UserId, minutes: PositiveMinutes, date: DateTime<Utc>) -> Result<TimeSession>; | |
| 230 | 233 | ||
| 231 | 234 | /// Gets aggregated time tracking summary grouped by project and date. | |
| 232 | 235 | async fn get_time_summary(&self, user_id: UserId, start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Vec<TimeTrackingSummary>>; |
| @@ -41,12 +41,17 @@ | |||
| 41 | 41 | ||
| 42 | 42 | let mut updated = 0u64; | |
| 43 | 43 | ||
| 44 | - | // Use a dedicated connection with FK enforcement off, wrapped in a transaction | |
| 44 | + | // Use a dedicated connection with FK enforcement off, wrapped in a transaction. | |
| 45 | + | // `detach()` removes it from the pool: `PRAGMA foreign_keys` is per-connection, | |
| 46 | + | // so any early-return between here and the re-enable below must NOT hand a | |
| 47 | + | // FK-disabled connection back to the pool. A detached connection is simply | |
| 48 | + | // closed on drop instead. | |
| 45 | 49 | let mut conn = pool.acquire().await | |
| 46 | - | .map_err(|e| format!("Failed to acquire connection: {e}"))?; | |
| 50 | + | .map_err(|e| format!("Failed to acquire connection: {e}"))? | |
| 51 | + | .detach(); | |
| 47 | 52 | ||
| 48 | 53 | sqlx::query("PRAGMA foreign_keys = OFF") | |
| 49 | - | .execute(&mut *conn) | |
| 54 | + | .execute(&mut conn) | |
| 50 | 55 | .await | |
| 51 | 56 | .map_err(|e| format!("Failed to disable FK: {e}"))?; | |
| 52 | 57 | ||
| @@ -95,7 +100,7 @@ | |||
| 95 | 100 | ||
| 96 | 101 | // Re-enable FK enforcement on the same connection | |
| 97 | 102 | sqlx::query("PRAGMA foreign_keys = ON") | |
| 98 | - | .execute(&mut *conn) | |
| 103 | + | .execute(&mut conn) | |
| 99 | 104 | .await | |
| 100 | 105 | .map_err(|e| format!("Failed to re-enable FK: {e}"))?; | |
| 101 | 106 |
| @@ -11,7 +11,7 @@ | |||
| 11 | 11 | use goingson_core::backup_restore::RestoreInput; | |
| 12 | 12 | use goingson_core::{ | |
| 13 | 13 | AttachmentRepository, DailyNoteRepository, EventRepository, MilestoneRepository, NewAttachment, | |
| 14 | - | NewEvent, NewMilestone, NewProject, NewTask, ProjectRepository, SyncAccountRepository, | |
| 14 | + | NewEvent, NewMilestone, NewProject, NewTask, PositiveMinutes, ProjectRepository, SyncAccountRepository, | |
| 15 | 15 | TaskRepository, TaskStatus, | |
| 16 | 16 | }; | |
| 17 | 17 | use goingson_db_sqlite::{ | |
| @@ -340,7 +340,7 @@ | |||
| 340 | 340 | .await | |
| 341 | 341 | .unwrap(); | |
| 342 | 342 | let session = tasks | |
| 343 | - | .log_manual_time(task.id, user, 45, chrono::Utc::now()) | |
| 343 | + | .log_manual_time(task.id, user, PositiveMinutes::try_new(45).unwrap(), chrono::Utc::now()) | |
| 344 | 344 | .await | |
| 345 | 345 | .unwrap(); | |
| 346 | 346 | let note = daily_notes |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | mod common; | |
| 4 | 4 | ||
| 5 | - | use goingson_core::{CoreError, TaskId, TaskRepository}; | |
| 5 | + | use goingson_core::{CoreError, PositiveMinutes, TaskId, TaskRepository}; | |
| 6 | 6 | use goingson_db_sqlite::SqliteTaskRepository; | |
| 7 | 7 | ||
| 8 | 8 | #[tokio::test] | |
| @@ -261,7 +261,7 @@ | |||
| 261 | 261 | let repo = SqliteTaskRepository::new(pool); | |
| 262 | 262 | ||
| 263 | 263 | let session = repo | |
| 264 | - | .log_manual_time(task_id, user_id, 30, chrono::Utc::now()) | |
| 264 | + | .log_manual_time(task_id, user_id, PositiveMinutes::try_new(30).unwrap(), chrono::Utc::now()) | |
| 265 | 265 | .await | |
| 266 | 266 | .expect("logging manual time on an owned task should succeed"); | |
| 267 | 267 | assert_eq!(session.duration_minutes, Some(30)); | |
| @@ -284,7 +284,7 @@ | |||
| 284 | 284 | ||
| 285 | 285 | let bogus_task = TaskId::new(); | |
| 286 | 286 | let result = repo | |
| 287 | - | .log_manual_time(bogus_task, user_id, 30, chrono::Utc::now()) | |
| 287 | + | .log_manual_time(bogus_task, user_id, PositiveMinutes::try_new(30).unwrap(), chrono::Utc::now()) | |
| 288 | 288 | .await; | |
| 289 | 289 | ||
| 290 | 290 | assert!(matches!(result, Err(CoreError::NotFound { .. })), "expected NOT_FOUND, got {result:?}"); |
| @@ -77,6 +77,17 @@ | |||
| 77 | 77 | return Err(ApiError::validation_msg("Either taskId or projectId is required")); | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | + | // Verify the parent exists *before* touching the blob store, so a bad id | |
| 81 | + | // fails fast instead of after a disk write orphans a blob. | |
| 82 | + | if let Some(tid) = task_id { | |
| 83 | + | state.tasks.get_by_id(tid, DESKTOP_USER_ID).await? | |
| 84 | + | .or_not_found("task", tid)?; | |
| 85 | + | } | |
| 86 | + | if let Some(pid) = project_id { | |
| 87 | + | state.projects.get_by_id(pid, DESKTOP_USER_ID).await? | |
| 88 | + | .or_not_found("project", pid)?; | |
| 89 | + | } | |
| 90 | + | ||
| 80 | 91 | let source_path = Path::new(&file_path); | |
| 81 | 92 | ||
| 82 | 93 | // Validate path exists and is a file | |
| @@ -116,9 +127,11 @@ | |||
| 116 | 127 | std::fs::create_dir_all(&blobs_dir) | |
| 117 | 128 | .map_api_err("Failed to create blobs directory", ApiError::internal)?; | |
| 118 | 129 | ||
| 119 | - | // Copy to blob store (skip if hash already exists — dedup) | |
| 130 | + | // Copy to blob store (skip if hash already exists — dedup). Track whether we | |
| 131 | + | // were the writer so a failed insert can roll back exactly the blob we added. | |
| 120 | 132 | let blob_path = blobs_dir.join(&hash); | |
| 121 | - | if !blob_path.exists() { | |
| 133 | + | let wrote_new_blob = !blob_path.exists(); | |
| 134 | + | if wrote_new_blob { | |
| 122 | 135 | std::fs::write(&blob_path, &file_data) | |
| 123 | 136 | .map_api_err("Failed to write blob", ApiError::internal)?; | |
| 124 | 137 | } | |
| @@ -133,7 +146,7 @@ | |||
| 133 | 146 | let mime_type = mime_from_extension(&filename).to_string(); | |
| 134 | 147 | let file_size = file_data.len() as i64; | |
| 135 | 148 | ||
| 136 | - | let attachment = state.attachments | |
| 149 | + | let create_result = state.attachments | |
| 137 | 150 | .create(DESKTOP_USER_ID, NewAttachment { | |
| 138 | 151 | task_id, | |
| 139 | 152 | project_id, | |
| @@ -143,7 +156,19 @@ | |||
| 143 | 156 | blob_hash: hash, | |
| 144 | 157 | source_email_id: None, | |
| 145 | 158 | }) | |
| 146 | - | .await?; | |
| 159 | + | .await; | |
| 160 | + | ||
| 161 | + | let attachment = match create_result { | |
| 162 | + | Ok(a) => a, | |
| 163 | + | Err(e) => { | |
| 164 | + | // The row never landed; reclaim the blob we just wrote so it doesn't | |
| 165 | + | // leak. Only remove a blob we created this call (dedup hits must stay). | |
| 166 | + | if wrote_new_blob { | |
| 167 | + | let _ = std::fs::remove_file(&blob_path); | |
| 168 | + | } | |
| 169 | + | return Err(e.into()); | |
| 170 | + | } | |
| 171 | + | }; | |
| 147 | 172 | ||
| 148 | 173 | Ok(to_response(attachment, &state.data_dir)) | |
| 149 | 174 | } |
| @@ -112,16 +112,13 @@ | |||
| 112 | 112 | state: State<'_, Arc<AppState>>, | |
| 113 | 113 | input: LogManualTimeInput, | |
| 114 | 114 | ) -> Result<TimeSession, ApiError> { | |
| 115 | - | // Reject non-positive durations: a negative value produces ended_at < | |
| 116 | - | // started_at and decrements the task's actual_minutes cache (can go | |
| 117 | - | // negative); zero records an empty session. | |
| 118 | - | if input.minutes < 1 { | |
| 119 | - | return Err(ApiError::validation("minutes", "Minutes must be at least 1")); | |
| 120 | - | } | |
| 121 | - | // Ownership of `task_id` is verified inside the repo method (it must precede | |
| 122 | - | // the session INSERT to avoid an orphan row); a foreign/bogus id surfaces as | |
| 123 | - | // a NOT_FOUND error here. | |
| 124 | - | Ok(state.tasks.log_manual_time(input.task_id, DESKTOP_USER_ID, input.minutes, input.date).await?) | |
| 115 | + | // Validate the duration via the newtype: anything below 1 is rejected here | |
| 116 | + | // (a negative value would make ended_at < started_at and drive the task's | |
| 117 | + | // actual_minutes cache negative; zero records an empty session). | |
| 118 | + | let minutes = goingson_core::PositiveMinutes::try_new(input.minutes)?; | |
| 119 | + | // Ownership of `task_id` is verified inside the repo method, within the same | |
| 120 | + | // transaction as the writes; a foreign/bogus id surfaces as NOT_FOUND. | |
| 121 | + | Ok(state.tasks.log_manual_time(input.task_id, DESKTOP_USER_ID, minutes, input.date).await?) | |
| 125 | 122 | } | |
| 126 | 123 | ||
| 127 | 124 | /// Gets time tracking summary grouped by project and date. |
| @@ -4,10 +4,40 @@ | |||
| 4 | 4 | //! when a timer is started and closed when it's stopped. At most one session | |
| 5 | 5 | //! per user can be active (ended_at IS NULL) at any time. | |
| 6 | 6 | ||
| 7 | + | use std::num::NonZeroU32; | |
| 8 | + | ||
| 7 | 9 | use chrono::{DateTime, Utc}; | |
| 8 | 10 | use serde::{Deserialize, Serialize}; | |
| 11 | + | use crate::error::CoreError; | |
| 9 | 12 | use crate::id_types::{TaskId, TimeSessionId, UserId, ProjectId}; | |
| 10 | 13 | ||
| 14 | + | /// A validated, strictly-positive minute count for a manual time entry. | |
| 15 | + | /// | |
| 16 | + | /// Constructing this is the single gate that rejects zero/negative durations: | |
| 17 | + | /// a negative value would make `ended_at < started_at` and drive a task's | |
| 18 | + | /// cached `actual_minutes` negative, and zero records an empty session. Because | |
| 19 | + | /// the repository takes `PositiveMinutes` (not a raw `i32`), that whole class is | |
| 20 | + | /// unrepresentable below the command layer. | |
| 21 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 22 | + | pub struct PositiveMinutes(NonZeroU32); | |
| 23 | + | ||
| 24 | + | impl PositiveMinutes { | |
| 25 | + | /// Validate a raw minute count. Returns a `Validation` error for anything | |
| 26 | + | /// less than 1 (including negatives, which fail the `u32` conversion). | |
| 27 | + | pub fn try_new(minutes: i32) -> crate::Result<Self> { | |
| 28 | + | u32::try_from(minutes) | |
| 29 | + | .ok() | |
| 30 | + | .and_then(NonZeroU32::new) | |
| 31 | + | .map(Self) | |
| 32 | + | .ok_or_else(|| CoreError::validation("minutes", "Minutes must be at least 1")) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// The validated count as an `i32` for binding into SQL. | |
| 36 | + | pub fn as_i32(self) -> i32 { | |
| 37 | + | self.0.get() as i32 | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 11 | 41 | /// A single time tracking session on a task. | |
| 12 | 42 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 13 | 43 | #[serde(rename_all = "camelCase")] | |
| @@ -90,4 +120,17 @@ | |||
| 90 | 120 | let session = make_session(start, None); | |
| 91 | 121 | assert!(session.elapsed_minutes() >= 9); | |
| 92 | 122 | } | |
| 123 | + | ||
| 124 | + | #[test] | |
| 125 | + | fn positive_minutes_accepts_positive_values() { | |
| 126 | + | assert_eq!(PositiveMinutes::try_new(1).unwrap().as_i32(), 1); | |
| 127 | + | assert_eq!(PositiveMinutes::try_new(30).unwrap().as_i32(), 30); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | #[test] | |
| 131 | + | fn positive_minutes_rejects_zero_and_negatives() { | |
| 132 | + | assert!(PositiveMinutes::try_new(0).is_err()); | |
| 133 | + | assert!(PositiveMinutes::try_new(-1).is_err()); | |
| 134 | + | assert!(PositiveMinutes::try_new(-30).is_err()); | |
| 135 | + | } | |
| 93 | 136 | } |