1use std::collections::BTreeMap;
2
3use anyhow::{Context, Result, bail, ensure};
4use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
5use serde_json::Value;
6use sqlx::{Row, SqliteConnection};
7
8use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
9use crate::choices::{TaskPriority, TaskStatus};
10use crate::db::{
11 Database, IdentifiedChange, begin_immediate, entity_conflict_exists, entity_field_version,
12 insert_change, insert_change_with_identity, recurrence_occurrence_from_row,
13 recurrence_series_from_row, set_entity_field_version, set_field_version,
14};
15use crate::ids::{TaskId, WorkspaceId, new_id, now};
16use crate::labels::{resolve_labels_in_workspace, resolve_or_create_labels_in_workspace};
17use crate::mutation::apply_field_value_in_workspace;
18use crate::projects::resolve_or_create_project_in_workspace;
19use crate::recurrence::{
20 RecurrenceDuePolicy, RecurrenceOutcome, RecurrenceProjectionState, RecurrenceSchedule,
21 RecurrenceSeriesId, RecurrenceSeriesState, derive_occurrence_identity, next_slot_after,
22 projection_slot_at, recurrence_series_display_ref, slot_cutoff, slot_values,
23};
24use crate::refs::get_task_in_workspace;
25use crate::task_fields::TaskField;
26use crate::types::{MutableEntityType, RecurrenceOccurrence, RecurrenceSeries, Task};
27use crate::undo::{UndoCommand, UndoContext, UndoPayload, record_tui_undo};
28use crate::workspaces::Workspace;
29
30#[cfg(test)]
31#[path = "recurrence_tests.rs"]
32mod tests;
33
34const RECONCILE_ATTEMPTS: usize = 3;
35const SERIES_REF_PREFIX: &str = "RCR";
36const SERIES_TEMPLATE_FIELDS: &[&str] = &[
37 "title",
38 "description",
39 "project",
40 "priority",
41 "initial_status",
42 "labels",
43 "available_local_time",
44 "due_policy",
45 "state",
46 "stopped_at",
47 "deleted",
48];
49
50#[derive(Debug, Clone, Copy, Default)]
51pub struct RecurrenceCreationOptions {
52 create_missing_labels: bool,
53}
54
55impl RecurrenceCreationOptions {
56 pub fn with_create_missing_labels(mut self) -> Self {
57 self.create_missing_labels = true;
58 self
59 }
60}
61
62#[derive(Debug, Clone, Copy, Default)]
63pub struct RecurrenceTemplateUpdateOptions {
64 create_missing_labels: bool,
65}
66
67impl RecurrenceTemplateUpdateOptions {
68 pub fn with_create_missing_labels(mut self) -> Self {
69 self.create_missing_labels = true;
70 self
71 }
72}
73
74#[derive(Debug, Clone)]
75pub struct RecurrenceSeriesDraft {
76 pub title: String,
77 pub description: String,
78 pub project: String,
79 pub priority: String,
80 pub initial_status: String,
81 pub labels: Vec<String>,
82 pub schedule: RecurrenceSchedule,
83}
84
85#[derive(Debug, Clone, Default)]
86pub struct RecurrenceTemplateUpdate {
87 pub title: Option<String>,
88 pub description: Option<String>,
89 pub project: Option<String>,
90 pub priority: Option<String>,
91 pub initial_status: Option<String>,
92 pub labels: Option<Vec<String>>,
93 pub available_local_time: Option<Option<NaiveTime>>,
94 pub due_policy: Option<RecurrenceDuePolicy>,
95}
96
97#[derive(Debug, Clone)]
98pub struct RecurrenceCreateOutcome {
99 pub series: RecurrenceSeries,
100 pub series_ref: String,
101 pub occurrence: RecurrenceOccurrence,
102 pub task: Task,
103}
104
105#[derive(Debug, Clone)]
106pub struct RecurrenceTemplateUpdateOutcome {
107 pub series: RecurrenceSeries,
108 pub changed: bool,
109}
110
111#[derive(Debug, Clone)]
112pub struct RecurrenceReconcileOutcome {
113 pub series: RecurrenceSeries,
114 pub occurrence: Option<RecurrenceOccurrence>,
115 pub changed: bool,
116 pub lifecycle_blocked: bool,
117}
118
119#[derive(Debug, Clone)]
120pub struct RecurrenceResolveOutcome {
121 pub series: RecurrenceSeries,
122 pub resolved: RecurrenceOccurrence,
123 pub task: Task,
124 pub successor: Option<Task>,
125}
126
127#[derive(Debug, Clone)]
128pub struct RecurrenceStateOutcome {
129 pub series: RecurrenceSeries,
130 pub occurrence: Option<RecurrenceOccurrence>,
131}
132
133impl Database {
134 pub async fn create_recurrence_series(
135 &self,
136 workspace: &Workspace,
137 draft: RecurrenceSeriesDraft,
138 ) -> Result<RecurrenceCreateOutcome> {
139 self.create_recurrence_series_at(workspace, draft, utc_now()?)
140 .await
141 }
142
143 pub async fn create_recurrence_series_with_options(
144 &self,
145 workspace: &Workspace,
146 draft: RecurrenceSeriesDraft,
147 options: RecurrenceCreationOptions,
148 ) -> Result<RecurrenceCreateOutcome> {
149 self.create_recurrence_series_at_with_options(workspace, draft, utc_now()?, options)
150 .await
151 }
152
153 pub async fn create_recurrence_series_at(
154 &self,
155 workspace: &Workspace,
156 draft: RecurrenceSeriesDraft,
157 at: DateTime<Utc>,
158 ) -> Result<RecurrenceCreateOutcome> {
159 let mut conn = self.acquire().await?;
160 create_recurrence_series(&mut conn, workspace, draft, at).await
161 }
162
163 pub async fn create_recurrence_series_at_with_options(
164 &self,
165 workspace: &Workspace,
166 draft: RecurrenceSeriesDraft,
167 at: DateTime<Utc>,
168 options: RecurrenceCreationOptions,
169 ) -> Result<RecurrenceCreateOutcome> {
170 let mut conn = self.acquire().await?;
171 create_recurrence_series_with_options(&mut conn, workspace, draft, at, options).await
172 }
173
174 pub async fn update_recurrence_template(
175 &self,
176 workspace: &Workspace,
177 series_id: &RecurrenceSeriesId,
178 update: RecurrenceTemplateUpdate,
179 ) -> Result<RecurrenceTemplateUpdateOutcome> {
180 let mut conn = self.acquire().await?;
181 update_recurrence_template(&mut conn, workspace, series_id, update).await
182 }
183
184 pub async fn update_recurrence_template_with_options(
185 &self,
186 workspace: &Workspace,
187 series_id: &RecurrenceSeriesId,
188 update: RecurrenceTemplateUpdate,
189 options: RecurrenceTemplateUpdateOptions,
190 ) -> Result<RecurrenceTemplateUpdateOutcome> {
191 let mut conn = self.acquire().await?;
192 update_recurrence_template_with_options(&mut conn, workspace, series_id, update, options)
193 .await
194 }
195
196 pub async fn reconcile_recurrence_series(
197 &self,
198 workspace: &Workspace,
199 series_id: &RecurrenceSeriesId,
200 at: DateTime<Utc>,
201 ) -> Result<RecurrenceReconcileOutcome> {
202 let mut conn = self.acquire().await?;
203 let mut last_error = None;
204 for _ in 0..RECONCILE_ATTEMPTS {
205 match reconcile_recurrence_series_once(&mut conn, workspace, series_id, at).await {
206 Ok(outcome) => return Ok(outcome),
207 Err(error) if retryable_reconcile_error(&error) => last_error = Some(error),
208 Err(error) => return Err(error),
209 }
210 }
211 Err(last_error.expect("bounded reconciliation records retry errors"))
212 }
213
214 pub async fn resolve_recurrence_occurrence(
215 &self,
216 workspace: &Workspace,
217 task_id: &TaskId,
218 outcome: RecurrenceOutcome,
219 ) -> Result<RecurrenceResolveOutcome> {
220 self.resolve_recurrence_occurrence_with_undo(workspace, task_id, outcome, UndoContext::None)
221 .await
222 }
223
224 pub async fn resolve_recurrence_occurrence_with_undo(
225 &self,
226 workspace: &Workspace,
227 task_id: &TaskId,
228 outcome: RecurrenceOutcome,
229 undo: UndoContext,
230 ) -> Result<RecurrenceResolveOutcome> {
231 let mut conn = self.acquire().await?;
232 let mut tx = begin_immediate(&mut conn).await?;
233 let before = get_task_in_workspace(&mut tx, workspace, task_id).await?;
234 let result = resolve_recurrence_occurrence_in_transaction(
235 &mut tx,
236 workspace,
237 task_id,
238 outcome,
239 &now(),
240 )
241 .await?;
242 if before.status != result.task.status
243 && let UndoContext::Tui { summary } = undo
244 {
245 record_tui_undo(
246 &mut tx,
247 &workspace.id,
248 &summary,
249 UndoPayload {
250 commands: vec![UndoCommand::SetTaskField {
251 task_id: task_id.clone(),
252 field: "status".to_string(),
253 before: before.status.as_str().to_string(),
254 after: result.task.status.as_str().to_string(),
255 }],
256 },
257 )
258 .await?;
259 }
260 tx.commit().await?;
261 Ok(result)
262 }
263
264 pub async fn pause_recurrence_series(
265 &self,
266 workspace: &Workspace,
267 series_id: &RecurrenceSeriesId,
268 ) -> Result<RecurrenceStateOutcome> {
269 let mut conn = self.acquire().await?;
270 pause_recurrence_series(&mut conn, workspace, series_id, &now()).await
271 }
272
273 pub async fn resume_recurrence_series(
274 &self,
275 workspace: &Workspace,
276 series_id: &RecurrenceSeriesId,
277 at: DateTime<Utc>,
278 ) -> Result<RecurrenceStateOutcome> {
279 let mut conn = self.acquire().await?;
280 resume_recurrence_series(&mut conn, workspace, series_id, at).await
281 }
282
283 pub async fn stop_recurrence_series(
284 &self,
285 workspace: &Workspace,
286 series_id: &RecurrenceSeriesId,
287 skip_current: bool,
288 ) -> Result<RecurrenceStateOutcome> {
289 let mut conn = self.acquire().await?;
290 stop_recurrence_series(&mut conn, workspace, series_id, skip_current, &now()).await
291 }
292
293 pub async fn resolve_recurrence_ref(
294 &self,
295 workspace: &Workspace,
296 input: &str,
297 ) -> Result<RecurrenceSeries> {
298 let mut conn = self.acquire().await?;
299 resolve_recurrence_ref(&mut conn, workspace, input).await
300 }
301
302 pub async fn recurrence_series_ref(
303 &self,
304 workspace_id: &WorkspaceId,
305 series_id: &RecurrenceSeriesId,
306 ) -> Result<String> {
307 let mut conn = self.acquire().await?;
308 recurrence_series_ref(&mut conn, workspace_id, series_id).await
309 }
310}
311
312pub async fn create_recurrence_series(
313 conn: &mut SqliteConnection,
314 workspace: &Workspace,
315 draft: RecurrenceSeriesDraft,
316 at: DateTime<Utc>,
317) -> Result<RecurrenceCreateOutcome> {
318 create_recurrence_series_with_options(
319 conn,
320 workspace,
321 draft,
322 at,
323 RecurrenceCreationOptions::default(),
324 )
325 .await
326}
327
328pub async fn create_recurrence_series_with_options(
329 conn: &mut SqliteConnection,
330 workspace: &Workspace,
331 draft: RecurrenceSeriesDraft,
332 at: DateTime<Utc>,
333 options: RecurrenceCreationOptions,
334) -> Result<RecurrenceCreateOutcome> {
335 let priority = TaskPriority::parse(&draft.priority)?;
336 let initial_status = TaskStatus::parse(&draft.initial_status)?;
337 ensure!(
338 initial_status.is_open(),
339 "error recurrence-initial-status-terminal status={}",
340 initial_status.as_str()
341 );
342 let creation_date = at
343 .with_timezone(&draft.schedule.timezone.timezone())
344 .date_naive();
345 let first_slot = draft
346 .schedule
347 .slots_on_or_after(draft.schedule.start_on.max(creation_date))
348 .next()
349 .context("recurrence schedule has no representable first slot")?;
350 let series_id = RecurrenceSeriesId::new();
351 let created_at = format_utc(at);
352
353 let mut tx = begin_immediate(conn).await?;
354 let project =
355 resolve_or_create_project_in_workspace(&mut tx, &workspace.id, draft.project.as_str())
356 .await?;
357 let labels = if options.create_missing_labels {
358 resolve_or_create_labels_in_workspace(&mut tx, workspace, &draft.labels)
359 .await?
360 .names
361 } else {
362 resolve_labels_in_workspace(&mut tx, &workspace.id, &draft.labels).await?
363 };
364 sqlx::query(
365 "INSERT INTO recurrence_series(
366 workspace_id, id, title, description, project_id, priority, initial_status,
367 frequency, interval, weekdays, timezone, start_on, available_local_time,
368 due_policy, state, stopped_at, created_at, updated_at, deleted
369 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', '', ?, ?, 0)",
370 )
371 .bind(&workspace.id)
372 .bind(&series_id)
373 .bind(&draft.title)
374 .bind(&draft.description)
375 .bind(&project.id)
376 .bind(priority.as_str())
377 .bind(initial_status.as_str())
378 .bind(draft.schedule.rule.frequency().as_str())
379 .bind(i64::from(draft.schedule.rule.interval()))
380 .bind(draft.schedule.rule.weekdays_set().to_string())
381 .bind(draft.schedule.timezone.as_str())
382 .bind(draft.schedule.start_on.format("%Y-%m-%d").to_string())
383 .bind(format_local_time(draft.schedule.available_local_time))
384 .bind(draft.schedule.due_policy.as_str())
385 .bind(&created_at)
386 .bind(&created_at)
387 .execute(&mut *tx)
388 .await?;
389 for label in &labels {
390 sqlx::query(
391 "INSERT INTO recurrence_series_labels(workspace_id, series_id, label)
392 VALUES (?, ?, ?)",
393 )
394 .bind(&workspace.id)
395 .bind(&series_id)
396 .bind(label)
397 .execute(&mut *tx)
398 .await?;
399 }
400 let create_change_id = append_change(
401 &mut tx,
402 ChangeEntity::RecurrenceSeries,
403 series_id.as_str(),
404 None,
405 op_type::CREATE_RECURRENCE_SERIES,
406 ChangePayload::workspace(workspace)
407 .set("series_id", series_id.as_str())
408 .set("title", &draft.title)
409 .set("description", &draft.description)
410 .set("project_id", project.id.as_str())
411 .set("project_key", &project.key)
412 .set("project_name", &project.name)
413 .set("project_prefix", &project.prefix)
414 .set("priority", priority.as_str())
415 .set("initial_status", initial_status.as_str())
416 .set("frequency", draft.schedule.rule.frequency().as_str())
417 .set("interval", draft.schedule.rule.interval())
418 .set("weekdays", draft.schedule.rule.weekdays_set().to_string())
419 .set("timezone", draft.schedule.timezone.as_str())
420 .set(
421 "start_on",
422 draft.schedule.start_on.format("%Y-%m-%d").to_string(),
423 )
424 .set(
425 "available_local_time",
426 format_local_time(draft.schedule.available_local_time),
427 )
428 .set("due_policy", draft.schedule.due_policy.as_str())
429 .set("labels", &labels)
430 .set("state", "active")
431 .set("stopped_at", "")
432 .set("created_at", &created_at)
433 .set("updated_at", &created_at),
434 )
435 .await?;
436 for field in SERIES_TEMPLATE_FIELDS {
437 set_entity_field_version(
438 &mut tx,
439 &workspace.id,
440 MutableEntityType::RecurrenceSeries,
441 series_id.as_str(),
442 field,
443 &create_change_id,
444 )
445 .await?;
446 }
447 let series = load_series(&mut tx, &workspace.id, &series_id).await?;
448 let occurrence =
449 materialize_occurrence(&mut tx, workspace, &series, &labels, first_slot).await?;
450 let task_id = occurrence
451 .task_id
452 .as_ref()
453 .expect("materialized occurrence has a task")
454 .clone();
455 let task = get_task_in_workspace(&mut tx, workspace, &task_id).await?;
456 let series_ref = recurrence_series_ref(&mut tx, &workspace.id, &series_id).await?;
457 tx.commit().await?;
458 Ok(RecurrenceCreateOutcome {
459 series,
460 series_ref,
461 occurrence,
462 task,
463 })
464}
465
466pub async fn update_recurrence_template(
467 conn: &mut SqliteConnection,
468 workspace: &Workspace,
469 series_id: &RecurrenceSeriesId,
470 update: RecurrenceTemplateUpdate,
471) -> Result<RecurrenceTemplateUpdateOutcome> {
472 update_recurrence_template_with_options(
473 conn,
474 workspace,
475 series_id,
476 update,
477 RecurrenceTemplateUpdateOptions::default(),
478 )
479 .await
480}
481
482pub async fn update_recurrence_template_with_options(
483 conn: &mut SqliteConnection,
484 workspace: &Workspace,
485 series_id: &RecurrenceSeriesId,
486 update: RecurrenceTemplateUpdate,
487 options: RecurrenceTemplateUpdateOptions,
488) -> Result<RecurrenceTemplateUpdateOutcome> {
489 if let Some(priority) = update.priority.as_deref() {
490 TaskPriority::parse(priority)?;
491 }
492 if let Some(status) = update.initial_status.as_deref() {
493 let status = TaskStatus::parse(status)?;
494 ensure!(
495 status.is_open(),
496 "error recurrence-initial-status-terminal status={}",
497 status.as_str()
498 );
499 }
500
501 let mut tx = begin_immediate(conn).await?;
502 let current = load_series(&mut tx, &workspace.id, series_id).await?;
503 let mut values = Vec::<(&str, String)>::new();
504 if let Some(title) = update.title
505 && title != current.title
506 {
507 values.push(("title", title));
508 }
509 if let Some(description) = update.description
510 && description != current.description
511 {
512 values.push(("description", description));
513 }
514 if let Some(priority) = update.priority
515 && priority != current.priority.as_str()
516 {
517 values.push(("priority", priority));
518 }
519 if let Some(status) = update.initial_status
520 && status != current.initial_status.as_str()
521 {
522 values.push(("initial_status", status));
523 }
524 if let Some(time) = update.available_local_time {
525 let value = format_local_time(time);
526 if value != format_local_time(current.available_local_time) {
527 values.push(("available_local_time", value));
528 }
529 }
530 if let Some(due_policy) = update.due_policy
531 && due_policy != current.due_policy
532 {
533 values.push(("due_policy", due_policy.as_str().to_string()));
534 }
535 if let Some(project) = update.project {
536 let project =
537 resolve_or_create_project_in_workspace(&mut tx, &workspace.id, &project).await?;
538 if project.id != current.project_id {
539 values.push(("project", project.id.to_string()));
540 }
541 }
542
543 let current_labels = load_series_labels(&mut tx, &workspace.id, series_id).await?;
544 let target_labels = if let Some(labels) = update.labels {
545 if options.create_missing_labels {
546 resolve_or_create_labels_in_workspace(&mut tx, workspace, &labels)
547 .await?
548 .names
549 } else {
550 resolve_labels_in_workspace(&mut tx, &workspace.id, &labels).await?
551 }
552 } else {
553 current_labels.clone()
554 };
555 let labels_changed = current_labels != target_labels;
556 if values.is_empty() && !labels_changed {
557 tx.commit().await?;
558 return Ok(RecurrenceTemplateUpdateOutcome {
559 series: current,
560 changed: false,
561 });
562 }
563
564 for (field, _) in &values {
565 ensure_no_series_conflict(&mut tx, &workspace.id, series_id, field).await?;
566 }
567 if labels_changed {
568 ensure_no_series_conflict(&mut tx, &workspace.id, series_id, "labels").await?;
569 }
570 let mut base_versions = BTreeMap::new();
571 for (field, _) in &values {
572 base_versions.insert(
573 (*field).to_string(),
574 entity_field_version(
575 &mut tx,
576 &workspace.id,
577 MutableEntityType::RecurrenceSeries,
578 series_id.as_str(),
579 field,
580 )
581 .await?,
582 );
583 }
584 if labels_changed {
585 base_versions.insert(
586 "labels".to_string(),
587 entity_field_version(
588 &mut tx,
589 &workspace.id,
590 MutableEntityType::RecurrenceSeries,
591 series_id.as_str(),
592 "labels",
593 )
594 .await?,
595 );
596 }
597 let updated_at = now();
598 for (field, value) in &values {
599 update_series_template_scalar(&mut tx, &workspace.id, series_id, field, value, &updated_at)
600 .await?;
601 }
602 if labels_changed {
603 sqlx::query(
604 "DELETE FROM recurrence_series_labels WHERE workspace_id = ? AND series_id = ?",
605 )
606 .bind(&workspace.id)
607 .bind(series_id)
608 .execute(&mut *tx)
609 .await?;
610 for label in &target_labels {
611 sqlx::query(
612 "INSERT INTO recurrence_series_labels(workspace_id, series_id, label)
613 VALUES (?, ?, ?)",
614 )
615 .bind(&workspace.id)
616 .bind(series_id)
617 .bind(label)
618 .execute(&mut *tx)
619 .await?;
620 }
621 }
622 let change_id = append_change(
623 &mut tx,
624 ChangeEntity::RecurrenceSeries,
625 series_id.as_str(),
626 None,
627 op_type::UPDATE_RECURRENCE_TEMPLATE,
628 ChangePayload::workspace(workspace)
629 .set("fields", &values)
630 .set("base_versions", &base_versions)
631 .set("labels_changed", labels_changed)
632 .set("labels", &target_labels)
633 .set("updated_at", &updated_at),
634 )
635 .await?;
636 for (field, _) in &values {
637 set_entity_field_version(
638 &mut tx,
639 &workspace.id,
640 MutableEntityType::RecurrenceSeries,
641 series_id.as_str(),
642 field,
643 &change_id,
644 )
645 .await?;
646 }
647 if labels_changed {
648 set_entity_field_version(
649 &mut tx,
650 &workspace.id,
651 MutableEntityType::RecurrenceSeries,
652 series_id.as_str(),
653 "labels",
654 &change_id,
655 )
656 .await?;
657 }
658 let series = load_series(&mut tx, &workspace.id, series_id).await?;
659 tx.commit().await?;
660 Ok(RecurrenceTemplateUpdateOutcome {
661 series,
662 changed: true,
663 })
664}
665
666async fn reconcile_recurrence_series_once(
667 conn: &mut SqliteConnection,
668 workspace: &Workspace,
669 series_id: &RecurrenceSeriesId,
670 at: DateTime<Utc>,
671) -> Result<RecurrenceReconcileOutcome> {
672 let mut tx = begin_immediate(conn).await?;
673 let outcome =
674 reconcile_recurrence_series_in_transaction(&mut tx, workspace, series_id, at).await?;
675 tx.commit().await?;
676 Ok(outcome)
677}
678
679pub(crate) async fn reconcile_recurrence_series_in_transaction(
680 conn: &mut SqliteConnection,
681 workspace: &Workspace,
682 series_id: &RecurrenceSeriesId,
683 at: DateTime<Utc>,
684) -> Result<RecurrenceReconcileOutcome> {
685 let series = load_series(conn, &workspace.id, series_id).await?;
686 let projected = load_projected_occurrence(conn, &workspace.id, series_id).await?;
687 if !matches!(series.state, RecurrenceSeriesState::Active) {
688 return Ok(RecurrenceReconcileOutcome {
689 series,
690 occurrence: projected,
691 changed: false,
692 lifecycle_blocked: false,
693 });
694 }
695 if lifecycle_conflict_exists(conn, &workspace.id, series_id).await? {
696 return Ok(RecurrenceReconcileOutcome {
697 series,
698 occurrence: projected,
699 changed: false,
700 lifecycle_blocked: true,
701 });
702 }
703
704 let schedule = series.schedule();
705 let target = projection_slot_at(&schedule, at)?;
706 if projected
707 .as_ref()
708 .is_some_and(|occurrence| occurrence.slot_on >= target)
709 {
710 return Ok(RecurrenceReconcileOutcome {
711 series,
712 occurrence: projected,
713 changed: false,
714 lifecycle_blocked: false,
715 });
716 }
717
718 let changed_at = format_utc(at);
719 if let Some(projected) = projected {
720 sqlx::query(
721 "UPDATE recurrence_occurrences
722 SET projection_state = 'archived', archived_at = ?
723 WHERE workspace_id = ? AND series_id = ? AND slot_on = ?
724 AND projection_state = 'projected'",
725 )
726 .bind(&changed_at)
727 .bind(&workspace.id)
728 .bind(series_id)
729 .bind(projected.slot_on.format("%Y-%m-%d").to_string())
730 .execute(&mut *conn)
731 .await?;
732 }
733 if let Some(existing) = load_occurrence(conn, &workspace.id, series_id, target).await?
734 && matches!(
735 existing.projection_state,
736 RecurrenceProjectionState::Archived
737 )
738 && existing.outcome.is_none()
739 {
740 let expected = derive_occurrence_identity(&workspace.id, series_id, &schedule, target)?;
741 ensure!(
742 existing.task_id.as_ref() == Some(&expected.task_id),
743 "error recurrence-generation-conflict slot={target} field=task_id"
744 );
745 let task = get_task_in_workspace(conn, workspace, &expected.task_id).await?;
746 ensure!(
747 task.status.is_open() && !task.deleted,
748 "error recurrence-generation-conflict slot={target} field=task"
749 );
750 sqlx::query(
751 "UPDATE recurrence_occurrences
752 SET projection_state = 'projected', archived_at = ''
753 WHERE workspace_id = ? AND series_id = ? AND slot_on = ?
754 AND projection_state = 'archived' AND outcome = ''",
755 )
756 .bind(&workspace.id)
757 .bind(series_id)
758 .bind(target.format("%Y-%m-%d").to_string())
759 .execute(&mut *conn)
760 .await?;
761 let occurrence = load_occurrence(conn, &workspace.id, series_id, target)
762 .await?
763 .context("promoted recurrence occurrence missing")?;
764 return Ok(RecurrenceReconcileOutcome {
765 series,
766 occurrence: Some(occurrence),
767 changed: true,
768 lifecycle_blocked: false,
769 });
770 }
771 let labels = load_series_labels(conn, &workspace.id, series_id).await?;
772 let occurrence = materialize_occurrence(conn, workspace, &series, &labels, target).await?;
773 Ok(RecurrenceReconcileOutcome {
774 series,
775 occurrence: Some(occurrence),
776 changed: true,
777 lifecycle_blocked: false,
778 })
779}
780
781pub(crate) async fn resolve_recurrence_occurrence_in_transaction(
782 conn: &mut SqliteConnection,
783 workspace: &Workspace,
784 task_id: &TaskId,
785 outcome: RecurrenceOutcome,
786 resolved_at: &str,
787) -> Result<RecurrenceResolveOutcome> {
788 let mut occurrence = load_occurrence_for_task(conn, &workspace.id, task_id)
789 .await?
790 .context("error recurrence-occurrence-not-found")?;
791 if matches!(
792 occurrence.projection_state,
793 RecurrenceProjectionState::Projected
794 ) {
795 reconcile_recurrence_series_in_transaction(
796 conn,
797 workspace,
798 &occurrence.series_id,
799 DateTime::parse_from_rfc3339(resolved_at)
800 .context("invalid recurrence resolution time")?
801 .with_timezone(&Utc),
802 )
803 .await?;
804 occurrence = load_occurrence_for_task(conn, &workspace.id, task_id)
805 .await?
806 .context("error recurrence-occurrence-not-found")?;
807 }
808 ensure!(
809 matches!(
810 occurrence.projection_state,
811 RecurrenceProjectionState::Projected
812 ),
813 "error recurrence-occurrence-not-current task_id={task_id}"
814 );
815 let task = get_task_in_workspace(conn, workspace, task_id).await?;
816 ensure!(
817 task.status.is_open(),
818 "error recurrence-occurrence-terminal task_id={task_id}"
819 );
820 let series = load_series(conn, &workspace.id, &occurrence.series_id).await?;
821 let target_status = match outcome {
822 RecurrenceOutcome::Completed => TaskStatus::Done,
823 RecurrenceOutcome::Skipped => TaskStatus::Canceled,
824 };
825 let status_change_id = write_task_status(
826 conn,
827 workspace,
828 task_id,
829 task.status,
830 target_status,
831 resolved_at,
832 )
833 .await?;
834
835 let successor_slot = if matches!(series.state, RecurrenceSeriesState::Active) {
836 Some(
837 next_slot_after(&series.rule, series.start_on, occurrence.slot_on)
838 .context("recurrence schedule has no representable successor")?,
839 )
840 } else {
841 None
842 };
843 let successor_task_id = successor_slot
844 .map(|slot| {
845 derive_occurrence_identity(&workspace.id, &series.id, &series.schedule(), slot)
846 .map(|identity| identity.task_id)
847 })
848 .transpose()?;
849 let outcome_change_id = append_change(
850 conn,
851 ChangeEntity::RecurrenceSeries,
852 series.id.as_str(),
853 Some("outcome"),
854 op_type::RESOLVE_RECURRENCE_OCCURRENCE,
855 ChangePayload::workspace(workspace)
856 .set("slot_on", occurrence.slot_on.format("%Y-%m-%d").to_string())
857 .set("task_id", task_id.as_str())
858 .set("outcome", outcome.as_str())
859 .set("task_status", target_status.as_str())
860 .set("resolved_at", resolved_at)
861 .set("task_status_change_id", &status_change_id)
862 .set(
863 "successor_task_id",
864 successor_task_id.as_ref().map(TaskId::as_str).unwrap_or(""),
865 )
866 .set("frequency", series.rule.frequency().as_str())
867 .set("interval", series.rule.interval())
868 .set("weekdays", series.rule.weekdays_set().to_string())
869 .set("timezone", series.timezone.as_str())
870 .set("start_on", series.start_on.format("%Y-%m-%d").to_string())
871 .set(
872 "available_local_time",
873 format_local_time(series.available_local_time),
874 )
875 .set("due_policy", series.due_policy.as_str()),
876 )
877 .await?;
878 sqlx::query(
879 "UPDATE recurrence_occurrences
880 SET outcome = ?, resolved_at = ?, outcome_change_id = ?, projection_state = 'resolved'
881 WHERE workspace_id = ? AND series_id = ? AND slot_on = ?
882 AND projection_state = 'projected'",
883 )
884 .bind(outcome.as_str())
885 .bind(resolved_at)
886 .bind(&outcome_change_id)
887 .bind(&workspace.id)
888 .bind(&series.id)
889 .bind(occurrence.slot_on.format("%Y-%m-%d").to_string())
890 .execute(&mut *conn)
891 .await?;
892
893 let labels = load_series_labels(conn, &workspace.id, &series.id).await?;
894 let successor = if let Some(next_slot) = successor_slot {
895 let successor_occurrence =
896 materialize_occurrence(conn, workspace, &series, &labels, next_slot).await?;
897 let successor_id = successor_occurrence
898 .task_id
899 .as_ref()
900 .expect("materialized successor has a task");
901 Some(get_task_in_workspace(conn, workspace, successor_id).await?)
902 } else {
903 None
904 };
905 let resolved = load_occurrence(conn, &workspace.id, &series.id, occurrence.slot_on)
906 .await?
907 .expect("resolved occurrence remains stored");
908 let task = get_task_in_workspace(conn, workspace, task_id).await?;
909 Ok(RecurrenceResolveOutcome {
910 series,
911 resolved,
912 task,
913 successor,
914 })
915}
916
917pub async fn pause_recurrence_series(
918 conn: &mut SqliteConnection,
919 workspace: &Workspace,
920 series_id: &RecurrenceSeriesId,
921 paused_at: &str,
922) -> Result<RecurrenceStateOutcome> {
923 let paused_at_utc = DateTime::parse_from_rfc3339(paused_at)
924 .context("invalid recurrence pause time")?
925 .with_timezone(&Utc);
926 let mut tx = begin_immediate(conn).await?;
927 let series = load_series(&mut tx, &workspace.id, series_id).await?;
928 ensure!(
929 matches!(series.state, RecurrenceSeriesState::Active),
930 "error recurrence-pause-invalid-state state={}",
931 series.state.as_str()
932 );
933 ensure_no_series_conflict(&mut tx, &workspace.id, series_id, "state").await?;
934 reconcile_recurrence_series_in_transaction(&mut tx, workspace, series_id, paused_at_utc)
935 .await?;
936 let projected = load_projected_occurrence(&mut tx, &workspace.id, series_id).await?;
937 let change_id = set_series_state(
938 &mut tx,
939 workspace,
940 series_id,
941 RecurrenceSeriesState::Paused,
942 None,
943 paused_at,
944 op_type::SET_RECURRENCE_STATE,
945 )
946 .await?;
947 let interval_id = new_id();
948 sqlx::query(
949 "INSERT INTO recurrence_pause_intervals(
950 workspace_id, id, series_id, paused_at, resumed_at, suspended_slot_on,
951 suspended_task_id, created_by_change_id, resolved_by_change_id
952 ) VALUES (?, ?, ?, ?, '', ?, ?, ?, '')",
953 )
954 .bind(&workspace.id)
955 .bind(&interval_id)
956 .bind(series_id)
957 .bind(paused_at)
958 .bind(
959 projected
960 .as_ref()
961 .map(|occurrence| occurrence.slot_on.format("%Y-%m-%d").to_string())
962 .unwrap_or_default(),
963 )
964 .bind(
965 projected
966 .as_ref()
967 .and_then(|occurrence| occurrence.task_id.as_ref())
968 .map(TaskId::as_str)
969 .unwrap_or(""),
970 )
971 .bind(&change_id)
972 .execute(&mut *tx)
973 .await?;
974 append_change(
975 &mut tx,
976 ChangeEntity::RecurrenceSeries,
977 series_id.as_str(),
978 Some("pause"),
979 op_type::OPEN_RECURRENCE_PAUSE,
980 ChangePayload::workspace(workspace)
981 .set("interval_id", &interval_id)
982 .set("paused_at", paused_at)
983 .set(
984 "suspended_slot_on",
985 projected
986 .as_ref()
987 .map(|occurrence| occurrence.slot_on.format("%Y-%m-%d").to_string())
988 .unwrap_or_default(),
989 )
990 .set(
991 "suspended_task_id",
992 projected
993 .as_ref()
994 .and_then(|occurrence| occurrence.task_id.as_ref())
995 .map(TaskId::as_str)
996 .unwrap_or(""),
997 ),
998 )
999 .await?;
1000 let series = load_series(&mut tx, &workspace.id, series_id).await?;
1001 tx.commit().await?;
1002 Ok(RecurrenceStateOutcome {
1003 series,
1004 occurrence: projected,
1005 })
1006}
1007
1008pub async fn resume_recurrence_series(
1009 conn: &mut SqliteConnection,
1010 workspace: &Workspace,
1011 series_id: &RecurrenceSeriesId,
1012 at: DateTime<Utc>,
1013) -> Result<RecurrenceStateOutcome> {
1014 let mut resumed_at = format_utc(at);
1015 let mut tx = begin_immediate(conn).await?;
1016 let series = load_series(&mut tx, &workspace.id, series_id).await?;
1017 ensure!(
1018 matches!(series.state, RecurrenceSeriesState::Paused),
1019 "error recurrence-resume-invalid-state state={}",
1020 series.state.as_str()
1021 );
1022 ensure_no_series_conflict(&mut tx, &workspace.id, series_id, "state").await?;
1023 let interval = sqlx::query(
1024 "SELECT id, paused_at, suspended_slot_on, suspended_task_id
1025 FROM recurrence_pause_intervals
1026 WHERE workspace_id = ? AND series_id = ? AND resumed_at = ''",
1027 )
1028 .bind(&workspace.id)
1029 .bind(series_id)
1030 .fetch_optional(&mut *tx)
1031 .await?
1032 .context("error recurrence-open-pause-missing")?;
1033 let interval_id: String = interval.get("id");
1034 let paused_at: String = interval.get("paused_at");
1035 resumed_at = timestamp_strictly_after(&paused_at, &resumed_at)?;
1036 let suspended_slot_text: String = interval.get("suspended_slot_on");
1037 let suspended_slot = (!suspended_slot_text.is_empty())
1038 .then(|| suspended_slot_text.parse::<NaiveDate>())
1039 .transpose()?;
1040
1041 let close_change_id = append_change(
1042 &mut tx,
1043 ChangeEntity::RecurrenceSeries,
1044 series_id.as_str(),
1045 Some("pause"),
1046 op_type::CLOSE_RECURRENCE_PAUSE,
1047 ChangePayload::workspace(workspace)
1048 .set("interval_id", &interval_id)
1049 .set("paused_at", &paused_at)
1050 .set("resumed_at", &resumed_at),
1051 )
1052 .await?;
1053 sqlx::query(
1054 "UPDATE recurrence_pause_intervals SET resumed_at = ?, resolved_by_change_id = ?
1055 WHERE workspace_id = ? AND id = ? AND resumed_at = ''",
1056 )
1057 .bind(&resumed_at)
1058 .bind(&close_change_id)
1059 .bind(&workspace.id)
1060 .bind(&interval_id)
1061 .execute(&mut *tx)
1062 .await?;
1063 set_series_state(
1064 &mut tx,
1065 workspace,
1066 series_id,
1067 RecurrenceSeriesState::Active,
1068 None,
1069 &resumed_at,
1070 op_type::SET_RECURRENCE_STATE,
1071 )
1072 .await?;
1073
1074 let schedule = series.schedule();
1075 let mut occurrence = load_projected_occurrence(&mut tx, &workspace.id, series_id).await?;
1076 let suspended_still_live = if let Some(suspended_slot) = suspended_slot {
1077 occurrence
1078 .as_ref()
1079 .is_some_and(|value| value.slot_on == suspended_slot)
1080 && at < slot_cutoff(&schedule, suspended_slot)?
1081 } else {
1082 false
1083 };
1084 if occurrence.is_some() && !suspended_still_live {
1085 let slot = occurrence.as_ref().expect("checked occurrence").slot_on;
1086 sqlx::query(
1087 "UPDATE recurrence_occurrences
1088 SET projection_state = 'archived', archived_at = ?
1089 WHERE workspace_id = ? AND series_id = ? AND slot_on = ?
1090 AND projection_state = 'projected'",
1091 )
1092 .bind(&resumed_at)
1093 .bind(&workspace.id)
1094 .bind(series_id)
1095 .bind(slot.format("%Y-%m-%d").to_string())
1096 .execute(&mut *tx)
1097 .await?;
1098 occurrence = None;
1099 }
1100 if occurrence.is_none() {
1101 let mut target = projection_slot_at(&schedule, at)?;
1102 let boundary = slot_values(&schedule, target)?.boundary_at;
1103 if boundary >= paused_at && boundary < resumed_at {
1104 target = next_slot_after(&series.rule, series.start_on, target)
1105 .context("recurrence schedule has no representable resumed slot")?;
1106 }
1107 let labels = load_series_labels(&mut tx, &workspace.id, series_id).await?;
1108 occurrence =
1109 Some(materialize_occurrence(&mut tx, workspace, &series, &labels, target).await?);
1110 }
1111 let series = load_series(&mut tx, &workspace.id, series_id).await?;
1112 tx.commit().await?;
1113 Ok(RecurrenceStateOutcome { series, occurrence })
1114}
1115
1116pub async fn stop_recurrence_series(
1117 conn: &mut SqliteConnection,
1118 workspace: &Workspace,
1119 series_id: &RecurrenceSeriesId,
1120 skip_current: bool,
1121 stopped_at: &str,
1122) -> Result<RecurrenceStateOutcome> {
1123 let mut stopped_at = stopped_at.to_string();
1124 let mut tx = begin_immediate(conn).await?;
1125 let series = load_series(&mut tx, &workspace.id, series_id).await?;
1126 ensure!(
1127 !matches!(series.state, RecurrenceSeriesState::Stopped),
1128 "error recurrence-already-stopped"
1129 );
1130 ensure_no_series_conflict(&mut tx, &workspace.id, series_id, "state").await?;
1131 let open_pause = if matches!(series.state, RecurrenceSeriesState::Paused) {
1132 let interval = sqlx::query(
1133 "SELECT id, paused_at FROM recurrence_pause_intervals
1134 WHERE workspace_id = ? AND series_id = ? AND resumed_at = ''",
1135 )
1136 .bind(&workspace.id)
1137 .bind(series_id)
1138 .fetch_optional(&mut *tx)
1139 .await?
1140 .context("error recurrence-open-pause-missing")?;
1141 let interval_id: String = interval.get("id");
1142 let paused_at: String = interval.get("paused_at");
1143 stopped_at = timestamp_strictly_after(&paused_at, &stopped_at)?;
1144 Some((interval_id, paused_at))
1145 } else {
1146 None
1147 };
1148 let stopped_at_utc = DateTime::parse_from_rfc3339(&stopped_at)
1149 .context("invalid recurrence stop time")?
1150 .with_timezone(&Utc);
1151 reconcile_recurrence_series_in_transaction(&mut tx, workspace, series_id, stopped_at_utc)
1152 .await?;
1153 if let Some((interval_id, paused_at)) = open_pause {
1154 let close_change_id = append_change(
1155 &mut tx,
1156 ChangeEntity::RecurrenceSeries,
1157 series_id.as_str(),
1158 Some("pause"),
1159 op_type::CLOSE_RECURRENCE_PAUSE,
1160 ChangePayload::workspace(workspace)
1161 .set("interval_id", &interval_id)
1162 .set("paused_at", &paused_at)
1163 .set("resumed_at", &stopped_at),
1164 )
1165 .await?;
1166 sqlx::query(
1167 "UPDATE recurrence_pause_intervals SET resumed_at = ?, resolved_by_change_id = ?
1168 WHERE workspace_id = ? AND id = ? AND resumed_at = ''",
1169 )
1170 .bind(&stopped_at)
1171 .bind(&close_change_id)
1172 .bind(&workspace.id)
1173 .bind(&interval_id)
1174 .execute(&mut *tx)
1175 .await?;
1176 }
1177 set_series_state(
1178 &mut tx,
1179 workspace,
1180 series_id,
1181 RecurrenceSeriesState::Stopped,
1182 Some(&stopped_at),
1183 &stopped_at,
1184 op_type::STOP_RECURRENCE_SERIES,
1185 )
1186 .await?;
1187 let projected = load_projected_occurrence(&mut tx, &workspace.id, series_id).await?;
1188 let occurrence = if skip_current {
1189 let projected = projected.context("error recurrence-current-occurrence-missing")?;
1190 let task_id = projected
1191 .task_id
1192 .as_ref()
1193 .expect("projected occurrence has a task");
1194 Some(
1195 resolve_recurrence_occurrence_in_transaction(
1196 &mut tx,
1197 workspace,
1198 task_id,
1199 RecurrenceOutcome::Skipped,
1200 &stopped_at,
1201 )
1202 .await?
1203 .resolved,
1204 )
1205 } else {
1206 projected
1207 };
1208 let series = load_series(&mut tx, &workspace.id, series_id).await?;
1209 tx.commit().await?;
1210 Ok(RecurrenceStateOutcome { series, occurrence })
1211}
1212
1213pub(crate) async fn route_recurrence_task_field(
1214 conn: &mut SqliteConnection,
1215 workspace: &Workspace,
1216 task_id: &TaskId,
1217 field: &str,
1218 value: &str,
1219) -> Result<Option<bool>> {
1220 let Some(mut occurrence) = load_occurrence_for_task(conn, &workspace.id, task_id).await? else {
1221 return Ok(None);
1222 };
1223 if matches!(
1224 occurrence.projection_state,
1225 RecurrenceProjectionState::Projected
1226 ) {
1227 reconcile_recurrence_series_in_transaction(
1228 conn,
1229 workspace,
1230 &occurrence.series_id,
1231 Utc::now(),
1232 )
1233 .await?;
1234 occurrence = load_occurrence_for_task(conn, &workspace.id, task_id)
1235 .await?
1236 .context("error recurrence-occurrence-not-found")?;
1237 }
1238 if matches!(
1239 occurrence.projection_state,
1240 RecurrenceProjectionState::Archived
1241 ) {
1242 bail!("error recurrence-occurrence-archived task_id={task_id}");
1243 }
1244 if field == "status" {
1245 let task = get_task_in_workspace(conn, workspace, task_id).await?;
1246 let target = TaskStatus::parse(value)?;
1247 if task.status == target {
1248 return Ok(Some(false));
1249 }
1250 if task.status.is_terminal() {
1251 if target.is_open() {
1252 bail!(
1253 "error recurrence-terminal-reopen task_id={task_id} hint=\"use immediate undo\""
1254 );
1255 }
1256 bail!("error recurrence-outcome-final task_id={task_id} hint=\"use immediate undo\"");
1257 }
1258 if target.is_terminal() {
1259 ensure!(
1260 matches!(
1261 occurrence.projection_state,
1262 RecurrenceProjectionState::Projected
1263 ),
1264 "error recurrence-occurrence-not-current task_id={task_id}"
1265 );
1266 let outcome = if target == TaskStatus::Done {
1267 RecurrenceOutcome::Completed
1268 } else {
1269 RecurrenceOutcome::Skipped
1270 };
1271 resolve_recurrence_occurrence_in_transaction(conn, workspace, task_id, outcome, &now())
1272 .await?;
1273 return Ok(Some(true));
1274 }
1275 return Ok(None);
1276 }
1277 if field == "deleted"
1278 && matches!(
1279 occurrence.projection_state,
1280 RecurrenceProjectionState::Projected
1281 )
1282 && value == "1"
1283 {
1284 let series = load_series(conn, &workspace.id, &occurrence.series_id).await?;
1285 let guidance = match series.state {
1286 RecurrenceSeriesState::Active => "skip, pause, or stop the series",
1287 RecurrenceSeriesState::Paused => "skip the occurrence or stop the series",
1288 RecurrenceSeriesState::Stopped => "complete or cancel the final occurrence",
1289 };
1290 bail!("error recurrence-current-delete task_id={task_id} hint=\"{guidance}\"");
1291 }
1292 Ok(None)
1293}
1294
1295pub(crate) async fn undo_recurrence_resolution(
1296 conn: &mut SqliteConnection,
1297 workspace_id: &WorkspaceId,
1298 task_id: &TaskId,
1299 before_status: &str,
1300 after_status: &str,
1301) -> Result<bool> {
1302 let Some(occurrence) = load_occurrence_for_task(conn, workspace_id, task_id).await? else {
1303 return Ok(false);
1304 };
1305 if !matches!(
1306 occurrence.projection_state,
1307 RecurrenceProjectionState::Resolved
1308 ) || occurrence.outcome.is_none()
1309 {
1310 return Ok(false);
1311 }
1312 let task_status: String =
1313 sqlx::query_scalar("SELECT status FROM tasks WHERE workspace_id = ? AND id = ?")
1314 .bind(workspace_id)
1315 .bind(task_id)
1316 .fetch_one(&mut *conn)
1317 .await?;
1318 ensure!(
1319 task_status == after_status,
1320 "error undo-state-changed task_id={task_id} field=status"
1321 );
1322 let series = load_series(conn, workspace_id, &occurrence.series_id).await?;
1323 let outcome_change_id = occurrence
1324 .outcome_change_id
1325 .as_deref()
1326 .context("error recurrence-undo-missing-outcome-change")?;
1327 ensure_change_is_latest_series_transition(conn, workspace_id, &series.id, outcome_change_id)
1328 .await?;
1329 ensure_change_unsynced(conn, outcome_change_id).await?;
1330 let status_change_id = entity_field_version(
1331 conn,
1332 workspace_id,
1333 MutableEntityType::Task,
1334 task_id.as_str(),
1335 "status",
1336 )
1337 .await?
1338 .context("error recurrence-undo-missing-status-change")?;
1339 ensure_change_unsynced(conn, &status_change_id).await?;
1340 let prior_status_version: Option<String> =
1341 sqlx::query_scalar("SELECT base_version FROM changes WHERE change_id = ?")
1342 .bind(&status_change_id)
1343 .fetch_one(&mut *conn)
1344 .await?;
1345
1346 let successor = load_projected_occurrence(conn, workspace_id, &series.id).await?;
1347 match series.state {
1348 RecurrenceSeriesState::Active => {
1349 let successor = successor.context("error recurrence-undo-successor-missing")?;
1350 let expected_slot = next_slot_after(&series.rule, series.start_on, occurrence.slot_on)
1351 .context("recurrence schedule has no representable successor")?;
1352 ensure!(
1353 successor.slot_on == expected_slot,
1354 "error recurrence-undo-successor-changed"
1355 );
1356 ensure_successor_untouched(conn, workspace_id, &series, &successor).await?;
1357 remove_materialized_occurrence(conn, workspace_id, &series, &successor).await?;
1358 }
1359 RecurrenceSeriesState::Paused | RecurrenceSeriesState::Stopped => {
1360 ensure!(
1361 successor.is_none(),
1362 "error recurrence-undo-successor-exists"
1363 );
1364 }
1365 }
1366
1367 apply_field_value_in_workspace(conn, workspace_id, task_id, "status", before_status).await?;
1368 if let Some(version) = prior_status_version {
1369 set_entity_field_version(
1370 conn,
1371 workspace_id,
1372 MutableEntityType::Task,
1373 task_id.as_str(),
1374 "status",
1375 &version,
1376 )
1377 .await?;
1378 } else {
1379 sqlx::query(
1380 "DELETE FROM field_versions
1381 WHERE workspace_id = ? AND entity_type = 'task' AND entity_id = ? AND field = 'status'",
1382 )
1383 .bind(workspace_id)
1384 .bind(task_id)
1385 .execute(&mut *conn)
1386 .await?;
1387 }
1388 sqlx::query(
1389 "UPDATE recurrence_occurrences
1390 SET outcome = '', resolved_at = '', outcome_change_id = '', projection_state = 'projected'
1391 WHERE workspace_id = ? AND series_id = ? AND slot_on = ?",
1392 )
1393 .bind(workspace_id)
1394 .bind(&series.id)
1395 .bind(occurrence.slot_on.format("%Y-%m-%d").to_string())
1396 .execute(&mut *conn)
1397 .await?;
1398 sqlx::query("DELETE FROM changes WHERE change_id IN (?, ?)")
1399 .bind(&status_change_id)
1400 .bind(outcome_change_id)
1401 .execute(&mut *conn)
1402 .await?;
1403 Ok(true)
1404}
1405
1406async fn materialize_occurrence(
1407 conn: &mut SqliteConnection,
1408 workspace: &Workspace,
1409 series: &RecurrenceSeries,
1410 labels: &[String],
1411 slot_on: NaiveDate,
1412) -> Result<RecurrenceOccurrence> {
1413 let schedule = series.schedule();
1414 let slot = slot_values(&schedule, slot_on)?;
1415 let identity = derive_occurrence_identity(&workspace.id, &series.id, &schedule, slot_on)?;
1416 if let Some(existing) = load_occurrence(conn, &workspace.id, &series.id, slot_on).await? {
1417 verify_materialized_occurrence(
1418 conn, workspace, series, labels, &slot, &identity, &existing,
1419 )
1420 .await?;
1421 return Ok(existing);
1422 }
1423
1424 sqlx::query(
1425 "INSERT INTO tasks(
1426 workspace_id, id, title, description, project_id, status, priority,
1427 created_at, updated_at, queue_activity_at, available_at, due_on, deleted, is_epic
1428 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0)",
1429 )
1430 .bind(&workspace.id)
1431 .bind(&identity.task_id)
1432 .bind(&series.title)
1433 .bind(&series.description)
1434 .bind(&series.project_id)
1435 .bind(series.initial_status.as_str())
1436 .bind(series.priority.as_str())
1437 .bind(&identity.created_at)
1438 .bind(&identity.updated_at)
1439 .bind(&identity.created_at)
1440 .bind(&slot.available_at)
1441 .bind(slot.due_on.as_deref().unwrap_or(""))
1442 .execute(&mut *conn)
1443 .await?;
1444 for label in labels {
1445 sqlx::query("INSERT INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)")
1446 .bind(&workspace.id)
1447 .bind(&identity.task_id)
1448 .bind(label)
1449 .execute(&mut *conn)
1450 .await?;
1451 }
1452 sqlx::query(
1453 "INSERT INTO recurrence_occurrences(
1454 workspace_id, series_id, slot_on, task_id, outcome, resolved_at,
1455 outcome_change_id, projection_state, archived_at
1456 ) VALUES (?, ?, ?, ?, '', '', '', 'projected', '')",
1457 )
1458 .bind(&workspace.id)
1459 .bind(&series.id)
1460 .bind(slot_on.format("%Y-%m-%d").to_string())
1461 .bind(&identity.task_id)
1462 .execute(&mut *conn)
1463 .await?;
1464
1465 let payload = deterministic_task_payload(workspace, series, labels, &slot, &identity);
1466 insert_change_with_identity(
1467 conn,
1468 IdentifiedChange {
1469 change_id: &identity.task_change_id,
1470 entity_type: "task",
1471 entity_id: identity.task_id.as_str(),
1472 field: None,
1473 op_type: op_type::CREATE_TASK,
1474 payload,
1475 base_version: None,
1476 created_at: &identity.created_at,
1477 },
1478 )
1479 .await?;
1480 let occurrence_payload = ChangePayload::workspace(workspace)
1481 .set("series_id", series.id.as_str())
1482 .set("slot_on", slot_on.format("%Y-%m-%d").to_string())
1483 .set("task_id", identity.task_id.as_str())
1484 .set("projected_at", &identity.occurrence_link.projected_at)
1485 .set("task_change_id", &identity.task_change_id)
1486 .set("occurrence_change_id", &identity.occurrence_change_id)
1487 .set(
1488 "task_field_version_seed",
1489 &identity.field_version_seeds.task,
1490 )
1491 .set(
1492 "occurrence_field_version_seed",
1493 &identity.field_version_seeds.occurrence,
1494 )
1495 .set("frequency", series.rule.frequency().as_str())
1496 .set("interval", series.rule.interval())
1497 .set("weekdays", series.rule.weekdays_set().to_string())
1498 .set("timezone", series.timezone.as_str())
1499 .set("start_on", series.start_on.format("%Y-%m-%d").to_string())
1500 .set(
1501 "available_local_time",
1502 format_local_time(series.available_local_time),
1503 )
1504 .set("due_policy", series.due_policy.as_str())
1505 .into_value();
1506 insert_change_with_identity(
1507 conn,
1508 IdentifiedChange {
1509 change_id: &identity.occurrence_change_id,
1510 entity_type: "recurrence_series",
1511 entity_id: series.id.as_str(),
1512 field: Some("projection"),
1513 op_type: op_type::PROJECT_RECURRENCE_OCCURRENCE,
1514 payload: occurrence_payload,
1515 base_version: None,
1516 created_at: &identity.occurrence_link.projected_at,
1517 },
1518 )
1519 .await?;
1520 for field in TaskField::VERSIONED {
1521 set_entity_field_version(
1522 conn,
1523 &workspace.id,
1524 MutableEntityType::Task,
1525 identity.task_id.as_str(),
1526 field.as_str(),
1527 &identity.field_version_seeds.task,
1528 )
1529 .await?;
1530 }
1531 load_occurrence(conn, &workspace.id, &series.id, slot_on)
1532 .await?
1533 .context("materialized recurrence occurrence missing")
1534}
1535
1536async fn verify_materialized_occurrence(
1537 conn: &mut SqliteConnection,
1538 workspace: &Workspace,
1539 series: &RecurrenceSeries,
1540 labels: &[String],
1541 slot: &crate::recurrence::RecurrenceSlot,
1542 identity: &crate::recurrence::RecurrenceOccurrenceIdentity,
1543 occurrence: &RecurrenceOccurrence,
1544) -> Result<()> {
1545 ensure!(
1546 occurrence.task_id.as_ref() == Some(&identity.task_id),
1547 "error recurrence-generation-conflict slot={} field=task_id",
1548 occurrence.slot_on
1549 );
1550 let task = get_task_in_workspace(conn, workspace, &identity.task_id).await?;
1551 ensure!(
1552 task.title == series.title
1553 && task.description == series.description
1554 && task.project_id == series.project_id
1555 && task.status == series.initial_status
1556 && task.priority == series.priority
1557 && task.created_at == identity.created_at
1558 && task.updated_at == identity.updated_at
1559 && task.queue_activity_at == identity.created_at
1560 && task.available_at.as_deref() == Some(slot.available_at.as_str())
1561 && task.due_on == slot.due_on
1562 && !task.deleted
1563 && !task.is_epic,
1564 "error recurrence-generation-conflict slot={} field=task",
1565 occurrence.slot_on
1566 );
1567 let stored_labels: Vec<String> = sqlx::query_scalar(
1568 "SELECT label FROM task_labels WHERE workspace_id = ? AND task_id = ? ORDER BY label",
1569 )
1570 .bind(&workspace.id)
1571 .bind(&identity.task_id)
1572 .fetch_all(&mut *conn)
1573 .await?;
1574 ensure!(
1575 stored_labels == labels,
1576 "error recurrence-generation-conflict slot={} field=labels",
1577 occurrence.slot_on
1578 );
1579 for field in TaskField::VERSIONED {
1580 let version = entity_field_version(
1581 conn,
1582 &workspace.id,
1583 MutableEntityType::Task,
1584 identity.task_id.as_str(),
1585 field.as_str(),
1586 )
1587 .await?;
1588 ensure!(
1589 version.as_deref() == Some(identity.field_version_seeds.task.as_str()),
1590 "error recurrence-generation-conflict slot={} field=field_versions",
1591 occurrence.slot_on
1592 );
1593 }
1594 let payload = deterministic_task_payload(workspace, series, labels, slot, identity);
1595 insert_change_with_identity(
1596 conn,
1597 IdentifiedChange {
1598 change_id: &identity.task_change_id,
1599 entity_type: "task",
1600 entity_id: identity.task_id.as_str(),
1601 field: None,
1602 op_type: op_type::CREATE_TASK,
1603 payload,
1604 base_version: None,
1605 created_at: &identity.created_at,
1606 },
1607 )
1608 .await?;
1609 Ok(())
1610}
1611
1612fn deterministic_task_payload(
1613 workspace: &Workspace,
1614 series: &RecurrenceSeries,
1615 labels: &[String],
1616 slot: &crate::recurrence::RecurrenceSlot,
1617 identity: &crate::recurrence::RecurrenceOccurrenceIdentity,
1618) -> Value {
1619 ChangePayload::workspace(workspace)
1620 .set("task_id", identity.task_id.as_str())
1621 .set("series_id", series.id.as_str())
1622 .set("slot_on", slot.scheduled_on.format("%Y-%m-%d").to_string())
1623 .set("title", &series.title)
1624 .set("description", &series.description)
1625 .set("project_id", series.project_id.as_str())
1626 .set("status", series.initial_status.as_str())
1627 .set("priority", series.priority.as_str())
1628 .set("available_at", &slot.available_at)
1629 .set("due_on", slot.due_on.as_deref().unwrap_or(""))
1630 .set("is_epic", "0")
1631 .set("labels", labels)
1632 .set("created_at", &identity.created_at)
1633 .set("updated_at", &identity.updated_at)
1634 .set("task_change_id", &identity.task_change_id)
1635 .set("occurrence_change_id", &identity.occurrence_change_id)
1636 .set(
1637 "task_field_version_seed",
1638 &identity.field_version_seeds.task,
1639 )
1640 .set(
1641 "occurrence_field_version_seed",
1642 &identity.field_version_seeds.occurrence,
1643 )
1644 .set("frequency", series.rule.frequency().as_str())
1645 .set("interval", series.rule.interval())
1646 .set("weekdays", series.rule.weekdays_set().to_string())
1647 .set("timezone", series.timezone.as_str())
1648 .set("start_on", series.start_on.format("%Y-%m-%d").to_string())
1649 .set(
1650 "available_local_time",
1651 format_local_time(series.available_local_time),
1652 )
1653 .set("due_policy", series.due_policy.as_str())
1654 .into_value()
1655}
1656
1657async fn write_task_status(
1658 conn: &mut SqliteConnection,
1659 workspace: &Workspace,
1660 task_id: &TaskId,
1661 before: TaskStatus,
1662 after: TaskStatus,
1663 resolved_at: &str,
1664) -> Result<String> {
1665 let field = "status";
1666 ensure!(
1667 !crate::db::conflict_exists(conn, &workspace.id, task_id, field).await?,
1668 "error conflicted-field ref={} field=status hint=\"use conflict resolve\"",
1669 task_id
1670 );
1671 let base = entity_field_version(
1672 conn,
1673 &workspace.id,
1674 MutableEntityType::Task,
1675 task_id.as_str(),
1676 field,
1677 )
1678 .await?;
1679 apply_field_value_in_workspace(conn, &workspace.id, task_id, field, after.as_str()).await?;
1680 let change_id = insert_change(
1681 conn,
1682 "task",
1683 task_id.as_str(),
1684 Some(field),
1685 op_type::SET_FIELD,
1686 TaskField::Status.scalar_payload(&workspace.id, &workspace.key, after.as_str())?,
1687 base.as_deref(),
1688 )
1689 .await?;
1690 set_field_version(conn, task_id, field, &change_id).await?;
1691 ensure!(
1692 before.is_open() && after.is_terminal(),
1693 "error recurrence-invalid-terminal-transition"
1694 );
1695 sqlx::query(
1696 "UPDATE tasks SET updated_at = ?, queue_activity_at = ?
1697 WHERE workspace_id = ? AND id = ?",
1698 )
1699 .bind(resolved_at)
1700 .bind(resolved_at)
1701 .bind(&workspace.id)
1702 .bind(task_id)
1703 .execute(&mut *conn)
1704 .await?;
1705 Ok(change_id)
1706}
1707
1708async fn set_series_state(
1709 conn: &mut SqliteConnection,
1710 workspace: &Workspace,
1711 series_id: &RecurrenceSeriesId,
1712 state: RecurrenceSeriesState,
1713 stopped_at: Option<&str>,
1714 changed_at: &str,
1715 operation: &'static str,
1716) -> Result<String> {
1717 let base = entity_field_version(
1718 conn,
1719 &workspace.id,
1720 MutableEntityType::RecurrenceSeries,
1721 series_id.as_str(),
1722 "state",
1723 )
1724 .await?;
1725 sqlx::query(
1726 "UPDATE recurrence_series SET state = ?, stopped_at = ?, updated_at = ?
1727 WHERE workspace_id = ? AND id = ?",
1728 )
1729 .bind(state.as_str())
1730 .bind(stopped_at.unwrap_or(""))
1731 .bind(changed_at)
1732 .bind(&workspace.id)
1733 .bind(series_id)
1734 .execute(&mut *conn)
1735 .await?;
1736 let change_id = insert_change(
1737 conn,
1738 "recurrence_series",
1739 series_id.as_str(),
1740 Some("state"),
1741 operation,
1742 ChangePayload::workspace(workspace)
1743 .set("state", state.as_str())
1744 .set("stopped_at", stopped_at.unwrap_or(""))
1745 .set("changed_at", changed_at)
1746 .into_value(),
1747 base.as_deref(),
1748 )
1749 .await?;
1750 set_entity_field_version(
1751 conn,
1752 &workspace.id,
1753 MutableEntityType::RecurrenceSeries,
1754 series_id.as_str(),
1755 "state",
1756 &change_id,
1757 )
1758 .await?;
1759 if matches!(state, RecurrenceSeriesState::Stopped) {
1760 set_entity_field_version(
1761 conn,
1762 &workspace.id,
1763 MutableEntityType::RecurrenceSeries,
1764 series_id.as_str(),
1765 "stopped_at",
1766 &change_id,
1767 )
1768 .await?;
1769 }
1770 Ok(change_id)
1771}
1772
1773async fn update_series_template_scalar(
1774 conn: &mut SqliteConnection,
1775 workspace_id: &WorkspaceId,
1776 series_id: &RecurrenceSeriesId,
1777 field: &str,
1778 value: &str,
1779 updated_at: &str,
1780) -> Result<()> {
1781 let query = match field {
1782 "title" => {
1783 "UPDATE recurrence_series SET title = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1784 }
1785 "description" => {
1786 "UPDATE recurrence_series SET description = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1787 }
1788 "project" => {
1789 "UPDATE recurrence_series SET project_id = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1790 }
1791 "priority" => {
1792 "UPDATE recurrence_series SET priority = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1793 }
1794 "initial_status" => {
1795 "UPDATE recurrence_series SET initial_status = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1796 }
1797 "available_local_time" => {
1798 "UPDATE recurrence_series SET available_local_time = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1799 }
1800 "due_policy" => {
1801 "UPDATE recurrence_series SET due_policy = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1802 }
1803 _ => bail!("invalid recurrence template field: {field}"),
1804 };
1805 sqlx::query(query)
1806 .bind(value)
1807 .bind(updated_at)
1808 .bind(workspace_id)
1809 .bind(series_id)
1810 .execute(&mut *conn)
1811 .await?;
1812 Ok(())
1813}
1814
1815async fn load_series(
1816 conn: &mut SqliteConnection,
1817 workspace_id: &WorkspaceId,
1818 series_id: &RecurrenceSeriesId,
1819) -> Result<RecurrenceSeries> {
1820 let row = sqlx::query(
1821 "SELECT workspace_id, id, title, description, project_id, priority, initial_status,
1822 frequency, interval, weekdays, timezone, start_on, available_local_time,
1823 due_policy, state, stopped_at, created_at, updated_at, deleted
1824 FROM recurrence_series WHERE workspace_id = ? AND id = ?",
1825 )
1826 .bind(workspace_id)
1827 .bind(series_id)
1828 .fetch_optional(&mut *conn)
1829 .await?
1830 .with_context(|| format!("error recurrence-series-not-found series_id={series_id}"))?;
1831 recurrence_series_from_row(&row)
1832}
1833
1834async fn load_series_labels(
1835 conn: &mut SqliteConnection,
1836 workspace_id: &WorkspaceId,
1837 series_id: &RecurrenceSeriesId,
1838) -> Result<Vec<String>> {
1839 Ok(sqlx::query_scalar(
1840 "SELECT label FROM recurrence_series_labels
1841 WHERE workspace_id = ? AND series_id = ? ORDER BY label",
1842 )
1843 .bind(workspace_id)
1844 .bind(series_id)
1845 .fetch_all(&mut *conn)
1846 .await?)
1847}
1848
1849async fn load_occurrence(
1850 conn: &mut SqliteConnection,
1851 workspace_id: &WorkspaceId,
1852 series_id: &RecurrenceSeriesId,
1853 slot_on: NaiveDate,
1854) -> Result<Option<RecurrenceOccurrence>> {
1855 let row = sqlx::query(
1856 "SELECT workspace_id, series_id, slot_on, task_id, outcome, resolved_at,
1857 outcome_change_id, projection_state, archived_at
1858 FROM recurrence_occurrences
1859 WHERE workspace_id = ? AND series_id = ? AND slot_on = ?",
1860 )
1861 .bind(workspace_id)
1862 .bind(series_id)
1863 .bind(slot_on.format("%Y-%m-%d").to_string())
1864 .fetch_optional(&mut *conn)
1865 .await?;
1866 row.as_ref().map(recurrence_occurrence_from_row).transpose()
1867}
1868
1869async fn load_projected_occurrence(
1870 conn: &mut SqliteConnection,
1871 workspace_id: &WorkspaceId,
1872 series_id: &RecurrenceSeriesId,
1873) -> Result<Option<RecurrenceOccurrence>> {
1874 let row = sqlx::query(
1875 "SELECT workspace_id, series_id, slot_on, task_id, outcome, resolved_at,
1876 outcome_change_id, projection_state, archived_at
1877 FROM recurrence_occurrences
1878 WHERE workspace_id = ? AND series_id = ? AND projection_state = 'projected'",
1879 )
1880 .bind(workspace_id)
1881 .bind(series_id)
1882 .fetch_optional(&mut *conn)
1883 .await?;
1884 row.as_ref().map(recurrence_occurrence_from_row).transpose()
1885}
1886
1887async fn load_occurrence_for_task(
1888 conn: &mut SqliteConnection,
1889 workspace_id: &WorkspaceId,
1890 task_id: &TaskId,
1891) -> Result<Option<RecurrenceOccurrence>> {
1892 let row = sqlx::query(
1893 "SELECT workspace_id, series_id, slot_on, task_id, outcome, resolved_at,
1894 outcome_change_id, projection_state, archived_at
1895 FROM recurrence_occurrences WHERE workspace_id = ? AND task_id = ?",
1896 )
1897 .bind(workspace_id)
1898 .bind(task_id)
1899 .fetch_optional(&mut *conn)
1900 .await?;
1901 row.as_ref().map(recurrence_occurrence_from_row).transpose()
1902}
1903
1904async fn lifecycle_conflict_exists(
1905 conn: &mut SqliteConnection,
1906 workspace_id: &WorkspaceId,
1907 series_id: &RecurrenceSeriesId,
1908) -> Result<bool> {
1909 for field in ["state", "stopped_at"] {
1910 if entity_conflict_exists(
1911 conn,
1912 workspace_id,
1913 MutableEntityType::RecurrenceSeries,
1914 series_id.as_str(),
1915 field,
1916 )
1917 .await?
1918 {
1919 return Ok(true);
1920 }
1921 }
1922 Ok(false)
1923}
1924
1925async fn ensure_no_series_conflict(
1926 conn: &mut SqliteConnection,
1927 workspace_id: &WorkspaceId,
1928 series_id: &RecurrenceSeriesId,
1929 field: &str,
1930) -> Result<()> {
1931 ensure!(
1932 !entity_conflict_exists(
1933 conn,
1934 workspace_id,
1935 MutableEntityType::RecurrenceSeries,
1936 series_id.as_str(),
1937 field,
1938 )
1939 .await?,
1940 "error recurrence-conflicted-field series_id={series_id} field={field}"
1941 );
1942 Ok(())
1943}
1944
1945pub async fn resolve_recurrence_ref(
1946 conn: &mut SqliteConnection,
1947 workspace: &Workspace,
1948 input: &str,
1949) -> Result<RecurrenceSeries> {
1950 let (hint, suffix) = split_ref(input);
1951 if suffix.len() < 3 {
1952 bail!("error recurrence-ref-too-short input={input} minimum=3");
1953 }
1954 let series_rows = sqlx::query(
1955 "SELECT workspace_id, id, title, description, project_id, priority, initial_status,
1956 frequency, interval, weekdays, timezone, start_on, available_local_time,
1957 due_policy, state, stopped_at, created_at, updated_at, deleted
1958 FROM recurrence_series WHERE workspace_id = ? AND id LIKE ? || '%' ORDER BY id",
1959 )
1960 .bind(&workspace.id)
1961 .bind(&suffix)
1962 .fetch_all(&mut *conn)
1963 .await?;
1964 let mut series = series_rows
1965 .iter()
1966 .map(recurrence_series_from_row)
1967 .collect::<Result<Vec<_>>>()?;
1968 if hint
1969 .as_deref()
1970 .is_some_and(|value| value != SERIES_REF_PREFIX)
1971 {
1972 series.clear();
1973 }
1974 if series.len() == 1 {
1975 return Ok(series.remove(0));
1976 }
1977 if series.len() > 1 {
1978 bail!("error ambiguous-recurrence-ref input={input}");
1979 }
1980 let task = crate::refs::resolve_task_ref_in_workspace(conn, workspace, input).await?;
1981 let occurrence = load_occurrence_for_task(conn, &workspace.id, &task.id)
1982 .await?
1983 .context("error task-is-not-recurring")?;
1984 load_series(conn, &workspace.id, &occurrence.series_id).await
1985}
1986
1987async fn recurrence_series_ref(
1988 conn: &mut SqliteConnection,
1989 workspace_id: &WorkspaceId,
1990 series_id: &RecurrenceSeriesId,
1991) -> Result<String> {
1992 let ids = sqlx::query_scalar::<_, RecurrenceSeriesId>(
1993 "SELECT id FROM recurrence_series WHERE workspace_id = ? ORDER BY id",
1994 )
1995 .bind(workspace_id)
1996 .fetch_all(&mut *conn)
1997 .await?;
1998 ensure!(
1999 ids.iter().any(|candidate| candidate == series_id),
2000 "error recurrence-series-not-found series_id={series_id}"
2001 );
2002 Ok(recurrence_series_display_ref(series_id, &ids))
2003}
2004
2005fn split_ref(input: &str) -> (Option<String>, String) {
2006 let (hint, suffix) = input
2007 .split_once('-')
2008 .map_or((None, input), |(hint, suffix)| (Some(hint), suffix));
2009 let suffix = suffix
2010 .chars()
2011 .filter(|value| value.is_ascii_alphanumeric())
2012 .map(|value| match value.to_ascii_uppercase() {
2013 'O' => '0',
2014 'I' | 'L' => '1',
2015 value => value,
2016 })
2017 .collect();
2018 (hint.map(|value| value.to_ascii_uppercase()), suffix)
2019}
2020
2021async fn ensure_change_is_latest_series_transition(
2022 conn: &mut SqliteConnection,
2023 workspace_id: &WorkspaceId,
2024 series_id: &RecurrenceSeriesId,
2025 change_id: &str,
2026) -> Result<()> {
2027 let latest: Option<String> = sqlx::query_scalar(
2028 "SELECT change_id FROM changes
2029 WHERE entity_type = 'recurrence_series' AND entity_id = ?
2030 AND json_extract(payload, '$.workspace_id') = ?
2031 AND op_type != 'project_recurrence_occurrence'
2032 ORDER BY local_seq DESC LIMIT 1",
2033 )
2034 .bind(series_id)
2035 .bind(workspace_id.as_str())
2036 .fetch_optional(&mut *conn)
2037 .await?;
2038 ensure!(
2039 latest.as_deref() == Some(change_id),
2040 "error recurrence-undo-later-operation"
2041 );
2042 Ok(())
2043}
2044
2045async fn ensure_change_unsynced(conn: &mut SqliteConnection, change_id: &str) -> Result<()> {
2046 let server_seq: Option<i64> =
2047 sqlx::query_scalar("SELECT server_seq FROM changes WHERE change_id = ?")
2048 .bind(change_id)
2049 .fetch_one(&mut *conn)
2050 .await?;
2051 ensure!(server_seq.is_none(), "error recurrence-undo-already-synced");
2052 Ok(())
2053}
2054
2055async fn ensure_successor_untouched(
2056 conn: &mut SqliteConnection,
2057 workspace_id: &WorkspaceId,
2058 series: &RecurrenceSeries,
2059 occurrence: &RecurrenceOccurrence,
2060) -> Result<()> {
2061 let task_id = occurrence
2062 .task_id
2063 .as_ref()
2064 .context("error recurrence-undo-successor-missing-task")?;
2065 let identity = derive_occurrence_identity(
2066 workspace_id,
2067 &series.id,
2068 &series.schedule(),
2069 occurrence.slot_on,
2070 )?;
2071 ensure!(
2072 task_id == &identity.task_id,
2073 "error recurrence-undo-successor-changed"
2074 );
2075 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2076 let labels = load_series_labels(conn, workspace_id, &series.id).await?;
2077 let slot = slot_values(&series.schedule(), occurrence.slot_on)?;
2078 verify_materialized_occurrence(
2079 conn, &workspace, series, &labels, &slot, &identity, occurrence,
2080 )
2081 .await
2082 .map_err(|_| anyhow::anyhow!("error recurrence-undo-successor-touched"))?;
2083 ensure_change_unsynced(conn, &identity.task_change_id).await?;
2084 ensure_change_unsynced(conn, &identity.occurrence_change_id).await?;
2085 let extra_changes: i64 = sqlx::query_scalar(
2086 "SELECT count(*) FROM changes
2087 WHERE entity_type = 'task' AND entity_id = ? AND change_id != ?",
2088 )
2089 .bind(task_id)
2090 .bind(&identity.task_change_id)
2091 .fetch_one(&mut *conn)
2092 .await?;
2093 let notes: i64 =
2094 sqlx::query_scalar("SELECT count(*) FROM notes WHERE workspace_id = ? AND task_id = ?")
2095 .bind(workspace_id)
2096 .bind(task_id)
2097 .fetch_one(&mut *conn)
2098 .await?;
2099 let attachments: i64 = sqlx::query_scalar(
2100 "SELECT count(*) FROM task_attachments WHERE workspace_id = ? AND task_id = ?",
2101 )
2102 .bind(workspace_id)
2103 .bind(task_id)
2104 .fetch_one(&mut *conn)
2105 .await?;
2106 let dependencies: i64 = sqlx::query_scalar(
2107 "SELECT count(*) FROM task_dependencies
2108 WHERE workspace_id = ? AND (task_id = ? OR depends_on_task_id = ?)",
2109 )
2110 .bind(workspace_id)
2111 .bind(task_id)
2112 .bind(task_id)
2113 .fetch_one(&mut *conn)
2114 .await?;
2115 let epics: i64 = sqlx::query_scalar(
2116 "SELECT count(*) FROM task_epic_links
2117 WHERE workspace_id = ? AND (child_task_id = ? OR epic_task_id = ?)",
2118 )
2119 .bind(workspace_id)
2120 .bind(task_id)
2121 .bind(task_id)
2122 .fetch_one(&mut *conn)
2123 .await?;
2124 ensure!(
2125 extra_changes == 0 && notes == 0 && attachments == 0 && dependencies == 0 && epics == 0,
2126 "error recurrence-undo-successor-touched"
2127 );
2128 Ok(())
2129}
2130
2131async fn remove_materialized_occurrence(
2132 conn: &mut SqliteConnection,
2133 workspace_id: &WorkspaceId,
2134 series: &RecurrenceSeries,
2135 occurrence: &RecurrenceOccurrence,
2136) -> Result<()> {
2137 let task_id = occurrence
2138 .task_id
2139 .as_ref()
2140 .expect("verified successor has task");
2141 let identity = derive_occurrence_identity(
2142 workspace_id,
2143 &series.id,
2144 &series.schedule(),
2145 occurrence.slot_on,
2146 )?;
2147 sqlx::query(
2148 "DELETE FROM recurrence_occurrences
2149 WHERE workspace_id = ? AND series_id = ? AND slot_on = ?",
2150 )
2151 .bind(workspace_id)
2152 .bind(&series.id)
2153 .bind(occurrence.slot_on.format("%Y-%m-%d").to_string())
2154 .execute(&mut *conn)
2155 .await?;
2156 sqlx::query("DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ?")
2157 .bind(workspace_id)
2158 .bind(task_id)
2159 .execute(&mut *conn)
2160 .await?;
2161 sqlx::query(
2162 "DELETE FROM field_versions
2163 WHERE workspace_id = ? AND entity_type = 'task' AND entity_id = ?",
2164 )
2165 .bind(workspace_id)
2166 .bind(task_id)
2167 .execute(&mut *conn)
2168 .await?;
2169 sqlx::query("DELETE FROM tasks WHERE workspace_id = ? AND id = ?")
2170 .bind(workspace_id)
2171 .bind(task_id)
2172 .execute(&mut *conn)
2173 .await?;
2174 sqlx::query("DELETE FROM changes WHERE change_id IN (?, ?)")
2175 .bind(&identity.task_change_id)
2176 .bind(&identity.occurrence_change_id)
2177 .execute(&mut *conn)
2178 .await?;
2179 Ok(())
2180}
2181
2182fn retryable_reconcile_error(error: &anyhow::Error) -> bool {
2183 error
2184 .downcast_ref::<sqlx::Error>()
2185 .and_then(sqlx::Error::as_database_error)
2186 .and_then(|error| error.code())
2187 .is_some_and(|code| matches!(code.as_ref(), "5" | "6" | "19"))
2188}
2189
2190pub(crate) fn timestamp_strictly_after(earlier: &str, candidate: &str) -> Result<String> {
2191 if candidate > earlier {
2192 return Ok(candidate.to_string());
2193 }
2194 let earlier = DateTime::parse_from_rfc3339(earlier)?.with_timezone(&Utc);
2195 let adjusted = earlier
2196 .checked_add_signed(chrono::Duration::seconds(1))
2197 .context("recurrence lifecycle timestamp is out of range")?;
2198 Ok(format_utc(adjusted))
2199}
2200
2201fn format_local_time(value: Option<NaiveTime>) -> String {
2202 value
2203 .map(|value| value.format("%H:%M:%S").to_string())
2204 .unwrap_or_default()
2205}
2206
2207fn format_utc(value: DateTime<Utc>) -> String {
2208 value.format("%Y-%m-%dT%H:%M:%SZ").to_string()
2209}
2210
2211fn utc_now() -> Result<DateTime<Utc>> {
2212 Ok(DateTime::parse_from_rfc3339(&now())?.with_timezone(&Utc))
2213}