feat(everything): Add document deletion

This commit is contained in:
aaldebs99 2025-06-20 03:49:16 +00:00
parent b24bf2c7d9
commit 1507532083
5 changed files with 629 additions and 11 deletions

View File

@ -29,6 +29,9 @@ import {
DialogTitle, DialogTitle,
DialogContent, DialogContent,
DialogActions, DialogActions,
Checkbox,
Fab,
Tooltip,
} from '@mui/material'; } from '@mui/material';
import Grid from '@mui/material/GridLegacy'; import Grid from '@mui/material/GridLegacy';
import { import {
@ -49,6 +52,11 @@ import {
ChevronLeft as ChevronLeftIcon, ChevronLeft as ChevronLeftIcon,
ChevronRight as ChevronRightIcon, ChevronRight as ChevronRightIcon,
Edit as EditIcon, Edit as EditIcon,
Delete as DeleteIcon,
CheckBoxOutlineBlank as CheckBoxOutlineBlankIcon,
CheckBox as CheckBoxIcon,
SelectAll as SelectAllIcon,
Close as CloseIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { documentService } from '../services/api'; import { documentService } from '../services/api';
import DocumentThumbnail from '../components/DocumentThumbnail'; import DocumentThumbnail from '../components/DocumentThumbnail';
@ -111,6 +119,17 @@ const DocumentsPage: React.FC = () => {
const [docMenuAnchor, setDocMenuAnchor] = useState<null | HTMLElement>(null); const [docMenuAnchor, setDocMenuAnchor] = useState<null | HTMLElement>(null);
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null); const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
// Delete confirmation dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState<boolean>(false);
const [documentToDelete, setDocumentToDelete] = useState<Document | null>(null);
const [deleteLoading, setDeleteLoading] = useState<boolean>(false);
// Mass selection state
const [selectionMode, setSelectionMode] = useState<boolean>(false);
const [selectedDocuments, setSelectedDocuments] = useState<Set<string>>(new Set());
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState<boolean>(false);
const [bulkDeleteLoading, setBulkDeleteLoading] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
fetchDocuments(); fetchDocuments();
fetchLabels(); fetchLabels();
@ -281,6 +300,37 @@ const DocumentsPage: React.FC = () => {
handleSortMenuClose(); handleSortMenuClose();
}; };
const handleDeleteClick = (doc: Document): void => {
setDocumentToDelete(doc);
setDeleteDialogOpen(true);
handleDocMenuClose();
};
const handleDeleteConfirm = async (): Promise<void> => {
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<unknown>, page: number): void => { const handlePageChange = (event: React.ChangeEvent<unknown>, page: number): void => {
const newOffset = (page - 1) * pagination.limit; const newOffset = (page - 1) * pagination.limit;
setPagination(prev => ({ ...prev, offset: newOffset })); setPagination(prev => ({ ...prev, offset: newOffset }));
@ -291,6 +341,61 @@ const DocumentsPage: React.FC = () => {
setPagination(prev => ({ ...prev, offset: 0 })); // Reset to first page when filtering 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<void> => {
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) => { const getOcrStatusChip = (doc: Document) => {
if (!doc.ocr_status) return null; if (!doc.ocr_status) return null;
@ -392,6 +497,17 @@ const DocumentsPage: React.FC = () => {
</ToggleButton> </ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
{/* Selection Mode Toggle */}
<Button
variant={selectionMode ? "contained" : "outlined"}
startIcon={selectionMode ? <CloseIcon /> : <CheckBoxOutlineBlankIcon />}
onClick={handleToggleSelectionMode}
size="small"
color={selectionMode ? "secondary" : "primary"}
>
{selectionMode ? 'Cancel' : 'Select'}
</Button>
{/* OCR Filter */} {/* OCR Filter */}
<FormControl size="small" sx={{ minWidth: 120 }}> <FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>OCR Status</InputLabel> <InputLabel>OCR Status</InputLabel>
@ -419,6 +535,43 @@ const DocumentsPage: React.FC = () => {
</Button> </Button>
</Box> </Box>
{/* Selection Toolbar */}
{selectionMode && (
<Box sx={{
mb: 2,
p: 2,
bgcolor: 'primary.light',
borderRadius: 1,
display: 'flex',
alignItems: 'center',
gap: 2,
color: 'primary.contrastText'
}}>
<Typography variant="body2" sx={{ flexGrow: 1 }}>
{selectedDocuments.size} of {sortedDocuments.length} documents selected
</Typography>
<Button
variant="text"
startIcon={selectedDocuments.size === sortedDocuments.length ? <CheckBoxIcon /> : <CheckBoxOutlineBlankIcon />}
onClick={handleSelectAll}
size="small"
sx={{ color: 'primary.contrastText' }}
>
{selectedDocuments.size === sortedDocuments.length ? 'Deselect All' : 'Select All'}
</Button>
<Button
variant="contained"
startIcon={<DeleteIcon />}
onClick={handleBulkDelete}
disabled={selectedDocuments.size === 0}
size="small"
color="error"
>
Delete Selected ({selectedDocuments.size})
</Button>
</Box>
)}
{/* Sort Menu */} {/* Sort Menu */}
<Menu <Menu
anchorEl={sortMenuAnchor} anchorEl={sortMenuAnchor}
@ -478,6 +631,13 @@ const DocumentsPage: React.FC = () => {
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon> <ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
<ListItemText>Edit Labels</ListItemText> <ListItemText>Edit Labels</ListItemText>
</MenuItem> </MenuItem>
<Divider />
<MenuItem onClick={() => {
if (selectedDoc) handleDeleteClick(selectedDoc);
}}>
<ListItemIcon><DeleteIcon fontSize="small" color="error" /></ListItemIcon>
<ListItemText>Delete</ListItemText>
</MenuItem>
</Menu> </Menu>
{/* Documents Grid/List */} {/* Documents Grid/List */}
@ -517,13 +677,50 @@ const DocumentsPage: React.FC = () => {
flexDirection: viewMode === 'list' ? 'row' : 'column', flexDirection: viewMode === 'list' ? 'row' : 'column',
transition: 'all 0.2s ease-in-out', transition: 'all 0.2s ease-in-out',
cursor: 'pointer', cursor: 'pointer',
position: 'relative',
'&:hover': { '&:hover': {
transform: 'translateY(-4px)', transform: 'translateY(-4px)',
boxShadow: (theme) => theme.shadows[4], 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 && (
<Box
sx={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 1,
bgcolor: 'background.paper',
borderRadius: '50%',
boxShadow: 1,
}}
onClick={(e) => {
e.stopPropagation();
handleDocumentSelect(doc.id, !selectedDocuments.has(doc.id));
}}
>
<Checkbox
checked={selectedDocuments.has(doc.id)}
size="small"
color="primary"
/>
</Box>
)}
{viewMode === 'grid' && ( {viewMode === 'grid' && (
<Box <Box
sx={{ sx={{
@ -628,6 +825,7 @@ const DocumentsPage: React.FC = () => {
</Typography> </Typography>
</Box> </Box>
{!selectionMode && (
<IconButton <IconButton
size="small" size="small"
onClick={(e) => { onClick={(e) => {
@ -637,6 +835,7 @@ const DocumentsPage: React.FC = () => {
> >
<MoreIcon /> <MoreIcon />
</IconButton> </IconButton>
)}
</Box> </Box>
</CardContent> </CardContent>
@ -683,6 +882,80 @@ const DocumentsPage: React.FC = () => {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onClose={handleDeleteCancel} maxWidth="sm">
<DialogTitle>Delete Document</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete "{documentToDelete?.original_filename}"?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
This action cannot be undone. The document file and all associated data will be permanently removed.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteCancel} disabled={deleteLoading}>
Cancel
</Button>
<Button
onClick={handleDeleteConfirm}
color="error"
variant="contained"
disabled={deleteLoading}
startIcon={deleteLoading ? <CircularProgress size={16} color="inherit" /> : <DeleteIcon />}
>
{deleteLoading ? 'Deleting...' : 'Delete'}
</Button>
</DialogActions>
</Dialog>
{/* Bulk Delete Confirmation Dialog */}
<Dialog open={bulkDeleteDialogOpen} onClose={handleBulkDeleteCancel} maxWidth="sm">
<DialogTitle>Delete Multiple Documents</DialogTitle>
<DialogContent>
<Typography gutterBottom>
Are you sure you want to delete {selectedDocuments.size} selected document{selectedDocuments.size !== 1 ? 's' : ''}?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
This action cannot be undone. All selected documents and their associated data will be permanently removed.
</Typography>
{selectedDocuments.size > 0 && (
<Box sx={{ mt: 2, maxHeight: 200, overflow: 'auto' }}>
<Typography variant="subtitle2" gutterBottom>
Documents to be deleted:
</Typography>
{Array.from(selectedDocuments).slice(0, 10).map(docId => {
const doc = documents.find(d => d.id === docId);
return doc ? (
<Typography key={docId} variant="body2" sx={{ pl: 1 }}>
{doc.original_filename}
</Typography>
) : null;
})}
{selectedDocuments.size > 10 && (
<Typography variant="body2" sx={{ pl: 1, fontStyle: 'italic' }}>
... and {selectedDocuments.size - 10} more
</Typography>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleBulkDeleteCancel} disabled={bulkDeleteLoading}>
Cancel
</Button>
<Button
onClick={handleBulkDeleteConfirm}
color="error"
variant="contained"
disabled={bulkDeleteLoading}
startIcon={bulkDeleteLoading ? <CircularProgress size={16} color="inherit" /> : <DeleteIcon />}
>
{bulkDeleteLoading ? 'Deleting...' : `Delete ${selectedDocuments.size} Document${selectedDocuments.size !== 1 ? 's' : ''}`}
</Button>
</DialogActions>
</Dialog>
{/* Results count and pagination */} {/* Results count and pagination */}
<Box sx={{ mt: 3 }}> <Box sx={{ mt: 3 }}>
<Box sx={{ textAlign: 'center', mb: 2 }}> <Box sx={{ textAlign: 'center', mb: 2 }}>

View File

@ -209,6 +209,16 @@ export const documentService = {
getFacets: () => { getFacets: () => {
return api.get<SearchFacetsResponse>('/search/facets') return api.get<SearchFacetsResponse>('/search/facets')
}, },
delete: (id: string) => {
return api.delete(`/documents/${id}`)
},
bulkDelete: (documentIds: string[]) => {
return api.delete('/documents', {
data: { document_ids: documentIds }
})
},
} }
export interface OcrStatusResponse { export interface OcrStatusResponse {

View File

@ -1359,4 +1359,154 @@ impl Database {
Ok(labels_map) Ok(labels_map)
} }
pub async fn delete_document(&self, document_id: Uuid, user_id: Uuid, user_role: crate::models::UserRole) -> Result<Option<Document>> {
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<Vec<Document>> {
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)
}
} }

View File

@ -429,4 +429,73 @@ impl FileService {
pub async fn get_or_generate_thumbnail(&self, _file_path: &str, _filename: &str) -> Result<Vec<u8>> { pub async fn get_or_generate_thumbnail(&self, _file_path: &str, _filename: &str) -> Result<Vec<u8>> {
anyhow::bail!("Thumbnail generation requires OCR feature") 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(())
}
} }

View File

@ -2,7 +2,7 @@ use axum::{
extract::{Multipart, Path, Query, State}, extract::{Multipart, Path, Query, State},
http::{StatusCode, header::CONTENT_TYPE}, http::{StatusCode, header::CONTENT_TYPE},
response::{Json, Response}, response::{Json, Response},
routing::{get, post}, routing::{get, post, delete},
Router, Router,
}; };
use serde::Deserialize; use serde::Deserialize;
@ -27,11 +27,18 @@ struct PaginationQuery {
ocr_status: Option<String>, ocr_status: Option<String>,
} }
#[derive(Deserialize, ToSchema)]
struct BulkDeleteRequest {
document_ids: Vec<uuid::Uuid>,
}
pub fn router() -> Router<Arc<AppState>> { pub fn router() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route("/", post(upload_document)) .route("/", post(upload_document))
.route("/", get(list_documents)) .route("/", get(list_documents))
.route("/", delete(bulk_delete_documents))
.route("/{id}", get(get_document_by_id)) .route("/{id}", get(get_document_by_id))
.route("/{id}", delete(delete_document))
.route("/{id}/download", get(download_document)) .route("/{id}/download", get(download_document))
.route("/{id}/view", get(view_document)) .route("/{id}/view", get(view_document))
.route("/{id}/thumbnail", get(get_document_thumbnail)) .route("/{id}/thumbnail", get(get_document_thumbnail))
@ -977,3 +984,112 @@ async fn get_user_duplicates(
Ok(Json(response)) 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<Arc<AppState>>,
auth_user: AuthUser,
Path(document_id): Path<uuid::Uuid>,
) -> Result<Json<serde_json::Value>, 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<Arc<AppState>>,
auth_user: AuthUser,
Json(request): Json<BulkDeleteRequest>,
) -> Result<Json<serde_json::Value>, 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::<Vec<_>>()
})))
}