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#[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}