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