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>
This commit is contained in:
2026-05-26 22:24:56 +02:00
parent b84827ecea
commit 2e2b0b45fa
30 changed files with 5438 additions and 335 deletions
+122
View File
@@ -0,0 +1,122 @@
<?php
/**
* cron/dbn-dms-trash-purge.php
*
* Daily cron that hard-deletes any document/folder soft-deleted more than
* DBN_DMS_TRASH_RETENTION_DAYS ago.
*
* Pass --dry-run for a no-op preview.
*
* Crontab (on chloe, as dobetternorge):
* 15 3 * * * /usr/bin/php /home/dobetternorge/public_html/cron/dbn-dms-trash-purge.php >> /home/dobetternorge/logs/dms-purge.log 2>&1
*/
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit("CLI only.\n");
}
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$dryRun = in_array('--dry-run', $argv ?? [], true);
$cutoff = DBN_DMS_TRASH_RETENTION_DAYS;
try {
dbnToolsBootCaveau();
$db = getDb();
} catch (Throwable $e) {
fwrite(STDERR, "[dbn-dms-purge] DB connect failed: " . $e->getMessage() . "\n");
exit(1);
}
$start = microtime(true);
$purgedDocs = 0;
$purgedFolders = 0;
$skipped = 0;
$errors = 0;
try {
$docs = $db->prepare(
"SELECT id, client_id, storage_path
FROM client_documents
WHERE deleted_at IS NOT NULL AND deleted_at < (NOW() - INTERVAL ? DAY)
LIMIT 5000"
);
$docs->execute([$cutoff]);
foreach ($docs->fetchAll(PDO::FETCH_ASSOC) as $row) {
$docId = (int)$row['id'];
$clientId = (int)$row['client_id'];
$path = $row['storage_path'] ?? null;
if ($dryRun) {
echo "DRY: would purge document {$docId} (client {$clientId})\n";
$skipped++;
continue;
}
try {
// Version-file cleanup
$vr = $db->prepare('SELECT storage_path FROM client_document_versions WHERE document_id = ? AND client_id = ?');
$vr->execute([$docId, $clientId]);
foreach ($vr->fetchAll() as $v) {
if (!empty($v['storage_path']) && is_file($v['storage_path'])) @unlink($v['storage_path']);
}
// Chunks
try {
$db->prepare('DELETE FROM client_chunks WHERE client_id = ? AND document_id = ?')->execute([$clientId, $docId]);
} catch (Throwable $e) { /* tolerated */ }
// Qdrant
try {
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 */ }
// Disk
if ($path && is_file($path)) @unlink($path);
if ($path) {
$verDir = dirname($path) . '/' . $docId . '_versions';
if (is_dir($verDir)) {
foreach (glob($verDir . '/*') ?: [] as $f) @unlink($f);
@rmdir($verDir);
}
}
// Row
$db->prepare('DELETE FROM client_documents WHERE id = ? AND client_id = ?')->execute([$docId, $clientId]);
$purgedDocs++;
} catch (Throwable $e) {
$errors++;
fwrite(STDERR, "[dbn-dms-purge] doc {$docId}: " . $e->getMessage() . "\n");
}
}
if ($dryRun) {
$foldCount = $db->prepare("SELECT COUNT(*) FROM client_folders WHERE deleted_at IS NOT NULL AND deleted_at < (NOW() - INTERVAL ? DAY)");
$foldCount->execute([$cutoff]);
$skipped += (int)$foldCount->fetchColumn();
echo "DRY: would purge {$skipped} folders\n";
} else {
$fold = $db->prepare("DELETE FROM client_folders WHERE deleted_at IS NOT NULL AND deleted_at < (NOW() - INTERVAL ? DAY)");
$fold->execute([$cutoff]);
$purgedFolders = $fold->rowCount();
}
} catch (Throwable $e) {
fwrite(STDERR, "[dbn-dms-purge] fatal: " . $e->getMessage() . "\n");
exit(1);
}
$elapsed = round(microtime(true) - $start, 2);
$mode = $dryRun ? 'DRY-RUN' : 'live';
printf("[dbn-dms-purge] %s done in %ss: docs=%d folders=%d errors=%d skipped=%d\n",
$mode, $elapsed, $purgedDocs, $purgedFolders, $errors, $skipped);