//! Contact domain model. //! //! Contacts represent people with multiple email addresses, phone numbers, //! and social handles. Sub-collections are stored in separate tables to //! enable querying by email address for future integration features. use chrono::{DateTime, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; use crate::id_types::{ContactId, ContactEmailId, ContactPhoneId, SocialHandleId, CustomFieldId}; // ============ Main Entity ============ /// A contact (person) with optional sub-collections. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Contact { pub id: ContactId, pub display_name: String, pub nickname: Option, pub company: Option, pub title: Option, pub notes: String, pub tags: Vec, pub birthday: Option, pub timezone: Option, pub external_source: Option, pub external_id: Option, pub is_implicit: bool, pub emails: Vec, pub phones: Vec, pub social_handles: Vec, pub custom_fields: Vec, pub created_at: DateTime, pub updated_at: DateTime, } impl Contact { /// Returns the primary email address, or the first email if none is marked primary. pub fn primary_email(&self) -> Option<&str> { self.emails .iter() .find(|e| e.is_primary) .or_else(|| self.emails.first()) .map(|e| e.address.as_str()) } /// Returns display initials (e.g., "JS" from "Jane Smith"). pub fn display_initials(&self) -> String { self.display_name .split_whitespace() .filter_map(|w| w.chars().next()) .take(2) .collect::() .to_uppercase() } /// Returns the number of email addresses. pub fn email_count(&self) -> usize { self.emails.len() } /// Returns true if the contact has any social handles. pub fn has_social(&self) -> bool { !self.social_handles.is_empty() } /// Returns true if the contact has a company set. pub fn has_company(&self) -> bool { self.company.as_ref().is_some_and(|c| !c.is_empty()) } /// Returns the company name or an empty string. pub fn company_or_empty(&self) -> &str { self.company.as_deref().unwrap_or("") } } // ============ Sub-collection Entities ============ /// A flattened (name, email) pair for compose autocomplete. /// /// Returned by `list_email_directory` — a single JOIN that skips the per-contact /// sub-collection hydration the compose screen does not need. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ContactEmailEntry { pub name: String, pub email: String, pub is_implicit: bool, } /// An email address belonging to a contact. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ContactEmail { pub id: ContactEmailId, #[serde(skip_serializing)] pub contact_id: ContactId, pub address: String, pub label: String, pub is_primary: bool, } /// A phone number belonging to a contact. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ContactPhone { pub id: ContactPhoneId, #[serde(skip_serializing)] pub contact_id: ContactId, pub number: String, pub label: String, pub is_primary: bool, } /// A social media handle belonging to a contact. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SocialHandle { pub id: SocialHandleId, #[serde(skip_serializing)] pub contact_id: ContactId, pub platform: String, pub handle: String, pub url: Option, } /// An arbitrary custom field on a contact (label + value + optional URL). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ContactCustomField { pub id: CustomFieldId, #[serde(skip_serializing)] pub contact_id: ContactId, pub label: String, pub value: String, pub url: Option, } // ============ DTOs ============ /// Data for creating a new contact. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewContact { pub display_name: String, pub nickname: Option, pub company: Option, pub title: Option, pub notes: String, pub tags: Vec, pub birthday: Option, pub timezone: Option, pub is_implicit: bool, } /// Data for updating an existing contact. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateContact { pub display_name: String, pub nickname: Option, pub company: Option, pub title: Option, pub notes: String, pub tags: Vec, pub birthday: Option, pub timezone: Option, } /// Data for adding an email to a contact. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewContactEmail { pub address: String, pub label: String, pub is_primary: bool, } /// Data for adding a phone number to a contact. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewContactPhone { pub number: String, pub label: String, pub is_primary: bool, } /// Data for adding a social handle to a contact. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewSocialHandle { pub platform: String, pub handle: String, pub url: Option, } /// Data for adding a custom field to a contact. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewContactCustomField { pub label: String, pub value: String, pub url: Option, } // ============ Activity Feed ============ /// Which kind of entity an activity item represents. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ActivityKind { Task, Event, Email, } /// A single entry in a contact's unified activity timeline. /// /// Carries fully pre-computed display fields so the frontend renders it /// directly: no date parsing, sorting, or merging on the JS side. Tasks, /// events, and emails all collapse into this one shape. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ActivityItem { /// Entity kind, drives icon and click-through route on the frontend. pub kind: ActivityKind, /// Target entity id (as a string) for click-through navigation. pub id: String, /// Row title (task description, event title, or email subject). pub title: String, /// Sort key / ISO timestamp: task created_at, event start_time, email received_at. pub timestamp: DateTime, /// Pre-formatted local date for display (e.g. "Jul 4, 2026"). pub date_formatted: String, /// Task status badge label, if this item is a task. pub status: Option, /// Whether an email item was outgoing (drives the direction icon). pub is_outgoing: bool, } /// Merges activity items into a single feed sorted newest-first and capped. /// /// Sources are already-built [`ActivityItem`]s (from tasks, events, emails); /// this only orders them by `timestamp` descending and truncates to `cap`. pub fn merge_activity(mut items: Vec, cap: usize) -> Vec { items.sort_by_key(|i| std::cmp::Reverse(i.timestamp)); items.truncate(cap); items } #[cfg(test)] mod tests { use super::*; fn make_contact(name: &str) -> Contact { Contact { id: ContactId::new(), display_name: name.to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, external_source: None, external_id: None, is_implicit: false, emails: vec![], phones: vec![], social_handles: vec![], custom_fields: vec![], created_at: Utc::now(), updated_at: Utc::now(), } } #[test] fn test_display_initials() { let c = make_contact("Jane Smith"); assert_eq!(c.display_initials(), "JS"); let c = make_contact("Madonna"); assert_eq!(c.display_initials(), "M"); let c = make_contact("John Jacob Jingleheimer Schmidt"); assert_eq!(c.display_initials(), "JJ"); } #[test] fn test_primary_email() { let mut c = make_contact("Test"); assert_eq!(c.primary_email(), None); c.emails.push(ContactEmail { id: ContactEmailId::new(), contact_id: c.id, address: "first@example.com".to_string(), label: "Work".to_string(), is_primary: false, }); c.emails.push(ContactEmail { id: ContactEmailId::new(), contact_id: c.id, address: "primary@example.com".to_string(), label: "Personal".to_string(), is_primary: true, }); assert_eq!(c.primary_email(), Some("primary@example.com")); } #[test] fn test_primary_email_fallback_to_first() { let mut c = make_contact("Test"); c.emails.push(ContactEmail { id: ContactEmailId::new(), contact_id: c.id, address: "only@example.com".to_string(), label: String::new(), is_primary: false, }); assert_eq!(c.primary_email(), Some("only@example.com")); } #[test] fn test_has_company() { let mut c = make_contact("Test"); assert!(!c.has_company()); c.company = Some("Acme Corp".to_string()); assert!(c.has_company()); c.company = Some(String::new()); assert!(!c.has_company()); } fn activity(kind: ActivityKind, secs: i64) -> ActivityItem { ActivityItem { kind, id: format!("{secs}"), title: format!("item {secs}"), timestamp: DateTime::::from_timestamp(secs, 0).expect("valid timestamp"), date_formatted: String::new(), status: None, is_outgoing: false, } } #[test] fn merge_activity_empty_is_empty() { assert!(merge_activity(vec![], 20).is_empty()); } #[test] fn merge_activity_sorts_newest_first_across_sources() { let items = vec![ activity(ActivityKind::Task, 100), activity(ActivityKind::Email, 300), activity(ActivityKind::Event, 200), ]; let merged = merge_activity(items, 20); let order: Vec = merged.iter().map(|i| i.timestamp.timestamp()).collect(); assert_eq!(order, vec![300, 200, 100]); assert_eq!(merged[0].kind, ActivityKind::Email); assert_eq!(merged[2].kind, ActivityKind::Task); } #[test] fn merge_activity_respects_cap() { let items = (0..50).map(|s| activity(ActivityKind::Task, s)).collect(); let merged = merge_activity(items, 20); assert_eq!(merged.len(), 20); // Newest-first: the highest timestamps survive the cap. assert_eq!(merged.first().unwrap().timestamp.timestamp(), 49); assert_eq!(merged.last().unwrap().timestamp.timestamp(), 30); } }