Skip to main content

max / goingson

Daily notes, implicit contacts, UX polish, calendar views, search Daily notes (migration 046), recurrence chain backfill (047), implicit contacts (048), contact dashboard, email compose highlighting, calendar month/week views, command palette search, plan/review toggle, task overview, settings page, and assorted UX improvements. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-07 03:25 UTC
Commit: 73d3f4aec0d2a87cc4a8b68ff80a7693a823e9d7
Parent: 9f94745
68 files changed, +4832 insertions, -458 deletions
M Cargo.lock +2 -2
@@ -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",
M Cargo.toml +2 -2
@@ -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