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
+222
View File
@@ -0,0 +1,222 @@
<?php
/**
* /api/dashboard/saved-searches.php — "smart folders" CRUD.
*
* GET ?action=list → { ok, items: [...] } (user-owned + tenant-shared)
* POST ?action=create body: { name, query, is_shared?, color?, icon? }
* POST ?action=update body: { id, name?, query?, is_shared?, color?, icon? }
* POST ?action=delete body: { id }
* 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'); listItems($db, $clientId, $userId); break;
case 'create': dbnToolsRequireMethod('POST'); createItem($db, $clientId, $userId, $tenantRole); break;
case 'update': dbnToolsRequireMethod('POST'); updateItem($db, $clientId, $userId, $tenantRole); break;
case 'delete': dbnToolsRequireMethod('POST'); deleteItem($db, $clientId, $userId, $tenantRole); break;
case 'reorder': dbnToolsRequireMethod('POST'); reorderItems($db, $clientId, $userId); 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/saved-searches] ' . $e->getMessage());
dbnToolsError('Saved-search operation failed.', 500, 'op_failed');
}
function listItems(PDO $db, int $clientId, int $userId): void
{
$stmt = $db->prepare(
'SELECT id, name, icon, color, query_json, is_shared, sort_order, user_id, created_at, updated_at
FROM client_saved_searches
WHERE client_id = ? AND (user_id = ? OR is_shared = 1)
ORDER BY sort_order ASC, name ASC'
);
$stmt->execute([$clientId, $userId]);
$rows = $stmt->fetchAll();
foreach ($rows as &$r) {
$r['query'] = json_decode((string)$r['query_json'], true) ?? [];
$r['is_mine'] = (int)$r['user_id'] === $userId;
$r['is_shared'] = (int)$r['is_shared'] === 1;
unset($r['query_json']);
}
dbnToolsRespond(['ok' => true, 'items' => $rows]);
}
function createItem(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(20_000);
$name = trim((string)($input['name'] ?? ''));
$query = $input['query'] ?? [];
$isShared = !empty($input['is_shared']);
$color = trim((string)($input['color'] ?? ''));
$icon = trim((string)($input['icon'] ?? ''));
if ($name === '' || mb_strlen($name, 'UTF-8') > 120) {
dbnToolsError('Name is required (1120 chars).', 422, 'invalid_name');
}
if (!is_array($query) || !$query) {
dbnToolsError('Query payload is required.', 422, 'invalid_query');
}
if ($isShared && !in_array($tenantRole, ['editor','admin','owner'], true)) {
dbnToolsError('Only editors+ can share smart folders.', 403, 'forbidden');
}
if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
dbnToolsError('Invalid color.', 422, 'invalid_color');
}
$stmt = $db->prepare(
'INSERT INTO client_saved_searches
(client_id, user_id, name, icon, color, query_json, is_shared, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, NOW(), NOW())'
);
$stmt->execute([
$clientId, $userId, $name,
$icon !== '' ? substr($icon, 0, 40) : null,
$color !== '' ? $color : null,
json_encode(sanitizeQuery($query), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$isShared ? 1 : 0,
]);
$id = (int)$db->lastInsertId();
dbnDmsLogAudit($clientId, $userId, 'saved_search_create', ['name' => $name, 'id' => $id]);
dbnToolsRespond(['ok' => true, 'id' => $id], 201);
}
function updateItem(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(20_000);
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
dbnToolsError('id is required.', 400, 'missing_id');
}
$row = $db->prepare('SELECT user_id, is_shared FROM client_saved_searches WHERE id = ? AND client_id = ?');
$row->execute([$id, $clientId]);
$existing = $row->fetch();
if (!$existing) {
dbnToolsError('Not found.', 404, 'not_found');
}
$isMine = (int)$existing['user_id'] === $userId;
$canEdit = $isMine || in_array($tenantRole, ['admin','owner'], true);
if (!$canEdit) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$fields = [];
$params = [];
if (array_key_exists('name', $input)) {
$name = trim((string)$input['name']);
if ($name === '' || mb_strlen($name, 'UTF-8') > 120) {
dbnToolsError('Invalid name.', 422, 'invalid_name');
}
$fields[] = 'name = ?';
$params[] = $name;
}
if (array_key_exists('query', $input)) {
if (!is_array($input['query']) || !$input['query']) {
dbnToolsError('Invalid query.', 422, 'invalid_query');
}
$fields[] = 'query_json = ?';
$params[] = json_encode(sanitizeQuery($input['query']), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
if (array_key_exists('is_shared', $input)) {
$fields[] = 'is_shared = ?';
$params[] = !empty($input['is_shared']) ? 1 : 0;
}
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 (!$fields) {
dbnToolsError('Nothing to update.', 400, 'no_fields');
}
$params[] = $id;
$params[] = $clientId;
$stmt = $db->prepare(
'UPDATE client_saved_searches SET ' . implode(', ', $fields) . ', updated_at = NOW()
WHERE id = ? AND client_id = ?'
);
$stmt->execute($params);
dbnDmsLogAudit($clientId, $userId, 'saved_search_update', ['id' => $id]);
dbnToolsRespond(['ok' => true]);
}
function deleteItem(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(2_000);
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
dbnToolsError('id is required.', 400, 'missing_id');
}
$row = $db->prepare('SELECT user_id FROM client_saved_searches WHERE id = ? AND client_id = ?');
$row->execute([$id, $clientId]);
$existing = $row->fetch();
if (!$existing) {
dbnToolsError('Not found.', 404, 'not_found');
}
$isMine = (int)$existing['user_id'] === $userId;
if (!$isMine && !in_array($tenantRole, ['admin','owner'], true)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$del = $db->prepare('DELETE FROM client_saved_searches WHERE id = ? AND client_id = ?');
$del->execute([$id, $clientId]);
dbnDmsLogAudit($clientId, $userId, 'saved_search_delete', ['id' => $id]);
dbnToolsRespond(['ok' => true]);
}
function reorderItems(PDO $db, int $clientId, int $userId): 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_saved_searches SET sort_order = ? WHERE id = ? AND client_id = ? AND (user_id = ? OR is_shared = 1)');
foreach ($ids as $i => $id) {
$upd->execute([$i, $id, $clientId, $userId]);
}
dbnToolsRespond(['ok' => true, 'reordered' => count($ids)]);
}
function sanitizeQuery(array $query): array
{
$allowed = ['q', 'category', 'status', 'source_type', 'folder_id', 'include_subfolders', 'tags', 'sort', 'dir'];
$clean = [];
foreach ($allowed as $key) {
if (array_key_exists($key, $query)) {
$clean[$key] = is_array($query[$key]) ? array_slice(array_values($query[$key]), 0, 50) : $query[$key];
}
}
return $clean;
}