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