Skip to main content

max / goingson

20.0 KB · 655 lines History Blame Raw
1 //! Contact management commands.
2 //!
3 //! Provides CRUD operations for contacts and their sub-collections
4 //! (emails, phones, social handles).
5
6 use chrono::{DateTime, Local, NaiveDate, Utc};
7 use serde::{Deserialize, Serialize};
8 use std::sync::Arc;
9 use tauri::State;
10 use tracing::instrument;
11
12 use goingson_core::{
13 Contact, ContactCustomField, ContactEmail, ContactEmailId, ContactId, ContactPhone,
14 ContactPhoneId, CustomFieldId, NewContact, NewContactCustomField, NewContactEmail,
15 NewContactPhone, NewSocialHandle, SocialHandle, SocialHandleId, UpdateContact, Validate,
16 };
17
18 use crate::state::{AppState, DESKTOP_USER_ID};
19 use super::{ApiError, OptionNotFound};
20
21 // ============ Response Types ============
22
23 /// Contact response with pre-computed fields for UI.
24 #[derive(Debug, Serialize)]
25 #[serde(rename_all = "camelCase")]
26 pub struct ContactResponse {
27 #[serde(flatten)]
28 pub contact: Contact,
29 /// Display initials (e.g., "JS" from "Jane Smith")
30 pub initials: String,
31 /// Primary email address (or first email as fallback)
32 pub primary_email: Option<String>,
33 /// Number of email addresses
34 pub email_count: usize,
35 /// Number of phone numbers
36 pub phone_count: usize,
37 }
38
39 impl From<Contact> for ContactResponse {
40 fn from(c: Contact) -> Self {
41 let initials = c.display_initials();
42 let primary_email = c.primary_email().map(|s| s.to_string());
43 let email_count = c.email_count();
44 let phone_count = c.phones.len();
45 ContactResponse {
46 contact: c,
47 initials,
48 primary_email,
49 email_count,
50 phone_count,
51 }
52 }
53 }
54
55 // ============ Input Types ============
56
57 #[derive(Debug, Deserialize)]
58 #[serde(rename_all = "camelCase")]
59 pub struct ContactInput {
60 pub display_name: String,
61 pub nickname: Option<String>,
62 pub company: Option<String>,
63 pub title: Option<String>,
64 pub notes: Option<String>,
65 pub tags: Option<Vec<String>>,
66 pub birthday: Option<String>,
67 pub timezone: Option<String>,
68 }
69
70 #[derive(Debug, Deserialize)]
71 #[serde(rename_all = "camelCase")]
72 pub struct ContactEmailInput {
73 pub address: String,
74 pub label: Option<String>,
75 pub is_primary: Option<bool>,
76 }
77
78 #[derive(Debug, Deserialize)]
79 #[serde(rename_all = "camelCase")]
80 pub struct ContactPhoneInput {
81 pub number: String,
82 pub label: Option<String>,
83 pub is_primary: Option<bool>,
84 }
85
86 #[derive(Debug, Deserialize)]
87 #[serde(rename_all = "camelCase")]
88 pub struct SocialHandleInput {
89 pub platform: String,
90 pub handle: String,
91 pub url: Option<String>,
92 }
93
94 #[derive(Debug, Deserialize)]
95 #[serde(rename_all = "camelCase")]
96 pub struct CustomFieldInput {
97 pub label: String,
98 pub value: String,
99 pub url: Option<String>,
100 }
101
102 // ============ Helper ============
103
104 fn parse_birthday(s: &str) -> Result<NaiveDate, ApiError> {
105 NaiveDate::parse_from_str(s, "%Y-%m-%d")
106 .map_err(|_| ApiError::validation("birthday", "Invalid date format, expected YYYY-MM-DD"))
107 }
108
109 // ============ Contact CRUD Commands ============
110
111 /// Lists all contacts.
112 #[tauri::command]
113 #[instrument(skip_all)]
114 pub async fn list_contacts(state: State<'_, Arc<AppState>>) -> Result<Vec<ContactResponse>, ApiError> {
115 Ok(state.contacts.list_all(DESKTOP_USER_ID).await?
116 .into_iter().map(ContactResponse::from).collect())
117 }
118
119 /// Retrieves a single contact by ID.
120 #[tauri::command]
121 #[instrument(skip_all)]
122 pub async fn get_contact(state: State<'_, Arc<AppState>>, id: ContactId) -> Result<Option<ContactResponse>, ApiError> {
123 Ok(state.contacts.get_by_id(id, DESKTOP_USER_ID).await?
124 .map(ContactResponse::from))
125 }
126
127 /// Creates a new contact.
128 #[tauri::command]
129 #[instrument(skip_all)]
130 pub async fn create_contact(state: State<'_, Arc<AppState>>, input: ContactInput) -> Result<ContactResponse, ApiError> {
131 let birthday = input.birthday.as_deref()
132 .filter(|s| !s.is_empty())
133 .map(parse_birthday)
134 .transpose()?;
135
136 let new_contact = NewContact {
137 display_name: input.display_name,
138 nickname: input.nickname,
139 company: input.company,
140 title: input.title,
141 notes: input.notes.unwrap_or_default(),
142 tags: input.tags.unwrap_or_default(),
143 birthday,
144 timezone: input.timezone,
145 is_implicit: false,
146 };
147
148 new_contact.validate()?;
149
150 Ok(ContactResponse::from(state.contacts.create(DESKTOP_USER_ID, new_contact).await?))
151 }
152
153 /// Updates an existing contact.
154 #[tauri::command]
155 #[instrument(skip_all)]
156 pub async fn update_contact(state: State<'_, Arc<AppState>>, id: ContactId, input: ContactInput) -> Result<ContactResponse, ApiError> {
157 let birthday = input.birthday.as_deref()
158 .filter(|s| !s.is_empty())
159 .map(parse_birthday)
160 .transpose()?;
161
162 let update = UpdateContact {
163 display_name: input.display_name,
164 nickname: input.nickname,
165 company: input.company,
166 title: input.title,
167 notes: input.notes.unwrap_or_default(),
168 tags: input.tags.unwrap_or_default(),
169 birthday,
170 timezone: input.timezone,
171 };
172
173 update.validate()?;
174
175 state.contacts
176 .update(id, DESKTOP_USER_ID, update)
177 .await?
178 .map(ContactResponse::from)
179 .or_not_found("contact", id)
180 }
181
182 /// Deletes a contact.
183 #[tauri::command]
184 #[instrument(skip_all)]
185 pub async fn delete_contact(state: State<'_, Arc<AppState>>, id: ContactId) -> Result<bool, ApiError> {
186 Ok(state.contacts.delete(id, DESKTOP_USER_ID).await?)
187 }
188
189 /// Deletes multiple contacts.
190 #[tauri::command]
191 #[instrument(skip_all)]
192 pub async fn bulk_delete_contacts(
193 state: State<'_, Arc<AppState>>,
194 ids: Vec<ContactId>,
195 ) -> Result<u64, ApiError> {
196 Ok(state.contacts.delete_many(&ids, DESKTOP_USER_ID).await?)
197 }
198
199 /// Adds a tag to multiple contacts.
200 #[tauri::command]
201 #[instrument(skip_all)]
202 pub async fn bulk_tag_contacts(
203 state: State<'_, Arc<AppState>>,
204 ids: Vec<ContactId>,
205 tag: String,
206 ) -> Result<u64, ApiError> {
207 Ok(state.contacts.tag_many(&ids, DESKTOP_USER_ID, &tag).await?)
208 }
209
210 // ============ Sub-collection Commands ============
211
212 /// Adds an email address to a contact.
213 #[tauri::command]
214 #[instrument(skip_all)]
215 pub async fn add_contact_email(
216 state: State<'_, Arc<AppState>>,
217 contact_id: ContactId,
218 input: ContactEmailInput,
219 ) -> Result<ContactEmail, ApiError> {
220 let email = NewContactEmail {
221 address: input.address,
222 label: input.label.unwrap_or_default(),
223 is_primary: input.is_primary.unwrap_or(false),
224 };
225 Ok(state.contacts.add_email(contact_id, DESKTOP_USER_ID, email).await?)
226 }
227
228 /// Removes an email address from a contact.
229 #[tauri::command]
230 #[instrument(skip_all)]
231 pub async fn remove_contact_email(
232 state: State<'_, Arc<AppState>>,
233 email_id: ContactEmailId,
234 ) -> Result<bool, ApiError> {
235 Ok(state.contacts.remove_email(email_id, DESKTOP_USER_ID).await?)
236 }
237
238 /// Adds a phone number to a contact.
239 #[tauri::command]
240 #[instrument(skip_all)]
241 pub async fn add_contact_phone(
242 state: State<'_, Arc<AppState>>,
243 contact_id: ContactId,
244 input: ContactPhoneInput,
245 ) -> Result<ContactPhone, ApiError> {
246 let phone = NewContactPhone {
247 number: input.number,
248 label: input.label.unwrap_or_default(),
249 is_primary: input.is_primary.unwrap_or(false),
250 };
251 Ok(state.contacts.add_phone(contact_id, DESKTOP_USER_ID, phone).await?)
252 }
253
254 /// Removes a phone number from a contact.
255 #[tauri::command]
256 #[instrument(skip_all)]
257 pub async fn remove_contact_phone(
258 state: State<'_, Arc<AppState>>,
259 phone_id: ContactPhoneId,
260 ) -> Result<bool, ApiError> {
261 Ok(state.contacts.remove_phone(phone_id, DESKTOP_USER_ID).await?)
262 }
263
264 /// Adds a social handle to a contact.
265 #[tauri::command]
266 #[instrument(skip_all)]
267 pub async fn add_contact_social_handle(
268 state: State<'_, Arc<AppState>>,
269 contact_id: ContactId,
270 input: SocialHandleInput,
271 ) -> Result<SocialHandle, ApiError> {
272 let handle = NewSocialHandle {
273 platform: input.platform,
274 handle: input.handle,
275 url: input.url,
276 };
277 Ok(state.contacts.add_social_handle(contact_id, DESKTOP_USER_ID, handle).await?)
278 }
279
280 /// Removes a social handle from a contact.
281 #[tauri::command]
282 #[instrument(skip_all)]
283 pub async fn remove_contact_social_handle(
284 state: State<'_, Arc<AppState>>,
285 handle_id: SocialHandleId,
286 ) -> Result<bool, ApiError> {
287 Ok(state.contacts.remove_social_handle(handle_id, DESKTOP_USER_ID).await?)
288 }
289
290 /// Adds a custom field to a contact.
291 #[tauri::command]
292 #[instrument(skip_all)]
293 pub async fn add_contact_custom_field(
294 state: State<'_, Arc<AppState>>,
295 contact_id: ContactId,
296 input: CustomFieldInput,
297 ) -> Result<ContactCustomField, ApiError> {
298 let field = NewContactCustomField {
299 label: input.label,
300 value: input.value,
301 url: input.url,
302 };
303 Ok(state.contacts.add_custom_field(contact_id, DESKTOP_USER_ID, field).await?)
304 }
305
306 /// Removes a custom field from a contact.
307 #[tauri::command]
308 #[instrument(skip_all)]
309 pub async fn remove_contact_custom_field(
310 state: State<'_, Arc<AppState>>,
311 field_id: CustomFieldId,
312 ) -> Result<bool, ApiError> {
313 Ok(state.contacts.remove_custom_field(field_id, DESKTOP_USER_ID).await?)
314 }
315
316 /// Updates an existing email address on a contact.
317 #[tauri::command]
318 #[instrument(skip_all)]
319 pub async fn update_contact_email(
320 state: State<'_, Arc<AppState>>,
321 email_id: ContactEmailId,
322 input: ContactEmailInput,
323 ) -> Result<ContactEmail, ApiError> {
324 let email = NewContactEmail {
325 address: input.address,
326 label: input.label.unwrap_or_default(),
327 is_primary: input.is_primary.unwrap_or(false),
328 };
329 state.contacts.update_email(email_id, DESKTOP_USER_ID, email).await?
330 .or_not_found("contact_email", email_id)
331 }
332
333 /// Updates an existing phone number on a contact.
334 #[tauri::command]
335 #[instrument(skip_all)]
336 pub async fn update_contact_phone(
337 state: State<'_, Arc<AppState>>,
338 phone_id: ContactPhoneId,
339 input: ContactPhoneInput,
340 ) -> Result<ContactPhone, ApiError> {
341 let phone = NewContactPhone {
342 number: input.number,
343 label: input.label.unwrap_or_default(),
344 is_primary: input.is_primary.unwrap_or(false),
345 };
346 state.contacts.update_phone(phone_id, DESKTOP_USER_ID, phone).await?
347 .or_not_found("contact_phone", phone_id)
348 }
349
350 /// Updates an existing social handle on a contact.
351 #[tauri::command]
352 #[instrument(skip_all)]
353 pub async fn update_contact_social_handle(
354 state: State<'_, Arc<AppState>>,
355 handle_id: SocialHandleId,
356 input: SocialHandleInput,
357 ) -> Result<SocialHandle, ApiError> {
358 let handle = NewSocialHandle {
359 platform: input.platform,
360 handle: input.handle,
361 url: input.url,
362 };
363 state.contacts.update_social_handle(handle_id, DESKTOP_USER_ID, handle).await?
364 .or_not_found("social_handle", handle_id)
365 }
366
367 /// Updates an existing custom field on a contact.
368 #[tauri::command]
369 #[instrument(skip_all)]
370 pub async fn update_contact_custom_field(
371 state: State<'_, Arc<AppState>>,
372 field_id: CustomFieldId,
373 input: CustomFieldInput,
374 ) -> Result<ContactCustomField, ApiError> {
375 let field = NewContactCustomField {
376 label: input.label,
377 value: input.value,
378 url: input.url,
379 };
380 state.contacts.update_custom_field(field_id, DESKTOP_USER_ID, field).await?
381 .or_not_found("contact_custom_field", field_id)
382 }
383
384 /// Lists contacts filtered by search query and/or tag.
385 /// Searches across display name, nickname, company, title, notes, and email addresses.
386 #[tauri::command]
387 #[instrument(skip_all)]
388 pub async fn list_contacts_filtered(
389 state: State<'_, Arc<AppState>>,
390 search: Option<String>,
391 tag: Option<String>,
392 include_implicit: Option<bool>,
393 ) -> Result<Vec<ContactResponse>, ApiError> {
394 let contacts = state.contacts
395 .list_filtered(
396 DESKTOP_USER_ID,
397 search.as_deref(),
398 tag.as_deref(),
399 include_implicit.unwrap_or(false),
400 )
401 .await?;
402 Ok(contacts.into_iter().map(ContactResponse::from).collect())
403 }
404
405 /// Lightweight name+email directory for compose autocomplete.
406 ///
407 /// Avoids the full contact hydration (4 sub-queries per contact) that
408 /// `list_contacts_filtered` performs; returns one entry per email address.
409 #[tauri::command]
410 #[instrument(skip_all)]
411 pub async fn list_contact_email_directory(
412 state: State<'_, Arc<AppState>>,
413 include_implicit: Option<bool>,
414 ) -> Result<Vec<goingson_core::ContactEmailEntry>, ApiError> {
415 Ok(state.contacts
416 .list_email_directory(DESKTOP_USER_ID, include_implicit.unwrap_or(false))
417 .await?)
418 }
419
420 /// Finds a contact by email address (case-insensitive).
421 #[tauri::command]
422 #[instrument(skip_all)]
423 pub async fn find_contact_by_email(
424 state: State<'_, Arc<AppState>>,
425 email: String,
426 ) -> Result<Option<ContactResponse>, ApiError> {
427 Ok(state.contacts.find_by_email(DESKTOP_USER_ID, &email).await?
428 .map(ContactResponse::from))
429 }
430
431 /// Validation status for a single email address.
432 #[derive(Debug, Serialize)]
433 #[serde(rename_all = "camelCase")]
434 pub struct AddressValidation {
435 pub email: String,
436 /// "malformed" | "valid" | "contact" | "verified"
437 pub status: String,
438 }
439
440 /// Batch-validate email addresses: format check, contact lookup, sender history.
441 #[tauri::command]
442 #[instrument(skip_all)]
443 pub async fn validate_email_addresses(
444 state: State<'_, Arc<AppState>>,
445 addresses: Vec<String>,
446 ) -> Result<Vec<AddressValidation>, ApiError> {
447 use goingson_db_sqlite::utils::is_valid_email;
448
449 let mut results = Vec::with_capacity(addresses.len());
450 let mut valid_addrs: Vec<String> = Vec::new();
451
452 // Phase 1: format validation
453 for addr in &addresses {
454 let trimmed = addr.trim().to_string();
455 if trimmed.is_empty() {
456 continue;
457 }
458 if is_valid_email(&trimmed) {
459 valid_addrs.push(trimmed);
460 } else {
461 results.push(AddressValidation {
462 email: trimmed,
463 status: "malformed".to_string(),
464 });
465 }
466 }
467
468 if valid_addrs.is_empty() {
469 return Ok(results);
470 }
471
472 // Phase 2: batch lookups
473 let addr_refs: Vec<&str> = valid_addrs.iter().map(|s| s.as_str()).collect();
474 let contact_set = state.contacts.find_emails_in_contacts(DESKTOP_USER_ID, &addr_refs).await?;
475 let sender_set = state.emails.exists_as_senders(DESKTOP_USER_ID, &addr_refs).await?;
476
477 // Phase 3: assign highest status
478 for addr in valid_addrs {
479 let lower = addr.to_lowercase();
480 let status = if sender_set.contains(&lower) {
481 "verified"
482 } else if contact_set.contains(&lower) {
483 "contact"
484 } else {
485 "valid"
486 };
487 results.push(AddressValidation {
488 email: addr,
489 status: status.to_string(),
490 });
491 }
492
493 Ok(results)
494 }
495
496 /// Promotes an implicit contact to explicit (visible in the contacts UI).
497 #[tauri::command]
498 #[instrument(skip_all)]
499 pub async fn promote_contact(
500 state: State<'_, Arc<AppState>>,
501 id: ContactId,
502 ) -> Result<ContactResponse, ApiError> {
503 state.contacts
504 .promote_contact(id, DESKTOP_USER_ID)
505 .await?
506 .map(ContactResponse::from)
507 .or_not_found("contact", id)
508 }
509
510 /// Lists tasks linked to a specific contact.
511 #[tauri::command]
512 #[instrument(skip_all)]
513 pub async fn list_tasks_for_contact(
514 state: State<'_, Arc<AppState>>,
515 contact_id: ContactId,
516 ) -> Result<Vec<super::TaskResponse>, ApiError> {
517 let tasks = state.tasks.list_by_contact(DESKTOP_USER_ID, contact_id).await?;
518 Ok(tasks.into_iter().map(super::TaskResponse::from).collect())
519 }
520
521 /// Lists events linked to a specific contact.
522 #[tauri::command]
523 #[instrument(skip_all)]
524 pub async fn list_events_for_contact(
525 state: State<'_, Arc<AppState>>,
526 contact_id: ContactId,
527 ) -> Result<Vec<super::EventResponse>, ApiError> {
528 let events = state.events.list_by_contact(DESKTOP_USER_ID, contact_id).await?;
529 Ok(events.into_iter().map(super::EventResponse::from).collect())
530 }
531
532 /// Lists emails sent from or to a specific contact's email addresses.
533 #[tauri::command]
534 #[instrument(skip_all)]
535 pub async fn list_emails_for_contact(
536 state: State<'_, Arc<AppState>>,
537 contact_id: ContactId,
538 ) -> Result<Vec<super::EmailResponse>, ApiError> {
539 let contact = state.contacts.get_by_id(contact_id, DESKTOP_USER_ID).await?
540 .or_not_found("contact", contact_id)?;
541
542 let addresses: Vec<String> = contact.emails.iter().map(|e| e.address.clone()).collect();
543 if addresses.is_empty() {
544 return Ok(Vec::new());
545 }
546
547 let addr_refs: Vec<&str> = addresses.iter().map(|s| s.as_str()).collect();
548 let emails = state.emails.list_by_addresses(DESKTOP_USER_ID, &addr_refs).await?;
549 Ok(emails.into_iter().map(super::EmailResponse::from).collect())
550 }
551
552 /// Unified, pre-sorted, pre-formatted activity feed for a contact.
553 #[derive(Debug, Serialize)]
554 #[serde(rename_all = "camelCase")]
555 pub struct ContactActivityResponse {
556 /// Newest-first, capped merge of the contact's tasks, events, and emails.
557 pub items: Vec<goingson_core::ActivityItem>,
558 /// Total linked task count (may exceed the number of task rows in `items`).
559 pub task_count: usize,
560 /// Total linked event count.
561 pub event_count: usize,
562 /// Total linked email count.
563 pub email_count: usize,
564 }
565
566 /// Builds the unified activity timeline (tasks + events + emails) for a contact.
567 ///
568 /// Merges the same three sources the dashboard previously stitched together in
569 /// JS, sorts newest-first, pre-formats each row's display date in local time,
570 /// and caps the feed server-side so the frontend renders it verbatim.
571 #[tauri::command]
572 #[instrument(skip_all)]
573 pub async fn get_contact_activity(
574 state: State<'_, Arc<AppState>>,
575 contact_id: ContactId,
576 limit: Option<u32>,
577 ) -> Result<ContactActivityResponse, ApiError> {
578 use goingson_core::{ActivityItem, ActivityKind, merge_activity};
579
580 let contact = state.contacts.get_by_id(contact_id, DESKTOP_USER_ID).await?
581 .or_not_found("contact", contact_id)?;
582
583 let tasks = state.tasks.list_by_contact(DESKTOP_USER_ID, contact_id).await?;
584 let events = state.events.list_by_contact(DESKTOP_USER_ID, contact_id).await?;
585
586 let addresses: Vec<String> = contact.emails.iter().map(|e| e.address.clone()).collect();
587 let emails = if addresses.is_empty() {
588 Vec::new()
589 } else {
590 let addr_refs: Vec<&str> = addresses.iter().map(|s| s.as_str()).collect();
591 state.emails.list_by_addresses(DESKTOP_USER_ID, &addr_refs).await?
592 };
593
594 let task_count = tasks.len();
595 let event_count = events.len();
596 let email_count = emails.len();
597
598 // Pre-format the display date in local time (matches the old toLocaleDateString).
599 fn fmt_date(ts: DateTime<Utc>) -> String {
600 ts.with_timezone(&Local).format("%b %-d, %Y").to_string()
601 }
602
603 let mut items: Vec<ActivityItem> =
604 Vec::with_capacity(task_count + event_count + email_count);
605
606 for t in tasks {
607 items.push(ActivityItem {
608 kind: ActivityKind::Task,
609 id: t.id.to_string(),
610 title: t.description,
611 date_formatted: fmt_date(t.created_at),
612 timestamp: t.created_at,
613 status: Some(t.status.as_str().to_string()),
614 is_outgoing: false,
615 });
616 }
617
618 for e in events {
619 items.push(ActivityItem {
620 kind: ActivityKind::Event,
621 id: e.id.to_string(),
622 title: e.title,
623 date_formatted: fmt_date(e.start_time),
624 timestamp: e.start_time,
625 status: None,
626 is_outgoing: false,
627 });
628 }
629
630 for e in emails {
631 let title = if e.subject.trim().is_empty() {
632 "(no subject)".to_string()
633 } else {
634 e.subject
635 };
636 items.push(ActivityItem {
637 kind: ActivityKind::Email,
638 id: e.id.to_string(),
639 title,
640 date_formatted: fmt_date(e.received_at),
641 timestamp: e.received_at,
642 status: None,
643 is_outgoing: e.is_outgoing,
644 });
645 }
646
647 let cap = limit.map(|n| n as usize).unwrap_or(usize::MAX);
648 Ok(ContactActivityResponse {
649 items: merge_activity(items, cap),
650 task_count,
651 event_count,
652 email_count,
653 })
654 }
655