//! Task completion: the status transitions that satisfy a task's outgoing //! dependency edges, plus the recurring-instance rollover. use goingson_core::{ CoreError, DbValue, MilestoneId, NewTask, Result, Task, TaskId, TaskStatus, UserId, }; use rusqlite::{Connection, params}; use crate::utils::{execute, format_datetime_now, format_datetime_opt}; use super::fetch::get_task_by_id; pub(super) fn start(conn: &Connection, id: TaskId, user_id: UserId) -> Result { // Same derivation as delete(). The `status = 'Pending'` guard means the // CASE cannot fire today, since a Pending task is not Completed; it is // written out anyway so this site states the rule rather than relying on a // guard elsewhere to make omitting it safe. let result = execute( conn, r" UPDATE tasks SET status = 'Started', completed_at = CASE WHEN status = 'Completed' THEN NULL ELSE completed_at END WHERE id = ? AND user_id = ? AND status = 'Pending' ", params![id.to_string(), user_id.to_string()], )?; Ok(result > 0) } pub(super) fn complete(conn: &Connection, id: TaskId, user_id: UserId) -> Result> { let Some(task) = get_task_by_id(conn, id, user_id)? else { return Ok(None); }; if task.status == TaskStatus::Completed { return Ok(None); } let now = format_datetime_now(); let result = execute( conn, "UPDATE tasks SET status = 'Completed', completed_at = ? WHERE id = ? AND user_id = ?", params![&now, id.to_string(), user_id.to_string()], )?; if result == 0 { return Ok(None); } // Completing a blocker is the ordinary way work becomes available, so // the graph cache has to move here rather than on the next edge write. crate::repository::dependency_repo::recompute(conn, user_id)?; get_task_by_id(conn, id, user_id) } /// Mark a recurring task complete and, when a successor is given, insert it in /// the same transaction. /// /// The caller has already read the task and rejected the already-completed case; /// this is the write half only, so it never touches the pool. pub(super) fn complete_recurring( conn: &mut Connection, task: &Task, user_id: UserId, next: Option<&NewTask>, ) -> Result<(Option, Option)> { let id = task.id; let tx = conn.transaction().map_err(CoreError::database)?; // Mark complete, but only if it is not already Completed. The single // conditional UPDATE serializes on the write lock, so of two concurrent // calls only the one that actually transitions the task matches a row; // the loser affects zero rows and skips the next-instance insert. Without // this both calls would pass the pre-txn guard (WAL snapshot isolation // hides the other's uncommitted write) and each insert a duplicate. let now = format_datetime_now(); let marked = execute( &tx, "UPDATE tasks SET status = 'Completed', completed_at = ? WHERE id = ? AND user_id = ? AND status != 'Completed'", params![&now, id.to_string(), user_id.to_string()], )?; if marked == 0 { // Another call completed it first (or it vanished); do not insert a // second recurring instance. tx.rollback().map_err(CoreError::database)?; return Ok((None, None)); } // Create next recurring instance if provided let next_id = if let Some(new_task) = next { let nid = TaskId::new(); let due_str = format_datetime_opt(new_task.due); let scheduled_start_str = format_datetime_opt(new_task.scheduled_start); let tags_json = serde_json::to_string(&new_task.tags).unwrap_or_else(|_| "[]".to_string()); execute( &tx, 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, recurrence_parent_id, created_at, group_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT group_id FROM projects WHERE id = ?)) ", params![ nid.to_string(), user_id.to_string(), new_task.project_id.map(|p| p.to_string()), new_task.contact_id.map(|c| c.to_string()), new_task.milestone_id.map(|m| m.to_string()), &new_task.title, &new_task.description, new_task.priority.db_value(), &due_str, &tags_json, new_task.recurrence.db_value(), new_task .recurrence_rule .as_ref() .map(|r| serde_json::to_string(r).unwrap_or_default()), new_task.urgency, new_task.source_email_id.map(|e| e.to_string()), &scheduled_start_str, new_task.scheduled_duration, new_task.estimated_minutes, new_task.recurrence_parent_id.map(|p| p.to_string()), &now, // Inherit the project's group scope (see the create() insert). new_task.project_id.map(|p| p.to_string()), ], )?; Some(nid) } else { None }; // Inside the transaction: the completion and the re-scored graph land // together, so no reader sees work that is neither done nor available. crate::repository::dependency_repo::recompute(&tx, user_id)?; tx.commit().map_err(CoreError::database)?; // Fetch the completed task and new task (outside transaction, committed) let completed = get_task_by_id(conn, id, user_id)?; let next_task = match next_id { Some(nid) => get_task_by_id(conn, nid, user_id)?, None => None, }; Ok((completed, next_task)) } pub(super) fn count_incomplete_by_milestone( conn: &Connection, milestone_id: MilestoneId, user_id: UserId, ) -> Result { 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)?; Ok(count) }