Skip to main content

max / goingson

13.5 KB · 337 lines History Blame Raw
1 //! Task writes: create, restore, update, the bulk field setters, and delete.
2
3 use goingson_core::{
4 CoreError, DbValue, NewTask, ParseableEnum, Priority, ProjectId, Result, Task, TaskId,
5 TaskStatus, UpdateTask, UserId, calculate_urgency,
6 };
7 use rusqlite::{Connection, params, params_from_iter};
8
9 use crate::utils::{
10 bind_placeholders, execute, format_datetime, format_datetime_now, format_datetime_opt,
11 parse_datetime, parse_tags, query_opt,
12 };
13
14 use super::fetch::{get_task_by_id, get_task_update_context};
15
16 /// Re-derive group scope for the rows hanging off a task, after the task itself
17 /// changed parent.
18 ///
19 /// Scope on these tables is derived from the task at INSERT, so a task that moves
20 /// between a shared and a personal project leaves its children carrying the old
21 /// group. They replicate on their own changelog rows, so a stale child keeps
22 /// leaking after the parent has been corrected.
23 fn cascade_task_scope(tx: &rusqlite::Transaction<'_>, ids: &[TaskId]) -> Result<()> {
24 if ids.is_empty() {
25 return Ok(());
26 }
27 let placeholders = bind_placeholders(ids.len());
28 for table in [
29 "subtasks",
30 "annotations",
31 "task_status_tokens",
32 "time_sessions",
33 "attachments",
34 ] {
35 let sql = format!(
36 "UPDATE {table} SET group_id = (SELECT group_id FROM tasks WHERE id = {table}.task_id) \
37 WHERE task_id IN ({placeholders})"
38 );
39 execute(
40 tx,
41 &sql,
42 params_from_iter(ids.iter().map(std::string::ToString::to_string)),
43 )?;
44 }
45 Ok(())
46 }
47
48 pub(super) fn create(conn: &Connection, user_id: UserId, task: &NewTask) -> Result<Task> {
49 let id = TaskId::new();
50 let now = format_datetime_now();
51 let due_str = format_datetime_opt(task.due);
52 let scheduled_start_str = format_datetime_opt(task.scheduled_start);
53 let tags_json = serde_json::to_string(&task.tags).unwrap_or_else(|_| "[]".to_string());
54
55 execute(
56 conn,
57 r"
58 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, created_at, group_id)
59 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT group_id FROM projects WHERE id = ?))
60 ",
61 params![
62 id.to_string(),
63 user_id.to_string(),
64 task.project_id.map(|p| p.to_string()),
65 task.contact_id.map(|c| c.to_string()),
66 task.milestone_id.map(|m| m.to_string()),
67 &task.title,
68 &task.description,
69 task.priority.db_value(),
70 &due_str,
71 &tags_json,
72 task.recurrence.db_value(),
73 task.recurrence_rule
74 .as_ref()
75 .map(|r| serde_json::to_string(r).unwrap_or_default()),
76 task.urgency,
77 task.source_email_id.map(|e| e.to_string()),
78 &scheduled_start_str,
79 task.scheduled_duration,
80 task.estimated_minutes,
81 &now,
82 // Inherit the project's group scope so a task created in a shared
83 // project joins the group atomically (a post-insert UPDATE would
84 // double-write the changelog and mis-route the row).
85 task.project_id.map(|p| p.to_string()),
86 ],
87 )?;
88
89 get_task_by_id(conn, id, user_id)?
90 .ok_or_else(|| CoreError::internal("Failed to retrieve created task"))
91 }
92
93 pub(super) fn restore(conn: &Connection, user_id: UserId, task: &Task) -> Result<()> {
94 let tags_json = serde_json::to_string(&task.tags).unwrap_or_else(|_| "[]".to_string());
95 let recurrence_rule_json = task
96 .recurrence_rule
97 .as_ref()
98 .map(|r| serde_json::to_string(r).unwrap_or_default());
99
100 execute(
101 conn,
102 r"
103 INSERT OR IGNORE INTO tasks (
104 id, user_id, project_id, contact_id, milestone_id, title, description, status,
105 priority, due, tags, urgency, recurrence, recurrence_rule, recurrence_parent_id,
106 source_email_id, snoozed_until, waiting_for_response, waiting_since, expected_response_date,
107 scheduled_start, scheduled_duration, estimated_minutes, actual_minutes,
108 created_at, completed_at, is_focus, focus_set_at
109 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
110 ",
111 params![
112 task.id.to_string(),
113 user_id.to_string(),
114 task.project_id.map(|p| p.to_string()),
115 task.contact_id.map(|c| c.to_string()),
116 task.milestone_id.map(|m| m.to_string()),
117 &task.title,
118 &task.description,
119 task.status.db_value(),
120 task.priority.db_value(),
121 format_datetime_opt(task.due),
122 &tags_json,
123 task.urgency,
124 task.recurrence.db_value(),
125 &recurrence_rule_json,
126 task.recurrence_parent_id.map(|p| p.to_string()),
127 task.source_email_id.map(|e| e.to_string()),
128 format_datetime_opt(task.snoozed_until),
129 i32::from(task.waiting_for_response),
130 format_datetime_opt(task.waiting_since),
131 format_datetime_opt(task.expected_response_date),
132 format_datetime_opt(task.scheduled_start),
133 task.scheduled_duration,
134 task.estimated_minutes,
135 task.actual_minutes,
136 format_datetime(&task.created_at),
137 task.completed_at.map(|d| format_datetime(&d)),
138 i32::from(task.is_focus),
139 task.focus_set_at.map(|d| format_datetime(&d))
140 ],
141 )?;
142 Ok(())
143 }
144
145 pub(super) fn update(
146 conn: &mut Connection,
147 id: TaskId,
148 user_id: UserId,
149 task: &UpdateTask,
150 ) -> Result<Option<Task>> {
151 let due_str = format_datetime_opt(task.due);
152 let scheduled_start_str = format_datetime_opt(task.scheduled_start);
153 let tags_json = serde_json::to_string(&task.tags).unwrap_or_else(|_| "[]".to_string());
154
155 // completed_at tracks the status transition, not every edit: stamp it when
156 // a task first becomes Completed, preserve it while it stays Completed,
157 // clear it only when it actually leaves Completed, and otherwise leave the
158 // stored value untouched. Lightweight context query (no sub-queries).
159 // Read the current completion context and write the update in one
160 // transaction: the completed_at derivation is a read-modify-write, so a
161 // concurrent complete()/remote-apply landing between the two checkouts
162 // would otherwise be silently clobbered.
163 let tx = conn.transaction().map_err(CoreError::database)?;
164
165 let ctx = get_task_update_context(&tx, id, user_id)?;
166 let was_completed = ctx
167 .as_ref()
168 .is_some_and(|c| c.status == TaskStatus::Completed);
169 let prior_completed_at = ctx
170 .as_ref()
171 .and_then(|c| c.completed_at.as_ref().map(format_datetime));
172 let completed_at_str: Option<String> = match task.status {
173 TaskStatus::Completed if was_completed => prior_completed_at, // stays completed
174 TaskStatus::Completed => Some(format_datetime_now()), // transition in
175 _ if was_completed => None, // transition out: clear
176 _ => prior_completed_at, // stays non-completed
177 };
178
179 let affected = execute(
180 &tx,
181 r"
182 UPDATE tasks
183 SET project_id = ?, group_id = (SELECT group_id FROM projects WHERE id = ?), contact_id = ?, milestone_id = ?, title = ?, description = ?, status = ?, priority = ?, due = ?, tags = ?, recurrence = ?, recurrence_rule = ?, urgency = ?, scheduled_start = ?, scheduled_duration = ?, estimated_minutes = ?, completed_at = ?
184 WHERE id = ? AND user_id = ?
185 ",
186 params![
187 task.project_id.map(|p| p.to_string()),
188 // Group scope is derived from the parent project, and re-parenting is
189 // the one path that used to change the parent without re-deriving it:
190 // a task moved out of a shared project kept the old group_id, so the
191 // changelog trigger (which reads NEW.group_id) went on replicating it
192 // to the group the user believed they had taken it back from. Derived
193 // in-statement for the same reason every create path does it, a
194 // follow-up UPDATE would double-write the changelog.
195 task.project_id.map(|p| p.to_string()),
196 task.contact_id.map(|c| c.to_string()),
197 task.milestone_id.map(|m| m.to_string()),
198 &task.title,
199 &task.description,
200 task.status.db_value(),
201 task.priority.db_value(),
202 &due_str,
203 &tags_json,
204 task.recurrence.db_value(),
205 task.recurrence_rule
206 .as_ref()
207 .map(|r| serde_json::to_string(r).unwrap_or_default()),
208 task.urgency,
209 &scheduled_start_str,
210 task.scheduled_duration,
211 task.estimated_minutes,
212 &completed_at_str,
213 id.to_string(),
214 user_id.to_string(),
215 ],
216 )?;
217
218 if affected > 0 {
219 cascade_task_scope(&tx, &[id])?;
220 // `update` is the path a status reaches Completed or Deleted by
221 // hand, either of which satisfies this task's outgoing edges.
222 crate::repository::dependency_repo::recompute(&tx, user_id)?;
223 }
224 tx.commit().map_err(CoreError::database)?;
225
226 if affected > 0 {
227 get_task_by_id(conn, id, user_id)
228 } else {
229 Ok(None)
230 }
231 }
232
233 pub(super) fn bulk_set_project(
234 conn: &mut Connection,
235 user_id: UserId,
236 ids: &[TaskId],
237 project_id: Option<ProjectId>,
238 ) -> Result<usize> {
239 if ids.is_empty() {
240 return Ok(0);
241 }
242 let placeholders = bind_placeholders(ids.len());
243 let sql = format!(
244 "UPDATE tasks SET project_id = ?, group_id = (SELECT group_id FROM projects WHERE id = ?) \
245 WHERE user_id = ? AND id IN ({placeholders})"
246 );
247 let mut binds: Vec<Option<String>> = Vec::with_capacity(ids.len() + 3);
248 binds.push(project_id.map(|p| p.to_string()));
249 binds.push(project_id.map(|p| p.to_string()));
250 binds.push(Some(user_id.to_string()));
251 binds.extend(ids.iter().map(|id| Some(id.to_string())));
252 // One transaction: a bulk move drags a whole selection across the sharing
253 // boundary, so the children must not be able to settle at a different scope
254 // than their parents.
255 let tx = conn.transaction().map_err(CoreError::database)?;
256 let result = execute(&tx, &sql, params_from_iter(binds))?;
257 cascade_task_scope(&tx, ids)?;
258 tx.commit().map_err(CoreError::database)?;
259 Ok(result)
260 }
261
262 pub(super) fn bulk_set_priority(
263 conn: &mut Connection,
264 user_id: UserId,
265 ids: &[TaskId],
266 priority: &Priority,
267 ) -> Result<usize> {
268 if ids.is_empty() {
269 return Ok(0);
270 }
271 // Priority feeds urgency, so each task's urgency must be recomputed. Do the
272 // whole batch in one transaction (one connection) instead of N command
273 // round-trips (Perf S4).
274 let tx = conn.transaction().map_err(CoreError::database)?;
275 let mut affected = 0usize;
276 for id in ids {
277 let row: Option<(String, Option<String>, String, String)> = query_opt(
278 &tx,
279 "SELECT status, due, created_at, tags FROM tasks WHERE id = ? AND user_id = ?",
280 params![id.to_string(), user_id.to_string()],
281 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
282 )?;
283 let Some((status_s, due_s, created_s, tags_s)) = row else {
284 continue;
285 };
286 let status = TaskStatus::from_str_or_default(&status_s);
287 let due = due_s.as_deref().map(parse_datetime).transpose()?;
288 let created = parse_datetime(&created_s)?;
289 let tags = parse_tags(&tags_s);
290 let urgency = calculate_urgency(priority, &status, due.as_ref(), &created, &tags);
291 let result = execute(
292 &tx,
293 "UPDATE tasks SET priority = ?, urgency = ? WHERE id = ? AND user_id = ?",
294 params![
295 priority.db_value(),
296 urgency,
297 id.to_string(),
298 user_id.to_string()
299 ],
300 )?;
301 affected += result;
302 }
303 tx.commit().map_err(CoreError::database)?;
304 Ok(affected)
305 }
306
307 pub(super) fn delete(conn: &Connection, id: TaskId, user_id: UserId) -> Result<bool> {
308 // completed_at follows the status transition, the same rule update()
309 // spells out at the top of this file: leaving Completed clears it. This
310 // used to set status alone, so deleting a completed task left it carrying
311 // the completion time of a status it no longer had.
312 //
313 // Expressed as a CASE rather than update()'s read-modify-write because the
314 // new status is fixed here, so the derivation is a function of the stored
315 // row and SQL can do it. One statement is also strictly safer than a
316 // transaction around two: there is no window for a concurrent complete()
317 // to land between the read and the write.
318 let result = execute(
319 conn,
320 r"
321 UPDATE tasks
322 SET status = 'Deleted',
323 completed_at = CASE WHEN status = 'Completed' THEN NULL ELSE completed_at END
324 WHERE id = ? AND user_id = ?
325 ",
326 params![id.to_string(), user_id.to_string()],
327 )?;
328
329 // A deleted task stops gating its dependents (see
330 // `GraphTask::is_satisfied`), so the cached graph columns move with it.
331 if result > 0 {
332 crate::repository::dependency_repo::recompute(conn, user_id)?;
333 }
334
335 Ok(result > 0)
336 }
337