Files
dobetternorge-tools/api/dashboard/document-versions.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

207 lines
7.9 KiB
PHP

<?php
/**
* /api/dashboard/document-versions.php — per-document version history.
*
* GET ?action=list&document_id=X → { ok, versions: [...] }
* GET ?action=get&version_id=X → { ok, version: {...with content} }
* POST ?action=restore body: { document_id, version_id }
* → { ok, document_id, new_version_number, chunks }
* POST ?action=delete body: { version_id }
*/
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');
listVersions($db, $clientId, $userId, $tenantRole);
break;
case 'get':
dbnToolsRequireMethod('GET');
getVersion($db, $clientId, $userId, $tenantRole);
break;
case 'restore':
dbnToolsRequireMethod('POST');
restoreVersion($db, $clientId, $userId, $tenantRole);
break;
case 'delete':
dbnToolsRequireMethod('POST');
deleteVersion($db, $clientId, $userId, $tenantRole);
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/versions] ' . $e->getMessage());
dbnToolsError('Version operation failed.', 500, 'version_op_failed');
}
function assertDocumentReadable(PDO $db, int $clientId, int $userId, string $tenantRole, int $docId): array
{
$stmt = $db->prepare('SELECT id, folder_id, title, current_version FROM client_documents WHERE id = ? AND client_id = ?');
$stmt->execute([$docId, $clientId]);
$row = $stmt->fetch();
if (!$row) {
dbnToolsError('Document not found.', 404, 'not_found');
}
$fid = $row['folder_id'] ? (int)$row['folder_id'] : 0;
if (!dbnDmsUserCanAccessFolder($fid ?: null, 'read', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
return $row;
}
function listVersions(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$docId = (int)($_GET['document_id'] ?? 0);
if ($docId <= 0) {
dbnToolsError('document_id is required.', 400, 'missing_document_id');
}
assertDocumentReadable($db, $clientId, $userId, $tenantRole, $docId);
$stmt = $db->prepare(
'SELECT v.id, v.version_number, v.title, v.original_filename, v.file_size_bytes,
v.word_count, v.notes, v.uploaded_by, v.created_at,
u.email AS uploaded_email, u.full_name AS uploaded_name
FROM client_document_versions v
LEFT JOIN client_users u ON u.id = v.uploaded_by
WHERE v.document_id = ? AND v.client_id = ?
ORDER BY v.version_number DESC'
);
$stmt->execute([$docId, $clientId]);
dbnToolsRespond(['ok' => true, 'versions' => $stmt->fetchAll()]);
}
function getVersion(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$vid = (int)($_GET['version_id'] ?? 0);
if ($vid <= 0) {
dbnToolsError('version_id is required.', 400, 'missing_version_id');
}
$stmt = $db->prepare(
'SELECT * FROM client_document_versions WHERE id = ? AND client_id = ?'
);
$stmt->execute([$vid, $clientId]);
$row = $stmt->fetch();
if (!$row) {
dbnToolsError('Version not found.', 404, 'not_found');
}
assertDocumentReadable($db, $clientId, $userId, $tenantRole, (int)$row['document_id']);
dbnToolsRespond(['ok' => true, 'version' => $row]);
}
function restoreVersion(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(5_000);
$docId = (int)($input['document_id'] ?? 0);
$vid = (int)($input['version_id'] ?? 0);
if ($docId <= 0 || $vid <= 0) {
dbnToolsError('document_id and version_id are required.', 400, 'missing_args');
}
$cur = assertDocumentReadable($db, $clientId, $userId, $tenantRole, $docId);
$fid = $cur['folder_id'] ? (int)$cur['folder_id'] : 0;
if (!dbnDmsUserCanAccessFolder($fid ?: null, 'write', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$vstmt = $db->prepare('SELECT * FROM client_document_versions WHERE id = ? AND document_id = ? AND client_id = ?');
$vstmt->execute([$vid, $docId, $clientId]);
$ver = $vstmt->fetch();
if (!$ver) {
dbnToolsError('Version not found.', 404, 'not_found');
}
// Snapshot current state before restoring so we can roll-forward later.
dbnDmsSnapshotVersion($docId, $clientId, $userId, "Snapshot before restoring v{$ver['version_number']}");
$next = (int)$db->query("SELECT current_version FROM client_documents WHERE id = {$docId}")->fetchColumn();
$next = max($next, (int)$ver['version_number']) + 1;
$upd = $db->prepare(
"UPDATE client_documents
SET title = ?, content = ?, file_size_bytes = ?, word_count = ?,
original_filename = ?, storage_path = ?, current_version = ?, status = 'pending',
error_message = NULL, updated_at = NOW()
WHERE id = ? AND client_id = ?"
);
$upd->execute([
(string)$ver['title'],
(string)$ver['content'],
(int)$ver['file_size_bytes'],
(int)$ver['word_count'],
$ver['original_filename'],
$ver['storage_path'],
$next,
$docId, $clientId,
]);
try {
$db->prepare('DELETE FROM client_chunks WHERE client_id = ? AND document_id = ?')->execute([$clientId, $docId]);
} catch (Throwable $e) { /* tolerated */ }
$chunks = 0;
try {
$rag = new ClientRagPipeline($clientId);
$chunks = (int)$rag->ingestDocument($docId);
} catch (Throwable $e) {
$db->prepare("UPDATE client_documents SET status='error', error_message=? WHERE id=?")
->execute([substr($e->getMessage(), 0, 1000), $docId]);
}
dbnDmsLogAudit($clientId, $userId ?: null, 'restore_version',
['version_id' => $vid, 'restored_to' => $next], $docId, $fid ?: null);
dbnToolsRespond([
'ok' => true,
'document_id' => $docId,
'new_version_number' => $next,
'chunks' => $chunks,
]);
}
function deleteVersion(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(2_000);
$vid = (int)($input['version_id'] ?? 0);
if ($vid <= 0) {
dbnToolsError('version_id is required.', 400, 'missing_version_id');
}
$stmt = $db->prepare('SELECT document_id, storage_path FROM client_document_versions WHERE id = ? AND client_id = ?');
$stmt->execute([$vid, $clientId]);
$ver = $stmt->fetch();
if (!$ver) {
dbnToolsError('Version not found.', 404, 'not_found');
}
$cur = assertDocumentReadable($db, $clientId, $userId, $tenantRole, (int)$ver['document_id']);
$fid = $cur['folder_id'] ? (int)$cur['folder_id'] : 0;
if (!dbnDmsUserCanAccessFolder($fid ?: null, 'write', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$del = $db->prepare('DELETE FROM client_document_versions WHERE id = ? AND client_id = ?');
$del->execute([$vid, $clientId]);
if (!empty($ver['storage_path']) && is_file($ver['storage_path'])) {
@unlink($ver['storage_path']);
}
dbnDmsLogAudit($clientId, $userId ?: null, 'delete_version', ['version_id' => $vid], (int)$ver['document_id']);
dbnToolsRespond(['ok' => true]);
}