//! Contact management commands. //! //! Provides CRUD operations for contacts and their sub-collections //! (emails, phones, social handles). use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; use tracing::instrument; use goingson_core::{ Contact, ContactCustomField, ContactEmail, ContactEmailId, ContactId, ContactPhone, ContactPhoneId, CustomFieldId, NewContact, NewContactCustomField, NewContactEmail, NewContactPhone, NewSocialHandle, SocialHandle, SocialHandleId, UpdateContact, Validate, }; use crate::state::{AppState, DESKTOP_USER_ID}; use super::{ApiError, OptionNotFound}; // ============ Response Types ============ /// Contact response with pre-computed fields for UI. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ContactResponse { #[serde(flatten)] pub contact: Contact, /// Display initials (e.g., "JS" from "Jane Smith") pub initials: String, /// Primary email address (or first email as fallback) pub primary_email: Option, /// Number of email addresses pub email_count: usize, /// Number of phone numbers pub phone_count: usize, } impl From for ContactResponse { fn from(c: Contact) -> Self { let initials = c.display_initials(); let primary_email = c.primary_email().map(|s| s.to_string()); let email_count = c.email_count(); let phone_count = c.phones.len(); ContactResponse { contact: c, initials, primary_email, email_count, phone_count, } } } // ============ Input Types ============ #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ContactInput { pub display_name: String, pub nickname: Option, pub company: Option, pub title: Option, pub notes: Option, pub tags: Option>, pub birthday: Option, pub timezone: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ContactEmailInput { pub address: String, pub label: Option, pub is_primary: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ContactPhoneInput { pub number: String, pub label: Option, pub is_primary: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SocialHandleInput { pub platform: String, pub handle: String, pub url: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CustomFieldInput { pub label: String, pub value: String, pub url: Option, } // ============ Helper ============ fn parse_birthday(s: &str) -> Result { NaiveDate::parse_from_str(s, "%Y-%m-%d") .map_err(|_| ApiError::validation("birthday", "Invalid date format, expected YYYY-MM-DD")) } // ============ Contact CRUD Commands ============ /// Lists all contacts. #[tauri::command] #[instrument(skip_all)] pub async fn list_contacts(state: State<'_, Arc>) -> Result, ApiError> { Ok(state.contacts.list_all(DESKTOP_USER_ID).await? .into_iter().map(ContactResponse::from).collect()) } /// Retrieves a single contact by ID. #[tauri::command] #[instrument(skip_all)] pub async fn get_contact(state: State<'_, Arc>, id: ContactId) -> Result, ApiError> { Ok(state.contacts.get_by_id(id, DESKTOP_USER_ID).await? .map(ContactResponse::from)) } /// Creates a new contact. #[tauri::command] #[instrument(skip_all)] pub async fn create_contact(state: State<'_, Arc>, input: ContactInput) -> Result { let birthday = input.birthday.as_deref() .filter(|s| !s.is_empty()) .map(parse_birthday) .transpose()?; let new_contact = NewContact { display_name: input.display_name, nickname: input.nickname, company: input.company, title: input.title, notes: input.notes.unwrap_or_default(), tags: input.tags.unwrap_or_default(), birthday, timezone: input.timezone, is_implicit: false, }; new_contact.validate()?; Ok(ContactResponse::from(state.contacts.create(DESKTOP_USER_ID, new_contact).await?)) } /// Updates an existing contact. #[tauri::command] #[instrument(skip_all)] pub async fn update_contact(state: State<'_, Arc>, id: ContactId, input: ContactInput) -> Result { let birthday = input.birthday.as_deref() .filter(|s| !s.is_empty()) .map(parse_birthday) .transpose()?; let update = UpdateContact { display_name: input.display_name, nickname: input.nickname, company: input.company, title: input.title, notes: input.notes.unwrap_or_default(), tags: input.tags.unwrap_or_default(), birthday, timezone: input.timezone, }; update.validate()?; state.contacts .update(id, DESKTOP_USER_ID, update) .await? .map(ContactResponse::from) .or_not_found("contact", id) } /// Deletes a contact. #[tauri::command] #[instrument(skip_all)] pub async fn delete_contact(state: State<'_, Arc>, id: ContactId) -> Result { Ok(state.contacts.delete(id, DESKTOP_USER_ID).await?) } /// Deletes multiple contacts. #[tauri::command] #[instrument(skip_all)] pub async fn bulk_delete_contacts( state: State<'_, Arc>, ids: Vec, ) -> Result { Ok(state.contacts.delete_many(&ids, DESKTOP_USER_ID).await?) } /// Adds a tag to multiple contacts. #[tauri::command] #[instrument(skip_all)] pub async fn bulk_tag_contacts( state: State<'_, Arc>, ids: Vec, tag: String, ) -> Result { Ok(state.contacts.tag_many(&ids, DESKTOP_USER_ID, &tag).await?) } // ============ Sub-collection Commands ============ /// Adds an email address to a contact. #[tauri::command] #[instrument(skip_all)] pub async fn add_contact_email( state: State<'_, Arc>, contact_id: ContactId, input: ContactEmailInput, ) -> Result { let email = NewContactEmail { address: input.address, label: input.label.unwrap_or_default(), is_primary: input.is_primary.unwrap_or(false), }; Ok(state.contacts.add_email(contact_id, DESKTOP_USER_ID, email).await?) } /// Removes an email address from a contact. #[tauri::command] #[instrument(skip_all)] pub async fn remove_contact_email( state: State<'_, Arc>, email_id: ContactEmailId, ) -> Result { Ok(state.contacts.remove_email(email_id, DESKTOP_USER_ID).await?) } /// Adds a phone number to a contact. #[tauri::command] #[instrument(skip_all)] pub async fn add_contact_phone( state: State<'_, Arc>, contact_id: ContactId, input: ContactPhoneInput, ) -> Result { let phone = NewContactPhone { number: input.number, label: input.label.unwrap_or_default(), is_primary: input.is_primary.unwrap_or(false), }; Ok(state.contacts.add_phone(contact_id, DESKTOP_USER_ID, phone).await?) } /// Removes a phone number from a contact. #[tauri::command] #[instrument(skip_all)] pub async fn remove_contact_phone( state: State<'_, Arc>, phone_id: ContactPhoneId, ) -> Result { Ok(state.contacts.remove_phone(phone_id, DESKTOP_USER_ID).await?) } /// Adds a social handle to a contact. #[tauri::command] #[instrument(skip_all)] pub async fn add_contact_social_handle( state: State<'_, Arc>, contact_id: ContactId, input: SocialHandleInput, ) -> Result { let handle = NewSocialHandle { platform: input.platform, handle: input.handle, url: input.url, }; Ok(state.contacts.add_social_handle(contact_id, DESKTOP_USER_ID, handle).await?) } /// Removes a social handle from a contact. #[tauri::command] #[instrument(skip_all)] pub async fn remove_contact_social_handle( state: State<'_, Arc>, handle_id: SocialHandleId, ) -> Result { Ok(state.contacts.remove_social_handle(handle_id, DESKTOP_USER_ID).await?) } /// Adds a custom field to a contact. #[tauri::command] #[instrument(skip_all)] pub async fn add_contact_custom_field( state: State<'_, Arc>, contact_id: ContactId, input: CustomFieldInput, ) -> Result { let field = NewContactCustomField { label: input.label, value: input.value, url: input.url, }; Ok(state.contacts.add_custom_field(contact_id, DESKTOP_USER_ID, field).await?) } /// Removes a custom field from a contact. #[tauri::command] #[instrument(skip_all)] pub async fn remove_contact_custom_field( state: State<'_, Arc>, field_id: CustomFieldId, ) -> Result { Ok(state.contacts.remove_custom_field(field_id, DESKTOP_USER_ID).await?) } /// Updates an existing email address on a contact. #[tauri::command] #[instrument(skip_all)] pub async fn update_contact_email( state: State<'_, Arc>, email_id: ContactEmailId, input: ContactEmailInput, ) -> Result { let email = NewContactEmail { address: input.address, label: input.label.unwrap_or_default(), is_primary: input.is_primary.unwrap_or(false), }; state.contacts.update_email(email_id, DESKTOP_USER_ID, email).await? .or_not_found("contact_email", email_id) } /// Updates an existing phone number on a contact. #[tauri::command] #[instrument(skip_all)] pub async fn update_contact_phone( state: State<'_, Arc>, phone_id: ContactPhoneId, input: ContactPhoneInput, ) -> Result { let phone = NewContactPhone { number: input.number, label: input.label.unwrap_or_default(), is_primary: input.is_primary.unwrap_or(false), }; state.contacts.update_phone(phone_id, DESKTOP_USER_ID, phone).await? .or_not_found("contact_phone", phone_id) } /// Updates an existing social handle on a contact. #[tauri::command] #[instrument(skip_all)] pub async fn update_contact_social_handle( state: State<'_, Arc>, handle_id: SocialHandleId, input: SocialHandleInput, ) -> Result { let handle = NewSocialHandle { platform: input.platform, handle: input.handle, url: input.url, }; state.contacts.update_social_handle(handle_id, DESKTOP_USER_ID, handle).await? .or_not_found("social_handle", handle_id) } /// Updates an existing custom field on a contact. #[tauri::command] #[instrument(skip_all)] pub async fn update_contact_custom_field( state: State<'_, Arc>, field_id: CustomFieldId, input: CustomFieldInput, ) -> Result { let field = NewContactCustomField { label: input.label, value: input.value, url: input.url, }; state.contacts.update_custom_field(field_id, DESKTOP_USER_ID, field).await? .or_not_found("contact_custom_field", field_id) } /// Lists contacts filtered by search query and/or tag. /// Searches across display name, nickname, company, title, notes, and email addresses. #[tauri::command] #[instrument(skip_all)] pub async fn list_contacts_filtered( state: State<'_, Arc>, search: Option, tag: Option, include_implicit: Option, ) -> Result, ApiError> { let contacts = state.contacts .list_filtered( DESKTOP_USER_ID, search.as_deref(), tag.as_deref(), include_implicit.unwrap_or(false), ) .await?; Ok(contacts.into_iter().map(ContactResponse::from).collect()) } /// Finds a contact by email address (case-insensitive). #[tauri::command] #[instrument(skip_all)] pub async fn find_contact_by_email( state: State<'_, Arc>, email: String, ) -> Result, ApiError> { Ok(state.contacts.find_by_email(DESKTOP_USER_ID, &email).await? .map(ContactResponse::from)) } /// Validation status for a single email address. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct AddressValidation { pub email: String, /// "malformed" | "valid" | "contact" | "verified" pub status: String, } /// Batch-validate email addresses: format check, contact lookup, sender history. #[tauri::command] #[instrument(skip_all)] pub async fn validate_email_addresses( state: State<'_, Arc>, addresses: Vec, ) -> Result, ApiError> { use goingson_db_sqlite::utils::is_valid_email; let mut results = Vec::with_capacity(addresses.len()); let mut valid_addrs: Vec = Vec::new(); // Phase 1: format validation for addr in &addresses { let trimmed = addr.trim().to_string(); if trimmed.is_empty() { continue; } if is_valid_email(&trimmed) { valid_addrs.push(trimmed); } else { results.push(AddressValidation { email: trimmed, status: "malformed".to_string(), }); } } if valid_addrs.is_empty() { return Ok(results); } // Phase 2: batch lookups let addr_refs: Vec<&str> = valid_addrs.iter().map(|s| s.as_str()).collect(); let contact_set = state.contacts.find_emails_in_contacts(DESKTOP_USER_ID, &addr_refs).await?; let sender_set = state.emails.exists_as_senders(DESKTOP_USER_ID, &addr_refs).await?; // Phase 3: assign highest status for addr in valid_addrs { let lower = addr.to_lowercase(); let status = if sender_set.contains(&lower) { "verified" } else if contact_set.contains(&lower) { "contact" } else { "valid" }; results.push(AddressValidation { email: addr, status: status.to_string(), }); } Ok(results) } /// Promotes an implicit contact to explicit (visible in the contacts UI). #[tauri::command] #[instrument(skip_all)] pub async fn promote_contact( state: State<'_, Arc>, id: ContactId, ) -> Result { state.contacts .promote_contact(id, DESKTOP_USER_ID) .await? .map(ContactResponse::from) .or_not_found("contact", id) } /// Lists tasks linked to a specific contact. #[tauri::command] #[instrument(skip_all)] pub async fn list_tasks_for_contact( state: State<'_, Arc>, contact_id: ContactId, ) -> Result, ApiError> { let tasks = state.tasks.list_by_contact(DESKTOP_USER_ID, contact_id).await?; Ok(tasks.into_iter().map(super::TaskResponse::from).collect()) } /// Lists events linked to a specific contact. #[tauri::command] #[instrument(skip_all)] pub async fn list_events_for_contact( state: State<'_, Arc>, contact_id: ContactId, ) -> Result, ApiError> { let events = state.events.list_by_contact(DESKTOP_USER_ID, contact_id).await?; Ok(events.into_iter().map(super::EventResponse::from).collect()) } /// Lists emails sent from or to a specific contact's email addresses. #[tauri::command] #[instrument(skip_all)] pub async fn list_emails_for_contact( state: State<'_, Arc>, contact_id: ContactId, ) -> Result, ApiError> { let contact = state.contacts.get_by_id(contact_id, DESKTOP_USER_ID).await? .or_not_found("contact", contact_id)?; let addresses: Vec = contact.emails.iter().map(|e| e.address.clone()).collect(); if addresses.is_empty() { return Ok(Vec::new()); } let addr_refs: Vec<&str> = addresses.iter().map(|s| s.as_str()).collect(); let emails = state.emails.list_by_addresses(DESKTOP_USER_ID, &addr_refs).await?; Ok(emails.into_iter().map(super::EmailResponse::from).collect()) }