max / goingson
53 files changed,
+1786 insertions,
-563 deletions
| @@ -10,7 +10,6 @@ goingson/ (workspace root) | |||
| 10 | 10 | crates/ | |
| 11 | 11 | core/ # Domain models, business logic, repository traits | |
| 12 | 12 | db-sqlite/ # SQLite repository implementations | |
| 13 | - | goingson-mcp/ # MCP server for AI task management | |
| 14 | 13 | plugin-runtime/ # Rhai plugin system | |
| 15 | 14 | src-tauri/ | |
| 16 | 15 | src/ | |
| @@ -32,7 +31,6 @@ goingson/ (workspace root) | |||
| 32 | 31 | | `core` | Models, validation, urgency, recurrence, repository traits | Nothing internal | | |
| 33 | 32 | | `db-sqlite` | SQLite implementations of repository traits | `core` | | |
| 34 | 33 | | `plugin-runtime` | Rhai plugin system | `core` | | |
| 35 | - | | `goingson-mcp` | MCP server for AI integration | `core`, `db-sqlite` | | |
| 36 | 34 | | `src-tauri` | Tauri app, commands, state | All crates | | |
| 37 | 35 | ||
| 38 | 36 | **Strict rule:** Business logic lives in `core`. Repository implementations in `db-sqlite`. Commands in `src-tauri` are thin wrappers. JavaScript never duplicates logic that exists in Rust. | |
| @@ -86,6 +84,10 @@ progressBar.style.width = `${task.subtaskProgress}%`; | |||
| 86 | 84 | ||
| 87 | 85 | When adding new features, always ask: can this be computed once in Rust instead of repeatedly in JavaScript? | |
| 88 | 86 | ||
| 87 | + | ## Plugin Style | |
| 88 | + | ||
| 89 | + | Import plugins in `plugins/` follow the cross-project Rhai style guide at `_meta/docs/rhai_style.md`. Run `_meta/scripts/lint-rhai.sh` to check formatting. Key points: 4-space indent, `snake_case` functions, `UPPER_CASE` constants, header comment block, host functions via `goingson::` namespace. | |
| 90 | + | ||
| 89 | 91 | ## Tauri Commands | |
| 90 | 92 | ||
| 91 | 93 | Commands are thin wrappers in `src-tauri/src/commands/`. They extract parameters, call repository methods, and map to response types: |
| @@ -1115,7 +1115,7 @@ dependencies = [ | |||
| 1115 | 1115 | ||
| 1116 | 1116 | [[package]] | |
| 1117 | 1117 | name = "docengine" | |
| 1118 | - | version = "0.3.0" | |
| 1118 | + | version = "0.3.1" | |
| 1119 | 1119 | dependencies = [ | |
| 1120 | 1120 | "ammonia", | |
| 1121 | 1121 | "pulldown-cmark", | |
| @@ -5541,7 +5541,7 @@ dependencies = [ | |||
| 5541 | 5541 | ||
| 5542 | 5542 | [[package]] | |
| 5543 | 5543 | name = "synckit-client" | |
| 5544 | - | version = "0.3.0" | |
| 5544 | + | version = "0.3.1" | |
| 5545 | 5545 | dependencies = [ | |
| 5546 | 5546 | "argon2", | |
| 5547 | 5547 | "base64 0.22.1", | |
| @@ -5611,7 +5611,7 @@ dependencies = [ | |||
| 5611 | 5611 | ||
| 5612 | 5612 | [[package]] | |
| 5613 | 5613 | name = "tagtree" | |
| 5614 | - | version = "0.3.0" | |
| 5614 | + | version = "0.3.1" | |
| 5615 | 5615 | ||
| 5616 | 5616 | [[package]] | |
| 5617 | 5617 | name = "tao" | |
| @@ -6087,7 +6087,7 @@ dependencies = [ | |||
| 6087 | 6087 | ||
| 6088 | 6088 | [[package]] | |
| 6089 | 6089 | name = "theme-common" | |
| 6090 | - | version = "0.3.0" | |
| 6090 | + | version = "0.3.1" | |
| 6091 | 6091 | dependencies = [ | |
| 6092 | 6092 | "serde", | |
| 6093 | 6093 | "toml 0.8.2", |
| @@ -33,17 +33,16 @@ cargo test --workspace | |||
| 33 | 33 | ||
| 34 | 34 | ## Workspace Architecture | |
| 35 | 35 | ||
| 36 | - | The project is a Cargo workspace with four library crates and one application crate: | |
| 36 | + | The project is a Cargo workspace with three library crates and one application crate: | |
| 37 | 37 | ||
| 38 | 38 | | Crate | Path | Role | | |
| 39 | 39 | |-------|------|------| | |
| 40 | 40 | | `goingson-core` | `crates/core/` | Domain models, repository traits, error types, business logic. No database dependency (optional sqlx feature for type derives). | | |
| 41 | 41 | | `goingson-db-sqlite` | `crates/db-sqlite/` | SQLite persistence via sqlx. Repository implementations, FTS5 full-text search, migrations. | | |
| 42 | 42 | | `goingson-plugin-runtime` | `crates/plugin-runtime/` | Rhai scripting engine for import plugins (CSV, custom formats). File watching for hot-reload. | | |
| 43 | - | | `goingson-mcp` | `crates/goingson-mcp/` | MCP server binary for Claude Desktop integration. Task management tools over stdio transport (rmcp). | | |
| 44 | 43 | | `goingson-desktop` | `src-tauri/` | Tauri 2 desktop shell. Commands (thin wrappers over library crates), frontend (vanilla HTML/CSS/JS), OAuth flows, email sync, SyncKit integration. | | |
| 45 | 44 | ||
| 46 | - | Dependency flow: `core` is leaf -> `db-sqlite` and `plugin-runtime` depend on `core` -> `mcp` depends on `core` + `db-sqlite` -> `src-tauri` depends on all four plus `synckit-client`. | |
| 45 | + | Dependency flow: `core` is leaf -> `db-sqlite` and `plugin-runtime` depend on `core` -> `src-tauri` depends on all three plus `synckit-client`. | |
| 47 | 46 | ||
| 48 | 47 | ## Features | |
| 49 | 48 | ||
| @@ -55,7 +54,6 @@ Dependency flow: `core` is leaf -> `db-sqlite` and `plugin-runtime` depend on `c | |||
| 55 | 54 | - **Search** -- FTS5 full-text search across all entity types | |
| 56 | 55 | - **Cloud sync** -- SyncKit integration with E2E encryption | |
| 57 | 56 | - **Plugins** -- Rhai scripting for CSV/data import | |
| 58 | - | - **MCP server** -- manage tasks from Claude Desktop | |
| 59 | 57 | - **Themes** -- 5 built-in themes (light and dark), system auto-detection | |
| 60 | 58 | - **Keyboard shortcuts** -- vim-style navigation throughout | |
| 61 | 59 | - **Platforms** -- macOS (primary), Windows, Linux; iOS in development |
| @@ -63,7 +63,7 @@ pub use id_types::{ | |||
| 63 | 63 | }; | |
| 64 | 64 | pub use models::{ | |
| 65 | 65 | Annotation, Attachment, BackupSettings, BlockType, CssClass, DbValue, Email, EmailAccount, | |
| 66 | - | EmailAuthType, EmailThread, Event, Milestone, | |
| 66 | + | EmailAuthType, EmailThread, Event, FolderSyncState, Milestone, | |
| 67 | 67 | MilestoneStatus, MonthlyGoal, MonthlyGoalStatus, MonthlyReflection, | |
| 68 | 68 | AttachmentMeta, NewAttachment, NewBackupSettings, NewEmail, NewEmailWithTracking, NewEvent, NewEventBuilder, | |
| 69 | 69 | NewMilestone, NewProject, NewSavedView, NewTask, NewTaskBuilder, Priority, |
| @@ -161,6 +161,13 @@ pub struct EmailAccount { | |||
| 161 | 161 | pub sync_interval_minutes: Option<i32>, | |
| 162 | 162 | } | |
| 163 | 163 | ||
| 164 | + | /// Per-folder IMAP sync state for incremental UID-based fetching. | |
| 165 | + | #[derive(Debug, Clone)] | |
| 166 | + | pub struct FolderSyncState { | |
| 167 | + | pub uid_validity: u32, | |
| 168 | + | pub last_seen_uid: u32, | |
| 169 | + | } | |
| 170 | + | ||
| 164 | 171 | impl EmailAccount { | |
| 165 | 172 | /// Returns true if this account uses OAuth2 authentication. | |
| 166 | 173 | pub fn is_oauth(&self) -> bool { |
| @@ -20,7 +20,7 @@ use crate::contact::{ | |||
| 20 | 20 | use crate::error::CoreError; | |
| 21 | 21 | use crate::models::{ | |
| 22 | 22 | Annotation, Attachment, Email, EmailAccount, EmailAuthType, EmailThread, Event, | |
| 23 | - | NewAttachment, NewEmail, NewEmailWithTracking, NewEvent, NewProject, | |
| 23 | + | FolderSyncState, NewAttachment, NewEmail, NewEmailWithTracking, NewEvent, NewProject, | |
| 24 | 24 | NewSavedView, NewTask, Project, SavedView, Subtask, Task, TaskFilterQuery, TimeSession, | |
| 25 | 25 | TimeTrackingSummary, UpdateTask, User, | |
| 26 | 26 | }; | |
| @@ -459,6 +459,15 @@ pub trait EmailAccountRepository: Send + Sync { | |||
| 459 | 459 | /// Lists accounts that need automatic sync based on their sync_interval_minutes. | |
| 460 | 460 | /// Returns accounts where sync is enabled and last_sync_at + interval < now. | |
| 461 | 461 | async fn list_accounts_needing_sync(&self, user_id: UserId) -> Result<Vec<EmailAccount>>; | |
| 462 | + | ||
| 463 | + | /// Gets the IMAP folder sync state for incremental UID-based fetching. | |
| 464 | + | async fn get_folder_sync_state(&self, account_id: EmailAccountId, folder: &str) -> Result<Option<FolderSyncState>>; | |
| 465 | + | ||
| 466 | + | /// Upserts the IMAP folder sync state after a successful sync. | |
| 467 | + | async fn upsert_folder_sync_state(&self, account_id: EmailAccountId, folder: &str, uid_validity: u32, last_seen_uid: u32) -> Result<()>; | |
| 468 | + | ||
| 469 | + | /// Deletes stale folder sync state (e.g. on UIDVALIDITY change). | |
| 470 | + | async fn delete_folder_sync_state(&self, account_id: EmailAccountId, folder: &str) -> Result<()>; | |
| 462 | 471 | } | |
| 463 | 472 | ||
| 464 | 473 | /// Aggregated statistics for the dashboard. |
| @@ -41,7 +41,15 @@ pub async fn init_pool(database_path: Option<&str>) -> Result<SqlitePool, sqlx:: | |||
| 41 | 41 | pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::migrate::MigrateError> { | |
| 42 | 42 | sqlx::migrate!("../../migrations/sqlite") | |
| 43 | 43 | .run(pool) | |
| 44 | + | .await?; | |
| 45 | + | ||
| 46 | + | // Ensure foreign keys are enabled (safety net for interrupted migrations) | |
| 47 | + | sqlx::query("PRAGMA foreign_keys = ON") | |
| 48 | + | .execute(pool) | |
| 44 | 49 | .await | |
| 50 | + | .expect("failed to re-enable foreign key constraints"); | |
| 51 | + | ||
| 52 | + | Ok(()) | |
| 45 | 53 | } | |
| 46 | 54 | ||
| 47 | 55 | pub use repository::{ |
| @@ -14,6 +14,7 @@ const MIGRATION_KEY: &str = "migration_deterministic_email_ids"; | |||
| 14 | 14 | /// 3. FTS triggers auto-handle the PK change on UPDATE | |
| 15 | 15 | /// | |
| 16 | 16 | /// Tracked via sync_state table — runs once, skipped on subsequent launches. | |
| 17 | + | #[tracing::instrument(skip_all)] | |
| 17 | 18 | pub async fn migrate_deterministic_email_ids(pool: &SqlitePool) -> Result<(), String> { | |
| 18 | 19 | // Check if already done | |
| 19 | 20 | let done: Option<(String,)> = sqlx::query_as( |
| @@ -49,6 +49,7 @@ pub struct SqliteAttachmentRepository { | |||
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | 51 | impl SqliteAttachmentRepository { | |
| 52 | + | #[tracing::instrument(skip_all)] | |
| 52 | 53 | pub fn new(pool: SqlitePool) -> Self { | |
| 53 | 54 | Self { pool } | |
| 54 | 55 | } | |
| @@ -58,6 +59,7 @@ const SELECT_COLS: &str = "id, user_id, task_id, project_id, filename, file_size | |||
| 58 | 59 | ||
| 59 | 60 | #[async_trait] | |
| 60 | 61 | impl AttachmentRepository for SqliteAttachmentRepository { | |
| 62 | + | #[tracing::instrument(skip_all)] | |
| 61 | 63 | async fn create(&self, user_id: UserId, attachment: NewAttachment) -> Result<Attachment> { | |
| 62 | 64 | let id = AttachmentId::new(); | |
| 63 | 65 | let now = format_datetime_now(); | |
| @@ -87,6 +89,7 @@ impl AttachmentRepository for SqliteAttachmentRepository { | |||
| 87 | 89 | .ok_or_else(|| CoreError::internal("Failed to retrieve created attachment")) | |
| 88 | 90 | } | |
| 89 | 91 | ||
| 92 | + | #[tracing::instrument(skip_all)] | |
| 90 | 93 | async fn list_for_task(&self, task_id: TaskId, user_id: UserId) -> Result<Vec<Attachment>> { | |
| 91 | 94 | let sql = format!( | |
| 92 | 95 | "SELECT {} FROM attachments WHERE task_id = ? AND user_id = ? ORDER BY created_at ASC", | |
| @@ -102,6 +105,7 @@ impl AttachmentRepository for SqliteAttachmentRepository { | |||
| 102 | 105 | rows.into_iter().map(Attachment::try_from).collect() | |
| 103 | 106 | } | |
| 104 | 107 | ||
| 108 | + | #[tracing::instrument(skip_all)] | |
| 105 | 109 | async fn list_for_project(&self, project_id: ProjectId, user_id: UserId) -> Result<Vec<Attachment>> { | |
| 106 | 110 | let sql = format!( | |
| 107 | 111 | "SELECT {} FROM attachments WHERE project_id = ? AND user_id = ? ORDER BY created_at ASC", | |
| @@ -117,6 +121,7 @@ impl AttachmentRepository for SqliteAttachmentRepository { | |||
| 117 | 121 | rows.into_iter().map(Attachment::try_from).collect() | |
| 118 | 122 | } | |
| 119 | 123 | ||
| 124 | + | #[tracing::instrument(skip_all)] | |
| 120 | 125 | async fn get_by_id(&self, id: AttachmentId, user_id: UserId) -> Result<Option<Attachment>> { | |
| 121 | 126 | let sql = format!( | |
| 122 | 127 | "SELECT {} FROM attachments WHERE id = ? AND user_id = ?", | |
| @@ -132,6 +137,7 @@ impl AttachmentRepository for SqliteAttachmentRepository { | |||
| 132 | 137 | row.map(Attachment::try_from).transpose() | |
| 133 | 138 | } | |
| 134 | 139 | ||
| 140 | + | #[tracing::instrument(skip_all)] | |
| 135 | 141 | async fn delete(&self, id: AttachmentId, user_id: UserId) -> Result<bool> { | |
| 136 | 142 | let result = sqlx::query("DELETE FROM attachments WHERE id = ? AND user_id = ?") | |
| 137 | 143 | .bind(id.to_string()) | |
| @@ -143,6 +149,7 @@ impl AttachmentRepository for SqliteAttachmentRepository { | |||
| 143 | 149 | Ok(result.rows_affected() > 0) | |
| 144 | 150 | } | |
| 145 | 151 | ||
| 152 | + | #[tracing::instrument(skip_all)] | |
| 146 | 153 | async fn list_by_blob_hash(&self, blob_hash: &str, user_id: UserId) -> Result<Vec<Attachment>> { | |
| 147 | 154 | let sql = format!( | |
| 148 | 155 | "SELECT {} FROM attachments WHERE blob_hash = ? AND user_id = ? ORDER BY created_at ASC", | |
| @@ -158,6 +165,7 @@ impl AttachmentRepository for SqliteAttachmentRepository { | |||
| 158 | 165 | rows.into_iter().map(Attachment::try_from).collect() | |
| 159 | 166 | } | |
| 160 | 167 | ||
| 168 | + | #[tracing::instrument(skip_all)] | |
| 161 | 169 | async fn list_all_blob_hashes(&self, user_id: UserId) -> Result<Vec<String>> { | |
| 162 | 170 | let rows: Vec<(String,)> = sqlx::query_as( | |
| 163 | 171 | "SELECT DISTINCT blob_hash FROM attachments WHERE user_id = ?" |
| @@ -57,6 +57,7 @@ pub struct SqliteBackupSettingsRepository { | |||
| 57 | 57 | ||
| 58 | 58 | impl SqliteBackupSettingsRepository { | |
| 59 | 59 | /// Creates a new repository instance with the given connection pool. | |
| 60 | + | #[tracing::instrument(skip_all)] | |
| 60 | 61 | pub fn new(pool: SqlitePool) -> Self { | |
| 61 | 62 | Self { pool } | |
| 62 | 63 | } | |
| @@ -64,6 +65,7 @@ impl SqliteBackupSettingsRepository { | |||
| 64 | 65 | ||
| 65 | 66 | #[async_trait] | |
| 66 | 67 | impl BackupSettingsRepository for SqliteBackupSettingsRepository { | |
| 68 | + | #[tracing::instrument(skip_all)] | |
| 67 | 69 | async fn get(&self, user_id: UserId) -> Result<Option<BackupSettings>> { | |
| 68 | 70 | let row = sqlx::query_as::<_, BackupSettingsRow>( | |
| 69 | 71 | r#"SELECT id, user_id, auto_backup_enabled, backup_frequency_minutes, | |
| @@ -78,6 +80,7 @@ impl BackupSettingsRepository for SqliteBackupSettingsRepository { | |||
| 78 | 80 | row.map(BackupSettings::try_from).transpose() | |
| 79 | 81 | } | |
| 80 | 82 | ||
| 83 | + | #[tracing::instrument(skip_all)] | |
| 81 | 84 | async fn upsert(&self, user_id: UserId, settings: NewBackupSettings) -> Result<BackupSettings> { | |
| 82 | 85 | let now = format_datetime_now(); | |
| 83 | 86 | ||
| @@ -130,6 +133,7 @@ impl BackupSettingsRepository for SqliteBackupSettingsRepository { | |||
| 130 | 133 | } | |
| 131 | 134 | } | |
| 132 | 135 | ||
| 136 | + | #[tracing::instrument(skip_all)] | |
| 133 | 137 | async fn update_last_backup_at(&self, user_id: UserId, timestamp: DateTime<Utc>) -> Result<()> { | |
| 134 | 138 | let now = format_datetime_now(); | |
| 135 | 139 | let backup_time = format_datetime(×tamp); |
| @@ -158,6 +158,7 @@ pub struct SqliteContactRepository { | |||
| 158 | 158 | ||
| 159 | 159 | impl SqliteContactRepository { | |
| 160 | 160 | /// Creates a new repository instance with the given connection pool. | |
| 161 | + | #[tracing::instrument(skip_all)] | |
| 161 | 162 | pub fn new(pool: SqlitePool) -> Self { | |
| 162 | 163 | Self { pool } | |
| 163 | 164 | } | |
| @@ -309,6 +310,7 @@ impl SqliteContactRepository { | |||
| 309 | 310 | ||
| 310 | 311 | #[async_trait] | |
| 311 | 312 | impl ContactRepository for SqliteContactRepository { | |
| 313 | + | #[tracing::instrument(skip_all)] | |
| 312 | 314 | async fn list_all(&self, user_id: UserId) -> Result<Vec<Contact>> { | |
| 313 | 315 | let rows = sqlx::query_as::<_, ContactRow>( | |
| 314 | 316 | r#" | |
| @@ -326,6 +328,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 326 | 328 | self.hydrate_contacts(rows).await | |
| 327 | 329 | } | |
| 328 | 330 | ||
| 331 | + | #[tracing::instrument(skip_all)] | |
| 329 | 332 | async fn get_by_id(&self, id: ContactId, user_id: UserId) -> Result<Option<Contact>> { | |
| 330 | 333 | let row = sqlx::query_as::<_, ContactRow>( | |
| 331 | 334 | r#" | |
| @@ -349,6 +352,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 349 | 352 | } | |
| 350 | 353 | } | |
| 351 | 354 | ||
| 355 | + | #[tracing::instrument(skip_all)] | |
| 352 | 356 | async fn create(&self, user_id: UserId, contact: NewContact) -> Result<Contact> { | |
| 353 | 357 | let id = ContactId::new(); | |
| 354 | 358 | let now = format_datetime_now(); | |
| @@ -382,6 +386,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 382 | 386 | .ok_or_else(|| CoreError::internal("Failed to retrieve created contact")) | |
| 383 | 387 | } | |
| 384 | 388 | ||
| 389 | + | #[tracing::instrument(skip_all)] | |
| 385 | 390 | async fn update(&self, id: ContactId, user_id: UserId, contact: UpdateContact) -> Result<Option<Contact>> { | |
| 386 | 391 | let now = format_datetime_now(); | |
| 387 | 392 | let tags_json = serde_json::to_string(&contact.tags).unwrap_or_else(|_| "[]".to_string()); | |
| @@ -416,6 +421,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 416 | 421 | } | |
| 417 | 422 | } | |
| 418 | 423 | ||
| 424 | + | #[tracing::instrument(skip_all)] | |
| 419 | 425 | async fn delete(&self, id: ContactId, user_id: UserId) -> Result<bool> { | |
| 420 | 426 | let result = sqlx::query("DELETE FROM contacts WHERE id = ? AND user_id = ?") | |
| 421 | 427 | .bind(id.to_string()) | |
| @@ -427,6 +433,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 427 | 433 | Ok(result.rows_affected() > 0) | |
| 428 | 434 | } | |
| 429 | 435 | ||
| 436 | + | #[tracing::instrument(skip_all)] | |
| 430 | 437 | async fn list_by_tag(&self, user_id: UserId, tag: &str) -> Result<Vec<Contact>> { | |
| 431 | 438 | // Tags stored as JSON array, use LIKE for matching | |
| 432 | 439 | let pattern = format!("%\"{}\"%" , tag); | |
| @@ -447,6 +454,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 447 | 454 | self.hydrate_contacts(rows).await | |
| 448 | 455 | } | |
| 449 | 456 | ||
| 457 | + | #[tracing::instrument(skip_all)] | |
| 450 | 458 | async fn list_filtered(&self, user_id: UserId, search: Option<&str>, tag: Option<&str>) -> Result<Vec<Contact>> { | |
| 451 | 459 | let has_search = search.is_some_and(|s| !s.is_empty()); | |
| 452 | 460 | let has_tag = tag.is_some_and(|t| !t.is_empty()); | |
| @@ -489,6 +497,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 489 | 497 | self.hydrate_contacts(rows).await | |
| 490 | 498 | } | |
| 491 | 499 | ||
| 500 | + | #[tracing::instrument(skip_all)] | |
| 492 | 501 | async fn find_by_email(&self, user_id: UserId, email: &str) -> Result<Option<Contact>> { | |
| 493 | 502 | let row = sqlx::query_as::<_, ContactRow>( | |
| 494 | 503 | r#" | |
| @@ -514,6 +523,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 514 | 523 | } | |
| 515 | 524 | } | |
| 516 | 525 | ||
| 526 | + | #[tracing::instrument(skip_all)] | |
| 517 | 527 | async fn find_by_external_id(&self, source: &str, ext_id: &str, user_id: UserId) -> Result<Option<Contact>> { | |
| 518 | 528 | let row = sqlx::query_as::<_, ContactRow>( | |
| 519 | 529 | r#" | |
| @@ -539,6 +549,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 539 | 549 | } | |
| 540 | 550 | } | |
| 541 | 551 | ||
| 552 | + | #[tracing::instrument(skip_all)] | |
| 542 | 553 | async fn add_email(&self, contact_id: ContactId, user_id: UserId, email: NewContactEmail) -> Result<ContactEmail> { | |
| 543 | 554 | // Verify contact ownership | |
| 544 | 555 | let exists = sqlx::query_scalar::<_, i32>( | |
| @@ -576,6 +587,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 576 | 587 | }) | |
| 577 | 588 | } | |
| 578 | 589 | ||
| 590 | + | #[tracing::instrument(skip_all)] | |
| 579 | 591 | async fn remove_email(&self, email_id: ContactEmailId, user_id: UserId) -> Result<bool> { | |
| 580 | 592 | // Verify ownership via JOIN | |
| 581 | 593 | let result = sqlx::query( | |
| @@ -593,6 +605,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 593 | 605 | Ok(result.rows_affected() > 0) | |
| 594 | 606 | } | |
| 595 | 607 | ||
| 608 | + | #[tracing::instrument(skip_all)] | |
| 596 | 609 | async fn add_phone(&self, contact_id: ContactId, user_id: UserId, phone: NewContactPhone) -> Result<ContactPhone> { | |
| 597 | 610 | // Verify contact ownership | |
| 598 | 611 | let exists = sqlx::query_scalar::<_, i32>( | |
| @@ -630,6 +643,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 630 | 643 | }) | |
| 631 | 644 | } | |
| 632 | 645 | ||
| 646 | + | #[tracing::instrument(skip_all)] | |
| 633 | 647 | async fn remove_phone(&self, phone_id: ContactPhoneId, user_id: UserId) -> Result<bool> { | |
| 634 | 648 | let result = sqlx::query( | |
| 635 | 649 | r#" | |
| @@ -646,6 +660,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 646 | 660 | Ok(result.rows_affected() > 0) | |
| 647 | 661 | } | |
| 648 | 662 | ||
| 663 | + | #[tracing::instrument(skip_all)] | |
| 649 | 664 | async fn add_social_handle(&self, contact_id: ContactId, user_id: UserId, handle: NewSocialHandle) -> Result<SocialHandle> { | |
| 650 | 665 | // Verify contact ownership | |
| 651 | 666 | let exists = sqlx::query_scalar::<_, i32>( | |
| @@ -683,6 +698,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 683 | 698 | }) | |
| 684 | 699 | } | |
| 685 | 700 | ||
| 701 | + | #[tracing::instrument(skip_all)] | |
| 686 | 702 | async fn remove_social_handle(&self, handle_id: SocialHandleId, user_id: UserId) -> Result<bool> { | |
| 687 | 703 | let result = sqlx::query( | |
| 688 | 704 | r#" | |
| @@ -699,6 +715,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 699 | 715 | Ok(result.rows_affected() > 0) | |
| 700 | 716 | } | |
| 701 | 717 | ||
| 718 | + | #[tracing::instrument(skip_all)] | |
| 702 | 719 | async fn add_custom_field(&self, contact_id: ContactId, user_id: UserId, field: NewContactCustomField) -> Result<ContactCustomField> { | |
| 703 | 720 | // Verify contact ownership | |
| 704 | 721 | let exists = sqlx::query_scalar::<_, i32>( | |
| @@ -736,6 +753,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 736 | 753 | }) | |
| 737 | 754 | } | |
| 738 | 755 | ||
| 756 | + | #[tracing::instrument(skip_all)] | |
| 739 | 757 | async fn remove_custom_field(&self, field_id: CustomFieldId, user_id: UserId) -> Result<bool> { | |
| 740 | 758 | let result = sqlx::query( | |
| 741 | 759 | r#" |
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | use async_trait::async_trait; | |
| 7 | 7 | use chrono::{DateTime, Utc}; | |
| 8 | 8 | use sqlx::SqlitePool; | |
| 9 | - | use goingson_core::{CoreError, EmailAccount, EmailAccountId, EmailAccountRepository, EmailAuthType, Result, UserId}; | |
| 9 | + | use goingson_core::{CoreError, EmailAccount, EmailAccountId, EmailAccountRepository, EmailAuthType, FolderSyncState, Result, UserId}; | |
| 10 | 10 | ||
| 11 | 11 | use crate::utils::{format_datetime, format_datetime_now, parse_datetime, parse_uuid}; | |
| 12 | 12 | ||
| @@ -73,6 +73,7 @@ pub struct SqliteEmailAccountRepository { pool: SqlitePool } | |||
| 73 | 73 | ||
| 74 | 74 | impl SqliteEmailAccountRepository { | |
| 75 | 75 | /// Creates a new repository instance with the given connection pool. | |
| 76 | + | #[tracing::instrument(skip_all)] | |
| 76 | 77 | pub fn new(pool: SqlitePool) -> Self { Self { pool } } | |
| 77 | 78 | ||
| 78 | 79 | /// Full SELECT query with all columns. | |
| @@ -88,6 +89,7 @@ impl SqliteEmailAccountRepository { | |||
| 88 | 89 | ||
| 89 | 90 | #[async_trait] | |
| 90 | 91 | impl EmailAccountRepository for SqliteEmailAccountRepository { | |
| 92 | + | #[tracing::instrument(skip_all)] | |
| 91 | 93 | async fn list_by_user(&self, user_id: UserId) -> Result<Vec<EmailAccount>> { | |
| 92 | 94 | let query = format!("{} WHERE user_id = ? ORDER BY account_name ASC", Self::SELECT_ALL); | |
| 93 | 95 | let rows = sqlx::query_as::<_, EmailAccountRow>(&query) | |
| @@ -95,6 +97,7 @@ impl EmailAccountRepository for SqliteEmailAccountRepository { | |||
| 95 | 97 | rows.into_iter().map(EmailAccount::try_from).collect() | |
| 96 | 98 | } | |
| 97 | 99 | ||
| 100 | + | #[tracing::instrument(skip_all)] | |
| 98 | 101 | async fn get_by_id(&self, id: EmailAccountId, user_id: UserId) -> Result<Option<EmailAccount>> { | |
| 99 | 102 | let query = format!("{} WHERE id = ? AND user_id = ?", Self::SELECT_ALL); | |
| 100 | 103 | let row = sqlx::query_as::<_, EmailAccountRow>(&query) | |
| @@ -102,6 +105,7 @@ impl EmailAccountRepository for SqliteEmailAccountRepository { | |||
| 102 | 105 | row.map(EmailAccount::try_from).transpose() | |
| 103 | 106 | } | |
| 104 | 107 | ||
| 108 | + | #[tracing::instrument(skip_all)] | |
| 105 | 109 | async fn create(&self, user_id: UserId, account_name: &str, email_address: &str, imap_server: &str, imap_port: i32, smtp_server: &str, smtp_port: i32, username: &str, password: &str, use_tls: bool, archive_folder_name: Option<&str>) -> Result<EmailAccount> { | |
| 106 | 110 | let id = EmailAccountId::new(); | |
| 107 | 111 | let now = format_datetime_now(); | |
| @@ -118,6 +122,7 @@ impl EmailAccountRepository for SqliteEmailAccountRepository { | |||
| 118 | 122 | self.get_by_id(id, user_id).await?.ok_or_else(|| CoreError::internal("Failed to retrieve created account")) | |
| 119 | 123 | } | |
| 120 | 124 | ||
| 125 | + | #[tracing::instrument(skip_all)] | |
| 121 | 126 | async fn create_oauth( | |
| 122 | 127 | &self, | |
| 123 | 128 | user_id: UserId, | |
| @@ -147,6 +152,7 @@ impl EmailAccountRepository for SqliteEmailAccountRepository { | |||
| 147 | 152 | self.get_by_id(id, user_id).await?.ok_or_else(|| CoreError::internal("Failed to retrieve created OAuth account")) | |
| 148 | 153 | } | |
| 149 | 154 | ||
| 155 | + | #[tracing::instrument(skip_all)] | |
| 150 | 156 | async fn create_oauth_imap( | |
| 151 | 157 | &self, | |
| 152 | 158 | user_id: UserId, | |
| @@ -192,6 +198,7 @@ impl EmailAccountRepository for SqliteEmailAccountRepository { | |||
| 192 | 198 | self.get_by_id(id, user_id).await?.ok_or_else(|| CoreError::internal("Failed to retrieve created OAuth IMAP account")) | |
| 193 | 199 | } | |
| 194 | 200 | ||
| 201 | + | #[tracing::instrument(skip_all)] | |
| 195 | 202 | async fn update(&self, id: EmailAccountId, user_id: UserId, account_name: &str, email_address: &str, imap_server: &str, imap_port: i32, smtp_server: &str, smtp_port: i32, username: &str, password: Option<&str>, use_tls: bool, archive_folder_name: Option<&str>) -> Result<Option<EmailAccount>> { | |
| 196 | 203 | let result = if let Some(pwd) = password { | |
| 197 | 204 | sqlx::query(r#" | |
| @@ -219,6 +226,7 @@ impl EmailAccountRepository for SqliteEmailAccountRepository { | |||
| 219 | 226 | if result.rows_affected() > 0 { self.get_by_id(id, user_id).await } else { Ok(None) } | |
| 220 | 227 | } | |
| 221 | 228 | ||
| 229 | + | #[tracing::instrument(skip_all)] | |
| 222 | 230 | async fn update_oauth_tokens( | |
| 223 | 231 | &self, | |
| 224 | 232 | id: EmailAccountId, | |
| @@ -248,6 +256,7 @@ impl EmailAccountRepository for SqliteEmailAccountRepository { | |||
| 248 | 256 | if result.rows_affected() > 0 { self.get_by_id(id, user_id).await } else { Ok(None) } | |
| 249 | 257 | } | |
| 250 | 258 | ||
| 259 | + | #[tracing::instrument(skip_all)] | |
| 251 | 260 | async fn update_jmap_session( | |
| 252 | 261 | &self, | |
| 253 | 262 | id: EmailAccountId, | |
| @@ -265,6 +274,7 @@ impl EmailAccountRepository for SqliteEmailAccountRepository { | |||
| 265 | 274 | if result.rows_affected() > 0 { self.get_by_id(id, user_id).await } else { Ok(None) } | |
| 266 | 275 | } | |
| 267 | 276 | ||
| 277 | + | #[tracing::instrument(skip_all)] | |
| 268 | 278 | async fn delete(&self, id: EmailAccountId, user_id: UserId) -> Result<bool> { | |
| 269 | 279 | let result = sqlx::query("DELETE FROM email_accounts WHERE id = ? AND user_id = ?") | |
| 270 | 280 | .bind(id.to_string()).bind(user_id.to_string()) | |
| @@ -272,6 +282,7 @@ impl EmailAccountRepository for SqliteEmailAccountRepository { | |||
| 272 | 282 | Ok(result.rows_affected() > 0) | |
| 273 | 283 | } | |
| 274 | 284 | ||
| 285 | + | #[tracing::instrument(skip_all)] | |
| 275 | 286 | async fn update_last_sync(&self, id: EmailAccountId, user_id: UserId) -> Result<bool> { | |
| 276 | 287 | let now = format_datetime_now(); | |
| 277 | 288 | let result = sqlx::query("UPDATE email_accounts SET last_sync_at = ? WHERE id = ? AND user_id = ?") | |
| @@ -280,6 +291,7 @@ impl EmailAccountRepository for SqliteEmailAccountRepository { | |||
| 280 | 291 | Ok(result.rows_affected() > 0) | |
| 281 | 292 | } | |
| 282 | 293 | ||
| 294 | + | #[tracing::instrument(skip_all)] | |
| 283 | 295 | async fn update_sync_interval(&self, id: EmailAccountId, user_id: UserId, interval_minutes: Option<i32>) -> Result<Option<EmailAccount>> { | |
| 284 | 296 | let result = sqlx::query("UPDATE email_accounts SET sync_interval_minutes = ? WHERE id = ? AND user_id = ?") | |
| 285 | 297 | .bind(interval_minutes) | |
| @@ -289,6 +301,7 @@ impl EmailAccountRepository for SqliteEmailAccountRepository { | |||
| 289 | 301 | if result.rows_affected() > 0 { self.get_by_id(id, user_id).await } else { Ok(None) } | |
| 290 | 302 | } | |
| 291 | 303 | ||
| 304 | + | #[tracing::instrument(skip_all)] | |
| 292 | 305 | async fn list_accounts_needing_sync(&self, user_id: UserId) -> Result<Vec<EmailAccount>> { | |
| 293 | 306 | let query = format!( | |
| 294 | 307 | "{} WHERE user_id = ? AND sync_interval_minutes IS NOT NULL \ | |
| @@ -301,4 +314,41 @@ impl EmailAccountRepository for SqliteEmailAccountRepository { | |||
| 301 | 314 | .fetch_all(&self.pool).await.map_err(CoreError::database)?; | |
| 302 | 315 | rows.into_iter().map(EmailAccount::try_from).collect() | |
| 303 | 316 | } | |
| 317 | + | ||
| 318 | + | #[tracing::instrument(skip_all)] | |
| 319 | + | async fn get_folder_sync_state(&self, account_id: EmailAccountId, folder: &str) -> Result<Option<FolderSyncState>> { | |
| 320 | + | let row = sqlx::query_as::<_, (i64, i64)>( | |
| 321 | + | "SELECT uid_validity, last_seen_uid FROM imap_folder_sync_state WHERE email_account_id = ? AND folder_name = ?" | |
| 322 | + | ) | |
| 323 | + | .bind(account_id.to_string()) | |
| 324 | + | .bind(folder) | |
| 325 | + | .fetch_optional(&self.pool).await.map_err(CoreError::database)?; | |
| 326 | + | Ok(row.map(|(v, u)| FolderSyncState { uid_validity: v as u32, last_seen_uid: u as u32 })) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | #[tracing::instrument(skip_all)] | |
| 330 | + | async fn upsert_folder_sync_state(&self, account_id: EmailAccountId, folder: &str, uid_validity: u32, last_seen_uid: u32) -> Result<()> { | |
| 331 | + | let now = format_datetime_now(); | |
| 332 | + | sqlx::query( | |
| 333 | + | "INSERT INTO imap_folder_sync_state (email_account_id, folder_name, uid_validity, last_seen_uid, updated_at) \ | |
| 334 | + | VALUES (?, ?, ?, ?, ?) \ | |
| 335 | + | ON CONFLICT (email_account_id, folder_name) DO UPDATE SET uid_validity = excluded.uid_validity, last_seen_uid = excluded.last_seen_uid, updated_at = excluded.updated_at" | |
| 336 | + | ) | |
| 337 | + | .bind(account_id.to_string()) | |
| 338 | + | .bind(folder) | |
| 339 | + | .bind(uid_validity as i64) | |
| 340 | + | .bind(last_seen_uid as i64) | |
| 341 | + | .bind(&now) | |
| 342 | + | .execute(&self.pool).await.map_err(CoreError::database)?; | |
| 343 | + | Ok(()) | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | #[tracing::instrument(skip_all)] | |
| 347 | + | async fn delete_folder_sync_state(&self, account_id: EmailAccountId, folder: &str) -> Result<()> { | |
| 348 | + | sqlx::query("DELETE FROM imap_folder_sync_state WHERE email_account_id = ? AND folder_name = ?") | |
| 349 | + | .bind(account_id.to_string()) | |
| 350 | + | .bind(folder) | |
| 351 | + | .execute(&self.pool).await.map_err(CoreError::database)?; | |
| 352 | + | Ok(()) | |
| 353 | + | } | |
| 304 | 354 | } |
| @@ -92,11 +92,13 @@ pub struct SqliteEmailRepository { pool: SqlitePool } | |||
| 92 | 92 | ||
| 93 | 93 | impl SqliteEmailRepository { | |
| 94 | 94 | /// Creates a new repository instance with the given connection pool. | |
| 95 | + | #[tracing::instrument(skip_all)] | |
| 95 | 96 | pub fn new(pool: SqlitePool) -> Self { Self { pool } } | |
| 96 | 97 | } | |
| 97 | 98 | ||
| 98 | 99 | #[async_trait] | |
| 99 | 100 | impl EmailRepository for SqliteEmailRepository { | |
| 101 | + | #[tracing::instrument(skip_all)] | |
| 100 | 102 | async fn list_all(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>> { | |
| 101 | 103 | let archived_filter = if include_archived { "" } else { "AND e.is_archived = 0" }; | |
| 102 | 104 | let query = format!( | |
| @@ -107,6 +109,7 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 107 | 109 | rows.into_iter().map(Email::try_from).collect() | |
| 108 | 110 | } | |
| 109 | 111 | ||
| 112 | + | #[tracing::instrument(skip_all)] | |
| 110 | 113 | async fn list_threaded(&self, user_id: UserId, include_archived: bool, offset: Option<i64>, limit: Option<i64>) -> Result<(Vec<EmailThread>, i64)> { | |
| 111 | 114 | let uid = user_id.to_string(); | |
| 112 | 115 | let archived_filter = if include_archived { "" } else { "AND e.is_archived = 0" }; | |
| @@ -211,6 +214,7 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 211 | 214 | Ok((threads, total)) | |
| 212 | 215 | } | |
| 213 | 216 | ||
| 217 | + | #[tracing::instrument(skip_all)] | |
| 214 | 218 | async fn list_by_project(&self, user_id: UserId, project_id: ProjectId) -> Result<Vec<Email>> { | |
| 215 | 219 | let query = format!( | |
| 216 | 220 | "SELECT {} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.project_id = ? ORDER BY e.received_at DESC", | |
| @@ -220,6 +224,7 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 220 | 224 | rows.into_iter().map(Email::try_from).collect() | |
| 221 | 225 | } | |
| 222 | 226 | ||
| 227 | + | #[tracing::instrument(skip_all)] | |
| 223 | 228 | async fn list_unlinked(&self, user_id: UserId) -> Result<Vec<Email>> { | |
| 224 | 229 | let query = format!( | |
| 225 | 230 | "SELECT {} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.project_id IS NULL AND e.is_archived = 0 ORDER BY e.received_at DESC", | |
| @@ -229,6 +234,7 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 229 | 234 | rows.into_iter().map(Email::try_from).collect() | |
| 230 | 235 | } | |
| 231 | 236 | ||
| 237 | + | #[tracing::instrument(skip_all)] | |
| 232 | 238 | async fn get_by_id(&self, id: EmailId, user_id: UserId) -> Result<Option<Email>> { | |
| 233 | 239 | let query = format!( | |
| 234 | 240 | "SELECT {} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.id = ? AND e.user_id = ?", | |
| @@ -238,6 +244,7 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 238 | 244 | row.map(Email::try_from).transpose() | |
| 239 | 245 | } | |
| 240 | 246 | ||
| 247 | + | #[tracing::instrument(skip_all)] | |
| 241 | 248 | async fn create(&self, user_id: UserId, email: NewEmail) -> Result<Email> { | |
| 242 | 249 | let id = EmailId::new(); | |
| 243 | 250 | let received_at = format_datetime(&email.received_at.unwrap_or_else(Utc::now)); | |
| @@ -249,6 +256,7 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 249 | 256 | self.get_by_id(id, user_id).await?.ok_or_else(|| CoreError::internal("Failed to retrieve created email")) | |
| 250 | 257 | } | |
| 251 | 258 | ||
| 259 | + | #[tracing::instrument(skip_all)] | |
| 252 | 260 | async fn create_with_tracking(&self, user_id: UserId, email: NewEmailWithTracking) -> Result<Email> { | |
| 253 | 261 | let id = goingson_core::deterministic_email_id(email.message_id.as_deref()); | |
| 254 | 262 | let received_at = format_datetime(&email.received_at.unwrap_or_else(Utc::now)); | |
| @@ -264,6 +272,7 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 264 | 272 | self.get_by_id(id, user_id).await?.ok_or_else(|| CoreError::internal("Failed to retrieve created email")) | |
| 265 | 273 | } | |
| 266 | 274 | ||
| 275 | + | #[tracing::instrument(skip_all)] | |
| 267 | 276 | async fn create_with_tracking_batch(&self, user_id: UserId, emails: Vec<NewEmailWithTracking>) -> Result<usize> { | |
| 268 | 277 | if emails.is_empty() { | |
| 269 | 278 | return Ok(0); | |
| @@ -295,56 +304,67 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 295 | 304 | Ok(count) | |
| 296 | 305 | } | |
| 297 | 306 | ||
| 307 | + | #[tracing::instrument(skip_all)] | |
| 298 | 308 | async fn delete(&self, id: EmailId, user_id: UserId) -> Result<bool> { | |
| 299 | 309 | let result = sqlx::query("DELETE FROM emails WHERE id = ? AND user_id = ?").bind(id.to_string()).bind(user_id.to_string()).execute(&self.pool).await.map_err(CoreError::database)?; | |
| 300 | 310 | Ok(result.rows_affected() > 0) | |
| 301 | 311 | } | |
| 302 | 312 | ||
| 313 | + | #[tracing::instrument(skip_all)] | |
| 303 | 314 | async fn mark_read(&self, id: EmailId, user_id: UserId) -> Result<bool> { | |
| 304 | 315 | let result = sqlx::query("UPDATE emails SET is_read = 1 WHERE id = ? AND user_id = ?").bind(id.to_string()).bind(user_id.to_string()).execute(&self.pool).await.map_err(CoreError::database)?; | |
| 305 | 316 | Ok(result.rows_affected() > 0) | |
| 306 | 317 | } | |
| 307 | 318 | ||
| 319 | + | #[tracing::instrument(skip_all)] | |
| 308 | 320 | async fn mark_unread(&self, id: EmailId, user_id: UserId) -> Result<bool> { | |
| 309 | 321 | let result = sqlx::query("UPDATE emails SET is_read = 0 WHERE id = ? AND user_id = ?").bind(id.to_string()).bind(user_id.to_string()).execute(&self.pool).await.map_err(CoreError::database)?; | |
| 310 | 322 | Ok(result.rows_affected() > 0) | |
| 311 | 323 | } | |
| 312 | 324 | ||
| 325 | + | #[tracing::instrument(skip_all)] | |
| 313 | 326 | async fn archive(&self, id: EmailId, user_id: UserId) -> Result<bool> { | |
| 314 | 327 | let result = sqlx::query("UPDATE emails SET is_archived = 1 WHERE id = ? AND user_id = ?").bind(id.to_string()).bind(user_id.to_string()).execute(&self.pool).await.map_err(CoreError::database)?; | |
| 315 | 328 | Ok(result.rows_affected() > 0) | |
| 316 | 329 | } | |
| 317 | 330 | ||
| 331 | + | #[tracing::instrument(skip_all)] | |
| 318 | 332 | async fn unarchive(&self, id: EmailId, user_id: UserId) -> Result<bool> { | |
| 319 | 333 | let result = sqlx::query("UPDATE emails SET is_archived = 0 WHERE id = ? AND user_id = ?").bind(id.to_string()).bind(user_id.to_string()).execute(&self.pool).await.map_err(CoreError::database)?; | |
| 320 | 334 | Ok(result.rows_affected() > 0) | |
| 321 | 335 | } | |
| 322 | 336 | ||
| 337 | + | #[tracing::instrument(skip_all)] | |
| 323 | 338 | async fn update_source_folder(&self, id: EmailId, user_id: UserId, new_folder: &str) -> Result<bool> { | |
| 324 | 339 | let result = sqlx::query("UPDATE emails SET source_folder = ? WHERE id = ? AND user_id = ?").bind(new_folder).bind(id.to_string()).bind(user_id.to_string()).execute(&self.pool).await.map_err(CoreError::database)?; | |
| 325 | 340 | Ok(result.rows_affected() > 0) | |
| 326 | 341 | } | |
| 327 | 342 | ||
| 343 | + | #[tracing::instrument(skip_all)] | |
| 328 | 344 | async fn mark_all_read(&self, user_id: UserId) -> Result<u64> { | |
| 329 | 345 | let result = sqlx::query("UPDATE emails SET is_read = 1 WHERE user_id = ? AND is_read = 0").bind(user_id.to_string()).execute(&self.pool).await.map_err(CoreError::database)?; | |
| 330 | 346 | Ok(result.rows_affected()) | |
| 331 | 347 | } | |
| 332 | 348 | ||
| 349 | + | #[tracing::instrument(skip_all)] | |
| 333 | 350 | async fn link_to_project(&self, id: EmailId, user_id: UserId, project_id: Option<ProjectId>) -> Result<bool> { | |
| 334 | 351 | let result = sqlx::query("UPDATE emails SET project_id = ? WHERE id = ? AND user_id = ?").bind(project_id.map(|p| p.to_string())).bind(id.to_string()).bind(user_id.to_string()).execute(&self.pool).await.map_err(CoreError::database)?; | |
| 335 | 352 | Ok(result.rows_affected() > 0) | |
| 336 | 353 | } | |
| 337 | 354 | ||
| 355 | + | #[tracing::instrument(skip_all)] | |
| 338 | 356 | async fn count_unread(&self, user_id: UserId) -> Result<i64> { | |
| 339 | 357 | let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM emails WHERE user_id = ? AND is_read = 0").bind(user_id.to_string()).fetch_one(&self.pool).await.map_err(CoreError::database)?; | |
| 340 | 358 | Ok(row.0) | |
| 341 | 359 | } | |
| 342 | 360 | ||
| 361 | + | #[tracing::instrument(skip_all)] | |
| 343 | 362 | async fn exists_by_message_id(&self, user_id: UserId, message_id: &str) -> Result<bool> { | |
| 344 | 363 | let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM emails WHERE user_id = ? AND message_id = ?").bind(user_id.to_string()).bind(message_id).fetch_one(&self.pool).await.map_err(CoreError::database)?; | |
| 345 | 364 | Ok(row.0 > 0) | |
| 346 | 365 | } | |
| 347 | 366 | ||
| 367 | + | #[tracing::instrument(skip_all)] | |
| 348 | 368 | async fn exists_by_message_ids(&self, user_id: UserId, message_ids: &[&str]) -> Result<HashSet<String>> { | |
| 349 | 369 | if message_ids.is_empty() { | |
| 350 | 370 | return Ok(HashSet::new()); | |
| @@ -367,6 +387,7 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 367 | 387 | Ok(rows.into_iter().map(|(id,)| id).collect()) | |
| 368 | 388 | } | |
| 369 | 389 | ||
| 390 | + | #[tracing::instrument(skip_all)] | |
| 370 | 391 | async fn snooze(&self, id: EmailId, user_id: UserId, until: DateTime<Utc>) -> Result<Option<Email>> { | |
| 371 | 392 | let until_str = format_datetime(&until); | |
| 372 | 393 | let result = sqlx::query("UPDATE emails SET snoozed_until = ? WHERE id = ? AND user_id = ?") | |
| @@ -379,6 +400,7 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 379 | 400 | if result.rows_affected() > 0 { self.get_by_id(id, user_id).await } else { Ok(None) } | |
| 380 | 401 | } | |
| 381 | 402 | ||
| 403 | + | #[tracing::instrument(skip_all)] | |
| 382 | 404 | async fn unsnooze(&self, id: EmailId, user_id: UserId) -> Result<Option<Email>> { | |
| 383 | 405 | let result = sqlx::query("UPDATE emails SET snoozed_until = NULL WHERE id = ? AND user_id = ?") | |
| 384 | 406 | .bind(id.to_string()) | |
| @@ -389,6 +411,7 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 389 | 411 | if result.rows_affected() > 0 { self.get_by_id(id, user_id).await } else { Ok(None) } | |
| 390 | 412 | } | |
| 391 | 413 | ||
| 414 | + | #[tracing::instrument(skip_all)] | |
| 392 | 415 | async fn list_snoozed(&self, user_id: UserId) -> Result<Vec<Email>> { | |
| 393 | 416 | let query = format!( | |
| 394 | 417 | "SELECT {} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.snoozed_until IS NOT NULL AND datetime(e.snoozed_until) > datetime('now') ORDER BY e.snoozed_until ASC", | |
| @@ -403,6 +426,7 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 403 | 426 | rows.into_iter().map(Email::try_from).collect() | |
| 404 | 427 | } | |
| 405 | 428 | ||
| 429 | + | #[tracing::instrument(skip_all)] | |
| 406 | 430 | async fn mark_waiting(&self, id: EmailId, user_id: UserId, expected_response: Option<DateTime<Utc>>) -> Result<Option<Email>> { | |
| 407 | 431 | let now = format_datetime_now(); | |
| 408 | 432 | let expected = format_datetime_opt(expected_response); | |
| @@ -421,6 +445,7 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 421 | 445 | if result.rows_affected() > 0 { self.get_by_id(id, user_id).await } else { Ok(None) } | |
| 422 | 446 | } | |
| 423 | 447 | ||
| 448 | + | #[tracing::instrument(skip_all)] | |
| 424 | 449 | async fn clear_waiting(&self, id: EmailId, user_id: UserId) -> Result<Option<Email>> { | |
| 425 | 450 | let result = sqlx::query( | |
| 426 | 451 | "UPDATE emails SET waiting_for_response = 0, waiting_since = NULL, expected_response_date = NULL WHERE id = ? AND user_id = ?" | |
| @@ -434,6 +459,7 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 434 | 459 | if result.rows_affected() > 0 { self.get_by_id(id, user_id).await } else { Ok(None) } | |
| 435 | 460 | } | |
| 436 | 461 | ||
| 462 | + | #[tracing::instrument(skip_all)] | |
| 437 | 463 | async fn list_waiting(&self, user_id: UserId) -> Result<Vec<Email>> { | |
| 438 | 464 | let query = format!( | |
| 439 | 465 | "SELECT {} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.waiting_for_response = 1 ORDER BY e.expected_response_date ASC", | |
| @@ -448,6 +474,7 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 448 | 474 | rows.into_iter().map(Email::try_from).collect() | |
| 449 | 475 | } | |
| 450 | 476 | ||
| 477 | + | #[tracing::instrument(skip_all)] | |
| 451 | 478 | async fn list_by_thread(&self, user_id: UserId, thread_id: &str) -> Result<Vec<Email>> { | |
| 452 | 479 | let query = format!( | |
| 453 | 480 | "SELECT {} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.thread_id = ? ORDER BY e.received_at ASC", | |
| @@ -463,6 +490,7 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 463 | 490 | rows.into_iter().map(Email::try_from).collect() | |
| 464 | 491 | } | |
| 465 | 492 | ||
| 493 | + | #[tracing::instrument(skip_all)] | |
| 466 | 494 | async fn get_by_message_id(&self, user_id: UserId, message_id: &str) -> Result<Option<Email>> { | |
| 467 | 495 | let query = format!( | |
| 468 | 496 | "SELECT {} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.message_id = ?", |
| @@ -82,6 +82,7 @@ pub struct SqliteEventRepository { | |||
| 82 | 82 | ||
| 83 | 83 | impl SqliteEventRepository { | |
| 84 | 84 | /// Creates a new repository instance with the given connection pool. | |
| 85 | + | #[tracing::instrument(skip_all)] | |
| 85 | 86 | pub fn new(pool: SqlitePool) -> Self { | |
| 86 | 87 | Self { pool } | |
| 87 | 88 | } | |
| @@ -89,6 +90,7 @@ impl SqliteEventRepository { | |||
| 89 | 90 | ||
| 90 | 91 | #[async_trait] | |
| 91 | 92 | impl EventRepository for SqliteEventRepository { | |
| 93 | + | #[tracing::instrument(skip_all)] | |
| 92 | 94 | async fn list_all(&self, user_id: UserId) -> Result<Vec<Event>> { | |
| 93 | 95 | let query = format!( | |
| 94 | 96 | "SELECT {} FROM events e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = e.contact_id WHERE e.user_id = ? ORDER BY e.start_time ASC", | |
| @@ -103,6 +105,7 @@ impl EventRepository for SqliteEventRepository { | |||
| 103 | 105 | rows.into_iter().map(Event::try_from).collect() | |
| 104 | 106 | } | |
| 105 | 107 | ||
| 108 | + | #[tracing::instrument(skip_all)] | |
| 106 | 109 | async fn list_by_project(&self, user_id: UserId, project_id: ProjectId) -> Result<Vec<Event>> { | |
| 107 | 110 | let query = format!( | |
| 108 | 111 | "SELECT {} FROM events e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = e.contact_id WHERE e.user_id = ? AND e.project_id = ? ORDER BY e.start_time ASC", | |
| @@ -118,6 +121,7 @@ impl EventRepository for SqliteEventRepository { | |||
| 118 | 121 | rows.into_iter().map(Event::try_from).collect() | |
| 119 | 122 | } | |
| 120 | 123 | ||
| 124 | + | #[tracing::instrument(skip_all)] | |
| 121 | 125 | async fn get_by_id(&self, id: EventId, user_id: UserId) -> Result<Option<Event>> { | |
| 122 | 126 | let query = format!( | |
| 123 | 127 | "SELECT {} FROM events e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = e.contact_id WHERE e.id = ? AND e.user_id = ?", | |
| @@ -133,6 +137,7 @@ impl EventRepository for SqliteEventRepository { | |||
| 133 | 137 | row.map(Event::try_from).transpose() | |
| 134 | 138 | } | |
| 135 | 139 | ||
| 140 | + | #[tracing::instrument(skip_all)] | |
| 136 | 141 | async fn create(&self, user_id: UserId, event: NewEvent) -> Result<Event> { | |
| 137 | 142 | let id = EventId::new(); | |
| 138 | 143 | let start_str = format_datetime(&event.start_time); | |
| @@ -160,6 +165,7 @@ impl EventRepository for SqliteEventRepository { | |||
| 160 | 165 | self.get_by_id(id, user_id).await?.ok_or_else(|| CoreError::internal("Failed to retrieve created event")) | |
| 161 | 166 | } | |
| 162 | 167 | ||
| 168 | + | #[tracing::instrument(skip_all)] | |
| 163 | 169 | async fn update(&self, id: EventId, user_id: UserId, event: UpdateEvent) -> Result<Option<Event>> { | |
| 164 | 170 | let start_str = format_datetime(&event.start_time); | |
| 165 | 171 | let end_str = format_datetime_opt(event.end_time); | |
| @@ -186,6 +192,7 @@ impl EventRepository for SqliteEventRepository { | |||
| 186 | 192 | if result.rows_affected() > 0 { self.get_by_id(id, user_id).await } else { Ok(None) } | |
| 187 | 193 | } | |
| 188 | 194 | ||
| 195 | + | #[tracing::instrument(skip_all)] | |
| 189 | 196 | async fn delete(&self, id: EventId, user_id: UserId) -> Result<bool> { | |
| 190 | 197 | let result = sqlx::query("DELETE FROM events WHERE id = ? AND user_id = ?") | |
| 191 | 198 | .bind(id.to_string()) | |
| @@ -197,6 +204,7 @@ impl EventRepository for SqliteEventRepository { | |||
| 197 | 204 | Ok(result.rows_affected() > 0) | |
| 198 | 205 | } | |
| 199 | 206 | ||
| 207 | + | #[tracing::instrument(skip_all)] | |
| 200 | 208 | async fn get_upcoming(&self, user_id: UserId, days: i64) -> Result<Vec<Event>> { | |
| 201 | 209 | let query = format!( | |
| 202 | 210 | "SELECT {} FROM events e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = e.contact_id WHERE e.user_id = ? AND datetime(e.start_time) >= datetime('now') AND datetime(e.start_time) <= datetime('now', ? || ' days') ORDER BY e.start_time ASC", | |
| @@ -212,6 +220,7 @@ impl EventRepository for SqliteEventRepository { | |||
| 212 | 220 | rows.into_iter().map(Event::try_from).collect() | |
| 213 | 221 | } | |
| 214 | 222 | ||
| 223 | + | #[tracing::instrument(skip_all)] | |
| 215 | 224 | async fn get_by_linked_task(&self, user_id: UserId, task_id: TaskId) -> Result<Option<Event>> { | |
| 216 | 225 | let query = format!( | |
| 217 | 226 | "SELECT {} FROM events e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = e.contact_id WHERE e.user_id = ? AND e.linked_task_id = ?", | |
| @@ -227,6 +236,7 @@ impl EventRepository for SqliteEventRepository { | |||
| 227 | 236 | row.map(Event::try_from).transpose() | |
| 228 | 237 | } | |
| 229 | 238 | ||
| 239 | + | #[tracing::instrument(skip_all)] | |
| 230 | 240 | async fn delete_by_linked_task(&self, user_id: UserId, task_id: TaskId) -> Result<bool> { | |
| 231 | 241 | let result = sqlx::query("DELETE FROM events WHERE user_id = ? AND linked_task_id = ?") | |
| 232 | 242 | .bind(user_id.to_string()) | |
| @@ -238,6 +248,7 @@ impl EventRepository for SqliteEventRepository { | |||
| 238 | 248 | Ok(result.rows_affected() > 0) | |
| 239 | 249 | } | |
| 240 | 250 | ||
| 251 | + | #[tracing::instrument(skip_all)] | |
| 241 | 252 | async fn list_for_date(&self, user_id: UserId, date: NaiveDate) -> Result<Vec<Event>> { | |
| 242 | 253 | let date_start = format!("{} 00:00:00", date); | |
| 243 | 254 | let date_end = format!("{} 23:59:59", date); | |
| @@ -256,6 +267,7 @@ impl EventRepository for SqliteEventRepository { | |||
| 256 | 267 | rows.into_iter().map(Event::try_from).collect() | |
| 257 | 268 | } | |
| 258 | 269 | ||
| 270 | + | #[tracing::instrument(skip_all)] | |
| 259 | 271 | async fn list_between(&self, user_id: UserId, start: chrono::DateTime<chrono::Utc>, end: chrono::DateTime<chrono::Utc>) -> Result<Vec<Event>> { | |
| 260 | 272 | let start_str = format_datetime(&start); | |
| 261 | 273 | let end_str = format_datetime(&end); | |
| @@ -274,6 +286,7 @@ impl EventRepository for SqliteEventRepository { | |||
| 274 | 286 | rows.into_iter().map(Event::try_from).collect() | |
| 275 | 287 | } | |
| 276 | 288 | ||
| 289 | + | #[tracing::instrument(skip_all)] | |
| 277 | 290 | async fn find_by_external_id(&self, source: &str, ext_id: &str, user_id: UserId) -> Result<Option<Event>> { | |
| 278 | 291 | let query = format!( | |
| 279 | 292 | "SELECT {} FROM events e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = e.contact_id WHERE e.user_id = ? AND e.external_source = ? AND e.external_id = ?", |
| @@ -61,6 +61,7 @@ pub struct SqliteMilestoneRepository { | |||
| 61 | 61 | ||
| 62 | 62 | impl SqliteMilestoneRepository { | |
| 63 | 63 | /// Creates a new repository instance with the given connection pool. | |
| 64 | + | #[tracing::instrument(skip_all)] | |
| 64 | 65 | pub fn new(pool: SqlitePool) -> Self { | |
| 65 | 66 | Self { pool } | |
| 66 | 67 | } | |
| @@ -68,6 +69,7 @@ impl SqliteMilestoneRepository { | |||
| 68 | 69 | ||
| 69 | 70 | #[async_trait] | |
| 70 | 71 | impl MilestoneRepository for SqliteMilestoneRepository { | |
| 72 | + | #[tracing::instrument(skip_all)] | |
| 71 | 73 | async fn list_by_project(&self, project_id: ProjectId, user_id: UserId) -> Result<Vec<Milestone>> { | |
| 72 | 74 | let rows = sqlx::query_as::<_, MilestoneRow>( | |
| 73 | 75 | r#" | |
| @@ -86,6 +88,7 @@ impl MilestoneRepository for SqliteMilestoneRepository { | |||
| 86 | 88 | rows.into_iter().map(Milestone::try_from).collect() | |
| 87 | 89 | } | |
| 88 | 90 | ||
| 91 | + | #[tracing::instrument(skip_all)] | |
| 89 | 92 | async fn get_by_id(&self, id: MilestoneId, user_id: UserId) -> Result<Option<Milestone>> { | |
| 90 | 93 | let row = sqlx::query_as::<_, MilestoneRow>( | |
| 91 | 94 | r#" | |
| @@ -103,6 +106,7 @@ impl MilestoneRepository for SqliteMilestoneRepository { | |||
| 103 | 106 | row.map(Milestone::try_from).transpose() | |
| 104 | 107 | } | |
| 105 | 108 | ||
| 109 | + | #[tracing::instrument(skip_all)] | |
| 106 | 110 | async fn create(&self, user_id: UserId, milestone: NewMilestone) -> Result<Milestone> { | |
| 107 | 111 | let id = MilestoneId::new(); | |
| 108 | 112 | let now = format_datetime_now(); | |
| @@ -131,6 +135,7 @@ impl MilestoneRepository for SqliteMilestoneRepository { | |||
| 131 | 135 | .ok_or_else(|| CoreError::internal("Failed to retrieve created milestone")) | |
| 132 | 136 | } | |
| 133 | 137 | ||
| 138 | + | #[tracing::instrument(skip_all)] | |
| 134 | 139 | async fn update( | |
| 135 | 140 | &self, | |
| 136 | 141 | id: MilestoneId, | |
| @@ -166,6 +171,7 @@ impl MilestoneRepository for SqliteMilestoneRepository { | |||
| 166 | 171 | } | |
| 167 | 172 | } | |
| 168 | 173 | ||
| 174 | + | #[tracing::instrument(skip_all)] | |
| 169 | 175 | async fn delete(&self, id: MilestoneId, user_id: UserId) -> Result<bool> { | |
| 170 | 176 | let result = sqlx::query("DELETE FROM milestones WHERE id = ? AND user_id = ?") | |
| 171 | 177 | .bind(id.to_string()) | |
| @@ -177,6 +183,7 @@ impl MilestoneRepository for SqliteMilestoneRepository { | |||
| 177 | 183 | Ok(result.rows_affected() > 0) | |
| 178 | 184 | } | |
| 179 | 185 | ||
| 186 | + | #[tracing::instrument(skip_all)] | |
| 180 | 187 | async fn reorder(&self, project_id: ProjectId, user_id: UserId, milestone_ids: &[MilestoneId]) -> Result<()> { | |
| 181 | 188 | for (i, id) in milestone_ids.iter().enumerate() { | |
| 182 | 189 | sqlx::query( |
| @@ -1,279 +1,286 @@ | |||
| 1 | - | //! SQLite implementation of the MonthlyReviewRepository. | |
| 2 | - | //! | |
| 3 | - | //! Provides monthly goal and reflection persistence. | |
| 4 | - | ||
| 5 | - | use async_trait::async_trait; | |
| 6 | - | use chrono::Utc; | |
| 7 | - | use sqlx::SqlitePool; | |
| 8 | - | use goingson_core::{ | |
| 9 | - | CoreError, MonthlyGoal, MonthlyGoalId, MonthlyGoalStatus, MonthlyReflection, | |
| 10 | - | MonthlyReflectionId, MonthlyReviewRepository, Result, UserId, | |
| 11 | - | }; | |
| 12 | - | ||
| 13 | - | use crate::utils::{format_datetime, parse_datetime, parse_uuid}; | |
| 14 | - | ||
| 15 | - | /// SQLite-backed implementation of [`MonthlyReviewRepository`]. | |
| 16 | - | pub struct SqliteMonthlyReviewRepository { | |
| 17 | - | pool: SqlitePool, | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | impl SqliteMonthlyReviewRepository { | |
| 21 | - | pub fn new(pool: SqlitePool) -> Self { | |
| 22 | - | Self { pool } | |
| 23 | - | } | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | // ============ Row Types ============ | |
| 27 | - | ||
| 28 | - | #[derive(sqlx::FromRow)] | |
| 29 | - | struct MonthlyGoalRow { | |
| 30 | - | id: String, | |
| 31 | - | user_id: String, | |
| 32 | - | month: String, | |
| 33 | - | text: String, | |
| 34 | - | status: String, | |
| 35 | - | position: i32, | |
| 36 | - | created_at: String, | |
| 37 | - | updated_at: String, | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | #[derive(sqlx::FromRow)] | |
| 41 | - | struct MonthlyReflectionRow { | |
| 42 | - | id: String, | |
| 43 | - | user_id: String, | |
| 44 | - | month: String, | |
| 45 | - | highlight_text: String, | |
| 46 | - | change_text: String, | |
| 47 | - | completed_at: String, | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // ============ Conversions ============ | |
| 51 | - | ||
| 52 | - | impl TryFrom<MonthlyGoalRow> for MonthlyGoal { | |
| 53 | - | type Error = CoreError; | |
| 54 | - | ||
| 55 | - | fn try_from(row: MonthlyGoalRow) -> Result<Self> { | |
| 56 | - | Ok(MonthlyGoal { | |
| 57 | - | id: parse_uuid(&row.id)?.into(), | |
| 58 | - | user_id: parse_uuid(&row.user_id)?.into(), | |
| 59 | - | month: row.month, | |
| 60 | - | text: row.text, | |
| 61 | - | status: row.status.parse()?, | |
| 62 | - | position: row.position, | |
| 63 | - | created_at: parse_datetime(&row.created_at)?, | |
| 64 | - | updated_at: parse_datetime(&row.updated_at)?, | |
| 65 | - | }) | |
| 66 | - | } | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | impl TryFrom<MonthlyReflectionRow> for MonthlyReflection { | |
| 70 | - | type Error = CoreError; | |
| 71 | - | ||
| 72 | - | fn try_from(row: MonthlyReflectionRow) -> Result<Self> { | |
| 73 | - | Ok(MonthlyReflection { | |
| 74 | - | id: parse_uuid(&row.id)?.into(), | |
| 75 | - | user_id: parse_uuid(&row.user_id)?.into(), | |
| 76 | - | month: row.month, | |
| 77 | - | highlight_text: row.highlight_text, | |
| 78 | - | change_text: row.change_text, | |
| 79 | - | completed_at: parse_datetime(&row.completed_at)?, | |
| 80 | - | }) | |
| 81 | - | } | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | // ============ Repository Implementation ============ | |
| 85 | - | ||
| 86 | - | #[async_trait] | |
| 87 | - | impl MonthlyReviewRepository for SqliteMonthlyReviewRepository { | |
| 88 | - | async fn list_goals(&self, user_id: UserId, month: &str) -> Result<Vec<MonthlyGoal>> { | |
| 89 | - | let user_id_str = user_id.to_string(); | |
| 90 | - | ||
| 91 | - | let rows: Vec<MonthlyGoalRow> = sqlx::query_as( | |
| 92 | - | "SELECT id, user_id, month, text, status, position, created_at, updated_at | |
| 93 | - | FROM monthly_goals | |
| 94 | - | WHERE user_id = ? AND month = ? | |
| 95 | - | ORDER BY position" | |
| 96 | - | ) | |
| 97 | - | .bind(&user_id_str) | |
| 98 | - | .bind(month) | |
| 99 | - | .fetch_all(&self.pool) | |
| 100 | - | .await | |
| 101 | - | .map_err(CoreError::database)?; | |
| 102 | - | ||
| 103 | - | rows.into_iter().map(MonthlyGoal::try_from).collect() | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | async fn upsert_goal(&self, user_id: UserId, month: &str, text: &str, position: i32) -> Result<MonthlyGoal> { | |
| 107 | - | let user_id_str = user_id.to_string(); | |
| 108 | - | let now = format_datetime(&Utc::now()); | |
| 109 | - | ||
| 110 | - | // Check if a goal exists at this position for this month | |
| 111 | - | let existing: Option<MonthlyGoalRow> = sqlx::query_as( | |
| 112 | - | "SELECT id, user_id, month, text, status, position, created_at, updated_at | |
| 113 | - | FROM monthly_goals | |
| 114 | - | WHERE user_id = ? AND month = ? AND position = ?" | |
| 115 | - | ) | |
| 116 | - | .bind(&user_id_str) | |
| 117 | - | .bind(month) | |
| 118 | - | .bind(position) | |
| 119 | - | .fetch_optional(&self.pool) | |
| 120 | - | .await | |
| 121 | - | .map_err(CoreError::database)?; | |
| 122 | - | ||
| 123 | - | if let Some(existing) = existing { | |
| 124 | - | let id = existing.id.clone(); | |
| 125 | - | sqlx::query( | |
| 126 | - | "UPDATE monthly_goals SET text = ?, updated_at = ? WHERE id = ?" | |
| 127 | - | ) | |
| 128 | - | .bind(text) | |
| 129 | - | .bind(&now) | |
| 130 | - | .bind(&id) | |
| 131 | - | .execute(&self.pool) | |
| 132 | - | .await | |
| 133 | - | .map_err(CoreError::database)?; | |
| 134 | - | ||
| 135 | - | let mut goal = MonthlyGoal::try_from(existing)?; | |
| 136 | - | goal.text = text.to_string(); | |
| 137 | - | goal.updated_at = Utc::now(); | |
| 138 | - | Ok(goal) | |
| 139 | - | } else { | |
| 140 | - | let id = MonthlyGoalId::new(); | |
| 141 | - | sqlx::query( | |
| 142 | - | "INSERT INTO monthly_goals (id, user_id, month, text, status, position, created_at, updated_at) | |
| 143 | - | VALUES (?, ?, ?, ?, 'active', ?, ?, ?)" | |
| 144 | - | ) | |
| 145 | - | .bind(id.to_string()) | |
| 146 | - | .bind(&user_id_str) | |
| 147 | - | .bind(month) | |
| 148 | - | .bind(text) | |
| 149 | - | .bind(position) | |
| 150 | - | .bind(&now) | |
| 151 | - | .bind(&now) | |
| 152 | - | .execute(&self.pool) | |
| 153 | - | .await | |
| 154 | - | .map_err(CoreError::database)?; | |
| 155 | - | ||
| 156 | - | let now_dt = Utc::now(); | |
| 157 | - | Ok(MonthlyGoal { | |
| 158 | - | id, | |
| 159 | - | user_id, | |
| 160 | - | month: month.to_string(), | |
| 161 | - | text: text.to_string(), | |
| 162 | - | status: MonthlyGoalStatus::Active, | |
| 163 | - | position, | |
| 164 | - | created_at: now_dt, | |
| 165 | - | updated_at: now_dt, | |
| 166 | - | }) | |
| 167 | - | } | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | async fn update_goal_status(&self, id: MonthlyGoalId, user_id: UserId, status: &MonthlyGoalStatus) -> Result<Option<MonthlyGoal>> { | |
| 171 | - | let user_id_str = user_id.to_string(); | |
| 172 | - | let id_str = id.to_string(); | |
| 173 | - | let now = format_datetime(&Utc::now()); | |
| 174 | - | ||
| 175 | - | let result = sqlx::query( | |
| 176 | - | "UPDATE monthly_goals SET status = ?, updated_at = ? WHERE id = ? AND user_id = ?" | |
| 177 | - | ) | |
| 178 | - | .bind(status.as_str()) | |
| 179 | - | .bind(&now) | |
| 180 | - | .bind(&id_str) | |
| 181 | - | .bind(&user_id_str) | |
| 182 | - | .execute(&self.pool) | |
| 183 | - | .await | |
| 184 | - | .map_err(CoreError::database)?; | |
| 185 | - | ||
| 186 | - | if result.rows_affected() == 0 { | |
| 187 | - | return Ok(None); | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | let row: MonthlyGoalRow = sqlx::query_as( | |
| 191 | - | "SELECT id, user_id, month, text, status, position, created_at, updated_at | |
| 192 | - | FROM monthly_goals WHERE id = ?" | |
| 193 | - | ) | |
| 194 | - | .bind(&id_str) | |
| 195 | - | .fetch_one(&self.pool) | |
| 196 | - | .await | |
| 197 | - | .map_err(CoreError::database)?; | |
| 198 | - | ||
| 199 | - | Ok(Some(MonthlyGoal::try_from(row)?)) | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | async fn delete_goal(&self, id: MonthlyGoalId, user_id: UserId) -> Result<bool> { | |
| 203 | - | let result = sqlx::query( | |
| 204 | - | "DELETE FROM monthly_goals WHERE id = ? AND user_id = ?" | |
| 205 | - | ) | |
| 206 | - | .bind(id.to_string()) | |
| 207 | - | .bind(user_id.to_string()) | |
| 208 | - | .execute(&self.pool) | |
| 209 | - | .await | |
| 210 | - | .map_err(CoreError::database)?; | |
| 211 | - | ||
| 212 | - | Ok(result.rows_affected() > 0) | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | async fn get_reflection(&self, user_id: UserId, month: &str) -> Result<Option<MonthlyReflection>> { | |
| 216 | - | let row: Option<MonthlyReflectionRow> = sqlx::query_as( | |
| 217 | - | "SELECT id, user_id, month, highlight_text, change_text, completed_at | |
| 218 | - | FROM monthly_reflections | |
| 219 | - | WHERE user_id = ? AND month = ?" | |
| 220 | - | ) | |
| 221 | - | .bind(user_id.to_string()) | |
| 222 | - | .bind(month) | |
| 223 | - | .fetch_optional(&self.pool) | |
| 224 | - | .await | |
| 225 | - | .map_err(CoreError::database)?; | |
| 226 | - | ||
| 227 | - | row.map(MonthlyReflection::try_from).transpose() | |
| 228 | - | } | |
| 229 | - | ||
| 230 | - | async fn upsert_reflection(&self, user_id: UserId, month: &str, highlight: &str, change: &str) -> Result<MonthlyReflection> { | |
| 231 | - | let user_id_str = user_id.to_string(); | |
| 232 | - | let now = Utc::now(); | |
| 233 | - | let now_str = format_datetime(&now); | |
| 234 | - | ||
| 235 | - | let existing = self.get_reflection(user_id, month).await?; | |
| 236 | - | ||
| 237 | - | let id = if let Some(existing) = existing { | |
| 238 | - | sqlx::query( | |
| 239 | - | "UPDATE monthly_reflections SET highlight_text = ?, change_text = ?, completed_at = ? | |
| 240 | - | WHERE id = ?" | |
| 241 | - | ) | |
| 242 | - | .bind(highlight) | |
| 243 | - | .bind(change) | |
| 244 | - | .bind(&now_str) | |
| 245 | - | .bind(existing.id.to_string()) | |
| 246 | - | .execute(&self.pool) | |
| 247 | - | .await | |
| 248 | - | .map_err(CoreError::database)?; | |
| 249 | - | ||
| 250 | - | existing.id | |
| 251 | - | } else { | |
| 252 | - | let id = MonthlyReflectionId::new(); | |
| 253 | - | sqlx::query( | |
| 254 | - | "INSERT INTO monthly_reflections (id, user_id, month, highlight_text, change_text, completed_at) | |
| 255 | - | VALUES (?, ?, ?, ?, ?, ?)" | |
| 256 | - | ) | |
| 257 | - | .bind(id.to_string()) | |
| 258 | - | .bind(&user_id_str) | |
| 259 | - | .bind(month) | |
| 260 | - | .bind(highlight) | |
| 261 | - | .bind(change) | |
| 262 | - | .bind(&now_str) | |
| 263 | - | .execute(&self.pool) | |
| 264 | - | .await | |
| 265 | - | .map_err(CoreError::database)?; | |
| 266 | - | ||
| 267 | - | id | |
| 268 | - | }; | |
| 269 | - | ||
| 270 | - | Ok(MonthlyReflection { | |
| 271 | - | id, | |
| 272 | - | user_id, | |
| 273 | - | month: month.to_string(), | |
| 274 | - | highlight_text: highlight.to_string(), | |
| 275 | - | change_text: change.to_string(), | |
| 276 | - | completed_at: now, | |
| 277 | - | }) | |
| 278 | - | } | |
| 279 | - | } | |
| 1 | + | //! SQLite implementation of the MonthlyReviewRepository. | |
| 2 | + | //! | |
| 3 | + | //! Provides monthly goal and reflection persistence. | |
| 4 | + | ||
| 5 | + | use async_trait::async_trait; | |
| 6 | + | use chrono::Utc; | |
| 7 | + | use sqlx::SqlitePool; | |
| 8 | + | use goingson_core::{ | |
| 9 | + | CoreError, MonthlyGoal, MonthlyGoalId, MonthlyGoalStatus, MonthlyReflection, | |
| 10 | + | MonthlyReflectionId, MonthlyReviewRepository, Result, UserId, | |
| 11 | + | }; | |
| 12 | + | ||
| 13 | + | use crate::utils::{format_datetime, parse_datetime, parse_uuid}; | |
| 14 | + | ||
| 15 | + | /// SQLite-backed implementation of [`MonthlyReviewRepository`]. | |
| 16 | + | pub struct SqliteMonthlyReviewRepository { | |
| 17 | + | pool: SqlitePool, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | impl SqliteMonthlyReviewRepository { | |
| 21 | + | #[tracing::instrument(skip_all)] | |
| 22 | + | pub fn new(pool: SqlitePool) -> Self { | |
| 23 | + | Self { pool } | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // ============ Row Types ============ | |
| 28 | + | ||
| 29 | + | #[derive(sqlx::FromRow)] | |
| 30 | + | struct MonthlyGoalRow { | |
| 31 | + | id: String, | |
| 32 | + | user_id: String, | |
| 33 | + | month: String, | |
| 34 | + | text: String, | |
| 35 | + | status: String, | |
| 36 | + | position: i32, | |
| 37 | + | created_at: String, | |
| 38 | + | updated_at: String, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | #[derive(sqlx::FromRow)] | |
| 42 | + | struct MonthlyReflectionRow { | |
| 43 | + | id: String, | |
| 44 | + | user_id: String, | |
| 45 | + | month: String, | |
| 46 | + | highlight_text: String, | |
| 47 | + | change_text: String, | |
| 48 | + | completed_at: String, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // ============ Conversions ============ | |
| 52 | + | ||
| 53 | + | impl TryFrom<MonthlyGoalRow> for MonthlyGoal { | |
| 54 | + | type Error = CoreError; | |
| 55 | + | ||
| 56 | + | fn try_from(row: MonthlyGoalRow) -> Result<Self> { | |
| 57 | + | Ok(MonthlyGoal { | |
| 58 | + | id: parse_uuid(&row.id)?.into(), | |
| 59 | + | user_id: parse_uuid(&row.user_id)?.into(), | |
| 60 | + | month: row.month, | |
| 61 | + | text: row.text, | |
| 62 | + | status: row.status.parse()?, | |
| 63 | + | position: row.position, | |
| 64 | + | created_at: parse_datetime(&row.created_at)?, | |
| 65 | + | updated_at: parse_datetime(&row.updated_at)?, | |
| 66 | + | }) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | impl TryFrom<MonthlyReflectionRow> for MonthlyReflection { | |
| 71 | + | type Error = CoreError; | |
| 72 | + | ||
| 73 | + | fn try_from(row: MonthlyReflectionRow) -> Result<Self> { | |
| 74 | + | Ok(MonthlyReflection { | |
| 75 | + | id: parse_uuid(&row.id)?.into(), | |
| 76 | + | user_id: parse_uuid(&row.user_id)?.into(), | |
| 77 | + | month: row.month, | |
| 78 | + | highlight_text: row.highlight_text, | |
| 79 | + | change_text: row.change_text, | |
| 80 | + | completed_at: parse_datetime(&row.completed_at)?, | |
| 81 | + | }) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // ============ Repository Implementation ============ | |
| 86 | + | ||
| 87 | + | #[async_trait] | |
| 88 | + | impl MonthlyReviewRepository for SqliteMonthlyReviewRepository { | |
| 89 | + | #[tracing::instrument(skip_all)] | |
| 90 | + | async fn list_goals(&self, user_id: UserId, month: &str) -> Result<Vec<MonthlyGoal>> { | |
| 91 | + | let user_id_str = user_id.to_string(); | |
| 92 | + | ||
| 93 | + | let rows: Vec<MonthlyGoalRow> = sqlx::query_as( | |
| 94 | + | "SELECT id, user_id, month, text, status, position, created_at, updated_at | |
| 95 | + | FROM monthly_goals | |
| 96 | + | WHERE user_id = ? AND month = ? | |
| 97 | + | ORDER BY position" | |
| 98 | + | ) | |
| 99 | + | .bind(&user_id_str) | |
| 100 | + | .bind(month) | |
| 101 | + | .fetch_all(&self.pool) | |
| 102 | + | .await | |
| 103 | + | .map_err(CoreError::database)?; | |
| 104 | + | ||
| 105 | + | rows.into_iter().map(MonthlyGoal::try_from).collect() | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | #[tracing::instrument(skip_all)] | |
| 109 | + | async fn upsert_goal(&self, user_id: UserId, month: &str, text: &str, position: i32) -> Result<MonthlyGoal> { | |
| 110 | + | let user_id_str = user_id.to_string(); | |
| 111 | + | let now = format_datetime(&Utc::now()); | |
| 112 | + | ||
| 113 | + | // Check if a goal exists at this position for this month | |
| 114 | + | let existing: Option<MonthlyGoalRow> = sqlx::query_as( | |
| 115 | + | "SELECT id, user_id, month, text, status, position, created_at, updated_at | |
| 116 | + | FROM monthly_goals | |
| 117 | + | WHERE user_id = ? AND month = ? AND position = ?" | |
| 118 | + | ) | |
| 119 | + | .bind(&user_id_str) | |
| 120 | + | .bind(month) | |
| 121 | + | .bind(position) | |
| 122 | + | .fetch_optional(&self.pool) | |
| 123 | + | .await | |
| 124 | + | .map_err(CoreError::database)?; | |
| 125 | + | ||
| 126 | + | if let Some(existing) = existing { | |
| 127 | + | let id = existing.id.clone(); | |
| 128 | + | sqlx::query( | |
| 129 | + | "UPDATE monthly_goals SET text = ?, updated_at = ? WHERE id = ?" | |
| 130 | + | ) | |
| 131 | + | .bind(text) | |
| 132 | + | .bind(&now) | |
| 133 | + | .bind(&id) | |
| 134 | + | .execute(&self.pool) | |
| 135 | + | .await | |
| 136 | + | .map_err(CoreError::database)?; | |
| 137 | + | ||
| 138 | + | let mut goal = MonthlyGoal::try_from(existing)?; | |
| 139 | + | goal.text = text.to_string(); | |
| 140 | + | goal.updated_at = Utc::now(); | |
| 141 | + | Ok(goal) | |
| 142 | + | } else { | |
| 143 | + | let id = MonthlyGoalId::new(); | |
| 144 | + | sqlx::query( | |
| 145 | + | "INSERT INTO monthly_goals (id, user_id, month, text, status, position, created_at, updated_at) | |
| 146 | + | VALUES (?, ?, ?, ?, 'active', ?, ?, ?)" | |
| 147 | + | ) | |
| 148 | + | .bind(id.to_string()) | |
| 149 | + | .bind(&user_id_str) | |
| 150 | + | .bind(month) | |
| 151 | + | .bind(text) | |
| 152 | + | .bind(position) | |
| 153 | + | .bind(&now) | |
| 154 | + | .bind(&now) | |
| 155 | + | .execute(&self.pool) | |
| 156 | + | .await | |
| 157 | + | .map_err(CoreError::database)?; | |
| 158 | + | ||
| 159 | + | let now_dt = Utc::now(); | |
| 160 | + | Ok(MonthlyGoal { | |
| 161 | + | id, | |
| 162 | + | user_id, | |
| 163 | + | month: month.to_string(), | |
| 164 | + | text: text.to_string(), | |
| 165 | + | status: MonthlyGoalStatus::Active, | |
| 166 | + | position, | |
| 167 | + | created_at: now_dt, | |
| 168 | + | updated_at: now_dt, | |
| 169 | + | }) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | #[tracing::instrument(skip_all)] | |
| 174 | + | async fn update_goal_status(&self, id: MonthlyGoalId, user_id: UserId, status: &MonthlyGoalStatus) -> Result<Option<MonthlyGoal>> { | |
| 175 | + | let user_id_str = user_id.to_string(); | |
| 176 | + | let id_str = id.to_string(); | |
| 177 | + | let now = format_datetime(&Utc::now()); | |
| 178 | + | ||
| 179 | + | let result = sqlx::query( | |
| 180 | + | "UPDATE monthly_goals SET status = ?, updated_at = ? WHERE id = ? AND user_id = ?" | |
| 181 | + | ) | |
| 182 | + | .bind(status.as_str()) | |
| 183 | + | .bind(&now) | |
| 184 | + | .bind(&id_str) | |
| 185 | + | .bind(&user_id_str) | |
| 186 | + | .execute(&self.pool) | |
| 187 | + | .await | |
| 188 | + | .map_err(CoreError::database)?; | |
| 189 | + | ||
| 190 | + | if result.rows_affected() == 0 { | |
| 191 | + | return Ok(None); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | let row: MonthlyGoalRow = sqlx::query_as( | |
| 195 | + | "SELECT id, user_id, month, text, status, position, created_at, updated_at | |
| 196 | + | FROM monthly_goals WHERE id = ?" | |
| 197 | + | ) | |
| 198 | + | .bind(&id_str) | |
| 199 | + | .fetch_one(&self.pool) | |
| 200 | + | .await | |
| 201 | + | .map_err(CoreError::database)?; | |
| 202 | + | ||
| 203 | + | Ok(Some(MonthlyGoal::try_from(row)?)) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | #[tracing::instrument(skip_all)] | |
| 207 | + | async fn delete_goal(&self, id: MonthlyGoalId, user_id: UserId) -> Result<bool> { | |
| 208 | + | let result = sqlx::query( | |
| 209 | + | "DELETE FROM monthly_goals WHERE id = ? AND user_id = ?" | |
| 210 | + | ) | |
| 211 | + | .bind(id.to_string()) | |
| 212 | + | .bind(user_id.to_string()) | |
| 213 | + | .execute(&self.pool) | |
| 214 | + | .await | |
| 215 | + | .map_err(CoreError::database)?; | |
| 216 | + | ||
| 217 | + | Ok(result.rows_affected() > 0) | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | #[tracing::instrument(skip_all)] | |
| 221 | + | async fn get_reflection(&self, user_id: UserId, month: &str) -> Result<Option<MonthlyReflection>> { |
Lines truncated
| @@ -49,6 +49,7 @@ pub struct SqliteProjectRepository { | |||
| 49 | 49 | ||
| 50 | 50 | impl SqliteProjectRepository { | |
| 51 | 51 | /// Creates a new repository instance with the given connection pool. | |
| 52 | + | #[tracing::instrument(skip_all)] | |
| 52 | 53 | pub fn new(pool: SqlitePool) -> Self { | |
| 53 | 54 | Self { pool } | |
| 54 | 55 | } | |
| @@ -56,6 +57,7 @@ impl SqliteProjectRepository { | |||
| 56 | 57 | ||
| 57 | 58 | #[async_trait] | |
| 58 | 59 | impl ProjectRepository for SqliteProjectRepository { | |
| 60 | + | #[tracing::instrument(skip_all)] | |
| 59 | 61 | async fn list_all(&self, user_id: UserId) -> Result<Vec<Project>> { | |
| 60 | 62 | let rows = sqlx::query_as::<_, ProjectRow>( | |
| 61 | 63 | r#" | |
| @@ -73,6 +75,7 @@ impl ProjectRepository for SqliteProjectRepository { | |||
| 73 | 75 | rows.into_iter().map(Project::try_from).collect() | |
| 74 | 76 | } | |
| 75 | 77 | ||
| 78 | + | #[tracing::instrument(skip_all)] | |
| 76 | 79 | async fn get_by_id(&self, id: ProjectId, user_id: UserId) -> Result<Option<Project>> { | |
| 77 | 80 | let row = sqlx::query_as::<_, ProjectRow>( | |
| 78 | 81 | r#" | |
| @@ -90,6 +93,7 @@ impl ProjectRepository for SqliteProjectRepository { | |||
| 90 | 93 | row.map(Project::try_from).transpose() | |
| 91 | 94 | } | |
| 92 | 95 | ||
| 96 | + | #[tracing::instrument(skip_all)] | |
| 93 | 97 | async fn create(&self, user_id: UserId, project: NewProject) -> Result<Project> { | |
| 94 | 98 | let id = ProjectId::new(); | |
| 95 | 99 | let now = format_datetime_now(); | |
| @@ -117,6 +121,7 @@ impl ProjectRepository for SqliteProjectRepository { | |||
| 117 | 121 | .ok_or_else(|| CoreError::internal("Failed to retrieve created project")) | |
| 118 | 122 | } | |
| 119 | 123 | ||
| 124 | + | #[tracing::instrument(skip_all)] | |
| 120 | 125 | async fn update( | |
| 121 | 126 | &self, | |
| 122 | 127 | id: ProjectId, | |
| @@ -147,6 +152,7 @@ impl ProjectRepository for SqliteProjectRepository { | |||
| 147 | 152 | } | |
| 148 | 153 | } | |
| 149 | 154 | ||
| 155 | + | #[tracing::instrument(skip_all)] | |
| 150 | 156 | async fn delete(&self, id: ProjectId, user_id: UserId) -> Result<bool> { | |
| 151 | 157 | let result = sqlx::query("DELETE FROM projects WHERE id = ? AND user_id = ?") | |
| 152 | 158 | .bind(id.to_string()) | |
| @@ -158,6 +164,7 @@ impl ProjectRepository for SqliteProjectRepository { | |||
| 158 | 164 | Ok(result.rows_affected() > 0) | |
| 159 | 165 | } | |
| 160 | 166 | ||
| 167 | + | #[tracing::instrument(skip_all)] | |
| 161 | 168 | async fn find_by_name(&self, user_id: UserId, name: &str) -> Result<Option<Project>> { | |
| 162 | 169 | let row = sqlx::query_as::<_, ProjectRow>( | |
| 163 | 170 | r#" |
| @@ -38,6 +38,7 @@ pub struct SqliteSavedViewRepository { | |||
| 38 | 38 | ||
| 39 | 39 | impl SqliteSavedViewRepository { | |
| 40 | 40 | /// Creates a new repository instance with the given connection pool. | |
| 41 | + | #[tracing::instrument(skip_all)] | |
| 41 | 42 | pub fn new(pool: SqlitePool) -> Self { | |
| 42 | 43 | Self { pool } | |
| 43 | 44 | } | |
| @@ -45,6 +46,7 @@ impl SqliteSavedViewRepository { | |||
| 45 | 46 | ||
| 46 | 47 | #[async_trait] | |
| 47 | 48 | impl SavedViewRepository for SqliteSavedViewRepository { | |
| 49 | + | #[tracing::instrument(skip_all)] | |
| 48 | 50 | async fn list_all(&self, user_id: UserId) -> Result<Vec<SavedView>> { | |
| 49 | 51 | let user_id_str = user_id.to_string(); | |
| 50 | 52 | ||
| @@ -65,6 +67,7 @@ impl SavedViewRepository for SqliteSavedViewRepository { | |||
| 65 | 67 | rows.into_iter().map(row_to_saved_view).collect() | |
| 66 | 68 | } | |
| 67 | 69 | ||
| 70 | + | #[tracing::instrument(skip_all)] | |
| 68 | 71 | async fn list_pinned(&self, user_id: UserId) -> Result<Vec<SavedView>> { | |
| 69 | 72 | let user_id_str = user_id.to_string(); | |
| 70 | 73 | ||
| @@ -85,6 +88,7 @@ impl SavedViewRepository for SqliteSavedViewRepository { | |||
| 85 | 88 | rows.into_iter().map(row_to_saved_view).collect() | |
| 86 | 89 | } | |
| 87 | 90 | ||
| 91 | + | #[tracing::instrument(skip_all)] | |
| 88 | 92 | async fn get_by_id(&self, id: SavedViewId, user_id: UserId) -> Result<Option<SavedView>> { | |
| 89 | 93 | let id_str = id.to_string(); | |
| 90 | 94 | let user_id_str = user_id.to_string(); | |
| @@ -109,6 +113,7 @@ impl SavedViewRepository for SqliteSavedViewRepository { | |||
| 109 | 113 | } | |
| 110 | 114 | } | |
| 111 | 115 | ||
| 116 | + | #[tracing::instrument(skip_all)] | |
| 112 | 117 | async fn create(&self, user_id: UserId, view: NewSavedView) -> Result<SavedView> { | |
| 113 | 118 | let id = SavedViewId::new(); | |
| 114 | 119 | let now = Utc::now(); | |
| @@ -161,6 +166,7 @@ impl SavedViewRepository for SqliteSavedViewRepository { | |||
| 161 | 166 | }) | |
| 162 | 167 | } | |
| 163 | 168 | ||
| 169 | + | #[tracing::instrument(skip_all)] | |
| 164 | 170 | async fn update(&self, id: SavedViewId, user_id: UserId, view: NewSavedView) -> Result<Option<SavedView>> { | |
| 165 | 171 | let id_str = id.to_string(); | |
| 166 | 172 | let user_id_str = user_id.to_string(); | |
| @@ -204,6 +210,7 @@ impl SavedViewRepository for SqliteSavedViewRepository { | |||
| 204 | 210 | self.get_by_id(id, user_id).await | |
| 205 | 211 | } | |
| 206 | 212 | ||
| 213 | + | #[tracing::instrument(skip_all)] | |
| 207 | 214 | async fn delete(&self, id: SavedViewId, user_id: UserId) -> Result<bool> { | |
| 208 | 215 | let id_str = id.to_string(); | |
| 209 | 216 | let user_id_str = user_id.to_string(); | |
| @@ -218,6 +225,7 @@ impl SavedViewRepository for SqliteSavedViewRepository { | |||
| 218 | 225 | Ok(result.rows_affected() > 0) | |
| 219 | 226 | } | |
| 220 | 227 | ||
| 228 | + | #[tracing::instrument(skip_all)] | |
| 221 | 229 | async fn toggle_pinned(&self, id: SavedViewId, user_id: UserId) -> Result<Option<SavedView>> { | |
| 222 | 230 | let id_str = id.to_string(); | |
| 223 | 231 | let user_id_str = user_id.to_string(); | |
| @@ -244,6 +252,7 @@ impl SavedViewRepository for SqliteSavedViewRepository { | |||
| 244 | 252 | self.get_by_id(id, user_id).await | |
| 245 | 253 | } | |
| 246 | 254 | ||
| 255 | + | #[tracing::instrument(skip_all)] | |
| 247 | 256 | async fn update_position(&self, id: SavedViewId, user_id: UserId, position: i32) -> Result<Option<SavedView>> { | |
| 248 | 257 | let id_str = id.to_string(); | |
| 249 | 258 | let user_id_str = user_id.to_string(); |
| @@ -26,6 +26,7 @@ pub struct SqliteSearchRepository { | |||
| 26 | 26 | ||
| 27 | 27 | impl SqliteSearchRepository { | |
| 28 | 28 | /// Creates a new repository instance with the given connection pool. | |
| 29 | + | #[tracing::instrument(skip_all)] | |
| 29 | 30 | pub fn new(pool: SqlitePool) -> Self { | |
| 30 | 31 | Self { pool } | |
| 31 | 32 | } | |
| @@ -33,6 +34,7 @@ impl SqliteSearchRepository { | |||
| 33 | 34 | ||
| 34 | 35 | #[async_trait] | |
| 35 | 36 | impl SearchRepository for SqliteSearchRepository { | |
| 37 | + | #[tracing::instrument(skip_all)] | |
| 36 | 38 | async fn search(&self, user_id: UserId, query: SearchQuery) -> Result<(Vec<SearchResultItem>, usize)> { | |
| 37 | 39 | // If no text and no filters, return empty | |
| 38 | 40 | let has_text = !query.query.trim().is_empty(); |
| @@ -20,11 +20,13 @@ pub struct SqliteStatsRepository { pool: SqlitePool } | |||
| 20 | 20 | ||
| 21 | 21 | impl SqliteStatsRepository { | |
| 22 | 22 | /// Creates a new repository instance with the given connection pool. | |
| 23 | + | #[tracing::instrument(skip_all)] | |
| 23 | 24 | pub fn new(pool: SqlitePool) -> Self { Self { pool } } | |
| 24 | 25 | } | |
| 25 | 26 | ||
| 26 | 27 | #[async_trait] | |
| 27 | 28 | impl StatsRepository for SqliteStatsRepository { | |
| 29 | + | #[tracing::instrument(skip_all)] | |
| 28 | 30 | async fn get_dashboard_stats(&self, user_id: UserId) -> Result<DashboardStats> { | |
| 29 | 31 | let user_id_str = user_id.to_string(); | |
| 30 | 32 |