Skip to main content

max / goingson

16.0 KB · 536 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::NaiveDate;
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 /// Finds a contact by email address (case-insensitive).
406 #[tauri::command]
407 #[instrument(skip_all)]
408 pub async fn find_contact_by_email(
409 state: State<'_, Arc<AppState>>,
410 email: String,
411 ) -> Result<Option<ContactResponse>, ApiError> {
412 Ok(state.contacts.find_by_email(DESKTOP_USER_ID, &email).await?
413 .map(ContactResponse::from))
414 }
415
416 /// Validation status for a single email address.
417 #[derive(Debug, Serialize)]
418 #[serde(rename_all = "camelCase")]
419 pub struct AddressValidation {
420 pub email: String,
421 /// "malformed" | "valid" | "contact" | "verified"
422 pub status: String,
423 }
424
425 /// Batch-validate email addresses: format check, contact lookup, sender history.
426 #[tauri::command]
427 #[instrument(skip_all)]
428 pub async fn validate_email_addresses(
429 state: State<'_, Arc<AppState>>,
430 addresses: Vec<String>,
431 ) -> Result<Vec<AddressValidation>, ApiError> {
432 use goingson_db_sqlite::utils::is_valid_email;
433
434 let mut results = Vec::with_capacity(addresses.len());
435 let mut valid_addrs: Vec<String> = Vec::new();
436
437 // Phase 1: format validation
438 for addr in &addresses {
439 let trimmed = addr.trim().to_string();
440 if trimmed.is_empty() {
441 continue;
442 }
443 if is_valid_email(&trimmed) {
444 valid_addrs.push(trimmed);
445 } else {
446 results.push(AddressValidation {
447 email: trimmed,
448 status: "malformed".to_string(),
449 });
450 }
451 }
452
453 if valid_addrs.is_empty() {
454 return Ok(results);
455 }
456
457 // Phase 2: batch lookups
458 let addr_refs: Vec<&str> = valid_addrs.iter().map(|s| s.as_str()).collect();
459 let contact_set = state.contacts.find_emails_in_contacts(DESKTOP_USER_ID, &addr_refs).await?;
460 let sender_set = state.emails.exists_as_senders(DESKTOP_USER_ID, &addr_refs).await?;
461
462 // Phase 3: assign highest status
463 for addr in valid_addrs {
464 let lower = addr.to_lowercase();
465 let status = if sender_set.contains(&lower) {
466 "verified"
467 } else if contact_set.contains(&lower) {
468 "contact"
469 } else {
470 "valid"
471 };
472 results.push(AddressValidation {
473 email: addr,
474 status: status.to_string(),
475 });
476 }
477
478 Ok(results)
479 }
480
481 /// Promotes an implicit contact to explicit (visible in the contacts UI).
482 #[tauri::command]
483 #[instrument(skip_all)]
484 pub async fn promote_contact(
485 state: State<'_, Arc<AppState>>,
486 id: ContactId,
487 ) -> Result<ContactResponse, ApiError> {
488 state.contacts
489 .promote_contact(id, DESKTOP_USER_ID)
490 .await?
491 .map(ContactResponse::from)
492 .or_not_found("contact", id)
493 }
494
495 /// Lists tasks linked to a specific contact.
496 #[tauri::command]
497 #[instrument(skip_all)]
498 pub async fn list_tasks_for_contact(
499 state: State<'_, Arc<AppState>>,
500 contact_id: ContactId,
501 ) -> Result<Vec<super::TaskResponse>, ApiError> {
502 let tasks = state.tasks.list_by_contact(DESKTOP_USER_ID, contact_id).await?;
503 Ok(tasks.into_iter().map(super::TaskResponse::from).collect())
504 }
505
506 /// Lists events linked to a specific contact.
507 #[tauri::command]
508 #[instrument(skip_all)]
509 pub async fn list_events_for_contact(
510 state: State<'_, Arc<AppState>>,
511 contact_id: ContactId,
512 ) -> Result<Vec<super::EventResponse>, ApiError> {
513 let events = state.events.list_by_contact(DESKTOP_USER_ID, contact_id).await?;
514 Ok(events.into_iter().map(super::EventResponse::from).collect())
515 }
516
517 /// Lists emails sent from or to a specific contact's email addresses.
518 #[tauri::command]
519 #[instrument(skip_all)]
520 pub async fn list_emails_for_contact(
521 state: State<'_, Arc<AppState>>,
522 contact_id: ContactId,
523 ) -> Result<Vec<super::EmailResponse>, ApiError> {
524 let contact = state.contacts.get_by_id(contact_id, DESKTOP_USER_ID).await?
525 .or_not_found("contact", contact_id)?;
526
527 let addresses: Vec<String> = contact.emails.iter().map(|e| e.address.clone()).collect();
528 if addresses.is_empty() {
529 return Ok(Vec::new());
530 }
531
532 let addr_refs: Vec<&str> = addresses.iter().map(|s| s.as_str()).collect();
533 let emails = state.emails.list_by_addresses(DESKTOP_USER_ID, &addr_refs).await?;
534 Ok(emails.into_iter().map(super::EmailResponse::from).collect())
535 }
536