Skip to main content

max / goingson

Fix backup-restore corruption, milestone delete, sync backoff, and time validation GO-1: backup restore reset every completed task to Pending (NewTask builder never set status/completed_at) and was non-idempotent - existence checks used the backup's IDs while create() minted new ones, so a second restore duplicated the whole dataset. Add id-preserving verbatim restore() methods to the Project/Task/Event/Email/Contact repositories (INSERT OR IGNORE, preserving UUID + status + completed_at + event/contact metadata), and rewrite restore_from_backup to use them - dropping the now-needless id remap. Restoring twice is now a no-op; completed tasks stay completed. First restore integration tests added. GO-2: milestone delete passed a callback to confirmDelete(itemType, count), whose second arg is a count - the delete never ran. Use the guard form. GO-3: sync_scheduler computed 2u64.pow(consecutive_failures) before the .min(15), overflowing at 64 consecutive failures (panic in debug, wrap to 0 -> tight retry loop in release). Clamp the exponent (.min(4)). GO-4: log_manual_time accepted negative/zero minutes, producing ended_at < started_at and decrementing the actual_minutes cache. Reject minutes < 1. Full test suite green.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-12 20:00 UTC
Signed with PGP, not checked
Commit: 54d2ae65ca9ae2d3d758ed1b2b07d2d4694d68c2
Parent: 5f28d6c
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);