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