2e2b0b45fa
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>
210 lines
8.1 KiB
PHP
210 lines
8.1 KiB
PHP
<?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)]);
|
||
}
|