Skip to main content

max / goingson

6.2 KB · 165 lines History Blame Raw
1 //! Task completion: the status transitions that satisfy a task's outgoing
2 //! dependency edges, plus the recurring-instance rollover.
3
4 use goingson_core::{
5 CoreError, DbValue, MilestoneId, NewTask, Result, Task, TaskId, TaskStatus, UserId,
6 };
7 use rusqlite::{Connection, params};
8
9 use crate::utils::{execute, format_datetime_now, format_datetime_opt};
10
11 use super::fetch::get_task_by_id;
12
13 pub(super) fn start(conn: &Connection, id: TaskId, user_id: UserId) -> Result<bool> {
14 // Same derivation as delete(). The `status = 'Pending'` guard means the
15 // CASE cannot fire today, since a Pending task is not Completed; it is
16 // written out anyway so this site states the rule rather than relying on a
17 // guard elsewhere to make omitting it safe.
18 let result = execute(
19 conn,
20 r"
21 UPDATE tasks
22 SET status = 'Started',
23 completed_at = CASE WHEN status = 'Completed' THEN NULL ELSE completed_at END
24 WHERE id = ? AND user_id = ? AND status = 'Pending'
25 ",
26 params![id.to_string(), user_id.to_string()],
27 )?;
28
29 Ok(result > 0)
30 }
31
32 pub(super) fn complete(conn: &Connection, id: TaskId, user_id: UserId) -> Result<Option<Task>> {
33 let Some(task) = get_task_by_id(conn, id, user_id)? else {
34 return Ok(None);
35 };
36
37 if task.status == TaskStatus::Completed {
38 return Ok(None);
39 }
40
41 let now = format_datetime_now();
42
43 let result = execute(
44 conn,
45 "UPDATE tasks SET status = 'Completed', completed_at = ? WHERE id = ? AND user_id = ?",
46 params![&now, id.to_string(), user_id.to_string()],
47 )?;
48
49 if result == 0 {
50 return Ok(None);
51 }
52
53 // Completing a blocker is the ordinary way work becomes available, so
54 // the graph cache has to move here rather than on the next edge write.
55 crate::repository::dependency_repo::recompute(conn, user_id)?;
56
57 get_task_by_id(conn, id, user_id)
58 }
59
60 /// Mark a recurring task complete and, when a successor is given, insert it in
61 /// the same transaction.
62 ///
63 /// The caller has already read the task and rejected the already-completed case;
64 /// this is the write half only, so it never touches the pool.
65 pub(super) fn complete_recurring(
66 conn: &mut Connection,
67 task: &Task,
68 user_id: UserId,
69 next: Option<&NewTask>,
70 ) -> Result<(Option<Task>, Option<Task>)> {
71 let id = task.id;
72 let tx = conn.transaction().map_err(CoreError::database)?;
73
74 // Mark complete, but only if it is not already Completed. The single
75 // conditional UPDATE serializes on the write lock, so of two concurrent
76 // calls only the one that actually transitions the task matches a row;
77 // the loser affects zero rows and skips the next-instance insert. Without
78 // this both calls would pass the pre-txn guard (WAL snapshot isolation
79 // hides the other's uncommitted write) and each insert a duplicate.
80 let now = format_datetime_now();
81 let marked = execute(
82 &tx,
83 "UPDATE tasks SET status = 'Completed', completed_at = ? WHERE id = ? AND user_id = ? AND status != 'Completed'",
84 params![&now, id.to_string(), user_id.to_string()],
85 )?;
86
87 if marked == 0 {
88 // Another call completed it first (or it vanished); do not insert a
89 // second recurring instance.
90 tx.rollback().map_err(CoreError::database)?;
91 return Ok((None, None));
92 }
93
94 // Create next recurring instance if provided
95 let next_id = if let Some(new_task) = next {
96 let nid = TaskId::new();
97 let due_str = format_datetime_opt(new_task.due);
98 let scheduled_start_str = format_datetime_opt(new_task.scheduled_start);
99 let tags_json = serde_json::to_string(&new_task.tags).unwrap_or_else(|_| "[]".to_string());
100
101 execute(
102 &tx,
103 r"
104 INSERT INTO tasks (id, user_id, project_id, contact_id, milestone_id, title, description, priority, due, tags, recurrence, recurrence_rule, urgency, source_email_id, scheduled_start, scheduled_duration, estimated_minutes, recurrence_parent_id, created_at, group_id)
105 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT group_id FROM projects WHERE id = ?))
106 ",
107 params![
108 nid.to_string(),
109 user_id.to_string(),
110 new_task.project_id.map(|p| p.to_string()),
111 new_task.contact_id.map(|c| c.to_string()),
112 new_task.milestone_id.map(|m| m.to_string()),
113 &new_task.title,
114 &new_task.description,
115 new_task.priority.db_value(),
116 &due_str,
117 &tags_json,
118 new_task.recurrence.db_value(),
119 new_task
120 .recurrence_rule
121 .as_ref()
122 .map(|r| serde_json::to_string(r).unwrap_or_default()),
123 new_task.urgency,
124 new_task.source_email_id.map(|e| e.to_string()),
125 &scheduled_start_str,
126 new_task.scheduled_duration,
127 new_task.estimated_minutes,
128 new_task.recurrence_parent_id.map(|p| p.to_string()),
129 &now,
130 // Inherit the project's group scope (see the create() insert).
131 new_task.project_id.map(|p| p.to_string()),
132 ],
133 )?;
134
135 Some(nid)
136 } else {
137 None
138 };
139
140 // Inside the transaction: the completion and the re-scored graph land
141 // together, so no reader sees work that is neither done nor available.
142 crate::repository::dependency_repo::recompute(&tx, user_id)?;
143
144 tx.commit().map_err(CoreError::database)?;
145
146 // Fetch the completed task and new task (outside transaction, committed)
147 let completed = get_task_by_id(conn, id, user_id)?;
148 let next_task = match next_id {
149 Some(nid) => get_task_by_id(conn, nid, user_id)?,
150 None => None,
151 };
152
153 Ok((completed, next_task))
154 }
155
156 pub(super) fn count_incomplete_by_milestone(
157 conn: &Connection,
158 milestone_id: MilestoneId,
159 user_id: UserId,
160 ) -> Result<i64> {
161 let count: i64 = conn.query_row("SELECT COUNT(*) FROM tasks WHERE milestone_id = ? AND user_id = ? AND status != 'Deleted' AND status != 'Completed'", params![milestone_id.to_string(), user_id.to_string()], |row| row.get(0)).map_err(CoreError::database)?;
162
163 Ok(count)
164 }
165