Skip to main content

max / goingson

7.7 KB · 277 lines History Blame Raw
1 //! Contact domain model.
2 //!
3 //! Contacts represent people with multiple email addresses, phone numbers,
4 //! and social handles. Sub-collections are stored in separate tables to
5 //! enable querying by email address for future integration features.
6
7 use chrono::{DateTime, NaiveDate, Utc};
8 use serde::{Deserialize, Serialize};
9 use crate::id_types::{ContactId, ContactEmailId, ContactPhoneId, SocialHandleId, CustomFieldId};
10
11 // ============ Main Entity ============
12
13 /// A contact (person) with optional sub-collections.
14 #[derive(Debug, Clone, Serialize, Deserialize)]
15 #[serde(rename_all = "camelCase")]
16 pub struct Contact {
17 pub id: ContactId,
18 pub display_name: String,
19 pub nickname: Option<String>,
20 pub company: Option<String>,
21 pub title: Option<String>,
22 pub notes: String,
23 pub tags: Vec<String>,
24 pub birthday: Option<NaiveDate>,
25 pub timezone: Option<String>,
26 pub external_source: Option<String>,
27 pub external_id: Option<String>,
28 pub is_implicit: bool,
29 pub emails: Vec<ContactEmail>,
30 pub phones: Vec<ContactPhone>,
31 pub social_handles: Vec<SocialHandle>,
32 pub custom_fields: Vec<ContactCustomField>,
33 pub created_at: DateTime<Utc>,
34 pub updated_at: DateTime<Utc>,
35 }
36
37 impl Contact {
38 /// Returns the primary email address, or the first email if none is marked primary.
39 pub fn primary_email(&self) -> Option<&str> {
40 self.emails
41 .iter()
42 .find(|e| e.is_primary)
43 .or_else(|| self.emails.first())
44 .map(|e| e.address.as_str())
45 }
46
47 /// Returns display initials (e.g., "JS" from "Jane Smith").
48 pub fn display_initials(&self) -> String {
49 self.display_name
50 .split_whitespace()
51 .filter_map(|w| w.chars().next())
52 .take(2)
53 .collect::<String>()
54 .to_uppercase()
55 }
56
57 /// Returns the number of email addresses.
58 pub fn email_count(&self) -> usize {
59 self.emails.len()
60 }
61
62 /// Returns true if the contact has any social handles.
63 pub fn has_social(&self) -> bool {
64 !self.social_handles.is_empty()
65 }
66
67 /// Returns true if the contact has a company set.
68 pub fn has_company(&self) -> bool {
69 self.company.as_ref().is_some_and(|c| !c.is_empty())
70 }
71
72 /// Returns the company name or an empty string.
73 pub fn company_or_empty(&self) -> &str {
74 self.company.as_deref().unwrap_or("")
75 }
76 }
77
78 // ============ Sub-collection Entities ============
79
80 /// An email address belonging to a contact.
81 #[derive(Debug, Clone, Serialize, Deserialize)]
82 #[serde(rename_all = "camelCase")]
83 pub struct ContactEmail {
84 pub id: ContactEmailId,
85 #[serde(skip_serializing)]
86 pub contact_id: ContactId,
87 pub address: String,
88 pub label: String,
89 pub is_primary: bool,
90 }
91
92 /// A phone number belonging to a contact.
93 #[derive(Debug, Clone, Serialize, Deserialize)]
94 #[serde(rename_all = "camelCase")]
95 pub struct ContactPhone {
96 pub id: ContactPhoneId,
97 #[serde(skip_serializing)]
98 pub contact_id: ContactId,
99 pub number: String,
100 pub label: String,
101 pub is_primary: bool,
102 }
103
104 /// A social media handle belonging to a contact.
105 #[derive(Debug, Clone, Serialize, Deserialize)]
106 #[serde(rename_all = "camelCase")]
107 pub struct SocialHandle {
108 pub id: SocialHandleId,
109 #[serde(skip_serializing)]
110 pub contact_id: ContactId,
111 pub platform: String,
112 pub handle: String,
113 pub url: Option<String>,
114 }
115
116 /// An arbitrary custom field on a contact (label + value + optional URL).
117 #[derive(Debug, Clone, Serialize, Deserialize)]
118 #[serde(rename_all = "camelCase")]
119 pub struct ContactCustomField {
120 pub id: CustomFieldId,
121 #[serde(skip_serializing)]
122 pub contact_id: ContactId,
123 pub label: String,
124 pub value: String,
125 pub url: Option<String>,
126 }
127
128 // ============ DTOs ============
129
130 /// Data for creating a new contact.
131 #[derive(Debug, Clone, Serialize, Deserialize)]
132 pub struct NewContact {
133 pub display_name: String,
134 pub nickname: Option<String>,
135 pub company: Option<String>,
136 pub title: Option<String>,
137 pub notes: String,
138 pub tags: Vec<String>,
139 pub birthday: Option<NaiveDate>,
140 pub timezone: Option<String>,
141 pub is_implicit: bool,
142 }
143
144 /// Data for updating an existing contact.
145 #[derive(Debug, Clone, Serialize, Deserialize)]
146 pub struct UpdateContact {
147 pub display_name: String,
148 pub nickname: Option<String>,
149 pub company: Option<String>,
150 pub title: Option<String>,
151 pub notes: String,
152 pub tags: Vec<String>,
153 pub birthday: Option<NaiveDate>,
154 pub timezone: Option<String>,
155 }
156
157 /// Data for adding an email to a contact.
158 #[derive(Debug, Clone, Serialize, Deserialize)]
159 pub struct NewContactEmail {
160 pub address: String,
161 pub label: String,
162 pub is_primary: bool,
163 }
164
165 /// Data for adding a phone number to a contact.
166 #[derive(Debug, Clone, Serialize, Deserialize)]
167 pub struct NewContactPhone {
168 pub number: String,
169 pub label: String,
170 pub is_primary: bool,
171 }
172
173 /// Data for adding a social handle to a contact.
174 #[derive(Debug, Clone, Serialize, Deserialize)]
175 pub struct NewSocialHandle {
176 pub platform: String,
177 pub handle: String,
178 pub url: Option<String>,
179 }
180
181 /// Data for adding a custom field to a contact.
182 #[derive(Debug, Clone, Serialize, Deserialize)]
183 pub struct NewContactCustomField {
184 pub label: String,
185 pub value: String,
186 pub url: Option<String>,
187 }
188
189 #[cfg(test)]
190 mod tests {
191 use super::*;
192
193 fn make_contact(name: &str) -> Contact {
194 Contact {
195 id: ContactId::new(),
196 display_name: name.to_string(),
197 nickname: None,
198 company: None,
199 title: None,
200 notes: String::new(),
201 tags: vec![],
202 birthday: None,
203 timezone: None,
204 external_source: None,
205 external_id: None,
206 is_implicit: false,
207 emails: vec![],
208 phones: vec![],
209 social_handles: vec![],
210 custom_fields: vec![],
211 created_at: Utc::now(),
212 updated_at: Utc::now(),
213 }
214 }
215
216 #[test]
217 fn test_display_initials() {
218 let c = make_contact("Jane Smith");
219 assert_eq!(c.display_initials(), "JS");
220
221 let c = make_contact("Madonna");
222 assert_eq!(c.display_initials(), "M");
223
224 let c = make_contact("John Jacob Jingleheimer Schmidt");
225 assert_eq!(c.display_initials(), "JJ");
226 }
227
228 #[test]
229 fn test_primary_email() {
230 let mut c = make_contact("Test");
231 assert_eq!(c.primary_email(), None);
232
233 c.emails.push(ContactEmail {
234 id: ContactEmailId::new(),
235 contact_id: c.id,
236 address: "first@example.com".to_string(),
237 label: "Work".to_string(),
238 is_primary: false,
239 });
240 c.emails.push(ContactEmail {
241 id: ContactEmailId::new(),
242 contact_id: c.id,
243 address: "primary@example.com".to_string(),
244 label: "Personal".to_string(),
245 is_primary: true,
246 });
247
248 assert_eq!(c.primary_email(), Some("primary@example.com"));
249 }
250
251 #[test]
252 fn test_primary_email_fallback_to_first() {
253 let mut c = make_contact("Test");
254 c.emails.push(ContactEmail {
255 id: ContactEmailId::new(),
256 contact_id: c.id,
257 address: "only@example.com".to_string(),
258 label: String::new(),
259 is_primary: false,
260 });
261
262 assert_eq!(c.primary_email(), Some("only@example.com"));
263 }
264
265 #[test]
266 fn test_has_company() {
267 let mut c = make_contact("Test");
268 assert!(!c.has_company());
269
270 c.company = Some("Acme Corp".to_string());
271 assert!(c.has_company());
272
273 c.company = Some(String::new());
274 assert!(!c.has_company());
275 }
276 }
277