Skip to main content

max / goingson

5.2 KB · 165 lines History Blame Raw
1 //! Project management commands.
2 //!
3 //! Provides CRUD operations for projects, which are the top-level organizational
4 //! unit in GoingsOn. Projects can be of various types (Job, SideProject, Company, etc.)
5 //! and have associated tasks, events, and emails.
6
7 use serde::{Deserialize, Serialize};
8 use std::sync::Arc;
9 use tauri::State;
10 use tracing::instrument;
11
12 use goingson_core::{NewProject, ParseableEnum, Project, ProjectId, ProjectStatus, ProjectType, UpdateProject, Validate};
13
14 use crate::state::{AppState, DESKTOP_USER_ID};
15 use super::{ApiError, OptionNotFound};
16
17 // ============ Types ============
18
19 #[derive(Debug, Deserialize)]
20 #[serde(rename_all = "camelCase")]
21 pub struct ProjectInput {
22 pub name: String,
23 pub description: String,
24 pub project_type: String,
25 pub status: String,
26 }
27
28 /// Project response with pre-computed display fields for UI.
29 #[derive(Debug, Serialize)]
30 #[serde(rename_all = "camelCase")]
31 pub struct ProjectResponse {
32 #[serde(flatten)]
33 pub project: Project,
34 /// Human-readable project type (e.g., "Side Project" instead of "SideProject")
35 pub project_type_display: String,
36 /// Human-readable status (e.g., "On Hold" instead of "OnHold")
37 pub status_display: String,
38 /// Pre-rendered markdown description as HTML
39 pub description_html: String,
40 }
41
42 impl From<Project> for ProjectResponse {
43 fn from(p: Project) -> Self {
44 let project_type_display = match &p.project_type {
45 ProjectType::SideProject => "Side Project".to_string(),
46 ProjectType::Job => "Job".to_string(),
47 ProjectType::Company => "Company".to_string(),
48 ProjectType::Essay => "Essay".to_string(),
49 ProjectType::Article => "Article".to_string(),
50 ProjectType::Painting => "Painting".to_string(),
51 ProjectType::Other => "Other".to_string(),
52 };
53 let status_display = match &p.status {
54 ProjectStatus::Active => "Active".to_string(),
55 ProjectStatus::OnHold => "On Hold".to_string(),
56 ProjectStatus::Completed => "Completed".to_string(),
57 ProjectStatus::Archived => "Archived".to_string(),
58 };
59 let description_html = docengine::render_standard(&p.description);
60 ProjectResponse {
61 project: p,
62 project_type_display,
63 status_display,
64 description_html,
65 }
66 }
67 }
68
69 // ============ Commands ============
70
71 /// Lists all projects for the current user.
72 ///
73 /// # Errors
74 ///
75 /// Returns `DATABASE_ERROR` if the query fails.
76 #[tauri::command]
77 #[instrument(skip_all)]
78 pub async fn list_projects(state: State<'_, Arc<AppState>>) -> Result<Vec<ProjectResponse>, ApiError> {
79 Ok(state.projects.list_all(DESKTOP_USER_ID).await?
80 .into_iter().map(ProjectResponse::from).collect())
81 }
82
83 /// Retrieves a single project by ID.
84 ///
85 /// # Errors
86 ///
87 /// Returns `DATABASE_ERROR` if the query fails.
88 /// Returns `None` (not an error) if the project doesn't exist.
89 #[tauri::command]
90 #[instrument(skip_all)]
91 pub async fn get_project(state: State<'_, Arc<AppState>>, id: ProjectId) -> Result<Option<ProjectResponse>, ApiError> {
92 Ok(state.projects.get_by_id(id, DESKTOP_USER_ID).await?
93 .map(ProjectResponse::from))
94 }
95
96 /// Creates a new project.
97 ///
98 /// # Arguments
99 ///
100 /// * `input` - Project data:
101 /// - `name`: Project name
102 /// - `description`: Optional description
103 /// - `project_type`: Type (Job, SideProject, Company, etc.)
104 /// - `status`: Status (Active, OnHold, Completed, Archived)
105 ///
106 /// # Errors
107 ///
108 /// Returns `DATABASE_ERROR` if the insert fails.
109 #[tauri::command]
110 #[instrument(skip_all)]
111 pub async fn create_project(state: State<'_, Arc<AppState>>, input: ProjectInput) -> Result<ProjectResponse, ApiError> {
112 let project_type = ProjectType::from_str_or_default(&input.project_type);
113 let status = ProjectStatus::from_str_or_default(&input.status);
114
115 let new_project = NewProject {
116 name: input.name,
117 description: input.description,
118 project_type,
119 status,
120 };
121
122 new_project.validate()?;
123
124 Ok(ProjectResponse::from(state.projects.create(DESKTOP_USER_ID, new_project).await?))
125 }
126
127 /// Updates an existing project.
128 ///
129 /// # Errors
130 ///
131 /// Returns `NOT_FOUND` if the project doesn't exist.
132 /// Returns `DATABASE_ERROR` if the update fails.
133 #[tauri::command]
134 #[instrument(skip_all)]
135 pub async fn update_project(state: State<'_, Arc<AppState>>, id: ProjectId, input: ProjectInput) -> Result<ProjectResponse, ApiError> {
136 let update = UpdateProject {
137 name: input.name,
138 description: input.description,
139 project_type: ProjectType::from_str_or_default(&input.project_type),
140 status: ProjectStatus::from_str_or_default(&input.status),
141 };
142
143 update.validate()?;
144
145 let project = state.projects
146 .update(id, DESKTOP_USER_ID, update)
147 .await?
148 .or_not_found("project", id)?;
149
150 Ok(ProjectResponse::from(project))
151 }
152
153 /// Deletes a project.
154 ///
155 /// Note: This does not cascade to tasks/events - they become unassociated.
156 ///
157 /// # Errors
158 ///
159 /// Returns `DATABASE_ERROR` if the delete fails.
160 #[tauri::command]
161 #[instrument(skip_all)]
162 pub async fn delete_project(state: State<'_, Arc<AppState>>, id: ProjectId) -> Result<bool, ApiError> {
163 Ok(state.projects.delete(id, DESKTOP_USER_ID).await?)
164 }
165