2e2b0b45fa
Rebuild the dashboard as a Drive-style document management system on top of the existing CaveauAI hybrid RAG pipeline. Backend: - 5 migrations (versions, trash soft-delete, saved searches, categories, audit) - DMS helpers (folder ACL walker, disk storage, audit, version snapshot, XLSX/PPTX/HTML/CSV/MD extractors) - New APIs: folders, document-versions, trash, bulk, preview, saved-searches, categories, diagnostics - Extended APIs: documents (folder_id, soft-delete, ACL filter, sort), upload (9 file types, version-collision detection with replace/new/keep-both, disk persistence), chat-stream (folder scoping + graph related-documents) - 30-day trash purge cron with Qdrant + disk + graph cleanup Frontend: - Drive-style two-pane browser with folder tree, drag-drop, bulk-action bar, right-click context menu, multi-select - New pages: folders (tree + per-folder ACL editor), trash (restore/purge) - Extended pages: upload (folder picker, version-collision modal, 9 file type chips), document (Preview/Versions/Permissions tabs with PDF.js + mammoth.js + audio), index (DMS KPIs + activity feed), settings (live diagnostics ping MariaDB/Qdrant/LiteLLM/FalkorDB/disk), chat (folder scope chips + related-authorities chips) - New CSS (dms.css) + JS bundle (dms.js) exposing window.DBN_DMS - Sidebar nav adds Folders + Trash items All routes return HTTP 200 in local smoke test; all 32 files lint clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
258 lines
10 KiB
PHP
258 lines
10 KiB
PHP
<?php
|
|
/**
|
|
* /api/dashboard/trash.php — trashed documents + folders, restore + permanent purge.
|
|
*
|
|
* GET ?action=list&offset=&limit= → { ok, total, items: [...] }
|
|
* POST ?action=restore body: { document_ids?: [..], folder_ids?: [..] }
|
|
* POST ?action=purge body: { document_ids?: [..], folder_ids?: [..], all?: bool }
|
|
* — admin/owner only for `all`
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
|
|
|
|
dbnToolsRequireAuth();
|
|
|
|
try {
|
|
$tenant = dbnToolsEnsureDashboardTenant();
|
|
} catch (DbnToolsHttpException $e) {
|
|
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
|
|
}
|
|
$clientId = (int)$tenant['client_id'];
|
|
$userId = (int)($tenant['client_user_id'] ?? 0);
|
|
$tenantRole = (string)($tenant['role'] ?? 'editor');
|
|
|
|
$db = dbnToolsDb();
|
|
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
|
$action = (string)($_GET['action'] ?? ($method === 'POST' ? '' : 'list'));
|
|
|
|
try {
|
|
switch ($action) {
|
|
case 'list':
|
|
dbnToolsRequireMethod('GET');
|
|
listTrash($db, $clientId, $userId, $tenantRole);
|
|
break;
|
|
case 'restore':
|
|
dbnToolsRequireMethod('POST');
|
|
restoreTrash($db, $clientId, $userId, $tenantRole);
|
|
break;
|
|
case 'purge':
|
|
dbnToolsRequireMethod('POST');
|
|
purgeTrash($db, $clientId, $userId, $tenantRole);
|
|
break;
|
|
default:
|
|
dbnToolsError('Unknown action.', 400, 'unknown_action');
|
|
}
|
|
} catch (DbnToolsHttpException $e) {
|
|
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
|
|
} catch (Throwable $e) {
|
|
error_log('[dbn-dms/trash] ' . $e->getMessage());
|
|
dbnToolsError('Trash operation failed.', 500, 'trash_op_failed');
|
|
}
|
|
|
|
function listTrash(PDO $db, int $clientId, int $userId, string $tenantRole): void
|
|
{
|
|
$offset = max(0, (int)($_GET['offset'] ?? 0));
|
|
$limit = max(1, min(200, (int)($_GET['limit'] ?? 50)));
|
|
|
|
$docs = $db->prepare(
|
|
"SELECT id, title, folder_id, source_type, file_size_bytes, deleted_at, deleted_by,
|
|
DATEDIFF(NOW(), deleted_at) AS days_in_trash
|
|
FROM client_documents
|
|
WHERE client_id = ? AND deleted_at IS NOT NULL
|
|
ORDER BY deleted_at DESC
|
|
LIMIT {$limit} OFFSET {$offset}"
|
|
);
|
|
$docs->execute([$clientId]);
|
|
$docRows = $docs->fetchAll();
|
|
|
|
// Filter by ACL
|
|
$visible = [];
|
|
foreach ($docRows as $row) {
|
|
$fid = $row['folder_id'] ? (int)$row['folder_id'] : 0;
|
|
if (dbnDmsUserCanAccessFolder($fid ?: null, 'read', $clientId, $userId, $tenantRole)) {
|
|
$row['kind'] = 'document';
|
|
$row['expires_in_days'] = max(0, DBN_DMS_TRASH_RETENTION_DAYS - (int)$row['days_in_trash']);
|
|
$visible[] = $row;
|
|
}
|
|
}
|
|
|
|
$folders = $db->prepare(
|
|
"SELECT id, name, color, deleted_at, deleted_by,
|
|
DATEDIFF(NOW(), deleted_at) AS days_in_trash
|
|
FROM client_folders
|
|
WHERE client_id = ? AND deleted_at IS NOT NULL
|
|
ORDER BY deleted_at DESC LIMIT 200"
|
|
);
|
|
$folders->execute([$clientId]);
|
|
foreach ($folders->fetchAll() as $row) {
|
|
$row['kind'] = 'folder';
|
|
$row['expires_in_days'] = max(0, DBN_DMS_TRASH_RETENTION_DAYS - (int)$row['days_in_trash']);
|
|
$visible[] = $row;
|
|
}
|
|
|
|
$countStmt = $db->prepare(
|
|
"SELECT COUNT(*) FROM client_documents WHERE client_id = ? AND deleted_at IS NOT NULL"
|
|
);
|
|
$countStmt->execute([$clientId]);
|
|
$total = (int)$countStmt->fetchColumn();
|
|
|
|
dbnToolsRespond([
|
|
'ok' => true,
|
|
'total' => $total,
|
|
'items' => $visible,
|
|
'retention_days' => DBN_DMS_TRASH_RETENTION_DAYS,
|
|
]);
|
|
}
|
|
|
|
function restoreTrash(PDO $db, int $clientId, int $userId, string $tenantRole): void
|
|
{
|
|
$input = dbnToolsJsonInput(20_000);
|
|
$docIds = sanitizeIdList($input['document_ids'] ?? []);
|
|
$folderIds = sanitizeIdList($input['folder_ids'] ?? []);
|
|
$restoredDocs = 0;
|
|
$restoredFolders = 0;
|
|
|
|
if ($docIds) {
|
|
$ph = implode(',', array_fill(0, count($docIds), '?'));
|
|
$rows = $db->prepare("SELECT id, folder_id FROM client_documents WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NOT NULL");
|
|
$rows->execute(array_merge([$clientId], $docIds));
|
|
$allowed = [];
|
|
foreach ($rows->fetchAll() as $r) {
|
|
$fid = $r['folder_id'] ? (int)$r['folder_id'] : 0;
|
|
if (dbnDmsUserCanAccessFolder($fid ?: null, 'write', $clientId, $userId, $tenantRole)) {
|
|
$allowed[] = (int)$r['id'];
|
|
}
|
|
}
|
|
if ($allowed) {
|
|
$ph2 = implode(',', array_fill(0, count($allowed), '?'));
|
|
$upd = $db->prepare("UPDATE client_documents SET deleted_at = NULL, deleted_by = NULL WHERE client_id = ? AND id IN ({$ph2})");
|
|
$upd->execute(array_merge([$clientId], $allowed));
|
|
$restoredDocs = $upd->rowCount();
|
|
dbnDmsLogAudit($clientId, $userId ?: null, 'restore', ['ids' => $allowed]);
|
|
}
|
|
}
|
|
|
|
if ($folderIds) {
|
|
$ph = implode(',', array_fill(0, count($folderIds), '?'));
|
|
$rows = $db->prepare("SELECT id FROM client_folders WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NOT NULL");
|
|
$rows->execute(array_merge([$clientId], $folderIds));
|
|
$allowed = [];
|
|
foreach ($rows->fetchAll() as $r) {
|
|
if (dbnDmsUserCanAccessFolder((int)$r['id'], 'manage', $clientId, $userId, $tenantRole)) {
|
|
$allowed[] = (int)$r['id'];
|
|
}
|
|
}
|
|
if ($allowed) {
|
|
$ph2 = implode(',', array_fill(0, count($allowed), '?'));
|
|
$upd = $db->prepare("UPDATE client_folders SET deleted_at = NULL, deleted_by = NULL WHERE client_id = ? AND id IN ({$ph2})");
|
|
$upd->execute(array_merge([$clientId], $allowed));
|
|
$restoredFolders = $upd->rowCount();
|
|
dbnDmsLogAudit($clientId, $userId ?: null, 'restore_folder', ['ids' => $allowed]);
|
|
}
|
|
}
|
|
|
|
dbnToolsRespond(['ok' => true, 'restored_documents' => $restoredDocs, 'restored_folders' => $restoredFolders]);
|
|
}
|
|
|
|
function purgeTrash(PDO $db, int $clientId, int $userId, string $tenantRole): void
|
|
{
|
|
if (!in_array($tenantRole, ['admin','owner'], true)) {
|
|
dbnToolsError('Permanent purge requires admin role.', 403, 'forbidden');
|
|
}
|
|
$input = dbnToolsJsonInput(20_000);
|
|
$all = !empty($input['all']);
|
|
$docIds = sanitizeIdList($input['document_ids'] ?? []);
|
|
$folderIds = sanitizeIdList($input['folder_ids'] ?? []);
|
|
|
|
$purgedDocs = 0;
|
|
$purgedFolders = 0;
|
|
|
|
if ($all) {
|
|
// Documents
|
|
$docs = $db->prepare("SELECT id, storage_path FROM client_documents WHERE client_id = ? AND deleted_at IS NOT NULL");
|
|
$docs->execute([$clientId]);
|
|
foreach ($docs->fetchAll() as $row) {
|
|
purgeDocument($db, $clientId, (int)$row['id'], $row['storage_path'] ?? null);
|
|
$purgedDocs++;
|
|
}
|
|
$delFolders = $db->prepare("DELETE FROM client_folders WHERE client_id = ? AND deleted_at IS NOT NULL");
|
|
$delFolders->execute([$clientId]);
|
|
$purgedFolders = $delFolders->rowCount();
|
|
dbnDmsLogAudit($clientId, $userId ?: null, 'purge_all', ['documents' => $purgedDocs, 'folders' => $purgedFolders]);
|
|
} else {
|
|
if ($docIds) {
|
|
$ph = implode(',', array_fill(0, count($docIds), '?'));
|
|
$rows = $db->prepare("SELECT id, storage_path FROM client_documents WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NOT NULL");
|
|
$rows->execute(array_merge([$clientId], $docIds));
|
|
foreach ($rows->fetchAll() as $row) {
|
|
purgeDocument($db, $clientId, (int)$row['id'], $row['storage_path'] ?? null);
|
|
$purgedDocs++;
|
|
}
|
|
}
|
|
if ($folderIds) {
|
|
$ph = implode(',', array_fill(0, count($folderIds), '?'));
|
|
$del = $db->prepare("DELETE FROM client_folders WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NOT NULL");
|
|
$del->execute(array_merge([$clientId], $folderIds));
|
|
$purgedFolders = $del->rowCount();
|
|
}
|
|
dbnDmsLogAudit($clientId, $userId ?: null, 'purge', ['documents' => $purgedDocs, 'folders' => $purgedFolders]);
|
|
}
|
|
|
|
dbnToolsRespond(['ok' => true, 'purged_documents' => $purgedDocs, 'purged_folders' => $purgedFolders]);
|
|
}
|
|
|
|
function purgeDocument(PDO $db, int $clientId, int $docId, ?string $storagePath): void
|
|
{
|
|
// Delete chunks + Qdrant points + on-disk file + versions
|
|
try {
|
|
$verRows = $db->prepare('SELECT storage_path FROM client_document_versions WHERE document_id = ? AND client_id = ?');
|
|
$verRows->execute([$docId, $clientId]);
|
|
foreach ($verRows->fetchAll() as $vr) {
|
|
if (!empty($vr['storage_path']) && is_file($vr['storage_path'])) {
|
|
@unlink($vr['storage_path']);
|
|
}
|
|
}
|
|
} catch (Throwable $e) { /* tolerated */ }
|
|
|
|
try {
|
|
$db->prepare('DELETE FROM client_chunks WHERE client_id = ? AND document_id = ?')->execute([$clientId, $docId]);
|
|
} catch (Throwable $e) { /* tolerated */ }
|
|
|
|
// Best-effort Qdrant cleanup — issue a delete-by-filter
|
|
try {
|
|
dbnToolsBootCaveau();
|
|
if (class_exists('QdrantClient')) {
|
|
$qd = new QdrantClient();
|
|
$qd->deleteByFilter('bnl_client_chunks', [
|
|
'must' => [
|
|
['key' => 'client_id', 'match' => ['value' => $clientId]],
|
|
['key' => 'document_id', 'match' => ['value' => $docId]],
|
|
],
|
|
]);
|
|
}
|
|
} catch (Throwable $e) { /* tolerated */ }
|
|
|
|
if ($storagePath && is_file($storagePath)) {
|
|
@unlink($storagePath);
|
|
}
|
|
// Also remove the versions folder if it exists.
|
|
if ($storagePath) {
|
|
$verDir = dirname($storagePath) . '/' . $docId . '_versions';
|
|
if (is_dir($verDir)) {
|
|
foreach (glob($verDir . '/*') ?: [] as $f) { @unlink($f); }
|
|
@rmdir($verDir);
|
|
}
|
|
}
|
|
|
|
$db->prepare('DELETE FROM client_documents WHERE id = ? AND client_id = ?')->execute([$docId, $clientId]);
|
|
}
|
|
|
|
function sanitizeIdList(mixed $raw): array
|
|
{
|
|
if (!is_array($raw)) return [];
|
|
$ids = array_values(array_unique(array_filter(array_map('intval', $raw), fn($v) => $v > 0)));
|
|
return array_slice($ids, 0, 500);
|
|
}
|