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
+209
View File
@@ -0,0 +1,209 @@
<?php
/**
* /api/dashboard/categories.php — tenant-managed category dictionary.
*
* GET ?action=list → { ok, categories: [...] }
* Auto-seeds defaults if dictionary empty for this tenant.
*
* POST ?action=create body: { slug, label, color?, icon? }
* POST ?action=update body: { id, label?, color?, icon?, sort_order? }
* POST ?action=delete body: { id } — blocked if is_system=1
* POST ?action=reorder body: { ids: [...] }
*/
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'); listCats($db, $clientId); break;
case 'create': dbnToolsRequireMethod('POST'); createCat($db, $clientId, $userId, $tenantRole); break;
case 'update': dbnToolsRequireMethod('POST'); updateCat($db, $clientId, $userId, $tenantRole); break;
case 'delete': dbnToolsRequireMethod('POST'); deleteCat($db, $clientId, $userId, $tenantRole); break;
case 'reorder': dbnToolsRequireMethod('POST'); reorderCats($db, $clientId); 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/categories] ' . $e->getMessage());
dbnToolsError('Category operation failed.', 500, 'op_failed');
}
function listCats(PDO $db, int $clientId): void
{
dbnDmsSeedDefaultCategoriesIfEmpty($clientId);
$stmt = $db->prepare(
'SELECT id, slug, label, color, icon, sort_order, is_system, created_at
FROM client_categories
WHERE client_id = ?
ORDER BY sort_order ASC, label ASC'
);
$stmt->execute([$clientId]);
// Also fetch usage counts.
$counts = $db->prepare(
"SELECT category, COUNT(*) AS n
FROM client_documents
WHERE client_id = ? AND deleted_at IS NULL
GROUP BY category"
);
$counts->execute([$clientId]);
$countMap = [];
foreach ($counts->fetchAll() as $r) {
$countMap[(string)$r['category']] = (int)$r['n'];
}
$rows = $stmt->fetchAll();
foreach ($rows as &$r) {
$r['doc_count'] = $countMap[(string)$r['slug']] ?? 0;
$r['is_system'] = (int)$r['is_system'] === 1;
}
dbnToolsRespond(['ok' => true, 'categories' => $rows]);
}
function createCat(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
if (!in_array($tenantRole, ['editor','admin','owner'], true)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$input = dbnToolsJsonInput(10_000);
$slug = strtolower(trim((string)($input['slug'] ?? '')));
$slug = preg_replace('/[^a-z0-9\-_]/', '', $slug) ?: '';
$label = trim((string)($input['label'] ?? ''));
$color = trim((string)($input['color'] ?? ''));
$icon = trim((string)($input['icon'] ?? ''));
if ($slug === '' || mb_strlen($slug, 'UTF-8') > 50) {
dbnToolsError('Slug is required (lowercase, 150, [a-z0-9-_]).', 422, 'invalid_slug');
}
if ($label === '' || mb_strlen($label, 'UTF-8') > 100) {
dbnToolsError('Label is required (1100).', 422, 'invalid_label');
}
if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
dbnToolsError('Invalid color.', 422, 'invalid_color');
}
try {
$stmt = $db->prepare(
'INSERT INTO client_categories (client_id, slug, label, color, icon, sort_order, is_system, created_at)
VALUES (?, ?, ?, ?, ?, 999, 0, NOW())'
);
$stmt->execute([
$clientId, $slug, $label,
$color !== '' ? $color : null,
$icon !== '' ? substr($icon, 0, 40) : null,
]);
} catch (PDOException $e) {
if ((int)$e->errorInfo[1] === 1062) {
dbnToolsError('A category with this slug already exists.', 409, 'duplicate_slug');
}
throw $e;
}
$id = (int)$db->lastInsertId();
dbnDmsLogAudit($clientId, $userId, 'category_create', ['slug' => $slug]);
dbnToolsRespond(['ok' => true, 'id' => $id], 201);
}
function updateCat(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
if (!in_array($tenantRole, ['editor','admin','owner'], true)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$input = dbnToolsJsonInput(10_000);
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
dbnToolsError('id is required.', 400, 'missing_id');
}
$fields = [];
$params = [];
if (array_key_exists('label', $input)) {
$label = trim((string)$input['label']);
if ($label === '' || mb_strlen($label, 'UTF-8') > 100) {
dbnToolsError('Invalid label.', 422, 'invalid_label');
}
$fields[] = 'label = ?'; $params[] = $label;
}
if (array_key_exists('color', $input)) {
$color = trim((string)$input['color']);
if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
dbnToolsError('Invalid color.', 422, 'invalid_color');
}
$fields[] = 'color = ?'; $params[] = $color !== '' ? $color : null;
}
if (array_key_exists('icon', $input)) {
$icon = trim((string)$input['icon']);
$fields[] = 'icon = ?'; $params[] = $icon !== '' ? substr($icon, 0, 40) : null;
}
if (array_key_exists('sort_order', $input)) {
$fields[] = 'sort_order = ?'; $params[] = (int)$input['sort_order'];
}
if (!$fields) {
dbnToolsError('Nothing to update.', 400, 'no_fields');
}
$params[] = $id;
$params[] = $clientId;
$stmt = $db->prepare('UPDATE client_categories SET ' . implode(', ', $fields) . ' WHERE id = ? AND client_id = ?');
$stmt->execute($params);
dbnDmsLogAudit($clientId, $userId, 'category_update', ['id' => $id]);
dbnToolsRespond(['ok' => true]);
}
function deleteCat(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
if (!in_array($tenantRole, ['admin','owner'], true)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$input = dbnToolsJsonInput(2_000);
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
dbnToolsError('id is required.', 400, 'missing_id');
}
$row = $db->prepare('SELECT slug, is_system FROM client_categories WHERE id = ? AND client_id = ?');
$row->execute([$id, $clientId]);
$existing = $row->fetch();
if (!$existing) {
dbnToolsError('Not found.', 404, 'not_found');
}
if ((int)$existing['is_system'] === 1) {
dbnToolsError('System categories cannot be deleted.', 422, 'is_system');
}
// Reassign any docs in this category to uncategorized.
$db->prepare("UPDATE client_documents SET category = 'uncategorized' WHERE client_id = ? AND category = ?")
->execute([$clientId, $existing['slug']]);
$del = $db->prepare('DELETE FROM client_categories WHERE id = ? AND client_id = ?');
$del->execute([$id, $clientId]);
dbnDmsLogAudit($clientId, $userId, 'category_delete', ['id' => $id, 'slug' => $existing['slug']]);
dbnToolsRespond(['ok' => true]);
}
function reorderCats(PDO $db, int $clientId): void
{
$input = dbnToolsJsonInput(20_000);
$ids = $input['ids'] ?? [];
if (!is_array($ids)) {
dbnToolsError('ids array is required.', 400, 'missing_ids');
}
$ids = array_values(array_filter(array_map('intval', $ids), fn($v) => $v > 0));
$upd = $db->prepare('UPDATE client_categories SET sort_order = ? WHERE id = ? AND client_id = ?');
foreach ($ids as $i => $id) {
$upd->execute([$i, $id, $clientId]);
}
dbnToolsRespond(['ok' => true, 'reordered' => count($ids)]);
}