max / goingson
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
11 files changed,
+442 insertions,
-128 deletions
| @@ -204,7 +204,12 @@ | |||
| 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); |
| @@ -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 @@ | |||
| 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 @@ | |||
| 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 | - | } | |
| 120 | - | } | |
| 121 | - | } | |
| 122 | - | } | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | // Second pass: restore subtasks with linked_task_id (requires all tasks to exist) | |
| 126 | - | for task in &input.tasks { | |
| 127 | - | let new_parent_id = task_id_map.get(&task.id).copied().unwrap_or(task.id); | |
| 128 | - | for subtask in &task.subtasks { | |
| 129 | - | 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() { | |
| 88 | + | if subtask.linked_task_id.is_none() | |
| 89 | + | && tasks.add_subtask(task.id, user_id, &subtask.text).await?.is_some() | |
| 90 | + | { | |
| 132 | 91 | result.subtasks_restored += 1; | |
| 133 | 92 | } | |
| 134 | 93 | } | |
| 135 | 94 | } | |
| 136 | 95 | } | |
| 137 | 96 | ||
| 138 | - | // Import events, remapping project_id references | |
| 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. | |
| 99 | + | for task in &input.tasks { | |
| 100 | + | for subtask in &task.subtasks { | |
| 101 | + | if let Some(linked_id) = subtask.linked_task_id { | |
| 102 | + | if tasks.add_subtask_link(task.id, user_id, linked_id).await.ok().flatten().is_some() { | |
| 103 | + | result.subtasks_restored += 1; | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 |
| @@ -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 @@ | |||
| 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 |
| @@ -13,7 +13,7 @@ | |||
| 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 | ||
| @@ -389,6 +389,38 @@ | |||
| 389 | 389 | .ok_or_else(|| CoreError::internal("Failed to retrieve created contact")) | |
| 390 | 390 | } | |
| 391 | 391 | ||
| 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 | + | ||
| 392 | 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(); |
| @@ -296,6 +296,47 @@ | |||
| 296 | 296 | self.get_by_id(id, user_id).await?.ok_or_else(|| CoreError::internal("Failed to retrieve created email")) | |
| 297 | 297 | } | |
| 298 | 298 | ||
| 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 | + | ||
| 299 | 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()); |
| @@ -204,6 +204,44 @@ | |||
| 204 | 204 | self.get_by_id(id, user_id).await?.ok_or_else(|| CoreError::internal("Failed to retrieve created event")) | |
| 205 | 205 | } | |
| 206 | 206 | ||
| 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 | + | ||
| 207 | 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); |