feat(everything): Add document deletion
This commit is contained in:
parent
b24bf2c7d9
commit
1507532083
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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<_>>()
|
||||
})))
|
||||
}
|
||||
Loading…
Reference in New Issue