use crate::db::pool::{Connection, params}; use crate::queries::sqlite::queries::db_contacts::CONTACT_SELECT_COLS; use serde::Serialize; use uuid::Uuid; #[derive(Debug, Clone, Serialize)] pub struct KnownAddress { pub email: String, pub name: String, } #[derive(Debug, Clone, Serialize)] pub struct Contact { pub id: String, pub email: String, pub name: String, pub company: String, pub notes: String, pub phone: String, pub job_title: String, pub address: String, pub website: String, pub birthday: String, pub emails: Vec, pub phones: Vec, pub addresses: Vec, pub is_favorite: bool, pub last_contacted: Option, pub contact_count: i64, pub source: String, pub created_at: String, pub updated_at: String, pub has_photo: bool, } fn row_to_contact(row: &crate::db::pool::Row<'_>) -> Result { Ok(Contact { id: row.get(0)?, email: row.get(1)?, name: row.get(2)?, company: row.get(3)?, notes: row.get(4)?, phone: row.get(5)?, job_title: row.get(6)?, address: row.get(7)?, website: row.get(8)?, birthday: row.get(9)?, emails: serde_json::from_str::>(&row.get::<_, String>(16)?).unwrap_or_default(), phones: serde_json::from_str::>(&row.get::<_, String>(17)?).unwrap_or_default(), addresses: serde_json::from_str::>(&row.get::<_, String>(18)?) .unwrap_or_default(), is_favorite: row.get::<_, i32>(10)? != 0, last_contacted: row.get(11)?, contact_count: row.get(12)?, source: row.get(13)?, created_at: row.get(14)?, updated_at: row.get(15)?, has_photo: row.get::<_, i32>(19)? != 0, }) } fn escape_like(term: &str) -> String { term.replace('!', "!!") .replace('%', "!%") .replace('_', "!_") } pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String> { conn.execute( crate::dbq!(db_contacts::Q_001), params![ contact.id, contact.email, contact.name, contact.company, contact.notes, contact.phone, contact.job_title, contact.address, contact.website, contact.birthday, contact.is_favorite as i32, contact.last_contacted, contact.contact_count, contact.source, contact.created_at, contact.updated_at, serde_json::to_string(&contact.emails).unwrap_or_else(|_| "[]".to_string()), serde_json::to_string(&contact.phones).unwrap_or_else(|_| "[]".to_string()), serde_json::to_string(&contact.addresses).unwrap_or_else(|_| "[]".to_string()), ], ) .map_err(|e| format!("Failed to upsert contact: {e}"))?; Ok(()) } pub fn get_contact(conn: &Connection, id: &str) -> Result, String> { let sql = crate::dbq!(db_contacts::FORMAT_001).replace("{CONTACT_SELECT_COLS}", CONTACT_SELECT_COLS); let result = conn.query_row(&sql, params![id], row_to_contact); match result { Ok(c) => Ok(Some(c)), Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None), Err(e) => Err(format!("Failed to get contact: {e}")), } } pub fn get_contact_by_email(conn: &Connection, email: &str) -> Result, String> { let sql = crate::dbq!(db_contacts::FORMAT_002).replace("{CONTACT_SELECT_COLS}", CONTACT_SELECT_COLS); let result = conn.query_row(&sql, params![email], row_to_contact); match result { Ok(c) => Ok(Some(c)), Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None), Err(e) => Err(format!("Failed to get contact by email: {e}")), } } pub fn list_contacts( conn: &Connection, search: Option<&str>, limit: u32, offset: u32, ) -> Result, String> { let (sql, has_search) = match search { Some(q) if !q.is_empty() => { let escaped = escape_like(q); let pattern = format!("%{escaped}%"); let sql = crate::dbq!(db_contacts::FORMAT_003) .replace("{CONTACT_SELECT_COLS}", CONTACT_SELECT_COLS); drop(pattern); (sql, Some(escaped)) } _ => { let sql = crate::dbq!(db_contacts::FORMAT_004) .replace("{CONTACT_SELECT_COLS}", CONTACT_SELECT_COLS); (sql, None) } }; match has_search { Some(escaped) => { let pattern = format!("%{escaped}%"); let mut stmt = conn .prepare(&sql) .map_err(|e| format!("Failed to prepare list_contacts: {e}"))?; let rows = stmt .query_map(params![pattern, limit, offset], row_to_contact) .map_err(|e| format!("Failed to query contacts: {e}"))?; let mut contacts = Vec::new(); for row in rows { contacts.push(row.map_err(|e| format!("Failed to read contact row: {e}"))?); } Ok(contacts) } None => { let mut stmt = conn .prepare(&sql) .map_err(|e| format!("Failed to prepare list_contacts: {e}"))?; let rows = stmt .query_map(params![limit, offset], row_to_contact) .map_err(|e| format!("Failed to query contacts: {e}"))?; let mut contacts = Vec::new(); for row in rows { contacts.push(row.map_err(|e| format!("Failed to read contact row: {e}"))?); } Ok(contacts) } } } pub fn delete_contact(conn: &Connection, id: &str) -> Result { let deleted = conn .execute(crate::dbq!(db_contacts::Q_002), params![id]) .map_err(|e| format!("Failed to delete contact: {e}"))?; Ok(deleted > 0) } pub fn search_contacts(conn: &Connection, query: &str, limit: u32) -> Result, String> { let escaped = escape_like(query); let pattern = format!("%{escaped}%"); let sql = crate::dbq!(db_contacts::FORMAT_005).replace("{CONTACT_SELECT_COLS}", CONTACT_SELECT_COLS); let mut stmt = conn .prepare(&sql) .map_err(|e| format!("Failed to prepare search_contacts: {e}"))?; let rows = stmt .query_map(params![pattern, limit], row_to_contact) .map_err(|e| format!("Failed to query search contacts: {e}"))?; let mut contacts = Vec::new(); for row in rows { contacts.push(row.map_err(|e| format!("Failed to read contact row: {e}"))?); } Ok(contacts) } pub fn search_known_addresses( conn: &Connection, query: &str, limit: u32, ) -> Result, String> { let escaped = escape_like(query); let pattern = format!("%{escaped}%"); let sql = crate::dbq!(db_contacts::Q_003); let mut stmt = conn .prepare(sql) .map_err(|e| format!("Failed to prepare search_known_addresses: {e}"))?; let rows = stmt .query_map(params![pattern, limit], |row| { Ok(KnownAddress { email: row.get(0)?, name: row.get(1)?, }) }) .map_err(|e| format!("Failed to query known addresses: {e}"))?; let mut addresses = Vec::new(); for row in rows { addresses.push(row.map_err(|e| format!("Failed to read known address row: {e}"))?); } Ok(addresses) } pub fn populate_known_addresses( conn: &Connection, from_address: &str, from_name: &str, to_json: &str, cc_json: &str, ) -> Result<(), String> { if !from_address.is_empty() { conn.execute( crate::dbq!(db_contacts::Q_004), params![from_address, from_name], ) .map_err(|e| format!("Failed to insert known address (from): {e}"))?; } insert_addresses_from_json(conn, to_json)?; insert_addresses_from_json(conn, cc_json)?; Ok(()) } fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), String> { let entries: Vec = match serde_json::from_str(json) { Ok(v) => v, Err(_) => return Ok(()), }; for entry in &entries { let email = entry.get("address").and_then(|v| v.as_str()).unwrap_or(""); if email.is_empty() { continue; } let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or(""); conn.execute(crate::dbq!(db_contacts::Q_005), params![email, name]) .map_err(|e| format!("Failed to insert known address (json): {e}"))?; } Ok(()) } #[allow(dead_code)] pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), String> { conn.execute(crate::dbq!(db_contacts::Q_006), params![email]) .map_err(|e| format!("Failed to increment contact count: {e}"))?; Ok(()) } #[allow(dead_code)] pub fn auto_add_contact(conn: &Connection, email: &str, name: &str) -> Result<(), String> { let id = Uuid::new_v4().to_string(); conn.execute(crate::dbq!(db_contacts::Q_007), params![id, email, name]) .map_err(|e| format!("Failed to auto-add contact: {e}"))?; Ok(()) } pub fn set_contact_photo( conn: &Connection, contact_id: &str, content_type: &str, data: &[u8], ) -> Result<(), String> { conn.execute( crate::dbq!(db_contacts::Q_008), params![contact_id, content_type, data], ) .map_err(|e| format!("Failed to save contact photo: {e}"))?; Ok(()) } pub fn get_contact_photo( conn: &Connection, contact_id: &str, ) -> Result)>, String> { match conn.query_row( crate::dbq!(db_contacts::Q_009), params![contact_id], |row| Ok((row.get(0)?, row.get(1)?)), ) { Ok(value) => Ok(Some(value)), Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None), Err(e) => Err(format!("Failed to load contact photo: {e}")), } } pub fn delete_contact_photo(conn: &Connection, contact_id: &str) -> Result { conn.execute(crate::dbq!(db_contacts::Q_010), params![contact_id]) .map(|n| n > 0) .map_err(|e| format!("Failed to delete contact photo: {e}")) } #[cfg(test)] mod tests { use super::*; use crate::db::pool::open_test_db; fn sample_contact(id: &str, email: &str, name: &str) -> Contact { Contact { id: id.to_string(), email: email.to_string(), name: name.to_string(), company: String::new(), notes: String::new(), phone: String::new(), job_title: String::new(), address: String::new(), website: String::new(), birthday: String::new(), emails: Vec::new(), phones: Vec::new(), addresses: Vec::new(), is_favorite: false, last_contacted: None, contact_count: 0, source: "manual".to_string(), created_at: "2024-01-01 00:00:00".to_string(), updated_at: "2024-01-01 00:00:00".to_string(), has_photo: false, } } #[test] fn test_upsert_and_get_contact() { let conn = open_test_db(); let contact = sample_contact("c1", "alice@example.com", "Alice"); upsert_contact(&conn, &contact).unwrap(); let fetched = get_contact(&conn, "c1").unwrap(); assert!(fetched.is_some()); let fetched = fetched.unwrap(); assert_eq!(fetched.id, "c1"); assert_eq!(fetched.email, "alice@example.com"); assert_eq!(fetched.name, "Alice"); assert_eq!(fetched.source, "manual"); } #[test] fn test_get_contact_by_email() { let conn = open_test_db(); let contact = sample_contact("c2", "bob@example.com", "Bob"); upsert_contact(&conn, &contact).unwrap(); let fetched = get_contact_by_email(&conn, "bob@example.com").unwrap(); assert!(fetched.is_some()); let fetched = fetched.unwrap(); assert_eq!(fetched.id, "c2"); assert_eq!(fetched.name, "Bob"); let missing = get_contact_by_email(&conn, "nobody@example.com").unwrap(); assert!(missing.is_none()); } #[test] fn test_list_contacts() { let conn = open_test_db(); upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap(); upsert_contact(&conn, &sample_contact("c2", "bob@example.com", "Bob")).unwrap(); let all = list_contacts(&conn, None, 100, 0).unwrap(); assert_eq!(all.len(), 2); assert_eq!(all[0].name, "Alice"); assert_eq!(all[1].name, "Bob"); } #[test] fn test_search_contacts() { let conn = open_test_db(); upsert_contact( &conn, &sample_contact("c1", "alice@example.com", "Alice Smith"), ) .unwrap(); upsert_contact(&conn, &sample_contact("c2", "bob@example.com", "Bob Jones")).unwrap(); let results = search_contacts(&conn, "Alice", 10).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].name, "Alice Smith"); let results = search_contacts(&conn, "bob@", 10).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].name, "Bob Jones"); } #[test] fn test_delete_contact() { let conn = open_test_db(); upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap(); let deleted = delete_contact(&conn, "c1").unwrap(); assert!(deleted); let fetched = get_contact(&conn, "c1").unwrap(); assert!(fetched.is_none()); let deleted_again = delete_contact(&conn, "c1").unwrap(); assert!(!deleted_again); } #[test] fn test_auto_add_contact() { let conn = open_test_db(); auto_add_contact(&conn, "auto@example.com", "Auto User").unwrap(); let fetched = get_contact_by_email(&conn, "auto@example.com").unwrap(); assert!(fetched.is_some()); let fetched = fetched.unwrap(); assert_eq!(fetched.source, "auto"); assert_eq!(fetched.name, "Auto User"); auto_add_contact(&conn, "auto@example.com", "Different Name").unwrap(); let fetched2 = get_contact_by_email(&conn, "auto@example.com") .unwrap() .unwrap(); assert_eq!(fetched2.name, "Auto User"); } #[test] fn test_increment_contact_count() { let conn = open_test_db(); upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap(); increment_contact_count(&conn, "alice@example.com").unwrap(); increment_contact_count(&conn, "alice@example.com").unwrap(); let fetched = get_contact_by_email(&conn, "alice@example.com") .unwrap() .unwrap(); assert_eq!(fetched.contact_count, 2); assert!(fetched.last_contacted.is_some()); } #[test] fn test_search_known_addresses_basic() { let conn = open_test_db(); populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap(); populate_known_addresses( &conn, "sender@other.com", "Sender", r#"[{"name":"Bob To","address":"bob@test.com"}]"#, "[]", ) .unwrap(); populate_known_addresses( &conn, "sender@other.com", "Sender", "[]", r#"[{"name":"Charlie Cc","address":"charlie@test.com"}]"#, ) .unwrap(); let results = search_known_addresses(&conn, "test.com", 10).unwrap(); assert_eq!(results.len(), 3); assert_eq!(results[0].email, "alice@test.com"); assert_eq!(results[0].name, "Alice From"); assert_eq!(results[1].email, "bob@test.com"); assert_eq!(results[1].name, "Bob To"); assert_eq!(results[2].email, "charlie@test.com"); assert_eq!(results[2].name, "Charlie Cc"); } #[test] fn test_search_known_addresses_excludes_contacts() { let conn = open_test_db(); upsert_contact( &conn, &sample_contact("c1", "alice@test.com", "Alice Contact"), ) .unwrap(); populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap(); populate_known_addresses( &conn, "bob@test.com", "Bob From", r#"[{"name":"Alice To","address":"alice@test.com"}]"#, "[]", ) .unwrap(); populate_known_addresses( &conn, "charlie@test.com", "Charlie", "[]", r#"[{"name":"Alice Cc","address":"alice@test.com"}]"#, ) .unwrap(); let results = search_known_addresses(&conn, "test.com", 10).unwrap(); assert_eq!(results.len(), 2); let emails: Vec<&str> = results.iter().map(|a| a.email.as_str()).collect(); assert!(emails.contains(&"bob@test.com")); assert!(emails.contains(&"charlie@test.com")); assert!(!emails.contains(&"alice@test.com")); } #[test] fn test_search_known_addresses_handles_invalid_json() { let conn = open_test_db(); populate_known_addresses(&conn, "valid@test.com", "Valid User", "[]", "[]").unwrap(); populate_known_addresses( &conn, "sender@other.com", "Sender", r#"[{"name":"","address":"emptyname@test.com"}]"#, r#"[{"name":"Cc Person","address":"ccperson@test.com"}]"#, ) .unwrap(); populate_known_addresses( &conn, "sender@other.com", "Sender", r#"[{"name":"Json Person","address":"json@test.com"}]"#, "[]", ) .unwrap(); let results = search_known_addresses(&conn, "test.com", 10).unwrap(); assert_eq!(results.len(), 4); let emails: Vec<&str> = results.iter().map(|a| a.email.as_str()).collect(); assert!(emails.contains(&"valid@test.com")); assert!(emails.contains(&"emptyname@test.com")); assert!(emails.contains(&"ccperson@test.com")); assert!(emails.contains(&"json@test.com")); } #[test] fn test_populate_known_addresses_deduplicates() { let conn = open_test_db(); populate_known_addresses(&conn, "alice@test.com", "Alice", "[]", "[]").unwrap(); populate_known_addresses(&conn, "alice@test.com", "Alice Different", "[]", "[]").unwrap(); let results = search_known_addresses(&conn, "alice@test.com", 10).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].name, "Alice Different"); } #[test] fn test_populate_known_addresses_skips_invalid_json() { let conn = open_test_db(); populate_known_addresses(&conn, "sender@test.com", "Sender", "not valid json", "[]") .unwrap(); let results = search_known_addresses(&conn, "sender@test.com", 10).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].email, "sender@test.com"); } }