//! Annotation and subtask commands for tasks. use serde::Deserialize; use std::sync::Arc; use tauri::State; use tracing::instrument; use goingson_core::{Annotation, Subtask, TaskId, AnnotationId, SubtaskId}; use crate::state::{AppState, DESKTOP_USER_ID}; use super::{ApiError, OptionNotFound}; // ============ Types ============ #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AnnotationInput { pub note: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SubtaskInput { pub text: String, } // ============ Annotation Commands ============ /// Lists all annotations (timestamped notes) for a task. /// /// # Errors /// /// Returns `NOT_FOUND` if the task doesn't exist. /// Returns `DATABASE_ERROR` if the query fails. #[tauri::command] #[instrument(skip_all)] pub async fn list_annotations(state: State<'_, Arc>, task_id: TaskId) -> Result, ApiError> { // Verify task belongs to user state.tasks .get_by_id(task_id, DESKTOP_USER_ID) .await? .or_not_found("task", task_id)?; Ok(state.tasks.get_annotations_for_task(task_id).await?) } /// Adds a timestamped note to a task. /// /// # Errors /// /// Returns `VALIDATION_ERROR` if the note is empty. /// Returns `NOT_FOUND` if the task doesn't exist. /// Returns `DATABASE_ERROR` if the insert fails. #[tauri::command] #[instrument(skip_all)] pub async fn add_annotation(state: State<'_, Arc>, task_id: TaskId, input: AnnotationInput) -> Result { if input.note.trim().is_empty() { return Err(ApiError::validation("note", "Note is required")); } let annotation = state.tasks .add_annotation(task_id, DESKTOP_USER_ID, &input.note) .await? .or_not_found("task", task_id)?; Ok(annotation) } /// Deletes an annotation. /// /// # Errors /// /// Returns `DATABASE_ERROR` if the delete fails. #[tauri::command] #[instrument(skip_all)] pub async fn delete_annotation(state: State<'_, Arc>, annotation_id: AnnotationId) -> Result { Ok(state.tasks.delete_annotation(annotation_id, DESKTOP_USER_ID).await?) } // ============ Subtask Commands ============ /// Lists all subtasks (checklist items) for a task. /// /// # Errors /// /// Returns `NOT_FOUND` if the task doesn't exist. /// Returns `DATABASE_ERROR` if the query fails. #[tauri::command] #[instrument(skip_all)] pub async fn list_subtasks(state: State<'_, Arc>, task_id: TaskId) -> Result, ApiError> { // Verify task belongs to user state.tasks .get_by_id(task_id, DESKTOP_USER_ID) .await? .or_not_found("task", task_id)?; Ok(state.tasks.get_subtasks_for_task(task_id).await?) } /// Adds a subtask (checklist item) to a task. /// /// # Errors /// /// Returns `VALIDATION_ERROR` if the text is empty. /// Returns `NOT_FOUND` if the task doesn't exist. /// Returns `DATABASE_ERROR` if the insert fails. #[tauri::command] #[instrument(skip_all)] pub async fn add_subtask(state: State<'_, Arc>, task_id: TaskId, input: SubtaskInput) -> Result { if input.text.trim().is_empty() { return Err(ApiError::validation("text", "Subtask text is required")); } let subtask = state.tasks .add_subtask(task_id, DESKTOP_USER_ID, &input.text) .await? .or_not_found("task", task_id)?; Ok(subtask) } /// Toggles the completion status of a subtask. /// /// # Errors /// /// Returns `NOT_FOUND` if the subtask doesn't exist. /// Returns `DATABASE_ERROR` if the update fails. #[tauri::command] #[instrument(skip_all)] pub async fn toggle_subtask(state: State<'_, Arc>, subtask_id: SubtaskId) -> Result { let subtask = state.tasks .toggle_subtask(subtask_id, DESKTOP_USER_ID) .await? .or_not_found("subtask", subtask_id)?; Ok(subtask) } /// Updates the text of a subtask. /// /// # Errors /// /// Returns `VALIDATION_ERROR` if the text is empty. /// Returns `NOT_FOUND` if the subtask doesn't exist. /// Returns `DATABASE_ERROR` if the update fails. #[tauri::command] #[instrument(skip_all)] pub async fn update_subtask(state: State<'_, Arc>, subtask_id: SubtaskId, input: SubtaskInput) -> Result { if input.text.trim().is_empty() { return Err(ApiError::validation("text", "Subtask text is required")); } let subtask = state.tasks .update_subtask(subtask_id, DESKTOP_USER_ID, &input.text) .await? .or_not_found("subtask", subtask_id)?; Ok(subtask) } /// Deletes a subtask. /// /// # Errors /// /// Returns `DATABASE_ERROR` if the delete fails. #[tauri::command] #[instrument(skip_all)] pub async fn delete_subtask(state: State<'_, Arc>, subtask_id: SubtaskId) -> Result { Ok(state.tasks.delete_subtask(subtask_id, DESKTOP_USER_ID).await?) } /// Links another task as a subtask. /// /// Creates a subtask that references another task. This enables multi-phase /// features where Phase 2 is a full task linked as a subtask of Phase 1. /// The linked subtask's text shows the linked task's description, and its /// completion status syncs with the linked task's status. /// /// # Errors /// /// Returns `NOT_FOUND` if either task doesn't exist. /// Returns `DATABASE_ERROR` if the insert fails. #[tauri::command] #[instrument(skip_all)] pub async fn add_subtask_link( state: State<'_, Arc>, task_id: TaskId, linked_task_id: TaskId, ) -> Result { // Prevent linking a task to itself if task_id == linked_task_id { return Err(ApiError::validation("linked_task_id", "Cannot link a task to itself")); } let subtask = state.tasks .add_subtask_link(task_id, DESKTOP_USER_ID, linked_task_id) .await? .or_not_found("task", task_id)?; Ok(subtask) }