Skip to main content

max / goingson

11.3 KB · 375 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 /// A flattened (name, email) pair for compose autocomplete.
81 ///
82 /// Returned by `list_email_directory` — a single JOIN that skips the per-contact
83 /// sub-collection hydration the compose screen does not need.
84 #[derive(Debug, Clone, Serialize, Deserialize)]
85 #[serde(rename_all = "camelCase")]
86 pub struct ContactEmailEntry {
87 pub name: String,
88 pub email: String,
89 pub is_implicit: bool,
90 }
91
92 /// An email address belonging to a contact.
93 #[derive(Debug, Clone, Serialize, Deserialize)]
94 #[serde(rename_all = "camelCase")]
95 pub struct ContactEmail {
96 pub id: ContactEmailId,
97 #[serde(skip_serializing)]
98 pub contact_id: ContactId,
99 pub address: String,
100 pub label: String,
101 pub is_primary: bool,
102 }
103
104 /// A phone number belonging to a contact.
105 #[derive(Debug, Clone, Serialize, Deserialize)]
106 #[serde(rename_all = "camelCase")]
107 pub struct ContactPhone {
108 pub id: ContactPhoneId,
109 #[serde(skip_serializing)]
110 pub contact_id: ContactId,
111 pub number: String,
112 pub label: String,
113 pub is_primary: bool,
114 }
115
116 /// A social media handle belonging to a contact.
117 #[derive(Debug, Clone, Serialize, Deserialize)]
118 #[serde(rename_all = "camelCase")]
119 pub struct SocialHandle {
120 pub id: SocialHandleId,
121 #[serde(skip_serializing)]
122 pub contact_id: ContactId,
123 pub platform: String,
124 pub handle: String,
125 pub url: Option<String>,
126 }
127
128 /// An arbitrary custom field on a contact (label + value + optional URL).
129 #[derive(Debug, Clone, Serialize, Deserialize)]
130 #[serde(rename_all = "camelCase")]
131 pub struct ContactCustomField {
132 pub id: CustomFieldId,
133 #[serde(skip_serializing)]
134 pub contact_id: ContactId,
135 pub label: String,
136 pub value: String,
137 pub url: Option<String>,
138 }
139
140 // ============ DTOs ============
141
142 /// Data for creating a new contact.
143 #[derive(Debug, Clone, Serialize, Deserialize)]
144 pub struct NewContact {
145 pub display_name: String,
146 pub nickname: Option<String>,
147 pub company: Option<String>,
148 pub title: Option<String>,
149 pub notes: String,
150 pub tags: Vec<String>,
151 pub birthday: Option<NaiveDate>,
152 pub timezone: Option<String>,
153 pub is_implicit: bool,
154 }
155
156 /// Data for updating an existing contact.
157 #[derive(Debug, Clone, Serialize, Deserialize)]
158 pub struct UpdateContact {
159 pub display_name: String,
160 pub nickname: Option<String>,
161 pub company: Option<String>,
162 pub title: Option<String>,
163 pub notes: String,
164 pub tags: Vec<String>,
165 pub birthday: Option<NaiveDate>,
166 pub timezone: Option<String>,
167 }
168
169 /// Data for adding an email to a contact.
170 #[derive(Debug, Clone, Serialize, Deserialize)]
171 pub struct NewContactEmail {
172 pub address: String,
173 pub label: String,
174 pub is_primary: bool,
175 }
176
177 /// Data for adding a phone number to a contact.
178 #[derive(Debug, Clone, Serialize, Deserialize)]
179 pub struct NewContactPhone {
180 pub number: String,
181 pub label: String,
182 pub is_primary: bool,
183 }
184
185 /// Data for adding a social handle to a contact.
186 #[derive(Debug, Clone, Serialize, Deserialize)]
187 pub struct NewSocialHandle {
188 pub platform: String,
189 pub handle: String,
190 pub url: Option<String>,
191 }
192
193 /// Data for adding a custom field to a contact.
194 #[derive(Debug, Clone, Serialize, Deserialize)]
195 pub struct NewContactCustomField {
196 pub label: String,
197 pub value: String,
198 pub url: Option<String>,
199 }
200
201 // ============ Activity Feed ============
202
203 /// Which kind of entity an activity item represents.
204 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205 #[serde(rename_all = "lowercase")]
206 pub enum ActivityKind {
207 Task,
208 Event,
209 Email,
210 }
211
212 /// A single entry in a contact's unified activity timeline.
213 ///
214 /// Carries fully pre-computed display fields so the frontend renders it
215 /// directly: no date parsing, sorting, or merging on the JS side. Tasks,
216 /// events, and emails all collapse into this one shape.
217 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218 #[serde(rename_all = "camelCase")]
219 pub struct ActivityItem {
220 /// Entity kind, drives icon and click-through route on the frontend.
221 pub kind: ActivityKind,
222 /// Target entity id (as a string) for click-through navigation.
223 pub id: String,
224 /// Row title (task description, event title, or email subject).
225 pub title: String,
226 /// Sort key / ISO timestamp: task created_at, event start_time, email received_at.
227 pub timestamp: DateTime<Utc>,
228 /// Pre-formatted local date for display (e.g. "Jul 4, 2026").
229 pub date_formatted: String,
230 /// Task status badge label, if this item is a task.
231 pub status: Option<String>,
232 /// Whether an email item was outgoing (drives the direction icon).
233 pub is_outgoing: bool,
234 }
235
236 /// Merges activity items into a single feed sorted newest-first and capped.
237 ///
238 /// Sources are already-built [`ActivityItem`]s (from tasks, events, emails);
239 /// this only orders them by `timestamp` descending and truncates to `cap`.
240 pub fn merge_activity(mut items: Vec<ActivityItem>, cap: usize) -> Vec<ActivityItem> {
241 items.sort_by_key(|i| std::cmp::Reverse(i.timestamp));
242 items.truncate(cap);
243 items
244 }
245
246 #[cfg(test)]
247 mod tests {
248 use super::*;
249
250 fn make_contact(name: &str) -> Contact {
251 Contact {
252 id: ContactId::new(),
253 display_name: name.to_string(),
254 nickname: None,
255 company: None,
256 title: None,
257 notes: String::new(),
258 tags: vec![],
259 birthday: None,
260 timezone: None,
261 external_source: None,
262 external_id: None,
263 is_implicit: false,
264 emails: vec![],
265 phones: vec![],
266 social_handles: vec![],
267 custom_fields: vec![],
268 created_at: Utc::now(),
269 updated_at: Utc::now(),
270 }
271 }
272
273 #[test]
274 fn test_display_initials() {
275 let c = make_contact("Jane Smith");
276 assert_eq!(c.display_initials(), "JS");
277
278 let c = make_contact("Madonna");
279 assert_eq!(c.display_initials(), "M");
280
281 let c = make_contact("John Jacob Jingleheimer Schmidt");
282 assert_eq!(c.display_initials(), "JJ");
283 }
284
285 #[test]
286 fn test_primary_email() {
287 let mut c = make_contact("Test");
288 assert_eq!(c.primary_email(), None);
289
290 c.emails.push(ContactEmail {
291 id: ContactEmailId::new(),
292 contact_id: c.id,
293 address: "first@example.com".to_string(),
294 label: "Work".to_string(),
295 is_primary: false,
296 });
297 c.emails.push(ContactEmail {
298 id: ContactEmailId::new(),
299 contact_id: c.id,
300 address: "primary@example.com".to_string(),
301 label: "Personal".to_string(),
302 is_primary: true,
303 });
304
305 assert_eq!(c.primary_email(), Some("primary@example.com"));
306 }
307
308 #[test]
309 fn test_primary_email_fallback_to_first() {
310 let mut c = make_contact("Test");
311 c.emails.push(ContactEmail {
312 id: ContactEmailId::new(),
313 contact_id: c.id,
314 address: "only@example.com".to_string(),
315 label: String::new(),
316 is_primary: false,
317 });
318
319 assert_eq!(c.primary_email(), Some("only@example.com"));
320 }
321
322 #[test]
323 fn test_has_company() {
324 let mut c = make_contact("Test");
325 assert!(!c.has_company());
326
327 c.company = Some("Acme Corp".to_string());
328 assert!(c.has_company());
329
330 c.company = Some(String::new());
331 assert!(!c.has_company());
332 }
333
334 fn activity(kind: ActivityKind, secs: i64) -> ActivityItem {
335 ActivityItem {
336 kind,
337 id: format!("{secs}"),
338 title: format!("item {secs}"),
339 timestamp: DateTime::<Utc>::from_timestamp(secs, 0).expect("valid timestamp"),
340 date_formatted: String::new(),
341 status: None,
342 is_outgoing: false,
343 }
344 }
345
346 #[test]
347 fn merge_activity_empty_is_empty() {
348 assert!(merge_activity(vec![], 20).is_empty());
349 }
350
351 #[test]
352 fn merge_activity_sorts_newest_first_across_sources() {
353 let items = vec![
354 activity(ActivityKind::Task, 100),
355 activity(ActivityKind::Email, 300),
356 activity(ActivityKind::Event, 200),
357 ];
358 let merged = merge_activity(items, 20);
359 let order: Vec<i64> = merged.iter().map(|i| i.timestamp.timestamp()).collect();
360 assert_eq!(order, vec![300, 200, 100]);
361 assert_eq!(merged[0].kind, ActivityKind::Email);
362 assert_eq!(merged[2].kind, ActivityKind::Task);
363 }
364
365 #[test]
366 fn merge_activity_respects_cap() {
367 let items = (0..50).map(|s| activity(ActivityKind::Task, s)).collect();
368 let merged = merge_activity(items, 20);
369 assert_eq!(merged.len(), 20);
370 // Newest-first: the highest timestamps survive the cap.
371 assert_eq!(merged.first().unwrap().timestamp.timestamp(), 49);
372 assert_eq!(merged.last().unwrap().timestamp.timestamp(), 30);
373 }
374 }
375