feat(everything): Add document deletion
This commit is contained in:
parent
b24bf2c7d9
commit
1507532083
|
|
@ -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';
|
||||||
|
|
@ -110,6 +118,17 @@ const DocumentsPage: React.FC = () => {
|
||||||
const [sortMenuAnchor, setSortMenuAnchor] = useState<null | HTMLElement>(null);
|
const [sortMenuAnchor, setSortMenuAnchor] = useState<null | HTMLElement>(null);
|
||||||
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();
|
||||||
|
|
@ -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,15 +825,17 @@ const DocumentsPage: React.FC = () => {
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<IconButton
|
{!selectionMode && (
|
||||||
size="small"
|
<IconButton
|
||||||
onClick={(e) => {
|
size="small"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
handleDocMenuClick(e, doc);
|
e.stopPropagation();
|
||||||
}}
|
handleDocMenuClick(e, doc);
|
||||||
>
|
}}
|
||||||
<MoreIcon />
|
>
|
||||||
</IconButton>
|
<MoreIcon />
|
||||||
|
</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 }}>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -976,4 +983,113 @@ 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<_>>()
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue