//! Status-token repository methods for SqliteTaskRepository. //! //! A task carries an ordered list of status tokens (typed at-a-glance markers); at //! most one is the `is_primary`, the token that resolved it. Token ids are //! deterministic in `(task_id, kind, reference)` (see //! [`StatusToken::deterministic_id`]), so recording a token is idempotent, locally //! via `ON CONFLICT(id)` and across devices via the sync changelog's id-keyed upsert. use sqlx::SqlitePool; use std::collections::HashMap; use goingson_core::{ CoreError, DbValue, ParseableEnum, Result, StatusToken, StatusTokenId, TaskId, TokenState, UserId, }; use crate::utils::{bind_placeholders, parse_uuid}; /// Row struct for task_status_tokens from SQLite. #[derive(Debug, Clone, sqlx::FromRow)] pub(crate) struct StatusTokenRow { pub id: String, pub task_id: String, pub kind: String, pub reference: String, pub state: String, pub is_primary: i32, pub position: i32, } impl TryFrom for StatusToken { type Error = CoreError; fn try_from(row: StatusTokenRow) -> std::result::Result { Ok(StatusToken { id: parse_uuid(&row.id)?.into(), task_id: parse_uuid(&row.task_id)?.into(), kind: row.kind, reference: row.reference, state: TokenState::from_str_or_default(&row.state), is_primary: row.is_primary != 0, position: row.position, }) } } /// Batch-fetch status tokens for multiple tasks by their IDs. pub(crate) async fn get_tokens_for_tasks( pool: &SqlitePool, task_ids: &[String], ) -> Result>> { if task_ids.is_empty() { return Ok(HashMap::new()); } let query = format!( r" SELECT id, task_id, kind, reference, state, is_primary, position FROM task_status_tokens WHERE task_id IN ({}) ORDER BY position ASC, created_at ASC ", bind_placeholders(task_ids.len()) ); let mut q = sqlx::query_as::<_, StatusTokenRow>(sqlx::AssertSqlSafe(query.as_str())); for id in task_ids { q = q.bind(id); } let rows = q.fetch_all(pool).await.map_err(CoreError::database)?; let mut map: HashMap> = HashMap::new(); for row in rows { let token = StatusToken::try_from(row)?; map.entry(token.task_id).or_default().push(token); } Ok(map) } /// Get all status tokens for a single task, in append order. pub(crate) async fn get_tokens_for_task( pool: &SqlitePool, task_id: TaskId, ) -> Result> { let rows = sqlx::query_as::<_, StatusTokenRow>( r" SELECT id, task_id, kind, reference, state, is_primary, position FROM task_status_tokens WHERE task_id = ? ORDER BY position ASC, created_at ASC ", ) .bind(task_id.to_string()) .fetch_all(pool) .await .map_err(CoreError::database)?; rows.into_iter().map(StatusToken::try_from).collect() } /// Record (upsert) a status token on a task (verifies task ownership). /// /// Idempotent in `(task_id, kind, reference)` via the deterministic id: a re-record /// updates `state`/`is_primary` in place. When `is_primary` is set, any prior primary /// flag on the task is cleared first so at most one token is ever primary. Returns /// `None` when the task is missing or not owned by `user_id`. pub(crate) async fn record_token( pool: &SqlitePool, task_id: TaskId, user_id: UserId, kind: &str, reference: &str, state: TokenState, is_primary: bool, ) -> Result> { let mut tx = pool.begin().await.map_err(CoreError::database)?; let task_exists: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM tasks WHERE id = ? AND user_id = ?") .bind(task_id.to_string()) .bind(user_id.to_string()) .fetch_one(&mut *tx) .await .map_err(CoreError::database)?; if task_exists.0 == 0 { return Ok(None); } // At most one primary per task: clear the flag on every other token first. The // deterministic id below means the current token, if it already exists, is the // one row this WHERE would also touch; harmless, its flag is set by the upsert. if is_primary { sqlx::query( "UPDATE task_status_tokens SET is_primary = 0 WHERE task_id = ? AND is_primary = 1", ) .bind(task_id.to_string()) .execute(&mut *tx) .await .map_err(CoreError::database)?; } let id = StatusToken::deterministic_id(task_id, kind, reference); let primary_int = i32::from(is_primary); // Insert with the next position, or, if this token was already recorded, keep its // position and update state/is_primary. Position is assigned inside the statement // so a concurrent record can't race the MAX (SQLite serializes writers). let (position,): (i32,) = sqlx::query_as( r" INSERT INTO task_status_tokens (id, task_id, kind, reference, state, is_primary, position, group_id) SELECT ?, ?, ?, ?, ?, ?, COALESCE(MAX(position), -1) + 1, (SELECT group_id FROM tasks WHERE id = ?) FROM task_status_tokens WHERE task_id = ? ON CONFLICT(id) DO UPDATE SET state = excluded.state, is_primary = excluded.is_primary RETURNING position ", ) .bind(id.to_string()) .bind(task_id.to_string()) .bind(kind) .bind(reference) .bind(state.db_value()) .bind(primary_int) // Inherit the parent task's group scope. .bind(task_id.to_string()) .bind(task_id.to_string()) .fetch_one(&mut *tx) .await .map_err(CoreError::database)?; tx.commit().await.map_err(CoreError::database)?; Ok(Some(StatusToken { id, task_id, kind: kind.to_string(), reference: reference.to_string(), state, is_primary, position, })) } /// Delete a status token by id, scoped to the user's own tasks. pub(crate) async fn delete_token( pool: &SqlitePool, token_id: StatusTokenId, user_id: UserId, ) -> Result { let result = sqlx::query( r" DELETE FROM task_status_tokens WHERE id = ? AND task_id IN (SELECT id FROM tasks WHERE user_id = ?) ", ) .bind(token_id.to_string()) .bind(user_id.to_string()) .execute(pool) .await .map_err(CoreError::database)?; Ok(result.rows_affected() > 0) }