Files
dobetternorge-tools/api/dashboard/saved-searches.php
T
daveadmin 2e2b0b45fa 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>
2026-05-26 22:24:56 +02:00

223 lines
8.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}