max / goingson
11 files changed,
+434 insertions,
-120 deletions
| @@ -4,16 +4,12 @@ | |||
| 4 | 4 | //! for restoring data from backups. The command layer handles file I/O; | |
| 5 | 5 | //! this module handles the restore logic using repository trait objects. | |
| 6 | 6 | ||
| 7 | - | use std::collections::HashMap; | |
| 8 | - | ||
| 9 | - | use crate::id_types::{ProjectId, TaskId, UserId}; | |
| 7 | + | use crate::id_types::UserId; | |
| 10 | 8 | ||
| 11 | 9 | use crate::error::CoreError; | |
| 12 | - | use crate::models::{ | |
| 13 | - | Email, Event, NewEmail, NewEvent, NewProject, Project, Task, | |
| 14 | - | }; | |
| 10 | + | use crate::models::{Email, Event, Project, Task}; | |
| 15 | 11 | use crate::{ | |
| 16 | - | Contact, NewContact, NewContactCustomField, NewContactEmail, NewContactPhone, NewSocialHandle, | |
| 12 | + | Contact, NewContactCustomField, NewContactEmail, NewContactPhone, NewSocialHandle, | |
| 17 | 13 | }; | |
| 18 | 14 | use crate::repository::{ContactRepository, EmailRepository, EventRepository, ProjectRepository, TaskRepository}; | |
| 19 | 15 | ||
| @@ -47,8 +43,13 @@ pub struct RestoreInput { | |||
| 47 | 43 | ||
| 48 | 44 | /// Restores entities from backup data, skipping those that already exist. | |
| 49 | 45 | /// | |
| 50 | - | /// Uses existence checks (by ID or message_id) to avoid duplicates. | |
| 51 | - | /// This is a merge operation — existing data is preserved. | |
| 46 | + | /// Entities are inserted **verbatim, preserving their original UUIDs** (and | |
| 47 | + | /// status / completed_at / metadata) via the repositories' `restore` methods. | |
| 48 | + | /// Because IDs are preserved, the existence checks below find previously-restored | |
| 49 | + | /// rows, so restoring the same backup twice is idempotent — it does not duplicate | |
| 50 | + | /// the dataset. This is a merge operation: existing data is preserved, and the | |
| 51 | + | /// backup's foreign-key references (project_id, linked_task_id) stay valid | |
| 52 | + | /// without remapping. | |
| 52 | 53 | pub async fn restore_from_backup( | |
| 53 | 54 | user_id: UserId, | |
| 54 | 55 | input: &RestoreInput, | |
| @@ -60,114 +61,61 @@ pub async fn restore_from_backup( | |||
| 60 | 61 | ) -> Result<RestoreResult, CoreError> { | |
| 61 | 62 | let mut result = RestoreResult::default(); | |
| 62 | 63 | ||
| 63 | - | // Import projects, tracking old-to-new ID mapping | |
| 64 | - | let mut project_id_map: HashMap<ProjectId, ProjectId> = HashMap::new(); | |
| 64 | + | // Projects — preserve id + created_at + status. | |
| 65 | 65 | for project in &input.projects { | |
| 66 | 66 | if projects.get_by_id(project.id, user_id).await?.is_none() { | |
| 67 | - | let new_project = NewProject { | |
| 68 | - | name: project.name.clone(), | |
| 69 | - | description: project.description.clone(), | |
| 70 | - | project_type: project.project_type.clone(), | |
| 71 | - | status: project.status.clone(), | |
| 72 | - | }; | |
| 73 | - | let created = projects.create(user_id, new_project).await?; | |
| 74 | - | project_id_map.insert(project.id, created.id); | |
| 67 | + | projects.restore(user_id, project).await?; | |
| 75 | 68 | result.projects_restored += 1; | |
| 76 | 69 | } | |
| 77 | 70 | } | |
| 78 | 71 | ||
| 79 | - | // Import tasks, remapping project_id references and restoring subtasks/annotations | |
| 80 | - | let mut task_id_map: HashMap<TaskId, TaskId> = HashMap::new(); | |
| 72 | + | // Tasks — preserve id + status + completed_at + all scheduling metadata. | |
| 73 | + | // project_id references stay valid because project ids are preserved. | |
| 81 | 74 | for task in &input.tasks { | |
| 82 | 75 | if tasks.get_by_id(task.id, user_id).await?.is_none() { | |
| 83 | - | let new_task = crate::models::NewTask::builder(&task.description) | |
| 84 | - | .priority(task.priority.clone()) | |
| 85 | - | .tags(task.tags.clone()) | |
| 86 | - | .recurrence(task.recurrence.clone()) | |
| 87 | - | .urgency(task.urgency); | |
| 88 | - | ||
| 89 | - | let new_task = if let Some(due) = task.due { | |
| 90 | - | new_task.due(due) | |
| 91 | - | } else { | |
| 92 | - | new_task | |
| 93 | - | }; | |
| 94 | - | ||
| 95 | - | let remapped_pid = task.project_id | |
| 96 | - | .and_then(|pid| project_id_map.get(&pid).copied().or(Some(pid))); | |
| 97 | - | let new_task = if let Some(pid) = remapped_pid { | |
| 98 | - | new_task.project_id(pid) | |
| 99 | - | } else { | |
| 100 | - | new_task | |
| 101 | - | }; | |
| 102 | - | ||
| 103 | - | let created = tasks.create(user_id, new_task.build()).await?; | |
| 104 | - | task_id_map.insert(task.id, created.id); | |
| 76 | + | tasks.restore(user_id, task).await?; | |
| 105 | 77 | result.tasks_restored += 1; | |
| 106 | 78 | ||
| 107 | 79 | // Restore annotations | |
| 108 | 80 | for annotation in &task.annotations { | |
| 109 | - | if tasks.add_annotation(created.id, user_id, &annotation.note).await?.is_some() { | |
| 81 | + | if tasks.add_annotation(task.id, user_id, &annotation.note).await?.is_some() { | |
| 110 | 82 | result.annotations_restored += 1; | |
| 111 | 83 | } | |
| 112 | 84 | } | |
| 113 | 85 | ||
| 114 | 86 | // Restore subtasks (text-only; linked subtasks handled in second pass) | |
| 115 | 87 | for subtask in &task.subtasks { | |
| 116 | - | if subtask.linked_task_id.is_none() { | |
| 117 | - | if tasks.add_subtask(created.id, user_id, &subtask.text).await?.is_some() { | |
| 118 | - | result.subtasks_restored += 1; | |
| 119 | - | } | |
| 88 | + | if subtask.linked_task_id.is_none() | |
| 89 | + | && tasks.add_subtask(task.id, user_id, &subtask.text).await?.is_some() | |
| 90 | + | { | |
| 91 | + | result.subtasks_restored += 1; | |
| 120 | 92 | } | |
| 121 | 93 | } | |
| 122 | 94 | } | |
| 123 | 95 | } | |
| 124 | 96 | ||
| 125 | - | // Second pass: restore subtasks with linked_task_id (requires all tasks to exist) | |
| 97 | + | // Second pass: restore subtasks with linked_task_id (requires all tasks to | |
| 98 | + | // exist). IDs are preserved, so parent and linked ids are used directly. | |
| 126 | 99 | for task in &input.tasks { | |
| 127 | - | let new_parent_id = task_id_map.get(&task.id).copied().unwrap_or(task.id); | |
| 128 | 100 | for subtask in &task.subtasks { | |
| 129 | 101 | if let Some(linked_id) = subtask.linked_task_id { | |
| 130 | - | let new_linked_id = task_id_map.get(&linked_id).copied().unwrap_or(linked_id); | |
| 131 | - | if tasks.add_subtask_link(new_parent_id, user_id, new_linked_id).await.ok().flatten().is_some() { | |
| 102 | + | if tasks.add_subtask_link(task.id, user_id, linked_id).await.ok().flatten().is_some() { | |
| 132 | 103 | result.subtasks_restored += 1; | |
| 133 | 104 | } | |
| 134 | 105 | } | |
| 135 | 106 | } | |
| 136 | 107 | } | |
| 137 | 108 | ||
| 138 | - | // Import events, remapping project_id references | |
| 109 | + | // Events — preserve id + full metadata (block_type, external_*, snoozed, | |
| 110 | + | // reminders, recurrence_parent_id). | |
| 139 | 111 | for event in &input.events { | |
| 140 | 112 | if events.get_by_id(event.id, user_id).await?.is_none() { | |
| 141 | - | let new_event = NewEvent::builder(&event.title, event.start_time) | |
| 142 | - | .description(&event.description) | |
| 143 | - | .recurrence(event.recurrence.clone()); | |
| 144 | - | ||
| 145 | - | let new_event = if let Some(end) = event.end_time { | |
| 146 | - | new_event.end_time(end) | |
| 147 | - | } else { | |
| 148 | - | new_event | |
| 149 | - | }; | |
| 150 | - | ||
| 151 | - | let new_event = if let Some(ref loc) = event.location { | |
| 152 | - | new_event.location(loc) | |
| 153 | - | } else { | |
| 154 | - | new_event | |
| 155 | - | }; | |
| 156 | - | ||
| 157 | - | let remapped_pid = event.project_id | |
| 158 | - | .and_then(|pid| project_id_map.get(&pid).copied().or(Some(pid))); | |
| 159 | - | let new_event = if let Some(pid) = remapped_pid { | |
| 160 | - | new_event.project_id(pid) | |
| 161 | - | } else { | |
| 162 | - | new_event | |
| 163 | - | }; | |
| 164 | - | ||
| 165 | - | events.create(user_id, new_event.build()).await?; | |
| 113 | + | events.restore(user_id, event).await?; | |
| 166 | 114 | result.events_restored += 1; | |
| 167 | 115 | } | |
| 168 | 116 | } | |
| 169 | 117 | ||
| 170 | - | // Import emails | |
| 118 | + | // Emails — dedupe by message_id when present, else by preserved id. | |
| 171 | 119 | for email in &input.emails { | |
| 172 | 120 | let exists = if let Some(ref msg_id) = email.message_id { | |
| 173 | 121 | emails.exists_by_message_id(user_id, msg_id).await? | |
| @@ -176,61 +124,42 @@ pub async fn restore_from_backup( | |||
| 176 | 124 | }; | |
| 177 | 125 | ||
| 178 | 126 | if !exists { | |
| 179 | - | let new_email = NewEmail { | |
| 180 | - | project_id: email.project_id, | |
| 181 | - | from_address: email.from.clone(), | |
| 182 | - | to_address: email.to.clone(), | |
| 183 | - | subject: email.subject.clone(), | |
| 184 | - | body: email.body.clone(), | |
| 185 | - | is_read: email.is_read, | |
| 186 | - | received_at: Some(email.received_at), | |
| 187 | - | }; | |
| 188 | - | emails.create(user_id, new_email).await?; | |
| 127 | + | emails.restore(user_id, email).await?; | |
| 189 | 128 | result.emails_restored += 1; | |
| 190 | 129 | } | |
| 191 | 130 | } | |
| 192 | 131 | ||
| 193 | - | // Import contacts | |
| 132 | + | // Contacts — preserve id + created_at/updated_at; restore sub-collections. | |
| 194 | 133 | for contact in &input.contacts { | |
| 195 | 134 | if contacts.get_by_id(contact.id, user_id).await?.is_none() { | |
| 196 | - | let new_contact = NewContact { | |
| 197 | - | display_name: contact.display_name.clone(), | |
| 198 | - | nickname: contact.nickname.clone(), | |
| 199 | - | company: contact.company.clone(), | |
| 200 | - | title: contact.title.clone(), | |
| 201 | - | notes: contact.notes.clone(), | |
| 202 | - | tags: contact.tags.clone(), | |
| 203 | - | birthday: contact.birthday, | |
| 204 | - | timezone: contact.timezone.clone(), | |
| 205 | - | is_implicit: contact.is_implicit, | |
| 206 | - | }; | |
| 207 | - | let created = contacts.create(user_id, new_contact).await?; | |
| 135 | + | contacts.restore(user_id, contact).await?; | |
| 208 | 136 | result.contacts_restored += 1; | |
| 209 | 137 | ||
| 210 | - | // Restore sub-collections | |
| 138 | + | // Restore sub-collections (fresh ids; gated by the parent existence | |
| 139 | + | // check above so a second restore does not re-add them). | |
| 211 | 140 | for email in &contact.emails { | |
| 212 | - | let _ = contacts.add_email(created.id, user_id, NewContactEmail { | |
| 141 | + | let _ = contacts.add_email(contact.id, user_id, NewContactEmail { | |
| 213 | 142 | address: email.address.clone(), | |
| 214 | 143 | label: email.label.clone(), | |
| 215 | 144 | is_primary: email.is_primary, | |
| 216 | 145 | }).await; | |
| 217 | 146 | } | |
| 218 | 147 | for phone in &contact.phones { | |
| 219 | - | let _ = contacts.add_phone(created.id, user_id, NewContactPhone { | |
| 148 | + | let _ = contacts.add_phone(contact.id, user_id, NewContactPhone { | |
| 220 | 149 | number: phone.number.clone(), | |
| 221 | 150 | label: phone.label.clone(), | |
| 222 | 151 | is_primary: phone.is_primary, | |
| 223 | 152 | }).await; | |
| 224 | 153 | } | |
| 225 | 154 | for handle in &contact.social_handles { | |
| 226 | - | let _ = contacts.add_social_handle(created.id, user_id, NewSocialHandle { | |
| 155 | + | let _ = contacts.add_social_handle(contact.id, user_id, NewSocialHandle { | |
| 227 | 156 | platform: handle.platform.clone(), | |
| 228 | 157 | handle: handle.handle.clone(), | |
| 229 | 158 | url: handle.url.clone(), | |
| 230 | 159 | }).await; | |
| 231 | 160 | } | |
| 232 | 161 | for field in &contact.custom_fields { | |
| 233 | - | let _ = contacts.add_custom_field(created.id, user_id, NewContactCustomField { | |
| 162 | + | let _ = contacts.add_custom_field(contact.id, user_id, NewContactCustomField { | |
| 234 | 163 | label: field.label.clone(), | |
| 235 | 164 | value: field.value.clone(), | |
| 236 | 165 | url: field.url.clone(), |
| @@ -42,6 +42,11 @@ pub trait ProjectRepository: Send + Sync { | |||
| 42 | 42 | /// Creates a new project. | |
| 43 | 43 | async fn create(&self, user_id: UserId, project: NewProject) -> Result<Project>; | |
| 44 | 44 | ||
| 45 | + | /// Restores a project verbatim from a backup, preserving its original ID | |
| 46 | + | /// and `created_at`. Idempotent (`INSERT OR IGNORE`): re-restoring the same | |
| 47 | + | /// backup is a no-op rather than a duplicate. | |
| 48 | + | async fn restore(&self, user_id: UserId, project: &Project) -> Result<()>; | |
| 49 | + | ||
| 45 | 50 | /// Updates an existing project, returning `None` if not found. | |
| 46 | 51 | async fn update( | |
| 47 | 52 | &self, | |
| @@ -86,6 +91,11 @@ pub trait TaskRepository: Send + Sync { | |||
| 86 | 91 | /// Creates a new task. | |
| 87 | 92 | async fn create(&self, user_id: UserId, task: NewTask) -> Result<Task>; | |
| 88 | 93 | ||
| 94 | + | /// Restores a task verbatim from a backup, preserving its original ID, | |
| 95 | + | /// `status`, `completed_at`, and `created_at`. Idempotent (`INSERT OR | |
| 96 | + | /// IGNORE`). Annotations and subtasks are restored separately by the caller. | |
| 97 | + | async fn restore(&self, user_id: UserId, task: &Task) -> Result<()>; | |
| 98 | + | ||
| 89 | 99 | /// Updates an existing task. | |
| 90 | 100 | async fn update(&self, id: TaskId, user_id: UserId, task: UpdateTask) -> Result<Option<Task>>; | |
| 91 | 101 | ||
| @@ -243,6 +253,12 @@ pub trait EventRepository: Send + Sync { | |||
| 243 | 253 | /// Creates a new event. | |
| 244 | 254 | async fn create(&self, user_id: UserId, event: NewEvent) -> Result<Event>; | |
| 245 | 255 | ||
| 256 | + | /// Restores an event verbatim from a backup, preserving its original ID and | |
| 257 | + | /// all metadata the normal create path never sets — `block_type`, | |
| 258 | + | /// `external_source`/`external_id`, `recurrence_parent_id`, `is_read_only`, | |
| 259 | + | /// `snoozed_until`, and reminder offsets. Idempotent (`INSERT OR IGNORE`). | |
| 260 | + | async fn restore(&self, user_id: UserId, event: &Event) -> Result<()>; | |
| 261 | + | ||
| 246 | 262 | /// Updates an existing event. | |
| 247 | 263 | async fn update(&self, id: EventId, user_id: UserId, event: crate::models::UpdateEvent) -> Result<Option<Event>>; | |
| 248 | 264 | ||
| @@ -313,6 +329,14 @@ pub trait EmailRepository: Send + Sync { | |||
| 313 | 329 | /// Creates a new email record. | |
| 314 | 330 | async fn create(&self, user_id: UserId, email: NewEmail) -> Result<Email>; | |
| 315 | 331 | ||
| 332 | + | /// Restores an email from a backup, preserving its original ID and | |
| 333 | + | /// `message_id` so re-restore dedupes. Round-trips the durable fields | |
| 334 | + | /// (addresses, subject, body, html_body, read/archived/outgoing flags, | |
| 335 | + | /// received_at, threading, labels); transient IMAP-sync and draft-compose | |
| 336 | + | /// state is not restored (emails re-sync from the server). Idempotent | |
| 337 | + | /// (`INSERT OR IGNORE`). | |
| 338 | + | async fn restore(&self, user_id: UserId, email: &Email) -> Result<()>; | |
| 339 | + | ||
| 316 | 340 | /// Creates an email with follow-up tracking fields. | |
| 317 | 341 | async fn create_with_tracking(&self, user_id: UserId, email: NewEmailWithTracking) -> Result<Email>; | |
| 318 | 342 | ||
| @@ -871,6 +895,12 @@ pub trait ContactRepository: Send + Sync { | |||
| 871 | 895 | /// Creates a new contact. | |
| 872 | 896 | async fn create(&self, user_id: UserId, contact: NewContact) -> Result<Contact>; | |
| 873 | 897 | ||
| 898 | + | /// Restores a contact verbatim from a backup, preserving its original ID, | |
| 899 | + | /// `created_at`, and `updated_at`. Idempotent (`INSERT OR IGNORE`). | |
| 900 | + | /// Sub-collections (emails, phones, social handles, custom fields) are | |
| 901 | + | /// restored separately by the caller. | |
| 902 | + | async fn restore(&self, user_id: UserId, contact: &Contact) -> Result<()>; | |
| 903 | + | ||
| 874 | 904 | /// Updates an existing contact, returning `None` if not found. | |
| 875 | 905 | async fn update(&self, id: ContactId, user_id: UserId, contact: UpdateContact) -> Result<Option<Contact>>; | |
| 876 | 906 |
| @@ -13,7 +13,7 @@ use goingson_core::{ | |||
| 13 | 13 | SocialHandle, SocialHandleId, UpdateContact, UserId, | |
| 14 | 14 | }; | |
| 15 | 15 | ||
| 16 | - | use crate::utils::{escape_like, format_datetime_now, parse_datetime, parse_tags, parse_uuid}; | |
| 16 | + | use crate::utils::{escape_like, format_datetime, format_datetime_now, parse_datetime, parse_tags, parse_uuid}; | |
| 17 | 17 | ||
| 18 | 18 | // ============ Row Structs ============ | |
| 19 | 19 | ||
| @@ -390,6 +390,38 @@ impl ContactRepository for SqliteContactRepository { | |||
| 390 | 390 | } | |
| 391 | 391 | ||
| 392 | 392 | #[tracing::instrument(skip_all)] | |
| 393 | + | async fn restore(&self, user_id: UserId, contact: &Contact) -> Result<()> { | |
| 394 | + | let tags_json = serde_json::to_string(&contact.tags).unwrap_or_else(|_| "[]".to_string()); | |
| 395 | + | let birthday_str = contact.birthday.map(|d| d.format("%Y-%m-%d").to_string()); | |
| 396 | + | ||
| 397 | + | sqlx::query( | |
| 398 | + | r#" | |
| 399 | + | 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) | |
| 400 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 401 | + | "#, | |
| 402 | + | ) | |
| 403 | + | .bind(contact.id.to_string()) | |
| 404 | + | .bind(user_id.to_string()) | |
| 405 | + | .bind(&contact.display_name) | |
| 406 | + | .bind(&contact.nickname) | |
| 407 | + | .bind(&contact.company) | |
| 408 | + | .bind(&contact.title) | |
| 409 | + | .bind(&contact.notes) | |
| 410 | + | .bind(&tags_json) | |
| 411 | + | .bind(&birthday_str) | |
| 412 | + | .bind(&contact.timezone) | |
| 413 | + | .bind(&contact.external_source) | |
| 414 | + | .bind(&contact.external_id) | |
| 415 | + | .bind(if contact.is_implicit { 1 } else { 0 }) | |
| 416 | + | .bind(format_datetime(&contact.created_at)) | |
| 417 | + | .bind(format_datetime(&contact.updated_at)) | |
| 418 | + | .execute(&self.pool) | |
| 419 | + | .await | |
| 420 | + | .map_err(CoreError::database)?; | |
| 421 | + | Ok(()) | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | #[tracing::instrument(skip_all)] | |
| 393 | 425 | async fn update(&self, id: ContactId, user_id: UserId, contact: UpdateContact) -> Result<Option<Contact>> { | |
| 394 | 426 | let now = format_datetime_now(); | |
| 395 | 427 | let tags_json = serde_json::to_string(&contact.tags).unwrap_or_else(|_| "[]".to_string()); |
| @@ -297,6 +297,47 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 297 | 297 | } | |
| 298 | 298 | ||
| 299 | 299 | #[tracing::instrument(skip_all)] | |
| 300 | + | async fn restore(&self, user_id: UserId, email: &Email) -> Result<()> { | |
| 301 | + | // Durable content fields are round-tripped; account-linked and | |
| 302 | + | // sync-transient state (email_account_id, imap_uid, source_folder, | |
| 303 | + | // attachment_meta, draft_account_id) is intentionally omitted — those | |
| 304 | + | // FK into email_accounts (not part of a backup) or are re-derived on the | |
| 305 | + | // next IMAP sync. Preserving the original id + message_id makes a second | |
| 306 | + | // restore a no-op. | |
| 307 | + | let labels_json = serde_json::to_string(&email.labels).unwrap_or_else(|_| "[]".to_string()); | |
| 308 | + | sqlx::query( | |
| 309 | + | "INSERT OR IGNORE INTO emails (id, user_id, project_id, from_address, to_address, subject, body, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 310 | + | ) | |
| 311 | + | .bind(email.id.to_string()) | |
| 312 | + | .bind(user_id.to_string()) | |
| 313 | + | .bind(email.project_id.map(|p| p.to_string())) | |
| 314 | + | .bind(&email.from) | |
| 315 | + | .bind(&email.to) | |
| 316 | + | .bind(&email.subject) | |
| 317 | + | .bind(&email.body) | |
| 318 | + | .bind(&email.html_body) | |
| 319 | + | .bind(if email.is_read { 1 } else { 0 }) | |
| 320 | + | .bind(if email.is_archived { 1 } else { 0 }) | |
| 321 | + | .bind(format_datetime(&email.received_at)) | |
| 322 | + | .bind(&email.message_id) | |
| 323 | + | .bind(&email.in_reply_to) | |
| 324 | + | .bind(&email.thread_id) | |
| 325 | + | .bind(if email.is_outgoing { 1 } else { 0 }) | |
| 326 | + | .bind(&labels_json) | |
| 327 | + | .bind(if email.is_draft { 1 } else { 0 }) | |
| 328 | + | .bind(&email.cc_address) | |
| 329 | + | .bind(&email.bcc_address) | |
| 330 | + | .bind(format_datetime_opt(email.snoozed_until)) | |
| 331 | + | .bind(if email.waiting_for_response { 1 } else { 0 }) | |
| 332 | + | .bind(format_datetime_opt(email.waiting_since)) | |
| 333 | + | .bind(format_datetime_opt(email.expected_response_date)) | |
| 334 | + | .execute(&self.pool) | |
| 335 | + | .await | |
| 336 | + | .map_err(CoreError::database)?; | |
| 337 | + | Ok(()) | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | #[tracing::instrument(skip_all)] | |
| 300 | 341 | async fn create_with_tracking(&self, user_id: UserId, email: NewEmailWithTracking) -> Result<Email> { | |
| 301 | 342 | let id = goingson_core::deterministic_email_id(email.message_id.as_deref()); | |
| 302 | 343 | let received_at = format_datetime(&email.received_at.unwrap_or_else(Utc::now)); |
| @@ -205,6 +205,44 @@ impl EventRepository for SqliteEventRepository { | |||
| 205 | 205 | } | |
| 206 | 206 | ||
| 207 | 207 | #[tracing::instrument(skip_all)] | |
| 208 | + | async fn restore(&self, user_id: UserId, event: &Event) -> Result<()> { | |
| 209 | + | let recurrence_rule_json = event.recurrence_rule.as_ref() | |
| 210 | + | .map(|r| serde_json::to_string(r).unwrap_or_default()); | |
| 211 | + | let reminder_offsets_json = if event.reminder_offsets_seconds.is_empty() { | |
| 212 | + | None | |
| 213 | + | } else { | |
| 214 | + | Some(serde_json::to_string(&event.reminder_offsets_seconds).unwrap_or_default()) | |
| 215 | + | }; | |
| 216 | + | ||
| 217 | + | sqlx::query( | |
| 218 | + | "INSERT OR IGNORE INTO events (id, user_id, project_id, title, description, start_time, end_time, location, linked_task_id, recurrence, recurrence_rule, recurrence_parent_id, contact_id, block_type, external_source, external_id, is_read_only, snoozed_until, reminder_offsets_seconds) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 219 | + | ) | |
| 220 | + | .bind(event.id.to_string()) | |
| 221 | + | .bind(user_id.to_string()) | |
| 222 | + | .bind(event.project_id.map(|p| p.to_string())) | |
| 223 | + | .bind(&event.title) | |
| 224 | + | .bind(&event.description) | |
| 225 | + | .bind(format_datetime(&event.start_time)) | |
| 226 | + | .bind(format_datetime_opt(event.end_time)) | |
| 227 | + | .bind(&event.location) | |
| 228 | + | .bind(event.linked_task_id.map(|t| t.to_string())) | |
| 229 | + | .bind(event.recurrence.db_value()) | |
| 230 | + | .bind(&recurrence_rule_json) | |
| 231 | + | .bind(event.recurrence_parent_id.map(|p| p.to_string())) | |
| 232 | + | .bind(event.contact_id.map(|c| c.to_string())) | |
| 233 | + | .bind(event.block_type.as_ref().map(|b| b.db_value())) | |
| 234 | + | .bind(&event.external_source) | |
| 235 | + | .bind(&event.external_id) | |
| 236 | + | .bind(if event.is_read_only { 1 } else { 0 }) | |
| 237 | + | .bind(format_datetime_opt(event.snoozed_until)) | |
| 238 | + | .bind(&reminder_offsets_json) | |
| 239 | + | .execute(&self.pool) | |
| 240 | + | .await | |
| 241 | + | .map_err(CoreError::database)?; | |
| 242 | + | Ok(()) | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | #[tracing::instrument(skip_all)] | |
| 208 | 246 | async fn update(&self, id: EventId, user_id: UserId, event: UpdateEvent) -> Result<Option<Event>> { | |
| 209 | 247 | let start_str = format_datetime(&event.start_time); | |
| 210 | 248 | let end_str = format_datetime_opt(event.end_time); |
| @@ -11,7 +11,7 @@ use goingson_core::{ | |||
| 11 | 11 | ProjectStatus, ProjectType, Result, UpdateProject, UserId, | |
| 12 | 12 | }; | |
| 13 | 13 | ||
| 14 | - | use crate::utils::{format_datetime_now, parse_datetime, parse_uuid}; | |
| 14 | + | use crate::utils::{format_datetime, format_datetime_now, parse_datetime, parse_uuid}; | |
| 15 | 15 | ||
| 16 | 16 | /// Database row struct for Project | |
| 17 | 17 | #[derive(Debug, Clone, sqlx::FromRow)] | |
| @@ -122,6 +122,27 @@ impl ProjectRepository for SqliteProjectRepository { | |||
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | 124 | #[tracing::instrument(skip_all)] | |
| 125 | + | async fn restore(&self, user_id: UserId, project: &Project) -> Result<()> { | |
| 126 | + | sqlx::query( | |
| 127 | + | r#" | |
| 128 | + | INSERT OR IGNORE INTO projects (id, user_id, name, description, project_type, status, created_at) | |
| 129 | + | VALUES (?, ?, ?, ?, ?, ?, ?) | |
| 130 | + | "#, | |
| 131 | + | ) | |
| 132 | + | .bind(project.id.to_string()) | |
| 133 | + | .bind(user_id.to_string()) | |
| 134 | + | .bind(&project.name) | |
| 135 | + | .bind(&project.description) | |
| 136 | + | .bind(project.project_type.db_value()) | |
| 137 | + | .bind(project.status.db_value()) | |
| 138 | + | .bind(format_datetime(&project.created_at)) | |
| 139 | + | .execute(&self.pool) | |
| 140 | + | .await | |
| 141 | + | .map_err(CoreError::database)?; | |
| 142 | + | Ok(()) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | #[tracing::instrument(skip_all)] | |
| 125 | 146 | async fn update( | |
| 126 | 147 | &self, | |
| 127 | 148 | id: ProjectId, |
| @@ -487,6 +487,56 @@ impl TaskRepository for SqliteTaskRepository { | |||
| 487 | 487 | } | |
| 488 | 488 | ||
| 489 | 489 | #[tracing::instrument(skip_all)] | |
| 490 | + | async fn restore(&self, user_id: UserId, task: &Task) -> Result<()> { | |
| 491 | + | let tags_json = serde_json::to_string(&task.tags).unwrap_or_else(|_| "[]".to_string()); | |
| 492 | + | let recurrence_rule_json = task.recurrence_rule.as_ref() | |
| 493 | + | .map(|r| serde_json::to_string(r).unwrap_or_default()); | |
| 494 | + | ||
| 495 | + | sqlx::query( | |
| 496 | + | r#" | |
| 497 | + | INSERT OR IGNORE INTO tasks ( | |
| 498 | + | id, user_id, project_id, contact_id, milestone_id, description, status, | |
| 499 | + | priority, due, tags, urgency, recurrence, recurrence_rule, recurrence_parent_id, | |
| 500 | + | source_email_id, snoozed_until, waiting_for_response, waiting_since, expected_response_date, | |
| 501 | + | scheduled_start, scheduled_duration, estimated_minutes, actual_minutes, | |
| 502 | + | created_at, completed_at, is_focus, focus_set_at | |
| 503 | + | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 504 | + | "#, | |
| 505 | + | ) | |
| 506 | + | .bind(task.id.to_string()) | |
| 507 | + | .bind(user_id.to_string()) | |
| 508 | + | .bind(task.project_id.map(|p| p.to_string())) | |
| 509 | + | .bind(task.contact_id.map(|c| c.to_string())) | |
| 510 | + | .bind(task.milestone_id.map(|m| m.to_string())) | |
| 511 | + | .bind(&task.description) | |
| 512 | + | .bind(task.status.db_value()) | |
| 513 | + | .bind(task.priority.db_value()) | |
| 514 | + | .bind(format_datetime_opt(task.due)) | |
| 515 | + | .bind(&tags_json) | |
| 516 | + | .bind(task.urgency) | |
| 517 | + | .bind(task.recurrence.db_value()) | |
| 518 | + | .bind(&recurrence_rule_json) | |
| 519 | + | .bind(task.recurrence_parent_id.map(|p| p.to_string())) | |
| 520 | + | .bind(task.source_email_id.map(|e| e.to_string())) | |
| 521 | + | .bind(format_datetime_opt(task.snoozed_until)) | |
| 522 | + | .bind(if task.waiting_for_response { 1 } else { 0 }) | |
| 523 | + | .bind(format_datetime_opt(task.waiting_since)) | |
| 524 | + | .bind(format_datetime_opt(task.expected_response_date)) | |
| 525 | + | .bind(format_datetime_opt(task.scheduled_start)) | |
| 526 | + | .bind(task.scheduled_duration) | |
| 527 | + | .bind(task.estimated_minutes) | |
| 528 | + | .bind(task.actual_minutes) | |
| 529 | + | .bind(format_datetime(&task.created_at)) | |
| 530 | + | .bind(task.completed_at.map(|d| format_datetime(&d))) | |
| 531 | + | .bind(if task.is_focus { 1 } else { 0 }) | |
| 532 | + | .bind(task.focus_set_at.map(|d| format_datetime(&d))) | |
| 533 | + | .execute(&self.pool) | |
| 534 | + | .await | |
| 535 | + | .map_err(CoreError::database)?; | |
| 536 | + | Ok(()) | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | #[tracing::instrument(skip_all)] | |
| 490 | 540 | async fn update(&self, id: TaskId, user_id: UserId, task: UpdateTask) -> Result<Option<Task>> { | |
| 491 | 541 | let due_str = format_datetime_opt(task.due); | |
| 492 | 542 | let scheduled_start_str = format_datetime_opt(task.scheduled_start); |
| @@ -0,0 +1,163 @@ | |||
| 1 | + | //! Integration tests for backup restore (GO-1 launch fixes). | |
| 2 | + | //! | |
| 3 | + | //! Covers: completed-status + completed_at preservation, event-metadata | |
| 4 | + | //! round-trip (the fields the normal create path never sets), and idempotent | |
| 5 | + | //! re-restore (restoring the same backup twice must not duplicate the dataset). | |
| 6 | + | ||
| 7 | + | mod common; | |
| 8 | + | ||
| 9 | + | use goingson_core::backup_restore::{restore_from_backup, RestoreInput}; | |
| 10 | + | use goingson_core::{ | |
| 11 | + | EventRepository, NewEvent, NewProject, NewTask, ProjectRepository, TaskRepository, TaskStatus, | |
| 12 | + | }; | |
| 13 | + | use goingson_db_sqlite::{ | |
| 14 | + | SqliteContactRepository, SqliteEmailRepository, SqliteEventRepository, SqliteProjectRepository, | |
| 15 | + | SqliteTaskRepository, | |
| 16 | + | }; | |
| 17 | + | ||
| 18 | + | #[tokio::test] | |
| 19 | + | async fn restore_preserves_completed_status_and_is_idempotent() { | |
| 20 | + | let pool = common::setup_test_db().await; | |
| 21 | + | let user_id = common::create_test_user(&pool).await; | |
| 22 | + | ||
| 23 | + | let projects = SqliteProjectRepository::new(pool.clone()); | |
| 24 | + | let tasks = SqliteTaskRepository::new(pool.clone()); | |
| 25 | + | let events = SqliteEventRepository::new(pool.clone()); | |
| 26 | + | let emails = SqliteEmailRepository::new(pool.clone()); | |
| 27 | + | let contacts = SqliteContactRepository::new(pool.clone()); | |
| 28 | + | ||
| 29 | + | // Manufacture a project and a COMPLETED task. | |
| 30 | + | let project = projects | |
| 31 | + | .create( | |
| 32 | + | user_id, | |
| 33 | + | NewProject { | |
| 34 | + | name: "Backup Project".into(), | |
| 35 | + | description: String::new(), | |
| 36 | + | project_type: Default::default(), | |
| 37 | + | status: Default::default(), | |
| 38 | + | }, | |
| 39 | + | ) | |
| 40 | + | .await | |
| 41 | + | .unwrap(); | |
| 42 | + | ||
| 43 | + | let task = tasks | |
| 44 | + | .create(user_id, NewTask::builder("Finish the report").build()) | |
| 45 | + | .await | |
| 46 | + | .unwrap(); | |
| 47 | + | tasks.complete(task.id, user_id).await.unwrap(); | |
| 48 | + | let completed = tasks.get_by_id(task.id, user_id).await.unwrap().unwrap(); | |
| 49 | + | assert_eq!(completed.status, TaskStatus::Completed); | |
| 50 | + | assert!(completed.completed_at.is_some()); | |
| 51 | + | ||
| 52 | + | // Simulate data loss: hard-delete the rows. | |
| 53 | + | sqlx::query("DELETE FROM tasks WHERE id = ?") | |
| 54 | + | .bind(task.id.to_string()) | |
| 55 | + | .execute(&pool) | |
| 56 | + | .await | |
| 57 | + | .unwrap(); | |
| 58 | + | sqlx::query("DELETE FROM projects WHERE id = ?") | |
| 59 | + | .bind(project.id.to_string()) | |
| 60 | + | .execute(&pool) | |
| 61 | + | .await | |
| 62 | + | .unwrap(); | |
| 63 | + | assert!(tasks.get_by_id(task.id, user_id).await.unwrap().is_none()); | |
| 64 | + | ||
| 65 | + | let input = RestoreInput { | |
| 66 | + | projects: vec![project.clone()], | |
| 67 | + | tasks: vec![completed.clone()], | |
| 68 | + | events: vec![], | |
| 69 | + | emails: vec![], | |
| 70 | + | contacts: vec![], | |
| 71 | + | }; | |
| 72 | + | ||
| 73 | + | // First restore recreates with status + completed_at + id preserved. | |
| 74 | + | let r1 = restore_from_backup(user_id, &input, &projects, &tasks, &events, &emails, &contacts) | |
| 75 | + | .await | |
| 76 | + | .unwrap(); | |
| 77 | + | assert_eq!(r1.projects_restored, 1); | |
| 78 | + | assert_eq!(r1.tasks_restored, 1); | |
| 79 | + | let restored = tasks | |
| 80 | + | .get_by_id(task.id, user_id) | |
| 81 | + | .await | |
| 82 | + | .unwrap() | |
| 83 | + | .expect("task restored"); | |
| 84 | + | assert_eq!( | |
| 85 | + | restored.status, | |
| 86 | + | TaskStatus::Completed, | |
| 87 | + | "completed status must survive restore" | |
| 88 | + | ); | |
| 89 | + | assert!( | |
| 90 | + | restored.completed_at.is_some(), | |
| 91 | + | "completed_at must survive restore" | |
| 92 | + | ); | |
| 93 | + | assert_eq!(restored.id, task.id, "original id preserved"); | |
| 94 | + | ||
| 95 | + | // Second restore is a no-op — no duplication. | |
| 96 | + | let r2 = restore_from_backup(user_id, &input, &projects, &tasks, &events, &emails, &contacts) | |
| 97 | + | .await | |
| 98 | + | .unwrap(); | |
| 99 | + | assert_eq!(r2.projects_restored, 0); | |
| 100 | + | assert_eq!(r2.tasks_restored, 0); | |
| 101 | + | assert_eq!( | |
| 102 | + | tasks.list_all(user_id).await.unwrap().len(), | |
| 103 | + | 1, | |
| 104 | + | "no duplicate task after re-restore" | |
| 105 | + | ); | |
| 106 | + | assert_eq!( | |
| 107 | + | projects.list_all(user_id).await.unwrap().len(), | |
| 108 | + | 1, | |
| 109 | + | "no duplicate project after re-restore" | |
| 110 | + | ); | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | #[tokio::test] | |
| 114 | + | async fn restore_event_preserves_external_metadata() { | |
| 115 | + | let pool = common::setup_test_db().await; | |
| 116 | + | let user_id = common::create_test_user(&pool).await; | |
| 117 | + | let events = SqliteEventRepository::new(pool.clone()); | |
| 118 | + | ||
| 119 | + | // Manufacture an event, then stamp external-sync metadata that the normal | |
| 120 | + | // create path never writes (external_source/external_id/is_read_only). | |
| 121 | + | let ev = events | |
| 122 | + | .create( | |
| 123 | + | user_id, | |
| 124 | + | NewEvent::builder("Sync Meeting", chrono::Utc::now()).build(), | |
| 125 | + | ) | |
| 126 | + | .await | |
| 127 | + | .unwrap(); | |
| 128 | + | sqlx::query("UPDATE events SET external_source = ?, external_id = ?, is_read_only = 1 WHERE id = ?") | |
| 129 | + | .bind("google") | |
| 130 | + | .bind("evt-123") | |
| 131 | + | .bind(ev.id.to_string()) | |
| 132 | + | .execute(&pool) | |
| 133 | + | .await | |
| 134 | + | .unwrap(); | |
| 135 | + | let full = events.get_by_id(ev.id, user_id).await.unwrap().unwrap(); | |
| 136 | + | assert_eq!(full.external_source.as_deref(), Some("google")); | |
| 137 | + | ||
| 138 | + | // Lose it, then restore verbatim. | |
| 139 | + | sqlx::query("DELETE FROM events WHERE id = ?") | |
| 140 | + | .bind(ev.id.to_string()) | |
| 141 | + | .execute(&pool) | |
| 142 | + | .await | |
| 143 | + | .unwrap(); | |
| 144 | + | events.restore(user_id, &full).await.unwrap(); | |
| 145 | + | ||
| 146 | + | let restored = events | |
| 147 | + | .get_by_id(ev.id, user_id) | |
| 148 | + | .await | |
| 149 | + | .unwrap() | |
| 150 | + | .expect("event restored"); | |
| 151 | + | assert_eq!(restored.id, ev.id); | |
| 152 | + | assert_eq!( | |
| 153 | + | restored.external_source.as_deref(), | |
| 154 | + | Some("google"), | |
| 155 | + | "external_source must survive restore" | |
| 156 | + | ); | |
| 157 | + | assert_eq!( | |
| 158 | + | restored.external_id.as_deref(), | |
| 159 | + | Some("evt-123"), | |
| 160 | + | "external_id must survive restore" | |
| 161 | + | ); | |
| 162 | + | assert!(restored.is_read_only, "is_read_only must survive restore"); | |
| 163 | + | } |
| @@ -498,16 +498,15 @@ | |||
| 498 | 498 | ||
| 499 | 499 | async function deleteMilestone(id) { | |
| 500 | 500 | const projectId = GoingsOn.state.currentProjectId; | |
| 501 | - | await GoingsOn.ui.confirmDelete('milestone', async () => { | |
| 502 | - | await GoingsOn.ui.apiCall( | |
| 503 | - | GoingsOn.api.milestones.delete(id), | |
| 504 | - | { | |
| 505 | - | successMessage: 'Milestone deleted', | |
| 506 | - | errorMessage: 'Failed to delete milestone', | |
| 507 | - | reload: () => loadDashboard(projectId), | |
| 508 | - | } | |
| 509 | - | ); | |
| 510 | - | }); | |
| 501 | + | if (!await GoingsOn.ui.confirmDelete('milestone')) return; | |
| 502 | + | await GoingsOn.ui.apiCall( | |
| 503 | + | GoingsOn.api.milestones.delete(id), | |
| 504 | + | { | |
| 505 | + | successMessage: 'Milestone deleted', | |
| 506 | + | errorMessage: 'Failed to delete milestone', | |
| 507 | + | reload: () => loadDashboard(projectId), | |
| 508 | + | } | |
| 509 | + | ); | |
| 511 | 510 | } | |
| 512 | 511 | ||
| 513 | 512 | // ============ Populate GoingsOn.projects Namespace ============ |
| @@ -112,6 +112,12 @@ pub async fn log_manual_time( | |||
| 112 | 112 | state: State<'_, Arc<AppState>>, | |
| 113 | 113 | input: LogManualTimeInput, | |
| 114 | 114 | ) -> Result<TimeSession, ApiError> { | |
| 115 | + | // Reject non-positive durations: a negative value produces ended_at < | |
| 116 | + | // started_at and decrements the task's actual_minutes cache (can go | |
| 117 | + | // negative); zero records an empty session. | |
| 118 | + | if input.minutes < 1 { | |
| 119 | + | return Err(ApiError::validation("minutes", "Minutes must be at least 1")); | |
| 120 | + | } | |
| 115 | 121 | Ok(state.tasks.log_manual_time(input.task_id, DESKTOP_USER_ID, input.minutes, input.date).await?) | |
| 116 | 122 | } | |
| 117 | 123 |
| @@ -204,7 +204,12 @@ pub async fn start_sync_scheduler(app: tauri::AppHandle, cancel: CancellationTok | |||
| 204 | 204 | backoff_until = Some(chrono::Utc::now() + chrono::Duration::hours(1)); | |
| 205 | 205 | } else { | |
| 206 | 206 | consecutive_failures += 1; | |
| 207 | - | let backoff_minutes = std::cmp::min(2u64.pow(consecutive_failures), 15); | |
| 207 | + | // Clamp the exponent before pow: an unbounded counter (failures | |
| 208 | + | // only reset on success) would overflow u64 at 64 consecutive | |
| 209 | + | // failures — panic in debug, wrap-to-0 (tight retry loop) in | |
| 210 | + | // release. min(4) caps backoff at 16m before the 15m clamp, | |
| 211 | + | // matching email_sync_scheduler. | |
| 212 | + | let backoff_minutes = std::cmp::min(2u64.pow(consecutive_failures.min(4)), 15); | |
| 208 | 213 | backoff_until = Some(chrono::Utc::now() + chrono::Duration::minutes(backoff_minutes as i64)); | |
| 209 | 214 | let _ = app.emit("sync:status-changed", "error"); | |
| 210 | 215 | warn!("Auto-sync failed (attempt {}, backoff {}m): {}", consecutive_failures, backoff_minutes, e); |