//! Project management commands. //! //! Provides CRUD operations for projects, which are the top-level organizational //! unit in GoingsOn. Projects can be of various types (Job, SideProject, Company, etc.) //! and have associated tasks, events, and emails. use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; use tracing::instrument; use goingson_core::{NewProject, ParseableEnum, Project, ProjectId, ProjectStatus, ProjectType, UpdateProject, Validate}; use crate::state::{AppState, DESKTOP_USER_ID}; use super::{ApiError, OptionNotFound}; // ============ Types ============ #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProjectInput { pub name: String, pub description: String, pub project_type: String, pub status: String, } /// Project response with pre-computed display fields for UI. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProjectResponse { #[serde(flatten)] pub project: Project, /// Human-readable project type (e.g., "Side Project" instead of "SideProject") pub project_type_display: String, /// Human-readable status (e.g., "On Hold" instead of "OnHold") pub status_display: String, /// Pre-rendered markdown description as HTML pub description_html: String, } impl From for ProjectResponse { fn from(p: Project) -> Self { let project_type_display = match &p.project_type { ProjectType::SideProject => "Side Project".to_string(), ProjectType::Job => "Job".to_string(), ProjectType::Company => "Company".to_string(), ProjectType::Essay => "Essay".to_string(), ProjectType::Article => "Article".to_string(), ProjectType::Painting => "Painting".to_string(), ProjectType::Other => "Other".to_string(), }; let status_display = match &p.status { ProjectStatus::Active => "Active".to_string(), ProjectStatus::OnHold => "On Hold".to_string(), ProjectStatus::Completed => "Completed".to_string(), ProjectStatus::Archived => "Archived".to_string(), }; let description_html = docengine::render_standard(&p.description); ProjectResponse { project: p, project_type_display, status_display, description_html, } } } // ============ Commands ============ /// Lists all projects for the current user. /// /// # Errors /// /// Returns `DATABASE_ERROR` if the query fails. #[tauri::command] #[instrument(skip_all)] pub async fn list_projects(state: State<'_, Arc>) -> Result, ApiError> { Ok(state.projects.list_all(DESKTOP_USER_ID).await? .into_iter().map(ProjectResponse::from).collect()) } /// Retrieves a single project by ID. /// /// # Errors /// /// Returns `DATABASE_ERROR` if the query fails. /// Returns `None` (not an error) if the project doesn't exist. #[tauri::command] #[instrument(skip_all)] pub async fn get_project(state: State<'_, Arc>, id: ProjectId) -> Result, ApiError> { Ok(state.projects.get_by_id(id, DESKTOP_USER_ID).await? .map(ProjectResponse::from)) } /// Creates a new project. /// /// # Arguments /// /// * `input` - Project data: /// - `name`: Project name /// - `description`: Optional description /// - `project_type`: Type (Job, SideProject, Company, etc.) /// - `status`: Status (Active, OnHold, Completed, Archived) /// /// # Errors /// /// Returns `DATABASE_ERROR` if the insert fails. #[tauri::command] #[instrument(skip_all)] pub async fn create_project(state: State<'_, Arc>, input: ProjectInput) -> Result { let project_type = ProjectType::from_str_or_default(&input.project_type); let status = ProjectStatus::from_str_or_default(&input.status); let new_project = NewProject { name: input.name, description: input.description, project_type, status, }; new_project.validate()?; Ok(ProjectResponse::from(state.projects.create(DESKTOP_USER_ID, new_project).await?)) } /// Updates an existing project. /// /// # Errors /// /// Returns `NOT_FOUND` if the project doesn't exist. /// Returns `DATABASE_ERROR` if the update fails. #[tauri::command] #[instrument(skip_all)] pub async fn update_project(state: State<'_, Arc>, id: ProjectId, input: ProjectInput) -> Result { let update = UpdateProject { name: input.name, description: input.description, project_type: ProjectType::from_str_or_default(&input.project_type), status: ProjectStatus::from_str_or_default(&input.status), }; update.validate()?; let project = state.projects .update(id, DESKTOP_USER_ID, update) .await? .or_not_found("project", id)?; Ok(ProjectResponse::from(project)) } /// Deletes a project. /// /// Note: This does not cascade to tasks/events - they become unassociated. /// /// # Errors /// /// Returns `DATABASE_ERROR` if the delete fails. #[tauri::command] #[instrument(skip_all)] pub async fn delete_project(state: State<'_, Arc>, id: ProjectId) -> Result { Ok(state.projects.delete(id, DESKTOP_USER_ID).await?) }