max / goingson
68 files changed,
+4832 insertions,
-458 deletions
| @@ -1935,7 +1935,7 @@ dependencies = [ | |||
| 1935 | 1935 | "openssl", | |
| 1936 | 1936 | "parking_lot", | |
| 1937 | 1937 | "pter", | |
| 1938 | - | "rand 0.8.5", | |
| 1938 | + | "rand 0.9.2", | |
| 1939 | 1939 | "reqwest 0.12.28", | |
| 1940 | 1940 | "serde", | |
| 1941 | 1941 | "serde_json", | |
| @@ -5630,7 +5630,7 @@ dependencies = [ | |||
| 5630 | 5630 | "chrono", | |
| 5631 | 5631 | "keyring", | |
| 5632 | 5632 | "parking_lot", | |
| 5633 | - | "rand 0.8.5", | |
| 5633 | + | "rand 0.9.2", | |
| 5634 | 5634 | "reqwest 0.12.28", | |
| 5635 | 5635 | "serde", | |
| 5636 | 5636 | "serde_json", |
| @@ -10,7 +10,7 @@ resolver = "2" | |||
| 10 | 10 | ||
| 11 | 11 | [workspace.package] | |
| 12 | 12 | version = "0.3.1" | |
| 13 | - | edition = "2021" | |
| 13 | + | edition = "2024" | |
| 14 | 14 | license-file = "LICENSE" | |
| 15 | 15 | ||
| 16 | 16 | [workspace.dependencies] | |
| @@ -44,7 +44,7 @@ lettre = { version = "0.11", default-features = false, features = ["tokio1", "to | |||
| 44 | 44 | reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] } | |
| 45 | 45 | ||
| 46 | 46 | # Security / OAuth | |
| 47 | - | rand = "0.8" | |
| 47 | + | rand = "0.9" | |
| 48 | 48 | base64 = "0.22" | |
| 49 | 49 | sha2 = "0.10" | |
| 50 | 50 |
| @@ -25,6 +25,7 @@ pub struct Contact { | |||
| 25 | 25 | pub timezone: Option<String>, | |
| 26 | 26 | pub external_source: Option<String>, | |
| 27 | 27 | pub external_id: Option<String>, | |
| 28 | + | pub is_implicit: bool, | |
| 28 | 29 | pub emails: Vec<ContactEmail>, | |
| 29 | 30 | pub phones: Vec<ContactPhone>, | |
| 30 | 31 | pub social_handles: Vec<SocialHandle>, | |
| @@ -137,6 +138,7 @@ pub struct NewContact { | |||
| 137 | 138 | pub tags: Vec<String>, | |
| 138 | 139 | pub birthday: Option<NaiveDate>, | |
| 139 | 140 | pub timezone: Option<String>, | |
| 141 | + | pub is_implicit: bool, | |
| 140 | 142 | } | |
| 141 | 143 | ||
| 142 | 144 | /// Data for updating an existing contact. | |
| @@ -201,6 +203,7 @@ mod tests { | |||
| 201 | 203 | timezone: None, | |
| 202 | 204 | external_source: None, | |
| 203 | 205 | external_id: None, | |
| 206 | + | is_implicit: false, | |
| 204 | 207 | emails: vec![], | |
| 205 | 208 | phones: vec![], | |
| 206 | 209 | social_handles: vec![], |
| @@ -115,6 +115,7 @@ define_uuid_id!( | |||
| 115 | 115 | EmailId, | |
| 116 | 116 | ContactId, | |
| 117 | 117 | MilestoneId, | |
| 118 | + | DailyNoteId, | |
| 118 | 119 | WeeklyReviewId, | |
| 119 | 120 | MonthlyGoalId, | |
| 120 | 121 | MonthlyReflectionId, |
| @@ -58,12 +58,12 @@ pub use contact::{ | |||
| 58 | 58 | pub use error::CoreError; | |
| 59 | 59 | pub use id_types::{ | |
| 60 | 60 | AnnotationId, AttachmentId, ContactEmailId, ContactId, ContactPhoneId, CustomFieldId, | |
| 61 | - | EmailAccountId, EmailId, EventId, MilestoneId, MonthlyGoalId, | |
| 61 | + | DailyNoteId, EmailAccountId, EmailId, EventId, MilestoneId, MonthlyGoalId, | |
| 62 | 62 | MonthlyReflectionId, ProjectId, SavedViewId, SocialHandleId, | |
| 63 | 63 | SubtaskId, SyncAccountId, TaskId, TimeSessionId, UserId, WeeklyReviewId, | |
| 64 | 64 | }; | |
| 65 | 65 | pub use models::{ | |
| 66 | - | Annotation, Attachment, BackupSettings, BlockType, CssClass, DbValue, Email, EmailAccount, | |
| 66 | + | Annotation, Attachment, BackupSettings, BlockType, CssClass, DailyNote, DbValue, Email, EmailAccount, | |
| 67 | 67 | EmailAuthType, EmailThread, Event, FolderSyncState, Milestone, | |
| 68 | 68 | MilestoneStatus, MonthlyGoal, MonthlyGoalStatus, MonthlyReflection, | |
| 69 | 69 | AttachmentMeta, NewAttachment, NewBackupSettings, NewEmail, NewEmailWithTracking, NewEvent, NewEventBuilder, |
| @@ -0,0 +1,20 @@ | |||
| 1 | + | //! Daily review note domain types. | |
| 2 | + | ||
| 3 | + | use chrono::{DateTime, NaiveDate, Utc}; | |
| 4 | + | use serde::{Deserialize, Serialize}; | |
| 5 | + | use crate::id_types::{DailyNoteId, UserId}; | |
| 6 | + | ||
| 7 | + | /// A daily review note capturing end-of-day reflection. | |
| 8 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 9 | + | #[serde(rename_all = "camelCase")] | |
| 10 | + | pub struct DailyNote { | |
| 11 | + | pub id: DailyNoteId, | |
| 12 | + | pub user_id: UserId, | |
| 13 | + | pub note_date: NaiveDate, | |
| 14 | + | pub went_well: String, | |
| 15 | + | pub could_improve: String, | |
| 16 | + | pub is_reviewed: bool, | |
| 17 | + | pub reviewed_at: Option<DateTime<Utc>>, | |
| 18 | + | pub created_at: DateTime<Utc>, | |
| 19 | + | pub updated_at: DateTime<Utc>, | |
| 20 | + | } |
| @@ -2,6 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | mod attachment; | |
| 4 | 4 | mod backup; | |
| 5 | + | mod daily_note; | |
| 5 | 6 | mod email; | |
| 6 | 7 | mod email_account; | |
| 7 | 8 | mod event; | |
| @@ -18,6 +19,7 @@ mod weekly_review; | |||
| 18 | 19 | ||
| 19 | 20 | pub use attachment::*; | |
| 20 | 21 | pub use backup::*; | |
| 22 | + | pub use daily_note::*; | |
| 21 | 23 | pub use email::*; | |
| 22 | 24 | pub use email_account::*; | |
| 23 | 25 | pub use event::*; |
| @@ -503,6 +503,8 @@ pub struct NewTask { | |||
| 503 | 503 | pub scheduled_duration: Option<i32>, | |
| 504 | 504 | /// Estimated duration in minutes. | |
| 505 | 505 | pub estimated_minutes: Option<i32>, | |
| 506 | + | /// Root task ID for recurrence chain (set when spawning next recurring instance). | |
| 507 | + | pub recurrence_parent_id: Option<TaskId>, | |
| 506 | 508 | } | |
| 507 | 509 | ||
| 508 | 510 | impl NewTask { | |
| @@ -542,6 +544,7 @@ pub struct NewTaskBuilder { | |||
| 542 | 544 | scheduled_start: Option<DateTime<Utc>>, | |
| 543 | 545 | scheduled_duration: Option<i32>, | |
| 544 | 546 | estimated_minutes: Option<i32>, | |
| 547 | + | recurrence_parent_id: Option<TaskId>, | |
| 545 | 548 | } | |
| 546 | 549 | ||
| 547 | 550 | impl NewTaskBuilder { | |
| @@ -562,6 +565,7 @@ impl NewTaskBuilder { | |||
| 562 | 565 | scheduled_start: None, | |
| 563 | 566 | scheduled_duration: None, | |
| 564 | 567 | estimated_minutes: None, | |
| 568 | + | recurrence_parent_id: None, | |
| 565 | 569 | } | |
| 566 | 570 | } | |
| 567 | 571 | ||
| @@ -649,6 +653,12 @@ impl NewTaskBuilder { | |||
| 649 | 653 | self | |
| 650 | 654 | } | |
| 651 | 655 | ||
| 656 | + | /// Sets the recurrence parent ID (root of the recurrence chain). | |
| 657 | + | pub fn recurrence_parent_id(mut self, id: TaskId) -> Self { | |
| 658 | + | self.recurrence_parent_id = Some(id); | |
| 659 | + | self | |
| 660 | + | } | |
| 661 | + | ||
| 652 | 662 | /// Builds the [`NewTask`]. | |
| 653 | 663 | pub fn build(self) -> NewTask { | |
| 654 | 664 | NewTask { | |
| @@ -666,6 +676,7 @@ impl NewTaskBuilder { | |||
| 666 | 676 | scheduled_start: self.scheduled_start, | |
| 667 | 677 | scheduled_duration: self.scheduled_duration, | |
| 668 | 678 | estimated_minutes: self.estimated_minutes, | |
| 679 | + | recurrence_parent_id: self.recurrence_parent_id, | |
| 669 | 680 | } | |
| 670 | 681 | } | |
| 671 | 682 | } |
| @@ -355,7 +355,7 @@ fn compute_patterns( | |||
| 355 | 355 | if max_completions > 0 { | |
| 356 | 356 | let best_days: Vec<&str> = completions_by_dow.iter() | |
| 357 | 357 | .enumerate() | |
| 358 | - | .filter(|(_, &c)| c == max_completions && c > 0) | |
| 358 | + | .filter(|(_, c)| **c == max_completions && **c > 0) | |
| 359 | 359 | .map(|(i, _)| day_names[i]) | |
| 360 | 360 | .collect(); | |
| 361 | 361 |
| @@ -8,8 +8,8 @@ use chrono::{DateTime, NaiveDate, Utc}; | |||
| 8 | 8 | use std::collections::HashSet; | |
| 9 | 9 | use crate::id_types::{ | |
| 10 | 10 | AnnotationId, AttachmentId, ContactEmailId, ContactId, ContactPhoneId, CustomFieldId, | |
| 11 | - | EmailAccountId, EmailId, EventId, MilestoneId, ProjectId, SavedViewId, SocialHandleId, | |
| 12 | - | SubtaskId, SyncAccountId, TaskId, UserId, | |
| 11 | + | EmailAccountId, EmailId, EventId, MilestoneId, ProjectId, SavedViewId, | |
| 12 | + | SocialHandleId, SubtaskId, SyncAccountId, TaskId, UserId, | |
| 13 | 13 | }; | |
| 14 | 14 | use uuid::Uuid; | |
| 15 | 15 | ||
| @@ -69,6 +69,9 @@ pub trait TaskRepository: Send + Sync { | |||
| 69 | 69 | /// Lists tasks belonging to a specific project. | |
| 70 | 70 | async fn list_by_project(&self, user_id: UserId, project_id: ProjectId) -> Result<Vec<Task>>; | |
| 71 | 71 | ||
| 72 | + | /// Lists tasks linked to a specific contact. | |
| 73 | + | async fn list_by_contact(&self, user_id: UserId, contact_id: ContactId) -> Result<Vec<Task>>; | |
| 74 | + | ||
| 72 | 75 | /// Lists tasks matching the given filter criteria with pagination. | |
| 73 | 76 | /// Returns (tasks, total_count) for pagination UI. | |
| 74 | 77 | async fn list_filtered(&self, user_id: UserId, query: TaskFilterQuery) -> Result<(Vec<Task>, i64)>; | |
| @@ -209,6 +212,9 @@ pub trait TaskRepository: Send + Sync { | |||
| 209 | 212 | ||
| 210 | 213 | /// Gets aggregated time tracking summary grouped by project and date. | |
| 211 | 214 | async fn get_time_summary(&self, user_id: UserId, start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Vec<TimeTrackingSummary>>; | |
| 215 | + | ||
| 216 | + | /// Lists all tasks in a recurrence chain (root + all descendants). | |
| 217 | + | async fn list_recurrence_chain(&self, root_id: TaskId, user_id: UserId) -> Result<Vec<Task>>; | |
| 212 | 218 | } | |
| 213 | 219 | ||
| 214 | 220 | /// Repository for calendar event operations. | |
| @@ -228,6 +234,9 @@ pub trait EventRepository: Send + Sync { | |||
| 228 | 234 | /// Lists events belonging to a specific project, ordered by `start_time ASC`. | |
| 229 | 235 | async fn list_by_project(&self, user_id: UserId, project_id: ProjectId) -> Result<Vec<Event>>; | |
| 230 | 236 | ||
| 237 | + | /// Lists events linked to a specific contact, ordered by `start_time DESC`. | |
| 238 | + | async fn list_by_contact(&self, user_id: UserId, contact_id: ContactId) -> Result<Vec<Event>>; | |
| 239 | + | ||
| 231 | 240 | /// Retrieves an event by ID. | |
| 232 | 241 | async fn get_by_id(&self, id: EventId, user_id: UserId) -> Result<Option<Event>>; | |
| 233 | 242 | ||
| @@ -240,6 +249,9 @@ pub trait EventRepository: Send + Sync { | |||
| 240 | 249 | /// Deletes an event. | |
| 241 | 250 | async fn delete(&self, id: EventId, user_id: UserId) -> Result<bool>; | |
| 242 | 251 | ||
| 252 | + | /// Deletes multiple events by ID, returning the number deleted. | |
| 253 | + | async fn delete_many(&self, ids: &[EventId], user_id: UserId) -> Result<u64>; | |
| 254 | + | ||
| 243 | 255 | /// Gets events starting within the next N days, ordered by `start_time ASC`. | |
| 244 | 256 | async fn get_upcoming(&self, user_id: UserId, days: i64) -> Result<Vec<Event>>; | |
| 245 | 257 | ||
| @@ -280,6 +292,9 @@ pub trait EmailRepository: Send + Sync { | |||
| 280 | 292 | /// Lists emails linked to a specific project. | |
| 281 | 293 | async fn list_by_project(&self, user_id: UserId, project_id: ProjectId) -> Result<Vec<Email>>; | |
| 282 | 294 | ||
| 295 | + | /// Lists emails sent from or to any of the given addresses. | |
| 296 | + | async fn list_by_addresses(&self, user_id: UserId, addresses: &[&str]) -> Result<Vec<Email>>; | |
| 297 | + | ||
| 283 | 298 | /// Lists emails not linked to any project. | |
| 284 | 299 | async fn list_unlinked(&self, user_id: UserId) -> Result<Vec<Email>>; | |
| 285 | 300 | ||
| @@ -329,6 +344,10 @@ pub trait EmailRepository: Send + Sync { | |||
| 329 | 344 | /// Batch check for existing Message-IDs, returns the set that exist. | |
| 330 | 345 | async fn exists_by_message_ids(&self, user_id: UserId, message_ids: &[&str]) -> Result<HashSet<String>>; | |
| 331 | 346 | ||
| 347 | + | /// Batch check which email addresses have appeared as senders. | |
| 348 | + | /// Returns the set of addresses (lowercased) that have sent at least one email. | |
| 349 | + | async fn exists_as_senders(&self, user_id: UserId, addresses: &[&str]) -> Result<HashSet<String>>; | |
| 350 | + | ||
| 332 | 351 | /// Snoozes an email until the specified time. | |
| 333 | 352 | async fn snooze(&self, id: EmailId, user_id: UserId, until: DateTime<Utc>) -> Result<Option<Email>>; | |
| 334 | 353 | ||
| @@ -749,6 +768,16 @@ pub trait WeeklyReviewRepository: Send + Sync { | |||
| 749 | 768 | async fn set_vacation_days(&self, user_id: UserId, week_start: NaiveDate, days: &[u8]) -> Result<()>; | |
| 750 | 769 | } | |
| 751 | 770 | ||
| 771 | + | /// Repository for daily review notes. | |
| 772 | + | #[async_trait] | |
| 773 | + | pub trait DailyNoteRepository: Send + Sync { | |
| 774 | + | /// Gets the daily note for a specific date. | |
| 775 | + | async fn get_by_date(&self, user_id: UserId, date: NaiveDate) -> Result<Option<crate::models::DailyNote>>; | |
| 776 | + | ||
| 777 | + | /// Creates or updates a daily note (upsert by user_id + date). | |
| 778 | + | async fn upsert(&self, user_id: UserId, date: NaiveDate, went_well: &str, could_improve: &str, is_reviewed: bool) -> Result<crate::models::DailyNote>; | |
| 779 | + | } | |
| 780 | + | ||
| 752 | 781 | /// Repository for monthly review goals and reflections. | |
| 753 | 782 | #[async_trait] | |
| 754 | 783 | pub trait MonthlyReviewRepository: Send + Sync { | |
| @@ -839,16 +868,29 @@ pub trait ContactRepository: Send + Sync { | |||
| 839 | 868 | /// Deletes a contact (CASCADE removes sub-entities), returning `true` if deleted. | |
| 840 | 869 | async fn delete(&self, id: ContactId, user_id: UserId) -> Result<bool>; | |
| 841 | 870 | ||
| 871 | + | /// Deletes multiple contacts by ID, returning the number deleted. | |
| 872 | + | async fn delete_many(&self, ids: &[ContactId], user_id: UserId) -> Result<u64>; | |
| 873 | + | ||
| 874 | + | /// Adds a tag to multiple contacts (skips contacts that already have the tag). | |
| 875 | + | async fn tag_many(&self, ids: &[ContactId], user_id: UserId, tag: &str) -> Result<u64>; | |
| 876 | + | ||
| 842 | 877 | /// Lists contacts matching a tag. | |
| 843 | 878 | async fn list_by_tag(&self, user_id: UserId, tag: &str) -> Result<Vec<Contact>>; | |
| 844 | 879 | ||
| 845 | 880 | /// Lists contacts matching a search query and/or tag filter. | |
| 846 | 881 | /// Searches across display_name, nickname, company, title, notes, and email addresses. | |
| 847 | - | async fn list_filtered(&self, user_id: UserId, search: Option<&str>, tag: Option<&str>) -> Result<Vec<Contact>>; | |
| 882 | + | async fn list_filtered(&self, user_id: UserId, search: Option<&str>, tag: Option<&str>, include_implicit: bool) -> Result<Vec<Contact>>; | |
| 848 | 883 | ||
| 849 | 884 | /// Finds a contact by email address. | |
| 850 | 885 | async fn find_by_email(&self, user_id: UserId, email: &str) -> Result<Option<Contact>>; | |
| 851 | 886 | ||
| 887 | + | /// Batch check which email addresses belong to known contacts. | |
| 888 | + | /// Returns the set of addresses (lowercased) that match at least one contact. | |
| 889 | + | async fn find_emails_in_contacts(&self, user_id: UserId, addresses: &[&str]) -> Result<HashSet<String>>; | |
| 890 | + | ||
| 891 | + | /// Promotes an implicit contact to explicit by setting is_implicit = 0. | |
| 892 | + | async fn promote_contact(&self, id: ContactId, user_id: UserId) -> Result<Option<Contact>>; | |
| 893 | + | ||
| 852 | 894 | /// Finds a contact by external source and ID (for dedup during import). | |
| 853 | 895 | async fn find_by_external_id(&self, source: &str, ext_id: &str, user_id: UserId) -> Result<Option<Contact>>; | |
| 854 | 896 |
| @@ -206,6 +206,7 @@ mod tests { | |||
| 206 | 206 | contact_id: None, | |
| 207 | 207 | milestone_id: None, | |
| 208 | 208 | estimated_minutes: None, | |
| 209 | + | recurrence_parent_id: None, | |
| 209 | 210 | }; | |
| 210 | 211 | assert!(task.validate().is_ok()); | |
| 211 | 212 | } | |
| @@ -227,6 +228,7 @@ mod tests { | |||
| 227 | 228 | contact_id: None, | |
| 228 | 229 | milestone_id: None, | |
| 229 | 230 | estimated_minutes: None, | |
| 231 | + | recurrence_parent_id: None, | |
| 230 | 232 | }; | |
| 231 | 233 | let err = task.validate().unwrap_err(); | |
| 232 | 234 | assert!(matches!(err, CoreError::Validation { field: "description", .. })); | |
| @@ -249,6 +251,7 @@ mod tests { | |||
| 249 | 251 | contact_id: None, | |
| 250 | 252 | milestone_id: None, | |
| 251 | 253 | estimated_minutes: None, | |
| 254 | + | recurrence_parent_id: None, | |
| 252 | 255 | }; | |
| 253 | 256 | let err = task.validate().unwrap_err(); | |
| 254 | 257 | assert!(matches!(err, CoreError::Validation { field: "scheduled_duration", .. })); | |
| @@ -333,6 +336,7 @@ mod tests { | |||
| 333 | 336 | contact_id: None, | |
| 334 | 337 | milestone_id: None, | |
| 335 | 338 | estimated_minutes: None, | |
| 339 | + | recurrence_parent_id: None, | |
| 336 | 340 | }; | |
| 337 | 341 | let err = task.validate().unwrap_err(); | |
| 338 | 342 | assert!(matches!(err, CoreError::Validation { field: "tags", .. })); | |
| @@ -355,6 +359,7 @@ mod tests { | |||
| 355 | 359 | contact_id: None, | |
| 356 | 360 | milestone_id: None, | |
| 357 | 361 | estimated_minutes: None, | |
| 362 | + | recurrence_parent_id: None, | |
| 358 | 363 | }; | |
| 359 | 364 | let err = task.validate().unwrap_err(); | |
| 360 | 365 | assert!(matches!(err, CoreError::Validation { field: "tags", .. })); | |
| @@ -377,6 +382,7 @@ mod tests { | |||
| 377 | 382 | contact_id: None, | |
| 378 | 383 | milestone_id: None, | |
| 379 | 384 | estimated_minutes: None, | |
| 385 | + | recurrence_parent_id: None, | |
| 380 | 386 | }; | |
| 381 | 387 | let err = task.validate().unwrap_err(); | |
| 382 | 388 | assert!(matches!(err, CoreError::Validation { field: "scheduled_duration", .. })); | |
| @@ -411,6 +417,7 @@ mod tests { | |||
| 411 | 417 | contact_id: None, | |
| 412 | 418 | milestone_id: None, | |
| 413 | 419 | estimated_minutes: None, | |
| 420 | + | recurrence_parent_id: None, | |
| 414 | 421 | }; | |
| 415 | 422 | let err = task.validate().unwrap_err(); | |
| 416 | 423 | assert!(matches!(err, CoreError::Validation { field: "description", .. })); | |
| @@ -505,6 +512,7 @@ mod tests { | |||
| 505 | 512 | contact_id: None, | |
| 506 | 513 | milestone_id: None, | |
| 507 | 514 | estimated_minutes: None, | |
| 515 | + | recurrence_parent_id: None, | |
| 508 | 516 | }; | |
| 509 | 517 | assert!(task.validate().is_err()); | |
| 510 | 518 | ||
| @@ -676,6 +684,7 @@ mod tests { | |||
| 676 | 684 | tags: vec![], | |
| 677 | 685 | birthday: None, | |
| 678 | 686 | timezone: None, | |
| 687 | + | is_implicit: false, | |
| 679 | 688 | } | |
| 680 | 689 | } | |
| 681 | 690 |
| @@ -56,6 +56,7 @@ pub use repository::{ | |||
| 56 | 56 | SqliteAttachmentRepository, | |
| 57 | 57 | SqliteBackupSettingsRepository, | |
| 58 | 58 | SqliteContactRepository, | |
| 59 | + | SqliteDailyNoteRepository, | |
| 59 | 60 | SqliteProjectRepository, | |
| 60 | 61 | SqliteTaskRepository, | |
| 61 | 62 | SqliteEventRepository, |
| @@ -5,7 +5,7 @@ | |||
| 5 | 5 | ||
| 6 | 6 | use async_trait::async_trait; | |
| 7 | 7 | use sqlx::SqlitePool; | |
| 8 | - | use std::collections::HashMap; | |
| 8 | + | use std::collections::{HashMap, HashSet}; | |
| 9 | 9 | use goingson_core::{ | |
| 10 | 10 | Contact, ContactCustomField, ContactEmail, ContactEmailId, ContactId, ContactPhone, | |
| 11 | 11 | ContactPhoneId, ContactRepository, CoreError, CustomFieldId, NewContact, | |
| @@ -30,6 +30,7 @@ struct ContactRow { | |||
| 30 | 30 | pub timezone: Option<String>, | |
| 31 | 31 | pub external_source: Option<String>, | |
| 32 | 32 | pub external_id: Option<String>, | |
| 33 | + | pub is_implicit: i32, | |
| 33 | 34 | pub created_at: String, | |
| 34 | 35 | pub updated_at: String, | |
| 35 | 36 | } | |
| @@ -140,6 +141,7 @@ fn contact_from_row( | |||
| 140 | 141 | timezone: row.timezone, | |
| 141 | 142 | external_source: row.external_source, | |
| 142 | 143 | external_id: row.external_id, | |
| 144 | + | is_implicit: row.is_implicit != 0, | |
| 143 | 145 | emails, | |
| 144 | 146 | phones, | |
| 145 | 147 | social_handles, | |
| @@ -314,9 +316,9 @@ impl ContactRepository for SqliteContactRepository { | |||
| 314 | 316 | async fn list_all(&self, user_id: UserId) -> Result<Vec<Contact>> { | |
| 315 | 317 | let rows = sqlx::query_as::<_, ContactRow>( | |
| 316 | 318 | r#" | |
| 317 | - | SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, created_at, updated_at | |
| 319 | + | SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at | |
| 318 | 320 | FROM contacts | |
| 319 | - | WHERE user_id = ? | |
| 321 | + | WHERE user_id = ? AND is_implicit = 0 | |
| 320 | 322 | ORDER BY display_name ASC | |
| 321 | 323 | "#, | |
| 322 | 324 | ) | |
| @@ -332,7 +334,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 332 | 334 | async fn get_by_id(&self, id: ContactId, user_id: UserId) -> Result<Option<Contact>> { | |
| 333 | 335 | let row = sqlx::query_as::<_, ContactRow>( | |
| 334 | 336 | r#" | |
| 335 | - | SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, created_at, updated_at | |
| 337 | + | SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at | |
| 336 | 338 | FROM contacts | |
| 337 | 339 | WHERE id = ? AND user_id = ? | |
| 338 | 340 | "#, | |
| @@ -361,8 +363,8 @@ impl ContactRepository for SqliteContactRepository { | |||
| 361 | 363 | ||
| 362 | 364 | sqlx::query( | |
| 363 | 365 | r#" | |
| 364 | - | INSERT INTO contacts (id, user_id, display_name, nickname, company, title, notes, tags, birthday, timezone, created_at, updated_at) | |
| 365 | - | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 366 | + | INSERT INTO contacts (id, user_id, display_name, nickname, company, title, notes, tags, birthday, timezone, is_implicit, created_at, updated_at) | |
| 367 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 366 | 368 | "#, | |
| 367 | 369 | ) | |
| 368 | 370 | .bind(id.to_string()) | |
| @@ -375,6 +377,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 375 | 377 | .bind(&tags_json) | |
| 376 | 378 | .bind(&birthday_str) | |
| 377 | 379 | .bind(&contact.timezone) | |
| 380 | + | .bind(if contact.is_implicit { 1 } else { 0 }) | |
| 378 | 381 | .bind(&now) | |
| 379 | 382 | .bind(&now) | |
| 380 | 383 | .execute(&self.pool) | |
| @@ -434,12 +437,60 @@ impl ContactRepository for SqliteContactRepository { | |||
| 434 | 437 | } | |
| 435 | 438 | ||
| 436 | 439 | #[tracing::instrument(skip_all)] | |
| 440 | + | async fn delete_many(&self, ids: &[ContactId], user_id: UserId) -> Result<u64> { | |
| 441 | + | if ids.is_empty() { | |
| 442 | + | return Ok(0); | |
| 443 | + | } | |
| 444 | + | let user_id_str = user_id.to_string(); | |
| 445 | + | let placeholders = vec!["?"; ids.len()].join(","); | |
| 446 | + | let sql = format!("DELETE FROM contacts WHERE user_id = ? AND id IN ({placeholders})"); | |
| 447 | + | let mut query = sqlx::query(&sql).bind(&user_id_str); | |
| 448 | + | for id in ids { | |
| 449 | + | query = query.bind(id.to_string()); | |
| 450 | + | } | |
| 451 | + | let result = query.execute(&self.pool).await.map_err(CoreError::database)?; | |
| 452 | + | Ok(result.rows_affected()) | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | #[tracing::instrument(skip_all)] | |
| 456 | + | async fn tag_many(&self, ids: &[ContactId], user_id: UserId, tag: &str) -> Result<u64> { | |
| 457 | + | if ids.is_empty() || tag.is_empty() { | |
| 458 | + | return Ok(0); | |
| 459 | + | } | |
| 460 | + | let user_id_str = user_id.to_string(); | |
| 461 | + | let like_pattern = format!("%\"{}\"%" , escape_like(tag)); | |
| 462 | + | let placeholders = vec!["?"; ids.len()].join(","); | |
| 463 | + | // Append tag to JSON array where not already present. | |
| 464 | + | let sql = format!( | |
| 465 | + | r#"UPDATE contacts | |
| 466 | + | SET tags = CASE | |
| 467 | + | WHEN tags IS NULL OR tags = '' OR tags = '[]' | |
| 468 | + | THEN json_array(?) | |
| 469 | + | ELSE json_insert(tags, '$[#]', ?) | |
| 470 | + | END, | |
| 471 | + | updated_at = datetime('now') | |
| 472 | + | WHERE user_id = ? AND id IN ({placeholders}) | |
| 473 | + | AND (tags IS NULL OR tags NOT LIKE ? ESCAPE '\')"#, | |
| 474 | + | ); | |
| 475 | + | let mut query = sqlx::query(&sql) | |
| 476 | + | .bind(tag) | |
| 477 | + | .bind(tag) | |
| 478 | + | .bind(&user_id_str); | |
| 479 | + | for id in ids { | |
| 480 | + | query = query.bind(id.to_string()); | |
| 481 | + | } | |
| 482 | + | query = query.bind(&like_pattern); | |
| 483 | + | let result = query.execute(&self.pool).await.map_err(CoreError::database)?; | |
| 484 | + | Ok(result.rows_affected()) | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | #[tracing::instrument(skip_all)] | |
| 437 | 488 | async fn list_by_tag(&self, user_id: UserId, tag: &str) -> Result<Vec<Contact>> { | |
| 438 | 489 | // Tags stored as JSON array, use LIKE for matching | |
| 439 | 490 | let pattern = format!("%\"{}\"%" , escape_like(tag)); | |
| 440 | 491 | let rows = sqlx::query_as::<_, ContactRow>( | |
| 441 | 492 | r#" | |
| 442 | - | SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, created_at, updated_at | |
| 493 | + | SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at | |
| 443 | 494 | FROM contacts | |
| 444 | 495 | WHERE user_id = ? AND tags LIKE ? ESCAPE '\' | |
| 445 | 496 | ORDER BY display_name ASC | |
| @@ -455,15 +506,18 @@ impl ContactRepository for SqliteContactRepository { | |||
| 455 | 506 | } | |
| 456 | 507 | ||
| 457 | 508 | #[tracing::instrument(skip_all)] | |
| 458 | - | async fn list_filtered(&self, user_id: UserId, search: Option<&str>, tag: Option<&str>) -> Result<Vec<Contact>> { | |
| 509 | + | async fn list_filtered(&self, user_id: UserId, search: Option<&str>, tag: Option<&str>, include_implicit: bool) -> Result<Vec<Contact>> { | |
| 459 | 510 | let has_search = search.is_some_and(|s| !s.is_empty()); | |
| 460 | 511 | let has_tag = tag.is_some_and(|t| !t.is_empty()); | |
| 461 | 512 | ||
| 462 | - | if !has_search && !has_tag { | |
| 513 | + | if !has_search && !has_tag && !include_implicit { | |
| 463 | 514 | return self.list_all(user_id).await; | |
| 464 | 515 | } | |
| 465 | 516 | ||
| 466 | 517 | let mut conditions = vec!["c.user_id = ?".to_string()]; | |
| 518 | + | if !include_implicit { | |
| 519 | + | conditions.push("c.is_implicit = 0".to_string()); | |
| 520 | + | } | |
| 467 | 521 | let mut binds: Vec<String> = vec![user_id.to_string()]; | |
| 468 | 522 | ||
| 469 | 523 | if let Some(t) = tag.filter(|t| !t.is_empty()) { | |
| @@ -484,7 +538,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 484 | 538 | } | |
| 485 | 539 | ||
| 486 | 540 | let sql = format!( | |
| 487 | - | "SELECT c.id, c.display_name, c.nickname, c.company, c.title, c.notes, c.tags, c.birthday, c.timezone, c.external_source, c.external_id, c.created_at, c.updated_at FROM contacts c WHERE {} ORDER BY c.display_name ASC", | |
| 541 | + | "SELECT c.id, c.display_name, c.nickname, c.company, c.title, c.notes, c.tags, c.birthday, c.timezone, c.external_source, c.external_id, c.is_implicit, c.created_at, c.updated_at FROM contacts c WHERE {} ORDER BY c.display_name ASC", | |
| 488 | 542 | conditions.join(" AND ") | |
| 489 | 543 | ); | |
| 490 | 544 | ||
| @@ -501,7 +555,7 @@ impl ContactRepository for SqliteContactRepository { | |||
| 501 | 555 | async fn find_by_email(&self, user_id: UserId, email: &str) -> Result<Option<Contact>> { | |
| 502 | 556 | let row = sqlx::query_as::<_, ContactRow>( | |
| 503 | 557 | r#" | |
| 504 | - | SELECT c.id, c.display_name, c.nickname, c.company, c.title, c.notes, c.tags, c.birthday, c.timezone, c.external_source, c.external_id, c.created_at, c.updated_at | |
| 558 | + | SELECT c.id, c.display_name, c.nickname, c.company, c.title, c.notes, c.tags, c.birthday, c.timezone, c.external_source, c.external_id, c.is_implicit, c.created_at, c.updated_at | |
| 505 | 559 | FROM contacts c | |
| 506 | 560 | JOIN contact_emails ce ON ce.contact_id = c.id | |
| 507 | 561 | WHERE c.user_id = ? AND LOWER(ce.address) = LOWER(?) | |
| @@ -524,10 +578,53 @@ impl ContactRepository for SqliteContactRepository { | |||
| 524 | 578 | } | |
| 525 | 579 | ||
| 526 | 580 | #[tracing::instrument(skip_all)] | |
| 581 | + | async fn find_emails_in_contacts(&self, user_id: UserId, addresses: &[&str]) -> Result<HashSet<String>> { | |
| 582 | + | if addresses.is_empty() { | |
| 583 | + | return Ok(HashSet::new()); | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | let placeholders = addresses.iter().map(|_| "?").collect::<Vec<_>>().join(","); | |
| 587 | + | let query = format!( | |
| 588 | + | "SELECT DISTINCT LOWER(ce.address) FROM contact_emails ce JOIN contacts c ON ce.contact_id = c.id WHERE c.user_id = ? AND LOWER(ce.address) IN ({})", | |
| 589 | + | placeholders | |
| 590 | + | ); | |
| 591 | + | ||
| 592 | + | let mut q = sqlx::query_as::<_, (String,)>(&query).bind(user_id.to_string()); | |
| 593 | + | for addr in addresses { | |
| 594 | + | q = q.bind(addr.to_lowercase()); | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | let rows = q.fetch_all(&self.pool).await | |
| 598 | + | .map_err(CoreError::database)?; | |
| 599 | + | ||
| 600 | + | Ok(rows.into_iter().map(|(a,)| a).collect()) | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | #[tracing::instrument(skip_all)] | |
| 604 | + | async fn promote_contact(&self, id: ContactId, user_id: UserId) -> Result<Option<Contact>> { | |
| 605 | + | let now = format_datetime_now(); | |
| 606 | + | let result = sqlx::query( | |
| 607 | + | "UPDATE contacts SET is_implicit = 0, updated_at = ? WHERE id = ? AND user_id = ?" | |
| 608 | + | ) | |
| 609 | + | .bind(&now) | |
| 610 | + | .bind(id.to_string()) | |
| 611 | + | .bind(user_id.to_string()) | |
| 612 | + | .execute(&self.pool) | |
| 613 | + | .await | |
| 614 | + | .map_err(CoreError::database)?; | |
| 615 | + | ||
| 616 | + | if result.rows_affected() > 0 { | |
| 617 | + | self.get_by_id(id, user_id).await | |
| 618 | + | } else { | |
| 619 | + | Ok(None) | |
| 620 | + | } | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | #[tracing::instrument(skip_all)] | |
| 527 | 624 | async fn find_by_external_id(&self, source: &str, ext_id: &str, user_id: UserId) -> Result<Option<Contact>> { | |
| 528 | 625 | let row = sqlx::query_as::<_, ContactRow>( | |
| 529 | 626 | r#" | |
| 530 | - | SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, created_at, updated_at | |
| 627 | + | SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at | |
| 531 | 628 | FROM contacts | |
| 532 | 629 | WHERE user_id = ? AND external_source = ? AND external_id = ? | |
| 533 | 630 | LIMIT 1 |
| @@ -0,0 +1,141 @@ | |||
| 1 | + | //! SQLite implementation of the DailyNoteRepository. | |
| 2 | + | ||
| 3 | + | use async_trait::async_trait; | |
| 4 | + | use chrono::{NaiveDate, Utc}; | |
| 5 | + | use sqlx::SqlitePool; | |
| 6 | + | use goingson_core::{CoreError, DailyNote, DailyNoteId, DailyNoteRepository, Result, UserId}; | |
| 7 | + | ||
| 8 | + | use crate::utils::{format_datetime, parse_datetime, parse_uuid}; | |
| 9 | + | ||
| 10 | + | pub struct SqliteDailyNoteRepository { | |
| 11 | + | pool: SqlitePool, | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | impl SqliteDailyNoteRepository { | |
| 15 | + | #[tracing::instrument(skip_all)] | |
| 16 | + | pub fn new(pool: SqlitePool) -> Self { | |
| 17 | + | Self { pool } | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | #[derive(sqlx::FromRow)] | |
| 22 | + | struct DailyNoteRow { | |
| 23 | + | id: String, | |
| 24 | + | user_id: String, | |
| 25 | + | note_date: String, | |
| 26 | + | went_well: String, | |
| 27 | + | could_improve: String, | |
| 28 | + | is_reviewed: i32, | |
| 29 | + | reviewed_at: Option<String>, | |
| 30 | + | created_at: String, | |
| 31 | + | updated_at: String, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | impl TryFrom<DailyNoteRow> for DailyNote { | |
| 35 | + | type Error = CoreError; | |
| 36 | + | ||
| 37 | + | fn try_from(row: DailyNoteRow) -> Result<Self> { | |
| 38 | + | Ok(DailyNote { | |
| 39 | + | id: parse_uuid(&row.id)?.into(), | |
| 40 | + | user_id: parse_uuid(&row.user_id)?.into(), | |
| 41 | + | note_date: NaiveDate::parse_from_str(&row.note_date, "%Y-%m-%d") | |
| 42 | + | .map_err(|_| CoreError::parse("Invalid date"))?, | |
| 43 | + | went_well: row.went_well, | |
| 44 | + | could_improve: row.could_improve, | |
| 45 | + | is_reviewed: row.is_reviewed != 0, | |
| 46 | + | reviewed_at: row.reviewed_at.as_deref().map(parse_datetime).transpose()?, | |
| 47 | + | created_at: parse_datetime(&row.created_at)?, | |
| 48 | + | updated_at: parse_datetime(&row.updated_at)?, | |
| 49 | + | }) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | #[async_trait] | |
| 54 | + | impl DailyNoteRepository for SqliteDailyNoteRepository { | |
| 55 | + | #[tracing::instrument(skip_all)] | |
| 56 | + | async fn get_by_date(&self, user_id: UserId, date: NaiveDate) -> Result<Option<DailyNote>> { | |
| 57 | + | let user_id_str = user_id.to_string(); | |
| 58 | + | let date_str = date.format("%Y-%m-%d").to_string(); | |
| 59 | + | ||
| 60 | + | let row: Option<DailyNoteRow> = sqlx::query_as( | |
| 61 | + | "SELECT id, user_id, note_date, went_well, could_improve, is_reviewed, reviewed_at, created_at, updated_at | |
| 62 | + | FROM daily_notes | |
| 63 | + | WHERE user_id = ? AND note_date = ?" | |
| 64 | + | ) | |
| 65 | + | .bind(&user_id_str) | |
| 66 | + | .bind(&date_str) | |
| 67 | + | .fetch_optional(&self.pool) | |
| 68 | + | .await | |
| 69 | + | .map_err(CoreError::database)?; | |
| 70 | + | ||
| 71 | + | row.map(DailyNote::try_from).transpose() | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | #[tracing::instrument(skip_all)] | |
| 75 | + | async fn upsert( | |
| 76 | + | &self, | |
| 77 | + | user_id: UserId, | |
| 78 | + | date: NaiveDate, | |
| 79 | + | went_well: &str, | |
| 80 | + | could_improve: &str, | |
| 81 | + | is_reviewed: bool, | |
| 82 | + | ) -> Result<DailyNote> { | |
| 83 | + | let user_id_str = user_id.to_string(); | |
| 84 | + | let date_str = date.format("%Y-%m-%d").to_string(); | |
| 85 | + | let now = Utc::now(); | |
| 86 | + | let now_str = format_datetime(&now); | |
| 87 | + | let reviewed_at_str = if is_reviewed { Some(now_str.clone()) } else { None }; | |
| 88 | + | ||
| 89 | + | let existing = self.get_by_date(user_id, date).await?; | |
| 90 | + | ||
| 91 | + | let (id, created_at) = if let Some(ref existing) = existing { | |
| 92 | + | sqlx::query( | |
| 93 | + | "UPDATE daily_notes SET went_well = ?, could_improve = ?, is_reviewed = ?, reviewed_at = ?, updated_at = ? | |
| 94 | + | WHERE id = ?" | |
| 95 | + | ) | |
| 96 | + | .bind(went_well) | |
| 97 | + | .bind(could_improve) | |
| 98 | + | .bind(is_reviewed as i32) | |
| 99 | + | .bind(&reviewed_at_str) | |
| 100 | + | .bind(&now_str) | |
| 101 | + | .bind(existing.id.to_string()) | |
| 102 | + | .execute(&self.pool) | |
| 103 | + | .await | |
| 104 | + | .map_err(CoreError::database)?; | |
| 105 | + | ||
| 106 | + | (existing.id, existing.created_at) | |
| 107 | + | } else { | |
| 108 | + | let id = DailyNoteId::new(); | |
| 109 | + | sqlx::query( | |
| 110 | + | "INSERT INTO daily_notes (id, user_id, note_date, went_well, could_improve, is_reviewed, reviewed_at, created_at, updated_at) | |
| 111 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" | |
| 112 | + | ) | |
| 113 | + | .bind(id.to_string()) | |
| 114 | + | .bind(&user_id_str) | |
| 115 | + | .bind(&date_str) | |
| 116 | + | .bind(went_well) | |
| 117 | + | .bind(could_improve) | |
| 118 | + | .bind(is_reviewed as i32) | |
| 119 | + | .bind(&reviewed_at_str) | |
| 120 | + | .bind(&now_str) | |
| 121 | + | .bind(&now_str) | |
| 122 | + | .execute(&self.pool) | |
| 123 | + | .await | |
| 124 | + | .map_err(CoreError::database)?; | |
| 125 | + | ||
| 126 | + | (id, now) | |
| 127 | + | }; | |
| 128 | + | ||
| 129 | + | Ok(DailyNote { | |
| 130 | + | id, | |
| 131 | + | user_id, | |
| 132 | + | note_date: date, | |
| 133 | + | went_well: went_well.to_string(), | |
| 134 | + | could_improve: could_improve.to_string(), | |
| 135 | + | is_reviewed, | |
| 136 | + | reviewed_at: reviewed_at_str.as_deref().map(parse_datetime).transpose()?, | |
| 137 | + | created_at, | |
| 138 | + | updated_at: now, | |
| 139 | + | }) | |
| 140 | + | } | |
| 141 | + | } |
| @@ -242,6 +242,29 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 242 | 242 | } | |
| 243 | 243 | ||
| 244 | 244 | #[tracing::instrument(skip_all)] | |
| 245 | + | async fn list_by_addresses(&self, user_id: UserId, addresses: &[&str]) -> Result<Vec<Email>> { | |
| 246 | + | if addresses.is_empty() { | |
| 247 | + | return Ok(Vec::new()); | |
| 248 | + | } | |
| 249 | + | let placeholders = addresses.iter().map(|_| "?").collect::<Vec<_>>().join(","); | |
| 250 | + | let query = format!( | |
| 251 | + | "SELECT {} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND (LOWER(e.from_address) IN ({placeholders}) OR LOWER(e.to_address) IN ({placeholders})) ORDER BY e.received_at DESC LIMIT 200", | |
| 252 | + | EMAIL_SELECT_COLUMNS | |
| 253 | + | ); | |
| 254 | + | let mut q = sqlx::query_as::<_, EmailRow>(&query) | |
| 255 | + | .bind(user_id.to_string()) | |
| 256 | + | .bind(user_id.to_string()); | |
| 257 | + | // Bind addresses twice (once for from_address IN, once for to_address IN) | |
| 258 | + | for _ in 0..2 { | |
| 259 | + | for addr in addresses { | |
| 260 | + | q = q.bind(addr.to_lowercase()); | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | let rows = q.fetch_all(&self.pool).await.map_err(CoreError::database)?; | |
| 264 | + | rows.into_iter().map(Email::try_from).collect() | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | #[tracing::instrument(skip_all)] | |
| 245 | 268 | async fn list_unlinked(&self, user_id: UserId) -> Result<Vec<Email>> { | |
| 246 | 269 | let query = format!( | |
| 247 | 270 | "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", | |
| @@ -405,6 +428,29 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 405 | 428 | } | |
| 406 | 429 | ||
| 407 | 430 | #[tracing::instrument(skip_all)] | |
| 431 | + | async fn exists_as_senders(&self, user_id: UserId, addresses: &[&str]) -> Result<HashSet<String>> { | |
| 432 | + | if addresses.is_empty() { | |
| 433 | + | return Ok(HashSet::new()); | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | let placeholders = addresses.iter().map(|_| "?").collect::<Vec<_>>().join(","); | |
| 437 | + | let query = format!( | |
| 438 | + | "SELECT DISTINCT LOWER(from_address) FROM emails WHERE user_id = ? AND LOWER(from_address) IN ({})", | |
| 439 | + | placeholders | |
| 440 | + | ); | |
| 441 | + | ||
| 442 | + | let mut q = sqlx::query_as::<_, (String,)>(&query).bind(user_id.to_string()); | |
| 443 | + | for addr in addresses { | |
| 444 | + | q = q.bind(addr.to_lowercase()); | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | let rows = q.fetch_all(&self.pool).await | |
| 448 | + | .map_err(CoreError::database)?; | |
| 449 | + | ||
| 450 | + | Ok(rows.into_iter().map(|(a,)| a).collect()) | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | #[tracing::instrument(skip_all)] | |
| 408 | 454 | async fn snooze(&self, id: EmailId, user_id: UserId, until: DateTime<Utc>) -> Result<Option<Email>> { | |
| 409 | 455 | let until_str = format_datetime(&until); | |
| 410 | 456 | let result = sqlx::query("UPDATE emails SET snoozed_until = ? WHERE id = ? AND user_id = ?") |
| @@ -10,7 +10,7 @@ use async_trait::async_trait; | |||
| 10 | 10 | use chrono::NaiveDate; | |
| 11 | 11 | use sqlx::SqlitePool; | |
| 12 | 12 | use goingson_core::{ | |
| 13 | - | BlockType, CoreError, DbValue, Event, EventId, EventRepository, NewEvent, ParseableEnum, | |
| 13 | + | BlockType, ContactId, CoreError, DbValue, Event, EventId, EventRepository, NewEvent, ParseableEnum, | |
| 14 | 14 | ProjectId, Recurrence, RecurrenceRule, Result, TaskId, UpdateEvent, UserId, | |
| 15 | 15 | }; | |
| 16 | 16 | ||
| @@ -127,6 +127,22 @@ impl EventRepository for SqliteEventRepository { | |||
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | 129 | #[tracing::instrument(skip_all)] | |
| 130 | + | async fn list_by_contact(&self, user_id: UserId, contact_id: ContactId) -> Result<Vec<Event>> { | |
| 131 | + | let query = format!( | |
| 132 | + | "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.contact_id = ? ORDER BY e.start_time DESC", | |
| 133 | + | EVENT_SELECT_COLUMNS | |
| 134 | + | ); | |
| 135 | + | let rows = sqlx::query_as::<_, EventRow>(&query) | |
| 136 | + | .bind(user_id.to_string()) | |
| 137 | + | .bind(user_id.to_string()) | |
| 138 | + | .bind(contact_id.to_string()) | |
| 139 | + | .fetch_all(&self.pool) | |
| 140 | + | .await | |
| 141 | + | .map_err(CoreError::database)?; | |
| 142 | + | rows.into_iter().map(Event::try_from).collect() | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | #[tracing::instrument(skip_all)] | |
| 130 | 146 | async fn get_by_id(&self, id: EventId, user_id: UserId) -> Result<Option<Event>> { | |
| 131 | 147 | let query = format!( | |
| 132 | 148 | "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 = ?", | |
| @@ -218,6 +234,22 @@ impl EventRepository for SqliteEventRepository { | |||
| 218 | 234 | } | |
| 219 | 235 | ||
| 220 | 236 | #[tracing::instrument(skip_all)] | |
| 237 | + | async fn delete_many(&self, ids: &[EventId], user_id: UserId) -> Result<u64> { | |
| 238 | + | if ids.is_empty() { | |
| 239 | + | return Ok(0); | |
| 240 | + | } | |
| 241 | + | let user_id_str = user_id.to_string(); | |
| 242 | + | let placeholders = vec!["?"; ids.len()].join(","); | |
| 243 | + | let sql = format!("DELETE FROM events WHERE user_id = ? AND id IN ({placeholders})"); | |
| 244 | + | let mut query = sqlx::query(&sql).bind(&user_id_str); | |
| 245 | + | for id in ids { | |
| 246 | + | query = query.bind(id.to_string()); | |
| 247 | + | } | |
| 248 | + | let result = query.execute(&self.pool).await.map_err(CoreError::database)?; | |
| 249 | + | Ok(result.rows_affected()) | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | #[tracing::instrument(skip_all)] | |
| 221 | 253 | async fn get_upcoming(&self, user_id: UserId, days: i64) -> Result<Vec<Event>> { | |
| 222 | 254 | let query = format!( | |
| 223 | 255 | "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", |
| @@ -6,6 +6,7 @@ | |||
| 6 | 6 | mod attachment_repo; | |
| 7 | 7 | mod backup_settings_repo; | |
| 8 | 8 | mod contact_repo; | |
| 9 | + | mod daily_note_repo; | |
| 9 | 10 | mod project_repo; | |
| 10 | 11 | mod annotation_repo; | |
| 11 | 12 | mod subtask_repo; | |
| @@ -27,6 +28,7 @@ mod weekly_review_repo; | |||
| 27 | 28 | pub use attachment_repo::SqliteAttachmentRepository; | |
| 28 | 29 | pub use backup_settings_repo::SqliteBackupSettingsRepository; | |
| 29 | 30 | pub use contact_repo::SqliteContactRepository; | |
| 31 | + | pub use daily_note_repo::SqliteDailyNoteRepository; | |
| 30 | 32 | pub use project_repo::SqliteProjectRepository; | |
| 31 | 33 | pub use task_repo::SqliteTaskRepository; | |
| 32 | 34 | pub use event_repo::SqliteEventRepository; |
| @@ -13,7 +13,7 @@ use chrono::{DateTime, NaiveDate, Utc}; | |||
| 13 | 13 | use sqlx::SqlitePool; | |
| 14 | 14 | ||
| 15 | 15 | use goingson_core::{ | |
| 16 | - | AnnotationId, Annotation, CoreError, DbValue, MilestoneId, NewTask, ParseableEnum, Priority, | |
| 16 | + | AnnotationId, Annotation, ContactId, CoreError, DbValue, MilestoneId, NewTask, ParseableEnum, Priority, | |
| 17 | 17 | ProjectId, Recurrence, Result, SortDirection, SubtaskId, Subtask, Task, TaskFilterQuery, | |
| 18 | 18 | TaskId, TaskRepository, TaskSortColumn, TaskStatus, TimeSession, | |
| 19 | 19 | TimeTrackingSummary, UpdateTask, UserId, | |
| @@ -289,6 +289,22 @@ impl TaskRepository for SqliteTaskRepository { | |||
| 289 | 289 | } | |
| 290 | 290 | ||
| 291 | 291 | #[tracing::instrument(skip_all)] | |
| 292 | + | async fn list_by_contact(&self, user_id: UserId, contact_id: ContactId) -> Result<Vec<Task>> { | |
| 293 | + | let sql = format!( | |
| 294 | + | r#" | |
| 295 | + | SELECT {} | |
| 296 | + | FROM tasks t | |
| 297 | + | LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? | |
| 298 | + | LEFT JOIN contacts ct ON ct.id = t.contact_id | |
| 299 | + | WHERE t.user_id = ? AND t.contact_id = ? AND t.status != 'Deleted' | |
| 300 | + | ORDER BY t.created_at DESC | |
| 301 | + | "#, | |
| 302 | + | TASK_SELECT_COLUMNS | |
| 303 | + | ); | |
| 304 | + | query_tasks(&self.pool, &sql, &[user_id.to_string(), user_id.to_string(), contact_id.to_string()]).await | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | #[tracing::instrument(skip_all)] | |
| 292 | 308 | async fn list_filtered(&self, user_id: UserId, query: TaskFilterQuery) -> Result<(Vec<Task>, i64)> { | |
| 293 | 309 | // Build dynamic WHERE clause | |
| 294 | 310 | let mut conditions = vec!["t.user_id = ?".to_string(), "t.status != 'Deleted'".to_string()]; | |
| @@ -610,8 +626,8 @@ impl TaskRepository for SqliteTaskRepository { | |||
| 610 | 626 | ||
| 611 | 627 | sqlx::query( | |
| 612 | 628 | r#" | |
| 613 | - | INSERT INTO tasks (id, user_id, project_id, contact_id, milestone_id, description, priority, due, tags, recurrence, recurrence_rule, urgency, source_email_id, scheduled_start, scheduled_duration, estimated_minutes, created_at) | |
| 614 | - | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 629 | + | INSERT INTO tasks (id, user_id, project_id, contact_id, milestone_id, description, priority, due, tags, recurrence, recurrence_rule, urgency, source_email_id, scheduled_start, scheduled_duration, estimated_minutes, recurrence_parent_id, created_at) | |
| 630 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 615 | 631 | "#, | |
| 616 | 632 | ) | |
| 617 | 633 | .bind(nid.to_string()) | |
| @@ -630,6 +646,7 @@ impl TaskRepository for SqliteTaskRepository { | |||
| 630 | 646 | .bind(&scheduled_start_str) | |
| 631 | 647 | .bind(new_task.scheduled_duration) | |
| 632 | 648 | .bind(new_task.estimated_minutes) | |
| 649 | + | .bind(new_task.recurrence_parent_id.map(|p| p.to_string())) | |
| 633 | 650 | .bind(&now) | |
| 634 | 651 | .execute(&mut *tx) | |
| 635 | 652 | .await | |
| @@ -857,4 +874,18 @@ impl TaskRepository for SqliteTaskRepository { | |||
| 857 | 874 | async fn get_time_summary(&self, user_id: UserId, start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Vec<TimeTrackingSummary>> { | |
| 858 | 875 | time_session_repo::get_time_summary(&self.pool, user_id, start, end).await | |
| 859 | 876 | } | |
| 877 | + | ||
| 878 | + | #[tracing::instrument(skip_all)] | |
| 879 | + | async fn list_recurrence_chain(&self, root_id: TaskId, user_id: UserId) -> Result<Vec<Task>> { | |
| 880 | + | let sql = format!( | |
| 881 | + | "SELECT {} FROM tasks t LEFT JOIN projects p ON t.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = t.contact_id WHERE (t.recurrence_parent_id = ? OR t.id = ?) AND t.user_id = ? ORDER BY t.created_at DESC", | |
| 882 | + | TASK_SELECT_COLUMNS | |
| 883 | + | ); | |
| 884 | + | query_tasks(&self.pool, &sql, &[ | |
| 885 | + | user_id.to_string(), | |
| 886 | + | root_id.to_string(), | |
| 887 | + | root_id.to_string(), | |
| 888 | + | user_id.to_string(), | |
| 889 | + | ]).await | |
| 890 | + | } | |
| 860 | 891 | } |
| @@ -25,6 +25,7 @@ async fn create_and_get_contact() { | |||
| 25 | 25 | tags: vec![], | |
| 26 | 26 | birthday: None, | |
| 27 | 27 | timezone: None, | |
| 28 | + | is_implicit: false, | |
| 28 | 29 | }; | |
| 29 | 30 | ||
| 30 | 31 | let created = repo.create(user_id, new).await.unwrap(); | |
| @@ -107,6 +108,7 @@ async fn update_contact() { | |||
| 107 | 108 | tags: vec![], | |
| 108 | 109 | birthday: None, | |
| 109 | 110 | timezone: None, | |
| 111 | + | is_implicit: false, | |
| 110 | 112 | }; | |
| 111 | 113 | ||
| 112 | 114 | let created = repo.create(user_id, new).await.unwrap(); | |
| @@ -120,6 +122,7 @@ async fn update_contact() { | |||
| 120 | 122 | tags: vec!["friend".to_string()], | |
| 121 | 123 | birthday: None, | |
| 122 | 124 | timezone: None, | |
| 125 | + | is_implicit: false, | |
| 123 | 126 | }; | |
| 124 | 127 | ||
| 125 | 128 | let updated = repo.update(created.id, user_id, update).await.unwrap().unwrap(); | |
| @@ -144,6 +147,7 @@ async fn update_nonexistent_returns_none() { | |||
| 144 | 147 | tags: vec![], | |
| 145 | 148 | birthday: None, | |
| 146 | 149 | timezone: None, | |
| 150 | + | is_implicit: false, | |
| 147 | 151 | }; | |
| 148 | 152 | ||
| 149 | 153 | let result = repo.update(ContactId::new(), user_id, update).await.unwrap(); | |
| @@ -165,6 +169,7 @@ async fn delete_contact() { | |||
| 165 | 169 | tags: vec![], | |
| 166 | 170 | birthday: None, | |
| 167 | 171 | timezone: None, | |
| 172 | + | is_implicit: false, | |
| 168 | 173 | }; | |
| 169 | 174 | ||
| 170 | 175 | let created = repo.create(user_id, new).await.unwrap(); | |
| @@ -190,6 +195,7 @@ async fn delete_cascades_sub_collections() { | |||
| 190 | 195 | tags: vec![], | |
| 191 | 196 | birthday: None, | |
| 192 | 197 | timezone: None, | |
| 198 | + | is_implicit: false, | |
| 193 | 199 | }; | |
| 194 | 200 | ||
| 195 | 201 | let contact = repo.create(user_id, new).await.unwrap(); | |
| @@ -260,6 +266,7 @@ async fn add_and_list_emails() { | |||
| 260 | 266 | tags: vec![], | |
| 261 | 267 | birthday: None, | |
| 262 | 268 | timezone: None, | |
| 269 | + | is_implicit: false, | |
| 263 | 270 | }, | |
| 264 | 271 | ) | |
| 265 | 272 | .await | |
| @@ -314,6 +321,7 @@ async fn remove_email() { | |||
| 314 | 321 | tags: vec![], | |
| 315 | 322 | birthday: None, | |
| 316 | 323 | timezone: None, | |
| 324 | + | is_implicit: false, | |
| 317 | 325 | }, | |
| 318 | 326 | ) | |
| 319 | 327 | .await | |
| @@ -357,6 +365,7 @@ async fn add_and_list_phones() { | |||
| 357 | 365 | tags: vec![], | |
| 358 | 366 | birthday: None, | |
| 359 | 367 | timezone: None, | |
| 368 | + | is_implicit: false, | |
| 360 | 369 | }, | |
| 361 | 370 | ) | |
| 362 | 371 | .await | |
| @@ -397,6 +406,7 @@ async fn add_and_list_social_handles() { | |||
| 397 | 406 | tags: vec![], | |
| 398 | 407 | birthday: None, | |
| 399 | 408 | timezone: None, | |
| 409 | + | is_implicit: false, | |
| 400 | 410 | }, | |
| 401 | 411 | ) | |
| 402 | 412 | .await | |
| @@ -438,6 +448,7 @@ async fn add_and_list_custom_fields() { | |||
| 438 | 448 | tags: vec![], | |
| 439 | 449 | birthday: None, | |
| 440 | 450 | timezone: None, | |
| 451 | + | is_implicit: false, | |
| 441 | 452 | }, | |
| 442 | 453 | ) | |
| 443 | 454 | .await | |
| @@ -502,6 +513,7 @@ async fn list_by_tag() { | |||
| 502 | 513 | tags: vec!["friend".to_string()], | |
| 503 | 514 | birthday: None, | |
| 504 | 515 | timezone: None, | |
| 516 | + | is_implicit: false, | |
| 505 | 517 | }, | |
| 506 | 518 | ) | |
| 507 | 519 | .await | |
| @@ -518,6 +530,7 @@ async fn list_by_tag() { | |||
| 518 | 530 | tags: vec![], | |
| 519 | 531 | birthday: None, | |
| 520 | 532 | timezone: None, | |
| 533 | + | is_implicit: false, | |
| 521 | 534 | }, | |
| 522 | 535 | ) | |
| 523 | 536 | .await | |
| @@ -545,6 +558,7 @@ async fn list_filtered_by_search() { | |||
| 545 | 558 | tags: vec![], | |
| 546 | 559 | birthday: None, | |
| 547 | 560 | timezone: None, | |
| 561 | + | is_implicit: false, | |
| 548 | 562 | }, | |
| 549 | 563 | ) | |
| 550 | 564 | .await | |
| @@ -561,12 +575,13 @@ async fn list_filtered_by_search() { | |||
| 561 | 575 | tags: vec![], | |
| 562 | 576 | birthday: None, | |
| 563 | 577 | timezone: None, | |
| 578 | + | is_implicit: false, | |
| 564 | 579 | }, | |
| 565 | 580 | ) | |
| 566 | 581 | .await | |
| 567 | 582 | .unwrap(); | |
| 568 | 583 | ||
| 569 | - | let result = repo.list_filtered(user_id, Some("alice"), None).await.unwrap(); | |
| 584 | + | let result = repo.list_filtered(user_id, Some("alice"), None, false).await.unwrap(); | |
| 570 | 585 | assert_eq!(result.len(), 1); | |
| 571 | 586 | assert_eq!(result[0].display_name, "Alice Smith"); | |
| 572 | 587 | } | |
| @@ -588,6 +603,7 @@ async fn list_filtered_by_tag_and_search() { | |||
| 588 | 603 | tags: vec!["work".to_string()], | |
| 589 | 604 | birthday: None, | |
| 590 | 605 | timezone: None, | |
| 606 | + | is_implicit: false, | |
| 591 | 607 | }, | |
| 592 | 608 | ) | |
| 593 | 609 | .await | |
| @@ -604,13 +620,14 @@ async fn list_filtered_by_tag_and_search() { | |||
| 604 | 620 | tags: vec!["personal".to_string()], | |
| 605 | 621 | birthday: None, | |
| 606 | 622 | timezone: None, | |
| 623 | + | is_implicit: false, | |
| 607 | 624 | }, | |
| 608 | 625 | ) | |
| 609 | 626 | .await | |
| 610 | 627 | .unwrap(); | |
| 611 | 628 | ||
| 612 | 629 | let result = repo | |
| 613 | - | .list_filtered(user_id, Some("alice"), Some("work")) | |
| 630 | + | .list_filtered(user_id, Some("alice"), Some("work"), false) | |
| 614 | 631 | .await | |
| 615 | 632 | .unwrap(); | |
| 616 | 633 | assert_eq!(result.len(), 1); | |
| @@ -635,6 +652,7 @@ async fn find_by_email() { | |||
| 635 | 652 | tags: vec![], | |
| 636 | 653 | birthday: None, | |
| 637 | 654 | timezone: None, | |
| 655 | + | is_implicit: false, | |
| 638 | 656 | }, | |
| 639 | 657 | ) | |
| 640 | 658 | .await | |
| @@ -678,6 +696,7 @@ async fn find_by_email_case_insensitive() { | |||
| 678 | 696 | tags: vec![], | |
| 679 | 697 | birthday: None, | |
| 680 | 698 | timezone: None, | |
| 699 | + | is_implicit: false, | |
| 681 | 700 | }, | |
| 682 | 701 | ) | |
| 683 | 702 | .await |
| @@ -26,6 +26,7 @@ async fn create_task_with_desc(pool: &sqlx::SqlitePool, user_id: UserId, descrip | |||
| 26 | 26 | contact_id: None, | |
| 27 | 27 | milestone_id: None, | |
| 28 | 28 | estimated_minutes: None, | |
| 29 | + | recurrence_parent_id: None, | |
| 29 | 30 | }; | |
| 30 | 31 | repo.create(user_id, new_task).await.unwrap(); | |
| 31 | 32 | } | |
| @@ -53,6 +54,7 @@ async fn create_task_with_priority( | |||
| 53 | 54 | contact_id: None, | |
| 54 | 55 | milestone_id: None, | |
| 55 | 56 | estimated_minutes: None, | |
| 57 | + | recurrence_parent_id: None, | |
| 56 | 58 | }; | |
| 57 | 59 | repo.create(user_id, new_task).await.unwrap(); | |
| 58 | 60 | } | |
| @@ -203,6 +205,7 @@ async fn search_tag_include() { | |||
| 203 | 205 | contact_id: None, | |
| 204 | 206 | milestone_id: None, | |
| 205 | 207 | estimated_minutes: None, | |
| 208 | + | recurrence_parent_id: None, | |
| 206 | 209 | }, | |
| 207 | 210 | ) | |
| 208 | 211 | .await | |
| @@ -244,6 +247,7 @@ async fn search_tag_exclude() { | |||
| 244 | 247 | contact_id: None, | |
| 245 | 248 | milestone_id: None, | |
| 246 | 249 | estimated_minutes: None, | |
| 250 | + | recurrence_parent_id: None, | |
| 247 | 251 | }, | |
| 248 | 252 | ) | |
| 249 | 253 | .await |