//! Task writes: create, restore, update, the bulk field setters, and delete. use goingson_core::{ CoreError, DbValue, NewTask, ParseableEnum, Priority, ProjectId, Result, Task, TaskId, TaskStatus, UpdateTask, UserId, calculate_urgency, }; use rusqlite::{Connection, params, params_from_iter}; use crate::utils::{ bind_placeholders, execute, format_datetime, format_datetime_now, format_datetime_opt, parse_datetime, parse_tags, query_opt, }; use super::fetch::{get_task_by_id, get_task_update_context}; /// Re-derive group scope for the rows hanging off a task, after the task itself /// changed parent. /// /// Scope on these tables is derived from the task at INSERT, so a task that moves /// between a shared and a personal project leaves its children carrying the old /// group. They replicate on their own changelog rows, so a stale child keeps /// leaking after the parent has been corrected. fn cascade_task_scope(tx: &rusqlite::Transaction<'_>, ids: &[TaskId]) -> Result<()> { if ids.is_empty() { return Ok(()); } let placeholders = bind_placeholders(ids.len()); for table in [ "subtasks", "annotations", "task_status_tokens", "time_sessions", "attachments", ] { let sql = format!( "UPDATE {table} SET group_id = (SELECT group_id FROM tasks WHERE id = {table}.task_id) \ WHERE task_id IN ({placeholders})" ); execute( tx, &sql, params_from_iter(ids.iter().map(std::string::ToString::to_string)), )?; } Ok(()) } pub(super) fn create(conn: &Connection, user_id: UserId, task: &NewTask) -> Result { let id = TaskId::new(); let now = format_datetime_now(); let due_str = format_datetime_opt(task.due); let scheduled_start_str = format_datetime_opt(task.scheduled_start); let tags_json = serde_json::to_string(&task.tags).unwrap_or_else(|_| "[]".to_string()); execute( conn, r" 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT group_id FROM projects WHERE id = ?)) ", params![ id.to_string(), user_id.to_string(), task.project_id.map(|p| p.to_string()), task.contact_id.map(|c| c.to_string()), task.milestone_id.map(|m| m.to_string()), &task.title, &task.description, task.priority.db_value(), &due_str, &tags_json, task.recurrence.db_value(), task.recurrence_rule .as_ref() .map(|r| serde_json::to_string(r).unwrap_or_default()), task.urgency, task.source_email_id.map(|e| e.to_string()), &scheduled_start_str, task.scheduled_duration, task.estimated_minutes, &now, // Inherit the project's group scope so a task created in a shared // project joins the group atomically (a post-insert UPDATE would // double-write the changelog and mis-route the row). task.project_id.map(|p| p.to_string()), ], )?; get_task_by_id(conn, id, user_id)? .ok_or_else(|| CoreError::internal("Failed to retrieve created task")) } pub(super) fn restore(conn: &Connection, user_id: UserId, task: &Task) -> Result<()> { let tags_json = serde_json::to_string(&task.tags).unwrap_or_else(|_| "[]".to_string()); let recurrence_rule_json = task .recurrence_rule .as_ref() .map(|r| serde_json::to_string(r).unwrap_or_default()); execute( conn, r" INSERT OR IGNORE INTO tasks ( id, user_id, project_id, contact_id, milestone_id, title, description, status, priority, due, tags, urgency, recurrence, recurrence_rule, recurrence_parent_id, source_email_id, snoozed_until, waiting_for_response, waiting_since, expected_response_date, scheduled_start, scheduled_duration, estimated_minutes, actual_minutes, created_at, completed_at, is_focus, focus_set_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ", params![ task.id.to_string(), user_id.to_string(), task.project_id.map(|p| p.to_string()), task.contact_id.map(|c| c.to_string()), task.milestone_id.map(|m| m.to_string()), &task.title, &task.description, task.status.db_value(), task.priority.db_value(), format_datetime_opt(task.due), &tags_json, task.urgency, task.recurrence.db_value(), &recurrence_rule_json, task.recurrence_parent_id.map(|p| p.to_string()), task.source_email_id.map(|e| e.to_string()), format_datetime_opt(task.snoozed_until), i32::from(task.waiting_for_response), format_datetime_opt(task.waiting_since), format_datetime_opt(task.expected_response_date), format_datetime_opt(task.scheduled_start), task.scheduled_duration, task.estimated_minutes, task.actual_minutes, format_datetime(&task.created_at), task.completed_at.map(|d| format_datetime(&d)), i32::from(task.is_focus), task.focus_set_at.map(|d| format_datetime(&d)) ], )?; Ok(()) } pub(super) fn update( conn: &mut Connection, id: TaskId, user_id: UserId, task: &UpdateTask, ) -> Result> { let due_str = format_datetime_opt(task.due); let scheduled_start_str = format_datetime_opt(task.scheduled_start); let tags_json = serde_json::to_string(&task.tags).unwrap_or_else(|_| "[]".to_string()); // completed_at tracks the status transition, not every edit: stamp it when // a task first becomes Completed, preserve it while it stays Completed, // clear it only when it actually leaves Completed, and otherwise leave the // stored value untouched. Lightweight context query (no sub-queries). // Read the current completion context and write the update in one // transaction: the completed_at derivation is a read-modify-write, so a // concurrent complete()/remote-apply landing between the two checkouts // would otherwise be silently clobbered. let tx = conn.transaction().map_err(CoreError::database)?; let ctx = get_task_update_context(&tx, id, user_id)?; let was_completed = ctx .as_ref() .is_some_and(|c| c.status == TaskStatus::Completed); let prior_completed_at = ctx .as_ref() .and_then(|c| c.completed_at.as_ref().map(format_datetime)); let completed_at_str: Option = match task.status { TaskStatus::Completed if was_completed => prior_completed_at, // stays completed TaskStatus::Completed => Some(format_datetime_now()), // transition in _ if was_completed => None, // transition out: clear _ => prior_completed_at, // stays non-completed }; let affected = execute( &tx, r" UPDATE tasks 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 = ? WHERE id = ? AND user_id = ? ", params![ task.project_id.map(|p| p.to_string()), // Group scope is derived from the parent project, and re-parenting is // the one path that used to change the parent without re-deriving it: // a task moved out of a shared project kept the old group_id, so the // changelog trigger (which reads NEW.group_id) went on replicating it // to the group the user believed they had taken it back from. Derived // in-statement for the same reason every create path does it, a // follow-up UPDATE would double-write the changelog. task.project_id.map(|p| p.to_string()), task.contact_id.map(|c| c.to_string()), task.milestone_id.map(|m| m.to_string()), &task.title, &task.description, task.status.db_value(), task.priority.db_value(), &due_str, &tags_json, task.recurrence.db_value(), task.recurrence_rule .as_ref() .map(|r| serde_json::to_string(r).unwrap_or_default()), task.urgency, &scheduled_start_str, task.scheduled_duration, task.estimated_minutes, &completed_at_str, id.to_string(), user_id.to_string(), ], )?; if affected > 0 { cascade_task_scope(&tx, &[id])?; // `update` is the path a status reaches Completed or Deleted by // hand, either of which satisfies this task's outgoing edges. crate::repository::dependency_repo::recompute(&tx, user_id)?; } tx.commit().map_err(CoreError::database)?; if affected > 0 { get_task_by_id(conn, id, user_id) } else { Ok(None) } } pub(super) fn bulk_set_project( conn: &mut Connection, user_id: UserId, ids: &[TaskId], project_id: Option, ) -> Result { if ids.is_empty() { return Ok(0); } let placeholders = bind_placeholders(ids.len()); let sql = format!( "UPDATE tasks SET project_id = ?, group_id = (SELECT group_id FROM projects WHERE id = ?) \ WHERE user_id = ? AND id IN ({placeholders})" ); let mut binds: Vec> = Vec::with_capacity(ids.len() + 3); binds.push(project_id.map(|p| p.to_string())); binds.push(project_id.map(|p| p.to_string())); binds.push(Some(user_id.to_string())); binds.extend(ids.iter().map(|id| Some(id.to_string()))); // One transaction: a bulk move drags a whole selection across the sharing // boundary, so the children must not be able to settle at a different scope // than their parents. let tx = conn.transaction().map_err(CoreError::database)?; let result = execute(&tx, &sql, params_from_iter(binds))?; cascade_task_scope(&tx, ids)?; tx.commit().map_err(CoreError::database)?; Ok(result) } pub(super) fn bulk_set_priority( conn: &mut Connection, user_id: UserId, ids: &[TaskId], priority: &Priority, ) -> Result { if ids.is_empty() { return Ok(0); } // Priority feeds urgency, so each task's urgency must be recomputed. Do the // whole batch in one transaction (one connection) instead of N command // round-trips (Perf S4). let tx = conn.transaction().map_err(CoreError::database)?; let mut affected = 0usize; for id in ids { let row: Option<(String, Option, String, String)> = query_opt( &tx, "SELECT status, due, created_at, tags FROM tasks WHERE id = ? AND user_id = ?", params![id.to_string(), user_id.to_string()], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), )?; let Some((status_s, due_s, created_s, tags_s)) = row else { continue; }; let status = TaskStatus::from_str_or_default(&status_s); let due = due_s.as_deref().map(parse_datetime).transpose()?; let created = parse_datetime(&created_s)?; let tags = parse_tags(&tags_s); let urgency = calculate_urgency(priority, &status, due.as_ref(), &created, &tags); let result = execute( &tx, "UPDATE tasks SET priority = ?, urgency = ? WHERE id = ? AND user_id = ?", params![ priority.db_value(), urgency, id.to_string(), user_id.to_string() ], )?; affected += result; } tx.commit().map_err(CoreError::database)?; Ok(affected) } pub(super) fn delete(conn: &Connection, id: TaskId, user_id: UserId) -> Result { // completed_at follows the status transition, the same rule update() // spells out at the top of this file: leaving Completed clears it. This // used to set status alone, so deleting a completed task left it carrying // the completion time of a status it no longer had. // // Expressed as a CASE rather than update()'s read-modify-write because the // new status is fixed here, so the derivation is a function of the stored // row and SQL can do it. One statement is also strictly safer than a // transaction around two: there is no window for a concurrent complete() // to land between the read and the write. let result = execute( conn, r" UPDATE tasks SET status = 'Deleted', completed_at = CASE WHEN status = 'Completed' THEN NULL ELSE completed_at END WHERE id = ? AND user_id = ? ", params![id.to_string(), user_id.to_string()], )?; // A deleted task stops gating its dependents (see // `GraphTask::is_satisfied`), so the cached graph columns move with it. if result > 0 { crate::repository::dependency_repo::recompute(conn, user_id)?; } Ok(result > 0) }