max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
26 files changed,
+4856 insertions,
-2503 deletions
| @@ -35,7 +35,6 @@ | |||
| 35 | 35 | mod subtask_repo; | |
| 36 | 36 | mod sync_account_repo; | |
| 37 | 37 | mod task_repo; | |
| 38 | - | mod task_repo_state; | |
| 39 | 38 | pub(crate) mod time_session_repo; | |
| 40 | 39 | mod user_repo; | |
| 41 | 40 | mod weekly_review_repo; |
| @@ -11,7 +11,8 @@ | |||
| 11 | 11 | ||
| 12 | 12 | use crate::utils::{execute, format_datetime, format_datetime_now, format_datetime_opt, query_all}; | |
| 13 | 13 | ||
| 14 | - | use super::task_repo::{TASK_SELECT_COLUMNS, get_task_by_id, query_tasks}; | |
| 14 | + | use super::fetch::{get_task_by_id, query_tasks}; | |
| 15 | + | use super::row::TASK_SELECT_COLUMNS; | |
| 15 | 16 | ||
| 16 | 17 | // Snooze | |
| 17 | 18 | ||
| @@ -376,8 +377,8 @@ | |||
| 376 | 377 | conn, | |
| 377 | 378 | &sql, | |
| 378 | 379 | params![user_id.to_string(), user_id.to_string(), limit], | |
| 379 | - | super::task_repo::TaskRowWithProject::from_row, | |
| 380 | + | super::row::TaskRowWithProject::from_row, | |
| 380 | 381 | )?; | |
| 381 | 382 | ||
| 382 | - | super::task_repo::rows_to_tasks(conn, rows) | |
| 383 | + | super::fetch::rows_to_tasks(conn, rows) | |
| 383 | 384 | } |
| @@ -1,1252 +1,0 @@ | |||
| 1 | - | //! SQLite implementation of the ContactRepository. | |
| 2 | - | //! | |
| 3 | - | //! Manages contacts with sub-collections (emails, phones, social handles). | |
| 4 | - | //! Sub-collections are stored in separate tables and batch-loaded for list operations. | |
| 5 | - | ||
| 6 | - | use goingson_core::{ | |
| 7 | - | Contact, ContactCustomField, ContactEmail, ContactEmailEntry, ContactEmailId, ContactId, | |
| 8 | - | ContactPhone, ContactPhoneId, ContactRepository, CoreError, CustomFieldId, NewContact, | |
| 9 | - | NewContactCustomField, NewContactEmail, NewContactPhone, NewSocialHandle, Result, SocialHandle, | |
| 10 | - | SocialHandleId, UpdateContact, UserId, | |
| 11 | - | }; | |
| 12 | - | use std::collections::{HashMap, HashSet}; | |
| 13 | - | ||
| 14 | - | use crate::utils::{ | |
| 15 | - | bind_placeholders, escape_like, execute, format_datetime, format_datetime_now, parse_datetime, | |
| 16 | - | parse_tags, parse_uuid, query_all, query_opt, | |
| 17 | - | }; | |
| 18 | - | use rusqlite::{Connection, params, params_from_iter}; | |
| 19 | - | ||
| 20 | - | use crate::Db; | |
| 21 | - | ||
| 22 | - | // Row Structs | |
| 23 | - | ||
| 24 | - | #[derive(Debug, Clone)] | |
| 25 | - | struct ContactRow { | |
| 26 | - | pub id: String, | |
| 27 | - | pub display_name: String, | |
| 28 | - | pub nickname: Option<String>, | |
| 29 | - | pub company: Option<String>, | |
| 30 | - | pub title: Option<String>, | |
| 31 | - | pub notes: String, | |
| 32 | - | pub tags: String, | |
| 33 | - | pub birthday: Option<String>, | |
| 34 | - | pub timezone: Option<String>, | |
| 35 | - | pub external_source: Option<String>, | |
| 36 | - | pub external_id: Option<String>, | |
| 37 | - | pub is_implicit: i32, | |
| 38 | - | pub created_at: String, | |
| 39 | - | pub updated_at: String, | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | impl ContactRow { | |
| 43 | - | fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> { | |
| 44 | - | Ok(Self { | |
| 45 | - | id: row.get("id")?, | |
| 46 | - | display_name: row.get("display_name")?, | |
| 47 | - | nickname: row.get("nickname")?, | |
| 48 | - | company: row.get("company")?, | |
| 49 | - | title: row.get("title")?, | |
| 50 | - | notes: row.get("notes")?, | |
| 51 | - | tags: row.get("tags")?, | |
| 52 | - | birthday: row.get("birthday")?, | |
| 53 | - | timezone: row.get("timezone")?, | |
| 54 | - | external_source: row.get("external_source")?, | |
| 55 | - | external_id: row.get("external_id")?, | |
| 56 | - | is_implicit: row.get("is_implicit")?, | |
| 57 | - | created_at: row.get("created_at")?, | |
| 58 | - | updated_at: row.get("updated_at")?, | |
| 59 | - | }) | |
| 60 | - | } | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | #[derive(Debug, Clone)] | |
| 64 | - | struct ContactEmailRow { | |
| 65 | - | pub id: String, | |
| 66 | - | pub contact_id: String, | |
| 67 | - | pub address: String, | |
| 68 | - | pub label: String, | |
| 69 | - | pub is_primary: i32, | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | impl ContactEmailRow { | |
| 73 | - | fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> { | |
| 74 | - | Ok(Self { | |
| 75 | - | id: row.get("id")?, | |
| 76 | - | contact_id: row.get("contact_id")?, | |
| 77 | - | address: row.get("address")?, | |
| 78 | - | label: row.get("label")?, | |
| 79 | - | is_primary: row.get("is_primary")?, | |
| 80 | - | }) | |
| 81 | - | } | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | #[derive(Debug, Clone)] | |
| 85 | - | struct ContactPhoneRow { | |
| 86 | - | pub id: String, | |
| 87 | - | pub contact_id: String, | |
| 88 | - | pub number: String, | |
| 89 | - | pub label: String, | |
| 90 | - | pub is_primary: i32, | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | impl ContactPhoneRow { | |
| 94 | - | fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> { | |
| 95 | - | Ok(Self { | |
| 96 | - | id: row.get("id")?, | |
| 97 | - | contact_id: row.get("contact_id")?, | |
| 98 | - | number: row.get("number")?, | |
| 99 | - | label: row.get("label")?, | |
| 100 | - | is_primary: row.get("is_primary")?, | |
| 101 | - | }) | |
| 102 | - | } | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | #[derive(Debug, Clone)] | |
| 106 | - | struct SocialHandleRow { | |
| 107 | - | pub id: String, | |
| 108 | - | pub contact_id: String, | |
| 109 | - | pub platform: String, | |
| 110 | - | pub handle: String, | |
| 111 | - | pub url: Option<String>, | |
| 112 | - | } | |
| 113 | - | ||
| 114 | - | impl SocialHandleRow { | |
| 115 | - | fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> { | |
| 116 | - | Ok(Self { | |
| 117 | - | id: row.get("id")?, | |
| 118 | - | contact_id: row.get("contact_id")?, | |
| 119 | - | platform: row.get("platform")?, | |
| 120 | - | handle: row.get("handle")?, | |
| 121 | - | url: row.get("url")?, | |
| 122 | - | }) | |
| 123 | - | } | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | #[derive(Debug, Clone)] | |
| 127 | - | struct CustomFieldRow { | |
| 128 | - | pub id: String, | |
| 129 | - | pub contact_id: String, | |
| 130 | - | pub label: String, | |
| 131 | - | pub value: String, | |
| 132 | - | pub url: Option<String>, | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | impl CustomFieldRow { | |
| 136 | - | fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> { | |
| 137 | - | Ok(Self { | |
| 138 | - | id: row.get("id")?, | |
| 139 | - | contact_id: row.get("contact_id")?, | |
| 140 | - | label: row.get("label")?, | |
| 141 | - | value: row.get("value")?, | |
| 142 | - | url: row.get("url")?, | |
| 143 | - | }) | |
| 144 | - | } | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | // Row Conversions | |
| 148 | - | ||
| 149 | - | fn contact_email_from_row(row: ContactEmailRow) -> Result<ContactEmail> { | |
| 150 | - | Ok(ContactEmail { | |
| 151 | - | id: parse_uuid(&row.id)?.into(), | |
| 152 | - | contact_id: parse_uuid(&row.contact_id)?.into(), | |
| 153 | - | address: row.address, | |
| 154 | - | label: row.label, | |
| 155 | - | is_primary: row.is_primary != 0, | |
| 156 | - | }) | |
| 157 | - | } | |
| 158 | - | ||
| 159 | - | fn contact_phone_from_row(row: ContactPhoneRow) -> Result<ContactPhone> { | |
| 160 | - | Ok(ContactPhone { | |
| 161 | - | id: parse_uuid(&row.id)?.into(), | |
| 162 | - | contact_id: parse_uuid(&row.contact_id)?.into(), | |
| 163 | - | number: row.number, | |
| 164 | - | label: row.label, | |
| 165 | - | is_primary: row.is_primary != 0, | |
| 166 | - | }) | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | fn social_handle_from_row(row: SocialHandleRow) -> Result<SocialHandle> { | |
| 170 | - | Ok(SocialHandle { | |
| 171 | - | id: parse_uuid(&row.id)?.into(), | |
| 172 | - | contact_id: parse_uuid(&row.contact_id)?.into(), | |
| 173 | - | platform: row.platform, | |
| 174 | - | handle: row.handle, | |
| 175 | - | url: row.url, | |
| 176 | - | }) | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | fn custom_field_from_row(row: CustomFieldRow) -> Result<ContactCustomField> { | |
| 180 | - | Ok(ContactCustomField { | |
| 181 | - | id: parse_uuid(&row.id)?.into(), | |
| 182 | - | contact_id: parse_uuid(&row.contact_id)?.into(), | |
| 183 | - | label: row.label, | |
| 184 | - | value: row.value, | |
| 185 | - | url: row.url, | |
| 186 | - | }) | |
| 187 | - | } | |
| 188 | - | ||
| 189 | - | fn contact_from_row( | |
| 190 | - | row: ContactRow, | |
| 191 | - | emails: Vec<ContactEmail>, | |
| 192 | - | phones: Vec<ContactPhone>, | |
| 193 | - | social_handles: Vec<SocialHandle>, | |
| 194 | - | custom_fields: Vec<ContactCustomField>, | |
| 195 | - | ) -> Result<Contact> { | |
| 196 | - | let birthday = row | |
| 197 | - | .birthday | |
| 198 | - | .as_deref() | |
| 199 | - | .map(|s| { | |
| 200 | - | chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") | |
| 201 | - | .map_err(|e| CoreError::database_msg(format!("Invalid birthday: {e}"))) | |
| 202 | - | }) | |
| 203 | - | .transpose()?; | |
| 204 | - | ||
| 205 | - | Ok(Contact { | |
| 206 | - | id: parse_uuid(&row.id)?.into(), | |
| 207 | - | display_name: row.display_name, | |
| 208 | - | nickname: row.nickname, | |
| 209 | - | company: row.company, | |
| 210 | - | title: row.title, | |
| 211 | - | notes: row.notes, | |
| 212 | - | tags: parse_tags(&row.tags), | |
| 213 | - | birthday, | |
| 214 | - | timezone: row.timezone, | |
| 215 | - | external_source: row.external_source, | |
| 216 | - | external_id: row.external_id, | |
| 217 | - | is_implicit: row.is_implicit != 0, | |
| 218 | - | emails, | |
| 219 | - | phones, | |
| 220 | - | social_handles, | |
| 221 | - | custom_fields, | |
| 222 | - | created_at: parse_datetime(&row.created_at)?, | |
| 223 | - | updated_at: parse_datetime(&row.updated_at)?, | |
| 224 | - | }) | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | // Repository | |
| 228 | - | ||
| 229 | - | /// SQLite-backed implementation of [`ContactRepository`]. | |
| 230 | - | pub struct SqliteContactRepository { | |
| 231 | - | db: Db, | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | impl SqliteContactRepository { | |
| 235 | - | /// Creates a new repository instance with the given connection pool. | |
| 236 | - | #[tracing::instrument(skip_all)] | |
| 237 | - | pub fn new(db: Db) -> Self { | |
| 238 | - | Self { db } | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | /// Batch-load emails for a set of contact IDs. | |
| 242 | - | fn load_emails_for_contacts( | |
| 243 | - | conn: &Connection, | |
| 244 | - | ids: &[String], | |
| 245 | - | ) -> Result<HashMap<ContactId, Vec<ContactEmail>>> { | |
| 246 | - | if ids.is_empty() { | |
| 247 | - | return Ok(HashMap::new()); | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | let placeholders = bind_placeholders(ids.len()); | |
| 251 | - | let sql = format!( | |
| 252 | - | "SELECT id, contact_id, address, label, is_primary FROM contact_emails WHERE contact_id IN ({placeholders}) ORDER BY is_primary DESC, rowid ASC" | |
| 253 | - | ); | |
| 254 | - | ||
| 255 | - | let rows = query_all( | |
| 256 | - | conn, | |
| 257 | - | &sql, | |
| 258 | - | params_from_iter(ids.iter()), | |
| 259 | - | ContactEmailRow::from_row, | |
| 260 | - | )?; | |
| 261 | - | let mut map: HashMap<ContactId, Vec<ContactEmail>> = HashMap::new(); | |
| 262 | - | for row in rows { | |
| 263 | - | let contact_id: ContactId = parse_uuid(&row.contact_id)?.into(); | |
| 264 | - | let email = contact_email_from_row(row)?; | |
| 265 | - | map.entry(contact_id).or_default().push(email); | |
| 266 | - | } | |
| 267 | - | Ok(map) | |
| 268 | - | } | |
| 269 | - | ||
| 270 | - | /// Batch-load phones for a set of contact IDs. | |
| 271 | - | fn load_phones_for_contacts( | |
| 272 | - | conn: &Connection, | |
| 273 | - | ids: &[String], | |
| 274 | - | ) -> Result<HashMap<ContactId, Vec<ContactPhone>>> { | |
| 275 | - | if ids.is_empty() { | |
| 276 | - | return Ok(HashMap::new()); | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | let placeholders = bind_placeholders(ids.len()); | |
| 280 | - | let sql = format!( | |
| 281 | - | "SELECT id, contact_id, number, label, is_primary FROM contact_phones WHERE contact_id IN ({placeholders}) ORDER BY is_primary DESC, rowid ASC" | |
| 282 | - | ); | |
| 283 | - | ||
| 284 | - | let rows = query_all( | |
| 285 | - | conn, | |
| 286 | - | &sql, | |
| 287 | - | params_from_iter(ids.iter()), | |
| 288 | - | ContactPhoneRow::from_row, | |
| 289 | - | )?; | |
| 290 | - | let mut map: HashMap<ContactId, Vec<ContactPhone>> = HashMap::new(); | |
| 291 | - | for row in rows { | |
| 292 | - | let contact_id: ContactId = parse_uuid(&row.contact_id)?.into(); | |
| 293 | - | let phone = contact_phone_from_row(row)?; | |
| 294 | - | map.entry(contact_id).or_default().push(phone); | |
| 295 | - | } | |
| 296 | - | Ok(map) | |
| 297 | - | } | |
| 298 | - | ||
| 299 | - | /// Batch-load social handles for a set of contact IDs. | |
| 300 | - | fn load_social_handles_for_contacts( | |
| 301 | - | conn: &Connection, | |
| 302 | - | ids: &[String], | |
| 303 | - | ) -> Result<HashMap<ContactId, Vec<SocialHandle>>> { | |
| 304 | - | if ids.is_empty() { | |
| 305 | - | return Ok(HashMap::new()); | |
| 306 | - | } | |
| 307 | - | ||
| 308 | - | let placeholders = bind_placeholders(ids.len()); | |
| 309 | - | let sql = format!( | |
| 310 | - | "SELECT id, contact_id, platform, handle, url FROM contact_social_handles WHERE contact_id IN ({placeholders}) ORDER BY rowid ASC" | |
| 311 | - | ); | |
| 312 | - | ||
| 313 | - | let rows = query_all( | |
| 314 | - | conn, | |
| 315 | - | &sql, | |
| 316 | - | params_from_iter(ids.iter()), | |
| 317 | - | SocialHandleRow::from_row, | |
| 318 | - | )?; | |
| 319 | - | let mut map: HashMap<ContactId, Vec<SocialHandle>> = HashMap::new(); | |
| 320 | - | for row in rows { | |
| 321 | - | let contact_id: ContactId = parse_uuid(&row.contact_id)?.into(); | |
| 322 | - | let handle = social_handle_from_row(row)?; | |
| 323 | - | map.entry(contact_id).or_default().push(handle); | |
| 324 | - | } | |
| 325 | - | Ok(map) | |
| 326 | - | } | |
| 327 | - | ||
| 328 | - | /// Batch-load custom fields for a set of contact IDs. | |
| 329 | - | fn load_custom_fields_for_contacts( | |
| 330 | - | conn: &Connection, | |
| 331 | - | ids: &[String], | |
| 332 | - | ) -> Result<HashMap<ContactId, Vec<ContactCustomField>>> { | |
| 333 | - | if ids.is_empty() { | |
| 334 | - | return Ok(HashMap::new()); | |
| 335 | - | } | |
| 336 | - | ||
| 337 | - | let placeholders = bind_placeholders(ids.len()); | |
| 338 | - | let sql = format!( | |
| 339 | - | "SELECT id, contact_id, label, value, url FROM contact_custom_fields WHERE contact_id IN ({placeholders}) ORDER BY rowid ASC" | |
| 340 | - | ); | |
| 341 | - | ||
| 342 | - | let rows = query_all( | |
| 343 | - | conn, | |
| 344 | - | &sql, | |
| 345 | - | params_from_iter(ids.iter()), | |
| 346 | - | CustomFieldRow::from_row, | |
| 347 | - | )?; | |
| 348 | - | let mut map: HashMap<ContactId, Vec<ContactCustomField>> = HashMap::new(); | |
| 349 | - | for row in rows { | |
| 350 | - | let contact_id: ContactId = parse_uuid(&row.contact_id)?.into(); | |
| 351 | - | let field = custom_field_from_row(row)?; | |
| 352 | - | map.entry(contact_id).or_default().push(field); | |
| 353 | - | } | |
| 354 | - | Ok(map) | |
| 355 | - | } | |
| 356 | - | ||
| 357 | - | /// Hydrate a list of contact rows with their sub-collections. | |
| 358 | - | fn hydrate_contacts(conn: &Connection, rows: Vec<ContactRow>) -> Result<Vec<Contact>> { | |
| 359 | - | if rows.is_empty() { | |
| 360 | - | return Ok(vec![]); | |
| 361 | - | } | |
| 362 | - | ||
| 363 | - | let ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect(); | |
| 364 | - | let mut emails_map = Self::load_emails_for_contacts(conn, &ids)?; | |
| 365 | - | let mut phones_map = Self::load_phones_for_contacts(conn, &ids)?; | |
| 366 | - | let mut social_map = Self::load_social_handles_for_contacts(conn, &ids)?; | |
| 367 | - | let mut custom_map = Self::load_custom_fields_for_contacts(conn, &ids)?; | |
| 368 | - | ||
| 369 | - | let mut contacts = Vec::with_capacity(rows.len()); | |
| 370 | - | for row in rows { | |
| 371 | - | let id: ContactId = parse_uuid(&row.id)?.into(); | |
| 372 | - | let emails = emails_map.remove(&id).unwrap_or_default(); | |
| 373 | - | let phones = phones_map.remove(&id).unwrap_or_default(); | |
| 374 | - | let social_handles = social_map.remove(&id).unwrap_or_default(); | |
| 375 | - | let custom_fields = custom_map.remove(&id).unwrap_or_default(); | |
| 376 | - | contacts.push(contact_from_row( | |
| 377 | - | row, | |
| 378 | - | emails, | |
| 379 | - | phones, | |
| 380 | - | social_handles, | |
| 381 | - | custom_fields, | |
| 382 | - | )?); | |
| 383 | - | } | |
| 384 | - | Ok(contacts) | |
| 385 | - | } | |
| 386 | - | } | |
| 387 | - | ||
| 388 | - | impl ContactRepository for SqliteContactRepository { | |
| 389 | - | #[tracing::instrument(skip_all)] | |
| 390 | - | fn list_all(&self, user_id: UserId) -> Result<Vec<Contact>> { | |
| 391 | - | let conn = self.db.conn()?; | |
| 392 | - | let rows = query_all( | |
| 393 | - | &conn, | |
| 394 | - | r" | |
| 395 | - | SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at | |
| 396 | - | FROM contacts | |
| 397 | - | WHERE user_id = ? AND is_implicit = 0 | |
| 398 | - | ORDER BY display_name ASC | |
| 399 | - | ", | |
| 400 | - | params![user_id.to_string()], | |
| 401 | - | ContactRow::from_row, | |
| 402 | - | )?; | |
| 403 | - | ||
| 404 | - | Self::hydrate_contacts(&conn, rows) | |
| 405 | - | } | |
| 406 | - | ||
| 407 | - | fn list_all_for_backup(&self, user_id: UserId) -> Result<Vec<Contact>> { | |
| 408 | - | let conn = self.db.conn()?; | |
| 409 | - | let rows = query_all( | |
| 410 | - | &conn, | |
| 411 | - | r" | |
| 412 | - | SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at | |
| 413 | - | FROM contacts | |
| 414 | - | WHERE user_id = ? | |
| 415 | - | ORDER BY display_name ASC | |
| 416 | - | ", | |
| 417 | - | params![user_id.to_string()], | |
| 418 | - | ContactRow::from_row, | |
| 419 | - | )?; | |
| 420 | - | ||
| 421 | - | Self::hydrate_contacts(&conn, rows) | |
| 422 | - | } | |
| 423 | - | ||
| 424 | - | #[tracing::instrument(skip_all)] | |
| 425 | - | fn get_by_id(&self, id: ContactId, user_id: UserId) -> Result<Option<Contact>> { | |
| 426 | - | let conn = self.db.conn()?; | |
| 427 | - | let row = query_opt( | |
| 428 | - | &conn, | |
| 429 | - | r" | |
| 430 | - | SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at | |
| 431 | - | FROM contacts | |
| 432 | - | WHERE id = ? AND user_id = ? | |
| 433 | - | ", | |
| 434 | - | params![id.to_string(), user_id.to_string()], | |
| 435 | - | ContactRow::from_row, | |
| 436 | - | )?; | |
| 437 | - | ||
| 438 | - | match row { | |
| 439 | - | Some(r) => { | |
| 440 | - | let contacts = Self::hydrate_contacts(&conn, vec![r])?; | |
| 441 | - | Ok(contacts.into_iter().next()) | |
| 442 | - | } | |
| 443 | - | None => Ok(None), | |
| 444 | - | } | |
| 445 | - | } | |
| 446 | - | ||
| 447 | - | #[tracing::instrument(skip_all)] | |
| 448 | - | fn create(&self, user_id: UserId, contact: NewContact) -> Result<Contact> { | |
| 449 | - | let conn = self.db.conn()?; | |
| 450 | - | let id = ContactId::new(); | |
| 451 | - | let now = format_datetime_now(); | |
| 452 | - | let tags_json = serde_json::to_string(&contact.tags).unwrap_or_else(|_| "[]".to_string()); | |
| 453 | - | let birthday_str = contact.birthday.map(|d| d.format("%Y-%m-%d").to_string()); | |
| 454 | - | ||
| 455 | - | execute( | |
| 456 | - | &conn, | |
| 457 | - | r" | |
| 458 | - | INSERT INTO contacts (id, user_id, display_name, nickname, company, title, notes, tags, birthday, timezone, is_implicit, created_at, updated_at) | |
| 459 | - | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 460 | - | ", | |
| 461 | - | params![ | |
| 462 | - | id.to_string(), | |
| 463 | - | user_id.to_string(), | |
| 464 | - | &contact.display_name, | |
| 465 | - | &contact.nickname, | |
| 466 | - | &contact.company, | |
| 467 | - | &contact.title, | |
| 468 | - | &contact.notes, | |
| 469 | - | &tags_json, | |
| 470 | - | &birthday_str, | |
| 471 | - | &contact.timezone, | |
| 472 | - | i32::from(contact.is_implicit), | |
| 473 | - | &now, | |
| 474 | - | &now | |
| 475 | - | ], | |
| 476 | - | )?; | |
| 477 | - | ||
| 478 | - | drop(conn); | |
| 479 | - | self.get_by_id(id, user_id)? | |
| 480 | - | .ok_or_else(|| CoreError::internal("Failed to retrieve created contact")) | |
| 481 | - | } | |
| 482 | - | ||
| 483 | - | #[tracing::instrument(skip_all)] | |
| 484 | - | fn restore(&self, user_id: UserId, contact: &Contact) -> Result<()> { | |
| 485 | - | let conn = self.db.conn()?; | |
| 486 | - | let tags_json = serde_json::to_string(&contact.tags).unwrap_or_else(|_| "[]".to_string()); | |
| 487 | - | let birthday_str = contact.birthday.map(|d| d.format("%Y-%m-%d").to_string()); | |
| 488 | - | ||
| 489 | - | execute( | |
| 490 | - | &conn, | |
| 491 | - | r" | |
| 492 | - | INSERT OR IGNORE INTO contacts (id, user_id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at) | |
| 493 | - | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 494 | - | ", | |
| 495 | - | params![ | |
| 496 | - | contact.id.to_string(), | |
| 497 | - | user_id.to_string(), | |
| 498 | - | &contact.display_name, | |
| 499 | - | &contact.nickname, | |
| 500 | - | &contact.company, |
Lines truncated
| @@ -1,0 +1,188 @@ | |||
| 1 | + | //! Create, update and delete for the contact row itself. | |
| 2 | + | //! | |
| 3 | + | //! Sub-collection mutations live in `subcollections`; reads live in `query`. | |
| 4 | + | ||
| 5 | + | use goingson_core::{Contact, ContactId, CoreError, NewContact, Result, UpdateContact, UserId}; | |
| 6 | + | ||
| 7 | + | use rusqlite::{Connection, params, params_from_iter}; | |
| 8 | + | ||
| 9 | + | use crate::utils::{bind_placeholders, escape_like, execute, format_datetime, format_datetime_now}; | |
| 10 | + | ||
| 11 | + | use super::query; | |
| 12 | + | ||
| 13 | + | pub(super) fn create(conn: &Connection, user_id: UserId, contact: &NewContact) -> Result<Contact> { | |
| 14 | + | let id = ContactId::new(); | |
| 15 | + | let now = format_datetime_now(); | |
| 16 | + | let tags_json = serde_json::to_string(&contact.tags).unwrap_or_else(|_| "[]".to_string()); | |
| 17 | + | let birthday_str = contact.birthday.map(|d| d.format("%Y-%m-%d").to_string()); | |
| 18 | + | ||
| 19 | + | execute( | |
| 20 | + | conn, | |
| 21 | + | r" | |
| 22 | + | INSERT INTO contacts (id, user_id, display_name, nickname, company, title, notes, tags, birthday, timezone, is_implicit, created_at, updated_at) | |
| 23 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 24 | + | ", | |
| 25 | + | params![ | |
| 26 | + | id.to_string(), | |
| 27 | + | user_id.to_string(), | |
| 28 | + | &contact.display_name, | |
| 29 | + | &contact.nickname, | |
| 30 | + | &contact.company, | |
| 31 | + | &contact.title, | |
| 32 | + | &contact.notes, | |
| 33 | + | &tags_json, | |
| 34 | + | &birthday_str, | |
| 35 | + | &contact.timezone, | |
| 36 | + | i32::from(contact.is_implicit), | |
| 37 | + | &now, | |
| 38 | + | &now | |
| 39 | + | ], | |
| 40 | + | )?; | |
| 41 | + | ||
| 42 | + | query::get_by_id(conn, id, user_id)? | |
| 43 | + | .ok_or_else(|| CoreError::internal("Failed to retrieve created contact")) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | pub(super) fn restore(conn: &Connection, user_id: UserId, contact: &Contact) -> Result<()> { | |
| 47 | + | let tags_json = serde_json::to_string(&contact.tags).unwrap_or_else(|_| "[]".to_string()); | |
| 48 | + | let birthday_str = contact.birthday.map(|d| d.format("%Y-%m-%d").to_string()); | |
| 49 | + | ||
| 50 | + | execute( | |
| 51 | + | conn, | |
| 52 | + | r" | |
| 53 | + | INSERT OR IGNORE INTO contacts (id, user_id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at) | |
| 54 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 55 | + | ", | |
| 56 | + | params![ | |
| 57 | + | contact.id.to_string(), | |
| 58 | + | user_id.to_string(), | |
| 59 | + | &contact.display_name, | |
| 60 | + | &contact.nickname, | |
| 61 | + | &contact.company, | |
| 62 | + | &contact.title, | |
| 63 | + | &contact.notes, | |
| 64 | + | &tags_json, | |
| 65 | + | &birthday_str, | |
| 66 | + | &contact.timezone, | |
| 67 | + | &contact.external_source, | |
| 68 | + | &contact.external_id, | |
| 69 | + | i32::from(contact.is_implicit), | |
| 70 | + | format_datetime(&contact.created_at), | |
| 71 | + | format_datetime(&contact.updated_at) | |
| 72 | + | ], | |
| 73 | + | )?; | |
| 74 | + | Ok(()) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | pub(super) fn update( | |
| 78 | + | conn: &Connection, | |
| 79 | + | id: ContactId, | |
| 80 | + | user_id: UserId, | |
| 81 | + | contact: &UpdateContact, | |
| 82 | + | ) -> Result<Option<Contact>> { | |
| 83 | + | let now = format_datetime_now(); | |
| 84 | + | let tags_json = serde_json::to_string(&contact.tags).unwrap_or_else(|_| "[]".to_string()); | |
| 85 | + | let birthday_str = contact.birthday.map(|d| d.format("%Y-%m-%d").to_string()); | |
| 86 | + | ||
| 87 | + | let result = execute( | |
| 88 | + | conn, | |
| 89 | + | r" | |
| 90 | + | UPDATE contacts | |
| 91 | + | SET display_name = ?, nickname = ?, company = ?, title = ?, notes = ?, tags = ?, birthday = ?, timezone = ?, updated_at = ? | |
| 92 | + | WHERE id = ? AND user_id = ? | |
| 93 | + | ", | |
| 94 | + | params![ | |
| 95 | + | &contact.display_name, | |
| 96 | + | &contact.nickname, | |
| 97 | + | &contact.company, | |
| 98 | + | &contact.title, | |
| 99 | + | &contact.notes, | |
| 100 | + | &tags_json, | |
| 101 | + | &birthday_str, | |
| 102 | + | &contact.timezone, | |
| 103 | + | &now, | |
| 104 | + | id.to_string(), | |
| 105 | + | user_id.to_string() | |
| 106 | + | ], | |
| 107 | + | )?; | |
| 108 | + | ||
| 109 | + | if result > 0 { | |
| 110 | + | query::get_by_id(conn, id, user_id) | |
| 111 | + | } else { | |
| 112 | + | Ok(None) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | pub(super) fn delete(conn: &Connection, id: ContactId, user_id: UserId) -> Result<bool> { | |
| 117 | + | let result = execute( | |
| 118 | + | conn, | |
| 119 | + | "DELETE FROM contacts WHERE id = ? AND user_id = ?", | |
| 120 | + | params![id.to_string(), user_id.to_string()], | |
| 121 | + | )?; | |
| 122 | + | ||
| 123 | + | Ok(result > 0) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | pub(super) fn set_external_ref( | |
| 127 | + | conn: &Connection, | |
| 128 | + | id: ContactId, | |
| 129 | + | user_id: UserId, | |
| 130 | + | source: &str, | |
| 131 | + | external_id: &str, | |
| 132 | + | ) -> Result<()> { | |
| 133 | + | execute( | |
| 134 | + | conn, | |
| 135 | + | "UPDATE contacts SET external_source = ?, external_id = ? WHERE id = ? AND user_id = ?", | |
| 136 | + | params![source, external_id, id.to_string(), user_id.to_string()], | |
| 137 | + | )?; | |
| 138 | + | ||
| 139 | + | Ok(()) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | pub(super) fn delete_many(conn: &Connection, ids: &[ContactId], user_id: UserId) -> Result<u64> { | |
| 143 | + | if ids.is_empty() { | |
| 144 | + | return Ok(0); | |
| 145 | + | } | |
| 146 | + | let user_id_str = user_id.to_string(); | |
| 147 | + | let placeholders = bind_placeholders(ids.len()); | |
| 148 | + | let sql = format!("DELETE FROM contacts WHERE user_id = ? AND id IN ({placeholders})"); | |
| 149 | + | let mut binds: Vec<String> = Vec::with_capacity(ids.len() + 1); | |
| 150 | + | binds.push(user_id_str); | |
| 151 | + | binds.extend(ids.iter().map(std::string::ToString::to_string)); | |
| 152 | + | let result = execute(conn, &sql, params_from_iter(binds))?; | |
| 153 | + | Ok(result as u64) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | pub(super) fn tag_many( | |
| 157 | + | conn: &Connection, | |
| 158 | + | ids: &[ContactId], | |
| 159 | + | user_id: UserId, | |
| 160 | + | tag: &str, | |
| 161 | + | ) -> Result<u64> { | |
| 162 | + | if ids.is_empty() || tag.is_empty() { | |
| 163 | + | return Ok(0); | |
| 164 | + | } | |
| 165 | + | let user_id_str = user_id.to_string(); | |
| 166 | + | let like_pattern = format!("%\"{}\"%", escape_like(tag)); | |
| 167 | + | let placeholders = bind_placeholders(ids.len()); | |
| 168 | + | // Append tag to JSON array where not already present. | |
| 169 | + | let sql = format!( | |
| 170 | + | r"UPDATE contacts | |
| 171 | + | SET tags = CASE | |
| 172 | + | WHEN tags IS NULL OR tags = '' OR tags = '[]' | |
| 173 | + | THEN json_array(?) | |
| 174 | + | ELSE json_insert(tags, '$[#]', ?) | |
| 175 | + | END, | |
| 176 | + | updated_at = datetime('now') | |
| 177 | + | WHERE user_id = ? AND id IN ({placeholders}) | |
| 178 | + | AND (tags IS NULL OR tags NOT LIKE ? ESCAPE '\')", | |
| 179 | + | ); | |
| 180 | + | let mut binds: Vec<String> = Vec::with_capacity(ids.len() + 4); | |
| 181 | + | binds.push(tag.to_string()); | |
| 182 | + | binds.push(tag.to_string()); | |
| 183 | + | binds.push(user_id_str); | |
| 184 | + | binds.extend(ids.iter().map(std::string::ToString::to_string)); | |
| 185 | + | binds.push(like_pattern); | |
| 186 | + | let result = execute(conn, &sql, params_from_iter(binds))?; | |
| 187 | + | Ok(result as u64) | |
| 188 | + | } |
| @@ -1,0 +1,166 @@ | |||
| 1 | + | //! Batch loading of contact sub-collections. | |
| 2 | + | //! | |
| 3 | + | //! One query per sub-collection table for a whole page of contacts, then a | |
| 4 | + | //! single assembly pass, so listing N contacts costs five queries rather than | |
| 5 | + | //! 4N + 1. | |
| 6 | + | ||
| 7 | + | use goingson_core::{ | |
| 8 | + | Contact, ContactCustomField, ContactEmail, ContactId, ContactPhone, Result, SocialHandle, | |
| 9 | + | }; | |
| 10 | + | use std::collections::HashMap; | |
| 11 | + | ||
| 12 | + | use rusqlite::{Connection, params_from_iter}; | |
| 13 | + | ||
| 14 | + | use crate::utils::{bind_placeholders, parse_uuid, query_all}; | |
| 15 | + | ||
| 16 | + | use super::row::{ | |
| 17 | + | ContactEmailRow, ContactPhoneRow, ContactRow, CustomFieldRow, SocialHandleRow, | |
| 18 | + | contact_email_from_row, contact_from_row, contact_phone_from_row, custom_field_from_row, | |
| 19 | + | social_handle_from_row, | |
| 20 | + | }; | |
| 21 | + | ||
| 22 | + | /// Batch-load emails for a set of contact IDs. | |
| 23 | + | pub(super) fn load_emails_for_contacts( | |
| 24 | + | conn: &Connection, | |
| 25 | + | ids: &[String], | |
| 26 | + | ) -> Result<HashMap<ContactId, Vec<ContactEmail>>> { | |
| 27 | + | if ids.is_empty() { | |
| 28 | + | return Ok(HashMap::new()); | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | let placeholders = bind_placeholders(ids.len()); | |
| 32 | + | let sql = format!( | |
| 33 | + | "SELECT id, contact_id, address, label, is_primary FROM contact_emails WHERE contact_id IN ({placeholders}) ORDER BY is_primary DESC, rowid ASC" | |
| 34 | + | ); | |
| 35 | + | ||
| 36 | + | let rows = query_all( | |
| 37 | + | conn, | |
| 38 | + | &sql, | |
| 39 | + | params_from_iter(ids.iter()), | |
| 40 | + | ContactEmailRow::from_row, | |
| 41 | + | )?; | |
| 42 | + | let mut map: HashMap<ContactId, Vec<ContactEmail>> = HashMap::new(); | |
| 43 | + | for row in rows { | |
| 44 | + | let contact_id: ContactId = parse_uuid(&row.contact_id)?.into(); | |
| 45 | + | let email = contact_email_from_row(row)?; | |
| 46 | + | map.entry(contact_id).or_default().push(email); | |
| 47 | + | } | |
| 48 | + | Ok(map) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// Batch-load phones for a set of contact IDs. | |
| 52 | + | pub(super) fn load_phones_for_contacts( | |
| 53 | + | conn: &Connection, | |
| 54 | + | ids: &[String], | |
| 55 | + | ) -> Result<HashMap<ContactId, Vec<ContactPhone>>> { | |
| 56 | + | if ids.is_empty() { | |
| 57 | + | return Ok(HashMap::new()); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | let placeholders = bind_placeholders(ids.len()); | |
| 61 | + | let sql = format!( | |
| 62 | + | "SELECT id, contact_id, number, label, is_primary FROM contact_phones WHERE contact_id IN ({placeholders}) ORDER BY is_primary DESC, rowid ASC" | |
| 63 | + | ); | |
| 64 | + | ||
| 65 | + | let rows = query_all( | |
| 66 | + | conn, | |
| 67 | + | &sql, | |
| 68 | + | params_from_iter(ids.iter()), | |
| 69 | + | ContactPhoneRow::from_row, | |
| 70 | + | )?; | |
| 71 | + | let mut map: HashMap<ContactId, Vec<ContactPhone>> = HashMap::new(); | |
| 72 | + | for row in rows { | |
| 73 | + | let contact_id: ContactId = parse_uuid(&row.contact_id)?.into(); | |
| 74 | + | let phone = contact_phone_from_row(row)?; | |
| 75 | + | map.entry(contact_id).or_default().push(phone); | |
| 76 | + | } | |
| 77 | + | Ok(map) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// Batch-load social handles for a set of contact IDs. | |
| 81 | + | pub(super) fn load_social_handles_for_contacts( | |
| 82 | + | conn: &Connection, | |
| 83 | + | ids: &[String], | |
| 84 | + | ) -> Result<HashMap<ContactId, Vec<SocialHandle>>> { | |
| 85 | + | if ids.is_empty() { | |
| 86 | + | return Ok(HashMap::new()); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | let placeholders = bind_placeholders(ids.len()); | |
| 90 | + | let sql = format!( | |
| 91 | + | "SELECT id, contact_id, platform, handle, url FROM contact_social_handles WHERE contact_id IN ({placeholders}) ORDER BY rowid ASC" | |
| 92 | + | ); | |
| 93 | + | ||
| 94 | + | let rows = query_all( | |
| 95 | + | conn, | |
| 96 | + | &sql, | |
| 97 | + | params_from_iter(ids.iter()), | |
| 98 | + | SocialHandleRow::from_row, | |
| 99 | + | )?; | |
| 100 | + | let mut map: HashMap<ContactId, Vec<SocialHandle>> = HashMap::new(); | |
| 101 | + | for row in rows { | |
| 102 | + | let contact_id: ContactId = parse_uuid(&row.contact_id)?.into(); | |
| 103 | + | let handle = social_handle_from_row(row)?; | |
| 104 | + | map.entry(contact_id).or_default().push(handle); | |
| 105 | + | } | |
| 106 | + | Ok(map) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | /// Batch-load custom fields for a set of contact IDs. | |
| 110 | + | pub(super) fn load_custom_fields_for_contacts( | |
| 111 | + | conn: &Connection, | |
| 112 | + | ids: &[String], | |
| 113 | + | ) -> Result<HashMap<ContactId, Vec<ContactCustomField>>> { | |
| 114 | + | if ids.is_empty() { | |
| 115 | + | return Ok(HashMap::new()); | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | let placeholders = bind_placeholders(ids.len()); | |
| 119 | + | let sql = format!( | |
| 120 | + | "SELECT id, contact_id, label, value, url FROM contact_custom_fields WHERE contact_id IN ({placeholders}) ORDER BY rowid ASC" | |
| 121 | + | ); | |
| 122 | + | ||
| 123 | + | let rows = query_all( | |
| 124 | + | conn, | |
| 125 | + | &sql, | |
| 126 | + | params_from_iter(ids.iter()), | |
| 127 | + | CustomFieldRow::from_row, | |
| 128 | + | )?; | |
| 129 | + | let mut map: HashMap<ContactId, Vec<ContactCustomField>> = HashMap::new(); | |
| 130 | + | for row in rows { | |
| 131 | + | let contact_id: ContactId = parse_uuid(&row.contact_id)?.into(); | |
| 132 | + | let field = custom_field_from_row(row)?; | |
| 133 | + | map.entry(contact_id).or_default().push(field); | |
| 134 | + | } | |
| 135 | + | Ok(map) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// Hydrate a list of contact rows with their sub-collections. | |
| 139 | + | pub(super) fn hydrate_contacts(conn: &Connection, rows: Vec<ContactRow>) -> Result<Vec<Contact>> { | |
| 140 | + | if rows.is_empty() { | |
| 141 | + | return Ok(vec![]); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | let ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect(); | |
| 145 | + | let mut emails_map = load_emails_for_contacts(conn, &ids)?; | |
| 146 | + | let mut phones_map = load_phones_for_contacts(conn, &ids)?; | |
| 147 | + | let mut social_map = load_social_handles_for_contacts(conn, &ids)?; | |
| 148 | + | let mut custom_map = load_custom_fields_for_contacts(conn, &ids)?; | |
| 149 | + | ||
| 150 | + | let mut contacts = Vec::with_capacity(rows.len()); | |
| 151 | + | for row in rows { | |
| 152 | + | let id: ContactId = parse_uuid(&row.id)?.into(); | |
| 153 | + | let emails = emails_map.remove(&id).unwrap_or_default(); | |
| 154 | + | let phones = phones_map.remove(&id).unwrap_or_default(); | |
| 155 | + | let social_handles = social_map.remove(&id).unwrap_or_default(); | |
| 156 | + | let custom_fields = custom_map.remove(&id).unwrap_or_default(); | |
| 157 | + | contacts.push(contact_from_row( | |
| 158 | + | row, | |
| 159 | + | emails, | |
| 160 | + | phones, | |
| 161 | + | social_handles, | |
| 162 | + | custom_fields, | |
| 163 | + | )?); | |
| 164 | + | } | |
| 165 | + | Ok(contacts) | |
| 166 | + | } |
| @@ -1,0 +1,278 @@ | |||
| 1 | + | //! SQLite implementation of the ContactRepository. | |
| 2 | + | //! | |
| 3 | + | //! Manages contacts with sub-collections (emails, phones, social handles). | |
| 4 | + | //! Sub-collections are stored in separate tables and batch-loaded for list operations. | |
| 5 | + | ||
| 6 | + | mod crud; | |
| 7 | + | mod hydrate; | |
| 8 | + | mod query; | |
| 9 | + | mod row; | |
| 10 | + | mod subcollections; | |
| 11 | + | ||
| 12 | + | use goingson_core::{ | |
| 13 | + | Contact, ContactCustomField, ContactEmail, ContactEmailEntry, ContactEmailId, ContactId, | |
| 14 | + | ContactPhone, ContactPhoneId, ContactRepository, CustomFieldId, NewContact, | |
| 15 | + | NewContactCustomField, NewContactEmail, NewContactPhone, NewSocialHandle, Result, SocialHandle, | |
| 16 | + | SocialHandleId, UpdateContact, UserId, | |
| 17 | + | }; | |
| 18 | + | use std::collections::HashSet; | |
| 19 | + | ||
| 20 | + | use crate::Db; | |
| 21 | + | ||
| 22 | + | /// SQLite-backed implementation of [`ContactRepository`]. | |
| 23 | + | pub struct SqliteContactRepository { | |
| 24 | + | db: Db, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | impl SqliteContactRepository { | |
| 28 | + | /// Creates a new repository instance with the given connection pool. | |
| 29 | + | #[tracing::instrument(skip_all)] | |
| 30 | + | pub fn new(db: Db) -> Self { | |
| 31 | + | Self { db } | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | impl ContactRepository for SqliteContactRepository { | |
| 36 | + | #[tracing::instrument(skip_all)] | |
| 37 | + | fn list_all(&self, user_id: UserId) -> Result<Vec<Contact>> { | |
| 38 | + | let conn = self.db.conn()?; | |
| 39 | + | query::list_all(&conn, user_id) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | fn list_all_for_backup(&self, user_id: UserId) -> Result<Vec<Contact>> { | |
| 43 | + | let conn = self.db.conn()?; | |
| 44 | + | query::list_all_for_backup(&conn, user_id) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | #[tracing::instrument(skip_all)] | |
| 48 | + | fn get_by_id(&self, id: ContactId, user_id: UserId) -> Result<Option<Contact>> { | |
| 49 | + | let conn = self.db.conn()?; | |
| 50 | + | query::get_by_id(&conn, id, user_id) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | #[tracing::instrument(skip_all)] | |
| 54 | + | fn create(&self, user_id: UserId, contact: NewContact) -> Result<Contact> { | |
| 55 | + | let conn = self.db.conn()?; | |
| 56 | + | crud::create(&conn, user_id, &contact) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | #[tracing::instrument(skip_all)] | |
| 60 | + | fn restore(&self, user_id: UserId, contact: &Contact) -> Result<()> { | |
| 61 | + | let conn = self.db.conn()?; | |
| 62 | + | crud::restore(&conn, user_id, contact) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | #[tracing::instrument(skip_all)] | |
| 66 | + | fn update( | |
| 67 | + | &self, | |
| 68 | + | id: ContactId, | |
| 69 | + | user_id: UserId, | |
| 70 | + | contact: UpdateContact, | |
| 71 | + | ) -> Result<Option<Contact>> { | |
| 72 | + | let conn = self.db.conn()?; | |
| 73 | + | crud::update(&conn, id, user_id, &contact) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | #[tracing::instrument(skip_all)] | |
| 77 | + | fn delete(&self, id: ContactId, user_id: UserId) -> Result<bool> { | |
| 78 | + | let conn = self.db.conn()?; | |
| 79 | + | crud::delete(&conn, id, user_id) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | #[tracing::instrument(skip_all)] | |
| 83 | + | fn set_external_ref( | |
| 84 | + | &self, | |
| 85 | + | id: ContactId, | |
| 86 | + | user_id: UserId, | |
| 87 | + | source: &str, | |
| 88 | + | external_id: &str, | |
| 89 | + | ) -> Result<()> { | |
| 90 | + | let conn = self.db.conn()?; | |
| 91 | + | crud::set_external_ref(&conn, id, user_id, source, external_id) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | #[tracing::instrument(skip_all)] | |
| 95 | + | fn delete_many(&self, ids: &[ContactId], user_id: UserId) -> Result<u64> { | |
| 96 | + | let conn = self.db.conn()?; | |
| 97 | + | crud::delete_many(&conn, ids, user_id) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | #[tracing::instrument(skip_all)] | |
| 101 | + | fn tag_many(&self, ids: &[ContactId], user_id: UserId, tag: &str) -> Result<u64> { | |
| 102 | + | let conn = self.db.conn()?; | |
| 103 | + | crud::tag_many(&conn, ids, user_id, tag) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | #[tracing::instrument(skip_all)] | |
| 107 | + | fn list_by_tag(&self, user_id: UserId, tag: &str) -> Result<Vec<Contact>> { | |
| 108 | + | let conn = self.db.conn()?; | |
| 109 | + | query::list_by_tag(&conn, user_id, tag) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | #[tracing::instrument(skip_all)] | |
| 113 | + | fn list_filtered( | |
| 114 | + | &self, | |
| 115 | + | user_id: UserId, | |
| 116 | + | search: Option<&str>, | |
| 117 | + | tag: Option<&str>, | |
| 118 | + | include_implicit: bool, | |
| 119 | + | ) -> Result<Vec<Contact>> { | |
| 120 | + | let conn = self.db.conn()?; | |
| 121 | + | query::list_filtered(&conn, user_id, search, tag, include_implicit) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | #[tracing::instrument(skip_all)] | |
| 125 | + | fn list_email_directory( | |
| 126 | + | &self, | |
| 127 | + | user_id: UserId, | |
| 128 | + | include_implicit: bool, | |
| 129 | + | ) -> Result<Vec<ContactEmailEntry>> { | |
| 130 | + | let conn = self.db.conn()?; | |
| 131 | + | query::list_email_directory(&conn, user_id, include_implicit) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | #[tracing::instrument(skip_all)] | |
| 135 | + | fn find_by_email(&self, user_id: UserId, email: &str) -> Result<Option<Contact>> { | |
| 136 | + | let conn = self.db.conn()?; | |
| 137 | + | query::find_by_email(&conn, user_id, email) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | #[tracing::instrument(skip_all)] | |
| 141 | + | fn find_emails_in_contacts( | |
| 142 | + | &self, | |
| 143 | + | user_id: UserId, | |
| 144 | + | addresses: &[&str], | |
| 145 | + | ) -> Result<HashSet<String>> { | |
| 146 | + | let conn = self.db.conn()?; | |
| 147 | + | query::find_emails_in_contacts(&conn, user_id, addresses) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | #[tracing::instrument(skip_all)] | |
| 151 | + | fn promote_contact(&self, id: ContactId, user_id: UserId) -> Result<Option<Contact>> { | |
| 152 | + | let conn = self.db.conn()?; | |
| 153 | + | query::promote_contact(&conn, id, user_id) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | #[tracing::instrument(skip_all)] | |
| 157 | + | fn find_by_external_id( | |
| 158 | + | &self, | |
| 159 | + | source: &str, | |
| 160 | + | ext_id: &str, | |
| 161 | + | user_id: UserId, | |
| 162 | + | ) -> Result<Option<Contact>> { | |
| 163 | + | let conn = self.db.conn()?; | |
| 164 | + | query::find_by_external_id(&conn, source, ext_id, user_id) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | #[tracing::instrument(skip_all)] | |
| 168 | + | fn add_email( | |
| 169 | + | &self, | |
| 170 | + | contact_id: ContactId, | |
| 171 | + | user_id: UserId, | |
| 172 | + | email: NewContactEmail, | |
| 173 | + | ) -> Result<ContactEmail> { | |
| 174 | + | let conn = self.db.conn()?; | |
| 175 | + | subcollections::add_email(&conn, contact_id, user_id, email) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | #[tracing::instrument(skip_all)] | |
| 179 | + | fn remove_email(&self, email_id: ContactEmailId, user_id: UserId) -> Result<bool> { | |
| 180 | + | let conn = self.db.conn()?; | |
| 181 | + | subcollections::remove_email(&conn, email_id, user_id) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | #[tracing::instrument(skip_all)] | |
| 185 | + | fn add_phone( | |
| 186 | + | &self, | |
| 187 | + | contact_id: ContactId, | |
| 188 | + | user_id: UserId, | |
| 189 | + | phone: NewContactPhone, | |
| 190 | + | ) -> Result<ContactPhone> { | |
| 191 | + | let conn = self.db.conn()?; | |
| 192 | + | subcollections::add_phone(&conn, contact_id, user_id, phone) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | #[tracing::instrument(skip_all)] | |
| 196 | + | fn remove_phone(&self, phone_id: ContactPhoneId, user_id: UserId) -> Result<bool> { | |
| 197 | + | let conn = self.db.conn()?; | |
| 198 | + | subcollections::remove_phone(&conn, phone_id, user_id) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | #[tracing::instrument(skip_all)] | |
| 202 | + | fn add_social_handle( | |
| 203 | + | &self, | |
| 204 | + | contact_id: ContactId, | |
| 205 | + | user_id: UserId, | |
| 206 | + | handle: NewSocialHandle, | |
| 207 | + | ) -> Result<SocialHandle> { | |
| 208 | + | let conn = self.db.conn()?; | |
| 209 | + | subcollections::add_social_handle(&conn, contact_id, user_id, handle) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | #[tracing::instrument(skip_all)] | |
| 213 | + | fn remove_social_handle(&self, handle_id: SocialHandleId, user_id: UserId) -> Result<bool> { | |
| 214 | + | let conn = self.db.conn()?; | |
| 215 | + | subcollections::remove_social_handle(&conn, handle_id, user_id) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | #[tracing::instrument(skip_all)] | |
| 219 | + | fn add_custom_field( | |
| 220 | + | &self, | |
| 221 | + | contact_id: ContactId, | |
| 222 | + | user_id: UserId, | |
| 223 | + | field: NewContactCustomField, | |
| 224 | + | ) -> Result<ContactCustomField> { | |
| 225 | + | let conn = self.db.conn()?; | |
| 226 | + | subcollections::add_custom_field(&conn, contact_id, user_id, field) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | #[tracing::instrument(skip_all)] | |
| 230 | + | fn remove_custom_field(&self, field_id: CustomFieldId, user_id: UserId) -> Result<bool> { | |
| 231 | + | let conn = self.db.conn()?; | |
| 232 | + | subcollections::remove_custom_field(&conn, field_id, user_id) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | #[tracing::instrument(skip_all)] | |
| 236 | + | fn update_email( | |
| 237 | + | &self, | |
| 238 | + | email_id: ContactEmailId, | |
| 239 | + | user_id: UserId, | |
| 240 | + | email: NewContactEmail, | |
| 241 | + | ) -> Result<Option<ContactEmail>> { | |
| 242 | + | let conn = self.db.conn()?; | |
| 243 | + | subcollections::update_email(&conn, email_id, user_id, &email) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | #[tracing::instrument(skip_all)] | |
| 247 | + | fn update_phone( | |
| 248 | + | &self, | |
| 249 | + | phone_id: ContactPhoneId, | |
| 250 | + | user_id: UserId, | |
| 251 | + | phone: NewContactPhone, | |
| 252 | + | ) -> Result<Option<ContactPhone>> { | |
| 253 | + | let conn = self.db.conn()?; | |
| 254 | + | subcollections::update_phone(&conn, phone_id, user_id, &phone) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | #[tracing::instrument(skip_all)] | |
| 258 | + | fn update_social_handle( | |
| 259 | + | &self, | |
| 260 | + | handle_id: SocialHandleId, | |
| 261 | + | user_id: UserId, | |
| 262 | + | handle: NewSocialHandle, | |
| 263 | + | ) -> Result<Option<SocialHandle>> { | |
| 264 | + | let conn = self.db.conn()?; | |
| 265 | + | subcollections::update_social_handle(&conn, handle_id, user_id, &handle) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | #[tracing::instrument(skip_all)] | |
| 269 | + | fn update_custom_field( | |
| 270 | + | &self, | |
| 271 | + | field_id: CustomFieldId, | |
| 272 | + | user_id: UserId, | |
| 273 | + | field: NewContactCustomField, | |
| 274 | + | ) -> Result<Option<ContactCustomField>> { | |
| 275 | + | let conn = self.db.conn()?; | |
| 276 | + | subcollections::update_custom_field(&conn, field_id, user_id, &field) | |
| 277 | + | } | |
| 278 | + | } |
| @@ -1,0 +1,272 @@ | |||
| 1 | + | //! Read paths for contacts. | |
| 2 | + | //! | |
| 3 | + | //! Every function takes an already-checked-out connection, so the parent trait | |
| 4 | + | //! impl owns the pool checkout. `promote_contact` writes a single flag but lives | |
| 5 | + | //! here: it is the terminal step of implicit-contact resolution and shares its | |
| 6 | + | //! query shape with `find_by_email`. | |
| 7 | + | ||
| 8 | + | use goingson_core::{Contact, ContactEmailEntry, ContactId, Result, UserId}; | |
| 9 | + | use std::collections::HashSet; | |
| 10 | + | ||
| 11 | + | use rusqlite::{Connection, params, params_from_iter}; | |
| 12 | + | ||
| 13 | + | use crate::utils::{ | |
| 14 | + | bind_placeholders, escape_like, execute, format_datetime_now, query_all, query_opt, | |
| 15 | + | }; | |
| 16 | + | ||
| 17 | + | use super::hydrate; | |
| 18 | + | use super::row::ContactRow; | |
| 19 | + | ||
| 20 | + | pub(super) fn list_all(conn: &Connection, user_id: UserId) -> Result<Vec<Contact>> { | |
| 21 | + | let rows = query_all( | |
| 22 | + | conn, | |
| 23 | + | r" | |
| 24 | + | SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at | |
| 25 | + | FROM contacts | |
| 26 | + | WHERE user_id = ? AND is_implicit = 0 | |
| 27 | + | ORDER BY display_name ASC | |
| 28 | + | ", | |
| 29 | + | params![user_id.to_string()], | |
| 30 | + | ContactRow::from_row, | |
| 31 | + | )?; | |
| 32 | + | ||
| 33 | + | hydrate::hydrate_contacts(conn, rows) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | pub(super) fn list_all_for_backup(conn: &Connection, user_id: UserId) -> Result<Vec<Contact>> { | |
| 37 | + | let rows = query_all( | |
| 38 | + | conn, | |
| 39 | + | r" | |
| 40 | + | SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at | |
| 41 | + | FROM contacts | |
| 42 | + | WHERE user_id = ? | |
| 43 | + | ORDER BY display_name ASC | |
| 44 | + | ", | |
| 45 | + | params![user_id.to_string()], | |
| 46 | + | ContactRow::from_row, | |
| 47 | + | )?; | |
| 48 | + | ||
| 49 | + | hydrate::hydrate_contacts(conn, rows) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | pub(super) fn get_by_id( | |
| 53 | + | conn: &Connection, | |
| 54 | + | id: ContactId, | |
| 55 | + | user_id: UserId, | |
| 56 | + | ) -> Result<Option<Contact>> { | |
| 57 | + | let row = query_opt( | |
| 58 | + | conn, | |
| 59 | + | r" | |
| 60 | + | SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at | |
| 61 | + | FROM contacts | |
| 62 | + | WHERE id = ? AND user_id = ? | |
| 63 | + | ", | |
| 64 | + | params![id.to_string(), user_id.to_string()], | |
| 65 | + | ContactRow::from_row, | |
| 66 | + | )?; | |
| 67 | + | ||
| 68 | + | match row { | |
| 69 | + | Some(r) => { | |
| 70 | + | let contacts = hydrate::hydrate_contacts(conn, vec![r])?; | |
| 71 | + | Ok(contacts.into_iter().next()) | |
| 72 | + | } | |
| 73 | + | None => Ok(None), | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | pub(super) fn list_by_tag(conn: &Connection, user_id: UserId, tag: &str) -> Result<Vec<Contact>> { | |
| 78 | + | // Tags stored as JSON array, use LIKE for matching | |
| 79 | + | let pattern = format!("%\"{}\"%", escape_like(tag)); | |
| 80 | + | let rows = query_all( | |
| 81 | + | conn, | |
| 82 | + | r" | |
| 83 | + | SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at | |
| 84 | + | FROM contacts | |
| 85 | + | WHERE user_id = ? AND tags LIKE ? ESCAPE '\' | |
| 86 | + | ORDER BY display_name ASC | |
| 87 | + | ", | |
| 88 | + | params![user_id.to_string(), &pattern], | |
| 89 | + | ContactRow::from_row, | |
| 90 | + | )?; | |
| 91 | + | ||
| 92 | + | hydrate::hydrate_contacts(conn, rows) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | pub(super) fn list_filtered( | |
| 96 | + | conn: &Connection, | |
| 97 | + | user_id: UserId, | |
| 98 | + | search: Option<&str>, | |
| 99 | + | tag: Option<&str>, | |
| 100 | + | include_implicit: bool, | |
| 101 | + | ) -> Result<Vec<Contact>> { | |
| 102 | + | let has_search = search.is_some_and(|s| !s.is_empty()); | |
| 103 | + | let has_tag = tag.is_some_and(|t| !t.is_empty()); | |
| 104 | + | ||
| 105 | + | if !has_search && !has_tag && !include_implicit { | |
| 106 | + | return list_all(conn, user_id); | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | let mut conditions = vec!["c.user_id = ?".to_string()]; | |
| 110 | + | if !include_implicit { | |
| 111 | + | conditions.push("c.is_implicit = 0".to_string()); | |
| 112 | + | } | |
| 113 | + | let mut binds: Vec<String> = vec![user_id.to_string()]; | |
| 114 | + | ||
| 115 | + | if let Some(t) = tag.filter(|t| !t.is_empty()) { | |
| 116 | + | conditions.push("c.tags LIKE ? ESCAPE '\\'".to_string()); | |
| 117 | + | binds.push(format!("%\"{}\"%", escape_like(t))); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | if let Some(s) = search.filter(|s| !s.is_empty()) { | |
| 121 | + | let search_pattern = format!("%{}%", escape_like(&s.to_lowercase())); | |
| 122 | + | // Search across contact fields and email addresses using a subquery | |
| 123 | + | conditions.push( | |
| 124 | + | "(LOWER(c.display_name) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(c.nickname, '')) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(c.company, '')) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(c.title, '')) LIKE ? ESCAPE '\\' OR LOWER(c.notes) LIKE ? ESCAPE '\\' OR EXISTS (SELECT 1 FROM contact_emails ce WHERE ce.contact_id = c.id AND LOWER(ce.address) LIKE ? ESCAPE '\\'))".to_string() | |
| 125 | + | ); | |
| 126 | + | // 6 binds for the search pattern | |
| 127 | + | for _ in 0..6 { | |
| 128 | + | binds.push(search_pattern.clone()); | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | let sql = format!( | |
| 133 | + | "SELECT c.id, c.display_name, c.nickname, c.company, c.title, c.notes, c.tags, c.birthday, c.timezone, c.external_source, c.external_id, c.is_implicit, c.created_at, c.updated_at FROM contacts c WHERE {} ORDER BY c.display_name ASC", | |
| 134 | + | conditions.join(" AND ") | |
| 135 | + | ); | |
| 136 | + | ||
| 137 | + | let rows = query_all( | |
| 138 | + | conn, | |
| 139 | + | &sql, | |
| 140 | + | params_from_iter(binds.iter()), | |
| 141 | + | ContactRow::from_row, | |
| 142 | + | )?; | |
| 143 | + | hydrate::hydrate_contacts(conn, rows) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | pub(super) fn list_email_directory( | |
| 147 | + | conn: &Connection, | |
| 148 | + | user_id: UserId, | |
| 149 | + | include_implicit: bool, | |
| 150 | + | ) -> Result<Vec<ContactEmailEntry>> { | |
| 151 | + | // One JOIN, one row per email address, no per-contact sub-collection | |
| 152 | + | // hydration (the compose autocomplete only needs name + address). | |
| 153 | + | let implicit_filter = if include_implicit { | |
| 154 | + | "" | |
| 155 | + | } else { | |
| 156 | + | "AND c.is_implicit = 0" | |
| 157 | + | }; | |
| 158 | + | let sql = format!( | |
| 159 | + | "SELECT c.display_name, ce.address, c.is_implicit \ | |
| 160 | + | FROM contacts c JOIN contact_emails ce ON ce.contact_id = c.id \ | |
| 161 | + | WHERE c.user_id = ? {implicit_filter} \ | |
| 162 | + | ORDER BY c.display_name ASC, ce.is_primary DESC" | |
| 163 | + | ); | |
| 164 | + | let rows: Vec<(String, String, i64)> = | |
| 165 | + | query_all(conn, &sql, params![user_id.to_string()], |row| { | |
| 166 | + | Ok((row.get(0)?, row.get(1)?, row.get(2)?)) | |
| 167 | + | })?; | |
| 168 | + | Ok(rows | |
| 169 | + | .into_iter() | |
| 170 | + | .map(|(name, email, is_implicit)| ContactEmailEntry { | |
| 171 | + | name, | |
| 172 | + | email, | |
| 173 | + | is_implicit: is_implicit != 0, | |
| 174 | + | }) | |
| 175 | + | .collect()) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | pub(super) fn find_by_email( | |
| 179 | + | conn: &Connection, | |
| 180 | + | user_id: UserId, | |
| 181 | + | email: &str, | |
| 182 | + | ) -> Result<Option<Contact>> { | |
| 183 | + | let row = query_opt( | |
| 184 | + | conn, | |
| 185 | + | r" | |
| 186 | + | SELECT c.id, c.display_name, c.nickname, c.company, c.title, c.notes, c.tags, c.birthday, c.timezone, c.external_source, c.external_id, c.is_implicit, c.created_at, c.updated_at | |
| 187 | + | FROM contacts c | |
| 188 | + | JOIN contact_emails ce ON ce.contact_id = c.id | |
| 189 | + | WHERE c.user_id = ? AND LOWER(ce.address) = LOWER(?) | |
| 190 | + | LIMIT 1 | |
| 191 | + | ", | |
| 192 | + | params![user_id.to_string(), email], | |
| 193 | + | ContactRow::from_row, | |
| 194 | + | )?; | |
| 195 | + | ||
| 196 | + | match row { | |
| 197 | + | Some(r) => { | |
| 198 | + | let contacts = hydrate::hydrate_contacts(conn, vec![r])?; | |
| 199 | + | Ok(contacts.into_iter().next()) | |
| 200 | + | } | |
| 201 | + | None => Ok(None), | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | pub(super) fn find_emails_in_contacts( | |
| 206 | + | conn: &Connection, | |
| 207 | + | user_id: UserId, | |
| 208 | + | addresses: &[&str], | |
| 209 | + | ) -> Result<HashSet<String>> { | |
| 210 | + | if addresses.is_empty() { | |
| 211 | + | return Ok(HashSet::new()); | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | let placeholders = bind_placeholders(addresses.len()); | |
| 215 | + | let query = format!( | |
| 216 | + | "SELECT DISTINCT LOWER(ce.address) FROM contact_emails ce JOIN contacts c ON ce.contact_id = c.id WHERE c.user_id = ? AND LOWER(ce.address) IN ({placeholders})" | |
| 217 | + | ); | |
| 218 | + | ||
| 219 | + | let mut binds: Vec<String> = Vec::with_capacity(addresses.len() + 1); | |
| 220 | + | binds.push(user_id.to_string()); | |
| 221 | + | binds.extend(addresses.iter().map(|a| a.to_lowercase())); | |
| 222 | + | ||
| 223 | + | let rows: Vec<String> = query_all(conn, &query, params_from_iter(binds), |row| row.get(0))?; | |
| 224 | + | ||
| 225 | + | Ok(rows.into_iter().collect()) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | pub(super) fn promote_contact( | |
| 229 | + | conn: &Connection, | |
| 230 | + | id: ContactId, | |
| 231 | + | user_id: UserId, | |
| 232 | + | ) -> Result<Option<Contact>> { | |
| 233 | + | let now = format_datetime_now(); | |
| 234 | + | let result = execute( | |
| 235 | + | conn, | |
| 236 | + | "UPDATE contacts SET is_implicit = 0, updated_at = ? WHERE id = ? AND user_id = ?", | |
| 237 | + | params![&now, id.to_string(), user_id.to_string()], | |
| 238 | + | )?; | |
| 239 | + | ||
| 240 | + | if result > 0 { | |
| 241 | + | get_by_id(conn, id, user_id) | |
| 242 | + | } else { | |
| 243 | + | Ok(None) | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | pub(super) fn find_by_external_id( | |
| 248 | + | conn: &Connection, | |
| 249 | + | source: &str, | |
| 250 | + | ext_id: &str, | |
| 251 | + | user_id: UserId, | |
| 252 | + | ) -> Result<Option<Contact>> { | |
| 253 | + | let row = query_opt( | |
| 254 | + | conn, | |
| 255 | + | r" | |
| 256 | + | SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at | |
| 257 | + | FROM contacts | |
| 258 | + | WHERE user_id = ? AND external_source = ? AND external_id = ? | |
| 259 | + | LIMIT 1 | |
| 260 | + | ", | |
| 261 | + | params![user_id.to_string(), source, ext_id], | |
| 262 | + | ContactRow::from_row, | |
| 263 | + | )?; | |
| 264 | + | ||
| 265 | + | match row { | |
| 266 | + | Some(r) => { | |
| 267 | + | let contacts = hydrate::hydrate_contacts(conn, vec![r])?; | |
| 268 | + | Ok(contacts.into_iter().next()) | |
| 269 | + | } | |
| 270 | + | None => Ok(None), | |
| 271 | + | } | |
| 272 | + | } |
| @@ -1,0 +1,214 @@ | |||
| 1 | + | //! Row structs for the five contact tables and their conversions to core models. | |
| 2 | + | //! | |
| 3 | + | //! Leaf module: it knows rusqlite and the core models, and nothing else in the crate. | |
| 4 | + | ||
| 5 | + | use goingson_core::{ | |
| 6 | + | Contact, ContactCustomField, ContactEmail, ContactPhone, CoreError, Result, SocialHandle, | |
| 7 | + | }; | |
| 8 | + | ||
| 9 | + | use crate::utils::{parse_datetime, parse_tags, parse_uuid}; | |
| 10 | + | ||
| 11 | + | // Row Structs | |
| 12 | + | ||
| 13 | + | #[derive(Debug, Clone)] | |
| 14 | + | pub(super) struct ContactRow { | |
| 15 | + | pub id: String, | |
| 16 | + | pub display_name: String, | |
| 17 | + | pub nickname: Option<String>, | |
| 18 | + | pub company: Option<String>, | |
| 19 | + | pub title: Option<String>, | |
| 20 | + | pub notes: String, | |
| 21 | + | pub tags: String, | |
| 22 | + | pub birthday: Option<String>, | |
| 23 | + | pub timezone: Option<String>, | |
| 24 | + | pub external_source: Option<String>, | |
| 25 | + | pub external_id: Option<String>, | |
| 26 | + | pub is_implicit: i32, | |
| 27 | + | pub created_at: String, | |
| 28 | + | pub updated_at: String, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | impl ContactRow { | |
| 32 | + | pub(super) fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> { | |
| 33 | + | Ok(Self { | |
| 34 | + | id: row.get("id")?, | |
| 35 | + | display_name: row.get("display_name")?, | |
| 36 | + | nickname: row.get("nickname")?, | |
| 37 | + | company: row.get("company")?, | |
| 38 | + | title: row.get("title")?, | |
| 39 | + | notes: row.get("notes")?, | |
| 40 | + | tags: row.get("tags")?, | |
| 41 | + | birthday: row.get("birthday")?, | |
| 42 | + | timezone: row.get("timezone")?, | |
| 43 | + | external_source: row.get("external_source")?, | |
| 44 | + | external_id: row.get("external_id")?, | |
| 45 | + | is_implicit: row.get("is_implicit")?, | |
| 46 | + | created_at: row.get("created_at")?, | |
| 47 | + | updated_at: row.get("updated_at")?, | |
| 48 | + | }) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | #[derive(Debug, Clone)] | |
| 53 | + | pub(super) struct ContactEmailRow { | |
| 54 | + | pub id: String, | |
| 55 | + | pub contact_id: String, | |
| 56 | + | pub address: String, | |
| 57 | + | pub label: String, | |
| 58 | + | pub is_primary: i32, | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | impl ContactEmailRow { | |
| 62 | + | pub(super) fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> { | |
| 63 | + | Ok(Self { | |
| 64 | + | id: row.get("id")?, | |
| 65 | + | contact_id: row.get("contact_id")?, | |
| 66 | + | address: row.get("address")?, | |
| 67 | + | label: row.get("label")?, | |
| 68 | + | is_primary: row.get("is_primary")?, | |
| 69 | + | }) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | #[derive(Debug, Clone)] | |
| 74 | + | pub(super) struct ContactPhoneRow { | |
| 75 | + | pub id: String, | |
| 76 | + | pub contact_id: String, | |
| 77 | + | pub number: String, | |
| 78 | + | pub label: String, | |
| 79 | + | pub is_primary: i32, | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | impl ContactPhoneRow { | |
| 83 | + | pub(super) fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> { | |
| 84 | + | Ok(Self { | |
| 85 | + | id: row.get("id")?, | |
| 86 | + | contact_id: row.get("contact_id")?, | |
| 87 | + | number: row.get("number")?, | |
| 88 | + | label: row.get("label")?, | |
| 89 | + | is_primary: row.get("is_primary")?, | |
| 90 | + | }) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | #[derive(Debug, Clone)] | |
| 95 | + | pub(super) struct SocialHandleRow { | |
| 96 | + | pub id: String, | |
| 97 | + | pub contact_id: String, | |
| 98 | + | pub platform: String, | |
| 99 | + | pub handle: String, | |
| 100 | + | pub url: Option<String>, | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | impl SocialHandleRow { | |
| 104 | + | pub(super) fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> { | |
| 105 | + | Ok(Self { | |
| 106 | + | id: row.get("id")?, | |
| 107 | + | contact_id: row.get("contact_id")?, | |
| 108 | + | platform: row.get("platform")?, | |
| 109 | + | handle: row.get("handle")?, | |
| 110 | + | url: row.get("url")?, | |
| 111 | + | }) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | #[derive(Debug, Clone)] | |
| 116 | + | pub(super) struct CustomFieldRow { | |
| 117 | + | pub id: String, | |
| 118 | + | pub contact_id: String, | |
| 119 | + | pub label: String, | |
| 120 | + | pub value: String, | |
| 121 | + | pub url: Option<String>, | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | impl CustomFieldRow { | |
| 125 | + | pub(super) fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> { | |
| 126 | + | Ok(Self { | |
| 127 | + | id: row.get("id")?, | |
| 128 | + | contact_id: row.get("contact_id")?, | |
| 129 | + | label: row.get("label")?, | |
| 130 | + | value: row.get("value")?, | |
| 131 | + | url: row.get("url")?, | |
| 132 | + | }) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // Row Conversions | |
| 137 | + | ||
| 138 | + | pub(super) fn contact_email_from_row(row: ContactEmailRow) -> Result<ContactEmail> { | |
| 139 | + | Ok(ContactEmail { | |
| 140 | + | id: parse_uuid(&row.id)?.into(), | |
| 141 | + | contact_id: parse_uuid(&row.contact_id)?.into(), | |
| 142 | + | address: row.address, | |
| 143 | + | label: row.label, | |
| 144 | + | is_primary: row.is_primary != 0, | |
| 145 | + | }) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | pub(super) fn contact_phone_from_row(row: ContactPhoneRow) -> Result<ContactPhone> { | |
| 149 | + | Ok(ContactPhone { | |
| 150 | + | id: parse_uuid(&row.id)?.into(), | |
| 151 | + | contact_id: parse_uuid(&row.contact_id)?.into(), | |
| 152 | + | number: row.number, | |
| 153 | + | label: row.label, | |
| 154 | + | is_primary: row.is_primary != 0, | |
| 155 | + | }) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | pub(super) fn social_handle_from_row(row: SocialHandleRow) -> Result<SocialHandle> { | |
| 159 | + | Ok(SocialHandle { | |
| 160 | + | id: parse_uuid(&row.id)?.into(), | |
| 161 | + | contact_id: parse_uuid(&row.contact_id)?.into(), | |
| 162 | + | platform: row.platform, | |
| 163 | + | handle: row.handle, | |
| 164 | + | url: row.url, | |
| 165 | + | }) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | pub(super) fn custom_field_from_row(row: CustomFieldRow) -> Result<ContactCustomField> { | |
| 169 | + | Ok(ContactCustomField { | |
| 170 | + | id: parse_uuid(&row.id)?.into(), | |
| 171 | + | contact_id: parse_uuid(&row.contact_id)?.into(), | |
| 172 | + | label: row.label, | |
| 173 | + | value: row.value, | |
| 174 | + | url: row.url, | |
| 175 | + | }) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | pub(super) fn contact_from_row( | |
| 179 | + | row: ContactRow, | |
| 180 | + | emails: Vec<ContactEmail>, | |
| 181 | + | phones: Vec<ContactPhone>, | |
| 182 | + | social_handles: Vec<SocialHandle>, | |
| 183 | + | custom_fields: Vec<ContactCustomField>, | |
| 184 | + | ) -> Result<Contact> { | |
| 185 | + | let birthday = row | |
| 186 | + | .birthday | |
| 187 | + | .as_deref() | |
| 188 | + | .map(|s| { | |
| 189 | + | chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") | |
| 190 | + | .map_err(|e| CoreError::database_msg(format!("Invalid birthday: {e}"))) | |
| 191 | + | }) | |
| 192 | + | .transpose()?; | |
| 193 | + | ||
| 194 | + | Ok(Contact { | |
| 195 | + | id: parse_uuid(&row.id)?.into(), | |
| 196 | + | display_name: row.display_name, | |
| 197 | + | nickname: row.nickname, | |
| 198 | + | company: row.company, | |
| 199 | + | title: row.title, | |
| 200 | + | notes: row.notes, | |
| 201 | + | tags: parse_tags(&row.tags), | |
| 202 | + | birthday, | |
| 203 | + | timezone: row.timezone, | |
| 204 | + | external_source: row.external_source, | |
| 205 | + | external_id: row.external_id, | |
| 206 | + | is_implicit: row.is_implicit != 0, | |
| 207 | + | emails, | |
| 208 | + | phones, | |
| 209 | + | social_handles, | |
| 210 | + | custom_fields, | |
| 211 | + | created_at: parse_datetime(&row.created_at)?, | |
| 212 | + | updated_at: parse_datetime(&row.updated_at)?, | |
| 213 | + | }) | |
| 214 | + | } |
| @@ -1,0 +1,421 @@ | |||
| 1 | + | //! Mutations over the four contact sub-collection tables. | |
| 2 | + | //! | |
| 3 | + | //! Twelve functions that are three shapes (add, remove, update) replicated over | |
| 4 | + | //! emails, phones, social handles and custom fields. They stay in one module so | |
| 5 | + | //! the repetition stays visible rather than spread over four files that each say | |
| 6 | + | //! the same thing. | |
| 7 | + | ||
| 8 | + | use goingson_core::{ | |
| 9 | + | ContactCustomField, ContactEmail, ContactEmailId, ContactId, ContactPhone, ContactPhoneId, | |
| 10 | + | CoreError, CustomFieldId, NewContactCustomField, NewContactEmail, NewContactPhone, | |
| 11 | + | NewSocialHandle, Result, SocialHandle, SocialHandleId, UserId, | |
| 12 | + | }; | |
| 13 | + | ||
| 14 | + | use rusqlite::{Connection, params}; | |
| 15 | + | ||
| 16 | + | use crate::utils::execute; | |
| 17 | + | ||
| 18 | + | pub(super) fn add_email( | |
| 19 | + | conn: &Connection, | |
| 20 | + | contact_id: ContactId, | |
| 21 | + | user_id: UserId, | |
| 22 | + | email: NewContactEmail, | |
| 23 | + | ) -> Result<ContactEmail> { | |
| 24 | + | // Verify contact ownership | |
| 25 | + | let exists: i64 = conn | |
| 26 | + | .query_row( | |
| 27 | + | "SELECT COUNT(*) FROM contacts WHERE id = ? AND user_id = ?", | |
| 28 | + | params![contact_id.to_string(), user_id.to_string()], | |
| 29 | + | |row| row.get(0), | |
| 30 | + | ) | |
| 31 | + | .map_err(CoreError::database)?; | |
| 32 | + | ||
| 33 | + | if exists == 0 { | |
| 34 | + | return Err(CoreError::not_found("contact", contact_id)); | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | let id = ContactEmailId::new(); | |
| 38 | + | execute( | |
| 39 | + | conn, | |
| 40 | + | "INSERT INTO contact_emails (id, contact_id, address, label, is_primary) VALUES (?, ?, ?, ?, ?)", | |
| 41 | + | params![ | |
| 42 | + | id.to_string(), | |
| 43 | + | contact_id.to_string(), | |
| 44 | + | &email.address, | |
| 45 | + | &email.label, | |
| 46 | + | email.is_primary as i32 | |
| 47 | + | ], | |
| 48 | + | )?; | |
| 49 | + | ||
| 50 | + | Ok(ContactEmail { | |
| 51 | + | id, | |
| 52 | + | contact_id, | |
| 53 | + | address: email.address, | |
| 54 | + | label: email.label, | |
| 55 | + | is_primary: email.is_primary, | |
| 56 | + | }) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | pub(super) fn remove_email( | |
| 60 | + | conn: &Connection, | |
| 61 | + | email_id: ContactEmailId, | |
| 62 | + | user_id: UserId, | |
| 63 | + | ) -> Result<bool> { | |
| 64 | + | // Verify ownership via JOIN | |
| 65 | + | let result = execute( | |
| 66 | + | conn, | |
| 67 | + | r" | |
| 68 | + | DELETE FROM contact_emails | |
| 69 | + | WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?) | |
| 70 | + | ", | |
| 71 | + | params![email_id.to_string(), user_id.to_string()], | |
| 72 | + | )?; | |
| 73 | + | ||
| 74 | + | Ok(result > 0) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | pub(super) fn update_email( | |
| 78 | + | conn: &Connection, | |
| 79 | + | email_id: ContactEmailId, | |
| 80 | + | user_id: UserId, | |
| 81 | + | email: &NewContactEmail, | |
| 82 | + | ) -> Result<Option<ContactEmail>> { | |
| 83 | + | let result = execute( | |
| 84 | + | conn, | |
| 85 | + | r" | |
| 86 | + | UPDATE contact_emails | |
| 87 | + | SET address = ?, label = ?, is_primary = ? | |
| 88 | + | WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?) | |
| 89 | + | ", | |
| 90 | + | params![ | |
| 91 | + | &email.address, | |
| 92 | + | &email.label, | |
| 93 | + | email.is_primary as i32, | |
| 94 | + | email_id.to_string(), | |
| 95 | + | user_id.to_string() | |
| 96 | + | ], | |
| 97 | + | )?; | |
| 98 | + | ||
| 99 | + | if result == 0 { | |
| 100 | + | return Ok(None); | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | let row: (String, String, String, i32) = conn | |
| 104 | + | .query_row( | |
| 105 | + | "SELECT contact_id, address, label, is_primary FROM contact_emails WHERE id = ?", | |
| 106 | + | params![email_id.to_string()], | |
| 107 | + | |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), | |
| 108 | + | ) | |
| 109 | + | .map_err(CoreError::database)?; | |
| 110 | + | ||
| 111 | + | Ok(Some(ContactEmail { | |
| 112 | + | id: email_id, | |
| 113 | + | contact_id: crate::utils::parse_uuid(&row.0)?.into(), | |
| 114 | + | address: row.1, | |
| 115 | + | label: row.2, | |
| 116 | + | is_primary: row.3 != 0, | |
| 117 | + | })) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | pub(super) fn add_phone( | |
| 121 | + | conn: &Connection, | |
| 122 | + | contact_id: ContactId, | |
| 123 | + | user_id: UserId, | |
| 124 | + | phone: NewContactPhone, | |
| 125 | + | ) -> Result<ContactPhone> { | |
| 126 | + | // Verify contact ownership | |
| 127 | + | let exists: i64 = conn | |
| 128 | + | .query_row( | |
| 129 | + | "SELECT COUNT(*) FROM contacts WHERE id = ? AND user_id = ?", | |
| 130 | + | params![contact_id.to_string(), user_id.to_string()], | |
| 131 | + | |row| row.get(0), | |
| 132 | + | ) | |
| 133 | + | .map_err(CoreError::database)?; | |
| 134 | + | ||
| 135 | + | if exists == 0 { | |
| 136 | + | return Err(CoreError::not_found("contact", contact_id)); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | let id = ContactPhoneId::new(); | |
| 140 | + | execute( | |
| 141 | + | conn, | |
| 142 | + | "INSERT INTO contact_phones (id, contact_id, number, label, is_primary) VALUES (?, ?, ?, ?, ?)", | |
| 143 | + | params![ | |
| 144 | + | id.to_string(), | |
| 145 | + | contact_id.to_string(), | |
| 146 | + | &phone.number, | |
| 147 | + | &phone.label, | |
| 148 | + | phone.is_primary as i32 | |
| 149 | + | ], | |
| 150 | + | )?; | |
| 151 | + | ||
| 152 | + | Ok(ContactPhone { | |
| 153 | + | id, | |
| 154 | + | contact_id, | |
| 155 | + | number: phone.number, | |
| 156 | + | label: phone.label, | |
| 157 | + | is_primary: phone.is_primary, | |
| 158 | + | }) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | pub(super) fn remove_phone( | |
| 162 | + | conn: &Connection, | |
| 163 | + | phone_id: ContactPhoneId, | |
| 164 | + | user_id: UserId, | |
| 165 | + | ) -> Result<bool> { | |
| 166 | + | let result = execute( | |
| 167 | + | conn, | |
| 168 | + | r" | |
| 169 | + | DELETE FROM contact_phones | |
| 170 | + | WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?) | |
| 171 | + | ", | |
| 172 | + | params![phone_id.to_string(), user_id.to_string()], | |
| 173 | + | )?; | |
| 174 | + | ||
| 175 | + | Ok(result > 0) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | pub(super) fn update_phone( | |
| 179 | + | conn: &Connection, | |
| 180 | + | phone_id: ContactPhoneId, | |
| 181 | + | user_id: UserId, | |
| 182 | + | phone: &NewContactPhone, | |
| 183 | + | ) -> Result<Option<ContactPhone>> { | |
| 184 | + | let result = execute( | |
| 185 | + | conn, | |
| 186 | + | r" | |
| 187 | + | UPDATE contact_phones | |
| 188 | + | SET number = ?, label = ?, is_primary = ? | |
| 189 | + | WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?) | |
| 190 | + | ", | |
| 191 | + | params![ | |
| 192 | + | &phone.number, | |
| 193 | + | &phone.label, | |
| 194 | + | phone.is_primary as i32, | |
| 195 | + | phone_id.to_string(), | |
| 196 | + | user_id.to_string() | |
| 197 | + | ], | |
| 198 | + | )?; | |
| 199 | + | ||
| 200 | + | if result == 0 { | |
| 201 | + | return Ok(None); | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | let row: (String, String, String, i32) = conn | |
| 205 | + | .query_row( | |
| 206 | + | "SELECT contact_id, number, label, is_primary FROM contact_phones WHERE id = ?", | |
| 207 | + | params![phone_id.to_string()], | |
| 208 | + | |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), | |
| 209 | + | ) | |
| 210 | + | .map_err(CoreError::database)?; | |
| 211 | + | ||
| 212 | + | Ok(Some(ContactPhone { | |
| 213 | + | id: phone_id, | |
| 214 | + | contact_id: crate::utils::parse_uuid(&row.0)?.into(), | |
| 215 | + | number: row.1, | |
| 216 | + | label: row.2, | |
| 217 | + | is_primary: row.3 != 0, | |
| 218 | + | })) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | pub(super) fn add_social_handle( | |
| 222 | + | conn: &Connection, | |
| 223 | + | contact_id: ContactId, | |
| 224 | + | user_id: UserId, | |
| 225 | + | handle: NewSocialHandle, | |
| 226 | + | ) -> Result<SocialHandle> { | |
| 227 | + | // Verify contact ownership | |
| 228 | + | let exists: i64 = conn | |
| 229 | + | .query_row( | |
| 230 | + | "SELECT COUNT(*) FROM contacts WHERE id = ? AND user_id = ?", | |
| 231 | + | params![contact_id.to_string(), user_id.to_string()], | |
| 232 | + | |row| row.get(0), | |
| 233 | + | ) | |
| 234 | + | .map_err(CoreError::database)?; | |
| 235 | + | ||
| 236 | + | if exists == 0 { | |
| 237 | + | return Err(CoreError::not_found("contact", contact_id)); | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | let id = SocialHandleId::new(); | |
| 241 | + | execute( | |
| 242 | + | conn, | |
| 243 | + | "INSERT INTO contact_social_handles (id, contact_id, platform, handle, url) VALUES (?, ?, ?, ?, ?)", | |
| 244 | + | params![ | |
| 245 | + | id.to_string(), | |
| 246 | + | contact_id.to_string(), | |
| 247 | + | &handle.platform, | |
| 248 | + | &handle.handle, | |
| 249 | + | &handle.url | |
| 250 | + | ], | |
| 251 | + | )?; | |
| 252 | + | ||
| 253 | + | Ok(SocialHandle { | |
| 254 | + | id, | |
| 255 | + | contact_id, | |
| 256 | + | platform: handle.platform, | |
| 257 | + | handle: handle.handle, | |
| 258 | + | url: handle.url, | |
| 259 | + | }) | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | pub(super) fn remove_social_handle( | |
| 263 | + | conn: &Connection, | |
| 264 | + | handle_id: SocialHandleId, | |
| 265 | + | user_id: UserId, | |
| 266 | + | ) -> Result<bool> { | |
| 267 | + | let result = execute( | |
| 268 | + | conn, | |
| 269 | + | r" | |
| 270 | + | DELETE FROM contact_social_handles | |
| 271 | + | WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?) | |
| 272 | + | ", | |
| 273 | + | params![handle_id.to_string(), user_id.to_string()], | |
| 274 | + | )?; | |
| 275 | + | ||
| 276 | + | Ok(result > 0) | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | pub(super) fn update_social_handle( | |
| 280 | + | conn: &Connection, | |
| 281 | + | handle_id: SocialHandleId, | |
| 282 | + | user_id: UserId, | |
| 283 | + | handle: &NewSocialHandle, | |
| 284 | + | ) -> Result<Option<SocialHandle>> { | |
| 285 | + | let result = execute( | |
| 286 | + | conn, | |
| 287 | + | r" | |
| 288 | + | UPDATE contact_social_handles | |
| 289 | + | SET platform = ?, handle = ?, url = ? | |
| 290 | + | WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?) | |
| 291 | + | ", | |
| 292 | + | params![ | |
| 293 | + | &handle.platform, | |
| 294 | + | &handle.handle, | |
| 295 | + | &handle.url, | |
| 296 | + | handle_id.to_string(), | |
| 297 | + | user_id.to_string() | |
| 298 | + | ], | |
| 299 | + | )?; | |
| 300 | + | ||
| 301 | + | if result == 0 { | |
| 302 | + | return Ok(None); | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | let row: (String, String, String, Option<String>) = conn | |
| 306 | + | .query_row( | |
| 307 | + | "SELECT contact_id, platform, handle, url FROM contact_social_handles WHERE id = ?", | |
| 308 | + | params![handle_id.to_string()], | |
| 309 | + | |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), | |
| 310 | + | ) | |
| 311 | + | .map_err(CoreError::database)?; | |
| 312 | + | ||
| 313 | + | Ok(Some(SocialHandle { | |
| 314 | + | id: handle_id, | |
| 315 | + | contact_id: crate::utils::parse_uuid(&row.0)?.into(), | |
| 316 | + | platform: row.1, | |
| 317 | + | handle: row.2, | |
| 318 | + | url: row.3, | |
| 319 | + | })) | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | pub(super) fn add_custom_field( | |
| 323 | + | conn: &Connection, | |
| 324 | + | contact_id: ContactId, | |
| 325 | + | user_id: UserId, | |
| 326 | + | field: NewContactCustomField, | |
| 327 | + | ) -> Result<ContactCustomField> { | |
| 328 | + | // Verify contact ownership | |
| 329 | + | let exists: i64 = conn | |
| 330 | + | .query_row( | |
| 331 | + | "SELECT COUNT(*) FROM contacts WHERE id = ? AND user_id = ?", | |
| 332 | + | params![contact_id.to_string(), user_id.to_string()], | |
| 333 | + | |row| row.get(0), | |
| 334 | + | ) | |
| 335 | + | .map_err(CoreError::database)?; | |
| 336 | + | ||
| 337 | + | if exists == 0 { | |
| 338 | + | return Err(CoreError::not_found("contact", contact_id)); | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | let id = CustomFieldId::new(); | |
| 342 | + | execute( | |
| 343 | + | conn, | |
| 344 | + | "INSERT INTO contact_custom_fields (id, contact_id, label, value, url) VALUES (?, ?, ?, ?, ?)", | |
| 345 | + | params![ | |
| 346 | + | id.to_string(), | |
| 347 | + | contact_id.to_string(), | |
| 348 | + | &field.label, | |
| 349 | + | &field.value, | |
| 350 | + | &field.url | |
| 351 | + | ], | |
| 352 | + | )?; | |
| 353 | + | ||
| 354 | + | Ok(ContactCustomField { | |
| 355 | + | id, | |
| 356 | + | contact_id, | |
| 357 | + | label: field.label, | |
| 358 | + | value: field.value, | |
| 359 | + | url: field.url, | |
| 360 | + | }) | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | pub(super) fn remove_custom_field( | |
| 364 | + | conn: &Connection, | |
| 365 | + | field_id: CustomFieldId, | |
| 366 | + | user_id: UserId, | |
| 367 | + | ) -> Result<bool> { | |
| 368 | + | let result = execute( | |
| 369 | + | conn, | |
| 370 | + | r" | |
| 371 | + | DELETE FROM contact_custom_fields | |
| 372 | + | WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?) | |
| 373 | + | ", | |
| 374 | + | params![field_id.to_string(), user_id.to_string()], | |
| 375 | + | )?; | |
| 376 | + | ||
| 377 | + | Ok(result > 0) | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | pub(super) fn update_custom_field( | |
| 381 | + | conn: &Connection, | |
| 382 | + | field_id: CustomFieldId, | |
| 383 | + | user_id: UserId, | |
| 384 | + | field: &NewContactCustomField, | |
| 385 | + | ) -> Result<Option<ContactCustomField>> { | |
| 386 | + | let result = execute( | |
| 387 | + | conn, | |
| 388 | + | r" | |
| 389 | + | UPDATE contact_custom_fields | |
| 390 | + | SET label = ?, value = ?, url = ? | |
| 391 | + | WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?) | |
| 392 | + | ", | |
| 393 | + | params![ | |
| 394 | + | &field.label, | |
| 395 | + | &field.value, | |
| 396 | + | &field.url, | |
| 397 | + | field_id.to_string(), | |
| 398 | + | user_id.to_string() | |
| 399 | + | ], | |
| 400 | + | )?; | |
| 401 | + | ||
| 402 | + | if result == 0 { | |
| 403 | + | return Ok(None); | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | let row: (String, String, String, Option<String>) = conn | |
| 407 | + | .query_row( | |
| 408 | + | "SELECT contact_id, label, value, url FROM contact_custom_fields WHERE id = ?", | |
| 409 | + | params![field_id.to_string()], | |
| 410 | + | |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), | |
| 411 | + | ) | |
| 412 | + | .map_err(CoreError::database)?; | |
| 413 | + | ||
| 414 | + | Ok(Some(ContactCustomField { | |
| 415 | + | id: field_id, | |
| 416 | + | contact_id: crate::utils::parse_uuid(&row.0)?.into(), | |
| 417 | + | label: row.1, | |
| 418 | + | value: row.2, | |
| 419 | + | url: row.3, | |
| 420 | + | })) | |
| 421 | + | } |
| @@ -1,1209 +1,0 @@ | |||
| 1 | - | //! SQLite implementation of the EmailRepository. | |
| 2 | - | //! | |
| 3 | - | //! Manages email messages with support for: | |
| 4 | - | //! - IMAP synchronization tracking (message_id, imap_uid) | |
| 5 | - | //! - Threading via in_reply_to and thread_id | |
| 6 | - | //! - Read/archived status | |
| 7 | - | //! - Project associations | |
| 8 | - | //! - Snoozing and waiting-for-response tracking | |
| 9 | - | ||
| 10 | - | use chrono::{DateTime, Utc}; | |
| 11 | - | use goingson_core::{ | |
| 12 | - | BodyFormat, CoreError, DbValue, Email, EmailAccountId, EmailId, EmailRepository, EmailThread, | |
| 13 | - | NewEmail, NewEmailWithTracking, ParseableEnum, ProjectId, Result, UserId, | |
| 14 | - | }; | |
| 15 | - | use std::collections::HashMap; | |
| 16 | - | use std::collections::HashSet; | |
| 17 | - | ||
| 18 | - | use crate::utils::{ | |
| 19 | - | bind_placeholders, execute, format_datetime, format_datetime_now, format_datetime_opt, | |
| 20 | - | parse_datetime, parse_uuid, parse_uuid_opt, query_all, query_opt, | |
| 21 | - | }; | |
| 22 | - | use rusqlite::{params, params_from_iter}; | |
| 23 | - | ||
| 24 | - | use crate::Db; | |
| 25 | - | ||
| 26 | - | /// Column list for SELECT queries - avoids duplication across methods. | |
| 27 | - | const EMAIL_SELECT_COLUMNS: &str = r"e.id, e.project_id, p.name as project_name, e.from_address, e.to_address, | |
| 28 | - | e.subject, e.body, e.body_format, e.html_body, e.is_read, e.is_archived, e.received_at, e.message_id, | |
| 29 | - | e.in_reply_to, e.thread_id, e.email_account_id, e.is_outgoing, e.imap_uid, e.source_folder, | |
| 30 | - | e.attachment_meta, e.labels, e.is_draft, e.cc_address, e.bcc_address, e.draft_account_id, | |
| 31 | - | e.snoozed_until, e.waiting_for_response, e.waiting_since, e.expected_response_date, | |
| 32 | - | e.body_truncated, e.jmap_id, | |
| 33 | - | e.queued_at, e.send_after, e.send_attempts, e.send_error"; | |
| 34 | - | ||
| 35 | - | /// Same shape as [`EMAIL_SELECT_COLUMNS`] but with the two heavy body columns | |
| 36 | - | /// blanked, for flat list views that never render a body (Perf S3). `body_truncated` | |
| 37 | - | /// is forced to 1 so the reader knows to re-fetch the full body on open. | |
| 38 | - | const EMAIL_LIST_COLUMNS: &str = r"e.id, e.project_id, p.name as project_name, e.from_address, e.to_address, | |
| 39 | - | e.subject, '' AS body, e.body_format, NULL AS html_body, e.is_read, e.is_archived, e.received_at, e.message_id, | |
| 40 | - | e.in_reply_to, e.thread_id, e.email_account_id, e.is_outgoing, e.imap_uid, e.source_folder, | |
| 41 | - | e.attachment_meta, e.labels, e.is_draft, e.cc_address, e.bcc_address, e.draft_account_id, | |
| 42 | - | e.snoozed_until, e.waiting_for_response, e.waiting_since, e.expected_response_date, | |
| 43 | - | 1 AS body_truncated, e.jmap_id, | |
| 44 | - | e.queued_at, e.send_after, e.send_attempts, e.send_error"; | |
| 45 | - | ||
| 46 | - | /// Upper bound on a flat metadata list so it can never materialize an unbounded | |
| 47 | - | /// number of rows (the list UI paginates via `list_threaded`; this flat path is a | |
| 48 | - | /// safety-capped fallback). | |
| 49 | - | const EMAIL_LIST_CAP: i64 = 1000; | |
| 50 | - | ||
| 51 | - | #[derive(Debug, Clone)] | |
| 52 | - | struct EmailRow { | |
| 53 | - | pub id: String, | |
| 54 | - | pub project_id: Option<String>, | |
| 55 | - | pub project_name: Option<String>, | |
| 56 | - | pub from_address: String, | |
| 57 | - | pub to_address: String, | |
| 58 | - | pub subject: String, | |
| 59 | - | pub body: String, | |
| 60 | - | pub body_format: String, | |
| 61 | - | pub html_body: Option<String>, | |
| 62 | - | pub is_read: i32, | |
| 63 | - | pub is_archived: i32, | |
| 64 | - | pub received_at: String, | |
| 65 | - | pub message_id: Option<String>, | |
| 66 | - | pub in_reply_to: Option<String>, | |
| 67 | - | pub thread_id: Option<String>, | |
| 68 | - | pub email_account_id: Option<String>, | |
| 69 | - | pub is_outgoing: i32, | |
| 70 | - | pub imap_uid: Option<i64>, | |
| 71 | - | pub source_folder: Option<String>, | |
| 72 | - | pub attachment_meta: Option<String>, | |
| 73 | - | pub labels: String, | |
| 74 | - | pub is_draft: i32, | |
| 75 | - | pub cc_address: Option<String>, | |
| 76 | - | pub bcc_address: Option<String>, | |
| 77 | - | pub draft_account_id: Option<String>, | |
| 78 | - | pub snoozed_until: Option<String>, | |
| 79 | - | pub waiting_for_response: i32, | |
| 80 | - | pub waiting_since: Option<String>, | |
| 81 | - | pub expected_response_date: Option<String>, | |
| 82 | - | pub body_truncated: i32, | |
| 83 | - | pub jmap_id: Option<String>, | |
| 84 | - | pub queued_at: Option<String>, | |
| 85 | - | pub send_after: Option<String>, | |
| 86 | - | pub send_attempts: i32, | |
| 87 | - | pub send_error: Option<String>, | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | impl EmailRow { | |
| 91 | - | fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> { | |
| 92 | - | Ok(Self { | |
| 93 | - | id: row.get("id")?, | |
| 94 | - | project_id: row.get("project_id")?, | |
| 95 | - | project_name: row.get("project_name")?, | |
| 96 | - | from_address: row.get("from_address")?, | |
| 97 | - | to_address: row.get("to_address")?, | |
| 98 | - | subject: row.get("subject")?, | |
| 99 | - | body: row.get("body")?, | |
| 100 | - | body_format: row.get("body_format")?, | |
| 101 | - | html_body: row.get("html_body")?, | |
| 102 | - | is_read: row.get("is_read")?, | |
| 103 | - | is_archived: row.get("is_archived")?, | |
| 104 | - | received_at: row.get("received_at")?, | |
| 105 | - | message_id: row.get("message_id")?, | |
| 106 | - | in_reply_to: row.get("in_reply_to")?, | |
| 107 | - | thread_id: row.get("thread_id")?, | |
| 108 | - | email_account_id: row.get("email_account_id")?, | |
| 109 | - | is_outgoing: row.get("is_outgoing")?, | |
| 110 | - | imap_uid: row.get("imap_uid")?, | |
| 111 | - | source_folder: row.get("source_folder")?, | |
| 112 | - | attachment_meta: row.get("attachment_meta")?, | |
| 113 | - | labels: row.get("labels")?, | |
| 114 | - | is_draft: row.get("is_draft")?, | |
| 115 | - | cc_address: row.get("cc_address")?, | |
| 116 | - | bcc_address: row.get("bcc_address")?, | |
| 117 | - | draft_account_id: row.get("draft_account_id")?, | |
| 118 | - | snoozed_until: row.get("snoozed_until")?, | |
| 119 | - | waiting_for_response: row.get("waiting_for_response")?, | |
| 120 | - | waiting_since: row.get("waiting_since")?, | |
| 121 | - | expected_response_date: row.get("expected_response_date")?, | |
| 122 | - | body_truncated: row.get("body_truncated")?, | |
| 123 | - | jmap_id: row.get("jmap_id")?, | |
| 124 | - | queued_at: row.get("queued_at")?, | |
| 125 | - | send_after: row.get("send_after")?, | |
| 126 | - | send_attempts: row.get("send_attempts")?, | |
| 127 | - | send_error: row.get("send_error")?, | |
| 128 | - | }) | |
| 129 | - | } | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | impl TryFrom<EmailRow> for Email { | |
| 133 | - | type Error = CoreError; | |
| 134 | - | ||
| 135 | - | fn try_from(row: EmailRow) -> std::result::Result<Self, Self::Error> { | |
| 136 | - | Ok(Email { | |
| 137 | - | id: parse_uuid(&row.id)?.into(), | |
| 138 | - | project_id: parse_uuid_opt(row.project_id.as_deref())?.map(Into::into), | |
| 139 | - | project_name: row.project_name, | |
| 140 | - | from: row.from_address, | |
| 141 | - | to: row.to_address, | |
| 142 | - | subject: row.subject, | |
| 143 | - | body: row.body, | |
| 144 | - | body_format: BodyFormat::from_str_or_default(&row.body_format), | |
| 145 | - | html_body: row.html_body, | |
| 146 | - | body_truncated: row.body_truncated != 0, | |
| 147 | - | jmap_id: row.jmap_id, | |
| 148 | - | queued_at: row | |
| 149 | - | .queued_at | |
| 150 | - | .as_ref() | |
| 151 | - | .map(|s| parse_datetime(s)) | |
| 152 | - | .transpose()?, | |
| 153 | - | send_after: row | |
| 154 | - | .send_after | |
| 155 | - | .as_ref() | |
| 156 | - | .map(|s| parse_datetime(s)) | |
| 157 | - | .transpose()?, | |
| 158 | - | send_attempts: row.send_attempts, | |
| 159 | - | send_error: row.send_error, | |
| 160 | - | is_read: row.is_read != 0, | |
| 161 | - | is_archived: row.is_archived != 0, | |
| 162 | - | received_at: parse_datetime(&row.received_at)?, | |
| 163 | - | message_id: row.message_id, | |
| 164 | - | in_reply_to: row.in_reply_to, | |
| 165 | - | thread_id: row.thread_id, | |
| 166 | - | email_account_id: parse_uuid_opt(row.email_account_id.as_deref())?.map(Into::into), | |
| 167 | - | is_outgoing: row.is_outgoing != 0, | |
| 168 | - | imap_uid: row.imap_uid, | |
| 169 | - | source_folder: row.source_folder, | |
| 170 | - | attachment_meta: row.attachment_meta, | |
| 171 | - | labels: serde_json::from_str(&row.labels).unwrap_or_default(), | |
| 172 | - | is_draft: row.is_draft != 0, | |
| 173 | - | cc_address: row.cc_address, | |
| 174 | - | bcc_address: row.bcc_address, | |
| 175 | - | draft_account_id: parse_uuid_opt(row.draft_account_id.as_deref())?.map(Into::into), | |
| 176 | - | snoozed_until: row | |
| 177 | - | .snoozed_until | |
| 178 | - | .as_ref() | |
| 179 | - | .map(|s| parse_datetime(s)) | |
| 180 | - | .transpose()?, | |
| 181 | - | waiting_for_response: row.waiting_for_response != 0, | |
| 182 | - | waiting_since: row | |
| 183 | - | .waiting_since | |
| 184 | - | .as_ref() | |
| 185 | - | .map(|s| parse_datetime(s)) | |
| 186 | - | .transpose()?, | |
| 187 | - | expected_response_date: row | |
| 188 | - | .expected_response_date | |
| 189 | - | .as_ref() | |
| 190 | - | .map(|s| parse_datetime(s)) | |
| 191 | - | .transpose()?, | |
| 192 | - | }) | |
| 193 | - | } | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | /// SQLite-backed implementation of [`EmailRepository`]. | |
| 197 | - | /// | |
| 198 | - | /// Manages email messages with threading support, snoozing, and | |
| 199 | - | /// waiting-for-response tracking. Integrates with IMAP sync via message_id. | |
| 200 | - | pub struct SqliteEmailRepository { | |
| 201 | - | db: Db, | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | impl SqliteEmailRepository { | |
| 205 | - | /// Creates a new repository instance with the given connection pool. | |
| 206 | - | #[tracing::instrument(skip_all)] | |
| 207 | - | pub fn new(db: Db) -> Self { | |
| 208 | - | Self { db } | |
| 209 | - | } | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | impl EmailRepository for SqliteEmailRepository { | |
| 213 | - | #[tracing::instrument(skip_all)] | |
| 214 | - | fn list_all_for_backup(&self, user_id: UserId) -> Result<Vec<Email>> { | |
| 215 | - | let conn = self.db.conn()?; | |
| 216 | - | let query = format!( | |
| 217 | - | "SELECT {EMAIL_SELECT_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? ORDER BY e.received_at DESC" | |
| 218 | - | ); | |
| 219 | - | let rows = query_all( | |
| 220 | - | &conn, | |
| 221 | - | &query, | |
| 222 | - | params![user_id.to_string(), user_id.to_string()], | |
| 223 | - | EmailRow::from_row, | |
| 224 | - | )?; | |
| 225 | - | rows.into_iter().map(Email::try_from).collect() | |
| 226 | - | } | |
| 227 | - | ||
| 228 | - | fn list_all(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>> { | |
| 229 | - | let conn = self.db.conn()?; | |
| 230 | - | let archived_filter = if include_archived { | |
| 231 | - | "" | |
| 232 | - | } else { | |
| 233 | - | "AND e.is_archived = 0" | |
| 234 | - | }; | |
| 235 | - | let query = format!( | |
| 236 | - | "SELECT {EMAIL_SELECT_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.is_draft = 0 {archived_filter} ORDER BY e.received_at DESC" | |
| 237 | - | ); | |
| 238 | - | let rows = query_all( | |
| 239 | - | &conn, | |
| 240 | - | &query, | |
| 241 | - | params![user_id.to_string(), user_id.to_string()], | |
| 242 | - | EmailRow::from_row, | |
| 243 | - | )?; | |
| 244 | - | rows.into_iter().map(Email::try_from).collect() | |
| 245 | - | } | |
| 246 | - | ||
| 247 | - | #[tracing::instrument(skip_all)] | |
| 248 | - | fn list_metadata(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>> { | |
| 249 | - | let conn = self.db.conn()?; | |
| 250 | - | let archived_filter = if include_archived { | |
| 251 | - | "" | |
| 252 | - | } else { | |
| 253 | - | "AND e.is_archived = 0" | |
| 254 | - | }; | |
| 255 | - | let query = format!( | |
| 256 | - | "SELECT {EMAIL_LIST_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.is_draft = 0 {archived_filter} ORDER BY e.received_at DESC LIMIT {EMAIL_LIST_CAP}" | |
| 257 | - | ); | |
| 258 | - | let rows = query_all( | |
| 259 | - | &conn, | |
| 260 | - | &query, | |
| 261 | - | params![user_id.to_string(), user_id.to_string()], | |
| 262 | - | EmailRow::from_row, | |
| 263 | - | )?; | |
| 264 | - | rows.into_iter().map(Email::try_from).collect() | |
| 265 | - | } | |
| 266 | - | ||
| 267 | - | #[tracing::instrument(skip_all)] | |
| 268 | - | fn list_threaded( | |
| 269 | - | &self, | |
| 270 | - | user_id: UserId, | |
| 271 | - | include_archived: bool, | |
| 272 | - | offset: Option<i64>, | |
| 273 | - | limit: Option<i64>, | |
| 274 | - | folder: Option<&str>, | |
| 275 | - | label: Option<&str>, | |
| 276 | - | ) -> Result<(Vec<EmailThread>, i64)> { | |
| 277 | - | let uid = user_id.to_string(); | |
| 278 | - | let archived_filter = if include_archived { | |
| 279 | - | "" | |
| 280 | - | } else { | |
| 281 | - | "AND e.is_archived = 0" | |
| 282 | - | }; | |
| 283 | - | let folder_filter = folder.map_or("", |_| "AND e.source_folder = ?"); | |
| 284 | - | let label_filter = label.map_or( | |
| 285 | - | "", | |
| 286 | - | |_| "AND EXISTS (SELECT 1 FROM json_each(e.labels) j WHERE j.value = ?)", | |
| 287 | - | ); | |
| 288 | - | // Defense-in-depth: clamp before binding. A negative LIMIT means | |
| 289 | - | // unbounded in SQLite (would load the whole mailbox); a negative OFFSET | |
| 290 | - | // is ignored. Matches task_repo/search_repo. | |
| 291 | - | const MAX_PAGE_LIMIT: i64 = 1000; | |
| 292 | - | let offset_val = offset.unwrap_or(0).max(0); | |
| 293 | - | let limit_val = limit.unwrap_or(50).clamp(0, MAX_PAGE_LIMIT); | |
| 294 | - | ||
| 295 | - | // Query 1: Get total thread count | |
| 296 | - | let count_sql = format!( | |
| 297 | - | "SELECT COUNT(DISTINCT COALESCE(e.thread_id, e.id)) FROM emails e WHERE e.user_id = ? AND e.is_draft = 0 {archived_filter} {folder_filter} {label_filter}" | |
| 298 | - | ); | |
| 299 | - | // The optional folder/label filters add a placeholder each, so the binds are | |
| 300 | - | // assembled in the same order the filters were spliced into the SQL. | |
| 301 | - | let mut filter_binds: Vec<String> = vec![uid.clone()]; | |
| 302 | - | if let Some(f) = folder { | |
| 303 | - | filter_binds.push(f.to_string()); | |
| 304 | - | } | |
| 305 | - | if let Some(l) = label { | |
| 306 | - | filter_binds.push(l.to_string()); | |
| 307 | - | } | |
| 308 | - | ||
| 309 | - | let conn = self.db.conn()?; | |
| 310 | - | let total: i64 = conn | |
| 311 | - | .query_row(&count_sql, params_from_iter(&filter_binds), |row| { | |
| 312 | - | row.get(0) | |
| 313 | - | }) | |
| 314 | - | .map_err(CoreError::database)?; | |
| 315 | - | ||
| 316 | - | if total == 0 { | |
| 317 | - | return Ok((vec![], 0)); | |
| 318 | - | } | |
| 319 | - | ||
| 320 | - | // Query 2: Thread summary, group by thread, get latest received_at, count, unread status | |
| 321 | - | #[allow(dead_code)] | |
| 322 | - | struct ThreadSummary { | |
| 323 | - | thread_key: String, | |
| 324 | - | latest_received_at: String, // needed for SQL ORDER BY | |
| 325 | - | thread_count: i64, | |
| 326 | - | unread_count: i64, | |
| 327 | - | latest_email_id: String, | |
| 328 | - | } | |
| 329 | - | ||
| 330 | - | // Rank emails within each thread by recency in a single pass with window | |
| 331 | - | // functions, then keep the latest row per thread. This replaces a | |
| 332 | - | // correlated subquery that rescanned `emails` once per thread group | |
| 333 | - | // (O(threads x emails)); the partition's MAX(received_at) is the rn = 1 | |
| 334 | - | // row, and the thread-wide counts come from window aggregates. | |
| 335 | - | let summary_sql = format!( | |
| 336 | - | r"WITH ranked AS ( | |
| 337 | - | SELECT | |
| 338 | - | e.id AS email_id, | |
| 339 | - | COALESCE(e.thread_id, e.id) AS thread_key, | |
| 340 | - | e.received_at AS received_at, | |
| 341 | - | ROW_NUMBER() OVER ( | |
| 342 | - | PARTITION BY COALESCE(e.thread_id, e.id) | |
| 343 | - | ORDER BY e.received_at DESC, e.id DESC | |
| 344 | - | ) AS rn, | |
| 345 | - | COUNT(*) OVER (PARTITION BY COALESCE(e.thread_id, e.id)) AS thread_count, | |
| 346 | - | SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END) | |
| 347 | - | OVER (PARTITION BY COALESCE(e.thread_id, e.id)) AS unread_count | |
| 348 | - | FROM emails e | |
| 349 | - | WHERE e.user_id = ? AND e.is_draft = 0 {archived_filter} {folder_filter} {label_filter} | |
| 350 | - | ) | |
| 351 | - | SELECT | |
| 352 | - | thread_key, | |
| 353 | - | received_at AS latest_received_at, | |
| 354 | - | thread_count, | |
| 355 | - | unread_count, | |
| 356 | - | email_id AS latest_email_id | |
| 357 | - | FROM ranked | |
| 358 | - | WHERE rn = 1 | |
| 359 | - | ORDER BY latest_received_at DESC | |
| 360 | - | LIMIT ? OFFSET ?", | |
| 361 | - | ); | |
| 362 | - | ||
| 363 | - | let mut summary_binds: Vec<rusqlite::types::Value> = filter_binds | |
| 364 | - | .iter() | |
| 365 | - | .map(|s| rusqlite::types::Value::from(s.clone())) | |
| 366 | - | .collect(); | |
| 367 | - | summary_binds.push(limit_val.into()); | |
| 368 | - | summary_binds.push(offset_val.into()); | |
| 369 | - | ||
| 370 | - | let summaries = query_all( | |
| 371 | - | &conn, | |
| 372 | - | &summary_sql, | |
| 373 | - | params_from_iter(summary_binds), | |
| 374 | - | |row| { | |
| 375 | - | Ok(ThreadSummary { | |
| 376 | - | thread_key: row.get("thread_key")?, | |
| 377 | - | latest_received_at: row.get("latest_received_at")?, | |
| 378 | - | thread_count: row.get("thread_count")?, | |
| 379 | - | unread_count: row.get("unread_count")?, | |
| 380 | - | latest_email_id: row.get("latest_email_id")?, | |
| 381 | - | }) | |
| 382 | - | }, | |
| 383 | - | )?; | |
| 384 | - | ||
| 385 | - | if summaries.is_empty() { | |
| 386 | - | return Ok((vec![], total)); | |
| 387 | - | } | |
| 388 | - | ||
| 389 | - | // Query 3: Fetch full emails for the page's most-recent-email IDs | |
| 390 | - | let email_ids: Vec<String> = summaries | |
| 391 | - | .iter() | |
| 392 | - | .map(|s| s.latest_email_id.clone()) | |
| 393 | - | .collect(); | |
| 394 | - | let placeholders = bind_placeholders(email_ids.len()); | |
| 395 | - | let emails_sql = format!( | |
| 396 | - | "SELECT {EMAIL_SELECT_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.id IN ({placeholders}) AND e.user_id = ?" | |
| 397 | - | ); | |
| 398 | - | ||
| 399 | - | let mut email_binds: Vec<String> = Vec::with_capacity(email_ids.len() + 2); | |
| 400 | - | email_binds.push(uid.clone()); | |
| 401 | - | email_binds.extend(email_ids.iter().cloned()); | |
| 402 | - | email_binds.push(uid.clone()); | |
| 403 | - | ||
| 404 | - | let rows = query_all( | |
| 405 | - | &conn, | |
| 406 | - | &emails_sql, | |
| 407 | - | params_from_iter(email_binds), | |
| 408 | - | EmailRow::from_row, | |
| 409 | - | )?; | |
| 410 | - | ||
| 411 | - | let email_map: HashMap<String, Email> = rows | |
| 412 | - | .into_iter() | |
| 413 | - | .filter_map(|row| { | |
| 414 | - | let id_str = row.id.clone(); | |
| 415 | - | Email::try_from(row).ok().map(|e| (id_str, e)) | |
| 416 | - | }) | |
| 417 | - | .collect(); | |
| 418 | - | ||
| 419 | - | // Assemble threads in summary order | |
| 420 | - | let threads: Vec<EmailThread> = summaries | |
| 421 | - | .into_iter() | |
| 422 | - | .filter_map(|s| { | |
| 423 | - | let email = email_map.get(&s.latest_email_id)?.clone(); | |
| 424 | - | Some(EmailThread { | |
| 425 | - | thread_id: s.thread_key, | |
| 426 | - | most_recent_email: email, | |
| 427 | - | thread_count: s.thread_count as usize, | |
| 428 | - | has_unread: s.unread_count > 0, | |
| 429 | - | }) | |
| 430 | - | }) | |
| 431 | - | .collect(); | |
| 432 | - | ||
| 433 | - | Ok((threads, total)) | |
| 434 | - | } | |
| 435 | - | ||
| 436 | - | #[tracing::instrument(skip_all)] | |
| 437 | - | fn list_by_project(&self, user_id: UserId, project_id: ProjectId) -> Result<Vec<Email>> { | |
| 438 | - | let conn = self.db.conn()?; | |
| 439 | - | // Body-less + capped: the project dashboard renders only subject/from/date | |
| 440 | - | // and opens the reader (which re-fetches the full body via `get_by_id`) on | |
| 441 | - | // click, so this must not materialize every body into RAM (Perf S3, same | |
| 442 | - | // rule as `list_metadata`). `EMAIL_LIST_COLUMNS` forces `body_truncated=1`. | |
| 443 | - | let query = format!( | |
| 444 | - | "SELECT {EMAIL_LIST_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.project_id = ? ORDER BY e.received_at DESC LIMIT {EMAIL_LIST_CAP}" | |
| 445 | - | ); | |
| 446 | - | let rows = query_all( | |
| 447 | - | &conn, | |
| 448 | - | &query, | |
| 449 | - | params![ | |
| 450 | - | user_id.to_string(), | |
| 451 | - | user_id.to_string(), | |
| 452 | - | project_id.to_string() | |
| 453 | - | ], | |
| 454 | - | EmailRow::from_row, | |
| 455 | - | )?; | |
| 456 | - | rows.into_iter().map(Email::try_from).collect() | |
| 457 | - | } | |
| 458 | - | ||
| 459 | - | #[tracing::instrument(skip_all)] | |
| 460 | - | fn list_by_addresses(&self, user_id: UserId, addresses: &[&str]) -> Result<Vec<Email>> { | |
| 461 | - | if addresses.is_empty() { | |
| 462 | - | return Ok(Vec::new()); | |
| 463 | - | } | |
| 464 | - | let placeholders = bind_placeholders(addresses.len()); | |
| 465 | - | let query = format!( | |
| 466 | - | "SELECT {EMAIL_SELECT_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND (LOWER(e.from_address) IN ({placeholders}) OR LOWER(e.to_address) IN ({placeholders})) ORDER BY e.received_at DESC LIMIT 200" | |
| 467 | - | ); | |
| 468 | - | let mut binds: Vec<String> = Vec::with_capacity(addresses.len() * 2 + 2); | |
| 469 | - | binds.push(user_id.to_string()); | |
| 470 | - | binds.push(user_id.to_string()); | |
| 471 | - | // Bind addresses twice (once for from_address IN, once for to_address IN) | |
| 472 | - | for _ in 0..2 { | |
| 473 | - | binds.extend(addresses.iter().map(|a| a.to_lowercase())); | |
| 474 | - | } | |
| 475 | - | let conn = self.db.conn()?; | |
| 476 | - | let rows = query_all(&conn, &query, params_from_iter(binds), EmailRow::from_row)?; | |
| 477 | - | rows.into_iter().map(Email::try_from).collect() | |
| 478 | - | } | |
| 479 | - | ||
| 480 | - | #[tracing::instrument(skip_all)] | |
| 481 | - | fn list_unlinked(&self, user_id: UserId) -> Result<Vec<Email>> { | |
| 482 | - | let conn = self.db.conn()?; | |
| 483 | - | // Body-less + capped: the sole caller is the "link email to project" picker, | |
| 484 | - | // which shows only subject/from. No body needed, and an unbounded mailbox | |
| 485 | - | // must not load every body into RAM (Perf S3). `EMAIL_LIST_COLUMNS` forces | |
| 486 | - | // `body_truncated=1` so any later reader re-fetches the full body. | |
| 487 | - | let query = format!( | |
| 488 | - | "SELECT {EMAIL_LIST_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.project_id IS NULL AND e.is_archived = 0 ORDER BY e.received_at DESC LIMIT {EMAIL_LIST_CAP}" | |
| 489 | - | ); | |
| 490 | - | let rows = query_all( | |
| 491 | - | &conn, | |
| 492 | - | &query, | |
| 493 | - | params![user_id.to_string(), user_id.to_string()], | |
| 494 | - | EmailRow::from_row, | |
| 495 | - | )?; | |
| 496 | - | rows.into_iter().map(Email::try_from).collect() | |
| 497 | - | } | |
| 498 | - | ||
| 499 | - | #[tracing::instrument(skip_all)] | |
| 500 | - | fn get_by_id(&self, id: EmailId, user_id: UserId) -> Result<Option<Email>> { |
Lines truncated
| @@ -1,0 +1,182 @@ | |||
| 1 | + | //! Email writes that create or remove rows: plain create, backup restore, the | |
| 2 | + | //! two IMAP-tracking inserts, and delete. | |
| 3 | + | ||
| 4 | + | use chrono::Utc; | |
| 5 | + | use goingson_core::{ | |
| 6 | + | CoreError, DbValue, Email, EmailId, NewEmail, NewEmailWithTracking, Result, UserId, | |
| 7 | + | }; | |
| 8 | + | use rusqlite::{Connection, params}; | |
| 9 | + | ||
| 10 | + | use crate::utils::{execute, format_datetime, format_datetime_opt}; | |
| 11 | + | ||
| 12 | + | use super::query; | |
| 13 | + | ||
| 14 | + | /// Insert a hand-composed email and return it as stored. | |
| 15 | + | pub(super) fn create(conn: &Connection, user_id: UserId, email: &NewEmail) -> Result<Email> { | |
| 16 | + | let id = EmailId::new(); | |
| 17 | + | let received_at = format_datetime(&email.received_at.unwrap_or_else(Utc::now)); | |
| 18 | + | execute( | |
| 19 | + | conn, | |
| 20 | + | "INSERT INTO emails (id, user_id, project_id, from_address, to_address, subject, body, is_read, received_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 21 | + | params![ | |
| 22 | + | id.to_string(), | |
| 23 | + | user_id.to_string(), | |
| 24 | + | email.project_id.as_ref().map(ToString::to_string), | |
| 25 | + | &email.from_address, | |
| 26 | + | &email.to_address, | |
| 27 | + | &email.subject, | |
| 28 | + | &email.body, | |
| 29 | + | i32::from(email.is_read), | |
| 30 | + | &received_at | |
| 31 | + | ], | |
| 32 | + | )?; | |
| 33 | + | query::get_by_id(conn, id, user_id)? | |
| 34 | + | .ok_or_else(|| CoreError::internal("Failed to retrieve created email")) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// Re-insert an email from a backup, preserving its original id. | |
| 38 | + | pub(super) fn restore(conn: &Connection, user_id: UserId, email: &Email) -> Result<()> { | |
| 39 | + | // Durable content fields are round-tripped; account-linked and | |
| 40 | + | // sync-transient state (email_account_id, imap_uid, source_folder, | |
| 41 | + | // attachment_meta, draft_account_id) is intentionally omitted, those | |
| 42 | + | // FK into email_accounts (not part of a backup) or are re-derived on the | |
| 43 | + | // next IMAP sync. Preserving the original id + message_id makes a second | |
| 44 | + | // restore a no-op. | |
| 45 | + | let labels_json = serde_json::to_string(&email.labels).unwrap_or_else(|_| "[]".to_string()); | |
| 46 | + | execute( | |
| 47 | + | conn, | |
| 48 | + | "INSERT OR IGNORE INTO emails (id, user_id, project_id, from_address, to_address, subject, body, body_format, html_body, is_read, is_archived, received_at, message_id, in_reply_to, thread_id, is_outgoing, labels, is_draft, cc_address, bcc_address, snoozed_until, waiting_for_response, waiting_since, expected_response_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 49 | + | params![ | |
| 50 | + | email.id.to_string(), | |
| 51 | + | user_id.to_string(), | |
| 52 | + | email.project_id.map(|p| p.to_string()), | |
| 53 | + | &email.from, | |
| 54 | + | &email.to, | |
| 55 | + | &email.subject, | |
| 56 | + | &email.body, | |
| 57 | + | email.body_format.db_value(), | |
| 58 | + | &email.html_body, | |
| 59 | + | i32::from(email.is_read), | |
| 60 | + | i32::from(email.is_archived), | |
| 61 | + | format_datetime(&email.received_at), | |
| 62 | + | &email.message_id, | |
| 63 | + | &email.in_reply_to, | |
| 64 | + | &email.thread_id, | |
| 65 | + | i32::from(email.is_outgoing), | |
| 66 | + | &labels_json, | |
| 67 | + | i32::from(email.is_draft), | |
| 68 | + | &email.cc_address, | |
| 69 | + | &email.bcc_address, | |
| 70 | + | format_datetime_opt(email.snoozed_until), | |
| 71 | + | i32::from(email.waiting_for_response), | |
| 72 | + | format_datetime_opt(email.waiting_since), | |
| 73 | + | format_datetime_opt(email.expected_response_date) | |
| 74 | + | ], | |
| 75 | + | )?; | |
| 76 | + | Ok(()) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// Insert one synced message, keyed by a deterministic id from its message-id. | |
| 80 | + | pub(super) fn create_with_tracking( | |
| 81 | + | conn: &Connection, | |
| 82 | + | user_id: UserId, | |
| 83 | + | email: &NewEmailWithTracking, | |
| 84 | + | ) -> Result<Email> { | |
| 85 | + | let id = goingson_core::deterministic_email_id(email.message_id.as_deref()); | |
| 86 | + | let received_at = format_datetime(&email.received_at.unwrap_or_else(Utc::now)); | |
| 87 | + | execute( | |
| 88 | + | conn, | |
| 89 | + | "INSERT INTO emails (id, user_id, project_id, from_address, to_address, subject, body, body_format, html_body, is_read, is_archived, received_at, message_id, in_reply_to, thread_id, email_account_id, is_outgoing, imap_uid, source_folder, attachment_meta, body_truncated, jmap_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 90 | + | params![ | |
| 91 | + | id.to_string(), | |
| 92 | + | user_id.to_string(), | |
| 93 | + | email.project_id.as_ref().map(ToString::to_string), | |
| 94 | + | &email.from_address, | |
| 95 | + | &email.to_address, | |
| 96 | + | &email.subject, | |
| 97 | + | &email.body, | |
| 98 | + | email.body_format.db_value(), | |
| 99 | + | &email.html_body, | |
| 100 | + | i32::from(email.is_read), | |
| 101 | + | i32::from(email.is_archived), | |
| 102 | + | &received_at, | |
| 103 | + | &email.message_id, | |
| 104 | + | &email.in_reply_to, | |
| 105 | + | &email.thread_id, | |
| 106 | + | email.email_account_id.as_ref().map(ToString::to_string), | |
| 107 | + | i32::from(email.is_outgoing), | |
| 108 | + | email.imap_uid, | |
| 109 | + | &email.source_folder, | |
| 110 | + | &email.attachment_meta, | |
| 111 | + | i32::from(email.body_truncated), | |
| 112 | + | &email.jmap_id | |
| 113 | + | ], | |
| 114 | + | )?; | |
| 115 | + | query::get_by_id(conn, id, user_id)? | |
| 116 | + | .ok_or_else(|| CoreError::internal("Failed to retrieve created email")) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// Insert a batch of synced messages in one transaction, skipping duplicates. | |
| 120 | + | pub(super) fn create_with_tracking_batch( | |
| 121 | + | conn: &mut Connection, | |
| 122 | + | user_id: UserId, | |
| 123 | + | emails: Vec<NewEmailWithTracking>, | |
| 124 | + | ) -> Result<usize> { | |
| 125 | + | if emails.is_empty() { | |
| 126 | + | return Ok(0); | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | let mut count = 0usize; | |
| 130 | + | let uid = user_id.to_string(); | |
| 131 | + | ||
| 132 | + | let tx = conn.transaction().map_err(CoreError::database)?; | |
| 133 | + | ||
| 134 | + | for email in emails { | |
| 135 | + | let id = goingson_core::deterministic_email_id(email.message_id.as_deref()); | |
| 136 | + | let received_at = format_datetime(&email.received_at.unwrap_or_else(Utc::now)); | |
| 137 | + | let result = execute( | |
| 138 | + | &tx, | |
| 139 | + | "INSERT OR IGNORE INTO emails (id, user_id, project_id, from_address, to_address, subject, body, body_format, html_body, is_read, is_archived, received_at, message_id, in_reply_to, thread_id, email_account_id, is_outgoing, imap_uid, source_folder, attachment_meta, body_truncated, jmap_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 140 | + | params![ | |
| 141 | + | id.to_string(), | |
| 142 | + | &uid, | |
| 143 | + | email.project_id.map(|p| p.to_string()), | |
| 144 | + | &email.from_address, | |
| 145 | + | &email.to_address, | |
| 146 | + | &email.subject, | |
| 147 | + | &email.body, | |
| 148 | + | email.body_format.db_value(), | |
| 149 | + | &email.html_body, | |
| 150 | + | i32::from(email.is_read), | |
| 151 | + | i32::from(email.is_archived), | |
| 152 | + | &received_at, | |
| 153 | + | &email.message_id, | |
| 154 | + | &email.in_reply_to, | |
| 155 | + | &email.thread_id, | |
| 156 | + | email.email_account_id.map(|a| a.to_string()), | |
| 157 | + | i32::from(email.is_outgoing), | |
| 158 | + | email.imap_uid, | |
| 159 | + | &email.source_folder, | |
| 160 | + | &email.attachment_meta, | |
| 161 | + | i32::from(email.body_truncated), | |
| 162 | + | &email.jmap_id | |
| 163 | + | ], | |
| 164 | + | )?; | |
| 165 | + | if result > 0 { | |
| 166 | + | count += 1; | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | tx.commit().map_err(CoreError::database)?; | |
| 171 | + | Ok(count) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /// Delete one email. Returns whether a row was removed. | |
| 175 | + | pub(super) fn delete(conn: &Connection, id: EmailId, user_id: UserId) -> Result<bool> { | |
| 176 | + | let result = execute( | |
| 177 | + | conn, | |
| 178 | + | "DELETE FROM emails WHERE id = ? AND user_id = ?", | |
| 179 | + | params![id.to_string(), user_id.to_string()], | |
| 180 | + | )?; | |
| 181 | + | Ok(result > 0) | |
| 182 | + | } |
| @@ -1,0 +1,187 @@ | |||
| 1 | + | //! The compose and send state machine: drafts, the outbox queue, the due set, | |
| 2 | + | //! and send-failure bookkeeping. | |
| 3 | + | ||
| 4 | + | use chrono::{DateTime, Utc}; | |
| 5 | + | use goingson_core::{CoreError, Email, EmailAccountId, EmailId, Result, UserId}; | |
| 6 | + | use rusqlite::{Connection, params}; | |
| 7 | + | ||
| 8 | + | use crate::utils::{execute, format_datetime, format_datetime_now, format_datetime_opt, query_all}; | |
| 9 | + | ||
| 10 | + | use super::query; | |
| 11 | + | use super::row::{EMAIL_LIST_COLUMNS, EMAIL_SELECT_COLUMNS, EmailRow}; | |
| 12 | + | ||
| 13 | + | /// Every draft, newest first. | |
| 14 | + | pub(super) fn list_drafts(conn: &Connection, user_id: UserId) -> Result<Vec<Email>> { | |
| 15 | + | let sql = format!( | |
| 16 | + | "SELECT {EMAIL_SELECT_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.is_draft = 1 ORDER BY e.received_at DESC" | |
| 17 | + | ); | |
| 18 | + | let rows = query_all( | |
| 19 | + | conn, | |
| 20 | + | &sql, | |
| 21 | + | params![user_id.to_string(), user_id.to_string()], | |
| 22 | + | EmailRow::from_row, | |
| 23 | + | )?; | |
| 24 | + | rows.into_iter().map(Email::try_from).collect() | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /// Upsert a draft by id and return it as stored. | |
| 28 | + | #[allow(clippy::too_many_arguments)] | |
| 29 | + | pub(super) fn save_draft( | |
| 30 | + | conn: &Connection, | |
| 31 | + | id: EmailId, | |
| 32 | + | user_id: UserId, | |
| 33 | + | from: &str, | |
| 34 | + | to: &str, | |
| 35 | + | cc: Option<&str>, | |
| 36 | + | bcc: Option<&str>, | |
| 37 | + | subject: &str, | |
| 38 | + | body: &str, | |
| 39 | + | account_id: Option<EmailAccountId>, | |
| 40 | + | in_reply_to: Option<&str>, | |
| 41 | + | thread_id: Option<&str>, | |
| 42 | + | ) -> Result<Email> { | |
| 43 | + | let now = format_datetime_now(); | |
| 44 | + | let account_id_str = account_id.map(|a: EmailAccountId| a.to_string()); | |
| 45 | + | ||
| 46 | + | // Upsert: update if exists, insert if not | |
| 47 | + | execute( | |
| 48 | + | conn, | |
| 49 | + | r" | |
| 50 | + | INSERT INTO emails (id, user_id, from_address, to_address, cc_address, bcc_address, subject, body, | |
| 51 | + | is_read, is_archived, is_draft, is_outgoing, received_at, draft_account_id, in_reply_to, thread_id) | |
| 52 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 1, 1, ?, ?, ?, ?) | |
| 53 | + | ON CONFLICT(id) DO UPDATE SET | |
| 54 | + | from_address = excluded.from_address, | |
| 55 | + | to_address = excluded.to_address, | |
| 56 | + | cc_address = excluded.cc_address, | |
| 57 | + | bcc_address = excluded.bcc_address, | |
| 58 | + | subject = excluded.subject, | |
| 59 | + | body = excluded.body, | |
| 60 | + | received_at = excluded.received_at, | |
| 61 | + | draft_account_id = excluded.draft_account_id, | |
| 62 | + | in_reply_to = excluded.in_reply_to, | |
| 63 | + | thread_id = excluded.thread_id | |
| 64 | + | ", | |
| 65 | + | params![ | |
| 66 | + | id.to_string(), | |
| 67 | + | user_id.to_string(), | |
| 68 | + | from, | |
| 69 | + | to, | |
| 70 | + | cc, | |
| 71 | + | bcc, | |
| 72 | + | subject, | |
| 73 | + | body, | |
| 74 | + | &now, | |
| 75 | + | &account_id_str, | |
| 76 | + | in_reply_to, | |
| 77 | + | thread_id | |
| 78 | + | ], | |
| 79 | + | )?; | |
| 80 | + | ||
| 81 | + | query::get_by_id(conn, id, user_id)? | |
| 82 | + | .ok_or_else(|| CoreError::internal("Failed to retrieve saved draft")) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// Queue a draft for sending, optionally not before `send_after`. | |
| 86 | + | pub(super) fn queue_draft( | |
| 87 | + | conn: &Connection, | |
| 88 | + | id: EmailId, | |
| 89 | + | user_id: UserId, | |
| 90 | + | send_after: Option<DateTime<Utc>>, | |
| 91 | + | ) -> Result<Option<Email>> { | |
| 92 | + | // `is_draft = 1` in the predicate rather than checked first: queueing a | |
| 93 | + | // received message is refused by the write not matching, so there is no | |
| 94 | + | // window between the check and the update. | |
| 95 | + | let changed = execute( | |
| 96 | + | conn, | |
| 97 | + | "UPDATE emails SET queued_at = ?, send_after = ?, send_attempts = 0, send_error = NULL | |
| 98 | + | WHERE id = ? AND user_id = ? AND is_draft = 1", | |
| 99 | + | params![ | |
| 100 | + | format_datetime_now(), | |
| 101 | + | format_datetime_opt(send_after), | |
| 102 | + | id.to_string(), | |
| 103 | + | user_id.to_string(), | |
| 104 | + | ], | |
| 105 | + | )?; | |
| 106 | + | if changed == 0 { | |
| 107 | + | return Ok(None); | |
| 108 | + | } | |
| 109 | + | query::get_by_id(conn, id, user_id) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /// Take a queued draft back out of the outbox. | |
| 113 | + | pub(super) fn unqueue_draft( | |
| 114 | + | conn: &Connection, | |
| 115 | + | id: EmailId, | |
| 116 | + | user_id: UserId, | |
| 117 | + | ) -> Result<Option<Email>> { | |
| 118 | + | let changed = execute( | |
| 119 | + | conn, | |
| 120 | + | "UPDATE emails SET queued_at = NULL, send_after = NULL, send_attempts = 0, send_error = NULL | |
| 121 | + | WHERE id = ? AND user_id = ? AND queued_at IS NOT NULL", | |
| 122 | + | params![id.to_string(), user_id.to_string()], | |
| 123 | + | )?; | |
| 124 | + | if changed == 0 { | |
| 125 | + | return Ok(None); | |
| 126 | + | } | |
| 127 | + | query::get_by_id(conn, id, user_id) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /// Queued drafts, oldest queued first. Bodies are blanked. | |
| 131 | + | pub(super) fn list_outbox(conn: &Connection, user_id: UserId) -> Result<Vec<Email>> { | |
| 132 | + | let sql = format!( | |
| 133 | + | "SELECT {EMAIL_LIST_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? | |
| 134 | + | WHERE e.user_id = ? AND e.is_draft = 1 AND e.queued_at IS NOT NULL | |
| 135 | + | ORDER BY e.queued_at ASC" | |
| 136 | + | ); | |
| 137 | + | let rows = query_all( | |
| 138 | + | conn, | |
| 139 | + | &sql, | |
| 140 | + | params![user_id.to_string(), user_id.to_string()], | |
| 141 | + | EmailRow::from_row, | |
| 142 | + | )?; | |
| 143 | + | rows.into_iter().map(Email::try_from).collect() | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// Queued drafts whose send time has arrived, bodies included. | |
| 147 | + | pub(super) fn list_due( | |
| 148 | + | conn: &Connection, | |
| 149 | + | user_id: UserId, | |
| 150 | + | now: DateTime<Utc>, | |
| 151 | + | ) -> Result<Vec<Email>> { | |
| 152 | + | // The body is wanted here, unlike the outbox list: this is the set that | |
| 153 | + | // is about to be sent, and a blanked body would send an empty message. | |
| 154 | + | let sql = format!( | |
| 155 | + | "SELECT {EMAIL_SELECT_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? | |
| 156 | + | WHERE e.user_id = ? AND e.is_draft = 1 AND e.queued_at IS NOT NULL | |
| 157 | + | AND (e.send_after IS NULL OR e.send_after <= ?) | |
| 158 | + | ORDER BY e.queued_at ASC" | |
| 159 | + | ); | |
| 160 | + | let rows = query_all( | |
| 161 | + | conn, | |
| 162 | + | &sql, | |
| 163 | + | params![ | |
| 164 | + | user_id.to_string(), | |
| 165 | + | user_id.to_string(), | |
| 166 | + | format_datetime(&now) | |
| 167 | + | ], | |
| 168 | + | EmailRow::from_row, | |
| 169 | + | )?; | |
| 170 | + | rows.into_iter().map(Email::try_from).collect() | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | /// Count a failed send attempt and record its error. | |
| 174 | + | pub(super) fn record_send_failure( | |
| 175 | + | conn: &Connection, | |
| 176 | + | id: EmailId, | |
| 177 | + | user_id: UserId, | |
| 178 | + | error: &str, | |
| 179 | + | ) -> Result<()> { | |
| 180 | + | execute( | |
| 181 | + | conn, | |
| 182 | + | "UPDATE emails SET send_attempts = send_attempts + 1, send_error = ? | |
| 183 | + | WHERE id = ? AND user_id = ?", | |
| 184 | + | params![error, id.to_string(), user_id.to_string()], | |
| 185 | + | )?; | |
| 186 | + | Ok(()) | |
| 187 | + | } |
| @@ -1,0 +1,138 @@ | |||
| 1 | + | //! Single-column flag updates on an email: read/unread, archive, folder, body, | |
| 2 | + | //! project link, labels, plus the unread count. | |
| 3 | + | ||
| 4 | + | use goingson_core::{CoreError, Email, EmailId, ProjectId, Result, UserId}; | |
| 5 | + | use rusqlite::{Connection, params}; | |
| 6 | + | ||
| 7 | + | use crate::utils::execute; | |
| 8 | + | ||
| 9 | + | use super::query; | |
| 10 | + | ||
| 11 | + | /// Mark one email read. Returns whether a row changed. | |
| 12 | + | pub(super) fn mark_read(conn: &Connection, id: EmailId, user_id: UserId) -> Result<bool> { | |
| 13 | + | let result = execute( | |
| 14 | + | conn, | |
| 15 | + | "UPDATE emails SET is_read = 1 WHERE id = ? AND user_id = ?", | |
| 16 | + | params![id.to_string(), user_id.to_string()], | |
| 17 | + | )?; | |
| 18 | + | Ok(result > 0) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /// Mark one email unread. Returns whether a row changed. | |
| 22 | + | pub(super) fn mark_unread(conn: &Connection, id: EmailId, user_id: UserId) -> Result<bool> { | |
| 23 | + | let result = execute( | |
| 24 | + | conn, | |
| 25 | + | "UPDATE emails SET is_read = 0 WHERE id = ? AND user_id = ?", | |
| 26 | + | params![id.to_string(), user_id.to_string()], | |
| 27 | + | )?; | |
| 28 | + | Ok(result > 0) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// Archive one email. Returns whether a row changed. | |
| 32 | + | pub(super) fn archive(conn: &Connection, id: EmailId, user_id: UserId) -> Result<bool> { | |
| 33 | + | let result = execute( | |
| 34 | + | conn, | |
| 35 | + | "UPDATE emails SET is_archived = 1 WHERE id = ? AND user_id = ?", | |
| 36 | + | params![id.to_string(), user_id.to_string()], | |
| 37 | + | )?; | |
| 38 | + | Ok(result > 0) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// Unarchive one email. Returns whether a row changed. | |
| 42 | + | pub(super) fn unarchive(conn: &Connection, id: EmailId, user_id: UserId) -> Result<bool> { | |
| 43 | + | let result = execute( | |
| 44 | + | conn, | |
| 45 | + | "UPDATE emails SET is_archived = 0 WHERE id = ? AND user_id = ?", | |
| 46 | + | params![id.to_string(), user_id.to_string()], | |
| 47 | + | )?; | |
| 48 | + | Ok(result > 0) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// Repoint one email at a different IMAP folder. | |
| 52 | + | pub(super) fn update_source_folder( | |
| 53 | + | conn: &Connection, | |
| 54 | + | id: EmailId, | |
| 55 | + | user_id: UserId, | |
| 56 | + | new_folder: &str, | |
| 57 | + | ) -> Result<bool> { | |
| 58 | + | let result = execute( | |
| 59 | + | conn, | |
| 60 | + | "UPDATE emails SET source_folder = ? WHERE id = ? AND user_id = ?", | |
| 61 | + | params![new_folder, id.to_string(), user_id.to_string()], | |
| 62 | + | )?; | |
| 63 | + | Ok(result > 0) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// Store a fetched full body and clear the truncated flag. | |
| 67 | + | pub(super) fn set_full_body( | |
| 68 | + | conn: &Connection, | |
| 69 | + | id: EmailId, | |
| 70 | + | user_id: UserId, | |
| 71 | + | body: &str, | |
| 72 | + | ) -> Result<()> { | |
| 73 | + | execute( | |
| 74 | + | conn, | |
| 75 | + | "UPDATE emails SET body = ?, body_truncated = 0 WHERE id = ? AND user_id = ?", | |
| 76 | + | params![body, id.to_string(), user_id.to_string()], | |
| 77 | + | )?; | |
| 78 | + | Ok(()) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// Mark every unread email read. Returns how many changed. | |
| 82 | + | pub(super) fn mark_all_read(conn: &Connection, user_id: UserId) -> Result<u64> { | |
| 83 | + | let result = execute( | |
| 84 | + | conn, | |
| 85 | + | "UPDATE emails SET is_read = 1 WHERE user_id = ? AND is_read = 0", | |
| 86 | + | params![user_id.to_string()], | |
| 87 | + | )?; | |
| 88 | + | Ok(result as u64) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Link one email to a project, or clear the link with `None`. | |
| 92 | + | pub(super) fn link_to_project( | |
| 93 | + | conn: &Connection, | |
| 94 | + | id: EmailId, | |
| 95 | + | user_id: UserId, | |
| 96 | + | project_id: Option<ProjectId>, | |
| 97 | + | ) -> Result<bool> { | |
| 98 | + | let result = execute( | |
| 99 | + | conn, | |
| 100 | + | "UPDATE emails SET project_id = ? WHERE id = ? AND user_id = ?", | |
| 101 | + | params![ | |
| 102 | + | project_id.map(|p| p.to_string()), | |
| 103 | + | id.to_string(), | |
| 104 | + | user_id.to_string() | |
| 105 | + | ], | |
| 106 | + | )?; | |
| 107 | + | Ok(result > 0) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /// How many unread emails the user has. | |
| 111 | + | pub(super) fn count_unread(conn: &Connection, user_id: UserId) -> Result<i64> { | |
| 112 | + | conn.query_row( | |
| 113 | + | "SELECT COUNT(*) FROM emails WHERE user_id = ? AND is_read = 0", | |
| 114 | + | params![user_id.to_string()], | |
| 115 | + | |row| row.get(0), | |
| 116 | + | ) | |
| 117 | + | .map_err(CoreError::database) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | /// Replace one email's label set, returning the updated email. | |
| 121 | + | pub(super) fn update_labels( | |
| 122 | + | conn: &Connection, | |
| 123 | + | id: EmailId, | |
| 124 | + | user_id: UserId, | |
| 125 | + | labels: &[String], | |
| 126 | + | ) -> Result<Option<Email>> { | |
| 127 | + | let labels_json = serde_json::to_string(labels).unwrap_or_else(|_| "[]".to_string()); | |
| 128 | + | let result = execute( | |
| 129 | + | conn, | |
| 130 | + | "UPDATE emails SET labels = ? WHERE id = ? AND user_id = ?", | |
| 131 | + | params![&labels_json, id.to_string(), user_id.to_string()], | |
| 132 | + | )?; | |
| 133 | + | if result > 0 { | |
| 134 | + | query::get_by_id(conn, id, user_id) | |
| 135 | + | } else { | |
| 136 | + | Ok(None) | |
| 137 | + | } | |
| 138 | + | } |
| @@ -1,0 +1,377 @@ | |||
| 1 | + | //! SQLite implementation of the EmailRepository. | |
| 2 | + | //! | |
| 3 | + | //! Manages email messages with support for: | |
| 4 | + | //! - IMAP synchronization tracking (message_id, imap_uid) | |
| 5 | + | //! - Threading via in_reply_to and thread_id | |
| 6 | + | //! - Read/archived status | |
| 7 | + | //! - Project associations | |
| 8 | + | //! - Snoozing and waiting-for-response tracking | |
| 9 | + | //! | |
| 10 | + | //! A trait impl cannot be split across files, so this module keeps the struct | |
| 11 | + | //! and the single `impl EmailRepository` block, and every method checks out a | |
| 12 | + | //! connection and hands it to a free function in one of the submodules below. | |
| 13 | + | ||
| 14 | + | mod crud; | |
| 15 | + | mod draft; | |
| 16 | + | mod flags; | |
| 17 | + | mod query; | |
| 18 | + | mod row; | |
| 19 | + | mod state; | |
| 20 | + | mod sync; | |
| 21 | + | mod thread; | |
| 22 | + | ||
| 23 | + | use std::collections::HashSet; | |
| 24 | + | ||
| 25 | + | use chrono::{DateTime, Utc}; | |
| 26 | + | use goingson_core::{ | |
| 27 | + | Email, EmailAccountId, EmailId, EmailRepository, EmailThread, NewEmail, NewEmailWithTracking, | |
| 28 | + | ProjectId, Result, UserId, | |
| 29 | + | }; | |
| 30 | + | ||
| 31 | + | use crate::Db; | |
| 32 | + | ||
| 33 | + | /// SQLite-backed implementation of [`EmailRepository`]. | |
| 34 | + | /// | |
| 35 | + | /// Manages email messages with threading support, snoozing, and | |
| 36 | + | /// waiting-for-response tracking. Integrates with IMAP sync via message_id. | |
| 37 | + | pub struct SqliteEmailRepository { | |
| 38 | + | db: Db, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | impl SqliteEmailRepository { | |
| 42 | + | /// Creates a new repository instance with the given connection pool. | |
| 43 | + | #[tracing::instrument(skip_all)] | |
| 44 | + | pub fn new(db: Db) -> Self { | |
| 45 | + | Self { db } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | impl EmailRepository for SqliteEmailRepository { | |
| 50 | + | #[tracing::instrument(skip_all)] | |
| 51 | + | fn list_all_for_backup(&self, user_id: UserId) -> Result<Vec<Email>> { | |
| 52 | + | let conn = self.db.conn()?; | |
| 53 | + | query::list_all_for_backup(&conn, user_id) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | fn list_all(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>> { | |
| 57 | + | let conn = self.db.conn()?; | |
| 58 | + | query::list_all(&conn, user_id, include_archived) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | #[tracing::instrument(skip_all)] | |
| 62 | + | fn list_metadata(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>> { | |
| 63 | + | let conn = self.db.conn()?; | |
| 64 | + | query::list_metadata(&conn, user_id, include_archived) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | #[tracing::instrument(skip_all)] | |
| 68 | + | fn list_threaded( | |
| 69 | + | &self, | |
| 70 | + | user_id: UserId, | |
| 71 | + | include_archived: bool, | |
| 72 | + | offset: Option<i64>, | |
| 73 | + | limit: Option<i64>, | |
| 74 | + | folder: Option<&str>, | |
| 75 | + | label: Option<&str>, | |
| 76 | + | ) -> Result<(Vec<EmailThread>, i64)> { | |
| 77 | + | let conn = self.db.conn()?; | |
| 78 | + | thread::list_threaded( | |
| 79 | + | &conn, | |
| 80 | + | user_id, | |
| 81 | + | include_archived, | |
| 82 | + | offset, | |
| 83 | + | limit, | |
| 84 | + | folder, | |
| 85 | + | label, | |
| 86 | + | ) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | #[tracing::instrument(skip_all)] | |
| 90 | + | fn list_by_project(&self, user_id: UserId, project_id: ProjectId) -> Result<Vec<Email>> { | |
| 91 | + | let conn = self.db.conn()?; | |
| 92 | + | query::list_by_project(&conn, user_id, project_id) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | #[tracing::instrument(skip_all)] | |
| 96 | + | fn list_by_addresses(&self, user_id: UserId, addresses: &[&str]) -> Result<Vec<Email>> { | |
| 97 | + | let conn = self.db.conn()?; | |
| 98 | + | query::list_by_addresses(&conn, user_id, addresses) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | #[tracing::instrument(skip_all)] | |
| 102 | + | fn list_unlinked(&self, user_id: UserId) -> Result<Vec<Email>> { | |
| 103 | + | let conn = self.db.conn()?; | |
| 104 | + | query::list_unlinked(&conn, user_id) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | #[tracing::instrument(skip_all)] | |
| 108 | + | fn get_by_id(&self, id: EmailId, user_id: UserId) -> Result<Option<Email>> { | |
| 109 | + | let conn = self.db.conn()?; | |
| 110 | + | query::get_by_id(&conn, id, user_id) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | #[tracing::instrument(skip_all)] | |
| 114 | + | fn create(&self, user_id: UserId, email: NewEmail) -> Result<Email> { | |
| 115 | + | let conn = self.db.conn()?; | |
| 116 | + | crud::create(&conn, user_id, &email) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | #[tracing::instrument(skip_all)] | |
| 120 | + | fn restore(&self, user_id: UserId, email: &Email) -> Result<()> { | |
| 121 | + | let conn = self.db.conn()?; | |
| 122 | + | crud::restore(&conn, user_id, email) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | #[tracing::instrument(skip_all)] | |
| 126 | + | fn create_with_tracking(&self, user_id: UserId, email: NewEmailWithTracking) -> Result<Email> { | |
| 127 | + | let conn = self.db.conn()?; | |
| 128 | + | crud::create_with_tracking(&conn, user_id, &email) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | #[tracing::instrument(skip_all)] | |
| 132 | + | fn create_with_tracking_batch( | |
| 133 | + | &self, | |
| 134 | + | user_id: UserId, | |
| 135 | + | emails: Vec<NewEmailWithTracking>, | |
| 136 | + | ) -> Result<usize> { | |
| 137 | + | let mut conn = self.db.conn()?; | |
| 138 | + | crud::create_with_tracking_batch(&mut conn, user_id, emails) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | #[tracing::instrument(skip_all)] | |
| 142 | + | fn delete(&self, id: EmailId, user_id: UserId) -> Result<bool> { | |
| 143 | + | let conn = self.db.conn()?; | |
| 144 | + | crud::delete(&conn, id, user_id) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | #[tracing::instrument(skip_all)] | |
| 148 | + | fn mark_read(&self, id: EmailId, user_id: UserId) -> Result<bool> { | |
| 149 | + | let conn = self.db.conn()?; | |
| 150 | + | flags::mark_read(&conn, id, user_id) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | #[tracing::instrument(skip_all)] | |
| 154 | + | fn mark_unread(&self, id: EmailId, user_id: UserId) -> Result<bool> { | |
| 155 | + | let conn = self.db.conn()?; | |
| 156 | + | flags::mark_unread(&conn, id, user_id) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | #[tracing::instrument(skip_all)] | |
| 160 | + | fn archive(&self, id: EmailId, user_id: UserId) -> Result<bool> { | |
| 161 | + | let conn = self.db.conn()?; | |
| 162 | + | flags::archive(&conn, id, user_id) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | #[tracing::instrument(skip_all)] | |
| 166 | + | fn unarchive(&self, id: EmailId, user_id: UserId) -> Result<bool> { | |
| 167 | + | let conn = self.db.conn()?; | |
| 168 | + | flags::unarchive(&conn, id, user_id) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | #[tracing::instrument(skip_all)] | |
| 172 | + | fn update_source_folder(&self, id: EmailId, user_id: UserId, new_folder: &str) -> Result<bool> { | |
| 173 | + | let conn = self.db.conn()?; | |
| 174 | + | flags::update_source_folder(&conn, id, user_id, new_folder) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | #[tracing::instrument(skip_all)] | |
| 178 | + | fn set_full_body(&self, id: EmailId, user_id: UserId, body: &str) -> Result<()> { | |
| 179 | + | let conn = self.db.conn()?; | |
| 180 | + | flags::set_full_body(&conn, id, user_id, body) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | #[tracing::instrument(skip_all)] | |
| 184 | + | fn mark_all_read(&self, user_id: UserId) -> Result<u64> { | |
| 185 | + | let conn = self.db.conn()?; | |
| 186 | + | flags::mark_all_read(&conn, user_id) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | #[tracing::instrument(skip_all)] | |
| 190 | + | fn link_to_project( | |
| 191 | + | &self, | |
| 192 | + | id: EmailId, | |
| 193 | + | user_id: UserId, | |
| 194 | + | project_id: Option<ProjectId>, | |
| 195 | + | ) -> Result<bool> { | |
| 196 | + | let conn = self.db.conn()?; | |
| 197 | + | flags::link_to_project(&conn, id, user_id, project_id) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | #[tracing::instrument(skip_all)] | |
| 201 | + | fn count_unread(&self, user_id: UserId) -> Result<i64> { | |
| 202 | + | let conn = self.db.conn()?; | |
| 203 | + | flags::count_unread(&conn, user_id) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | #[tracing::instrument(skip_all)] | |
| 207 | + | fn exists_by_message_id(&self, user_id: UserId, message_id: &str) -> Result<bool> { | |
| 208 | + | let conn = self.db.conn()?; | |
| 209 | + | sync::exists_by_message_id(&conn, user_id, message_id) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | #[tracing::instrument(skip_all)] | |
| 213 | + | fn exists_by_message_ids( | |
| 214 | + | &self, | |
| 215 | + | user_id: UserId, | |
| 216 | + | message_ids: &[&str], | |
| 217 | + | ) -> Result<HashSet<String>> { | |
| 218 | + | let conn = self.db.conn()?; | |
| 219 | + | sync::exists_by_message_ids(&conn, user_id, message_ids) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | #[tracing::instrument(skip_all)] | |
| 223 | + | fn exists_as_senders(&self, user_id: UserId, addresses: &[&str]) -> Result<HashSet<String>> { | |
| 224 | + | let conn = self.db.conn()?; | |
| 225 | + | sync::exists_as_senders(&conn, user_id, addresses) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | #[tracing::instrument(skip_all)] | |
| 229 | + | fn snooze(&self, id: EmailId, user_id: UserId, until: DateTime<Utc>) -> Result<Option<Email>> { | |
| 230 | + | let conn = self.db.conn()?; | |
| 231 | + | state::snooze(&conn, id, user_id, until) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | #[tracing::instrument(skip_all)] | |
| 235 | + | fn unsnooze(&self, id: EmailId, user_id: UserId) -> Result<Option<Email>> { | |
| 236 | + | let conn = self.db.conn()?; | |
| 237 | + | state::unsnooze(&conn, id, user_id) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | #[tracing::instrument(skip_all)] | |
| 241 | + | fn list_snoozed(&self, user_id: UserId) -> Result<Vec<Email>> { | |
| 242 | + | let conn = self.db.conn()?; | |
| 243 | + | state::list_snoozed(&conn, user_id) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | #[tracing::instrument(skip_all)] | |
| 247 | + | fn mark_waiting( | |
| 248 | + | &self, | |
| 249 | + | id: EmailId, | |
| 250 | + | user_id: UserId, | |
| 251 | + | expected_response: Option<DateTime<Utc>>, | |
| 252 | + | ) -> Result<Option<Email>> { | |
| 253 | + | let conn = self.db.conn()?; | |
| 254 | + | state::mark_waiting(&conn, id, user_id, expected_response) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | #[tracing::instrument(skip_all)] | |
| 258 | + | fn clear_waiting(&self, id: EmailId, user_id: UserId) -> Result<Option<Email>> { | |
| 259 | + | let conn = self.db.conn()?; | |
| 260 | + | state::clear_waiting(&conn, id, user_id) | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | #[tracing::instrument(skip_all)] | |
| 264 | + | fn list_waiting(&self, user_id: UserId) -> Result<Vec<Email>> { | |
| 265 | + | let conn = self.db.conn()?; | |
| 266 | + | state::list_waiting(&conn, user_id) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | #[tracing::instrument(skip_all)] | |
| 270 | + | fn list_by_thread(&self, user_id: UserId, thread_id: &str) -> Result<Vec<Email>> { | |
| 271 | + | let conn = self.db.conn()?; | |
| 272 | + | thread::list_by_thread(&conn, user_id, thread_id) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | #[tracing::instrument(skip_all)] | |
| 276 | + | fn list_drafts(&self, user_id: UserId) -> Result<Vec<Email>> { | |
| 277 | + | let conn = self.db.conn()?; | |
| 278 | + | draft::list_drafts(&conn, user_id) | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | #[tracing::instrument(skip_all)] | |
| 282 | + | fn save_draft( | |
| 283 | + | &self, | |
| 284 | + | id: EmailId, | |
| 285 | + | user_id: UserId, | |
| 286 | + | from: &str, | |
| 287 | + | to: &str, | |
| 288 | + | cc: Option<&str>, | |
| 289 | + | bcc: Option<&str>, | |
| 290 | + | subject: &str, | |
| 291 | + | body: &str, | |
| 292 | + | account_id: Option<EmailAccountId>, | |
| 293 | + | in_reply_to: Option<&str>, | |
| 294 | + | _references: Option<&str>, | |
| 295 | + | thread_id: Option<&str>, | |
| 296 | + | ) -> Result<Email> { | |
| 297 | + | let conn = self.db.conn()?; | |
| 298 | + | draft::save_draft( | |
| 299 | + | &conn, | |
| 300 | + | id, | |
| 301 | + | user_id, | |
| 302 | + | from, | |
| 303 | + | to, | |
| 304 | + | cc, | |
| 305 | + | bcc, | |
| 306 | + | subject, | |
| 307 | + | body, | |
| 308 | + | account_id, | |
| 309 | + | in_reply_to, | |
| 310 | + | thread_id, | |
| 311 | + | ) | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | #[tracing::instrument(skip_all)] | |
| 315 | + | fn queue_draft( | |
| 316 | + | &self, | |
| 317 | + | id: EmailId, | |
| 318 | + | user_id: UserId, | |
| 319 | + | send_after: Option<DateTime<Utc>>, | |
| 320 | + | ) -> Result<Option<Email>> { | |
| 321 | + | let conn = self.db.conn()?; | |
| 322 | + | draft::queue_draft(&conn, id, user_id, send_after) | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | #[tracing::instrument(skip_all)] | |
| 326 | + | fn unqueue_draft(&self, id: EmailId, user_id: UserId) -> Result<Option<Email>> { | |
| 327 | + | let conn = self.db.conn()?; | |
| 328 | + | draft::unqueue_draft(&conn, id, user_id) | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | #[tracing::instrument(skip_all)] | |
| 332 | + | fn list_outbox(&self, user_id: UserId) -> Result<Vec<Email>> { | |
| 333 | + | let conn = self.db.conn()?; | |
| 334 | + | draft::list_outbox(&conn, user_id) | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | #[tracing::instrument(skip_all)] | |
| 338 | + | fn list_due(&self, user_id: UserId, now: DateTime<Utc>) -> Result<Vec<Email>> { | |
| 339 | + | let conn = self.db.conn()?; | |
| 340 | + | draft::list_due(&conn, user_id, now) | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | #[tracing::instrument(skip_all)] | |
| 344 | + | fn record_send_failure(&self, id: EmailId, user_id: UserId, error: &str) -> Result<()> { | |
| 345 | + | let conn = self.db.conn()?; | |
| 346 | + | draft::record_send_failure(&conn, id, user_id, error) | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | #[tracing::instrument(skip_all)] | |
| 350 | + | fn get_by_message_id(&self, user_id: UserId, message_id: &str) -> Result<Option<Email>> { | |
| 351 | + | let conn = self.db.conn()?; | |
| 352 | + | sync::get_by_message_id(&conn, user_id, message_id) | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | #[tracing::instrument(skip_all)] | |
| 356 | + | fn update_labels( | |
| 357 | + | &self, | |
| 358 | + | id: EmailId, | |
| 359 | + | user_id: UserId, | |
| 360 | + | labels: &[String], | |
| 361 | + | ) -> Result<Option<Email>> { | |
| 362 | + | let conn = self.db.conn()?; | |
| 363 | + | flags::update_labels(&conn, id, user_id, labels) | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | #[tracing::instrument(skip_all)] | |
| 367 | + | fn list_folders(&self, user_id: UserId) -> Result<Vec<String>> { | |
| 368 | + | let conn = self.db.conn()?; | |
| 369 | + | sync::list_folders(&conn, user_id) | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | #[tracing::instrument(skip_all)] | |
| 373 | + | fn list_labels(&self, user_id: UserId) -> Result<Vec<String>> { | |
| 374 | + | let conn = self.db.conn()?; | |
| 375 | + | sync::list_labels(&conn, user_id) | |
| 376 | + | } | |
| 377 | + | } |