Skip to main content

aven_core/operations/
recurrence.rs

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}