//! 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 ============ /// 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, } #[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()); } }