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,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, 1–50, [a-z0-9-_]).', 422, 'invalid_slug');
|
||||
}
|
||||
if ($label === '' || mb_strlen($label, 'UTF-8') > 100) {
|
||||
dbnToolsError('Label is required (1–100).', 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)]);
|
||||
}
|
||||
Reference in New Issue
Block a user