Skip to main content

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