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
+493
View File
@@ -0,0 +1,493 @@
<?php
/**
* /api/dashboard/folders.php — folder tree CRUD + per-folder ACLs.
*
* GET ?action=list_tree → { ok, tree: [...] }
* GET ?action=get_breadcrumb&folder_id=X → { ok, breadcrumb: [...] }
* POST ?action=create body: { parent_id?, name, color?, description? }
* POST ?action=rename body: { folder_id, name }
* POST ?action=recolor body: { folder_id, color }
* POST ?action=move body: { folder_id, parent_id|null }
* POST ?action=delete body: { folder_id } (soft delete — docs become Unassigned via SET NULL)
* POST ?action=set_permission body: { folder_id, min_role?, user_id?, can_read?, can_write?, can_manage? }
* POST ?action=remove_permission body: { permission_id }
* GET ?action=list_permissions&folder_id=X → { ok, permissions: [...] }
*
* Tenant-scoped via dbnToolsEnsureDashboardTenant().
*/
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'];
$corpusId = (int)$tenant['corpus_id'];
$clientUser = (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_tree'));
try {
switch ($action) {
case 'list_tree':
dbnToolsRequireMethod('GET');
respondTree($db, $clientId, $corpusId, $clientUser, $tenantRole);
break;
case 'get_breadcrumb':
dbnToolsRequireMethod('GET');
$fid = (int)($_GET['folder_id'] ?? 0);
dbnToolsRespond(['ok' => true, 'breadcrumb' => dbnDmsBreadcrumb($fid ?: null, $clientId)]);
break;
case 'create':
dbnToolsRequireMethod('POST');
respondCreate($db, $clientId, $corpusId, $clientUser, $tenantRole);
break;
case 'rename':
dbnToolsRequireMethod('POST');
respondRename($db, $clientId, $clientUser, $tenantRole);
break;
case 'recolor':
dbnToolsRequireMethod('POST');
respondRecolor($db, $clientId, $clientUser, $tenantRole);
break;
case 'move':
dbnToolsRequireMethod('POST');
respondMove($db, $clientId, $clientUser, $tenantRole);
break;
case 'delete':
dbnToolsRequireMethod('POST');
respondDelete($db, $clientId, $clientUser, $tenantRole);
break;
case 'list_permissions':
dbnToolsRequireMethod('GET');
respondListPermissions($db, $clientId, $clientUser, $tenantRole);
break;
case 'set_permission':
dbnToolsRequireMethod('POST');
respondSetPermission($db, $clientId, $clientUser, $tenantRole);
break;
case 'remove_permission':
dbnToolsRequireMethod('POST');
respondRemovePermission($db, $clientId, $clientUser, $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/folders] ' . $e->getMessage());
dbnToolsError('Folder operation failed.', 500, 'folder_op_failed');
}
function respondTree(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole): void
{
$stmt = $db->prepare(
"SELECT f.id, f.parent_id, f.name, f.slug, f.color, f.description, f.sort_order, f.created_at,
COALESCE(c.cnt, 0) AS doc_count
FROM client_folders f
LEFT JOIN (
SELECT folder_id, COUNT(*) AS cnt
FROM client_documents
WHERE client_id = ? AND deleted_at IS NULL
GROUP BY folder_id
) c ON c.folder_id = f.id
WHERE f.client_id = ? AND f.corpus_id = ? AND f.deleted_at IS NULL
ORDER BY f.sort_order ASC, f.name ASC"
);
$stmt->execute([$clientId, $clientId, $corpusId]);
$rows = $stmt->fetchAll();
// Filter by read ACL.
$visible = [];
foreach ($rows as $row) {
if (dbnDmsUserCanAccessFolder((int)$row['id'], 'read', $clientId, $userId, $tenantRole)) {
$visible[(int)$row['id']] = [
'id' => (int)$row['id'],
'parent_id' => $row['parent_id'] ? (int)$row['parent_id'] : null,
'name' => (string)$row['name'],
'slug' => (string)$row['slug'],
'color' => $row['color'] ?? null,
'description'=> $row['description'] ?? null,
'sort_order' => (int)$row['sort_order'],
'doc_count' => (int)$row['doc_count'],
'children' => [],
];
}
}
// Tree assembly.
$roots = [];
foreach ($visible as $id => &$node) {
$pid = $node['parent_id'];
if ($pid && isset($visible[$pid])) {
$visible[$pid]['children'][] = &$node;
} else {
$roots[] = &$node;
}
}
unset($node);
// Unassigned bucket count.
$unassigned = $db->prepare(
'SELECT COUNT(*) FROM client_documents WHERE client_id = ? AND folder_id IS NULL AND deleted_at IS NULL'
);
$unassigned->execute([$clientId]);
// Trash count.
$trash = $db->prepare(
'SELECT COUNT(*) FROM client_documents WHERE client_id = ? AND deleted_at IS NOT NULL'
);
$trash->execute([$clientId]);
dbnToolsRespond([
'ok' => true,
'tree' => $roots,
'unassigned_count' => (int)$unassigned->fetchColumn(),
'trash_count' => (int)$trash->fetchColumn(),
'max_depth' => DBN_DMS_MAX_FOLDER_DEPTH,
]);
}
function respondCreate(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(20_000);
$name = trim((string)($input['name'] ?? ''));
$parentId = isset($input['parent_id']) && $input['parent_id'] !== null && $input['parent_id'] !== ''
? (int)$input['parent_id'] : null;
$color = trim((string)($input['color'] ?? ''));
$desc = trim((string)($input['description'] ?? ''));
if ($name === '' || mb_strlen($name, 'UTF-8') > 200) {
dbnToolsError('Folder name is required (1200 chars).', 422, 'invalid_name');
}
if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
dbnToolsError('Color must be a #RRGGBB hex value.', 422, 'invalid_color');
}
if (mb_strlen($desc, 'UTF-8') > 1000) {
dbnToolsError('Description is too long (max 1000 chars).', 422, 'description_too_long');
}
$parentDepth = dbnDmsFolderDepth($parentId, $clientId);
if ($parentDepth + 1 > DBN_DMS_MAX_FOLDER_DEPTH) {
dbnToolsError("Folder depth limit reached (max " . DBN_DMS_MAX_FOLDER_DEPTH . " levels).", 422, 'depth_exceeded');
}
if (!dbnDmsUserCanAccessFolder($parentId, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('You do not have permission to create folders here.', 403, 'forbidden');
}
if ($parentId !== null) {
$parentCheck = $db->prepare('SELECT id FROM client_folders WHERE id = ? AND client_id = ? AND deleted_at IS NULL');
$parentCheck->execute([$parentId, $clientId]);
if (!$parentCheck->fetchColumn()) {
dbnToolsError('Parent folder not found.', 404, 'parent_not_found');
}
}
$slug = dbnDmsUniqueSlug($db, $clientId, $corpusId, $name);
$stmt = $db->prepare(
'INSERT INTO client_folders
(client_id, corpus_id, parent_id, name, slug, description, color, sort_order, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, NOW())'
);
$stmt->execute([
$clientId, $corpusId, $parentId, $name, $slug,
$desc !== '' ? $desc : null,
$color !== '' ? $color : null,
$userId ?: null,
]);
$id = (int)$db->lastInsertId();
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_create', ['name' => $name, 'parent_id' => $parentId], null, $id);
dbnToolsRespond(['ok' => true, 'folder_id' => $id, 'slug' => $slug], 201);
}
function respondRename(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(10_000);
$fid = (int)($input['folder_id'] ?? 0);
$name = trim((string)($input['name'] ?? ''));
if ($fid <= 0 || $name === '' || mb_strlen($name, 'UTF-8') > 200) {
dbnToolsError('folder_id and a valid name (1200) are required.', 422, 'invalid_input');
}
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('You do not have permission to rename this folder.', 403, 'forbidden');
}
$stmt = $db->prepare('UPDATE client_folders SET name = ?, updated_at = NOW() WHERE id = ? AND client_id = ?');
$stmt->execute([$name, $fid, $clientId]);
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_rename', ['name' => $name], null, $fid);
dbnToolsRespond(['ok' => true]);
}
function respondRecolor(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(2_000);
$fid = (int)($input['folder_id'] ?? 0);
$color = trim((string)($input['color'] ?? ''));
if ($fid <= 0) {
dbnToolsError('folder_id is required.', 422, 'invalid_input');
}
if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
dbnToolsError('Color must be a #RRGGBB hex value.', 422, 'invalid_color');
}
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$stmt = $db->prepare('UPDATE client_folders SET color = ?, updated_at = NOW() WHERE id = ? AND client_id = ?');
$stmt->execute([$color !== '' ? $color : null, $fid, $clientId]);
dbnToolsRespond(['ok' => true]);
}
function respondMove(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(5_000);
$fid = (int)($input['folder_id'] ?? 0);
$parentId = isset($input['parent_id']) && $input['parent_id'] !== null && $input['parent_id'] !== ''
? (int)$input['parent_id'] : null;
if ($fid <= 0) {
dbnToolsError('folder_id is required.', 422, 'invalid_input');
}
if ($parentId !== null && $parentId === $fid) {
dbnToolsError('Folder cannot be its own parent.', 422, 'invalid_parent');
}
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden on source.', 403, 'forbidden_source');
}
if ($parentId !== null && !dbnDmsUserCanAccessFolder($parentId, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden on destination.', 403, 'forbidden_dest');
}
// Cycle + depth checks.
if ($parentId !== null) {
$chain = dbnDmsFolderChain($parentId, $clientId);
foreach ($chain as $c) {
if ((int)$c['id'] === $fid) {
dbnToolsError('Cannot move a folder into one of its own descendants.', 422, 'invalid_cycle');
}
}
$newDepth = count($chain) + 1; // +1 for the moved folder itself
$childDepth = dbnDmsSubtreeMaxDepth($db, $fid);
if ($newDepth + ($childDepth - 1) > DBN_DMS_MAX_FOLDER_DEPTH) {
dbnToolsError('Move would exceed max folder depth.', 422, 'depth_exceeded');
}
}
$stmt = $db->prepare('UPDATE client_folders SET parent_id = ?, updated_at = NOW() WHERE id = ? AND client_id = ?');
$stmt->execute([$parentId, $fid, $clientId]);
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_move', ['parent_id' => $parentId], null, $fid);
dbnToolsRespond(['ok' => true]);
}
function respondDelete(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(5_000);
$fid = (int)($input['folder_id'] ?? 0);
if ($fid <= 0) {
dbnToolsError('folder_id is required.', 422, 'invalid_input');
}
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
// Soft delete folder + cascade soft-delete on descendant folders.
$db->beginTransaction();
try {
$allIds = dbnDmsCollectSubtreeIds($db, $fid, $clientId);
$allIds[] = $fid;
$placeholders = implode(',', array_fill(0, count($allIds), '?'));
$stmt = $db->prepare(
"UPDATE client_folders SET deleted_at = NOW(), deleted_by = ?
WHERE client_id = ? AND id IN ({$placeholders})"
);
$stmt->execute(array_merge([$userId ?: null, $clientId], $allIds));
// Documents inside: also soft-delete (they appear in Trash).
$docStmt = $db->prepare(
"UPDATE client_documents SET deleted_at = NOW(), deleted_by = ?
WHERE client_id = ? AND folder_id IN ({$placeholders}) AND deleted_at IS NULL"
);
$docStmt->execute(array_merge([$userId ?: null, $clientId], $allIds));
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_delete', [], null, $fid);
dbnToolsRespond(['ok' => true]);
}
function respondListPermissions(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$fid = (int)($_GET['folder_id'] ?? 0);
if ($fid <= 0) {
dbnToolsError('folder_id is required.', 422, 'invalid_input');
}
if (!dbnDmsUserCanAccessFolder($fid, 'read', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$stmt = $db->prepare(
"SELECT p.id, p.folder_id, p.min_role, p.user_id, p.can_read, p.can_write, p.can_manage,
p.created_at, u.email AS user_email, u.full_name AS user_name
FROM client_folder_permissions p
LEFT JOIN client_users u ON u.id = p.user_id
WHERE p.folder_id = ? AND p.client_id = ?
ORDER BY p.id ASC"
);
$stmt->execute([$fid, $clientId]);
dbnToolsRespond(['ok' => true, 'permissions' => $stmt->fetchAll()]);
}
function respondSetPermission(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(10_000);
$fid = (int)($input['folder_id'] ?? 0);
$minRole = trim((string)($input['min_role'] ?? ''));
$targetUid= isset($input['user_id']) && $input['user_id'] ? (int)$input['user_id'] : null;
$canRead = !empty($input['can_read']) ? 1 : 0;
$canWrite = !empty($input['can_write']) ? 1 : 0;
$canManage= !empty($input['can_manage']) ? 1 : 0;
if ($fid <= 0) {
dbnToolsError('folder_id is required.', 422, 'invalid_input');
}
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$validRoles = ['viewer','editor','admin','owner'];
if ($minRole !== '' && !in_array($minRole, $validRoles, true)) {
dbnToolsError('Invalid min_role.', 422, 'invalid_role');
}
if (($minRole === '' && $targetUid === null) || ($minRole !== '' && $targetUid !== null)) {
dbnToolsError('Exactly one of min_role or user_id must be set.', 422, 'invalid_grantee');
}
// UPSERT on the appropriate unique key.
if ($minRole !== '') {
$stmt = $db->prepare(
'INSERT INTO client_folder_permissions
(folder_id, client_id, min_role, can_read, can_write, can_manage, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
can_read = VALUES(can_read),
can_write = VALUES(can_write),
can_manage = VALUES(can_manage)'
);
$stmt->execute([$fid, $clientId, $minRole, $canRead, $canWrite, $canManage, $userId ?: null]);
} else {
$stmt = $db->prepare(
'INSERT INTO client_folder_permissions
(folder_id, client_id, user_id, can_read, can_write, can_manage, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
can_read = VALUES(can_read),
can_write = VALUES(can_write),
can_manage = VALUES(can_manage)'
);
$stmt->execute([$fid, $clientId, $targetUid, $canRead, $canWrite, $canManage, $userId ?: null]);
}
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_acl_set', [
'min_role' => $minRole ?: null,
'user_id' => $targetUid,
'can_read' => $canRead, 'can_write' => $canWrite, 'can_manage' => $canManage,
], null, $fid);
dbnToolsRespond(['ok' => true]);
}
function respondRemovePermission(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(2_000);
$pid = (int)($input['permission_id'] ?? 0);
if ($pid <= 0) {
dbnToolsError('permission_id is required.', 422, 'invalid_input');
}
// Look up the folder to ACL-check.
$row = $db->prepare('SELECT folder_id FROM client_folder_permissions WHERE id = ? AND client_id = ?');
$row->execute([$pid, $clientId]);
$fid = (int)$row->fetchColumn();
if (!$fid) {
dbnToolsError('Permission not found.', 404, 'not_found');
}
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$del = $db->prepare('DELETE FROM client_folder_permissions WHERE id = ? AND client_id = ?');
$del->execute([$pid, $clientId]);
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_acl_remove', ['permission_id' => $pid], null, $fid);
dbnToolsRespond(['ok' => true]);
}
function dbnDmsCollectSubtreeIds(PDO $db, int $rootId, int $clientId): array
{
$collected = [];
$stack = [$rootId];
$guard = 0;
while ($stack && $guard++ < 1000) {
$batch = $stack;
$stack = [];
$placeholders = implode(',', array_fill(0, count($batch), '?'));
$stmt = $db->prepare(
"SELECT id FROM client_folders
WHERE client_id = ? AND parent_id IN ({$placeholders}) AND deleted_at IS NULL"
);
$stmt->execute(array_merge([$clientId], $batch));
foreach ($stmt->fetchAll() as $row) {
$cid = (int)$row['id'];
$collected[] = $cid;
$stack[] = $cid;
}
}
return $collected;
}
function dbnDmsSubtreeMaxDepth(PDO $db, int $rootId): int
{
// Depth of the subtree starting at rootId, where rootId itself counts as 1.
$depth = 1;
$current = [$rootId];
$guard = 0;
while ($current && $guard++ < 20) {
$placeholders = implode(',', array_fill(0, count($current), '?'));
$stmt = $db->prepare(
"SELECT id FROM client_folders
WHERE parent_id IN ({$placeholders}) AND deleted_at IS NULL"
);
$stmt->execute($current);
$next = array_map(fn($r) => (int)$r['id'], $stmt->fetchAll());
if (!$next) {
break;
}
$current = $next;
$depth++;
}
return $depth;
}
function dbnDmsUniqueSlug(PDO $db, int $clientId, int $corpusId, string $name): string
{
$base = strtolower(trim($name));
$base = preg_replace('/[^a-z0-9]+/u', '-', $base) ?: 'folder';
$base = trim($base, '-');
if ($base === '') {
$base = 'folder';
}
$base = substr($base, 0, 180);
$slug = $base;
$check = $db->prepare('SELECT 1 FROM client_folders WHERE client_id = ? AND corpus_id = ? AND slug = ? LIMIT 1');
$n = 2;
while (true) {
$check->execute([$clientId, $corpusId, $slug]);
if (!$check->fetchColumn()) {
return $slug;
}
$slug = $base . '-' . $n++;
if ($n > 999) {
return $base . '-' . substr(bin2hex(random_bytes(3)), 0, 6);
}
}
}