Add document deletion feature with confirmation dialog
This commit is contained in:
parent
ba36803f05
commit
1e8b338a8f
3 changed files with 268 additions and 35 deletions
157
client/src/components/ConfirmDialog.vue
Normal file
157
client/src/components/ConfirmDialog.vue
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm px-4"
|
||||
@click.self="onCancel"
|
||||
>
|
||||
<div class="bg-dark-800 rounded-2xl shadow-2xl border border-white/10 max-w-md w-full overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="px-6 py-4 border-b border-white/10">
|
||||
<div class="flex items-center gap-3">
|
||||
<div :class="[
|
||||
'w-10 h-10 rounded-xl flex items-center justify-center',
|
||||
variantClasses[variant].bg
|
||||
]">
|
||||
<svg v-if="variant === 'danger'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<svg v-else-if="variant === 'warning'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg v-else class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-white">{{ title }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-6 py-4">
|
||||
<p class="text-white/80 leading-relaxed">{{ message }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-6 py-4 bg-dark-900/50 flex items-center justify-end gap-3">
|
||||
<button
|
||||
@click="onCancel"
|
||||
class="px-4 py-2 rounded-lg border border-white/20 text-white hover:bg-white/5 transition-colors"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
@click="onConfirm"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-lg font-medium transition-colors',
|
||||
variantClasses[variant].button,
|
||||
loading && 'opacity-50 cursor-not-allowed'
|
||||
]"
|
||||
:disabled="loading"
|
||||
>
|
||||
<span v-if="!loading">{{ confirmText }}</span>
|
||||
<span v-else class="flex items-center gap-2">
|
||||
<div class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
{{ loadingText }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Confirm Action'
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: 'Confirm'
|
||||
},
|
||||
cancelText: {
|
||||
type: String,
|
||||
default: 'Cancel'
|
||||
},
|
||||
loadingText: {
|
||||
type: String,
|
||||
default: 'Processing...'
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'info', // 'info', 'warning', 'danger'
|
||||
validator: (value) => ['info', 'warning', 'danger'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel'])
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const variantClasses = {
|
||||
danger: {
|
||||
bg: 'bg-red-500/20 text-red-400',
|
||||
button: 'bg-red-500 hover:bg-red-600 text-white'
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-yellow-500/20 text-yellow-400',
|
||||
button: 'bg-yellow-500 hover:bg-yellow-600 text-white'
|
||||
},
|
||||
info: {
|
||||
bg: 'bg-blue-500/20 text-blue-400',
|
||||
button: 'bg-gradient-to-r from-pink-400 to-purple-500 hover:from-pink-500 hover:to-purple-600 text-white'
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (loading.value) return
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await emit('confirm')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-active > div,
|
||||
.modal-leave-active > div {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from > div,
|
||||
.modal-leave-to > div {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -252,7 +252,7 @@
|
|||
</h4>
|
||||
<div class="space-y-3">
|
||||
<div v-for="doc in documentsByStatus.indexed" :key="doc.id"
|
||||
class="bg-white/10 backdrop-blur-lg rounded-lg p-4 hover:bg-white/15 transition-all cursor-pointer border border-white/10"
|
||||
class="bg-white/10 backdrop-blur-lg rounded-lg p-4 hover:bg-white/15 transition-all cursor-pointer border border-white/10 group"
|
||||
@click="$router.push(`/document/${doc.id}`)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
|
|
@ -261,6 +261,15 @@
|
|||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="badge badge-success">Ready</span>
|
||||
<button
|
||||
@click="confirmDelete(doc, $event)"
|
||||
class="p-2 rounded-lg text-white/50 hover:text-red-400 hover:bg-red-500/10 transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Delete document"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<svg class="w-5 h-5 text-white/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
|
|
@ -308,6 +317,18 @@
|
|||
|
||||
<!-- Upload Modal -->
|
||||
<UploadModal :isOpen="showUploadModal" @close="showUploadModal = false" />
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:isOpen="showDeleteDialog"
|
||||
title="Delete Document"
|
||||
:message="`Are you sure you want to delete "${documentToDelete?.title}"? This action cannot be undone. All associated files and search data will be permanently removed.`"
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
@confirm="handleDelete"
|
||||
@cancel="cancelDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -315,9 +336,14 @@
|
|||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import UploadModal from '../components/UploadModal.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const showUploadModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const documentToDelete = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const loading = ref(false)
|
||||
const documents = ref([])
|
||||
|
|
@ -373,6 +399,45 @@ function handleSearch() {
|
|||
}
|
||||
}
|
||||
|
||||
function confirmDelete(doc, event) {
|
||||
// Stop propagation to prevent navigation
|
||||
event.stopPropagation()
|
||||
documentToDelete.value = doc
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!documentToDelete.value) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/documents/${documentToDelete.value.id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete document')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
toast.success(`"${documentToDelete.value.title}" deleted successfully`)
|
||||
|
||||
// Remove from local list
|
||||
documents.value = documents.value.filter(d => d.id !== documentToDelete.value.id)
|
||||
|
||||
// Close dialog
|
||||
showDeleteDialog.value = false
|
||||
documentToDelete.value = null
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error)
|
||||
toast.error(`Failed to delete document: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
showDeleteDialog.value = false
|
||||
documentToDelete.value = null
|
||||
}
|
||||
|
||||
// Load documents on mount
|
||||
onMounted(() => {
|
||||
loadDocuments()
|
||||
|
|
|
|||
|
|
@ -5,10 +5,16 @@
|
|||
|
||||
import express from 'express';
|
||||
import { getDb } from '../db/db.js';
|
||||
import { getMeilisearchClient } from '../config/meilisearch.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { rm } from 'fs/promises';
|
||||
import { loggers } from '../utils/logger.js';
|
||||
|
||||
const router = express.Router();
|
||||
const logger = loggers.app.child('Documents');
|
||||
|
||||
const MEILISEARCH_INDEX_NAME = process.env.MEILISEARCH_INDEX_NAME || 'navidocs-pages';
|
||||
|
||||
/**
|
||||
* GET /api/documents/:id
|
||||
|
|
@ -343,59 +349,64 @@ router.get('/', async (req, res) => {
|
|||
|
||||
/**
|
||||
* DELETE /api/documents/:id
|
||||
* Soft delete a document (mark as deleted)
|
||||
* Hard delete a document (removes from DB, filesystem, and search index)
|
||||
* For single-tenant demo - simplified permissions
|
||||
*/
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { id } = req.params;
|
||||
|
||||
// TODO: Authentication middleware should provide req.user
|
||||
const userId = req.user?.id || 'test-user-id';
|
||||
try {
|
||||
logger.info(`Deleting document ${id}`);
|
||||
|
||||
const db = getDb();
|
||||
const searchClient = getMeilisearchClient();
|
||||
|
||||
// Check ownership
|
||||
const document = db.prepare(`
|
||||
SELECT id, organization_id, uploaded_by
|
||||
FROM documents
|
||||
WHERE id = ?
|
||||
`).get(id);
|
||||
// Get document info before deletion
|
||||
const document = db.prepare('SELECT * FROM documents WHERE id = ?').get(id);
|
||||
|
||||
if (!document) {
|
||||
logger.warn(`Document ${id} not found`);
|
||||
return res.status(404).json({ error: 'Document not found' });
|
||||
}
|
||||
|
||||
// Verify user has permission (must be uploader or org admin)
|
||||
const hasPermission = db.prepare(`
|
||||
SELECT 1 FROM user_organizations
|
||||
WHERE user_id = ? AND organization_id = ? AND role IN ('admin', 'manager')
|
||||
UNION
|
||||
SELECT 1 FROM documents
|
||||
WHERE id = ? AND uploaded_by = ?
|
||||
`).get(userId, document.organization_id, id, userId);
|
||||
|
||||
if (!hasPermission) {
|
||||
return res.status(403).json({
|
||||
error: 'Access denied',
|
||||
message: 'You do not have permission to delete this document'
|
||||
});
|
||||
// Delete from Meilisearch index
|
||||
try {
|
||||
const index = await searchClient.getIndex(MEILISEARCH_INDEX_NAME);
|
||||
const filter = `docId = "${id}"`;
|
||||
await index.deleteDocuments({ filter });
|
||||
logger.info(`Deleted search entries for document ${id}`);
|
||||
} catch (err) {
|
||||
logger.warn(`Meilisearch cleanup failed for ${id}:`, err);
|
||||
// Continue with deletion even if search cleanup fails
|
||||
}
|
||||
|
||||
// Soft delete - update status
|
||||
const timestamp = Date.now();
|
||||
db.prepare(`
|
||||
UPDATE documents
|
||||
SET status = 'deleted', updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(timestamp, id);
|
||||
// Delete from database (CASCADE will handle document_pages, ocr_jobs)
|
||||
const deleteStmt = db.prepare('DELETE FROM documents WHERE id = ?');
|
||||
deleteStmt.run(id);
|
||||
logger.info(`Deleted database record for document ${id}`);
|
||||
|
||||
// Delete from filesystem
|
||||
const uploadsDir = path.join(process.cwd(), '../uploads');
|
||||
const docFolder = path.join(uploadsDir, id);
|
||||
|
||||
if (fs.existsSync(docFolder)) {
|
||||
await rm(docFolder, { recursive: true, force: true });
|
||||
logger.info(`Deleted filesystem folder for document ${id}`);
|
||||
} else {
|
||||
logger.warn(`Folder not found for document ${id}`);
|
||||
}
|
||||
|
||||
logger.info(`Document ${id} deleted successfully`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Document deleted successfully',
|
||||
documentId: id
|
||||
documentId: id,
|
||||
title: document.title
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Document deletion error:', error);
|
||||
logger.error(`Failed to delete document ${id}`, error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to delete document',
|
||||
message: error.message
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue