Skip to main content

things3_core/database/mutations/
tasks.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::{
10        CreateTaskRequest, DeleteChildHandling, TaskStatus, TaskType, ThingsId, UpdateTaskRequest,
11    },
12};
13use chrono::Utc;
14use sqlx::Row;
15use tracing::{info, instrument};
16
17impl ThingsDatabase {
18    /// Create a new task in the database
19    ///
20    /// Validates:
21    /// - Project UUID exists if provided
22    /// - Area UUID exists if provided
23    /// - Parent task UUID exists if provided
24    /// - Date range (deadline >= start_date)
25    ///
26    /// Returns the UUID of the created task
27    ///
28    /// # Examples
29    ///
30    /// ```no_run
31    /// use things3_core::{ThingsDatabase, CreateTaskRequest, ThingsError};
32    /// use std::path::Path;
33    /// use chrono::NaiveDate;
34    ///
35    /// # async fn example() -> Result<(), ThingsError> {
36    /// let db = ThingsDatabase::new(Path::new("/path/to/things.db")).await?;
37    ///
38    /// // Create a simple task
39    /// let request = CreateTaskRequest {
40    ///     title: "Buy groceries".to_string(),
41    ///     notes: Some("Milk, eggs, bread".to_string()),
42    ///     deadline: Some(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()),
43    ///     start_date: None,
44    ///     project_uuid: None,
45    ///     area_uuid: None,
46    ///     parent_uuid: None,
47    ///     tags: None,
48    ///     task_type: None,
49    ///     status: None,
50    /// };
51    ///
52    /// let task_uuid = db.create_task(request).await?;
53    /// println!("Created task with UUID: {}", task_uuid);
54    /// # Ok(())
55    /// # }
56    /// ```
57    ///
58    /// # Errors
59    ///
60    /// Returns an error if validation fails or if the database insert fails
61    #[instrument(skip(self))]
62    pub async fn create_task(&self, request: CreateTaskRequest) -> ThingsResult<ThingsId> {
63        // Validate date range (deadline must be >= start_date)
64        crate::database::validate_date_range(request.start_date, request.deadline)?;
65
66        // Generate ID for new task
67        let id = ThingsId::new_things_native();
68
69        // Validate referenced entities
70        if let Some(project_uuid) = &request.project_uuid {
71            validators::validate_project_exists(&self.pool, project_uuid).await?;
72        }
73
74        if let Some(area_uuid) = &request.area_uuid {
75            validators::validate_area_exists(&self.pool, area_uuid).await?;
76        }
77
78        if let Some(parent_uuid) = &request.parent_uuid {
79            validators::validate_task_exists(&self.pool, parent_uuid).await?;
80        }
81
82        // Convert dates to Things 3 format (seconds since 2001-01-01)
83        let start_date_ts = request.start_date.map(naive_date_to_things_timestamp);
84        let deadline_ts = request.deadline.map(naive_date_to_things_timestamp);
85
86        // Get current timestamp for creation/modification dates
87        let now = Utc::now().timestamp() as f64;
88
89        // Insert into TMTask table
90        sqlx::query(
91            r"
92            INSERT INTO TMTask (
93                uuid, title, type, status, notes,
94                startDate, deadline, project, area, heading,
95                creationDate, userModificationDate,
96                trashed
97            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
98            ",
99        )
100        .bind(id.as_str())
101        .bind(&request.title)
102        .bind(request.task_type.unwrap_or(TaskType::Todo) as i32)
103        .bind(request.status.unwrap_or(TaskStatus::Incomplete) as i32)
104        .bind(request.notes.as_ref())
105        .bind(start_date_ts)
106        .bind(deadline_ts)
107        .bind(request.project_uuid.map(|u| u.into_string()))
108        .bind(request.area_uuid.map(|u| u.into_string()))
109        .bind(request.parent_uuid.map(|u| u.into_string()))
110        .bind(now)
111        .bind(now)
112        .bind(0) // not trashed
113        .execute(&self.pool)
114        .await
115        .map_err(|e| ThingsError::unknown(format!("Failed to create task: {e}")))?;
116
117        // Handle tags via TMTaskTag
118        if let Some(tags) = request.tags {
119            self.set_task_tags(&id, tags).await?;
120        }
121
122        info!("Created task with UUID: {}", id);
123        Ok(id)
124    }
125
126    /// Update an existing task
127    ///
128    /// Only updates fields that are provided (Some(_))
129    /// Validates existence of referenced entities
130    ///
131    /// # Errors
132    ///
133    /// Returns an error if the task doesn't exist, validation fails, or the database update fails
134    #[instrument(skip(self))]
135    pub async fn update_task(&self, request: UpdateTaskRequest) -> ThingsResult<()> {
136        // Verify task exists
137        validators::validate_task_exists(&self.pool, &request.uuid).await?;
138
139        // Validate dates if either is being updated
140        if request.start_date.is_some() || request.deadline.is_some() {
141            // Get current task to merge dates
142            if let Some(current_task) = self.get_task_by_uuid(&request.uuid).await? {
143                let final_start = request.start_date.or(current_task.start_date);
144                let final_deadline = request.deadline.or(current_task.deadline);
145                crate::database::validate_date_range(final_start, final_deadline)?;
146            }
147        }
148
149        // Validate referenced entities if being updated
150        if let Some(project_uuid) = &request.project_uuid {
151            validators::validate_project_exists(&self.pool, project_uuid).await?;
152        }
153
154        if let Some(area_uuid) = &request.area_uuid {
155            validators::validate_area_exists(&self.pool, area_uuid).await?;
156        }
157
158        // Use the TaskUpdateBuilder to construct the query
159        let builder = TaskUpdateBuilder::from_request(&request);
160
161        // If no fields to update, just return (modification date will still be updated)
162        if builder.is_empty() {
163            return Ok(());
164        }
165
166        let query_string = builder.build_query_string();
167        let mut q = sqlx::query(&query_string);
168
169        // Bind values in the same order as the builder added fields
170        if let Some(title) = &request.title {
171            q = q.bind(title);
172        }
173
174        if let Some(notes) = &request.notes {
175            q = q.bind(notes);
176        }
177
178        if let Some(start_date) = request.start_date {
179            q = q.bind(naive_date_to_things_timestamp(start_date));
180        }
181
182        if let Some(deadline) = request.deadline {
183            q = q.bind(naive_date_to_things_timestamp(deadline));
184        }
185
186        if let Some(status) = request.status {
187            q = q.bind(status as i32);
188        }
189
190        if let Some(project_uuid) = request.project_uuid {
191            q = q.bind(project_uuid.into_string());
192        }
193
194        if let Some(area_uuid) = request.area_uuid {
195            q = q.bind(area_uuid.into_string());
196        }
197
198        // Bind modification date and UUID (always added by builder)
199        let now = Utc::now().timestamp() as f64;
200        q = q.bind(now).bind(request.uuid.as_str());
201
202        q.execute(&self.pool)
203            .await
204            .map_err(|e| ThingsError::unknown(format!("Failed to update task: {e}")))?;
205
206        // Handle tags via TMTaskTag (separate from the UPDATE query)
207        if let Some(tags) = request.tags {
208            self.set_task_tags(&request.uuid, tags).await?;
209        }
210
211        info!("Updated task with UUID: {}", request.uuid);
212        Ok(())
213    }
214
215    /// Mark a task as completed
216    ///
217    /// # Errors
218    ///
219    /// Returns an error if the task does not exist or if the database update fails
220    #[instrument(skip(self))]
221    pub async fn complete_task(&self, id: &ThingsId) -> ThingsResult<()> {
222        // Verify task exists
223        validators::validate_task_exists(&self.pool, id).await?;
224
225        let now = Utc::now().timestamp() as f64;
226
227        sqlx::query(
228            "UPDATE TMTask SET status = 3, stopDate = ?, userModificationDate = ? WHERE uuid = ?",
229        )
230        .bind(now)
231        .bind(now)
232        .bind(id.as_str())
233        .execute(&self.pool)
234        .await
235        .map_err(|e| ThingsError::unknown(format!("Failed to complete task: {e}")))?;
236
237        info!("Completed task with UUID: {}", id);
238        Ok(())
239    }
240
241    /// Mark a completed task as incomplete
242    ///
243    /// # Errors
244    ///
245    /// Returns an error if the task does not exist or if the database update fails
246    #[instrument(skip(self))]
247    pub async fn uncomplete_task(&self, id: &ThingsId) -> ThingsResult<()> {
248        // Verify task exists
249        validators::validate_task_exists(&self.pool, id).await?;
250
251        let now = Utc::now().timestamp() as f64;
252
253        sqlx::query(
254            "UPDATE TMTask SET status = 0, stopDate = NULL, userModificationDate = ? WHERE uuid = ?",
255        )
256        .bind(now)
257        .bind(id.as_str())
258        .execute(&self.pool)
259        .await
260        .map_err(|e| ThingsError::unknown(format!("Failed to uncomplete task: {e}")))?;
261
262        info!("Uncompleted task with UUID: {}", id);
263        Ok(())
264    }
265
266    /// Soft delete a task (set trashed flag)
267    ///
268    /// # Errors
269    ///
270    /// Returns an error if the task does not exist, if child handling fails, or if the database update fails
271    #[instrument(skip(self))]
272    pub async fn delete_task(
273        &self,
274        id: &ThingsId,
275        child_handling: DeleteChildHandling,
276    ) -> ThingsResult<()> {
277        // Verify task exists
278        validators::validate_task_exists(&self.pool, id).await?;
279
280        // Check for child tasks
281        let children = sqlx::query("SELECT uuid FROM TMTask WHERE heading = ? AND trashed = 0")
282            .bind(id.as_str())
283            .fetch_all(&self.pool)
284            .await
285            .map_err(|e| ThingsError::unknown(format!("Failed to query child tasks: {e}")))?;
286
287        let has_children = !children.is_empty();
288
289        if has_children {
290            match child_handling {
291                DeleteChildHandling::Error => {
292                    return Err(ThingsError::unknown(format!(
293                        "Task {} has {} child task(s). Use cascade or orphan mode to delete.",
294                        id,
295                        children.len()
296                    )));
297                }
298                DeleteChildHandling::Cascade => {
299                    // Delete all children
300                    let now = Utc::now().timestamp() as f64;
301                    for child_row in &children {
302                        let child_uuid: String = child_row.get("uuid");
303                        sqlx::query(
304                            "UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?",
305                        )
306                        .bind(now)
307                        .bind(&child_uuid)
308                        .execute(&self.pool)
309                        .await
310                        .map_err(|e| {
311                            ThingsError::unknown(format!("Failed to delete child task: {e}"))
312                        })?;
313                    }
314                    info!("Cascade deleted {} child task(s)", children.len());
315                }
316                DeleteChildHandling::Orphan => {
317                    // Clear parent reference for children
318                    let now = Utc::now().timestamp() as f64;
319                    for child_row in &children {
320                        let child_uuid: String = child_row.get("uuid");
321                        sqlx::query(
322                            "UPDATE TMTask SET heading = NULL, userModificationDate = ? WHERE uuid = ?",
323                        )
324                        .bind(now)
325                        .bind(&child_uuid)
326                        .execute(&self.pool)
327                        .await
328                        .map_err(|e| {
329                            ThingsError::unknown(format!("Failed to orphan child task: {e}"))
330                        })?;
331                    }
332                    info!("Orphaned {} child task(s)", children.len());
333                }
334            }
335        }
336
337        // Delete the parent task
338        let now = Utc::now().timestamp() as f64;
339        sqlx::query("UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?")
340            .bind(now)
341            .bind(id.as_str())
342            .execute(&self.pool)
343            .await
344            .map_err(|e| ThingsError::unknown(format!("Failed to delete task: {e}")))?;
345
346        info!("Deleted task with UUID: {}", id);
347        Ok(())
348    }
349}