Files
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

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()];
}