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:
@@ -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 (1–120 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;
|
||||
}
|
||||
Reference in New Issue
Block a user