Skip to main content

things3_core/database/mutations/
bulk.rs

1use crate::{
2    database::{conversions::naive_date_to_things_timestamp, validators, ThingsDatabase},
3    error::{Result as ThingsResult, ThingsError},
4};
5use chrono::Utc;
6use sqlx::Row;
7use tracing::{info, instrument};
8
9impl ThingsDatabase {
10    /// Maximum number of tasks that can be processed in a single bulk operation
11    /// This prevents abuse and ensures reasonable transaction sizes
12    const MAX_BULK_BATCH_SIZE: usize = 1000;
13
14    /// Move multiple tasks to a project or area (transactional)
15    ///
16    /// All tasks must exist and be valid, or the entire operation will be rolled back.
17    ///
18    /// # Errors
19    ///
20    /// Returns an error if:
21    /// - Task UUIDs array is empty
22    /// - Neither project_uuid nor area_uuid is specified
23    /// - Target project or area doesn't exist
24    /// - Any task UUID is invalid or doesn't exist
25    /// - Database operation fails
26    #[instrument(skip(self))]
27    pub async fn bulk_move(
28        &self,
29        request: crate::models::BulkMoveRequest,
30    ) -> ThingsResult<crate::models::BulkOperationResult> {
31        // Validation
32        if request.task_uuids.is_empty() {
33            return Err(ThingsError::validation("Task UUIDs cannot be empty"));
34        }
35        if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
36            return Err(ThingsError::validation(format!(
37                "Batch size {} exceeds maximum of {}",
38                request.task_uuids.len(),
39                Self::MAX_BULK_BATCH_SIZE
40            )));
41        }
42        if request.project_uuid.is_none() && request.area_uuid.is_none() {
43            return Err(ThingsError::validation(
44                "Must specify either project_uuid or area_uuid",
45            ));
46        }
47
48        // Validate target project/area exists
49        if let Some(project_uuid) = &request.project_uuid {
50            validators::validate_project_exists(&self.pool, project_uuid).await?;
51        }
52        if let Some(area_uuid) = &request.area_uuid {
53            validators::validate_area_exists(&self.pool, area_uuid).await?;
54        }
55
56        // Begin transaction
57        let mut tx = self
58            .pool
59            .begin()
60            .await
61            .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
62
63        // Validate all tasks exist in a single batch query (prevent N+1)
64        let placeholders = request
65            .task_uuids
66            .iter()
67            .map(|_| "?")
68            .collect::<Vec<_>>()
69            .join(",");
70        let query_str = format!(
71            "SELECT uuid FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
72            placeholders
73        );
74
75        let mut query = sqlx::query(&query_str);
76        for id in &request.task_uuids {
77            query = query.bind(id.as_str());
78        }
79
80        let found_uuids: Vec<String> = query
81            .fetch_all(&mut *tx)
82            .await
83            .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?
84            .iter()
85            .map(|row| row.get("uuid"))
86            .collect();
87
88        // Check if any UUIDs were not found
89        if found_uuids.len() != request.task_uuids.len() {
90            // Find the first missing UUID for error reporting
91            for id in &request.task_uuids {
92                if !found_uuids.contains(&id.to_string()) {
93                    tx.rollback().await.ok();
94                    return Err(ThingsError::TaskNotFound {
95                        uuid: id.to_string(),
96                    });
97                }
98            }
99        }
100
101        // Batch update
102        let now = Utc::now().timestamp() as f64;
103        let placeholders = request
104            .task_uuids
105            .iter()
106            .map(|_| "?")
107            .collect::<Vec<_>>()
108            .join(",");
109        let query_str = format!(
110            "UPDATE TMTask SET project = ?, area = ?, userModificationDate = ? WHERE uuid IN ({})",
111            placeholders
112        );
113
114        let mut query = sqlx::query(&query_str)
115            .bind(request.project_uuid.map(|u| u.into_string()))
116            .bind(request.area_uuid.map(|u| u.into_string()))
117            .bind(now);
118
119        for id in &request.task_uuids {
120            query = query.bind(id.as_str());
121        }
122
123        query
124            .execute(&mut *tx)
125            .await
126            .map_err(|e| ThingsError::unknown(format!("Failed to bulk move tasks: {e}")))?;
127
128        // Commit transaction
129        tx.commit()
130            .await
131            .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
132
133        info!("Bulk moved {} task(s)", request.task_uuids.len());
134        Ok(crate::models::BulkOperationResult {
135            success: true,
136            processed_count: request.task_uuids.len(),
137            message: format!("Successfully moved {} task(s)", request.task_uuids.len()),
138        })
139    }
140
141    /// Update dates for multiple tasks with validation (transactional)
142    ///
143    /// All tasks must exist and dates must be valid, or the entire operation will be rolled back.
144    /// Validates that deadline >= start_date for each task after merging with existing dates.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error if:
149    /// - Task UUIDs array is empty
150    /// - Any task UUID is invalid or doesn't exist
151    /// - Date range validation fails (deadline before start_date)
152    /// - Database operation fails
153    #[instrument(skip(self))]
154    pub async fn bulk_update_dates(
155        &self,
156        request: crate::models::BulkUpdateDatesRequest,
157    ) -> ThingsResult<crate::models::BulkOperationResult> {
158        use crate::database::{safe_things_date_to_naive_date, validate_date_range};
159
160        // Validation
161        if request.task_uuids.is_empty() {
162            return Err(ThingsError::validation("Task UUIDs cannot be empty"));
163        }
164        if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
165            return Err(ThingsError::validation(format!(
166                "Batch size {} exceeds maximum of {}",
167                request.task_uuids.len(),
168                Self::MAX_BULK_BATCH_SIZE
169            )));
170        }
171
172        // Validate date range if both are provided
173        if let (Some(start), Some(deadline)) = (request.start_date, request.deadline) {
174            validate_date_range(Some(start), Some(deadline))?;
175        }
176
177        // Begin transaction
178        let mut tx = self
179            .pool
180            .begin()
181            .await
182            .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
183
184        // Validate all tasks exist and check merged date validity in a single batch query
185        let placeholders = request
186            .task_uuids
187            .iter()
188            .map(|_| "?")
189            .collect::<Vec<_>>()
190            .join(",");
191        let query_str = format!(
192            "SELECT uuid, startDate, deadline FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
193            placeholders
194        );
195
196        let mut query = sqlx::query(&query_str);
197        for id in &request.task_uuids {
198            query = query.bind(id.as_str());
199        }
200
201        let rows = query
202            .fetch_all(&mut *tx)
203            .await
204            .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?;
205
206        // Check if all UUIDs were found
207        if rows.len() != request.task_uuids.len() {
208            // Find the first missing UUID for error reporting
209            let found_uuids: Vec<String> = rows.iter().map(|row| row.get("uuid")).collect();
210            for id in &request.task_uuids {
211                if !found_uuids.contains(&id.to_string()) {
212                    tx.rollback().await.ok();
213                    return Err(ThingsError::TaskNotFound {
214                        uuid: id.to_string(),
215                    });
216                }
217            }
218        }
219
220        // Validate merged dates for all tasks
221        for row in &rows {
222            let current_start: Option<i64> = row.get("startDate");
223            let current_deadline: Option<i64> = row.get("deadline");
224
225            let final_start = if request.clear_start_date {
226                None
227            } else if let Some(new_start) = request.start_date {
228                Some(new_start)
229            } else {
230                current_start.and_then(|ts| safe_things_date_to_naive_date(ts).ok())
231            };
232
233            let final_deadline = if request.clear_deadline {
234                None
235            } else if let Some(new_deadline) = request.deadline {
236                Some(new_deadline)
237            } else {
238                current_deadline.and_then(|ts| safe_things_date_to_naive_date(ts).ok())
239            };
240
241            validate_date_range(final_start, final_deadline)?;
242        }
243
244        // Build and execute batch update
245        let now = Utc::now().timestamp() as f64;
246        let placeholders = request
247            .task_uuids
248            .iter()
249            .map(|_| "?")
250            .collect::<Vec<_>>()
251            .join(",");
252
253        let start_date_value = if request.clear_start_date {
254            None
255        } else {
256            request.start_date.map(naive_date_to_things_timestamp)
257        };
258
259        let deadline_value = if request.clear_deadline {
260            None
261        } else {
262            request.deadline.map(naive_date_to_things_timestamp)
263        };
264
265        let query_str = format!(
266            "UPDATE TMTask SET startDate = ?, deadline = ?, userModificationDate = ? WHERE uuid IN ({})",
267            placeholders
268        );
269
270        let mut query = sqlx::query(&query_str)
271            .bind(start_date_value)
272            .bind(deadline_value)
273            .bind(now);
274
275        for id in &request.task_uuids {
276            query = query.bind(id.as_str());
277        }
278
279        query
280            .execute(&mut *tx)
281            .await
282            .map_err(|e| ThingsError::unknown(format!("Failed to bulk update dates: {e}")))?;
283
284        tx.commit()
285            .await
286            .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
287
288        info!(
289            "Bulk updated dates for {} task(s)",
290            request.task_uuids.len()
291        );
292        Ok(crate::models::BulkOperationResult {
293            success: true,
294            processed_count: request.task_uuids.len(),
295            message: format!(
296                "Successfully updated dates for {} task(s)",
297                request.task_uuids.len()
298            ),
299        })
300    }
301
302    /// Complete multiple tasks (transactional)
303    ///
304    /// All tasks must exist, or the entire operation will be rolled back.
305    ///
306    /// # Errors
307    ///
308    /// Returns an error if:
309    /// - Task UUIDs array is empty
310    /// - Any task UUID is invalid or doesn't exist
311    /// - Database operation fails
312    #[instrument(skip(self))]
313    pub async fn bulk_complete(
314        &self,
315        request: crate::models::BulkCompleteRequest,
316    ) -> ThingsResult<crate::models::BulkOperationResult> {
317        // Validation
318        if request.task_uuids.is_empty() {
319            return Err(ThingsError::validation("Task UUIDs cannot be empty"));
320        }
321        if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
322            return Err(ThingsError::validation(format!(
323                "Batch size {} exceeds maximum of {}",
324                request.task_uuids.len(),
325                Self::MAX_BULK_BATCH_SIZE
326            )));
327        }
328
329        // Begin transaction
330        let mut tx = self
331            .pool
332            .begin()
333            .await
334            .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
335
336        // Validate all tasks exist in a single batch query (prevent N+1)
337        let placeholders = request
338            .task_uuids
339            .iter()
340            .map(|_| "?")
341            .collect::<Vec<_>>()
342            .join(",");
343        let query_str = format!(
344            "SELECT uuid FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
345            placeholders
346        );
347
348        let mut query = sqlx::query(&query_str);
349        for id in &request.task_uuids {
350            query = query.bind(id.as_str());
351        }
352
353        let found_uuids: Vec<String> = query
354            .fetch_all(&mut *tx)
355            .await
356            .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?
357            .iter()
358            .map(|row| row.get("uuid"))
359            .collect();
360
361        // Check if any UUIDs were not found
362        if found_uuids.len() != request.task_uuids.len() {
363            // Find the first missing UUID for error reporting
364            for id in &request.task_uuids {
365                if !found_uuids.contains(&id.to_string()) {
366                    tx.rollback().await.ok();
367                    return Err(ThingsError::TaskNotFound {
368                        uuid: id.to_string(),
369                    });
370                }
371            }
372        }
373
374        // Batch update: mark as completed
375        let now = Utc::now().timestamp() as f64;
376        let placeholders = request
377            .task_uuids
378            .iter()
379            .map(|_| "?")
380            .collect::<Vec<_>>()
381            .join(",");
382        let query_str = format!(
383            "UPDATE TMTask SET status = 3, stopDate = ?, userModificationDate = ? WHERE uuid IN ({})",
384            placeholders
385        );
386
387        let mut query = sqlx::query(&query_str).bind(now).bind(now);
388
389        for id in &request.task_uuids {
390            query = query.bind(id.as_str());
391        }
392
393        query
394            .execute(&mut *tx)
395            .await
396            .map_err(|e| ThingsError::unknown(format!("Failed to bulk complete tasks: {e}")))?;
397
398        // Commit transaction
399        tx.commit()
400            .await
401            .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
402
403        info!("Bulk completed {} task(s)", request.task_uuids.len());
404        Ok(crate::models::BulkOperationResult {
405            success: true,
406            processed_count: request.task_uuids.len(),
407            message: format!(
408                "Successfully completed {} task(s)",
409                request.task_uuids.len()
410            ),
411        })
412    }
413
414    /// Delete multiple tasks (soft delete, transactional)
415    ///
416    /// All tasks must exist, or the entire operation will be rolled back.
417    ///
418    /// # Errors
419    ///
420    /// Returns an error if:
421    /// - Task UUIDs array is empty
422    /// - Any task UUID is invalid or doesn't exist
423    /// - Database operation fails
424    #[instrument(skip(self))]
425    pub async fn bulk_delete(
426        &self,
427        request: crate::models::BulkDeleteRequest,
428    ) -> ThingsResult<crate::models::BulkOperationResult> {
429        // Validation
430        if request.task_uuids.is_empty() {
431            return Err(ThingsError::validation("Task UUIDs cannot be empty"));
432        }
433        if request.task_uuids.len() > Self::MAX_BULK_BATCH_SIZE {
434            return Err(ThingsError::validation(format!(
435                "Batch size {} exceeds maximum of {}",
436                request.task_uuids.len(),
437                Self::MAX_BULK_BATCH_SIZE
438            )));
439        }
440
441        // Begin transaction
442        let mut tx = self
443            .pool
444            .begin()
445            .await
446            .map_err(|e| ThingsError::unknown(format!("Failed to begin transaction: {e}")))?;
447
448        // Validate all tasks exist in a single batch query (prevent N+1)
449        let placeholders = request
450            .task_uuids
451            .iter()
452            .map(|_| "?")
453            .collect::<Vec<_>>()
454            .join(",");
455        let query_str = format!(
456            "SELECT uuid FROM TMTask WHERE uuid IN ({}) AND trashed = 0",
457            placeholders
458        );
459
460        let mut query = sqlx::query(&query_str);
461        for id in &request.task_uuids {
462            query = query.bind(id.as_str());
463        }
464
465        let found_uuids: Vec<String> = query
466            .fetch_all(&mut *tx)
467            .await
468            .map_err(|e| ThingsError::unknown(format!("Failed to validate tasks: {e}")))?
469            .iter()
470            .map(|row| row.get("uuid"))
471            .collect();
472
473        // Check if any UUIDs were not found
474        if found_uuids.len() != request.task_uuids.len() {
475            // Find the first missing UUID for error reporting
476            for id in &request.task_uuids {
477                if !found_uuids.contains(&id.to_string()) {
478                    tx.rollback().await.ok();
479                    return Err(ThingsError::TaskNotFound {
480                        uuid: id.to_string(),
481                    });
482                }
483            }
484        }
485
486        // Batch update: soft delete
487        let now = Utc::now().timestamp() as f64;
488        let placeholders = request
489            .task_uuids
490            .iter()
491            .map(|_| "?")
492            .collect::<Vec<_>>()
493            .join(",");
494        let query_str = format!(
495            "UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid IN ({})",
496            placeholders
497        );
498
499        let mut query = sqlx::query(&query_str).bind(now);
500
501        for id in &request.task_uuids {
502            query = query.bind(id.as_str());
503        }
504
505        query
506            .execute(&mut *tx)
507            .await
508            .map_err(|e| ThingsError::unknown(format!("Failed to bulk delete tasks: {e}")))?;
509
510        // Commit transaction
511        tx.commit()
512            .await
513            .map_err(|e| ThingsError::unknown(format!("Failed to commit transaction: {e}")))?;
514
515        info!("Bulk deleted {} task(s)", request.task_uuids.len());
516        Ok(crate::models::BulkOperationResult {
517            success: true,
518            processed_count: request.task_uuids.len(),
519            message: format!("Successfully deleted {} task(s)", request.task_uuids.len()),
520        })
521    }
522}