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