feat(tasks): persist list_id/effort/notes + update_task_cmd — close Task 2 metadata gap
This commit is contained in:
@@ -271,30 +271,43 @@ pub async fn delete_dictionary_entry(pool: &SqlitePool, id: i64) -> Result<()> {
|
||||
|
||||
// --- Task CRUD ---
|
||||
|
||||
/// Insert a task. `list_id` and `effort` are nullable (schema predates their
|
||||
/// UI surfacing); `notes` defaults to '' at the column level. Callers that
|
||||
/// want to set metadata at creation time pass `Some(...)`; omit for defaults.
|
||||
pub async fn insert_task(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
text: &str,
|
||||
bucket: &str,
|
||||
source_transcript_id: Option<&str>,
|
||||
list_id: Option<&str>,
|
||||
effort: Option<&str>,
|
||||
) -> Result<()> {
|
||||
sqlx::query("INSERT INTO tasks (id, text, bucket, source_transcript_id) VALUES (?, ?, ?, ?)")
|
||||
.bind(id)
|
||||
.bind(text)
|
||||
.bind(bucket)
|
||||
.bind(source_transcript_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Insert task failed: {e}")))?;
|
||||
sqlx::query(
|
||||
"INSERT INTO tasks (id, text, bucket, source_transcript_id, list_id, effort) \
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(text)
|
||||
.bind(bucket)
|
||||
.bind(source_transcript_id)
|
||||
.bind(list_id)
|
||||
.bind(effort)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Insert task failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
|
||||
let rows =
|
||||
sqlx::query("SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id, parent_task_id FROM tasks WHERE parent_task_id IS NULL ORDER BY created_at DESC")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?;
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
|
||||
source_transcript_id, parent_task_id \
|
||||
FROM tasks WHERE parent_task_id IS NULL ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
@@ -304,8 +317,8 @@ pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
|
||||
|
||||
pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRow>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, text, bucket, list_id, effort, done, done_at, created_at, \
|
||||
source_transcript_id, parent_task_id FROM tasks WHERE id = ?"
|
||||
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
|
||||
source_transcript_id, parent_task_id FROM tasks WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
@@ -315,6 +328,46 @@ pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRo
|
||||
Ok(row.map(task_row_from))
|
||||
}
|
||||
|
||||
/// Patch-style update. Each `Option` that is `Some` overwrites the column;
|
||||
/// `None` preserves the existing value via COALESCE. Returns the refreshed
|
||||
/// row, or an error if `id` does not exist after the UPDATE.
|
||||
///
|
||||
/// Intentionally does not touch `done` / `done_at` — those go through the
|
||||
/// dedicated `complete_task` / `uncomplete_task` commands because they also
|
||||
/// set the server-side timestamp.
|
||||
pub async fn update_task(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
text: Option<&str>,
|
||||
bucket: Option<&str>,
|
||||
list_id: Option<&str>,
|
||||
effort: Option<&str>,
|
||||
notes: Option<&str>,
|
||||
) -> Result<TaskRow> {
|
||||
sqlx::query(
|
||||
"UPDATE tasks SET \
|
||||
text = COALESCE(?, text), \
|
||||
bucket = COALESCE(?, bucket), \
|
||||
list_id = COALESCE(?, list_id), \
|
||||
effort = COALESCE(?, effort), \
|
||||
notes = COALESCE(?, notes) \
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(text)
|
||||
.bind(bucket)
|
||||
.bind(list_id)
|
||||
.bind(effort)
|
||||
.bind(notes)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Update task failed: {e}")))?;
|
||||
|
||||
get_task_by_id(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| KonError::StorageError(format!("update_task: task {id} not found after update")))
|
||||
}
|
||||
|
||||
pub async fn insert_subtask(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
@@ -335,9 +388,9 @@ pub async fn insert_subtask(
|
||||
|
||||
pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<TaskRow>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, text, bucket, list_id, effort, done, done_at, created_at, \
|
||||
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
|
||||
source_transcript_id, parent_task_id \
|
||||
FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC"
|
||||
FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC",
|
||||
)
|
||||
.bind(parent_id)
|
||||
.fetch_all(pool)
|
||||
@@ -468,6 +521,7 @@ pub struct TaskRow {
|
||||
pub bucket: String,
|
||||
pub list_id: Option<String>,
|
||||
pub effort: Option<String>,
|
||||
pub notes: String,
|
||||
pub done: bool,
|
||||
pub done_at: Option<String>,
|
||||
pub created_at: String,
|
||||
@@ -503,6 +557,7 @@ fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow {
|
||||
bucket: r.get("bucket"),
|
||||
list_id: r.get("list_id"),
|
||||
effort: r.get("effort"),
|
||||
notes: r.get("notes"),
|
||||
done: r.get("done"),
|
||||
done_at: r.get("done_at"),
|
||||
created_at: r.get("created_at"),
|
||||
@@ -643,7 +698,7 @@ mod tests {
|
||||
async fn task_crud_roundtrip() {
|
||||
let pool = test_pool().await;
|
||||
|
||||
insert_task(&pool, "task1", "Buy groceries", "today", None)
|
||||
insert_task(&pool, "task1", "Buy groceries", "today", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -664,7 +719,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn subtask_crud_roundtrip() {
|
||||
let pool = test_pool().await;
|
||||
insert_task(&pool, "p1", "Write report", "inbox", None).await.unwrap();
|
||||
insert_task(&pool, "p1", "Write report", "inbox", None, None, None).await.unwrap();
|
||||
insert_subtask(&pool, "s1", "Open document", "p1").await.unwrap();
|
||||
insert_subtask(&pool, "s2", "Write introduction", "p1").await.unwrap();
|
||||
|
||||
@@ -688,6 +743,55 @@ mod tests {
|
||||
assert!(parent.done, "parent should auto-complete when all children done");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_task_overwrites_provided_fields() {
|
||||
// Task 2.6 — happy path: insert, update bucket + effort, read back.
|
||||
let pool = test_pool().await;
|
||||
insert_task(&pool, "u1", "Draft post", "inbox", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row = update_task(
|
||||
&pool,
|
||||
"u1",
|
||||
None,
|
||||
Some("today"),
|
||||
None,
|
||||
Some("15m"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.bucket, "today");
|
||||
assert_eq!(row.effort.as_deref(), Some("15m"));
|
||||
assert_eq!(row.text, "Draft post", "text must be unchanged");
|
||||
assert_eq!(row.notes, "", "notes default must survive");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_task_partial_leaves_others_unchanged() {
|
||||
// Task 2.6 — partial update: only notes changes; text/bucket intact.
|
||||
let pool = test_pool().await;
|
||||
insert_task(&pool, "u2", "Prep slides", "today", None, None, Some("30m"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row = update_task(&pool, "u2", None, None, None, None, Some("remember venue wifi"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.text, "Prep slides");
|
||||
assert_eq!(row.bucket, "today");
|
||||
assert_eq!(row.effort.as_deref(), Some("30m"));
|
||||
assert_eq!(row.notes, "remember venue wifi");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_task_missing_id_errors() {
|
||||
let pool = test_pool().await;
|
||||
let res = update_task(&pool, "missing", Some("x"), None, None, None, None).await;
|
||||
assert!(res.is_err(), "update_task must error when id does not exist");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_crud_roundtrip() {
|
||||
let pool = test_pool().await;
|
||||
|
||||
@@ -7,7 +7,7 @@ pub use database::{
|
||||
delete_dictionary_entry, delete_task, delete_transcript, get_setting, get_task_by_id, uncomplete_task,
|
||||
get_transcript, init, insert_subtask, insert_task, insert_transcript, list_dictionary,
|
||||
list_recent_errors, list_subtasks, list_tasks, list_transcripts, list_transcripts_paged,
|
||||
log_error, search_transcripts, set_setting, update_transcript, DictionaryEntry, ErrorLogRow,
|
||||
InsertTranscriptParams, TaskRow, TranscriptRow,
|
||||
log_error, search_transcripts, set_setting, update_task, update_transcript, DictionaryEntry,
|
||||
ErrorLogRow, InsertTranscriptParams, TaskRow, TranscriptRow,
|
||||
};
|
||||
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};
|
||||
|
||||
@@ -112,6 +112,9 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
||||
ALTER TABLE tasks ADD COLUMN parent_task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE;
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id)
|
||||
"#),
|
||||
(4, "tasks_meta: notes column for persisted free-text", r#"
|
||||
ALTER TABLE tasks ADD COLUMN notes TEXT NOT NULL DEFAULT ''
|
||||
"#),
|
||||
];
|
||||
|
||||
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
||||
@@ -197,6 +200,7 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use sqlx::Row;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migrations_run_on_empty_db() {
|
||||
@@ -211,7 +215,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 3);
|
||||
assert_eq!(count, 4);
|
||||
|
||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||
.execute(&pool)
|
||||
@@ -233,7 +237,35 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 3);
|
||||
assert_eq!(count, 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_tasks_meta_adds_columns() {
|
||||
// Task 2.6 — verify list_id / effort / notes are all present on the
|
||||
// tasks table after migrations run. list_id and effort have been
|
||||
// present since v1 (nullable); notes is added by v4.
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("pool");
|
||||
run_migrations(&pool).await.expect("migrate");
|
||||
|
||||
let info = sqlx::query("PRAGMA table_info(tasks)")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let names: Vec<String> = info
|
||||
.iter()
|
||||
.map(|r| r.get::<String, _>("name"))
|
||||
.collect();
|
||||
for col in ["list_id", "effort", "notes"] {
|
||||
assert!(
|
||||
names.contains(&col.to_string()),
|
||||
"tasks must have {col}; got {names:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user