Skip to main content

things3_core/database/mutations/
projects.rs

1use crate::{
2    database::{
3        conversions::naive_date_to_things_timestamp, query_builders::TaskUpdateBuilder, validators,
4        ThingsDatabase,
5    },
6    error::{Result as ThingsResult, ThingsError},
7    models::ThingsId,
8};
9use chrono::Utc;
10use tracing::{info, instrument};
11
12impl ThingsDatabase {
13    /// Create a new project
14    ///
15    /// Projects are tasks with type = 1 in the TMTask table
16    ///
17    /// # Errors
18    ///
19    /// Returns an error if validation fails or the database insert fails
20    #[instrument(skip(self))]
21    pub async fn create_project(
22        &self,
23        request: crate::models::CreateProjectRequest,
24    ) -> ThingsResult<ThingsId> {
25        // Validate date range (deadline must be >= start_date)
26        crate::database::validate_date_range(request.start_date, request.deadline)?;
27
28        // Generate ID for new project
29        let id = ThingsId::new_things_native();
30
31        // Validate area if provided
32        if let Some(area_uuid) = &request.area_uuid {
33            validators::validate_area_exists(&self.pool, area_uuid).await?;
34        }
35
36        // Convert dates to Things 3 format (seconds since 2001-01-01)
37        let start_date_ts = request.start_date.map(naive_date_to_things_timestamp);
38        let deadline_ts = request.deadline.map(naive_date_to_things_timestamp);
39
40        // Get current timestamp for creation/modification dates
41        let now = Utc::now().timestamp() as f64;
42
43        // Insert into TMTask table with type = 1 (project)
44        sqlx::query(
45            r"
46            INSERT INTO TMTask (
47                uuid, title, type, status, notes,
48                startDate, deadline, project, area, heading,
49                creationDate, userModificationDate,
50                trashed
51            ) VALUES (?, ?, 1, 0, ?, ?, ?, NULL, ?, NULL, ?, ?, 0)
52            ",
53        )
54        .bind(id.as_str())
55        .bind(&request.title)
56        .bind(request.notes.as_ref())
57        .bind(start_date_ts)
58        .bind(deadline_ts)
59        .bind(request.area_uuid.map(|u| u.into_string()))
60        .bind(now)
61        .bind(now)
62        .execute(&self.pool)
63        .await
64        .map_err(|e| ThingsError::unknown(format!("Failed to create project: {e}")))?;
65
66        // Handle tags via TMTaskTag
67        if let Some(tags) = request.tags {
68            self.set_task_tags(&id, tags).await?;
69        }
70
71        info!("Created project with UUID: {}", id);
72        Ok(id)
73    }
74
75    /// Update an existing project
76    ///
77    /// Only updates fields that are provided (Some(_))
78    /// Validates existence and that the entity is a project (type = 1)
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if the project doesn't exist, validation fails, or the database update fails
83    #[instrument(skip(self))]
84    pub async fn update_project(
85        &self,
86        request: crate::models::UpdateProjectRequest,
87    ) -> ThingsResult<()> {
88        // Verify project exists (type = 1, trashed = 0)
89        validators::validate_project_exists(&self.pool, &request.uuid).await?;
90
91        // Validate dates if either is being updated
92        if request.start_date.is_some() || request.deadline.is_some() {
93            // Fetch current project to merge dates
94            if let Some(current_project) = self.get_project_by_uuid(&request.uuid).await? {
95                let final_start = request.start_date.or(current_project.start_date);
96                let final_deadline = request.deadline.or(current_project.deadline);
97                crate::database::validate_date_range(final_start, final_deadline)?;
98            }
99        }
100
101        // Validate area if being updated
102        if let Some(area_uuid) = &request.area_uuid {
103            validators::validate_area_exists(&self.pool, area_uuid).await?;
104        }
105
106        // Build dynamic query using TaskUpdateBuilder
107        let mut builder = TaskUpdateBuilder::new();
108
109        // Add fields to update
110        if request.title.is_some() {
111            builder = builder.add_field("title");
112        }
113        if request.notes.is_some() {
114            builder = builder.add_field("notes");
115        }
116        if request.start_date.is_some() {
117            builder = builder.add_field("startDate");
118        }
119        if request.deadline.is_some() {
120            builder = builder.add_field("deadline");
121        }
122        if request.area_uuid.is_some() {
123            builder = builder.add_field("area");
124        }
125
126        // If nothing to update (tags-only changes still need to proceed for TMTaskTag)
127        let has_db_fields = !builder.is_empty();
128
129        if has_db_fields {
130            // Build query string
131            let query_str = builder.build_query_string();
132            let mut q = sqlx::query(&query_str);
133
134            // Bind values in the same order they were added to the builder
135            if let Some(ref title) = request.title {
136                q = q.bind(title);
137            }
138            if let Some(ref notes) = request.notes {
139                q = q.bind(notes);
140            }
141            if let Some(start_date) = request.start_date {
142                q = q.bind(naive_date_to_things_timestamp(start_date));
143            }
144            if let Some(deadline) = request.deadline {
145                q = q.bind(naive_date_to_things_timestamp(deadline));
146            }
147            if let Some(area_uuid) = request.area_uuid {
148                q = q.bind(area_uuid.into_string());
149            }
150
151            // Bind modification date and UUID (always added by builder)
152            let now = Utc::now().timestamp() as f64;
153            q = q.bind(now).bind(request.uuid.as_str());
154
155            q.execute(&self.pool)
156                .await
157                .map_err(|e| ThingsError::unknown(format!("Failed to update project: {e}")))?;
158        } else if request.tags.is_none() {
159            // Nothing to update at all
160            return Ok(());
161        }
162
163        // Handle tags via TMTaskTag (separate from the UPDATE query)
164        if let Some(tags) = request.tags {
165            self.set_task_tags(&request.uuid, tags).await?;
166        }
167
168        info!("Updated project with UUID: {}", request.uuid);
169        Ok(())
170    }
171
172    /// Complete a project and optionally handle its child tasks
173    ///
174    /// # Errors
175    ///
176    /// Returns an error if the project doesn't exist or if the database update fails
177    #[instrument(skip(self))]
178    pub async fn complete_project(
179        &self,
180        id: &ThingsId,
181        child_handling: crate::models::ProjectChildHandling,
182    ) -> ThingsResult<()> {
183        // Verify project exists
184        validators::validate_project_exists(&self.pool, id).await?;
185
186        let now = Utc::now().timestamp() as f64;
187
188        // Handle child tasks based on the handling mode
189        match child_handling {
190            crate::models::ProjectChildHandling::Error => {
191                // Check if project has children
192                let child_count: i64 = sqlx::query_scalar(
193                    "SELECT COUNT(*) FROM TMTask WHERE project = ? AND trashed = 0",
194                )
195                .bind(id.as_str())
196                .fetch_one(&self.pool)
197                .await
198                .map_err(|e| {
199                    ThingsError::unknown(format!("Failed to check for child tasks: {e}"))
200                })?;
201
202                if child_count > 0 {
203                    return Err(ThingsError::unknown(format!(
204                        "Project {} has {} child task(s). Use cascade or orphan mode to complete.",
205                        id, child_count
206                    )));
207                }
208            }
209            crate::models::ProjectChildHandling::Cascade => {
210                // Complete all child tasks
211                sqlx::query(
212                    "UPDATE TMTask SET status = 3, stopDate = ?, userModificationDate = ? WHERE project = ? AND trashed = 0",
213                )
214                .bind(now)
215                .bind(now)
216                .bind(id.as_str())
217                .execute(&self.pool)
218                .await
219                .map_err(|e| ThingsError::unknown(format!("Failed to complete child tasks: {e}")))?;
220            }
221            crate::models::ProjectChildHandling::Orphan => {
222                // Move child tasks to inbox (set project to NULL)
223                sqlx::query(
224                    "UPDATE TMTask SET project = NULL, userModificationDate = ? WHERE project = ? AND trashed = 0",
225                )
226                .bind(now)
227                .bind(id.as_str())
228                .execute(&self.pool)
229                .await
230                .map_err(|e| ThingsError::unknown(format!("Failed to orphan child tasks: {e}")))?;
231            }
232        }
233
234        // Complete the project
235        sqlx::query(
236            "UPDATE TMTask SET status = 3, stopDate = ?, userModificationDate = ? WHERE uuid = ?",
237        )
238        .bind(now)
239        .bind(now)
240        .bind(id.as_str())
241        .execute(&self.pool)
242        .await
243        .map_err(|e| ThingsError::unknown(format!("Failed to complete project: {e}")))?;
244
245        info!("Completed project with UUID: {}", id);
246        Ok(())
247    }
248
249    /// Soft delete a project and handle its child tasks
250    ///
251    /// # Errors
252    ///
253    /// Returns an error if the project doesn't exist, if child handling fails, or if the database update fails
254    #[instrument(skip(self))]
255    pub async fn delete_project(
256        &self,
257        id: &ThingsId,
258        child_handling: crate::models::ProjectChildHandling,
259    ) -> ThingsResult<()> {
260        // Verify project exists
261        validators::validate_project_exists(&self.pool, id).await?;
262
263        let now = Utc::now().timestamp() as f64;
264
265        // Handle child tasks based on the handling mode
266        match child_handling {
267            crate::models::ProjectChildHandling::Error => {
268                // Check if project has children
269                let child_count: i64 = sqlx::query_scalar(
270                    "SELECT COUNT(*) FROM TMTask WHERE project = ? AND trashed = 0",
271                )
272                .bind(id.as_str())
273                .fetch_one(&self.pool)
274                .await
275                .map_err(|e| {
276                    ThingsError::unknown(format!("Failed to check for child tasks: {e}"))
277                })?;
278
279                if child_count > 0 {
280                    return Err(ThingsError::unknown(format!(
281                        "Project {} has {} child task(s). Use cascade or orphan mode to delete.",
282                        id, child_count
283                    )));
284                }
285            }
286            crate::models::ProjectChildHandling::Cascade => {
287                // Delete all child tasks
288                sqlx::query(
289                    "UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE project = ? AND trashed = 0",
290                )
291                .bind(now)
292                .bind(id.as_str())
293                .execute(&self.pool)
294                .await
295                .map_err(|e| ThingsError::unknown(format!("Failed to delete child tasks: {e}")))?;
296            }
297            crate::models::ProjectChildHandling::Orphan => {
298                // Move child tasks to inbox (set project to NULL)
299                sqlx::query(
300                    "UPDATE TMTask SET project = NULL, userModificationDate = ? WHERE project = ? AND trashed = 0",
301                )
302                .bind(now)
303                .bind(id.as_str())
304                .execute(&self.pool)
305                .await
306                .map_err(|e| ThingsError::unknown(format!("Failed to orphan child tasks: {e}")))?;
307            }
308        }
309
310        // Delete the project
311        sqlx::query("UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?")
312            .bind(now)
313            .bind(id.as_str())
314            .execute(&self.pool)
315            .await
316            .map_err(|e| ThingsError::unknown(format!("Failed to delete project: {e}")))?;
317
318        info!("Deleted project with UUID: {}", id);
319        Ok(())
320    }
321}