Skip to main content

max / goingson

Audit remediation: indexes, observability, docs - Add migration 040: partial indexes for focus mode and waiting-for-response queries - Add #[instrument(skip_all)] to all db-sqlite and plugin-runtime pub functions (259 annotations) - Audit review corrected to grade A (test regression was false, FK migration non-issue) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-22 22:18 UTC
Commit: 831eea25e10aefa990b647b4ce04fca0216d8add
Parent: ba392f7
53 files changed, +1786 insertions, -563 deletions
M CONTRIBUTING.md +4 -2
@@ -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:
M Cargo.lock +4 -4
@@ -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",
M README.md +2 -4
@@ -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(&timestamp);
@@ -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