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,
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 | HTMLElement>(null);
const [docMenuAnchor, setDocMenuAnchor] = useState<null | HTMLElement>(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(() => {
fetchDocuments();
@ -281,6 +300,37 @@ const DocumentsPage: React.FC = () => {
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 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<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) => {
if (!doc.ocr_status) return null;
@ -392,6 +497,17 @@ const DocumentsPage: React.FC = () => {
</ToggleButton>
</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 */}
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>OCR Status</InputLabel>
@ -419,6 +535,43 @@ const DocumentsPage: React.FC = () => {
</Button>
</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 */}
<Menu
anchorEl={sortMenuAnchor}
@ -478,6 +631,13 @@ const DocumentsPage: React.FC = () => {
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
<ListItemText>Edit Labels</ListItemText>
</MenuItem>
<Divider />
<MenuItem onClick={() => {
if (selectedDoc) handleDeleteClick(selectedDoc);
}}>
<ListItemIcon><DeleteIcon fontSize="small" color="error" /></ListItemIcon>
<ListItemText>Delete</ListItemText>
</MenuItem>
</Menu>
{/* 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 && (
<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' && (
<Box
sx={{
@ -628,15 +825,17 @@ const DocumentsPage: React.FC = () => {
</Typography>
</Box>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleDocMenuClick(e, doc);
}}
>
<MoreIcon />
</IconButton>
{!selectionMode && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleDocMenuClick(e, doc);
}}
>
<MoreIcon />
</IconButton>
)}
</Box>
</CardContent>
@ -683,6 +882,80 @@ const DocumentsPage: React.FC = () => {
</DialogActions>
</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 */}
<Box sx={{ mt: 3 }}>
<Box sx={{ textAlign: 'center', mb: 2 }}>

View File

@ -209,6 +209,16 @@ export const documentService = {
getFacets: () => {
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 {

View File

@ -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<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>> {
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},
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<String>,
}
#[derive(Deserialize, ToSchema)]
struct BulkDeleteRequest {
document_ids: Vec<uuid::Uuid>,
}
pub fn router() -> Router<Arc<AppState>> {
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<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<_>>()
})))
}