From a5ebbd59bd1174458d9c32190453bcd67673eebe Mon Sep 17 00:00:00 2001 From: aaldebs99 Date: Fri, 20 Jun 2025 03:49:16 +0000 Subject: [PATCH] feat(everything): Add document deletion --- frontend/src/pages/DocumentsPage.tsx | 293 ++++++++++++++++++++++++++- frontend/src/services/api.ts | 10 + src/db/documents.rs | 150 ++++++++++++++ src/file_service.rs | 69 +++++++ src/routes/documents.rs | 118 ++++++++++- 5 files changed, 629 insertions(+), 11 deletions(-) diff --git a/frontend/src/pages/DocumentsPage.tsx b/frontend/src/pages/DocumentsPage.tsx index 90a4b95..9f88d5a 100644 --- a/frontend/src/pages/DocumentsPage.tsx +++ b/frontend/src/pages/DocumentsPage.tsx @@ -29,6 +29,9 @@ import { DialogTitle, DialogContent, DialogActions, + Checkbox, + Fab, + Tooltip, } from '@mui/material'; import Grid from '@mui/material/GridLegacy'; import { @@ -49,6 +52,11 @@ import { ChevronLeft as ChevronLeftIcon, ChevronRight as ChevronRightIcon, Edit as EditIcon, + Delete as DeleteIcon, + CheckBoxOutlineBlank as CheckBoxOutlineBlankIcon, + CheckBox as CheckBoxIcon, + SelectAll as SelectAllIcon, + Close as CloseIcon, } from '@mui/icons-material'; import { documentService } from '../services/api'; import DocumentThumbnail from '../components/DocumentThumbnail'; @@ -110,6 +118,17 @@ const DocumentsPage: React.FC = () => { const [sortMenuAnchor, setSortMenuAnchor] = useState(null); const [docMenuAnchor, setDocMenuAnchor] = useState(null); const [selectedDoc, setSelectedDoc] = useState(null); + + // Delete confirmation dialog state + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [documentToDelete, setDocumentToDelete] = useState(null); + const [deleteLoading, setDeleteLoading] = useState(false); + + // Mass selection state + const [selectionMode, setSelectionMode] = useState(false); + const [selectedDocuments, setSelectedDocuments] = useState>(new Set()); + const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false); + const [bulkDeleteLoading, setBulkDeleteLoading] = useState(false); useEffect(() => { fetchDocuments(); @@ -281,6 +300,37 @@ const DocumentsPage: React.FC = () => { handleSortMenuClose(); }; + const handleDeleteClick = (doc: Document): void => { + setDocumentToDelete(doc); + setDeleteDialogOpen(true); + handleDocMenuClose(); + }; + + const handleDeleteConfirm = async (): Promise => { + if (!documentToDelete) return; + + try { + setDeleteLoading(true); + await documentService.delete(documentToDelete.id); + + setDocuments(prev => prev.filter(doc => doc.id !== documentToDelete.id)); + setPagination(prev => ({ ...prev, total: prev.total - 1 })); + + setDeleteDialogOpen(false); + setDocumentToDelete(null); + } catch (error) { + console.error('Failed to delete document:', error); + setError('Failed to delete document'); + } finally { + setDeleteLoading(false); + } + }; + + const handleDeleteCancel = (): void => { + setDeleteDialogOpen(false); + setDocumentToDelete(null); + }; + const handlePageChange = (event: React.ChangeEvent, page: number): void => { const newOffset = (page - 1) * pagination.limit; setPagination(prev => ({ ...prev, offset: newOffset })); @@ -291,6 +341,61 @@ const DocumentsPage: React.FC = () => { setPagination(prev => ({ ...prev, offset: 0 })); // Reset to first page when filtering }; + // Mass selection handlers + const handleToggleSelectionMode = (): void => { + setSelectionMode(!selectionMode); + setSelectedDocuments(new Set()); + }; + + const handleDocumentSelect = (documentId: string, isSelected: boolean): void => { + const newSelection = new Set(selectedDocuments); + if (isSelected) { + newSelection.add(documentId); + } else { + newSelection.delete(documentId); + } + setSelectedDocuments(newSelection); + }; + + const handleSelectAll = (): void => { + if (selectedDocuments.size === sortedDocuments.length) { + setSelectedDocuments(new Set()); + } else { + setSelectedDocuments(new Set(sortedDocuments.map(doc => doc.id))); + } + }; + + const handleBulkDelete = (): void => { + if (selectedDocuments.size === 0) return; + setBulkDeleteDialogOpen(true); + }; + + const handleBulkDeleteConfirm = async (): Promise => { + if (selectedDocuments.size === 0) return; + + try { + setBulkDeleteLoading(true); + const documentIds = Array.from(selectedDocuments); + await documentService.bulkDelete(documentIds); + + setDocuments(prev => prev.filter(doc => !selectedDocuments.has(doc.id))); + setPagination(prev => ({ ...prev, total: prev.total - selectedDocuments.size })); + + setSelectedDocuments(new Set()); + setSelectionMode(false); + setBulkDeleteDialogOpen(false); + } catch (error) { + console.error('Failed to delete documents:', error); + setError('Failed to delete selected documents'); + } finally { + setBulkDeleteLoading(false); + } + }; + + const handleBulkDeleteCancel = (): void => { + setBulkDeleteDialogOpen(false); + }; + const getOcrStatusChip = (doc: Document) => { if (!doc.ocr_status) return null; @@ -392,6 +497,17 @@ const DocumentsPage: React.FC = () => { + {/* Selection Mode Toggle */} + + {/* OCR Filter */} OCR Status @@ -419,6 +535,43 @@ const DocumentsPage: React.FC = () => { + {/* Selection Toolbar */} + {selectionMode && ( + + + {selectedDocuments.size} of {sortedDocuments.length} documents selected + + + + + )} + {/* Sort Menu */} { Edit Labels + + { + if (selectedDoc) handleDeleteClick(selectedDoc); + }}> + + Delete + {/* Documents Grid/List */} @@ -517,13 +677,50 @@ const DocumentsPage: React.FC = () => { flexDirection: viewMode === 'list' ? 'row' : 'column', transition: 'all 0.2s ease-in-out', cursor: 'pointer', + position: 'relative', '&:hover': { transform: 'translateY(-4px)', boxShadow: (theme) => theme.shadows[4], }, + ...(selectionMode && selectedDocuments.has(doc.id) && { + boxShadow: (theme) => `0 0 0 2px ${theme.palette.primary.main}`, + bgcolor: 'primary.light', + }), + }} + onClick={(e) => { + if (selectionMode) { + e.stopPropagation(); + handleDocumentSelect(doc.id, !selectedDocuments.has(doc.id)); + } else { + navigate(`/documents/${doc.id}`); + } }} - onClick={() => navigate(`/documents/${doc.id}`)} > + {/* Selection checkbox */} + {selectionMode && ( + { + e.stopPropagation(); + handleDocumentSelect(doc.id, !selectedDocuments.has(doc.id)); + }} + > + + + )} + {viewMode === 'grid' && ( { - { - e.stopPropagation(); - handleDocMenuClick(e, doc); - }} - > - - + {!selectionMode && ( + { + e.stopPropagation(); + handleDocMenuClick(e, doc); + }} + > + + + )} @@ -683,6 +882,80 @@ const DocumentsPage: React.FC = () => { + {/* Delete Confirmation Dialog */} + + Delete Document + + + Are you sure you want to delete "{documentToDelete?.original_filename}"? + + + This action cannot be undone. The document file and all associated data will be permanently removed. + + + + + + + + + {/* Bulk Delete Confirmation Dialog */} + + Delete Multiple Documents + + + Are you sure you want to delete {selectedDocuments.size} selected document{selectedDocuments.size !== 1 ? 's' : ''}? + + + This action cannot be undone. All selected documents and their associated data will be permanently removed. + + {selectedDocuments.size > 0 && ( + + + Documents to be deleted: + + {Array.from(selectedDocuments).slice(0, 10).map(docId => { + const doc = documents.find(d => d.id === docId); + return doc ? ( + + • {doc.original_filename} + + ) : null; + })} + {selectedDocuments.size > 10 && ( + + ... and {selectedDocuments.size - 10} more + + )} + + )} + + + + + + + {/* Results count and pagination */} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d2a7f14..e4c15c2 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -209,6 +209,16 @@ export const documentService = { getFacets: () => { return api.get('/search/facets') }, + + delete: (id: string) => { + return api.delete(`/documents/${id}`) + }, + + bulkDelete: (documentIds: string[]) => { + return api.delete('/documents', { + data: { document_ids: documentIds } + }) + }, } export interface OcrStatusResponse { diff --git a/src/db/documents.rs b/src/db/documents.rs index a837e80..3bdec41 100644 --- a/src/db/documents.rs +++ b/src/db/documents.rs @@ -1359,4 +1359,154 @@ impl Database { Ok(labels_map) } + + pub async fn delete_document(&self, document_id: Uuid, user_id: Uuid, user_role: crate::models::UserRole) -> Result> { + let document = if user_role == crate::models::UserRole::Admin { + let row = sqlx::query( + r#" + DELETE FROM documents + WHERE id = $1 + RETURNING id, filename, original_filename, file_path, file_size, mime_type, content, ocr_text, ocr_confidence, ocr_word_count, ocr_processing_time_ms, ocr_status, ocr_error, ocr_completed_at, tags, created_at, updated_at, user_id, file_hash + "#, + ) + .bind(document_id) + .fetch_optional(&self.pool) + .await?; + + row.map(|r| Document { + id: r.get("id"), + filename: r.get("filename"), + original_filename: r.get("original_filename"), + file_path: r.get("file_path"), + file_size: r.get("file_size"), + mime_type: r.get("mime_type"), + content: r.get("content"), + ocr_text: r.get("ocr_text"), + ocr_confidence: r.get("ocr_confidence"), + ocr_word_count: r.get("ocr_word_count"), + ocr_processing_time_ms: r.get("ocr_processing_time_ms"), + ocr_status: r.get("ocr_status"), + ocr_error: r.get("ocr_error"), + ocr_completed_at: r.get("ocr_completed_at"), + tags: r.get("tags"), + created_at: r.get("created_at"), + updated_at: r.get("updated_at"), + user_id: r.get("user_id"), + file_hash: r.get("file_hash"), + }) + } else { + let row = sqlx::query( + r#" + DELETE FROM documents + WHERE id = $1 AND user_id = $2 + RETURNING id, filename, original_filename, file_path, file_size, mime_type, content, ocr_text, ocr_confidence, ocr_word_count, ocr_processing_time_ms, ocr_status, ocr_error, ocr_completed_at, tags, created_at, updated_at, user_id, file_hash + "#, + ) + .bind(document_id) + .bind(user_id) + .fetch_optional(&self.pool) + .await?; + + row.map(|r| Document { + id: r.get("id"), + filename: r.get("filename"), + original_filename: r.get("original_filename"), + file_path: r.get("file_path"), + file_size: r.get("file_size"), + mime_type: r.get("mime_type"), + content: r.get("content"), + ocr_text: r.get("ocr_text"), + ocr_confidence: r.get("ocr_confidence"), + ocr_word_count: r.get("ocr_word_count"), + ocr_processing_time_ms: r.get("ocr_processing_time_ms"), + ocr_status: r.get("ocr_status"), + ocr_error: r.get("ocr_error"), + ocr_completed_at: r.get("ocr_completed_at"), + tags: r.get("tags"), + created_at: r.get("created_at"), + updated_at: r.get("updated_at"), + user_id: r.get("user_id"), + file_hash: r.get("file_hash"), + }) + }; + + Ok(document) + } + + pub async fn bulk_delete_documents(&self, document_ids: &[uuid::Uuid], user_id: uuid::Uuid, user_role: crate::models::UserRole) -> Result> { + if document_ids.is_empty() { + return Ok(Vec::new()); + } + + let deleted_documents = if user_role == crate::models::UserRole::Admin { + let rows = sqlx::query( + r#" + DELETE FROM documents + WHERE id = ANY($1) + RETURNING id, filename, original_filename, file_path, file_size, mime_type, content, ocr_text, ocr_confidence, ocr_word_count, ocr_processing_time_ms, ocr_status, ocr_error, ocr_completed_at, tags, created_at, updated_at, user_id, file_hash + "#, + ) + .bind(document_ids) + .fetch_all(&self.pool) + .await?; + + rows.into_iter().map(|r| Document { + id: r.get("id"), + filename: r.get("filename"), + original_filename: r.get("original_filename"), + file_path: r.get("file_path"), + file_size: r.get("file_size"), + mime_type: r.get("mime_type"), + content: r.get("content"), + ocr_text: r.get("ocr_text"), + ocr_confidence: r.get("ocr_confidence"), + ocr_word_count: r.get("ocr_word_count"), + ocr_processing_time_ms: r.get("ocr_processing_time_ms"), + ocr_status: r.get("ocr_status"), + ocr_error: r.get("ocr_error"), + ocr_completed_at: r.get("ocr_completed_at"), + tags: r.get("tags"), + created_at: r.get("created_at"), + updated_at: r.get("updated_at"), + user_id: r.get("user_id"), + file_hash: r.get("file_hash"), + }).collect() + } else { + let rows = sqlx::query( + r#" + DELETE FROM documents + WHERE id = ANY($1) AND user_id = $2 + RETURNING id, filename, original_filename, file_path, file_size, mime_type, content, ocr_text, ocr_confidence, ocr_word_count, ocr_processing_time_ms, ocr_status, ocr_error, ocr_completed_at, tags, created_at, updated_at, user_id, file_hash + "#, + ) + .bind(document_ids) + .bind(user_id) + .fetch_all(&self.pool) + .await?; + + rows.into_iter().map(|r| Document { + id: r.get("id"), + filename: r.get("filename"), + original_filename: r.get("original_filename"), + file_path: r.get("file_path"), + file_size: r.get("file_size"), + mime_type: r.get("mime_type"), + content: r.get("content"), + ocr_text: r.get("ocr_text"), + ocr_confidence: r.get("ocr_confidence"), + ocr_word_count: r.get("ocr_word_count"), + ocr_processing_time_ms: r.get("ocr_processing_time_ms"), + ocr_status: r.get("ocr_status"), + ocr_error: r.get("ocr_error"), + ocr_completed_at: r.get("ocr_completed_at"), + tags: r.get("tags"), + created_at: r.get("created_at"), + updated_at: r.get("updated_at"), + user_id: r.get("user_id"), + file_hash: r.get("file_hash"), + }).collect() + }; + + Ok(deleted_documents) + } } \ No newline at end of file diff --git a/src/file_service.rs b/src/file_service.rs index 2b4ef15..641edda 100644 --- a/src/file_service.rs +++ b/src/file_service.rs @@ -429,4 +429,73 @@ impl FileService { pub async fn get_or_generate_thumbnail(&self, _file_path: &str, _filename: &str) -> Result> { anyhow::bail!("Thumbnail generation requires OCR feature") } + + pub async fn delete_document_files(&self, document: &Document) -> Result<()> { + let mut deleted_files = Vec::new(); + let mut errors = Vec::new(); + + // Delete main document file + let main_file = Path::new(&document.file_path); + if main_file.exists() { + match fs::remove_file(&main_file).await { + Ok(_) => { + deleted_files.push(main_file.to_string_lossy().to_string()); + info!("Deleted document file: {}", document.file_path); + } + Err(e) => { + errors.push(format!("Failed to delete document file {}: {}", document.file_path, e)); + warn!("Failed to delete document file {}: {}", document.file_path, e); + } + } + } + + // Delete thumbnail if it exists + let thumbnail_filename = format!("{}_thumb.jpg", document.id); + let thumbnail_path = self.get_thumbnails_path().join(&thumbnail_filename); + if thumbnail_path.exists() { + match fs::remove_file(&thumbnail_path).await { + Ok(_) => { + deleted_files.push(thumbnail_path.to_string_lossy().to_string()); + info!("Deleted thumbnail: {}", thumbnail_path.display()); + } + Err(e) => { + errors.push(format!("Failed to delete thumbnail {}: {}", thumbnail_path.display(), e)); + warn!("Failed to delete thumbnail {}: {}", thumbnail_path.display(), e); + } + } + } + + // Delete processed image if it exists + let processed_image_filename = format!("{}_processed.png", document.id); + let processed_image_path = self.get_processed_images_path().join(&processed_image_filename); + if processed_image_path.exists() { + match fs::remove_file(&processed_image_path).await { + Ok(_) => { + deleted_files.push(processed_image_path.to_string_lossy().to_string()); + info!("Deleted processed image: {}", processed_image_path.display()); + } + Err(e) => { + errors.push(format!("Failed to delete processed image {}: {}", processed_image_path.display(), e)); + warn!("Failed to delete processed image {}: {}", processed_image_path.display(), e); + } + } + } + + if !errors.is_empty() { + // Log all deletion results + if !deleted_files.is_empty() { + info!("Successfully deleted {} files for document {}", deleted_files.len(), document.id); + } + error!("Failed to delete some files for document {}: {}", document.id, errors.join("; ")); + return Err(anyhow::anyhow!("Partial file deletion failure: {}", errors.join("; "))); + } + + if deleted_files.is_empty() { + warn!("No files found to delete for document {}", document.id); + } else { + info!("Successfully deleted all {} files for document {}", deleted_files.len(), document.id); + } + + Ok(()) + } } \ No newline at end of file diff --git a/src/routes/documents.rs b/src/routes/documents.rs index fe5fe45..2b7076e 100644 --- a/src/routes/documents.rs +++ b/src/routes/documents.rs @@ -2,7 +2,7 @@ use axum::{ extract::{Multipart, Path, Query, State}, http::{StatusCode, header::CONTENT_TYPE}, response::{Json, Response}, - routing::{get, post}, + routing::{get, post, delete}, Router, }; use serde::Deserialize; @@ -27,11 +27,18 @@ struct PaginationQuery { ocr_status: Option, } +#[derive(Deserialize, ToSchema)] +struct BulkDeleteRequest { + document_ids: Vec, +} + pub fn router() -> Router> { Router::new() .route("/", post(upload_document)) .route("/", get(list_documents)) + .route("/", delete(bulk_delete_documents)) .route("/{id}", get(get_document_by_id)) + .route("/{id}", delete(delete_document)) .route("/{id}/download", get(download_document)) .route("/{id}/view", get(view_document)) .route("/{id}/thumbnail", get(get_document_thumbnail)) @@ -976,4 +983,113 @@ async fn get_user_duplicates( }); Ok(Json(response)) +} + +#[utoipa::path( + delete, + path = "/api/documents/{id}", + tag = "documents", + security( + ("bearer_auth" = []) + ), + params( + ("id" = uuid::Uuid, Path, description = "Document ID") + ), + responses( + (status = 200, description = "Document deleted successfully", body = String), + (status = 404, description = "Document not found"), + (status = 401, description = "Unauthorized") + ) +)] +async fn delete_document( + State(state): State>, + auth_user: AuthUser, + Path(document_id): Path, +) -> Result, StatusCode> { + let deleted_document = state + .db + .delete_document(document_id, auth_user.user.id, auth_user.user.role) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let file_service = FileService::new(state.config.upload_path.clone()); + + if let Err(e) = file_service.delete_document_files(&deleted_document).await { + tracing::warn!("Failed to delete some files for document {}: {}", document_id, e); + } + + Ok(Json(serde_json::json!({ + "success": true, + "message": "Document deleted successfully", + "document_id": document_id, + "filename": deleted_document.filename + }))) +} + +#[utoipa::path( + delete, + path = "/api/documents", + tag = "documents", + security( + ("bearer_auth" = []) + ), + request_body(content = BulkDeleteRequest, description = "List of document IDs to delete"), + responses( + (status = 200, description = "Documents deleted successfully", body = String), + (status = 400, description = "Bad request - no document IDs provided"), + (status = 401, description = "Unauthorized") + ) +)] +async fn bulk_delete_documents( + State(state): State>, + auth_user: AuthUser, + Json(request): Json, +) -> Result, StatusCode> { + if request.document_ids.is_empty() { + return Ok(Json(serde_json::json!({ + "success": false, + "message": "No document IDs provided", + "deleted_count": 0 + }))); + } + + let deleted_documents = state + .db + .bulk_delete_documents(&request.document_ids, auth_user.user.id, auth_user.user.role) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let file_service = FileService::new(state.config.upload_path.clone()); + let mut successful_file_deletions = 0; + let mut failed_file_deletions = 0; + + for document in &deleted_documents { + match file_service.delete_document_files(document).await { + Ok(_) => successful_file_deletions += 1, + Err(e) => { + failed_file_deletions += 1; + tracing::warn!("Failed to delete files for document {}: {}", document.id, e); + } + } + } + + let deleted_count = deleted_documents.len(); + let requested_count = request.document_ids.len(); + + let message = if deleted_count == requested_count { + format!("Successfully deleted {} documents", deleted_count) + } else { + format!("Deleted {} of {} requested documents (some may not exist or belong to other users)", deleted_count, requested_count) + }; + + Ok(Json(serde_json::json!({ + "success": true, + "message": message, + "deleted_count": deleted_count, + "requested_count": requested_count, + "successful_file_deletions": successful_file_deletions, + "failed_file_deletions": failed_file_deletions, + "deleted_document_ids": deleted_documents.iter().map(|d| d.id).collect::>() + }))) } \ No newline at end of file