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 stopped_at = stopped_at.to_string();
1170    let mut tx = begin_immediate(conn).await?;
1171    let series = load_series(&mut tx, &workspace.id, series_id).await?;
1172    ensure!(
1173        !matches!(series.state, RecurrenceSeriesState::Stopped),
1174        CoreError::validation("error recurrence-already-stopped")
1175    );
1176    ensure_no_series_conflict(&mut tx, &workspace.id, series_id, "state").await?;
1177    let open_pause = if matches!(series.state, RecurrenceSeriesState::Paused) {
1178        let interval = sqlx::query(
1179            "SELECT id, paused_at FROM recurrence_pause_intervals
1180             WHERE workspace_id = ? AND series_id = ? AND resumed_at = ''",
1181        )
1182        .bind(&workspace.id)
1183        .bind(series_id)
1184        .fetch_optional(&mut *tx)
1185        .await?
1186        .ok_or_else(|| CoreError::validation("error recurrence-open-pause-missing"))?;
1187        let interval_id: String = interval.get("id");
1188        let paused_at: String = interval.get("paused_at");
1189        stopped_at = timestamp_strictly_after(&paused_at, &stopped_at)?;
1190        Some((interval_id, paused_at))
1191    } else {
1192        None
1193    };
1194    let stopped_at_utc = DateTime::parse_from_rfc3339(&stopped_at)
1195        .context("invalid recurrence stop time")?
1196        .with_timezone(&Utc);
1197    reconcile_recurrence_series_in_transaction(&mut tx, workspace, series_id, stopped_at_utc)
1198        .await?;
1199    if let Some((interval_id, paused_at)) = open_pause {
1200        let close_change_id = append_change(
1201            &mut tx,
1202            ChangeEntity::RecurrenceSeries,
1203            series_id.as_str(),
1204            Some("pause"),
1205            op_type::CLOSE_RECURRENCE_PAUSE,
1206            ChangePayload::workspace(workspace)
1207                .set("interval_id", &interval_id)
1208                .set("paused_at", &paused_at)
1209                .set("resumed_at", &stopped_at),
1210        )
1211        .await?;
1212        sqlx::query(
1213            "UPDATE recurrence_pause_intervals SET resumed_at = ?, resolved_by_change_id = ?
1214             WHERE workspace_id = ? AND id = ? AND resumed_at = ''",
1215        )
1216        .bind(&stopped_at)
1217        .bind(&close_change_id)
1218        .bind(&workspace.id)
1219        .bind(&interval_id)
1220        .execute(&mut *tx)
1221        .await?;
1222    }
1223    set_series_state(
1224        &mut tx,
1225        workspace,
1226        series_id,
1227        RecurrenceSeriesState::Stopped,
1228        Some(&stopped_at),
1229        &stopped_at,
1230        op_type::STOP_RECURRENCE_SERIES,
1231    )
1232    .await?;
1233    let projected = load_projected_occurrence(&mut tx, &workspace.id, series_id).await?;
1234    let occurrence = if skip_current {
1235        let projected = projected
1236            .ok_or_else(|| CoreError::validation("error recurrence-current-occurrence-missing"))?;
1237        let task_id = projected
1238            .task_id
1239            .as_ref()
1240            .expect("projected occurrence has a task");
1241        Some(
1242            resolve_recurrence_occurrence_in_transaction(
1243                &mut tx,
1244                workspace,
1245                task_id,
1246                RecurrenceOutcome::Skipped,
1247                &stopped_at,
1248            )
1249            .await?
1250            .resolved,
1251        )
1252    } else {
1253        projected
1254    };
1255    let series = load_series(&mut tx, &workspace.id, series_id).await?;
1256    tx.commit().await?;
1257    Ok(RecurrenceStateOutcome { series, occurrence })
1258}
1259
1260pub(crate) async fn route_recurrence_task_field(
1261    conn: &mut SqliteConnection,
1262    workspace: &Workspace,
1263    task_id: &TaskId,
1264    field: &str,
1265    value: &str,
1266) -> Result<Option<bool>> {
1267    let Some(mut occurrence) = load_occurrence_for_task(conn, &workspace.id, task_id).await? else {
1268        return Ok(None);
1269    };
1270    if matches!(
1271        occurrence.projection_state,
1272        RecurrenceProjectionState::Projected
1273    ) {
1274        reconcile_recurrence_series_in_transaction(
1275            conn,
1276            workspace,
1277            &occurrence.series_id,
1278            Utc::now(),
1279        )
1280        .await?;
1281        occurrence = load_occurrence_for_task(conn, &workspace.id, task_id)
1282            .await?
1283            .ok_or_else(|| CoreError::not_found("error recurrence-occurrence-not-found"))?;
1284    }
1285    if matches!(
1286        occurrence.projection_state,
1287        RecurrenceProjectionState::Archived
1288    ) {
1289        return Err(CoreError::validation(format!(
1290            "error recurrence-occurrence-archived task_id={task_id}"
1291        ))
1292        .into());
1293    }
1294    if field == "status" {
1295        let task = get_task_in_workspace(conn, workspace, task_id).await?;
1296        let target = TaskStatus::parse(value)?;
1297        if task.status == target {
1298            return Ok(Some(false));
1299        }
1300        if task.status.is_terminal() {
1301            if target.is_open() {
1302                return Err(CoreError::validation(format!(
1303                    "error recurrence-terminal-reopen task_id={task_id} hint=\"use immediate undo\""
1304                ))
1305                .into());
1306            }
1307            return Err(CoreError::validation(format!(
1308                "error recurrence-outcome-final task_id={task_id} hint=\"use immediate undo\""
1309            ))
1310            .into());
1311        }
1312        if target.is_terminal() {
1313            ensure!(
1314                matches!(
1315                    occurrence.projection_state,
1316                    RecurrenceProjectionState::Projected
1317                ),
1318                "error recurrence-occurrence-not-current task_id={task_id}"
1319            );
1320            let outcome = if target == TaskStatus::Done {
1321                RecurrenceOutcome::Completed
1322            } else {
1323                RecurrenceOutcome::Skipped
1324            };
1325            resolve_recurrence_occurrence_in_transaction(conn, workspace, task_id, outcome, &now())
1326                .await?;
1327            return Ok(Some(true));
1328        }
1329        return Ok(None);
1330    }
1331    if field == "deleted"
1332        && matches!(
1333            occurrence.projection_state,
1334            RecurrenceProjectionState::Projected
1335        )
1336        && value == "1"
1337    {
1338        let series = load_series(conn, &workspace.id, &occurrence.series_id).await?;
1339        let guidance = match series.state {
1340            RecurrenceSeriesState::Active => "skip, pause, or stop the series",
1341            RecurrenceSeriesState::Paused => "skip the occurrence or stop the series",
1342            RecurrenceSeriesState::Stopped => "complete or cancel the final occurrence",
1343        };
1344        return Err(CoreError::validation(format!(
1345            "error recurrence-current-delete task_id={task_id} hint=\"{guidance}\""
1346        ))
1347        .into());
1348    }
1349    Ok(None)
1350}
1351
1352pub(crate) async fn undo_recurrence_resolution(
1353    conn: &mut SqliteConnection,
1354    workspace_id: &WorkspaceId,
1355    task_id: &TaskId,
1356    before_status: &str,
1357    after_status: &str,
1358) -> Result<bool> {
1359    let Some(occurrence) = load_occurrence_for_task(conn, workspace_id, task_id).await? else {
1360        return Ok(false);
1361    };
1362    if !matches!(
1363        occurrence.projection_state,
1364        RecurrenceProjectionState::Resolved
1365    ) || occurrence.outcome.is_none()
1366    {
1367        return Ok(false);
1368    }
1369    let task_status: String =
1370        sqlx::query_scalar("SELECT status FROM tasks WHERE workspace_id = ? AND id = ?")
1371            .bind(workspace_id)
1372            .bind(task_id)
1373            .fetch_one(&mut *conn)
1374            .await?;
1375    ensure!(
1376        task_status == after_status,
1377        "error undo-state-changed task_id={task_id} field=status"
1378    );
1379    let series = load_series(conn, workspace_id, &occurrence.series_id).await?;
1380    let outcome_change_id = occurrence
1381        .outcome_change_id
1382        .as_deref()
1383        .context("error recurrence-undo-missing-outcome-change")?;
1384    ensure_change_is_latest_series_transition(conn, workspace_id, &series.id, outcome_change_id)
1385        .await?;
1386    ensure_change_unsynced(conn, outcome_change_id).await?;
1387    let status_change_id = entity_field_version(
1388        conn,
1389        workspace_id,
1390        MutableEntityType::Task,
1391        task_id.as_str(),
1392        "status",
1393    )
1394    .await?
1395    .context("error recurrence-undo-missing-status-change")?;
1396    ensure_change_unsynced(conn, &status_change_id).await?;
1397    let prior_status_version: Option<String> =
1398        sqlx::query_scalar("SELECT base_version FROM changes WHERE change_id = ?")
1399            .bind(&status_change_id)
1400            .fetch_one(&mut *conn)
1401            .await?;
1402
1403    let successor = load_projected_occurrence(conn, workspace_id, &series.id).await?;
1404    match series.state {
1405        RecurrenceSeriesState::Active => {
1406            let successor = successor.context("error recurrence-undo-successor-missing")?;
1407            let expected_slot = next_slot_after(&series.rule, series.start_on, occurrence.slot_on)
1408                .context("recurrence schedule has no representable successor")?;
1409            ensure!(
1410                successor.slot_on == expected_slot,
1411                "error recurrence-undo-successor-changed"
1412            );
1413            ensure_successor_untouched(conn, workspace_id, &series, &successor).await?;
1414            remove_materialized_occurrence(conn, workspace_id, &series, &successor).await?;
1415        }
1416        RecurrenceSeriesState::Paused | RecurrenceSeriesState::Stopped => {
1417            ensure!(
1418                successor.is_none(),
1419                "error recurrence-undo-successor-exists"
1420            );
1421        }
1422    }
1423
1424    apply_field_value_in_workspace(conn, workspace_id, task_id, "status", before_status).await?;
1425    if let Some(version) = prior_status_version {
1426        set_entity_field_version(
1427            conn,
1428            workspace_id,
1429            MutableEntityType::Task,
1430            task_id.as_str(),
1431            "status",
1432            &version,
1433        )
1434        .await?;
1435    } else {
1436        sqlx::query(
1437            "DELETE FROM field_versions
1438             WHERE workspace_id = ? AND entity_type = 'task' AND entity_id = ? AND field = 'status'",
1439        )
1440        .bind(workspace_id)
1441        .bind(task_id)
1442        .execute(&mut *conn)
1443        .await?;
1444    }
1445    sqlx::query(
1446        "UPDATE recurrence_occurrences
1447         SET outcome = '', resolved_at = '', outcome_change_id = '', projection_state = 'projected'
1448         WHERE workspace_id = ? AND series_id = ? AND slot_on = ?",
1449    )
1450    .bind(workspace_id)
1451    .bind(&series.id)
1452    .bind(occurrence.slot_on.format("%Y-%m-%d").to_string())
1453    .execute(&mut *conn)
1454    .await?;
1455    sqlx::query("DELETE FROM changes WHERE change_id IN (?, ?)")
1456        .bind(&status_change_id)
1457        .bind(outcome_change_id)
1458        .execute(&mut *conn)
1459        .await?;
1460    Ok(true)
1461}
1462
1463async fn materialize_occurrence(
1464    conn: &mut SqliteConnection,
1465    workspace: &Workspace,
1466    series: &RecurrenceSeries,
1467    labels: &[String],
1468    slot_on: NaiveDate,
1469) -> Result<RecurrenceOccurrence> {
1470    let schedule = series.schedule();
1471    let slot = slot_values(&schedule, slot_on)?;
1472    let identity = derive_occurrence_identity(&workspace.id, &series.id, &schedule, slot_on)?;
1473    let metadata = load_series_metadata(conn, &workspace.id, &series.id).await?;
1474    if let Some(existing) = load_occurrence(conn, &workspace.id, &series.id, slot_on).await? {
1475        verify_materialized_occurrence(
1476            conn, workspace, series, labels, &metadata, &slot, &identity, &existing,
1477        )
1478        .await?;
1479        return Ok(existing);
1480    }
1481
1482    sqlx::query(
1483        "INSERT INTO tasks(
1484            workspace_id, id, title, description, project_id, status, priority,
1485            created_at, updated_at, queue_activity_at, available_at, due_on, deleted, is_epic
1486         ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0)",
1487    )
1488    .bind(&workspace.id)
1489    .bind(&identity.task_id)
1490    .bind(&series.title)
1491    .bind(&series.description)
1492    .bind(&series.project_id)
1493    .bind(series.initial_status.as_str())
1494    .bind(series.priority.as_str())
1495    .bind(&identity.created_at)
1496    .bind(&identity.updated_at)
1497    .bind(&identity.created_at)
1498    .bind(&slot.available_at)
1499    .bind(slot.due_on.as_deref().unwrap_or(""))
1500    .execute(&mut *conn)
1501    .await?;
1502    for label in labels {
1503        sqlx::query("INSERT INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)")
1504            .bind(&workspace.id)
1505            .bind(&identity.task_id)
1506            .bind(label)
1507            .execute(&mut *conn)
1508            .await?;
1509    }
1510    for value in &metadata {
1511        sqlx::query(
1512            "INSERT INTO task_metadata(
1513                 workspace_id, task_id, field_id, value, created_at, updated_at
1514             ) VALUES (?, ?, ?, ?, ?, ?)",
1515        )
1516        .bind(&workspace.id)
1517        .bind(&identity.task_id)
1518        .bind(&value.field_id)
1519        .bind(&value.value)
1520        .bind(&identity.created_at)
1521        .bind(&identity.updated_at)
1522        .execute(&mut *conn)
1523        .await?;
1524    }
1525    sqlx::query(
1526        "INSERT INTO recurrence_occurrences(
1527            workspace_id, series_id, slot_on, task_id, outcome, resolved_at,
1528            outcome_change_id, projection_state, archived_at
1529         ) VALUES (?, ?, ?, ?, '', '', '', 'projected', '')",
1530    )
1531    .bind(&workspace.id)
1532    .bind(&series.id)
1533    .bind(slot_on.format("%Y-%m-%d").to_string())
1534    .bind(&identity.task_id)
1535    .execute(&mut *conn)
1536    .await?;
1537
1538    let payload =
1539        deterministic_task_payload(workspace, series, labels, &metadata, &slot, &identity);
1540    insert_change_with_identity(
1541        conn,
1542        IdentifiedChange {
1543            change_id: &identity.task_change_id,
1544            entity_type: "task",
1545            entity_id: identity.task_id.as_str(),
1546            field: None,
1547            op_type: op_type::CREATE_TASK,
1548            payload,
1549            base_version: None,
1550            created_at: &identity.created_at,
1551        },
1552    )
1553    .await?;
1554    let occurrence_payload = ChangePayload::workspace(workspace)
1555        .set("series_id", series.id.as_str())
1556        .set("slot_on", slot_on.format("%Y-%m-%d").to_string())
1557        .set("task_id", identity.task_id.as_str())
1558        .set("projected_at", &identity.occurrence_link.projected_at)
1559        .set("task_change_id", &identity.task_change_id)
1560        .set("occurrence_change_id", &identity.occurrence_change_id)
1561        .set(
1562            "task_field_version_seed",
1563            &identity.field_version_seeds.task,
1564        )
1565        .set(
1566            "occurrence_field_version_seed",
1567            &identity.field_version_seeds.occurrence,
1568        )
1569        .set("frequency", series.rule.frequency().as_str())
1570        .set("interval", series.rule.interval())
1571        .set("weekdays", series.rule.weekdays_set().to_string())
1572        .set("timezone", series.timezone.as_str())
1573        .set("start_on", series.start_on.format("%Y-%m-%d").to_string())
1574        .set(
1575            "available_local_time",
1576            format_local_time(series.available_local_time),
1577        )
1578        .set("due_policy", series.due_policy.as_str())
1579        .into_value();
1580    insert_change_with_identity(
1581        conn,
1582        IdentifiedChange {
1583            change_id: &identity.occurrence_change_id,
1584            entity_type: "recurrence_series",
1585            entity_id: series.id.as_str(),
1586            field: Some("projection"),
1587            op_type: op_type::PROJECT_RECURRENCE_OCCURRENCE,
1588            payload: occurrence_payload,
1589            base_version: None,
1590            created_at: &identity.occurrence_link.projected_at,
1591        },
1592    )
1593    .await?;
1594    for field in TaskField::VERSIONED {
1595        set_entity_field_version(
1596            conn,
1597            &workspace.id,
1598            MutableEntityType::Task,
1599            identity.task_id.as_str(),
1600            field.as_str(),
1601            &identity.field_version_seeds.task,
1602        )
1603        .await?;
1604    }
1605    for value in &metadata {
1606        set_entity_field_version(
1607            conn,
1608            &workspace.id,
1609            MutableEntityType::Task,
1610            identity.task_id.as_str(),
1611            &format!("metadata:{}", value.field_id),
1612            &identity.field_version_seeds.task,
1613        )
1614        .await?;
1615    }
1616    load_occurrence(conn, &workspace.id, &series.id, slot_on)
1617        .await?
1618        .context("materialized recurrence occurrence missing")
1619}
1620
1621#[allow(clippy::too_many_arguments)]
1622async fn verify_materialized_occurrence(
1623    conn: &mut SqliteConnection,
1624    workspace: &Workspace,
1625    series: &RecurrenceSeries,
1626    labels: &[String],
1627    metadata: &[crate::metadata::ResolvedMetadataValue],
1628    slot: &crate::recurrence::RecurrenceSlot,
1629    identity: &crate::recurrence::RecurrenceOccurrenceIdentity,
1630    occurrence: &RecurrenceOccurrence,
1631) -> Result<()> {
1632    ensure!(
1633        occurrence.task_id.as_ref() == Some(&identity.task_id),
1634        CoreError::generation_conflict(format!(
1635            "error recurrence-generation-conflict slot={} field=task_id",
1636            occurrence.slot_on
1637        ))
1638    );
1639    let task = get_task_in_workspace(conn, workspace, &identity.task_id).await?;
1640    ensure!(
1641        task.title == series.title
1642            && task.description == series.description
1643            && task.project_id == series.project_id
1644            && task.status == series.initial_status
1645            && task.priority == series.priority
1646            && task.created_at == identity.created_at
1647            && task.updated_at == identity.updated_at
1648            && task.queue_activity_at == identity.created_at
1649            && task.available_at.as_deref() == Some(slot.available_at.as_str())
1650            && task.due_on == slot.due_on
1651            && !task.deleted
1652            && !task.is_epic,
1653        CoreError::generation_conflict(format!(
1654            "error recurrence-generation-conflict slot={} field=task",
1655            occurrence.slot_on
1656        ))
1657    );
1658    let stored_labels: Vec<String> = sqlx::query_scalar(
1659        "SELECT label FROM task_labels WHERE workspace_id = ? AND task_id = ? ORDER BY label",
1660    )
1661    .bind(&workspace.id)
1662    .bind(&identity.task_id)
1663    .fetch_all(&mut *conn)
1664    .await?;
1665    ensure!(
1666        stored_labels == labels,
1667        CoreError::generation_conflict(format!(
1668            "error recurrence-generation-conflict slot={} field=labels",
1669            occurrence.slot_on
1670        ))
1671    );
1672    let stored_metadata: Vec<(crate::ids::MetadataFieldId, String)> = sqlx::query_as(
1673        "SELECT field_id, value FROM task_metadata
1674         WHERE workspace_id = ? AND task_id = ? ORDER BY field_id",
1675    )
1676    .bind(&workspace.id)
1677    .bind(&identity.task_id)
1678    .fetch_all(&mut *conn)
1679    .await?;
1680    let mut expected_metadata = metadata
1681        .iter()
1682        .map(|value| (value.field_id.clone(), value.value.clone()))
1683        .collect::<Vec<_>>();
1684    expected_metadata.sort_by(|left, right| left.0.as_str().cmp(right.0.as_str()));
1685    ensure!(
1686        stored_metadata == expected_metadata,
1687        CoreError::generation_conflict(format!(
1688            "error recurrence-generation-conflict slot={} field=metadata",
1689            occurrence.slot_on
1690        ))
1691    );
1692    for field in TaskField::VERSIONED {
1693        let version = entity_field_version(
1694            conn,
1695            &workspace.id,
1696            MutableEntityType::Task,
1697            identity.task_id.as_str(),
1698            field.as_str(),
1699        )
1700        .await?;
1701        ensure!(
1702            version.as_deref() == Some(identity.field_version_seeds.task.as_str()),
1703            CoreError::generation_conflict(format!(
1704                "error recurrence-generation-conflict slot={} field=field_versions",
1705                occurrence.slot_on
1706            ))
1707        );
1708    }
1709    for value in metadata {
1710        let version = entity_field_version(
1711            conn,
1712            &workspace.id,
1713            MutableEntityType::Task,
1714            identity.task_id.as_str(),
1715            &format!("metadata:{}", value.field_id),
1716        )
1717        .await?;
1718        ensure!(
1719            version.as_deref() == Some(identity.field_version_seeds.task.as_str()),
1720            CoreError::generation_conflict(format!(
1721                "error recurrence-generation-conflict slot={} field=metadata_versions",
1722                occurrence.slot_on
1723            ))
1724        );
1725    }
1726    let payload = deterministic_task_payload(workspace, series, labels, metadata, slot, identity);
1727    insert_change_with_identity(
1728        conn,
1729        IdentifiedChange {
1730            change_id: &identity.task_change_id,
1731            entity_type: "task",
1732            entity_id: identity.task_id.as_str(),
1733            field: None,
1734            op_type: op_type::CREATE_TASK,
1735            payload,
1736            base_version: None,
1737            created_at: &identity.created_at,
1738        },
1739    )
1740    .await?;
1741    Ok(())
1742}
1743
1744fn deterministic_task_payload(
1745    workspace: &Workspace,
1746    series: &RecurrenceSeries,
1747    labels: &[String],
1748    metadata: &[crate::metadata::ResolvedMetadataValue],
1749    slot: &crate::recurrence::RecurrenceSlot,
1750    identity: &crate::recurrence::RecurrenceOccurrenceIdentity,
1751) -> Value {
1752    ChangePayload::workspace(workspace)
1753        .set("task_id", identity.task_id.as_str())
1754        .set("series_id", series.id.as_str())
1755        .set("slot_on", slot.scheduled_on.format("%Y-%m-%d").to_string())
1756        .set("title", &series.title)
1757        .set("description", &series.description)
1758        .set("project_id", series.project_id.as_str())
1759        .set("status", series.initial_status.as_str())
1760        .set("priority", series.priority.as_str())
1761        .set("available_at", &slot.available_at)
1762        .set("due_on", slot.due_on.as_deref().unwrap_or(""))
1763        .set("is_epic", "0")
1764        .set("labels", labels)
1765        .set("metadata", metadata)
1766        .set("created_at", &identity.created_at)
1767        .set("updated_at", &identity.updated_at)
1768        .set("task_change_id", &identity.task_change_id)
1769        .set("occurrence_change_id", &identity.occurrence_change_id)
1770        .set(
1771            "task_field_version_seed",
1772            &identity.field_version_seeds.task,
1773        )
1774        .set(
1775            "occurrence_field_version_seed",
1776            &identity.field_version_seeds.occurrence,
1777        )
1778        .set("frequency", series.rule.frequency().as_str())
1779        .set("interval", series.rule.interval())
1780        .set("weekdays", series.rule.weekdays_set().to_string())
1781        .set("timezone", series.timezone.as_str())
1782        .set("start_on", series.start_on.format("%Y-%m-%d").to_string())
1783        .set(
1784            "available_local_time",
1785            format_local_time(series.available_local_time),
1786        )
1787        .set("due_policy", series.due_policy.as_str())
1788        .into_value()
1789}
1790
1791async fn write_task_status(
1792    conn: &mut SqliteConnection,
1793    workspace: &Workspace,
1794    task_id: &TaskId,
1795    before: TaskStatus,
1796    after: TaskStatus,
1797    resolved_at: &str,
1798) -> Result<String> {
1799    let field = "status";
1800    ensure!(
1801        !crate::db::conflict_exists(conn, &workspace.id, task_id, field).await?,
1802        "error conflicted-field ref={} field=status hint=\"use conflict resolve\"",
1803        task_id
1804    );
1805    let base = entity_field_version(
1806        conn,
1807        &workspace.id,
1808        MutableEntityType::Task,
1809        task_id.as_str(),
1810        field,
1811    )
1812    .await?;
1813    apply_field_value_in_workspace(conn, &workspace.id, task_id, field, after.as_str()).await?;
1814    let change_id = insert_change(
1815        conn,
1816        "task",
1817        task_id.as_str(),
1818        Some(field),
1819        op_type::SET_FIELD,
1820        TaskField::Status.scalar_payload(&workspace.id, &workspace.key, after.as_str())?,
1821        base.as_deref(),
1822    )
1823    .await?;
1824    set_field_version(conn, task_id, field, &change_id).await?;
1825    ensure!(
1826        before.is_open() && after.is_terminal(),
1827        "error recurrence-invalid-terminal-transition"
1828    );
1829    sqlx::query(
1830        "UPDATE tasks SET updated_at = ?, queue_activity_at = ?
1831         WHERE workspace_id = ? AND id = ?",
1832    )
1833    .bind(resolved_at)
1834    .bind(resolved_at)
1835    .bind(&workspace.id)
1836    .bind(task_id)
1837    .execute(&mut *conn)
1838    .await?;
1839    Ok(change_id)
1840}
1841
1842async fn set_series_state(
1843    conn: &mut SqliteConnection,
1844    workspace: &Workspace,
1845    series_id: &RecurrenceSeriesId,
1846    state: RecurrenceSeriesState,
1847    stopped_at: Option<&str>,
1848    changed_at: &str,
1849    operation: &'static str,
1850) -> Result<String> {
1851    let base = entity_field_version(
1852        conn,
1853        &workspace.id,
1854        MutableEntityType::RecurrenceSeries,
1855        series_id.as_str(),
1856        "state",
1857    )
1858    .await?;
1859    sqlx::query(
1860        "UPDATE recurrence_series SET state = ?, stopped_at = ?, updated_at = ?
1861         WHERE workspace_id = ? AND id = ?",
1862    )
1863    .bind(state.as_str())
1864    .bind(stopped_at.unwrap_or(""))
1865    .bind(changed_at)
1866    .bind(&workspace.id)
1867    .bind(series_id)
1868    .execute(&mut *conn)
1869    .await?;
1870    let change_id = insert_change(
1871        conn,
1872        "recurrence_series",
1873        series_id.as_str(),
1874        Some("state"),
1875        operation,
1876        ChangePayload::workspace(workspace)
1877            .set("state", state.as_str())
1878            .set("stopped_at", stopped_at.unwrap_or(""))
1879            .set("changed_at", changed_at)
1880            .into_value(),
1881        base.as_deref(),
1882    )
1883    .await?;
1884    set_entity_field_version(
1885        conn,
1886        &workspace.id,
1887        MutableEntityType::RecurrenceSeries,
1888        series_id.as_str(),
1889        "state",
1890        &change_id,
1891    )
1892    .await?;
1893    if matches!(state, RecurrenceSeriesState::Stopped) {
1894        set_entity_field_version(
1895            conn,
1896            &workspace.id,
1897            MutableEntityType::RecurrenceSeries,
1898            series_id.as_str(),
1899            "stopped_at",
1900            &change_id,
1901        )
1902        .await?;
1903    }
1904    Ok(change_id)
1905}
1906
1907async fn update_series_template_scalar(
1908    conn: &mut SqliteConnection,
1909    workspace_id: &WorkspaceId,
1910    series_id: &RecurrenceSeriesId,
1911    field: &str,
1912    value: &str,
1913    updated_at: &str,
1914) -> Result<()> {
1915    let query = match field {
1916        "title" => {
1917            "UPDATE recurrence_series SET title = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1918        }
1919        "description" => {
1920            "UPDATE recurrence_series SET description = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1921        }
1922        "project" => {
1923            "UPDATE recurrence_series SET project_id = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1924        }
1925        "priority" => {
1926            "UPDATE recurrence_series SET priority = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1927        }
1928        "initial_status" => {
1929            "UPDATE recurrence_series SET initial_status = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1930        }
1931        "available_local_time" => {
1932            "UPDATE recurrence_series SET available_local_time = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1933        }
1934        "due_policy" => {
1935            "UPDATE recurrence_series SET due_policy = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1936        }
1937        _ => bail!("invalid recurrence template field: {field}"),
1938    };
1939    sqlx::query(query)
1940        .bind(value)
1941        .bind(updated_at)
1942        .bind(workspace_id)
1943        .bind(series_id)
1944        .execute(&mut *conn)
1945        .await?;
1946    Ok(())
1947}
1948
1949async fn load_series(
1950    conn: &mut SqliteConnection,
1951    workspace_id: &WorkspaceId,
1952    series_id: &RecurrenceSeriesId,
1953) -> Result<RecurrenceSeries> {
1954    let row = sqlx::query(
1955        "SELECT workspace_id, id, title, description, project_id, priority, initial_status,
1956                frequency, interval, weekdays, timezone, start_on, available_local_time,
1957                due_policy, state, stopped_at, created_at, updated_at, deleted
1958         FROM recurrence_series WHERE workspace_id = ? AND id = ?",
1959    )
1960    .bind(workspace_id)
1961    .bind(series_id)
1962    .fetch_optional(&mut *conn)
1963    .await?
1964    .ok_or_else(|| {
1965        CoreError::not_found(format!(
1966            "error recurrence-series-not-found series_id={series_id}"
1967        ))
1968    })?;
1969    recurrence_series_from_row(&row)
1970}
1971
1972async fn load_series_labels(
1973    conn: &mut SqliteConnection,
1974    workspace_id: &WorkspaceId,
1975    series_id: &RecurrenceSeriesId,
1976) -> Result<Vec<String>> {
1977    Ok(sqlx::query_scalar(
1978        "SELECT label FROM recurrence_series_labels
1979         WHERE workspace_id = ? AND series_id = ? ORDER BY label",
1980    )
1981    .bind(workspace_id)
1982    .bind(series_id)
1983    .fetch_all(&mut *conn)
1984    .await?)
1985}
1986
1987async fn load_series_metadata(
1988    conn: &mut SqliteConnection,
1989    workspace_id: &WorkspaceId,
1990    series_id: &RecurrenceSeriesId,
1991) -> Result<Vec<crate::metadata::ResolvedMetadataValue>> {
1992    let rows = sqlx::query(
1993        "SELECT m.field_id, f.key, m.value
1994         FROM recurrence_series_metadata m
1995         JOIN metadata_fields f ON f.workspace_id = m.workspace_id AND f.id = m.field_id
1996         WHERE m.workspace_id = ? AND m.series_id = ? ORDER BY f.key",
1997    )
1998    .bind(workspace_id)
1999    .bind(series_id)
2000    .fetch_all(&mut *conn)
2001    .await?;
2002    Ok(rows
2003        .into_iter()
2004        .map(|row| crate::metadata::ResolvedMetadataValue {
2005            field_id: row.get("field_id"),
2006            key: row.get("key"),
2007            value: row.get("value"),
2008        })
2009        .collect())
2010}
2011
2012async fn load_occurrence(
2013    conn: &mut SqliteConnection,
2014    workspace_id: &WorkspaceId,
2015    series_id: &RecurrenceSeriesId,
2016    slot_on: NaiveDate,
2017) -> Result<Option<RecurrenceOccurrence>> {
2018    let row = sqlx::query(
2019        "SELECT workspace_id, series_id, slot_on, task_id, outcome, resolved_at,
2020                outcome_change_id, projection_state, archived_at
2021         FROM recurrence_occurrences
2022         WHERE workspace_id = ? AND series_id = ? AND slot_on = ?",
2023    )
2024    .bind(workspace_id)
2025    .bind(series_id)
2026    .bind(slot_on.format("%Y-%m-%d").to_string())
2027    .fetch_optional(&mut *conn)
2028    .await?;
2029    row.as_ref().map(recurrence_occurrence_from_row).transpose()
2030}
2031
2032async fn load_projected_occurrence(
2033    conn: &mut SqliteConnection,
2034    workspace_id: &WorkspaceId,
2035    series_id: &RecurrenceSeriesId,
2036) -> Result<Option<RecurrenceOccurrence>> {
2037    let row = sqlx::query(
2038        "SELECT workspace_id, series_id, slot_on, task_id, outcome, resolved_at,
2039                outcome_change_id, projection_state, archived_at
2040         FROM recurrence_occurrences
2041         WHERE workspace_id = ? AND series_id = ? AND projection_state = 'projected'",
2042    )
2043    .bind(workspace_id)
2044    .bind(series_id)
2045    .fetch_optional(&mut *conn)
2046    .await?;
2047    row.as_ref().map(recurrence_occurrence_from_row).transpose()
2048}
2049
2050async fn load_occurrence_for_task(
2051    conn: &mut SqliteConnection,
2052    workspace_id: &WorkspaceId,
2053    task_id: &TaskId,
2054) -> Result<Option<RecurrenceOccurrence>> {
2055    let row = sqlx::query(
2056        "SELECT workspace_id, series_id, slot_on, task_id, outcome, resolved_at,
2057                outcome_change_id, projection_state, archived_at
2058         FROM recurrence_occurrences WHERE workspace_id = ? AND task_id = ?",
2059    )
2060    .bind(workspace_id)
2061    .bind(task_id)
2062    .fetch_optional(&mut *conn)
2063    .await?;
2064    row.as_ref().map(recurrence_occurrence_from_row).transpose()
2065}
2066
2067async fn lifecycle_conflict_exists(
2068    conn: &mut SqliteConnection,
2069    workspace_id: &WorkspaceId,
2070    series_id: &RecurrenceSeriesId,
2071) -> Result<bool> {
2072    for field in ["state", "stopped_at"] {
2073        if entity_conflict_exists(
2074            conn,
2075            workspace_id,
2076            MutableEntityType::RecurrenceSeries,
2077            series_id.as_str(),
2078            field,
2079        )
2080        .await?
2081        {
2082            return Ok(true);
2083        }
2084    }
2085    Ok(false)
2086}
2087
2088async fn ensure_no_series_conflict(
2089    conn: &mut SqliteConnection,
2090    workspace_id: &WorkspaceId,
2091    series_id: &RecurrenceSeriesId,
2092    field: &str,
2093) -> Result<()> {
2094    ensure!(
2095        !entity_conflict_exists(
2096            conn,
2097            workspace_id,
2098            MutableEntityType::RecurrenceSeries,
2099            series_id.as_str(),
2100            field,
2101        )
2102        .await?,
2103        CoreError::open_conflict(format!(
2104            "error recurrence-conflicted-field series_id={series_id} field={field}"
2105        ))
2106    );
2107    Ok(())
2108}
2109
2110pub async fn resolve_recurrence_ref(
2111    conn: &mut SqliteConnection,
2112    workspace: &Workspace,
2113    input: &str,
2114) -> Result<RecurrenceSeries> {
2115    let (hint, suffix) = split_ref(input);
2116    if suffix.len() < 3 {
2117        return Err(CoreError::validation(format!(
2118            "error recurrence-ref-too-short input={input} minimum=3"
2119        ))
2120        .into());
2121    }
2122    let series_rows = sqlx::query(
2123        "SELECT workspace_id, id, title, description, project_id, priority, initial_status,
2124                frequency, interval, weekdays, timezone, start_on, available_local_time,
2125                due_policy, state, stopped_at, created_at, updated_at, deleted
2126         FROM recurrence_series WHERE workspace_id = ? AND id LIKE ? || '%' ORDER BY id",
2127    )
2128    .bind(&workspace.id)
2129    .bind(&suffix)
2130    .fetch_all(&mut *conn)
2131    .await?;
2132    let mut series = series_rows
2133        .iter()
2134        .map(recurrence_series_from_row)
2135        .collect::<Result<Vec<_>>>()?;
2136    if hint
2137        .as_deref()
2138        .is_some_and(|value| value != SERIES_REF_PREFIX)
2139    {
2140        series.clear();
2141    }
2142    if series.len() == 1 {
2143        return Ok(series.remove(0));
2144    }
2145    if series.len() > 1 {
2146        return Err(
2147            CoreError::validation(format!("error ambiguous-recurrence-ref input={input}")).into(),
2148        );
2149    }
2150    let task = crate::refs::resolve_task_ref_in_workspace(conn, workspace, input).await?;
2151    let occurrence = load_occurrence_for_task(conn, &workspace.id, &task.id)
2152        .await?
2153        .context("error task-is-not-recurring")?;
2154    load_series(conn, &workspace.id, &occurrence.series_id).await
2155}
2156
2157async fn recurrence_series_ref(
2158    conn: &mut SqliteConnection,
2159    workspace_id: &WorkspaceId,
2160    series_id: &RecurrenceSeriesId,
2161) -> Result<String> {
2162    let ids = sqlx::query_scalar::<_, RecurrenceSeriesId>(
2163        "SELECT id FROM recurrence_series WHERE workspace_id = ? ORDER BY id",
2164    )
2165    .bind(workspace_id)
2166    .fetch_all(&mut *conn)
2167    .await?;
2168    ensure!(
2169        ids.iter().any(|candidate| candidate == series_id),
2170        CoreError::not_found(format!(
2171            "error recurrence-series-not-found series_id={series_id}"
2172        ))
2173    );
2174    Ok(recurrence_series_display_ref(series_id, &ids))
2175}
2176
2177fn split_ref(input: &str) -> (Option<String>, String) {
2178    let (hint, suffix) = input
2179        .split_once('-')
2180        .map_or((None, input), |(hint, suffix)| (Some(hint), suffix));
2181    let suffix = suffix
2182        .chars()
2183        .filter(|value| value.is_ascii_alphanumeric())
2184        .map(|value| match value.to_ascii_uppercase() {
2185            'O' => '0',
2186            'I' | 'L' => '1',
2187            value => value,
2188        })
2189        .collect();
2190    (hint.map(|value| value.to_ascii_uppercase()), suffix)
2191}
2192
2193async fn ensure_change_is_latest_series_transition(
2194    conn: &mut SqliteConnection,
2195    workspace_id: &WorkspaceId,
2196    series_id: &RecurrenceSeriesId,
2197    change_id: &str,
2198) -> Result<()> {
2199    let latest: Option<String> = sqlx::query_scalar(
2200        "SELECT change_id FROM changes
2201         WHERE entity_type = 'recurrence_series' AND entity_id = ?
2202         AND json_extract(payload, '$.workspace_id') = ?
2203         AND op_type != 'project_recurrence_occurrence'
2204         ORDER BY local_seq DESC LIMIT 1",
2205    )
2206    .bind(series_id)
2207    .bind(workspace_id.as_str())
2208    .fetch_optional(&mut *conn)
2209    .await?;
2210    ensure!(
2211        latest.as_deref() == Some(change_id),
2212        "error recurrence-undo-later-operation"
2213    );
2214    Ok(())
2215}
2216
2217async fn ensure_change_unsynced(conn: &mut SqliteConnection, change_id: &str) -> Result<()> {
2218    let server_seq: Option<i64> =
2219        sqlx::query_scalar("SELECT server_seq FROM changes WHERE change_id = ?")
2220            .bind(change_id)
2221            .fetch_one(&mut *conn)
2222            .await?;
2223    ensure!(server_seq.is_none(), "error recurrence-undo-already-synced");
2224    Ok(())
2225}
2226
2227async fn ensure_successor_untouched(
2228    conn: &mut SqliteConnection,
2229    workspace_id: &WorkspaceId,
2230    series: &RecurrenceSeries,
2231    occurrence: &RecurrenceOccurrence,
2232) -> Result<()> {
2233    let task_id = occurrence
2234        .task_id
2235        .as_ref()
2236        .context("error recurrence-undo-successor-missing-task")?;
2237    let identity = derive_occurrence_identity(
2238        workspace_id,
2239        &series.id,
2240        &series.schedule(),
2241        occurrence.slot_on,
2242    )?;
2243    ensure!(
2244        task_id == &identity.task_id,
2245        "error recurrence-undo-successor-changed"
2246    );
2247    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2248    let labels = load_series_labels(conn, workspace_id, &series.id).await?;
2249    let metadata = load_series_metadata(conn, workspace_id, &series.id).await?;
2250    let slot = slot_values(&series.schedule(), occurrence.slot_on)?;
2251    verify_materialized_occurrence(
2252        conn, &workspace, series, &labels, &metadata, &slot, &identity, occurrence,
2253    )
2254    .await
2255    .map_err(|_| anyhow::anyhow!("error recurrence-undo-successor-touched"))?;
2256    ensure_change_unsynced(conn, &identity.task_change_id).await?;
2257    ensure_change_unsynced(conn, &identity.occurrence_change_id).await?;
2258    let extra_changes: i64 = sqlx::query_scalar(
2259        "SELECT count(*) FROM changes
2260         WHERE entity_type = 'task' AND entity_id = ? AND change_id != ?",
2261    )
2262    .bind(task_id)
2263    .bind(&identity.task_change_id)
2264    .fetch_one(&mut *conn)
2265    .await?;
2266    let notes: i64 =
2267        sqlx::query_scalar("SELECT count(*) FROM notes WHERE workspace_id = ? AND task_id = ?")
2268            .bind(workspace_id)
2269            .bind(task_id)
2270            .fetch_one(&mut *conn)
2271            .await?;
2272    let attachments: i64 = sqlx::query_scalar(
2273        "SELECT count(*) FROM task_attachments WHERE workspace_id = ? AND task_id = ?",
2274    )
2275    .bind(workspace_id)
2276    .bind(task_id)
2277    .fetch_one(&mut *conn)
2278    .await?;
2279    let dependencies: i64 = sqlx::query_scalar(
2280        "SELECT count(*) FROM task_dependencies
2281         WHERE workspace_id = ? AND (task_id = ? OR depends_on_task_id = ?)",
2282    )
2283    .bind(workspace_id)
2284    .bind(task_id)
2285    .bind(task_id)
2286    .fetch_one(&mut *conn)
2287    .await?;
2288    let epics: i64 = sqlx::query_scalar(
2289        "SELECT count(*) FROM task_epic_links
2290         WHERE workspace_id = ? AND (child_task_id = ? OR epic_task_id = ?)",
2291    )
2292    .bind(workspace_id)
2293    .bind(task_id)
2294    .bind(task_id)
2295    .fetch_one(&mut *conn)
2296    .await?;
2297    let related: i64 = sqlx::query_scalar(
2298        "SELECT count(*) FROM task_related_links
2299         WHERE workspace_id = ? AND (task_a_id = ? OR task_b_id = ?)",
2300    )
2301    .bind(workspace_id)
2302    .bind(task_id)
2303    .bind(task_id)
2304    .fetch_one(&mut *conn)
2305    .await?;
2306    ensure!(
2307        extra_changes == 0
2308            && notes == 0
2309            && attachments == 0
2310            && dependencies == 0
2311            && epics == 0
2312            && related == 0,
2313        "error recurrence-undo-successor-touched"
2314    );
2315    Ok(())
2316}
2317
2318async fn remove_materialized_occurrence(
2319    conn: &mut SqliteConnection,
2320    workspace_id: &WorkspaceId,
2321    series: &RecurrenceSeries,
2322    occurrence: &RecurrenceOccurrence,
2323) -> Result<()> {
2324    let task_id = occurrence
2325        .task_id
2326        .as_ref()
2327        .expect("verified successor has task");
2328    let identity = derive_occurrence_identity(
2329        workspace_id,
2330        &series.id,
2331        &series.schedule(),
2332        occurrence.slot_on,
2333    )?;
2334    sqlx::query(
2335        "DELETE FROM recurrence_occurrences
2336         WHERE workspace_id = ? AND series_id = ? AND slot_on = ?",
2337    )
2338    .bind(workspace_id)
2339    .bind(&series.id)
2340    .bind(occurrence.slot_on.format("%Y-%m-%d").to_string())
2341    .execute(&mut *conn)
2342    .await?;
2343    sqlx::query("DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ?")
2344        .bind(workspace_id)
2345        .bind(task_id)
2346        .execute(&mut *conn)
2347        .await?;
2348    sqlx::query(
2349        "DELETE FROM field_versions
2350         WHERE workspace_id = ? AND entity_type = 'task' AND entity_id = ?",
2351    )
2352    .bind(workspace_id)
2353    .bind(task_id)
2354    .execute(&mut *conn)
2355    .await?;
2356    sqlx::query("DELETE FROM tasks WHERE workspace_id = ? AND id = ?")
2357        .bind(workspace_id)
2358        .bind(task_id)
2359        .execute(&mut *conn)
2360        .await?;
2361    sqlx::query("DELETE FROM changes WHERE change_id IN (?, ?)")
2362        .bind(&identity.task_change_id)
2363        .bind(&identity.occurrence_change_id)
2364        .execute(&mut *conn)
2365        .await?;
2366    Ok(())
2367}
2368
2369fn retryable_reconcile_error(error: &anyhow::Error) -> bool {
2370    error
2371        .downcast_ref::<sqlx::Error>()
2372        .and_then(sqlx::Error::as_database_error)
2373        .and_then(|error| error.code())
2374        .is_some_and(|code| matches!(code.as_ref(), "5" | "6" | "19"))
2375}
2376
2377pub(crate) fn timestamp_strictly_after(earlier: &str, candidate: &str) -> Result<String> {
2378    if candidate > earlier {
2379        return Ok(candidate.to_string());
2380    }
2381    let earlier = DateTime::parse_from_rfc3339(earlier)?.with_timezone(&Utc);
2382    let adjusted = earlier
2383        .checked_add_signed(chrono::Duration::seconds(1))
2384        .context("recurrence lifecycle timestamp is out of range")?;
2385    Ok(format_utc(adjusted))
2386}
2387
2388fn format_local_time(value: Option<NaiveTime>) -> String {
2389    value
2390        .map(|value| value.format("%H:%M:%S").to_string())
2391        .unwrap_or_default()
2392}
2393
2394fn format_utc(value: DateTime<Utc>) -> String {
2395    value.format("%Y-%m-%dT%H:%M:%SZ").to_string()
2396}
2397
2398fn utc_now() -> Result<DateTime<Utc>> {
2399    Ok(now_utc())
2400}