Skip to main content

things3_core/database/mutations/
bulk.rs

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