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>
184 lines
8.1 KiB
PHP
184 lines
8.1 KiB
PHP
<?php
|
|
/**
|
|
* /api/dashboard/bulk.php — bulk operations on documents.
|
|
*
|
|
* POST body: { op: "move"|"retag"|"recategorize"|"trash"|"restore",
|
|
* ids: [1,2,3,...],
|
|
* ...op-specific args }
|
|
*
|
|
* Op args:
|
|
* move: { folder_id } — null/0 = unassigned
|
|
* retag: { tags: "a,b,c", mode: "replace"|"append"|"remove" }
|
|
* recategorize: { category: "slug" }
|
|
* trash: {} — soft delete
|
|
* restore: {} — un-trash
|
|
*
|
|
* Max 500 IDs per call.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
|
|
|
|
dbnToolsRequireMethod('POST');
|
|
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();
|
|
$input = dbnToolsJsonInput(200_000);
|
|
$op = (string)($input['op'] ?? '');
|
|
$ids = $input['ids'] ?? [];
|
|
|
|
if (!is_array($ids) || !$ids) {
|
|
dbnToolsError('ids array is required.', 400, 'missing_ids');
|
|
}
|
|
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v) => $v > 0)));
|
|
if (!$ids) {
|
|
dbnToolsError('No valid ids.', 400, 'invalid_ids');
|
|
}
|
|
if (count($ids) > 500) {
|
|
dbnToolsError('Maximum 500 ids per bulk operation.', 422, 'too_many');
|
|
}
|
|
|
|
$ph = implode(',', array_fill(0, count($ids), '?'));
|
|
|
|
// Load + ACL-check
|
|
$rows = $db->prepare("SELECT id, folder_id FROM client_documents WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NULL");
|
|
$rows->execute(array_merge([$clientId], $ids));
|
|
$allowedIds = [];
|
|
$perDoc = [];
|
|
foreach ($rows->fetchAll() as $r) {
|
|
$fid = $r['folder_id'] ? (int)$r['folder_id'] : 0;
|
|
if (dbnDmsUserCanAccessFolder($fid ?: null, 'write', $clientId, $userId, $tenantRole)) {
|
|
$allowedIds[] = (int)$r['id'];
|
|
$perDoc[(int)$r['id']] = $fid;
|
|
}
|
|
}
|
|
if (!$allowedIds) {
|
|
dbnToolsError('No accessible documents in selection.', 403, 'forbidden');
|
|
}
|
|
|
|
try {
|
|
switch ($op) {
|
|
case 'move': $result = opMove($db, $clientId, $userId, $tenantRole, $allowedIds, $input); break;
|
|
case 'retag': $result = opRetag($db, $clientId, $userId, $allowedIds, $input); break;
|
|
case 'recategorize': $result = opRecategorize($db, $clientId, $userId, $allowedIds, $input); break;
|
|
case 'trash': $result = opTrash($db, $clientId, $userId, $allowedIds); break;
|
|
case 'restore': $result = opRestore($db, $clientId, $userId, $allowedIds); break;
|
|
default:
|
|
dbnToolsError('Unknown op: ' . $op, 400, 'unknown_op');
|
|
}
|
|
} catch (DbnToolsHttpException $e) {
|
|
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
|
|
} catch (Throwable $e) {
|
|
error_log('[dbn-dms/bulk] ' . $e->getMessage());
|
|
dbnToolsError('Bulk operation failed.', 500, 'bulk_failed');
|
|
}
|
|
|
|
dbnToolsRespond($result);
|
|
|
|
|
|
function opMove(PDO $db, int $clientId, int $userId, string $tenantRole, array $ids, array $input): array
|
|
{
|
|
$dest = $input['folder_id'] ?? null;
|
|
$destId = ($dest === null || $dest === '' || $dest === 'unassigned' || (int)$dest === 0) ? null : (int)$dest;
|
|
if ($destId !== null && !dbnDmsUserCanAccessFolder($destId, 'write', $clientId, $userId, $tenantRole)) {
|
|
dbnToolsError('Forbidden on destination folder.', 403, 'forbidden_dest');
|
|
}
|
|
if ($destId !== null) {
|
|
$check = $db->prepare('SELECT id FROM client_folders WHERE id = ? AND client_id = ? AND deleted_at IS NULL');
|
|
$check->execute([$destId, $clientId]);
|
|
if (!$check->fetchColumn()) {
|
|
dbnToolsError('Destination folder not found.', 404, 'folder_not_found');
|
|
}
|
|
}
|
|
$ph = implode(',', array_fill(0, count($ids), '?'));
|
|
$stmt = $db->prepare("UPDATE client_documents SET folder_id = ?, updated_at = NOW() WHERE client_id = ? AND id IN ({$ph})");
|
|
$stmt->execute(array_merge([$destId, $clientId], $ids));
|
|
dbnDmsLogAudit($clientId, $userId ?: null, 'bulk_move', ['dest' => $destId, 'count' => count($ids), 'ids' => $ids]);
|
|
return ['ok' => true, 'op' => 'move', 'affected' => $stmt->rowCount()];
|
|
}
|
|
|
|
function opRetag(PDO $db, int $clientId, int $userId, array $ids, array $input): array
|
|
{
|
|
$mode = strtolower((string)($input['mode'] ?? 'replace'));
|
|
if (!in_array($mode, ['replace','append','remove'], true)) {
|
|
dbnToolsError('Invalid mode (replace|append|remove).', 422, 'invalid_mode');
|
|
}
|
|
$raw = (string)($input['tags'] ?? '');
|
|
$newTags = array_values(array_filter(array_map('trim', explode(',', $raw))));
|
|
$newTags = array_map(fn($t) => substr($t, 0, 32), $newTags);
|
|
$newTags = array_slice($newTags, 0, 20);
|
|
|
|
if ($mode === 'replace') {
|
|
$ph = implode(',', array_fill(0, count($ids), '?'));
|
|
$stmt = $db->prepare("UPDATE client_documents SET tags = ?, updated_at = NOW() WHERE client_id = ? AND id IN ({$ph})");
|
|
$stmt->execute(array_merge([implode(',', $newTags), $clientId], $ids));
|
|
$affected = $stmt->rowCount();
|
|
} else {
|
|
// Per-row merge.
|
|
$affected = 0;
|
|
$ph = implode(',', array_fill(0, count($ids), '?'));
|
|
$cur = $db->prepare("SELECT id, tags FROM client_documents WHERE client_id = ? AND id IN ({$ph})");
|
|
$cur->execute(array_merge([$clientId], $ids));
|
|
$upd = $db->prepare("UPDATE client_documents SET tags = ?, updated_at = NOW() WHERE id = ? AND client_id = ?");
|
|
foreach ($cur->fetchAll() as $row) {
|
|
$existing = array_values(array_filter(array_map('trim', explode(',', (string)$row['tags']))));
|
|
if ($mode === 'append') {
|
|
$merged = array_values(array_unique(array_merge($existing, $newTags)));
|
|
} else { // remove
|
|
$merged = array_values(array_diff($existing, $newTags));
|
|
}
|
|
$merged = array_slice($merged, 0, 20);
|
|
$upd->execute([implode(',', $merged), (int)$row['id'], $clientId]);
|
|
$affected++;
|
|
}
|
|
}
|
|
dbnDmsLogAudit($clientId, $userId ?: null, 'bulk_retag', ['mode' => $mode, 'tags' => $newTags, 'count' => count($ids), 'ids' => $ids]);
|
|
return ['ok' => true, 'op' => 'retag', 'mode' => $mode, 'affected' => $affected];
|
|
}
|
|
|
|
function opRecategorize(PDO $db, int $clientId, int $userId, array $ids, array $input): array
|
|
{
|
|
$cat = strtolower(trim((string)($input['category'] ?? '')));
|
|
$cat = preg_replace('/[^a-z0-9\-_]/', '', $cat) ?: 'uncategorized';
|
|
$cat = substr($cat, 0, 50);
|
|
$ph = implode(',', array_fill(0, count($ids), '?'));
|
|
$stmt = $db->prepare("UPDATE client_documents SET category = ?, updated_at = NOW() WHERE client_id = ? AND id IN ({$ph})");
|
|
$stmt->execute(array_merge([$cat, $clientId], $ids));
|
|
dbnDmsLogAudit($clientId, $userId ?: null, 'bulk_recategorize', ['category' => $cat, 'count' => count($ids), 'ids' => $ids]);
|
|
return ['ok' => true, 'op' => 'recategorize', 'category' => $cat, 'affected' => $stmt->rowCount()];
|
|
}
|
|
|
|
function opTrash(PDO $db, int $clientId, int $userId, array $ids): array
|
|
{
|
|
$ph = implode(',', array_fill(0, count($ids), '?'));
|
|
$stmt = $db->prepare(
|
|
"UPDATE client_documents SET deleted_at = NOW(), deleted_by = ?, updated_at = NOW()
|
|
WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NULL"
|
|
);
|
|
$stmt->execute(array_merge([$userId ?: null, $clientId], $ids));
|
|
dbnDmsLogAudit($clientId, $userId ?: null, 'bulk_trash', ['count' => count($ids), 'ids' => $ids]);
|
|
return ['ok' => true, 'op' => 'trash', 'affected' => $stmt->rowCount()];
|
|
}
|
|
|
|
function opRestore(PDO $db, int $clientId, int $userId, array $ids): array
|
|
{
|
|
$ph = implode(',', array_fill(0, count($ids), '?'));
|
|
$stmt = $db->prepare(
|
|
"UPDATE client_documents SET deleted_at = NULL, deleted_by = NULL, updated_at = NOW()
|
|
WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NOT NULL"
|
|
);
|
|
$stmt->execute(array_merge([$clientId], $ids));
|
|
dbnDmsLogAudit($clientId, $userId ?: null, 'bulk_restore', ['count' => count($ids), 'ids' => $ids]);
|
|
return ['ok' => true, 'op' => 'restore', 'affected' => $stmt->rowCount()];
|
|
}
|