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