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