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