Skip to main content

things3_core/database/mutations/
projects.rs

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