Files
dobetternorge-tools/api/dashboard/trash.php
T
daveadmin 2e2b0b45fa Full DMS: folders + ACLs, versioning, trash, bulk ops, preview, smart folders
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>
2026-05-26 22:24:56 +02:00

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);
}