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:
2026-05-26 22:24:56 +02:00
parent b84827ecea
commit 2e2b0b45fa
30 changed files with 5438 additions and 335 deletions
+183
View File
@@ -0,0 +1,183 @@
<?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()];
}
+209
View File
@@ -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, 150, [a-z0-9-_]).', 422, 'invalid_slug');
}
if ($label === '' || mb_strlen($label, 'UTF-8') > 100) {
dbnToolsError('Label is required (1100).', 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)]);
}
+56
View File
@@ -48,6 +48,15 @@ $history = array_values(array_filter($history, fn($m) => is_array($m)
$category = trim((string)($input['category'] ?? '')) ?: null; $category = trim((string)($input['category'] ?? '')) ?: null;
$language = in_array($input['language'] ?? 'no', ['no', 'en'], true) ? $input['language'] : 'no'; $language = in_array($input['language'] ?? 'no', ['no', 'en'], true) ? $input['language'] : 'no';
// Folder scope: limit retrieval to a folder subtree, ACL-checked.
$folderScopeRaw = $input['folder_id'] ?? null;
$folderScope = null;
if ($folderScopeRaw !== null && $folderScopeRaw !== '' && $folderScopeRaw !== 'all') {
$folderScope = $folderScopeRaw === 'unassigned' ? 0 : (int)$folderScopeRaw;
}
$includeSubfolders = !empty($input['include_subfolders']);
$includeRelated = !empty($input['include_related']);
// SSE setup // SSE setup
header('Content-Type: text/event-stream'); header('Content-Type: text/event-stream');
header('Cache-Control: no-cache, no-transform'); header('Cache-Control: no-cache, no-transform');
@@ -75,6 +84,37 @@ try {
'user_role' => 'owner', 'user_role' => 'owner',
]; ];
// Apply folder scoping via allowed_folder_ids (supported by ClientRagPipeline).
if ($folderScope !== null) {
if ($folderScope === 0) {
// Unassigned only — currently not supported by allowed_folder_ids; pass empty array
// which the pipeline treats as "no folders allowed" → falls back to docs with NULL folder_id.
$options['allowed_folder_ids'] = [];
} else {
$ids = [$folderScope];
if ($includeSubfolders) {
try {
$db = dbnToolsDb();
$stack = [$folderScope];
$guard = 0;
while ($stack && $guard++ < 1000) {
$batch = $stack;
$stack = [];
$ph = implode(',', array_fill(0, count($batch), '?'));
$st = $db->prepare("SELECT id FROM client_folders WHERE client_id = ? AND parent_id IN ({$ph}) AND deleted_at IS NULL");
$st->execute(array_merge([$clientId], $batch));
foreach ($st->fetchAll() as $r) {
$cid = (int)$r['id'];
$ids[] = $cid;
$stack[] = $cid;
}
}
} catch (Throwable $e) { /* tolerated */ }
}
$options['allowed_folder_ids'] = $ids;
}
}
$result = $rag->askStreaming( $result = $rag->askStreaming(
$question, $question,
null, // model: let pipeline choose default null, // model: let pipeline choose default
@@ -97,12 +137,28 @@ try {
]; ];
} }
// Related documents from FalkorDB graph (co-citation), based on the top source doc.
$related = [];
if ($includeRelated && $sources && method_exists($rag, 'relatedDocumentsFromGraph')) {
$topDoc = (int)($sources[0]['document_id'] ?? 0);
if ($topDoc > 0) {
try {
$related = $rag->relatedDocumentsFromGraph($topDoc, 6);
} catch (Throwable $e) { /* tolerated */ }
}
}
sseEmit('done', [ sseEmit('done', [
'ok' => true, 'ok' => true,
'chunks_used' => (int)($result['chunks_used'] ?? count($sources)), 'chunks_used' => (int)($result['chunks_used'] ?? count($sources)),
'model' => (string)($result['model'] ?? ''), 'model' => (string)($result['model'] ?? ''),
'response_time_ms'=> (int)($result['response_time_ms'] ?? 0), 'response_time_ms'=> (int)($result['response_time_ms'] ?? 0),
'sources' => $sources, 'sources' => $sources,
'related_documents' => $related,
'scope' => [
'folder_id' => $folderScope,
'include_subfolders'=> $includeSubfolders,
],
]); ]);
} catch (Throwable $e) { } catch (Throwable $e) {
sseEmit('fail', ['ok' => false, 'message' => $e->getMessage()]); sseEmit('fail', ['ok' => false, 'message' => $e->getMessage()]);
+165
View File
@@ -0,0 +1,165 @@
<?php
/**
* /api/dashboard/diagnostics.php
*
* Returns live status of the DMS stack for the current tenant:
* - MariaDB tenant tables (counts, FULLTEXT index)
* - Qdrant bnl_client_chunks collection (points)
* - LiteLLM embed endpoint (reachability + model)
* - FalkorDB dbn_client_graph (node count for this client)
* - On-disk storage usage
*
* GET → { ok, sections: [{key,label,value,status,detail}] }
*/
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'];
$out = [];
/* MariaDB doc/chunk counts */
try {
$db = dbnToolsDb();
$docs = (int)$db->query("SELECT COUNT(*) FROM client_documents WHERE client_id = {$clientId} AND deleted_at IS NULL")->fetchColumn();
$deleted = (int)$db->query("SELECT COUNT(*) FROM client_documents WHERE client_id = {$clientId} AND deleted_at IS NOT NULL")->fetchColumn();
$chunks = 0;
try {
$chunks = (int)$db->query("SELECT COUNT(*) FROM client_chunks WHERE client_id = {$clientId}")->fetchColumn();
} catch (Throwable $_) {}
$out[] = ['key' => 'mariadb', 'label' => 'MariaDB (bnl_admin)',
'value' => "{$docs} docs · {$chunks} chunks · {$deleted} trashed",
'status' => 'ok',
'detail' => 'chloe MySQL — client_documents + client_chunks tables'];
} catch (Throwable $e) {
$out[] = ['key' => 'mariadb', 'label' => 'MariaDB', 'value' => 'unreachable', 'status' => 'err', 'detail' => $e->getMessage()];
}
/* FULLTEXT index presence */
try {
$db = dbnToolsDb();
$ft = $db->query("SHOW INDEX FROM client_chunks WHERE Key_name = 'ft_content'")->fetchAll();
$out[] = ['key' => 'fulltext', 'label' => 'MariaDB FULLTEXT (BM25)',
'value' => $ft ? 'present on client_chunks.content' : 'missing',
'status' => $ft ? 'ok' : 'warn',
'detail' => 'Required for hybrid keyword search; migration 007'];
} catch (Throwable $e) {
$out[] = ['key' => 'fulltext', 'label' => 'MariaDB FULLTEXT', 'value' => '—', 'status' => 'warn', 'detail' => $e->getMessage()];
}
/* Qdrant — count vectors for this client via scroll */
try {
$ch = curl_init('http://10.0.1.10:6333/collections/bnl_client_chunks/points/count');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 4,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'exact' => true,
'filter' => [ 'must' => [ ['key' => 'client_id', 'match' => ['value' => $clientId]] ] ],
]),
]);
$body = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http === 200 && $body) {
$j = json_decode($body, true);
$cnt = (int)($j['result']['count'] ?? 0);
$out[] = ['key' => 'qdrant', 'label' => 'Qdrant (bnl_client_chunks)',
'value' => $cnt . ' points',
'status' => 'ok',
'detail' => 'Colin Docker @ 10.0.2.10:6333, filtered by client_id'];
} else {
$out[] = ['key' => 'qdrant', 'label' => 'Qdrant', 'value' => 'http ' . $http, 'status' => 'warn', 'detail' => 'Not reachable from web container; vector search may fall back to MariaDB.'];
}
} catch (Throwable $e) {
$out[] = ['key' => 'qdrant', 'label' => 'Qdrant', 'value' => 'error', 'status' => 'err', 'detail' => $e->getMessage()];
}
/* LiteLLM embed endpoint */
try {
$ch = curl_init('http://10.0.1.10:4000/v1/models');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 4,
CURLOPT_HTTPHEADER => ['Authorization: Bearer sk-bnl-litellm-26xR9mK4qvN3wL8sTj7pB2d'],
]);
$body = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$hasEmbed = $body && strpos($body, 'nomic-embed') !== false;
$out[] = ['key' => 'litellm', 'label' => 'LiteLLM (Colin)',
'value' => $http === 200 ? ($hasEmbed ? 'reachable · nomic-embed-text registered' : 'reachable · embed model not found') : ('http ' . $http),
'status' => $http === 200 ? ($hasEmbed ? 'ok' : 'warn') : 'warn',
'detail' => 'http://10.0.1.10:4000 — chunker uses /v1/embeddings'];
} catch (Throwable $e) {
$out[] = ['key' => 'litellm', 'label' => 'LiteLLM', 'value' => 'error', 'status' => 'err', 'detail' => $e->getMessage()];
}
/* FalkorDB nodes for this client */
try {
$sock = @stream_socket_client('tcp://10.0.2.10:6379', $errno, $errstr, 2);
if ($sock) {
$cmd = "GRAPH.QUERY";
$graph = "dbn_client_graph";
$cypher = "MATCH (d:Document {client_id: {$clientId}}) RETURN count(d)";
$payload = "*4\r\n$" . strlen($cmd) . "\r\n{$cmd}\r\n$" . strlen($graph) . "\r\n{$graph}\r\n$" . strlen($cypher) . "\r\n{$cypher}\r\n$8\r\n--compact\r\n";
fwrite($sock, $payload);
stream_set_timeout($sock, 2);
$resp = @fread($sock, 8192);
fclose($sock);
$count = 0;
if (preg_match('/(\d+)/', (string)$resp, $m)) $count = (int)$m[1];
$out[] = ['key' => 'falkor', 'label' => 'FalkorDB (dbn_client_graph)',
'value' => $count . ' Document nodes',
'status' => 'ok',
'detail' => 'Colin @ 10.0.2.10:6379 — populated during ingest'];
} else {
$out[] = ['key' => 'falkor', 'label' => 'FalkorDB', 'value' => 'unreachable', 'status' => 'warn',
'detail' => 'Graph features hidden; ingest still works.'];
}
} catch (Throwable $e) {
$out[] = ['key' => 'falkor', 'label' => 'FalkorDB', 'value' => 'error', 'status' => 'warn', 'detail' => $e->getMessage()];
}
/* On-disk storage usage */
try {
$root = dbnToolsEnv('DBN_TOOLS_UPLOAD_ROOT', '')
?: (is_dir('/home/dobetternorge/uploads') ? '/home/dobetternorge/uploads' : DBN_TOOLS_ROOT . '/uploads');
$clientDir = rtrim($root, '/') . '/' . $clientId;
$total = 0; $files = 0;
if (is_dir($clientDir)) {
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($clientDir, FilesystemIterator::SKIP_DOTS));
foreach ($it as $f) {
if ($f->isFile()) { $total += $f->getSize(); $files++; }
}
}
$human = $total < 1024*1024 ? round($total/1024) . ' KB'
: ($total < 1024*1024*1024 ? round($total/1024/1024, 1) . ' MB' : round($total/1024/1024/1024, 2) . ' GB');
$out[] = ['key' => 'storage', 'label' => 'Original file storage',
'value' => $human . ' · ' . $files . ' files',
'status' => is_dir($clientDir) ? 'ok' : 'warn',
'detail' => $clientDir];
} catch (Throwable $e) {
$out[] = ['key' => 'storage', 'label' => 'Storage', 'value' => 'error', 'status' => 'warn', 'detail' => $e->getMessage()];
}
dbnToolsRespond([
'ok' => true,
'tenant' => [
'client_id' => $clientId,
'corpus_id' => (int)$tenant['corpus_id'],
'user_id' => (int)$tenant['client_user_id'],
],
'sections' => $out,
]);
+206
View File
@@ -0,0 +1,206 @@
<?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]);
}
+355 -107
View File
@@ -1,17 +1,25 @@
<?php <?php
/** /**
* /api/dashboard/documents.php — CRUD for the current user's CaveauAI documents. * /api/dashboard/documents.php — CRUD for the current tenant's documents.
*
* GET ?action=list &offset=&limit=&q=&status=&category=&source_type=
* &folder_id=(int|'unassigned'|'all')&include_subfolders=1
* &trashed=0|1&sort=updated_at|title|file_size_bytes&dir=asc|desc
* → { ok, total, documents: [...], folder?: {...} }
* *
* GET ?action=list&offset=0&limit=20&q=&status=&category=
* → { ok, total, documents: [...] }
* GET ?action=get&id=123 * GET ?action=get&id=123
* → { ok, document: {...}, chunks: [...], versions: [...], permissions: {...} }
*
* POST ?action=update body: { id, title?, category?, tags?, language?, author?, folder_id? }
* → { ok, document: {...} } * → { ok, document: {...} }
* POST ?action=update body: { id, title?, category?, tags?, language?, author? } *
* → { ok, document: {...} } * POST ?action=delete body: { ids: [1,2,3], hard_delete?: false }
* POST ?action=delete body: { ids: [1,2,3] }
* → { ok, deleted: N } * → { ok, deleted: N }
* *
* All filtered by client_id from the dashboard session — no cross-tenant access possible. * POST ?action=restore body: { ids: [1,2,3] }
* → { ok, restored: N }
*
* All tenant-isolated via dbnToolsEnsureDashboardTenant().
*/ */
declare(strict_types=1); declare(strict_types=1);
@@ -25,53 +33,105 @@ try {
} catch (DbnToolsHttpException $e) { } catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode); dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
} }
$clientId = (int)$tenant['client_id'];
$clientId = (int)$tenant['client_id'];
$userId = (int)($tenant['client_user_id'] ?? 0);
$tenantRole = (string)($tenant['role'] ?? 'editor');
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')); $method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$action = (string)($_GET['action'] ?? ($method === 'POST' ? '' : 'list')); $action = (string)($_GET['action'] ?? ($method === 'POST' ? '' : 'list'));
$db = dbnToolsDb(); $db = dbnToolsDb();
switch ($action) { try {
case 'list': switch ($action) {
dbnToolsRequireMethod('GET'); case 'list':
respondList($db, $clientId); dbnToolsRequireMethod('GET');
break; respondList($db, $clientId, $userId, $tenantRole);
case 'get': break;
dbnToolsRequireMethod('GET'); case 'get':
respondGet($db, $clientId); dbnToolsRequireMethod('GET');
break; respondGet($db, $clientId, $userId, $tenantRole);
case 'update': break;
dbnToolsRequireMethod('POST'); case 'update':
respondUpdate($db, $clientId); dbnToolsRequireMethod('POST');
break; respondUpdate($db, $clientId, $userId, $tenantRole);
case 'delete': break;
dbnToolsRequireMethod('POST'); case 'delete':
respondDelete($db, $clientId); dbnToolsRequireMethod('POST');
break; respondDelete($db, $clientId, $userId, $tenantRole);
default: break;
dbnToolsError('Unknown action.', 400, 'unknown_action'); case 'restore':
dbnToolsRequireMethod('POST');
respondRestore($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/documents] ' . $e->getMessage());
dbnToolsError('Document operation failed.', 500, 'doc_op_failed');
} }
function respondList(PDO $db, int $clientId): void function respondList(PDO $db, int $clientId, int $userId, string $tenantRole): void
{ {
$offset = max(0, (int)($_GET['offset'] ?? 0)); $offset = max(0, (int)($_GET['offset'] ?? 0));
$limit = max(1, min(100, (int)($_GET['limit'] ?? 20))); $limit = max(1, min(200, (int)($_GET['limit'] ?? 25)));
$q = trim((string)($_GET['q'] ?? '')); $q = trim((string)($_GET['q'] ?? ''));
$status = trim((string)($_GET['status'] ?? '')); $status = trim((string)($_GET['status'] ?? ''));
$category = trim((string)($_GET['category'] ?? '')); $category = trim((string)($_GET['category'] ?? ''));
$sourceType = trim((string)($_GET['source_type'] ?? ''));
$trashed = !empty($_GET['trashed']);
$folderParam = (string)($_GET['folder_id'] ?? 'all');
$includeSub = !empty($_GET['include_subfolders']);
$sort = strtolower((string)($_GET['sort'] ?? 'updated_at'));
$dir = strtolower((string)($_GET['dir'] ?? 'desc')) === 'asc' ? 'ASC' : 'DESC';
$where = ['client_id = ?']; $where = ['client_id = ?'];
$params = [$clientId]; $params = [$clientId];
if ($trashed) {
$where[] = 'deleted_at IS NOT NULL';
} else {
$where[] = 'deleted_at IS NULL';
}
// Folder scoping
$folderMeta = null;
if ($folderParam === 'unassigned') {
$where[] = 'folder_id IS NULL';
} elseif ($folderParam === 'all' || $folderParam === '') {
// no folder filter
} else {
$fid = (int)$folderParam;
if ($fid > 0) {
if (!dbnDmsUserCanAccessFolder($fid, 'read', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
if ($includeSub) {
$ids = array_merge([$fid], dbnDmsCollectSubtreeIdsForList($db, $fid, $clientId));
$ph = implode(',', array_fill(0, count($ids), '?'));
$where[] = "folder_id IN ({$ph})";
$params = array_merge($params, $ids);
} else {
$where[] = 'folder_id = ?';
$params[] = $fid;
}
$folderRow = $db->prepare('SELECT id, name, parent_id, color FROM client_folders WHERE id = ? AND client_id = ?');
$folderRow->execute([$fid, $clientId]);
$folderMeta = $folderRow->fetch() ?: null;
}
}
if ($q !== '') { if ($q !== '') {
$where[] = '(title LIKE ? OR tags LIKE ?)'; $where[] = '(title LIKE ? OR tags LIKE ?)';
$like = '%' . str_replace(['%', '_'], ['\%', '\_'], $q) . '%'; $like = '%' . str_replace(['%','_'], ['\%','\_'], $q) . '%';
$params[] = $like; $params[] = $like;
$params[] = $like; $params[] = $like;
} }
$allowedStatus = ['pending', 'processing', 'ready', 'error']; if ($status !== '' && in_array($status, ['pending','processing','ready','error'], true)) {
if ($status !== '' && in_array($status, $allowedStatus, true)) {
$where[] = 'status = ?'; $where[] = 'status = ?';
$params[] = $status; $params[] = $status;
} }
@@ -79,77 +139,138 @@ function respondList(PDO $db, int $clientId): void
$where[] = 'category = ?'; $where[] = 'category = ?';
$params[] = $category; $params[] = $category;
} }
$sourceType = trim((string)($_GET['source_type'] ?? '')); if ($sourceType !== '' && in_array($sourceType, ['text','audio','url','tool-output','upload','pdf','docx'], true)) {
$allowedSourceTypes = ['text', 'audio', 'url', 'tool-output', 'upload'];
if ($sourceType !== '' && in_array($sourceType, $allowedSourceTypes, true)) {
$where[] = 'source_type = ?'; $where[] = 'source_type = ?';
$params[] = $sourceType; $params[] = $sourceType;
} }
$whereSql = 'WHERE ' . implode(' AND ', $where); $whereSql = 'WHERE ' . implode(' AND ', $where);
$sortMap = [
'updated_at' => 'COALESCE(updated_at, created_at)',
'created_at' => 'created_at',
'title' => 'title',
'file_size_bytes' => 'file_size_bytes',
'word_count' => 'word_count',
];
$sortCol = $sortMap[$sort] ?? 'COALESCE(updated_at, created_at)';
$countStmt = $db->prepare("SELECT COUNT(*) FROM client_documents {$whereSql}"); $countStmt = $db->prepare("SELECT COUNT(*) FROM client_documents {$whereSql}");
$countStmt->execute($params); $countStmt->execute($params);
$total = (int)$countStmt->fetchColumn(); $total = (int)$countStmt->fetchColumn();
$sql = "SELECT id, title, source_type, language, category, tags, author, $sql = "SELECT id, folder_id, title, source_type, language, category, tags, author,
source_tool, import_method, status, word_count, chunk_count, source_tool, import_method, status, word_count, chunk_count,
file_size_bytes, source_url, error_message, file_size_bytes, source_url, original_filename, storage_path,
current_version, deleted_at, error_message,
created_at, updated_at created_at, updated_at
FROM client_documents FROM client_documents
{$whereSql} {$whereSql}
ORDER BY id DESC ORDER BY {$sortCol} {$dir}, id DESC
LIMIT {$limit} OFFSET {$offset}"; LIMIT {$limit} OFFSET {$offset}";
$stmt = $db->prepare($sql); $stmt = $db->prepare($sql);
$stmt->execute($params); $stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
// ACL filter: drop docs whose folder the user can't read.
$visible = [];
$aclCache = [];
foreach ($rows as $row) {
$fid = isset($row['folder_id']) ? (int)$row['folder_id'] : 0;
if (!isset($aclCache[$fid])) {
$aclCache[$fid] = $fid === 0
? true
: dbnDmsUserCanAccessFolder($fid, 'read', $clientId, $userId, $tenantRole);
}
if (!$aclCache[$fid]) {
continue;
}
$visible[] = shapeDoc($row);
}
dbnToolsRespond([ dbnToolsRespond([
'ok' => true, 'ok' => true,
'total' => $total, 'total' => $total,
'offset' => $offset, 'offset' => $offset,
'limit' => $limit, 'limit' => $limit,
'documents' => array_map('shapeDoc', $rows), 'documents' => $visible,
'folder' => $folderMeta ? [
'id' => (int)$folderMeta['id'],
'name' => (string)$folderMeta['name'],
'parent_id' => $folderMeta['parent_id'] ? (int)$folderMeta['parent_id'] : null,
'color' => $folderMeta['color'] ?? null,
'breadcrumb'=> dbnDmsBreadcrumb((int)$folderMeta['id'], $clientId),
] : null,
]); ]);
} }
function respondGet(PDO $db, int $clientId): void function respondGet(PDO $db, int $clientId, int $userId, string $tenantRole): void
{ {
$id = (int)($_GET['id'] ?? 0); $id = (int)($_GET['id'] ?? 0);
if ($id <= 0) { if ($id <= 0) {
dbnToolsError('id is required.', 400, 'missing_id'); dbnToolsError('id is required.', 400, 'missing_id');
} }
$stmt = $db->prepare( $stmt = $db->prepare('SELECT * FROM client_documents WHERE id = ? AND client_id = ? LIMIT 1');
'SELECT * FROM client_documents WHERE id = ? AND client_id = ? LIMIT 1'
);
$stmt->execute([$id, $clientId]); $stmt->execute([$id, $clientId]);
$doc = $stmt->fetch(PDO::FETCH_ASSOC); $doc = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$doc) { if (!$doc) {
dbnToolsError('Document not found.', 404, 'not_found'); dbnToolsError('Document not found.', 404, 'not_found');
} }
$fid = $doc['folder_id'] ? (int)$doc['folder_id'] : 0;
$chunks = $db->prepare( if (!dbnDmsUserCanAccessFolder($fid ?: null, 'read', $clientId, $userId, $tenantRole)) {
'SELECT id, content, section_title dbnToolsError('Forbidden.', 403, 'forbidden');
FROM client_chunks
WHERE client_id = ? AND document_id = ?
ORDER BY id ASC
LIMIT 200'
);
try {
$chunks->execute([$clientId, $id]);
$chunkRows = $chunks->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) {
$chunkRows = [];
} }
$chunkRows = [];
try {
$chunks = $db->prepare(
'SELECT id, content, section_title
FROM client_chunks
WHERE client_id = ? AND document_id = ?
ORDER BY id ASC LIMIT 200'
);
$chunks->execute([$clientId, $id]);
$chunkRows = $chunks->fetchAll();
} catch (Throwable $e) {
// tolerated
}
$versions = [];
try {
$vstmt = $db->prepare(
'SELECT id, version_number, title, original_filename, file_size_bytes, word_count,
uploaded_by, notes, created_at
FROM client_document_versions
WHERE document_id = ? AND client_id = ?
ORDER BY version_number DESC LIMIT 50'
);
$vstmt->execute([$id, $clientId]);
$versions = $vstmt->fetchAll();
} catch (Throwable $e) {
// table may not exist yet
}
$permissions = [
'can_read' => true,
'can_write' => dbnDmsUserCanAccessFolder($fid ?: null, 'write', $clientId, $userId, $tenantRole),
'can_manage' => dbnDmsUserCanAccessFolder($fid ?: null, 'manage', $clientId, $userId, $tenantRole),
];
dbnDmsLogAudit($clientId, $userId ?: null, 'view', [], $id, $fid ?: null);
dbnToolsRespond([ dbnToolsRespond([
'ok' => true, 'ok' => true,
'document' => shapeDoc($doc) + ['content' => (string)$doc['content']], 'document' => shapeDoc($doc) + [
'chunks' => $chunkRows, 'content' => (string)($doc['content'] ?? ''),
'breadcrumb' => dbnDmsBreadcrumb($fid ?: null, $clientId),
],
'chunks' => $chunkRows,
'versions' => $versions,
'permissions' => $permissions,
]); ]);
} }
function respondUpdate(PDO $db, int $clientId): void function respondUpdate(PDO $db, int $clientId, int $userId, string $tenantRole): void
{ {
$input = dbnToolsJsonInput(20_000); $input = dbnToolsJsonInput(20_000);
$id = (int)($input['id'] ?? 0); $id = (int)($input['id'] ?? 0);
@@ -157,16 +278,29 @@ function respondUpdate(PDO $db, int $clientId): void
dbnToolsError('id is required.', 400, 'missing_id'); dbnToolsError('id is required.', 400, 'missing_id');
} }
// Load doc to ACL-check current folder.
$cur = $db->prepare('SELECT id, folder_id FROM client_documents WHERE id = ? AND client_id = ?');
$cur->execute([$id, $clientId]);
$existing = $cur->fetch();
if (!$existing) {
dbnToolsError('Document not found.', 404, 'not_found');
}
$existingFid = $existing['folder_id'] ? (int)$existing['folder_id'] : 0;
if (!dbnDmsUserCanAccessFolder($existingFid ?: null, 'write', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden on source folder.', 403, 'forbidden_source');
}
$allowed = [
'title' => 500,
'category' => 50,
'tags' => 500,
'language' => 10,
'author' => 200,
];
$fields = []; $fields = [];
$params = []; $params = [];
$allowed = [ foreach ($allowed as $col => $max) {
'title' => ['VARCHAR', 500],
'category' => ['VARCHAR', 50],
'tags' => ['VARCHAR', 500],
'language' => ['VARCHAR', 10],
'author' => ['VARCHAR', 200],
];
foreach ($allowed as $col => [$kind, $max]) {
if (!array_key_exists($col, $input)) { if (!array_key_exists($col, $input)) {
continue; continue;
} }
@@ -177,6 +311,26 @@ function respondUpdate(PDO $db, int $clientId): void
$fields[] = "{$col} = ?"; $fields[] = "{$col} = ?";
$params[] = $val !== '' ? $val : null; $params[] = $val !== '' ? $val : null;
} }
// folder_id move
$movedTo = null;
if (array_key_exists('folder_id', $input)) {
$newFid = $input['folder_id'] === null || $input['folder_id'] === '' ? null : (int)$input['folder_id'];
if ($newFid !== null && $newFid > 0) {
if (!dbnDmsUserCanAccessFolder($newFid, 'write', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden on destination folder.', 403, 'forbidden_dest');
}
$check = $db->prepare('SELECT id FROM client_folders WHERE id = ? AND client_id = ? AND deleted_at IS NULL');
$check->execute([$newFid, $clientId]);
if (!$check->fetchColumn()) {
dbnToolsError('Destination folder not found.', 404, 'folder_not_found');
}
}
$fields[] = 'folder_id = ?';
$params[] = $newFid;
$movedTo = $newFid;
}
if (!$fields) { if (!$fields) {
dbnToolsError('No editable fields supplied.', 400, 'no_fields'); dbnToolsError('No editable fields supplied.', 400, 'no_fields');
} }
@@ -189,6 +343,12 @@ function respondUpdate(PDO $db, int $clientId): void
); );
$stmt->execute($params); $stmt->execute($params);
dbnDmsLogAudit($clientId, $userId ?: null,
$movedTo !== null ? 'move' : 'edit',
['fields' => array_keys(array_intersect_key($input, $allowed)),
'new_folder_id' => $movedTo],
$id, $movedTo);
$stmt = $db->prepare('SELECT * FROM client_documents WHERE id = ? AND client_id = ? LIMIT 1'); $stmt = $db->prepare('SELECT * FROM client_documents WHERE id = ? AND client_id = ? LIMIT 1');
$stmt->execute([$id, $clientId]); $stmt->execute([$id, $clientId]);
$doc = $stmt->fetch(PDO::FETCH_ASSOC); $doc = $stmt->fetch(PDO::FETCH_ASSOC);
@@ -196,7 +356,65 @@ function respondUpdate(PDO $db, int $clientId): void
dbnToolsRespond(['ok' => true, 'document' => shapeDoc($doc ?: [])]); dbnToolsRespond(['ok' => true, 'document' => shapeDoc($doc ?: [])]);
} }
function respondDelete(PDO $db, int $clientId): void function respondDelete(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(50_000);
$ids = $input['ids'] ?? [];
$hardDelete = !empty($input['hard_delete']);
if (!is_array($ids) || !$ids) {
dbnToolsError('ids array is required.', 400, 'missing_ids');
}
$ids = array_values(array_unique(array_map('intval', $ids)));
$ids = array_filter($ids, fn($v) => $v > 0);
if (!$ids) {
dbnToolsError('No valid ids.', 400, 'invalid_ids');
}
if (count($ids) > 500) {
dbnToolsError('Cannot delete more than 500 documents at once.', 422, 'too_many');
}
// ACL-check each doc's folder.
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$rows = $db->prepare("SELECT id, folder_id FROM client_documents WHERE client_id = ? AND id IN ({$placeholders})");
$rows->execute(array_merge([$clientId], $ids));
$allowedIds = [];
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'];
}
}
if (!$allowedIds) {
dbnToolsError('Nothing to delete (insufficient permissions).', 403, 'forbidden');
}
$ph = implode(',', array_fill(0, count($allowedIds), '?'));
if ($hardDelete) {
if (!in_array($tenantRole, ['admin','owner'], true)) {
dbnToolsError('Hard delete requires admin role.', 403, 'forbidden_hard_delete');
}
$stmt = $db->prepare("DELETE FROM client_documents WHERE client_id = ? AND id IN ({$ph})");
$stmt->execute(array_merge([$clientId], $allowedIds));
try {
$chunks = $db->prepare("DELETE FROM client_chunks WHERE client_id = ? AND document_id IN ({$ph})");
$chunks->execute(array_merge([$clientId], $allowedIds));
} catch (Throwable $e) { /* tolerated */ }
dbnDmsLogAudit($clientId, $userId ?: null, 'delete_hard', ['count' => count($allowedIds), 'ids' => $allowedIds]);
dbnToolsRespond(['ok' => true, 'deleted' => $stmt->rowCount(), 'hard' => true]);
}
// Soft delete (default)
$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], $allowedIds));
dbnDmsLogAudit($clientId, $userId ?: null, 'delete', ['count' => count($allowedIds), 'ids' => $allowedIds]);
dbnToolsRespond(['ok' => true, 'deleted' => $stmt->rowCount(), 'hard' => false]);
}
function respondRestore(PDO $db, int $clientId, int $userId, string $tenantRole): void
{ {
$input = dbnToolsJsonInput(50_000); $input = dbnToolsJsonInput(50_000);
$ids = $input['ids'] ?? []; $ids = $input['ids'] ?? [];
@@ -208,48 +426,78 @@ function respondDelete(PDO $db, int $clientId): void
if (!$ids) { if (!$ids) {
dbnToolsError('No valid ids.', 400, 'invalid_ids'); dbnToolsError('No valid ids.', 400, 'invalid_ids');
} }
if (count($ids) > 200) {
dbnToolsError('Cannot delete more than 200 documents at once.', 422, 'too_many');
}
$placeholders = implode(',', array_fill(0, count($ids), '?')); $placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare( $rows = $db->prepare("SELECT id, folder_id FROM client_documents WHERE client_id = ? AND id IN ({$placeholders}) AND deleted_at IS NOT NULL");
"DELETE FROM client_documents $rows->execute(array_merge([$clientId], $ids));
WHERE client_id = ? AND id IN ({$placeholders})" $allowedIds = [];
); foreach ($rows->fetchAll() as $r) {
$stmt->execute(array_merge([$clientId], $ids)); $fid = $r['folder_id'] ? (int)$r['folder_id'] : 0;
if (dbnDmsUserCanAccessFolder($fid ?: null, 'write', $clientId, $userId, $tenantRole)) {
try { $allowedIds[] = (int)$r['id'];
$chunks = $db->prepare( }
"DELETE FROM client_chunks WHERE client_id = ? AND document_id IN ({$placeholders})"
);
$chunks->execute(array_merge([$clientId], $ids));
} catch (Throwable $e) {
// table may be filtered to client_id only; non-fatal
} }
if (!$allowedIds) {
dbnToolsRespond(['ok' => true, 'deleted' => $stmt->rowCount()]); dbnToolsRespond(['ok' => true, 'restored' => 0]);
}
$ph = implode(',', array_fill(0, count($allowedIds), '?'));
$stmt = $db->prepare(
"UPDATE client_documents
SET deleted_at = NULL, deleted_by = NULL, updated_at = NOW()
WHERE client_id = ? AND id IN ({$ph})"
);
$stmt->execute(array_merge([$clientId], $allowedIds));
dbnDmsLogAudit($clientId, $userId ?: null, 'restore', ['count' => count($allowedIds), 'ids' => $allowedIds]);
dbnToolsRespond(['ok' => true, 'restored' => $stmt->rowCount()]);
} }
function shapeDoc(array $row): array function shapeDoc(array $row): array
{ {
return [ return [
'id' => (int)($row['id'] ?? 0), 'id' => (int)($row['id'] ?? 0),
'title' => (string)($row['title'] ?? ''), 'folder_id' => isset($row['folder_id']) && $row['folder_id'] !== null ? (int)$row['folder_id'] : null,
'source_type' => (string)($row['source_type'] ?? ''), 'title' => (string)($row['title'] ?? ''),
'language' => (string)($row['language'] ?? ''), 'source_type' => (string)($row['source_type'] ?? ''),
'category' => (string)($row['category'] ?? ''), 'language' => (string)($row['language'] ?? ''),
'tags' => (string)($row['tags'] ?? ''), 'category' => (string)($row['category'] ?? ''),
'author' => $row['author'] ?? null, 'tags' => (string)($row['tags'] ?? ''),
'source_url' => $row['source_url'] ?? null, 'author' => $row['author'] ?? null,
'source_tool' => $row['source_tool'] ?? null, 'source_url' => $row['source_url'] ?? null,
'import_method' => (string)($row['import_method'] ?? ''), 'source_tool' => $row['source_tool'] ?? null,
'status' => (string)($row['status'] ?? ''), 'import_method' => (string)($row['import_method'] ?? ''),
'word_count' => (int)($row['word_count'] ?? 0), 'status' => (string)($row['status'] ?? ''),
'chunk_count' => (int)($row['chunk_count'] ?? 0), 'word_count' => (int)($row['word_count'] ?? 0),
'file_size_bytes'=> (int)($row['file_size_bytes'] ?? 0), 'chunk_count' => (int)($row['chunk_count'] ?? 0),
'error_message' => $row['error_message'] ?? null, 'file_size_bytes' => (int)($row['file_size_bytes'] ?? 0),
'created_at' => (string)($row['created_at'] ?? ''), 'original_filename'=> $row['original_filename'] ?? null,
'updated_at' => (string)($row['updated_at'] ?? ''), 'has_storage' => !empty($row['storage_path']),
'current_version' => (int)($row['current_version'] ?? 1),
'deleted_at' => $row['deleted_at'] ?? null,
'error_message' => $row['error_message'] ?? null,
'created_at' => (string)($row['created_at'] ?? ''),
'updated_at' => (string)($row['updated_at'] ?? ''),
]; ];
} }
function dbnDmsCollectSubtreeIdsForList(PDO $db, int $rootId, int $clientId): array
{
$collected = [];
$stack = [$rootId];
$guard = 0;
while ($stack && $guard++ < 1000) {
$batch = $stack;
$stack = [];
$ph = implode(',', array_fill(0, count($batch), '?'));
$stmt = $db->prepare(
"SELECT id FROM client_folders
WHERE client_id = ? AND parent_id IN ({$ph}) AND deleted_at IS NULL"
);
$stmt->execute(array_merge([$clientId], $batch));
foreach ($stmt->fetchAll() as $row) {
$cid = (int)$row['id'];
$collected[] = $cid;
$stack[] = $cid;
}
}
return $collected;
}
+493
View File
@@ -0,0 +1,493 @@
<?php
/**
* /api/dashboard/folders.php — folder tree CRUD + per-folder ACLs.
*
* GET ?action=list_tree → { ok, tree: [...] }
* GET ?action=get_breadcrumb&folder_id=X → { ok, breadcrumb: [...] }
* POST ?action=create body: { parent_id?, name, color?, description? }
* POST ?action=rename body: { folder_id, name }
* POST ?action=recolor body: { folder_id, color }
* POST ?action=move body: { folder_id, parent_id|null }
* POST ?action=delete body: { folder_id } (soft delete — docs become Unassigned via SET NULL)
* POST ?action=set_permission body: { folder_id, min_role?, user_id?, can_read?, can_write?, can_manage? }
* POST ?action=remove_permission body: { permission_id }
* GET ?action=list_permissions&folder_id=X → { ok, permissions: [...] }
*
* Tenant-scoped via dbnToolsEnsureDashboardTenant().
*/
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'];
$corpusId = (int)$tenant['corpus_id'];
$clientUser = (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_tree'));
try {
switch ($action) {
case 'list_tree':
dbnToolsRequireMethod('GET');
respondTree($db, $clientId, $corpusId, $clientUser, $tenantRole);
break;
case 'get_breadcrumb':
dbnToolsRequireMethod('GET');
$fid = (int)($_GET['folder_id'] ?? 0);
dbnToolsRespond(['ok' => true, 'breadcrumb' => dbnDmsBreadcrumb($fid ?: null, $clientId)]);
break;
case 'create':
dbnToolsRequireMethod('POST');
respondCreate($db, $clientId, $corpusId, $clientUser, $tenantRole);
break;
case 'rename':
dbnToolsRequireMethod('POST');
respondRename($db, $clientId, $clientUser, $tenantRole);
break;
case 'recolor':
dbnToolsRequireMethod('POST');
respondRecolor($db, $clientId, $clientUser, $tenantRole);
break;
case 'move':
dbnToolsRequireMethod('POST');
respondMove($db, $clientId, $clientUser, $tenantRole);
break;
case 'delete':
dbnToolsRequireMethod('POST');
respondDelete($db, $clientId, $clientUser, $tenantRole);
break;
case 'list_permissions':
dbnToolsRequireMethod('GET');
respondListPermissions($db, $clientId, $clientUser, $tenantRole);
break;
case 'set_permission':
dbnToolsRequireMethod('POST');
respondSetPermission($db, $clientId, $clientUser, $tenantRole);
break;
case 'remove_permission':
dbnToolsRequireMethod('POST');
respondRemovePermission($db, $clientId, $clientUser, $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/folders] ' . $e->getMessage());
dbnToolsError('Folder operation failed.', 500, 'folder_op_failed');
}
function respondTree(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole): void
{
$stmt = $db->prepare(
"SELECT f.id, f.parent_id, f.name, f.slug, f.color, f.description, f.sort_order, f.created_at,
COALESCE(c.cnt, 0) AS doc_count
FROM client_folders f
LEFT JOIN (
SELECT folder_id, COUNT(*) AS cnt
FROM client_documents
WHERE client_id = ? AND deleted_at IS NULL
GROUP BY folder_id
) c ON c.folder_id = f.id
WHERE f.client_id = ? AND f.corpus_id = ? AND f.deleted_at IS NULL
ORDER BY f.sort_order ASC, f.name ASC"
);
$stmt->execute([$clientId, $clientId, $corpusId]);
$rows = $stmt->fetchAll();
// Filter by read ACL.
$visible = [];
foreach ($rows as $row) {
if (dbnDmsUserCanAccessFolder((int)$row['id'], 'read', $clientId, $userId, $tenantRole)) {
$visible[(int)$row['id']] = [
'id' => (int)$row['id'],
'parent_id' => $row['parent_id'] ? (int)$row['parent_id'] : null,
'name' => (string)$row['name'],
'slug' => (string)$row['slug'],
'color' => $row['color'] ?? null,
'description'=> $row['description'] ?? null,
'sort_order' => (int)$row['sort_order'],
'doc_count' => (int)$row['doc_count'],
'children' => [],
];
}
}
// Tree assembly.
$roots = [];
foreach ($visible as $id => &$node) {
$pid = $node['parent_id'];
if ($pid && isset($visible[$pid])) {
$visible[$pid]['children'][] = &$node;
} else {
$roots[] = &$node;
}
}
unset($node);
// Unassigned bucket count.
$unassigned = $db->prepare(
'SELECT COUNT(*) FROM client_documents WHERE client_id = ? AND folder_id IS NULL AND deleted_at IS NULL'
);
$unassigned->execute([$clientId]);
// Trash count.
$trash = $db->prepare(
'SELECT COUNT(*) FROM client_documents WHERE client_id = ? AND deleted_at IS NOT NULL'
);
$trash->execute([$clientId]);
dbnToolsRespond([
'ok' => true,
'tree' => $roots,
'unassigned_count' => (int)$unassigned->fetchColumn(),
'trash_count' => (int)$trash->fetchColumn(),
'max_depth' => DBN_DMS_MAX_FOLDER_DEPTH,
]);
}
function respondCreate(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(20_000);
$name = trim((string)($input['name'] ?? ''));
$parentId = isset($input['parent_id']) && $input['parent_id'] !== null && $input['parent_id'] !== ''
? (int)$input['parent_id'] : null;
$color = trim((string)($input['color'] ?? ''));
$desc = trim((string)($input['description'] ?? ''));
if ($name === '' || mb_strlen($name, 'UTF-8') > 200) {
dbnToolsError('Folder name is required (1200 chars).', 422, 'invalid_name');
}
if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
dbnToolsError('Color must be a #RRGGBB hex value.', 422, 'invalid_color');
}
if (mb_strlen($desc, 'UTF-8') > 1000) {
dbnToolsError('Description is too long (max 1000 chars).', 422, 'description_too_long');
}
$parentDepth = dbnDmsFolderDepth($parentId, $clientId);
if ($parentDepth + 1 > DBN_DMS_MAX_FOLDER_DEPTH) {
dbnToolsError("Folder depth limit reached (max " . DBN_DMS_MAX_FOLDER_DEPTH . " levels).", 422, 'depth_exceeded');
}
if (!dbnDmsUserCanAccessFolder($parentId, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('You do not have permission to create folders here.', 403, 'forbidden');
}
if ($parentId !== null) {
$parentCheck = $db->prepare('SELECT id FROM client_folders WHERE id = ? AND client_id = ? AND deleted_at IS NULL');
$parentCheck->execute([$parentId, $clientId]);
if (!$parentCheck->fetchColumn()) {
dbnToolsError('Parent folder not found.', 404, 'parent_not_found');
}
}
$slug = dbnDmsUniqueSlug($db, $clientId, $corpusId, $name);
$stmt = $db->prepare(
'INSERT INTO client_folders
(client_id, corpus_id, parent_id, name, slug, description, color, sort_order, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, NOW())'
);
$stmt->execute([
$clientId, $corpusId, $parentId, $name, $slug,
$desc !== '' ? $desc : null,
$color !== '' ? $color : null,
$userId ?: null,
]);
$id = (int)$db->lastInsertId();
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_create', ['name' => $name, 'parent_id' => $parentId], null, $id);
dbnToolsRespond(['ok' => true, 'folder_id' => $id, 'slug' => $slug], 201);
}
function respondRename(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(10_000);
$fid = (int)($input['folder_id'] ?? 0);
$name = trim((string)($input['name'] ?? ''));
if ($fid <= 0 || $name === '' || mb_strlen($name, 'UTF-8') > 200) {
dbnToolsError('folder_id and a valid name (1200) are required.', 422, 'invalid_input');
}
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('You do not have permission to rename this folder.', 403, 'forbidden');
}
$stmt = $db->prepare('UPDATE client_folders SET name = ?, updated_at = NOW() WHERE id = ? AND client_id = ?');
$stmt->execute([$name, $fid, $clientId]);
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_rename', ['name' => $name], null, $fid);
dbnToolsRespond(['ok' => true]);
}
function respondRecolor(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(2_000);
$fid = (int)($input['folder_id'] ?? 0);
$color = trim((string)($input['color'] ?? ''));
if ($fid <= 0) {
dbnToolsError('folder_id is required.', 422, 'invalid_input');
}
if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
dbnToolsError('Color must be a #RRGGBB hex value.', 422, 'invalid_color');
}
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$stmt = $db->prepare('UPDATE client_folders SET color = ?, updated_at = NOW() WHERE id = ? AND client_id = ?');
$stmt->execute([$color !== '' ? $color : null, $fid, $clientId]);
dbnToolsRespond(['ok' => true]);
}
function respondMove(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(5_000);
$fid = (int)($input['folder_id'] ?? 0);
$parentId = isset($input['parent_id']) && $input['parent_id'] !== null && $input['parent_id'] !== ''
? (int)$input['parent_id'] : null;
if ($fid <= 0) {
dbnToolsError('folder_id is required.', 422, 'invalid_input');
}
if ($parentId !== null && $parentId === $fid) {
dbnToolsError('Folder cannot be its own parent.', 422, 'invalid_parent');
}
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden on source.', 403, 'forbidden_source');
}
if ($parentId !== null && !dbnDmsUserCanAccessFolder($parentId, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden on destination.', 403, 'forbidden_dest');
}
// Cycle + depth checks.
if ($parentId !== null) {
$chain = dbnDmsFolderChain($parentId, $clientId);
foreach ($chain as $c) {
if ((int)$c['id'] === $fid) {
dbnToolsError('Cannot move a folder into one of its own descendants.', 422, 'invalid_cycle');
}
}
$newDepth = count($chain) + 1; // +1 for the moved folder itself
$childDepth = dbnDmsSubtreeMaxDepth($db, $fid);
if ($newDepth + ($childDepth - 1) > DBN_DMS_MAX_FOLDER_DEPTH) {
dbnToolsError('Move would exceed max folder depth.', 422, 'depth_exceeded');
}
}
$stmt = $db->prepare('UPDATE client_folders SET parent_id = ?, updated_at = NOW() WHERE id = ? AND client_id = ?');
$stmt->execute([$parentId, $fid, $clientId]);
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_move', ['parent_id' => $parentId], null, $fid);
dbnToolsRespond(['ok' => true]);
}
function respondDelete(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(5_000);
$fid = (int)($input['folder_id'] ?? 0);
if ($fid <= 0) {
dbnToolsError('folder_id is required.', 422, 'invalid_input');
}
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
// Soft delete folder + cascade soft-delete on descendant folders.
$db->beginTransaction();
try {
$allIds = dbnDmsCollectSubtreeIds($db, $fid, $clientId);
$allIds[] = $fid;
$placeholders = implode(',', array_fill(0, count($allIds), '?'));
$stmt = $db->prepare(
"UPDATE client_folders SET deleted_at = NOW(), deleted_by = ?
WHERE client_id = ? AND id IN ({$placeholders})"
);
$stmt->execute(array_merge([$userId ?: null, $clientId], $allIds));
// Documents inside: also soft-delete (they appear in Trash).
$docStmt = $db->prepare(
"UPDATE client_documents SET deleted_at = NOW(), deleted_by = ?
WHERE client_id = ? AND folder_id IN ({$placeholders}) AND deleted_at IS NULL"
);
$docStmt->execute(array_merge([$userId ?: null, $clientId], $allIds));
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_delete', [], null, $fid);
dbnToolsRespond(['ok' => true]);
}
function respondListPermissions(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$fid = (int)($_GET['folder_id'] ?? 0);
if ($fid <= 0) {
dbnToolsError('folder_id is required.', 422, 'invalid_input');
}
if (!dbnDmsUserCanAccessFolder($fid, 'read', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$stmt = $db->prepare(
"SELECT p.id, p.folder_id, p.min_role, p.user_id, p.can_read, p.can_write, p.can_manage,
p.created_at, u.email AS user_email, u.full_name AS user_name
FROM client_folder_permissions p
LEFT JOIN client_users u ON u.id = p.user_id
WHERE p.folder_id = ? AND p.client_id = ?
ORDER BY p.id ASC"
);
$stmt->execute([$fid, $clientId]);
dbnToolsRespond(['ok' => true, 'permissions' => $stmt->fetchAll()]);
}
function respondSetPermission(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(10_000);
$fid = (int)($input['folder_id'] ?? 0);
$minRole = trim((string)($input['min_role'] ?? ''));
$targetUid= isset($input['user_id']) && $input['user_id'] ? (int)$input['user_id'] : null;
$canRead = !empty($input['can_read']) ? 1 : 0;
$canWrite = !empty($input['can_write']) ? 1 : 0;
$canManage= !empty($input['can_manage']) ? 1 : 0;
if ($fid <= 0) {
dbnToolsError('folder_id is required.', 422, 'invalid_input');
}
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$validRoles = ['viewer','editor','admin','owner'];
if ($minRole !== '' && !in_array($minRole, $validRoles, true)) {
dbnToolsError('Invalid min_role.', 422, 'invalid_role');
}
if (($minRole === '' && $targetUid === null) || ($minRole !== '' && $targetUid !== null)) {
dbnToolsError('Exactly one of min_role or user_id must be set.', 422, 'invalid_grantee');
}
// UPSERT on the appropriate unique key.
if ($minRole !== '') {
$stmt = $db->prepare(
'INSERT INTO client_folder_permissions
(folder_id, client_id, min_role, can_read, can_write, can_manage, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
can_read = VALUES(can_read),
can_write = VALUES(can_write),
can_manage = VALUES(can_manage)'
);
$stmt->execute([$fid, $clientId, $minRole, $canRead, $canWrite, $canManage, $userId ?: null]);
} else {
$stmt = $db->prepare(
'INSERT INTO client_folder_permissions
(folder_id, client_id, user_id, can_read, can_write, can_manage, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
can_read = VALUES(can_read),
can_write = VALUES(can_write),
can_manage = VALUES(can_manage)'
);
$stmt->execute([$fid, $clientId, $targetUid, $canRead, $canWrite, $canManage, $userId ?: null]);
}
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_acl_set', [
'min_role' => $minRole ?: null,
'user_id' => $targetUid,
'can_read' => $canRead, 'can_write' => $canWrite, 'can_manage' => $canManage,
], null, $fid);
dbnToolsRespond(['ok' => true]);
}
function respondRemovePermission(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(2_000);
$pid = (int)($input['permission_id'] ?? 0);
if ($pid <= 0) {
dbnToolsError('permission_id is required.', 422, 'invalid_input');
}
// Look up the folder to ACL-check.
$row = $db->prepare('SELECT folder_id FROM client_folder_permissions WHERE id = ? AND client_id = ?');
$row->execute([$pid, $clientId]);
$fid = (int)$row->fetchColumn();
if (!$fid) {
dbnToolsError('Permission not found.', 404, 'not_found');
}
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$del = $db->prepare('DELETE FROM client_folder_permissions WHERE id = ? AND client_id = ?');
$del->execute([$pid, $clientId]);
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_acl_remove', ['permission_id' => $pid], null, $fid);
dbnToolsRespond(['ok' => true]);
}
function dbnDmsCollectSubtreeIds(PDO $db, int $rootId, int $clientId): array
{
$collected = [];
$stack = [$rootId];
$guard = 0;
while ($stack && $guard++ < 1000) {
$batch = $stack;
$stack = [];
$placeholders = implode(',', array_fill(0, count($batch), '?'));
$stmt = $db->prepare(
"SELECT id FROM client_folders
WHERE client_id = ? AND parent_id IN ({$placeholders}) AND deleted_at IS NULL"
);
$stmt->execute(array_merge([$clientId], $batch));
foreach ($stmt->fetchAll() as $row) {
$cid = (int)$row['id'];
$collected[] = $cid;
$stack[] = $cid;
}
}
return $collected;
}
function dbnDmsSubtreeMaxDepth(PDO $db, int $rootId): int
{
// Depth of the subtree starting at rootId, where rootId itself counts as 1.
$depth = 1;
$current = [$rootId];
$guard = 0;
while ($current && $guard++ < 20) {
$placeholders = implode(',', array_fill(0, count($current), '?'));
$stmt = $db->prepare(
"SELECT id FROM client_folders
WHERE parent_id IN ({$placeholders}) AND deleted_at IS NULL"
);
$stmt->execute($current);
$next = array_map(fn($r) => (int)$r['id'], $stmt->fetchAll());
if (!$next) {
break;
}
$current = $next;
$depth++;
}
return $depth;
}
function dbnDmsUniqueSlug(PDO $db, int $clientId, int $corpusId, string $name): string
{
$base = strtolower(trim($name));
$base = preg_replace('/[^a-z0-9]+/u', '-', $base) ?: 'folder';
$base = trim($base, '-');
if ($base === '') {
$base = 'folder';
}
$base = substr($base, 0, 180);
$slug = $base;
$check = $db->prepare('SELECT 1 FROM client_folders WHERE client_id = ? AND corpus_id = ? AND slug = ? LIMIT 1');
$n = 2;
while (true) {
$check->execute([$clientId, $corpusId, $slug]);
if (!$check->fetchColumn()) {
return $slug;
}
$slug = $base . '-' . $n++;
if ($n > 999) {
return $base . '-' . substr(bin2hex(random_bytes(3)), 0, 6);
}
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
/**
* /api/dashboard/preview.php?id=123[&version_id=5][&download=1]
*
* Streams the on-disk original file for a document, with the correct
* Content-Type. Used by PDF.js, the <audio> player, image previews, and
* "Download original" links.
*
* ACL: enforces folder read permission on the document.
*/
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');
$id = (int)($_GET['id'] ?? 0);
$versionId = (int)($_GET['version_id'] ?? 0);
$download = !empty($_GET['download']);
if ($id <= 0) {
dbnToolsError('id is required.', 400, 'missing_id');
}
$db = dbnToolsDb();
$stmt = $db->prepare('SELECT id, folder_id, storage_path, original_filename, source_type FROM client_documents WHERE id = ? AND client_id = ?');
$stmt->execute([$id, $clientId]);
$doc = $stmt->fetch();
if (!$doc) {
dbnToolsError('Document not found.', 404, 'not_found');
}
$fid = $doc['folder_id'] ? (int)$doc['folder_id'] : 0;
if (!dbnDmsUserCanAccessFolder($fid ?: null, 'read', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$path = (string)($doc['storage_path'] ?? '');
$filename = (string)($doc['original_filename'] ?? '');
if ($versionId > 0) {
$vs = $db->prepare('SELECT storage_path, original_filename FROM client_document_versions WHERE id = ? AND document_id = ? AND client_id = ?');
$vs->execute([$versionId, $id, $clientId]);
$ver = $vs->fetch();
if (!$ver) {
dbnToolsError('Version not found.', 404, 'version_not_found');
}
$path = (string)($ver['storage_path'] ?? '');
$filename = (string)($ver['original_filename'] ?? $filename);
}
if ($path === '' || !is_file($path) || !is_readable($path)) {
dbnToolsError('Original file is not available for this document.', 404, 'file_missing',
['hint' => 'Document predates disk storage, or file was purged.']);
}
$ext = dbnDmsExtensionFromFilename($filename);
if ($ext === '' && $path !== '') {
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
}
$contentType = dbnDmsContentTypeForExt($ext);
dbnDmsLogAudit($clientId, $userId ?: null, $download ? 'download' : 'preview',
['version_id' => $versionId ?: null, 'ext' => $ext], $id, $fid ?: null);
// Suppress any earlier output (defensive).
if (ob_get_level() > 0) { @ob_end_clean(); }
$size = filesize($path) ?: 0;
header('Content-Type: ' . $contentType);
header('Content-Length: ' . $size);
header('X-Content-Type-Options: nosniff');
header('Cache-Control: private, max-age=300');
$disposition = $download ? 'attachment' : 'inline';
$safeName = $filename !== '' ? $filename : ('document-' . $id . '.' . ($ext ?: 'bin'));
$safeName = preg_replace('/[\r\n"]/', '_', $safeName) ?? $safeName;
header(sprintf('Content-Disposition: %s; filename="%s"', $disposition, $safeName));
// Range requests (basic) — useful for PDF.js + audio scrubbing.
$range = $_SERVER['HTTP_RANGE'] ?? '';
if ($range && preg_match('/bytes=(\d+)-(\d+)?/', $range, $m)) {
$start = (int)$m[1];
$end = isset($m[2]) && $m[2] !== '' ? (int)$m[2] : ($size - 1);
if ($end >= $size) $end = $size - 1;
$length = $end - $start + 1;
http_response_code(206);
header('Accept-Ranges: bytes');
header("Content-Range: bytes {$start}-{$end}/{$size}");
header('Content-Length: ' . $length);
$fh = fopen($path, 'rb');
if ($fh) {
fseek($fh, $start);
$remaining = $length;
while (!feof($fh) && $remaining > 0) {
$chunk = fread($fh, min(8192, $remaining));
if ($chunk === false) break;
echo $chunk;
$remaining -= strlen($chunk);
@ob_flush(); @flush();
}
fclose($fh);
}
exit;
}
header('Accept-Ranges: bytes');
readfile($path);
exit;
+222
View File
@@ -0,0 +1,222 @@
<?php
/**
* /api/dashboard/saved-searches.php — "smart folders" CRUD.
*
* GET ?action=list → { ok, items: [...] } (user-owned + tenant-shared)
* POST ?action=create body: { name, query, is_shared?, color?, icon? }
* POST ?action=update body: { id, name?, query?, is_shared?, color?, icon? }
* POST ?action=delete body: { id }
* 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'); listItems($db, $clientId, $userId); break;
case 'create': dbnToolsRequireMethod('POST'); createItem($db, $clientId, $userId, $tenantRole); break;
case 'update': dbnToolsRequireMethod('POST'); updateItem($db, $clientId, $userId, $tenantRole); break;
case 'delete': dbnToolsRequireMethod('POST'); deleteItem($db, $clientId, $userId, $tenantRole); break;
case 'reorder': dbnToolsRequireMethod('POST'); reorderItems($db, $clientId, $userId); 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/saved-searches] ' . $e->getMessage());
dbnToolsError('Saved-search operation failed.', 500, 'op_failed');
}
function listItems(PDO $db, int $clientId, int $userId): void
{
$stmt = $db->prepare(
'SELECT id, name, icon, color, query_json, is_shared, sort_order, user_id, created_at, updated_at
FROM client_saved_searches
WHERE client_id = ? AND (user_id = ? OR is_shared = 1)
ORDER BY sort_order ASC, name ASC'
);
$stmt->execute([$clientId, $userId]);
$rows = $stmt->fetchAll();
foreach ($rows as &$r) {
$r['query'] = json_decode((string)$r['query_json'], true) ?? [];
$r['is_mine'] = (int)$r['user_id'] === $userId;
$r['is_shared'] = (int)$r['is_shared'] === 1;
unset($r['query_json']);
}
dbnToolsRespond(['ok' => true, 'items' => $rows]);
}
function createItem(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(20_000);
$name = trim((string)($input['name'] ?? ''));
$query = $input['query'] ?? [];
$isShared = !empty($input['is_shared']);
$color = trim((string)($input['color'] ?? ''));
$icon = trim((string)($input['icon'] ?? ''));
if ($name === '' || mb_strlen($name, 'UTF-8') > 120) {
dbnToolsError('Name is required (1120 chars).', 422, 'invalid_name');
}
if (!is_array($query) || !$query) {
dbnToolsError('Query payload is required.', 422, 'invalid_query');
}
if ($isShared && !in_array($tenantRole, ['editor','admin','owner'], true)) {
dbnToolsError('Only editors+ can share smart folders.', 403, 'forbidden');
}
if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
dbnToolsError('Invalid color.', 422, 'invalid_color');
}
$stmt = $db->prepare(
'INSERT INTO client_saved_searches
(client_id, user_id, name, icon, color, query_json, is_shared, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, NOW(), NOW())'
);
$stmt->execute([
$clientId, $userId, $name,
$icon !== '' ? substr($icon, 0, 40) : null,
$color !== '' ? $color : null,
json_encode(sanitizeQuery($query), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$isShared ? 1 : 0,
]);
$id = (int)$db->lastInsertId();
dbnDmsLogAudit($clientId, $userId, 'saved_search_create', ['name' => $name, 'id' => $id]);
dbnToolsRespond(['ok' => true, 'id' => $id], 201);
}
function updateItem(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(20_000);
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
dbnToolsError('id is required.', 400, 'missing_id');
}
$row = $db->prepare('SELECT user_id, is_shared FROM client_saved_searches WHERE id = ? AND client_id = ?');
$row->execute([$id, $clientId]);
$existing = $row->fetch();
if (!$existing) {
dbnToolsError('Not found.', 404, 'not_found');
}
$isMine = (int)$existing['user_id'] === $userId;
$canEdit = $isMine || in_array($tenantRole, ['admin','owner'], true);
if (!$canEdit) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$fields = [];
$params = [];
if (array_key_exists('name', $input)) {
$name = trim((string)$input['name']);
if ($name === '' || mb_strlen($name, 'UTF-8') > 120) {
dbnToolsError('Invalid name.', 422, 'invalid_name');
}
$fields[] = 'name = ?';
$params[] = $name;
}
if (array_key_exists('query', $input)) {
if (!is_array($input['query']) || !$input['query']) {
dbnToolsError('Invalid query.', 422, 'invalid_query');
}
$fields[] = 'query_json = ?';
$params[] = json_encode(sanitizeQuery($input['query']), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
if (array_key_exists('is_shared', $input)) {
$fields[] = 'is_shared = ?';
$params[] = !empty($input['is_shared']) ? 1 : 0;
}
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 (!$fields) {
dbnToolsError('Nothing to update.', 400, 'no_fields');
}
$params[] = $id;
$params[] = $clientId;
$stmt = $db->prepare(
'UPDATE client_saved_searches SET ' . implode(', ', $fields) . ', updated_at = NOW()
WHERE id = ? AND client_id = ?'
);
$stmt->execute($params);
dbnDmsLogAudit($clientId, $userId, 'saved_search_update', ['id' => $id]);
dbnToolsRespond(['ok' => true]);
}
function deleteItem(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(2_000);
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
dbnToolsError('id is required.', 400, 'missing_id');
}
$row = $db->prepare('SELECT user_id FROM client_saved_searches WHERE id = ? AND client_id = ?');
$row->execute([$id, $clientId]);
$existing = $row->fetch();
if (!$existing) {
dbnToolsError('Not found.', 404, 'not_found');
}
$isMine = (int)$existing['user_id'] === $userId;
if (!$isMine && !in_array($tenantRole, ['admin','owner'], true)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$del = $db->prepare('DELETE FROM client_saved_searches WHERE id = ? AND client_id = ?');
$del->execute([$id, $clientId]);
dbnDmsLogAudit($clientId, $userId, 'saved_search_delete', ['id' => $id]);
dbnToolsRespond(['ok' => true]);
}
function reorderItems(PDO $db, int $clientId, int $userId): 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_saved_searches SET sort_order = ? WHERE id = ? AND client_id = ? AND (user_id = ? OR is_shared = 1)');
foreach ($ids as $i => $id) {
$upd->execute([$i, $id, $clientId, $userId]);
}
dbnToolsRespond(['ok' => true, 'reordered' => count($ids)]);
}
function sanitizeQuery(array $query): array
{
$allowed = ['q', 'category', 'status', 'source_type', 'folder_id', 'include_subfolders', 'tags', 'sort', 'dir'];
$clean = [];
foreach ($allowed as $key) {
if (array_key_exists($key, $query)) {
$clean[$key] = is_array($query[$key]) ? array_slice(array_values($query[$key]), 0, 50) : $query[$key];
}
}
return $clean;
}
+257
View File
@@ -0,0 +1,257 @@
<?php
/**
* /api/dashboard/trash.php — trashed documents + folders, restore + permanent purge.
*
* GET ?action=list&offset=&limit= → { ok, total, items: [...] }
* POST ?action=restore body: { document_ids?: [..], folder_ids?: [..] }
* POST ?action=purge body: { document_ids?: [..], folder_ids?: [..], all?: bool }
* — admin/owner only for `all`
*/
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');
listTrash($db, $clientId, $userId, $tenantRole);
break;
case 'restore':
dbnToolsRequireMethod('POST');
restoreTrash($db, $clientId, $userId, $tenantRole);
break;
case 'purge':
dbnToolsRequireMethod('POST');
purgeTrash($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/trash] ' . $e->getMessage());
dbnToolsError('Trash operation failed.', 500, 'trash_op_failed');
}
function listTrash(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$offset = max(0, (int)($_GET['offset'] ?? 0));
$limit = max(1, min(200, (int)($_GET['limit'] ?? 50)));
$docs = $db->prepare(
"SELECT id, title, folder_id, source_type, file_size_bytes, deleted_at, deleted_by,
DATEDIFF(NOW(), deleted_at) AS days_in_trash
FROM client_documents
WHERE client_id = ? AND deleted_at IS NOT NULL
ORDER BY deleted_at DESC
LIMIT {$limit} OFFSET {$offset}"
);
$docs->execute([$clientId]);
$docRows = $docs->fetchAll();
// Filter by ACL
$visible = [];
foreach ($docRows as $row) {
$fid = $row['folder_id'] ? (int)$row['folder_id'] : 0;
if (dbnDmsUserCanAccessFolder($fid ?: null, 'read', $clientId, $userId, $tenantRole)) {
$row['kind'] = 'document';
$row['expires_in_days'] = max(0, DBN_DMS_TRASH_RETENTION_DAYS - (int)$row['days_in_trash']);
$visible[] = $row;
}
}
$folders = $db->prepare(
"SELECT id, name, color, deleted_at, deleted_by,
DATEDIFF(NOW(), deleted_at) AS days_in_trash
FROM client_folders
WHERE client_id = ? AND deleted_at IS NOT NULL
ORDER BY deleted_at DESC LIMIT 200"
);
$folders->execute([$clientId]);
foreach ($folders->fetchAll() as $row) {
$row['kind'] = 'folder';
$row['expires_in_days'] = max(0, DBN_DMS_TRASH_RETENTION_DAYS - (int)$row['days_in_trash']);
$visible[] = $row;
}
$countStmt = $db->prepare(
"SELECT COUNT(*) FROM client_documents WHERE client_id = ? AND deleted_at IS NOT NULL"
);
$countStmt->execute([$clientId]);
$total = (int)$countStmt->fetchColumn();
dbnToolsRespond([
'ok' => true,
'total' => $total,
'items' => $visible,
'retention_days' => DBN_DMS_TRASH_RETENTION_DAYS,
]);
}
function restoreTrash(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
$input = dbnToolsJsonInput(20_000);
$docIds = sanitizeIdList($input['document_ids'] ?? []);
$folderIds = sanitizeIdList($input['folder_ids'] ?? []);
$restoredDocs = 0;
$restoredFolders = 0;
if ($docIds) {
$ph = implode(',', array_fill(0, count($docIds), '?'));
$rows = $db->prepare("SELECT id, folder_id FROM client_documents WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NOT NULL");
$rows->execute(array_merge([$clientId], $docIds));
$allowed = [];
foreach ($rows->fetchAll() as $r) {
$fid = $r['folder_id'] ? (int)$r['folder_id'] : 0;
if (dbnDmsUserCanAccessFolder($fid ?: null, 'write', $clientId, $userId, $tenantRole)) {
$allowed[] = (int)$r['id'];
}
}
if ($allowed) {
$ph2 = implode(',', array_fill(0, count($allowed), '?'));
$upd = $db->prepare("UPDATE client_documents SET deleted_at = NULL, deleted_by = NULL WHERE client_id = ? AND id IN ({$ph2})");
$upd->execute(array_merge([$clientId], $allowed));
$restoredDocs = $upd->rowCount();
dbnDmsLogAudit($clientId, $userId ?: null, 'restore', ['ids' => $allowed]);
}
}
if ($folderIds) {
$ph = implode(',', array_fill(0, count($folderIds), '?'));
$rows = $db->prepare("SELECT id FROM client_folders WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NOT NULL");
$rows->execute(array_merge([$clientId], $folderIds));
$allowed = [];
foreach ($rows->fetchAll() as $r) {
if (dbnDmsUserCanAccessFolder((int)$r['id'], 'manage', $clientId, $userId, $tenantRole)) {
$allowed[] = (int)$r['id'];
}
}
if ($allowed) {
$ph2 = implode(',', array_fill(0, count($allowed), '?'));
$upd = $db->prepare("UPDATE client_folders SET deleted_at = NULL, deleted_by = NULL WHERE client_id = ? AND id IN ({$ph2})");
$upd->execute(array_merge([$clientId], $allowed));
$restoredFolders = $upd->rowCount();
dbnDmsLogAudit($clientId, $userId ?: null, 'restore_folder', ['ids' => $allowed]);
}
}
dbnToolsRespond(['ok' => true, 'restored_documents' => $restoredDocs, 'restored_folders' => $restoredFolders]);
}
function purgeTrash(PDO $db, int $clientId, int $userId, string $tenantRole): void
{
if (!in_array($tenantRole, ['admin','owner'], true)) {
dbnToolsError('Permanent purge requires admin role.', 403, 'forbidden');
}
$input = dbnToolsJsonInput(20_000);
$all = !empty($input['all']);
$docIds = sanitizeIdList($input['document_ids'] ?? []);
$folderIds = sanitizeIdList($input['folder_ids'] ?? []);
$purgedDocs = 0;
$purgedFolders = 0;
if ($all) {
// Documents
$docs = $db->prepare("SELECT id, storage_path FROM client_documents WHERE client_id = ? AND deleted_at IS NOT NULL");
$docs->execute([$clientId]);
foreach ($docs->fetchAll() as $row) {
purgeDocument($db, $clientId, (int)$row['id'], $row['storage_path'] ?? null);
$purgedDocs++;
}
$delFolders = $db->prepare("DELETE FROM client_folders WHERE client_id = ? AND deleted_at IS NOT NULL");
$delFolders->execute([$clientId]);
$purgedFolders = $delFolders->rowCount();
dbnDmsLogAudit($clientId, $userId ?: null, 'purge_all', ['documents' => $purgedDocs, 'folders' => $purgedFolders]);
} else {
if ($docIds) {
$ph = implode(',', array_fill(0, count($docIds), '?'));
$rows = $db->prepare("SELECT id, storage_path FROM client_documents WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NOT NULL");
$rows->execute(array_merge([$clientId], $docIds));
foreach ($rows->fetchAll() as $row) {
purgeDocument($db, $clientId, (int)$row['id'], $row['storage_path'] ?? null);
$purgedDocs++;
}
}
if ($folderIds) {
$ph = implode(',', array_fill(0, count($folderIds), '?'));
$del = $db->prepare("DELETE FROM client_folders WHERE client_id = ? AND id IN ({$ph}) AND deleted_at IS NOT NULL");
$del->execute(array_merge([$clientId], $folderIds));
$purgedFolders = $del->rowCount();
}
dbnDmsLogAudit($clientId, $userId ?: null, 'purge', ['documents' => $purgedDocs, 'folders' => $purgedFolders]);
}
dbnToolsRespond(['ok' => true, 'purged_documents' => $purgedDocs, 'purged_folders' => $purgedFolders]);
}
function purgeDocument(PDO $db, int $clientId, int $docId, ?string $storagePath): void
{
// Delete chunks + Qdrant points + on-disk file + versions
try {
$verRows = $db->prepare('SELECT storage_path FROM client_document_versions WHERE document_id = ? AND client_id = ?');
$verRows->execute([$docId, $clientId]);
foreach ($verRows->fetchAll() as $vr) {
if (!empty($vr['storage_path']) && is_file($vr['storage_path'])) {
@unlink($vr['storage_path']);
}
}
} catch (Throwable $e) { /* tolerated */ }
try {
$db->prepare('DELETE FROM client_chunks WHERE client_id = ? AND document_id = ?')->execute([$clientId, $docId]);
} catch (Throwable $e) { /* tolerated */ }
// Best-effort Qdrant cleanup — issue a delete-by-filter
try {
dbnToolsBootCaveau();
if (class_exists('QdrantClient')) {
$qd = new QdrantClient();
$qd->deleteByFilter('bnl_client_chunks', [
'must' => [
['key' => 'client_id', 'match' => ['value' => $clientId]],
['key' => 'document_id', 'match' => ['value' => $docId]],
],
]);
}
} catch (Throwable $e) { /* tolerated */ }
if ($storagePath && is_file($storagePath)) {
@unlink($storagePath);
}
// Also remove the versions folder if it exists.
if ($storagePath) {
$verDir = dirname($storagePath) . '/' . $docId . '_versions';
if (is_dir($verDir)) {
foreach (glob($verDir . '/*') ?: [] as $f) { @unlink($f); }
@rmdir($verDir);
}
}
$db->prepare('DELETE FROM client_documents WHERE id = ? AND client_id = ?')->execute([$docId, $clientId]);
}
function sanitizeIdList(mixed $raw): array
{
if (!is_array($raw)) return [];
$ids = array_values(array_unique(array_filter(array_map('intval', $raw), fn($v) => $v > 0)));
return array_slice($ids, 0, 500);
}
+218 -34
View File
@@ -3,16 +3,19 @@
* POST /api/dashboard/upload.php * POST /api/dashboard/upload.php
* *
* Three input modes: * Three input modes:
* - multipart/form-data with `file` field (PDF/DOCX/TXT, <= 8 MB) * - multipart/form-data with `file` field
* - JSON body { "kind":"text", "title":..., "content":..., "category"?, "tags"?, "author"?, "language"? } * Allowed: pdf, docx, txt, md, html, htm, csv, xlsx, pptx, json (≤8 MB)
* - JSON body { "kind":"url", "title":..., "url":... } (fetched via ClientUniversalScraper; queued) * Optional fields: title, category, tags, author, language, folder_id,
* version_action (replace|new|force_separate)
* *
* For file + text: writes pending row, runs ClientRagPipeline::ingestDocument() synchronously, * - JSON body { "kind":"text", "title":..., "content":..., "category"?, "tags"?,
* returns { ok, document_id, chunks, status } * "author"?, "language"?, "folder_id"?, "version_action"? }
* For url: writes pending row, returns immediately with status:'pending' — a separate cron job
* (run_client_one.php on the ai-portal) does the ingest.
* *
* If file text extraction yields less than 200 chars, attempts OCR via `tesseract` shell util. * - JSON body { "kind":"url", "title":..., "url":..., "folder_id"? }
*
* On title collision in the same folder, returns HTTP 409 with
* { ok: false, collision: true, existing_id, message }
* unless `version_action` is provided.
*/ */
declare(strict_types=1); declare(strict_types=1);
@@ -27,8 +30,11 @@ try {
} catch (DbnToolsHttpException $e) { } catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode); dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
} }
$clientId = (int)$tenant['client_id'];
$corpusId = (int)$tenant['corpus_id']; $clientId = (int)$tenant['client_id'];
$corpusId = (int)$tenant['corpus_id'];
$userId = (int)($tenant['client_user_id'] ?? 0);
$tenantRole = (string)($tenant['role'] ?? 'editor');
dbnToolsBootCaveau(); dbnToolsBootCaveau();
$db = getDb(); $db = getDb();
@@ -38,13 +44,13 @@ $isMultipart = stripos($contentType, 'multipart/form-data') === 0;
try { try {
if ($isMultipart) { if ($isMultipart) {
$result = handleFileUpload($db, $clientId, $corpusId); $result = handleFileUpload($db, $clientId, $corpusId, $userId, $tenantRole);
} else { } else {
$input = dbnToolsJsonInput(2_500_000); $input = dbnToolsJsonInput(2_500_000);
$kind = (string)($input['kind'] ?? 'text'); $kind = (string)($input['kind'] ?? 'text');
$result = match ($kind) { $result = match ($kind) {
'text' => handleTextPaste($db, $clientId, $corpusId, $input), 'text' => handleTextPaste($db, $clientId, $corpusId, $userId, $tenantRole, $input),
'url' => handleUrlImport($db, $clientId, $corpusId, $input), 'url' => handleUrlImport($db, $clientId, $corpusId, $userId, $tenantRole, $input),
default => dbnToolsError('Unknown kind: ' . $kind, 400, 'unknown_kind'), default => dbnToolsError('Unknown kind: ' . $kind, 400, 'unknown_kind'),
}; };
} }
@@ -57,12 +63,19 @@ try {
dbnToolsRespond($result, 201); dbnToolsRespond($result, 201);
function handleFileUpload(PDO $db, int $clientId, int $corpusId): array function handleFileUpload(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole): array
{ {
if (empty($_FILES['file'])) { if (empty($_FILES['file'])) {
dbnToolsError('No file uploaded.', 400, 'missing_file'); dbnToolsError('No file uploaded.', 400, 'missing_file');
} }
$folderId = resolveFolderId($_POST['folder_id'] ?? null);
$versionAction = trim((string)($_POST['version_action'] ?? ''));
if ($folderId !== null && !dbnDmsUserCanAccessFolder($folderId, 'write', $clientId, $userId, $tenantRole)) {
dbnToolsError('You do not have permission to upload here.', 403, 'forbidden_dest');
}
$tmpPath = (string)($_FILES['file']['tmp_name'] ?? '');
$extract = dbnToolsExtractUploadedFile($_FILES['file']); $extract = dbnToolsExtractUploadedFile($_FILES['file']);
$text = (string)$extract['text']; $text = (string)$extract['text'];
$filename = (string)$extract['filename']; $filename = (string)$extract['filename'];
@@ -71,17 +84,22 @@ function handleFileUpload(PDO $db, int $clientId, int $corpusId): array
$sourceType = match ($ext) { $sourceType = match ($ext) {
'pdf' => 'pdf', 'pdf' => 'pdf',
'docx' => 'docx', 'docx' => 'docx',
'xlsx' => 'xlsx',
'pptx' => 'pptx',
'html', 'htm' => 'html',
'csv' => 'csv',
'md' => 'markdown',
default => 'text', default => 'text',
}; };
$importMethod = 'dbn_upload';
if (mb_strlen($text, 'UTF-8') < 200 && $ext === 'pdf') { if (mb_strlen($text, 'UTF-8') < 200 && $ext === 'pdf') {
$ocrText = tryOcrPdf((string)($_FILES['file']['tmp_name'] ?? '')); $ocrText = tryOcrPdf($tmpPath);
if ($ocrText !== null && mb_strlen($ocrText, 'UTF-8') > mb_strlen($text, 'UTF-8')) { if ($ocrText !== null && mb_strlen($ocrText, 'UTF-8') > mb_strlen($text, 'UTF-8')) {
$text = $ocrText; $text = $ocrText;
$importMethod = 'ocr_scan'; $importMethod = 'ocr_scan';
} }
} }
$importMethod = $importMethod ?? 'dbn_upload';
$title = trim((string)($_POST['title'] ?? '')) ?: pathinfo($filename, PATHINFO_FILENAME); $title = trim((string)($_POST['title'] ?? '')) ?: pathinfo($filename, PATHINFO_FILENAME);
$category = sanitizeCategory((string)($_POST['category'] ?? 'uncategorized')); $category = sanitizeCategory((string)($_POST['category'] ?? 'uncategorized'));
@@ -89,7 +107,7 @@ function handleFileUpload(PDO $db, int $clientId, int $corpusId): array
$author = trim((string)($_POST['author'] ?? '')) ?: null; $author = trim((string)($_POST['author'] ?? '')) ?: null;
$language = trim((string)($_POST['language'] ?? 'no')) ?: 'no'; $language = trim((string)($_POST['language'] ?? 'no')) ?: 'no';
return persistAndIngest($db, $clientId, $corpusId, [ $doc = [
'title' => $title, 'title' => $title,
'source_type' => $sourceType, 'source_type' => $sourceType,
'content' => $text, 'content' => $text,
@@ -101,18 +119,29 @@ function handleFileUpload(PDO $db, int $clientId, int $corpusId): array
'original_filename' => $filename, 'original_filename' => $filename,
'file_size_bytes' => (int)($_FILES['file']['size'] ?? 0), 'file_size_bytes' => (int)($_FILES['file']['size'] ?? 0),
'source_tool' => 'dashboard-upload', 'source_tool' => 'dashboard-upload',
]); 'folder_id' => $folderId,
'_tmp_path' => $tmpPath,
'_ext' => $ext,
];
return handleCollisionAndIngest($db, $clientId, $corpusId, $userId, $tenantRole, $doc, $versionAction);
} }
function handleTextPaste(PDO $db, int $clientId, int $corpusId, array $input): array function handleTextPaste(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole, array $input): array
{ {
$title = trim((string)($input['title'] ?? '')); $title = trim((string)($input['title'] ?? ''));
$content = trim((string)($input['content'] ?? '')); $content = trim((string)($input['content'] ?? ''));
if ($title === '') dbnToolsError('title is required.', 400, 'missing_title'); if ($title === '') dbnToolsError('title is required.', 400, 'missing_title');
if (mb_strlen($content, 'UTF-8') < 30) dbnToolsError('content too short (min 30 chars).', 400, 'content_too_short'); if (mb_strlen($content, 'UTF-8') < 30) dbnToolsError('content too short (min 30 chars).', 400, 'content_too_short');
if (mb_strlen($content, 'UTF-8') > 2_000_000) dbnToolsError('content exceeds 2 MB.', 400, 'content_too_large'); if (mb_strlen($content, 'UTF-8') > 2_000_000) dbnToolsError('content exceeds 2 MB.', 400, 'content_too_large');
return persistAndIngest($db, $clientId, $corpusId, [ $folderId = resolveFolderId($input['folder_id'] ?? null);
$versionAction = trim((string)($input['version_action'] ?? ''));
if ($folderId !== null && !dbnDmsUserCanAccessFolder($folderId, 'write', $clientId, $userId, $tenantRole)) {
dbnToolsError('You do not have permission to upload here.', 403, 'forbidden_dest');
}
$doc = [
'title' => $title, 'title' => $title,
'source_type' => 'text', 'source_type' => 'text',
'content' => $content, 'content' => $content,
@@ -122,10 +151,12 @@ function handleTextPaste(PDO $db, int $clientId, int $corpusId, array $input): a
'language' => trim((string)($input['language'] ?? 'no')) ?: 'no', 'language' => trim((string)($input['language'] ?? 'no')) ?: 'no',
'import_method' => 'manual', 'import_method' => 'manual',
'source_tool' => 'dashboard-paste', 'source_tool' => 'dashboard-paste',
]); 'folder_id' => $folderId,
];
return handleCollisionAndIngest($db, $clientId, $corpusId, $userId, $tenantRole, $doc, $versionAction);
} }
function handleUrlImport(PDO $db, int $clientId, int $corpusId, array $input): array function handleUrlImport(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole, array $input): array
{ {
$url = trim((string)($input['url'] ?? '')); $url = trim((string)($input['url'] ?? ''));
$title = trim((string)($input['title'] ?? '')); $title = trim((string)($input['title'] ?? ''));
@@ -138,42 +169,155 @@ function handleUrlImport(PDO $db, int $clientId, int $corpusId, array $input): a
} }
if ($title === '') $title = $url; if ($title === '') $title = $url;
$folderId = resolveFolderId($input['folder_id'] ?? null);
if ($folderId !== null && !dbnDmsUserCanAccessFolder($folderId, 'write', $clientId, $userId, $tenantRole)) {
dbnToolsError('You do not have permission to upload here.', 403, 'forbidden_dest');
}
$stmt = $db->prepare(" $stmt = $db->prepare("
INSERT INTO client_documents INSERT INTO client_documents
(client_id, corpus_id, title, source_type, source_url, content, (client_id, corpus_id, folder_id, title, source_type, source_url, content,
category, tags, language, import_method, source_tool, status) category, tags, language, import_method, source_tool, status)
VALUES (?, ?, ?, 'url', ?, '', ?, ?, ?, 'url', 'dashboard-url', 'pending') VALUES (?, ?, ?, ?, 'url', ?, '', ?, ?, ?, 'url', 'dashboard-url', 'pending')
"); ");
$stmt->execute([ $stmt->execute([
$clientId, $corpusId, $title, $url, $clientId, $corpusId, $folderId, $title, $url,
sanitizeCategory((string)($input['category'] ?? 'uncategorized')), sanitizeCategory((string)($input['category'] ?? 'uncategorized')),
sanitizeTagsCsv((string)($input['tags'] ?? '')), sanitizeTagsCsv((string)($input['tags'] ?? '')),
trim((string)($input['language'] ?? 'no')) ?: 'no', trim((string)($input['language'] ?? 'no')) ?: 'no',
]); ]);
$docId = (int)$db->lastInsertId();
dbnDmsLogAudit($clientId, $userId ?: null, 'upload', ['mode' => 'url', 'url' => $url], $docId, $folderId);
return [ return [
'ok' => true, 'ok' => true,
'document_id' => (int)$db->lastInsertId(), 'document_id' => $docId,
'status' => 'pending', 'status' => 'pending',
'chunks' => 0, 'chunks' => 0,
'note' => 'URL queued for background ingest.', 'note' => 'URL queued for background ingest.',
]; ];
} }
function persistAndIngest(PDO $db, int $clientId, int $corpusId, array $doc): array /**
* Title collision detection inside the same folder; dispatches to insert/replace per action.
*/
function handleCollisionAndIngest(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole, array $doc, string $versionAction): array
{ {
$wordCount = str_word_count($doc['content']); if ($versionAction !== 'force_separate') {
$check = $db->prepare(
"SELECT id FROM client_documents
WHERE client_id = ?
AND (folder_id <=> ?)
AND LOWER(title) = LOWER(?)
AND deleted_at IS NULL
ORDER BY id DESC LIMIT 1"
);
$check->execute([$clientId, $doc['folder_id'], $doc['title']]);
$existingId = (int)$check->fetchColumn();
if ($existingId > 0 && $versionAction === '') {
dbnToolsError(
'A document with this title already exists in the target folder.',
409,
'title_collision',
['collision' => true, 'existing_id' => $existingId,
'options' => ['replace','new','force_separate']]
);
}
if ($existingId > 0 && in_array($versionAction, ['replace', 'new'], true)) {
return replaceAsVersion($db, $clientId, $userId, $existingId, $doc, $versionAction);
}
}
if ($versionAction === 'force_separate') {
$doc['title'] = uniqueTitle($db, $clientId, $doc['folder_id'], $doc['title']);
}
return persistAndIngest($db, $clientId, $corpusId, $userId, $doc);
}
function replaceAsVersion(PDO $db, int $clientId, int $userId, int $existingId, array $doc, string $versionAction): array
{
// Snapshot current → versions
$newVer = dbnDmsSnapshotVersion($existingId, $clientId, $userId, "Replaced via {$versionAction}");
$current = (int)$db->query("SELECT current_version FROM client_documents WHERE id = {$existingId}")->fetchColumn();
$nextVer = max($current + 1, $newVer + 1);
// Update with new content
$stmt = $db->prepare(
"UPDATE client_documents
SET title=?, source_type=?, content=?, category=?, tags=?, author=?, language=?,
import_method=?, source_tool=?, original_filename=?, file_size_bytes=?, word_count=?,
current_version=?, status='pending', error_message=NULL, updated_at=NOW(),
storage_path = NULL
WHERE id=? AND client_id=?"
);
$stmt->execute([
$doc['title'], $doc['source_type'], $doc['content'], $doc['category'], $doc['tags'],
$doc['author'] ?? null, $doc['language'], $doc['import_method'], $doc['source_tool'],
$doc['original_filename'] ?? null,
(int)($doc['file_size_bytes'] ?? 0),
str_word_count((string)$doc['content']),
$nextVer,
$existingId, $clientId,
]);
// Persist file to disk if we have a tmp upload
if (!empty($doc['_tmp_path']) && !empty($doc['_ext'])) {
$storagePath = dbnDmsPersistFile($doc['_tmp_path'], $clientId, $existingId, $doc['_ext'], $nextVer);
if ($storagePath) {
$db->prepare('UPDATE client_documents SET storage_path = ? WHERE id = ?')
->execute([$storagePath, $existingId]);
}
}
// Wipe chunks & re-ingest
try {
$db->prepare('DELETE FROM client_chunks WHERE client_id = ? AND document_id = ?')->execute([$clientId, $existingId]);
} catch (Throwable $e) { /* tolerated */ }
$chunks = 0;
try {
$rag = new ClientRagPipeline($clientId);
$chunks = (int)$rag->ingestDocument($existingId);
dbnDmsLogAudit($clientId, $userId ?: null, 'version', ['version' => $nextVer], $existingId, $doc['folder_id']);
return [
'ok' => true,
'document_id' => $existingId,
'version_number' => $nextVer,
'chunks' => $chunks,
'status' => 'ready',
'collision_resolved' => $versionAction,
];
} catch (Throwable $e) {
$db->prepare("UPDATE client_documents SET status='error', error_message=? WHERE id=?")
->execute([substr($e->getMessage(), 0, 1000), $existingId]);
return [
'ok' => false,
'document_id' => $existingId,
'version_number' => $nextVer,
'status' => 'error',
'error' => ['code' => 'index_failed', 'message' => $e->getMessage()],
];
}
}
function persistAndIngest(PDO $db, int $clientId, int $corpusId, int $userId, array $doc): array
{
$wordCount = str_word_count((string)$doc['content']);
$stmt = $db->prepare(" $stmt = $db->prepare("
INSERT INTO client_documents INSERT INTO client_documents
(client_id, corpus_id, title, source_type, original_filename, file_size_bytes, (client_id, corpus_id, folder_id, title, source_type, original_filename, file_size_bytes,
content, category, tags, author, language, content, category, tags, author, language,
import_method, source_tool, word_count, status) import_method, source_tool, word_count, status, current_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 1)
"); ");
$stmt->execute([ $stmt->execute([
$clientId, $clientId,
$corpusId, $corpusId,
$doc['folder_id'] ?? null,
$doc['title'], $doc['title'],
$doc['source_type'], $doc['source_type'],
$doc['original_filename'] ?? null, $doc['original_filename'] ?? null,
@@ -189,9 +333,21 @@ function persistAndIngest(PDO $db, int $clientId, int $corpusId, array $doc): ar
]); ]);
$docId = (int)$db->lastInsertId(); $docId = (int)$db->lastInsertId();
// Persist original file bytes if available (file upload path only).
if (!empty($doc['_tmp_path']) && !empty($doc['_ext'])) {
$storagePath = dbnDmsPersistFile($doc['_tmp_path'], $clientId, $docId, $doc['_ext']);
if ($storagePath) {
$db->prepare('UPDATE client_documents SET storage_path = ? WHERE id = ?')
->execute([$storagePath, $docId]);
}
}
try { try {
$rag = new ClientRagPipeline($clientId); $rag = new ClientRagPipeline($clientId);
$chunks = $rag->ingestDocument($docId); $chunks = $rag->ingestDocument($docId);
dbnDmsLogAudit($clientId, $userId ?: null, 'upload',
['source_type' => $doc['source_type'], 'word_count' => $wordCount],
$docId, $doc['folder_id'] ?? null);
return [ return [
'ok' => true, 'ok' => true,
'document_id' => $docId, 'document_id' => $docId,
@@ -211,6 +367,34 @@ function persistAndIngest(PDO $db, int $clientId, int $corpusId, array $doc): ar
} }
} }
function resolveFolderId(mixed $raw): ?int
{
if ($raw === null || $raw === '' || $raw === 'unassigned' || $raw === '0') {
return null;
}
$n = (int)$raw;
return $n > 0 ? $n : null;
}
function uniqueTitle(PDO $db, int $clientId, ?int $folderId, string $title): string
{
$check = $db->prepare(
"SELECT COUNT(*) FROM client_documents
WHERE client_id = ? AND (folder_id <=> ?) AND LOWER(title) = LOWER(?) AND deleted_at IS NULL"
);
$n = 2;
$base = $title;
while ($n < 100) {
$candidate = $base . ' (' . $n . ')';
$check->execute([$clientId, $folderId, $candidate]);
if ((int)$check->fetchColumn() === 0) {
return $candidate;
}
$n++;
}
return $base . ' (' . substr(bin2hex(random_bytes(3)), 0, 6) . ')';
}
function sanitizeCategory(string $cat): string function sanitizeCategory(string $cat): string
{ {
$cat = strtolower(trim($cat)); $cat = strtolower(trim($cat));
+719
View File
@@ -0,0 +1,719 @@
/* ============================================================
* DMS — Document Management System styles
* Two-pane Drive-style browser, folder tree, list, modals.
* Reuses dashboard.css design tokens (paper, navy, red, gold, line).
* ============================================================ */
:root {
--dms-tree-w: 240px;
--dms-row-h: 48px;
--dms-radius: 10px;
--dms-radius-sm: 6px;
--dms-stroke: rgba(22,19,15,0.16);
--dms-stroke-soft: rgba(22,19,15,0.08);
--dms-hover: rgba(0,32,91,0.06);
--dms-selected: rgba(184,138,44,0.16);
--dms-paper: #f6f2ea;
--dms-navy: #00205b;
--dms-red: #ba0c2f;
--dms-gold: #b88a2c;
--dms-shadow-soft: 0 1px 0 var(--dms-stroke);
--dms-shadow-modal: 0 12px 48px rgba(0,0,0,0.18);
}
/* ─── Drive-style two-pane shell (lives inside .dash-main__body) ─── */
.dms-shell {
display: grid;
grid-template-columns: var(--dms-tree-w) minmax(0, 1fr);
gap: 16px;
min-height: 60vh;
}
.dms-shell--single { grid-template-columns: 1fr; }
/* ─── Tree sidebar ─── */
.dms-tree {
background: #fff;
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius);
padding: 8px 6px;
height: fit-content;
position: sticky;
top: 16px;
max-height: calc(100vh - 32px);
overflow-y: auto;
}
.dms-tree__section {
padding: 8px 8px 4px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(22,19,15,0.55);
font-weight: 600;
}
.dms-tree__list { list-style: none; margin: 0; padding: 0; }
.dms-tree__node {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: var(--dms-radius-sm);
cursor: pointer;
user-select: none;
font-size: 14px;
color: #1a1c2c;
line-height: 1.2;
}
.dms-tree__node:hover { background: var(--dms-hover); }
.dms-tree__node.is-active {
background: var(--dms-selected);
color: var(--dms-navy);
font-weight: 600;
}
.dms-tree__node.is-drop-target {
background: rgba(0,32,91,0.10);
outline: 2px dashed var(--dms-navy);
outline-offset: -2px;
}
.dms-tree__caret {
width: 14px;
height: 14px;
flex: 0 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.15s;
opacity: 0.6;
}
.dms-tree__caret.is-open { transform: rotate(90deg); }
.dms-tree__caret--empty { visibility: hidden; }
.dms-tree__icon { width: 16px; flex: 0 0 16px; }
.dms-tree__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--dms-stroke);
flex: 0 0 8px;
}
.dms-tree__label {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dms-tree__count {
font-size: 11px;
color: rgba(22,19,15,0.55);
background: var(--dms-stroke-soft);
padding: 1px 6px;
border-radius: 999px;
}
.dms-tree__node.is-active .dms-tree__count {
background: rgba(255,255,255,0.6);
color: var(--dms-navy);
}
.dms-tree__children {
list-style: none;
margin: 0;
padding-left: 18px;
}
.dms-tree__btn {
width: 100%;
background: none;
border: 0;
padding: 6px 8px;
text-align: left;
color: var(--dms-navy);
cursor: pointer;
border-radius: var(--dms-radius-sm);
font-weight: 600;
font-size: 13px;
margin-top: 4px;
}
.dms-tree__btn:hover { background: var(--dms-hover); }
/* ─── Main pane ─── */
.dms-main {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.dms-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
background: #fff;
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius);
padding: 10px 12px;
}
.dms-toolbar__crumbs {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
font-size: 14px;
color: #1a1c2c;
min-width: 0;
}
.dms-toolbar__crumbs a {
color: inherit;
text-decoration: none;
padding: 2px 6px;
border-radius: 4px;
}
.dms-toolbar__crumbs a:hover { background: var(--dms-hover); }
.dms-toolbar__crumb-sep { opacity: 0.4; font-size: 12px; }
.dms-toolbar__crumb--current { font-weight: 600; color: var(--dms-navy); }
.dms-toolbar__actions {
margin-left: auto;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.dms-filters {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
padding: 8px 12px;
background: #fdfaf3;
border: 1px solid var(--dms-stroke-soft);
border-radius: var(--dms-radius);
}
.dms-filters input[type=search],
.dms-filters select {
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius-sm);
padding: 6px 10px;
background: #fff;
font-size: 13px;
min-width: 0;
}
.dms-filters input[type=search] { flex: 1 1 240px; }
/* Chips */
.dms-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
background: var(--dms-stroke-soft);
font-size: 12px;
color: #1a1c2c;
}
.dms-chip--cat { background: rgba(0,32,91,0.10); color: var(--dms-navy); }
.dms-chip--tag { background: rgba(184,138,44,0.16); color: #6c5212; }
.dms-chip--folder{ background: rgba(255,255,255,0.6); color: var(--dms-navy); border: 1px solid var(--dms-stroke-soft); }
.dms-chip__x {
cursor: pointer;
opacity: 0.6;
margin-left: 2px;
}
.dms-chip__x:hover { opacity: 1; }
/* ─── Doc list (table style) ─── */
.dms-list {
background: #fff;
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius);
overflow: hidden;
}
.dms-list__head,
.dms-list__row {
display: grid;
grid-template-columns: 36px minmax(0,3.5fr) 1.2fr 1.2fr 110px 90px 36px;
gap: 8px;
align-items: center;
padding: 8px 12px;
}
.dms-list__head {
background: #fdfaf3;
border-bottom: 1px solid var(--dms-stroke);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(22,19,15,0.65);
font-weight: 600;
}
.dms-list__head button {
background: none;
border: 0;
padding: 0;
cursor: pointer;
text-align: left;
color: inherit;
font: inherit;
text-transform: inherit;
letter-spacing: inherit;
}
.dms-list__head button.is-sorted { color: var(--dms-navy); }
.dms-list__row {
border-bottom: 1px solid var(--dms-stroke-soft);
font-size: 14px;
cursor: pointer;
transition: background 0.1s;
}
.dms-list__row:hover { background: var(--dms-hover); }
.dms-list__row.is-selected { background: var(--dms-selected); }
.dms-list__row.is-dragging { opacity: 0.4; }
.dms-list__row[data-status="pending"] { color: #6c5212; }
.dms-list__row[data-status="processing"] { color: #6c5212; }
.dms-list__row[data-status="error"] { color: var(--dms-red); }
.dms-list__title {
font-weight: 600;
color: var(--dms-navy);
display: flex;
align-items: center;
gap: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.dms-list__title-icon {
width: 18px;
flex: 0 0 18px;
opacity: 0.7;
}
.dms-list__cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.dms-list__cell--muted { color: rgba(22,19,15,0.55); font-size: 13px; }
.dms-list__more {
background: none;
border: 0;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
opacity: 0.5;
}
.dms-list__more:hover { opacity: 1; background: var(--dms-hover); }
.dms-list__empty {
padding: 60px 24px;
text-align: center;
color: rgba(22,19,15,0.55);
}
.dms-list__empty strong {
display: block;
color: var(--dms-navy);
font-size: 16px;
margin-bottom: 6px;
}
/* ─── Bulk-action bar (sticky bottom of list when selection > 0) ─── */
.dms-bulk-bar {
position: sticky;
bottom: 12px;
margin-top: 8px;
background: var(--dms-navy);
color: #fff;
padding: 8px 12px;
border-radius: var(--dms-radius);
box-shadow: 0 6px 24px rgba(0,32,91,0.30);
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
z-index: 10;
}
.dms-bulk-bar__count {
font-weight: 600;
margin-right: 4px;
}
.dms-bulk-bar__btn {
background: rgba(255,255,255,0.10);
border: 1px solid rgba(255,255,255,0.20);
color: #fff;
padding: 4px 10px;
border-radius: var(--dms-radius-sm);
cursor: pointer;
font-size: 13px;
}
.dms-bulk-bar__btn:hover { background: rgba(255,255,255,0.20); }
.dms-bulk-bar__btn--danger { background: var(--dms-red); border-color: var(--dms-red); }
.dms-bulk-bar__btn--danger:hover { background: #8c0822; }
.dms-bulk-bar__cancel {
margin-left: auto;
background: none;
border: 0;
color: rgba(255,255,255,0.7);
cursor: pointer;
padding: 4px 8px;
font-size: 13px;
}
/* ─── Context menu (right-click) ─── */
.dms-ctx-menu {
position: fixed;
z-index: 50;
background: #fff;
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius-sm);
box-shadow: var(--dms-shadow-modal);
padding: 4px;
min-width: 180px;
font-size: 13px;
}
.dms-ctx-menu__item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
color: #1a1c2c;
}
.dms-ctx-menu__item:hover { background: var(--dms-hover); }
.dms-ctx-menu__item--danger { color: var(--dms-red); }
.dms-ctx-menu__sep {
height: 1px;
background: var(--dms-stroke-soft);
margin: 4px 0;
}
/* ─── Modal ─── */
.dms-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(10,15,30,0.40);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.dms-modal {
background: #fff;
border-radius: var(--dms-radius);
box-shadow: var(--dms-shadow-modal);
max-width: 520px;
width: 100%;
overflow: hidden;
}
.dms-modal--wide { max-width: 760px; }
.dms-modal__head {
padding: 16px 20px;
border-bottom: 1px solid var(--dms-stroke-soft);
display: flex;
align-items: center;
gap: 8px;
}
.dms-modal__title {
font-size: 17px;
font-weight: 600;
color: var(--dms-navy);
margin: 0;
}
.dms-modal__body {
padding: 16px 20px;
max-height: 70vh;
overflow-y: auto;
}
.dms-modal__foot {
padding: 12px 20px;
border-top: 1px solid var(--dms-stroke-soft);
display: flex;
gap: 8px;
justify-content: flex-end;
flex-wrap: wrap;
}
.dms-modal__close {
margin-left: auto;
background: none;
border: 0;
font-size: 22px;
line-height: 1;
color: rgba(22,19,15,0.55);
cursor: pointer;
padding: 0 6px;
}
/* ─── Form bits ─── */
.dms-field {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.dms-field label {
font-size: 12px;
font-weight: 600;
color: rgba(22,19,15,0.7);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dms-field input,
.dms-field select,
.dms-field textarea {
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius-sm);
padding: 8px 10px;
font-size: 14px;
background: #fff;
font-family: inherit;
}
.dms-field textarea { min-height: 80px; resize: vertical; }
.dms-field--inline {
flex-direction: row;
align-items: center;
gap: 8px;
}
/* ─── Tabs (document.php) ─── */
.dms-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--dms-stroke);
margin-bottom: 16px;
}
.dms-tab {
padding: 10px 16px;
border: 0;
background: none;
cursor: pointer;
font-size: 14px;
color: rgba(22,19,15,0.65);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-weight: 500;
}
.dms-tab.is-active {
color: var(--dms-navy);
border-bottom-color: var(--dms-navy);
font-weight: 600;
}
.dms-tab__pill {
display: inline-block;
background: var(--dms-stroke-soft);
color: rgba(22,19,15,0.7);
border-radius: 999px;
font-size: 11px;
padding: 1px 7px;
margin-left: 4px;
}
.dms-tab-panel { display: none; }
.dms-tab-panel.is-active { display: block; }
/* ─── Version timeline ─── */
.dms-version {
display: grid;
grid-template-columns: 60px 1fr auto;
gap: 12px;
align-items: start;
padding: 12px;
border: 1px solid var(--dms-stroke-soft);
border-radius: var(--dms-radius);
margin-bottom: 8px;
}
.dms-version__num {
background: var(--dms-navy);
color: #fff;
border-radius: var(--dms-radius-sm);
padding: 4px 8px;
text-align: center;
font-weight: 600;
font-size: 12px;
}
.dms-version__meta {
font-size: 13px;
color: rgba(22,19,15,0.7);
}
.dms-version__title {
color: var(--dms-navy);
font-weight: 600;
font-size: 14px;
}
.dms-version__actions { display: flex; gap: 6px; }
.dms-version--current {
border-left: 3px solid var(--dms-gold);
background: #fdfaf3;
}
/* ─── Permissions panel ─── */
.dms-perm-row {
display: grid;
grid-template-columns: 1fr 70px 70px 70px 32px;
gap: 8px;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--dms-stroke-soft);
font-size: 13px;
}
.dms-perm-row__head {
background: #fdfaf3;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(22,19,15,0.65);
font-weight: 600;
}
/* ─── Preview frames ─── */
.dms-preview-frame {
width: 100%;
height: 70vh;
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius);
background: #f9f7f1;
}
.dms-preview-audio {
width: 100%;
margin: 20px 0;
}
/* ─── Drop overlay for upload drag-anywhere ─── */
.dms-drop-overlay {
position: fixed;
inset: 0;
background: rgba(0,32,91,0.85);
color: #fff;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 12px;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
font-size: 24px;
font-weight: 600;
}
.dms-drop-overlay.is-visible { opacity: 1; }
/* ─── KPI tiles ─── */
.dms-kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.dms-kpi {
background: #fff;
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius);
padding: 14px 16px;
}
.dms-kpi__label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(22,19,15,0.6);
font-weight: 600;
margin: 0 0 6px;
}
.dms-kpi__value {
font-size: 28px;
line-height: 1;
color: var(--dms-navy);
font-weight: 700;
margin: 0;
}
.dms-kpi__hint {
font-size: 12px;
color: rgba(22,19,15,0.55);
margin: 6px 0 0;
}
/* ─── Activity feed ─── */
.dms-activity {
background: #fff;
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius);
padding: 8px 0;
}
.dms-activity__row {
display: grid;
grid-template-columns: 28px 1fr auto;
gap: 10px;
align-items: center;
padding: 8px 16px;
font-size: 13px;
border-bottom: 1px solid var(--dms-stroke-soft);
}
.dms-activity__row:last-child { border-bottom: 0; }
.dms-activity__icon {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--dms-stroke-soft);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--dms-navy);
font-size: 12px;
}
.dms-activity__time {
font-size: 11px;
color: rgba(22,19,15,0.5);
}
/* ─── Settings diagnostics ─── */
.dms-diag {
background: #fff;
border: 1px solid var(--dms-stroke);
border-radius: var(--dms-radius);
overflow: hidden;
}
.dms-diag__row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--dms-stroke-soft);
font-size: 13px;
align-items: center;
}
.dms-diag__row:last-child { border-bottom: 0; }
.dms-diag__label { font-weight: 600; color: var(--dms-navy); }
.dms-diag__value { font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; font-size: 12px; }
.dms-diag__status {
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
}
.dms-diag__status--ok { background: rgba(4,120,87,0.12); color: #047857; }
.dms-diag__status--warn { background: rgba(180,138,44,0.16); color: #6c5212; }
.dms-diag__status--err { background: rgba(186,12,47,0.12); color: var(--dms-red); }
/* ─── Loading / empty states ─── */
.dms-loading {
padding: 40px 16px;
text-align: center;
color: rgba(22,19,15,0.5);
font-size: 14px;
}
.dms-loading::before {
content: '';
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--dms-stroke);
border-top-color: var(--dms-navy);
border-radius: 50%;
animation: dms-spin 0.8s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes dms-spin { to { transform: rotate(360deg); } }
/* ─── Responsive ─── */
@media (max-width: 880px) {
.dms-shell {
grid-template-columns: 1fr;
}
.dms-tree {
position: relative;
max-height: 280px;
}
.dms-list__head,
.dms-list__row {
grid-template-columns: 36px 1fr 90px 36px;
}
.dms-list__head > :nth-child(n+4):nth-child(-n+6),
.dms-list__row > :nth-child(n+4):nth-child(-n+6) {
display: none;
}
}
+696
View File
@@ -0,0 +1,696 @@
/**
* dms.js — Drive-style DMS interactivity bundle.
*
* Exposes window.DBN_DMS with helpers used by documents.php, folders.php,
* trash.php, document.php. Vanilla JS, no build step.
*/
(function () {
'use strict';
const API = (window.DBN_DASHBOARD && window.DBN_DASHBOARD.apiBase) || '/api/dashboard';
const I18N = window.DBN_I18N || {};
const LOC = I18N.locale || 'en-GB';
/* ─── utils ─── */
function $(sel, ctx) { return (ctx || document).querySelector(sel); }
function $$(sel, ctx) { return Array.from((ctx || document).querySelectorAll(sel)); }
function safe(s) {
return String(s == null ? '' : s)
.replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
}
function fmtBytes(n) {
n = Number(n) || 0;
if (n < 1024) return n + ' B';
if (n < 1024*1024) return (n/1024).toFixed(0) + ' KB';
if (n < 1024*1024*1024) return (n/1024/1024).toFixed(1) + ' MB';
return (n/1024/1024/1024).toFixed(2) + ' GB';
}
function fmtDate(s) {
if (!s) return '—';
try { return new Date(String(s).replace(' ', 'T') + 'Z')
.toLocaleDateString(LOC, { day:'numeric', month:'short', year:'numeric' }); }
catch (_) { return s; }
}
function fmtRelative(s) {
if (!s) return '—';
const d = new Date(String(s).replace(' ', 'T') + 'Z');
const diff = (Date.now() - d.getTime()) / 1000;
if (diff < 60) return I18N.just_now || 'just now';
if (diff < 3600) return Math.floor(diff/60) + 'm';
if (diff < 86400) return Math.floor(diff/3600) + 'h';
if (diff < 86400*7) return Math.floor(diff/86400) + 'd';
return fmtDate(s);
}
function fileIcon(srcType) {
const map = { pdf:'📄', docx:'📝', xlsx:'📊', pptx:'📑', csv:'📋',
html:'🌐', markdown:'📔', text:'📃', audio:'🔊', url:'🔗' };
return map[srcType] || '📄';
}
async function api(path, opts) {
opts = opts || {};
const headers = Object.assign({ 'Accept': 'application/json' }, opts.headers || {});
let body = opts.body;
if (body && typeof body === 'object' && !(body instanceof FormData)) {
body = JSON.stringify(body);
headers['Content-Type'] = 'application/json';
}
const res = await fetch(API + path, {
method: opts.method || 'GET',
credentials: 'same-origin',
headers,
body,
});
let json = null;
try { json = await res.json(); } catch (_) { /* may be a stream */ }
if (!res.ok) {
const err = new Error((json && json.message) || ('HTTP ' + res.status));
err.status = res.status;
err.payload = json;
throw err;
}
return json;
}
/* ─── Folder tree ─── */
const Tree = {
state: { tree: [], activeFolderId: null, unassignedCount: 0, trashCount: 0, maxDepth: 2 },
async load() {
const data = await api('/folders.php?action=list_tree');
this.state.tree = data.tree || [];
this.state.unassignedCount = data.unassigned_count || 0;
this.state.trashCount = data.trash_count || 0;
this.state.maxDepth = data.max_depth || 2;
return this.state;
},
render(container, opts) {
opts = opts || {};
const active = String(opts.activeFolderId == null ? '' : opts.activeFolderId);
const html = [];
html.push('<div class="dms-tree__section">' + safe(I18N.dms_folders || 'Folders') + '</div>');
html.push('<ul class="dms-tree__list">');
html.push(this._row({ id: 'all', name: I18N.dms_all_files || 'All files', icon: '🗂' },
active === 'all' || active === 'null', 0));
if (this.state.unassignedCount) {
html.push(this._row({ id: 'unassigned',
name: I18N.dms_unassigned || 'Unassigned',
icon: '📥',
doc_count: this.state.unassignedCount },
active === 'unassigned', 0));
}
(this.state.tree || []).forEach(node => {
html.push(this._renderNode(node, active, 0));
});
html.push('</ul>');
html.push('<div class="dms-tree__section">' + safe(I18N.dms_smart || 'Smart folders') + '</div>');
html.push('<ul class="dms-tree__list" id="dmsSmartList"><li class="dms-tree__node dms-tree__node--placeholder" style="opacity:.5;font-size:12px;padding-left:14px">—</li></ul>');
html.push('<button class="dms-tree__btn" type="button" data-action="new-folder">+ ' + safe(I18N.dms_new_folder || 'New folder') + '</button>');
html.push('<button class="dms-tree__btn" type="button" data-action="manage-folders">⚙ ' + safe(I18N.dms_manage_folders || 'Manage folders') + '</button>');
html.push('<div class="dms-tree__section" style="margin-top:8px">' + safe(I18N.dms_system || 'System') + '</div>');
html.push('<ul class="dms-tree__list">');
html.push(this._row({ id: 'trash', name: (I18N.dms_trash || 'Trash') + (this.state.trashCount ? '' : ''),
icon: '🗑', doc_count: this.state.trashCount }, active === 'trash', 0, { href: '/dashboard/trash.php' }));
html.push('</ul>');
container.innerHTML = html.join('');
container.addEventListener('click', this._onClick.bind(this));
this._installDropHandlers(container);
},
_row(node, isActive, depth, opts) {
opts = opts || {};
const count = node.doc_count ? ('<span class="dms-tree__count">' + node.doc_count + '</span>') : '';
const dot = node.color ? ('<span class="dms-tree__dot" style="background:' + safe(node.color) + '"></span>') : '';
const icon = node.icon || dot || '📁';
const tag = opts.href ? 'a' : 'div';
const href = opts.href ? (' href="' + safe(opts.href) + '"') : '';
return '<li><'+tag+' class="dms-tree__node ' + (isActive ? 'is-active' : '') + '"'
+ ' data-folder-id="' + safe(node.id) + '"' + href + '>'
+ '<span class="dms-tree__icon">' + (typeof icon === 'string' && icon.length < 3 ? icon : dot) + '</span>'
+ '<span class="dms-tree__label">' + safe(node.name) + '</span>'
+ count + '</'+tag+'></li>';
},
_renderNode(node, active, depth) {
const hasChildren = node.children && node.children.length;
const isActive = String(node.id) === active;
const html = [];
html.push('<li>');
html.push('<div class="dms-tree__node ' + (isActive ? 'is-active' : '') + '"'
+ ' data-folder-id="' + node.id + '"'
+ ' data-droptarget="1">');
html.push('<span class="dms-tree__caret ' + (hasChildren ? 'is-open' : 'dms-tree__caret--empty') + '">▸</span>');
html.push('<span class="dms-tree__dot" style="background:' + (node.color || '#94a3b8') + '"></span>');
html.push('<span class="dms-tree__label">' + safe(node.name) + '</span>');
if (node.doc_count) html.push('<span class="dms-tree__count">' + node.doc_count + '</span>');
html.push('</div>');
if (hasChildren) {
html.push('<ul class="dms-tree__children">');
node.children.forEach(c => html.push(this._renderNode(c, active, depth + 1)));
html.push('</ul>');
}
html.push('</li>');
return html.join('');
},
_onClick(e) {
const node = e.target.closest('.dms-tree__node');
const btn = e.target.closest('[data-action]');
if (btn) {
e.preventDefault();
if (btn.dataset.action === 'new-folder') {
DMS.folderModal.open({ parentId: this.state.activeFolderId });
} else if (btn.dataset.action === 'manage-folders') {
window.location.href = '/dashboard/folders.php';
}
return;
}
if (node && !node.matches('a')) {
e.preventDefault();
const id = node.dataset.folderId;
this.state.activeFolderId = id;
document.dispatchEvent(new CustomEvent('dms:folder-changed', { detail: { folderId: id } }));
}
},
_installDropHandlers(container) {
container.addEventListener('dragover', e => {
const node = e.target.closest('[data-droptarget="1"]');
if (!node) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
$$('.is-drop-target', container).forEach(el => el.classList.remove('is-drop-target'));
node.classList.add('is-drop-target');
});
container.addEventListener('dragleave', e => {
const node = e.target.closest('[data-droptarget="1"]');
if (node) node.classList.remove('is-drop-target');
});
container.addEventListener('drop', async e => {
const node = e.target.closest('[data-droptarget="1"]');
if (!node) return;
e.preventDefault();
node.classList.remove('is-drop-target');
const folderId = node.dataset.folderId;
let payload;
try { payload = JSON.parse(e.dataTransfer.getData('application/x-dms')); } catch (_) { return; }
if (!payload || !payload.ids || !payload.ids.length) return;
try {
await api('/bulk.php', { method: 'POST', body: { op: 'move', ids: payload.ids, folder_id: folderId === 'unassigned' ? null : folderId } });
document.dispatchEvent(new CustomEvent('dms:reload-required'));
} catch (err) {
alert((I18N.dms_move_failed || 'Move failed') + ': ' + err.message);
}
});
},
};
/* ─── Folder create/rename modal ─── */
const folderModal = {
open(opts) {
opts = opts || {};
const isEdit = !!opts.folderId;
const m = openModal({
title: isEdit ? (I18N.dms_rename_folder || 'Rename folder') : (I18N.dms_new_folder || 'New folder'),
body:
'<div class="dms-field"><label>' + safe(I18N.dms_folder_name || 'Name') + '</label>' +
'<input id="dmsFolderName" type="text" maxlength="200" value="' + safe(opts.name || '') + '"></div>' +
'<div class="dms-field"><label>' + safe(I18N.dms_folder_color || 'Colour') + '</label>' +
'<input id="dmsFolderColor" type="color" value="' + safe(opts.color || '#3b82f6') + '"></div>',
actions: [
{ label: I18N.dms_cancel || 'Cancel', cls: 'dash-btn', onclick: closeModal },
{ label: isEdit ? (I18N.dms_save || 'Save') : (I18N.dms_create || 'Create'),
cls: 'dash-btn dash-btn--primary',
onclick: async () => {
const name = $('#dmsFolderName').value.trim();
const color = $('#dmsFolderColor').value;
if (!name) return;
try {
if (isEdit) {
await api('/folders.php?action=rename', { method: 'POST', body: { folder_id: opts.folderId, name } });
await api('/folders.php?action=recolor', { method: 'POST', body: { folder_id: opts.folderId, color } });
} else {
const res = await api('/folders.php?action=create', {
method: 'POST',
body: { name, color, parent_id: opts.parentId && opts.parentId !== 'all' && opts.parentId !== 'unassigned' ? opts.parentId : null },
});
if (opts.onCreated) opts.onCreated(res);
}
closeModal();
document.dispatchEvent(new CustomEvent('dms:reload-required'));
} catch (err) {
alert(err.message);
}
} },
],
});
setTimeout(() => $('#dmsFolderName').focus(), 30);
},
};
/* ─── Generic modal ─── */
let _modalRoot = null;
function openModal(opts) {
closeModal();
_modalRoot = document.createElement('div');
_modalRoot.className = 'dms-modal-backdrop';
const actionsHtml = (opts.actions || []).map((a, i) =>
'<button data-mi="' + i + '" class="' + safe(a.cls || 'dash-btn') + '">' + safe(a.label) + '</button>'
).join('');
_modalRoot.innerHTML =
'<div class="dms-modal ' + safe(opts.modalClass || '') + '">' +
'<div class="dms-modal__head">' +
'<h3 class="dms-modal__title">' + safe(opts.title || '') + '</h3>' +
'<button class="dms-modal__close" type="button" aria-label="Close">×</button>' +
'</div>' +
'<div class="dms-modal__body">' + (opts.body || '') + '</div>' +
(opts.actions ? '<div class="dms-modal__foot">' + actionsHtml + '</div>' : '') +
'</div>';
document.body.appendChild(_modalRoot);
_modalRoot.addEventListener('click', e => { if (e.target === _modalRoot) closeModal(); });
$('.dms-modal__close', _modalRoot).addEventListener('click', closeModal);
(opts.actions || []).forEach((a, i) => {
const b = _modalRoot.querySelector('[data-mi="' + i + '"]');
if (b && a.onclick) b.addEventListener('click', a.onclick);
});
return _modalRoot;
}
function closeModal() {
if (_modalRoot && _modalRoot.parentNode) _modalRoot.parentNode.removeChild(_modalRoot);
_modalRoot = null;
}
/* ─── Context menu ─── */
let _ctxMenu = null;
function openCtxMenu(x, y, items) {
closeCtxMenu();
_ctxMenu = document.createElement('div');
_ctxMenu.className = 'dms-ctx-menu';
_ctxMenu.style.left = x + 'px';
_ctxMenu.style.top = y + 'px';
_ctxMenu.innerHTML = items.map((it, i) => {
if (it === '-') return '<div class="dms-ctx-menu__sep"></div>';
return '<div class="dms-ctx-menu__item ' + (it.danger ? 'dms-ctx-menu__item--danger' : '') + '" data-i="' + i + '">'
+ (it.icon ? '<span>' + it.icon + '</span>' : '')
+ safe(it.label) + '</div>';
}).join('');
document.body.appendChild(_ctxMenu);
// Position correction if off-screen
const r = _ctxMenu.getBoundingClientRect();
if (r.right > window.innerWidth) _ctxMenu.style.left = (window.innerWidth - r.width - 8) + 'px';
if (r.bottom > window.innerHeight) _ctxMenu.style.top = (window.innerHeight - r.height - 8) + 'px';
items.forEach((it, i) => {
if (it === '-') return;
const el = _ctxMenu.querySelector('[data-i="' + i + '"]');
if (el && it.onclick) el.addEventListener('click', () => { closeCtxMenu(); it.onclick(); });
});
setTimeout(() => document.addEventListener('click', closeCtxMenu, { once: true }), 0);
}
function closeCtxMenu() {
if (_ctxMenu && _ctxMenu.parentNode) _ctxMenu.parentNode.removeChild(_ctxMenu);
_ctxMenu = null;
}
/* ─── Doc list ─── */
const List = {
state: {
offset: 0, limit: 50, total: 0,
folderId: 'all', includeSubfolders: true,
q: '', status: '', category: '',
sort: 'updated_at', dir: 'desc',
selected: new Set(),
docs: [],
},
async load() {
const qs = new URLSearchParams({
action: 'list',
offset: String(this.state.offset),
limit: String(this.state.limit),
folder_id: String(this.state.folderId),
include_subfolders: this.state.includeSubfolders ? '1' : '0',
sort: this.state.sort,
dir: this.state.dir,
});
if (this.state.q) qs.set('q', this.state.q);
if (this.state.status) qs.set('status', this.state.status);
if (this.state.category) qs.set('category', this.state.category);
const data = await api('/documents.php?' + qs.toString());
this.state.docs = data.documents || [];
this.state.total = data.total || 0;
return data;
},
render(container) {
const docs = this.state.docs;
if (!docs.length) {
container.innerHTML =
'<div class="dms-list">' +
'<div class="dms-list__empty">' +
'<strong>' + safe(I18N.dms_empty_title || 'No documents here yet') + '</strong>' +
'<span>' + safe(I18N.dms_empty_hint || 'Drag files anywhere, or click Upload.') + '</span>' +
'</div></div>';
return;
}
const sortHead = (key, label) => {
const active = this.state.sort === key;
const arrow = active ? (this.state.dir === 'asc' ? ' ↑' : ' ↓') : '';
return '<button data-sort="' + key + '" class="' + (active ? 'is-sorted' : '') + '">' + safe(label) + arrow + '</button>';
};
const rows = docs.map(d => {
const isSel = this.state.selected.has(d.id);
const cat = d.category ? '<span class="dms-chip dms-chip--cat">' + safe(d.category) + '</span>' : '';
const tags = (d.tags || '').split(',').filter(Boolean).slice(0,3)
.map(t => '<span class="dms-chip dms-chip--tag">' + safe(t.trim()) + '</span>').join(' ');
return '<div class="dms-list__row" data-id="' + d.id + '" data-status="' + safe(d.status) + '"'
+ (isSel ? ' class="is-selected"' : '') + ' draggable="true">'
+ '<input type="checkbox" class="dms-sel"' + (isSel ? ' checked' : '') + '>'
+ '<div class="dms-list__title"><span class="dms-list__title-icon">' + fileIcon(d.source_type) + '</span>'
+ '<a href="/dashboard/document.php?id=' + d.id + '">' + safe(d.title || '(untitled)') + '</a></div>'
+ '<div class="dms-list__cell dms-list__cell--muted">' + cat + ' ' + tags + '</div>'
+ '<div class="dms-list__cell dms-list__cell--muted">' + safe(d.author || '') + '</div>'
+ '<div class="dms-list__cell dms-list__cell--muted">' + fmtRelative(d.updated_at || d.created_at) + '</div>'
+ '<div class="dms-list__cell dms-list__cell--muted">' + (d.file_size_bytes ? fmtBytes(d.file_size_bytes) : '—') + '</div>'
+ '<button class="dms-list__more" data-id="' + d.id + '" aria-label="Actions">⋯</button>'
+ '</div>';
}).join('');
container.innerHTML =
'<div class="dms-list">' +
'<div class="dms-list__head">' +
'<input type="checkbox" id="dmsSelAll">' +
sortHead('title', I18N.dms_col_title || 'Title') +
'<span>' + safe(I18N.dms_col_meta || 'Category · Tags') + '</span>' +
'<span>' + safe(I18N.dms_col_author || 'Author') + '</span>' +
sortHead('updated_at', I18N.dms_col_updated || 'Updated') +
sortHead('file_size_bytes', I18N.dms_col_size || 'Size') +
'<span></span>' +
'</div>' + rows +
'</div>' +
(this.state.selected.size ? this._bulkBar() : '');
this._wire(container);
},
_bulkBar() {
const n = this.state.selected.size;
return '<div class="dms-bulk-bar">' +
'<span class="dms-bulk-bar__count">' + n + ' ' + safe(I18N.dms_selected || 'selected') + '</span>' +
'<button class="dms-bulk-bar__btn" data-bulk="move">' + safe(I18N.dms_move || 'Move') + '</button>' +
'<button class="dms-bulk-bar__btn" data-bulk="retag">' + safe(I18N.dms_tag || 'Tag') + '</button>' +
'<button class="dms-bulk-bar__btn" data-bulk="recategorize">' + safe(I18N.dms_recategorize || 'Category') + '</button>' +
'<button class="dms-bulk-bar__btn dms-bulk-bar__btn--danger" data-bulk="trash">' + safe(I18N.dms_trash || 'Trash') + '</button>' +
'<button class="dms-bulk-bar__cancel" data-bulk="cancel">' + safe(I18N.dms_cancel || 'Cancel') + '</button>' +
'</div>';
},
_wire(container) {
// Selection
container.addEventListener('change', e => {
if (e.target.id === 'dmsSelAll') {
const checked = e.target.checked;
this.state.selected.clear();
if (checked) this.state.docs.forEach(d => this.state.selected.add(d.id));
this.render(container);
return;
}
if (e.target.classList.contains('dms-sel')) {
const row = e.target.closest('[data-id]');
const id = Number(row.dataset.id);
if (e.target.checked) this.state.selected.add(id); else this.state.selected.delete(id);
row.classList.toggle('is-selected', e.target.checked);
this.render(container);
}
});
// Sort
container.addEventListener('click', e => {
const sortBtn = e.target.closest('[data-sort]');
if (sortBtn) {
const key = sortBtn.dataset.sort;
if (this.state.sort === key) {
this.state.dir = this.state.dir === 'asc' ? 'desc' : 'asc';
} else {
this.state.sort = key; this.state.dir = 'desc';
}
this.load().then(() => this.render(container));
return;
}
const more = e.target.closest('.dms-list__more');
if (more) {
e.preventDefault();
e.stopPropagation();
const id = Number(more.dataset.id);
const doc = this.state.docs.find(d => d.id === id);
if (doc) this._openCtx(more.getBoundingClientRect().left, more.getBoundingClientRect().bottom, doc, container);
return;
}
const bulk = e.target.closest('[data-bulk]');
if (bulk) {
this._bulkAction(bulk.dataset.bulk, container);
return;
}
});
// Right-click context menu
container.addEventListener('contextmenu', e => {
const row = e.target.closest('.dms-list__row');
if (!row) return;
e.preventDefault();
const id = Number(row.dataset.id);
const doc = this.state.docs.find(d => d.id === id);
if (doc) this._openCtx(e.clientX, e.clientY, doc, container);
});
// Drag to folder tree
container.addEventListener('dragstart', e => {
const row = e.target.closest('.dms-list__row');
if (!row) return;
const id = Number(row.dataset.id);
const ids = this.state.selected.size && this.state.selected.has(id)
? Array.from(this.state.selected) : [id];
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/x-dms', JSON.stringify({ ids }));
row.classList.add('is-dragging');
});
container.addEventListener('dragend', e => {
const row = e.target.closest('.dms-list__row');
if (row) row.classList.remove('is-dragging');
});
},
_openCtx(x, y, doc, container) {
openCtxMenu(x, y, [
{ label: I18N.dms_open || 'Open', icon: '📂', onclick: () => { window.location.href = '/dashboard/document.php?id=' + doc.id; } },
{ label: I18N.dms_download || 'Download', icon: '⬇',
onclick: () => { window.location.href = API + '/preview.php?id=' + doc.id + '&download=1'; } },
'-',
{ label: I18N.dms_move || 'Move…', icon: '➜', onclick: () => this._bulkAction('move', container, [doc.id]) },
{ label: I18N.dms_recategorize || 'Change category…', icon: '🏷',
onclick: () => this._bulkAction('recategorize', container, [doc.id]) },
{ label: I18N.dms_tag || 'Edit tags…', icon: '🔖', onclick: () => this._bulkAction('retag', container, [doc.id]) },
'-',
{ label: I18N.dms_trash || 'Move to trash', icon: '🗑', danger: true, onclick: () => this._bulkAction('trash', container, [doc.id]) },
]);
},
async _bulkAction(op, container, idsOverride) {
const ids = idsOverride || Array.from(this.state.selected);
if (!ids.length) return;
if (op === 'cancel') { this.state.selected.clear(); this.render(container); return; }
try {
if (op === 'move') {
const folderId = await pickFolder(I18N.dms_pick_destination || 'Pick destination folder');
if (folderId === undefined) return;
await api('/bulk.php', { method: 'POST', body: { op, ids, folder_id: folderId } });
} else if (op === 'recategorize') {
const cat = prompt(I18N.dms_prompt_category || 'New category slug:');
if (!cat) return;
await api('/bulk.php', { method: 'POST', body: { op, ids, category: cat } });
} else if (op === 'retag') {
const tags = prompt(I18N.dms_prompt_tags || 'Tags (comma-separated):');
if (tags === null) return;
await api('/bulk.php', { method: 'POST', body: { op, ids, tags, mode: 'replace' } });
} else {
if (op === 'trash' && !confirm((I18N.dms_confirm_trash || 'Move {n} document(s) to trash?').replace('{n}', ids.length))) return;
await api('/bulk.php', { method: 'POST', body: { op, ids } });
}
this.state.selected.clear();
await this.load();
this.render(container);
document.dispatchEvent(new CustomEvent('dms:reload-tree'));
} catch (err) {
alert(err.message);
}
},
};
/* ─── Folder picker prompt ─── */
async function pickFolder(title) {
try {
const data = await api('/folders.php?action=list_tree');
const flat = [];
const walk = (nodes, prefix) => {
(nodes || []).forEach(n => {
flat.push({ id: n.id, label: prefix + n.name });
if (n.children) walk(n.children, prefix + n.name + ' / ');
});
};
walk(data.tree || [], '');
return await new Promise(resolve => {
const opts = ['<option value="">' + safe(I18N.dms_unassigned || 'Unassigned') + '</option>']
.concat(flat.map(f => '<option value="' + f.id + '">' + safe(f.label) + '</option>')).join('');
openModal({
title,
body: '<div class="dms-field"><label>' + safe(I18N.dms_destination || 'Destination') + '</label>' +
'<select id="dmsPickFolder">' + opts + '</select></div>',
actions: [
{ label: I18N.dms_cancel || 'Cancel', cls: 'dash-btn', onclick: () => { closeModal(); resolve(undefined); } },
{ label: I18N.dms_move || 'Move', cls: 'dash-btn dash-btn--primary',
onclick: () => {
const v = $('#dmsPickFolder').value;
closeModal();
resolve(v ? Number(v) : null);
} },
],
});
});
} catch (err) {
alert(err.message);
return undefined;
}
}
/* ─── Smart-folder sidebar (saved searches) ─── */
const Smart = {
async load() {
try {
const data = await api('/saved-searches.php?action=list');
const items = data.items || [];
const ul = $('#dmsSmartList');
if (!ul) return;
if (!items.length) {
ul.innerHTML = '<li class="dms-tree__node" style="opacity:.5;font-size:12px;padding-left:14px">' +
safe(I18N.dms_smart_empty || '(none yet)') + '</li>';
return;
}
ul.innerHTML = items.map(s =>
'<li><div class="dms-tree__node" data-smart="' + s.id + '">' +
'<span class="dms-tree__icon">' + (s.icon || '★') + '</span>' +
'<span class="dms-tree__label">' + safe(s.name) + '</span>' +
(s.is_shared ? '<span class="dms-tree__count">∞</span>' : '') +
'</div></li>'
).join('');
ul.addEventListener('click', e => {
const node = e.target.closest('[data-smart]');
if (!node) return;
const item = items.find(i => i.id === Number(node.dataset.smart));
if (!item) return;
document.dispatchEvent(new CustomEvent('dms:apply-smart', { detail: item.query || {} }));
});
} catch (_) { /* ignored */ }
},
async save(query) {
const name = prompt(I18N.dms_smart_name_prompt || 'Name for this smart folder:');
if (!name) return;
try {
await api('/saved-searches.php?action=create', { method: 'POST', body: { name, query } });
await this.load();
} catch (err) {
alert(err.message);
}
},
};
/* ─── Drag-anywhere upload overlay ─── */
function installDropAnywhereUpload(opts) {
opts = opts || {};
const overlay = document.createElement('div');
overlay.className = 'dms-drop-overlay';
overlay.innerHTML = '<span>📥</span><span>' + safe(I18N.dms_drop_here || 'Drop files to upload') + '</span>';
document.body.appendChild(overlay);
let depth = 0;
window.addEventListener('dragenter', e => {
if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('Files')) {
depth++;
overlay.classList.add('is-visible');
}
});
window.addEventListener('dragleave', () => {
depth = Math.max(0, depth - 1);
if (depth === 0) overlay.classList.remove('is-visible');
});
window.addEventListener('dragover', e => {
if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('Files')) e.preventDefault();
});
window.addEventListener('drop', async e => {
depth = 0;
overlay.classList.remove('is-visible');
if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;
e.preventDefault();
const files = Array.from(e.dataTransfer.files);
for (const file of files) {
await uploadOne(file, opts.getFolderId ? opts.getFolderId() : null);
}
document.dispatchEvent(new CustomEvent('dms:reload-required'));
});
}
async function uploadOne(file, folderId, versionAction) {
const fd = new FormData();
fd.append('file', file);
if (folderId && folderId !== 'all') fd.append('folder_id', String(folderId));
if (versionAction) fd.append('version_action', versionAction);
try {
const res = await fetch(API + '/upload.php', {
method: 'POST',
credentials: 'same-origin',
body: fd,
});
const json = await res.json();
if (res.status === 409 && json && json.collision) {
const action = await chooseCollisionAction(file.name);
if (!action) return null;
return uploadOne(file, folderId, action);
}
if (!res.ok) throw new Error(json && (json.message || json.error) || 'Upload failed');
return json;
} catch (err) {
alert(file.name + ': ' + err.message);
return null;
}
}
function chooseCollisionAction(filename) {
return new Promise(resolve => {
openModal({
title: I18N.dms_collision_title || 'Document already exists',
body: '<p>' + safe(
(I18N.dms_collision_body || 'A document with the same title already exists in this folder ({name}). What should we do?').replace('{name}', filename)
) + '</p>',
actions: [
{ label: I18N.dms_cancel || 'Cancel', cls: 'dash-btn', onclick: () => { closeModal(); resolve(null); } },
{ label: I18N.dms_keep_both || 'Keep both', cls: 'dash-btn',
onclick: () => { closeModal(); resolve('force_separate'); } },
{ label: I18N.dms_save_version || 'Save as new version', cls: 'dash-btn dash-btn--primary',
onclick: () => { closeModal(); resolve('new'); } },
],
});
});
}
/* ─── Public surface ─── */
const DMS = {
api, safe, fmtDate, fmtRelative, fmtBytes, fileIcon,
Tree, List, Smart,
openModal, closeModal, openCtxMenu, closeCtxMenu,
folderModal,
pickFolder, installDropAnywhereUpload, uploadOne, chooseCollisionAction,
};
window.DBN_DMS = DMS;
})();
+122
View File
@@ -0,0 +1,122 @@
<?php
/**
* cron/dbn-dms-trash-purge.php
*
* Daily cron that hard-deletes any document/folder soft-deleted more than
* DBN_DMS_TRASH_RETENTION_DAYS ago.
*
* Pass --dry-run for a no-op preview.
*
* Crontab (on chloe, as dobetternorge):
* 15 3 * * * /usr/bin/php /home/dobetternorge/public_html/cron/dbn-dms-trash-purge.php >> /home/dobetternorge/logs/dms-purge.log 2>&1
*/
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit("CLI only.\n");
}
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$dryRun = in_array('--dry-run', $argv ?? [], true);
$cutoff = DBN_DMS_TRASH_RETENTION_DAYS;
try {
dbnToolsBootCaveau();
$db = getDb();
} catch (Throwable $e) {
fwrite(STDERR, "[dbn-dms-purge] DB connect failed: " . $e->getMessage() . "\n");
exit(1);
}
$start = microtime(true);
$purgedDocs = 0;
$purgedFolders = 0;
$skipped = 0;
$errors = 0;
try {
$docs = $db->prepare(
"SELECT id, client_id, storage_path
FROM client_documents
WHERE deleted_at IS NOT NULL AND deleted_at < (NOW() - INTERVAL ? DAY)
LIMIT 5000"
);
$docs->execute([$cutoff]);
foreach ($docs->fetchAll(PDO::FETCH_ASSOC) as $row) {
$docId = (int)$row['id'];
$clientId = (int)$row['client_id'];
$path = $row['storage_path'] ?? null;
if ($dryRun) {
echo "DRY: would purge document {$docId} (client {$clientId})\n";
$skipped++;
continue;
}
try {
// Version-file cleanup
$vr = $db->prepare('SELECT storage_path FROM client_document_versions WHERE document_id = ? AND client_id = ?');
$vr->execute([$docId, $clientId]);
foreach ($vr->fetchAll() as $v) {
if (!empty($v['storage_path']) && is_file($v['storage_path'])) @unlink($v['storage_path']);
}
// Chunks
try {
$db->prepare('DELETE FROM client_chunks WHERE client_id = ? AND document_id = ?')->execute([$clientId, $docId]);
} catch (Throwable $e) { /* tolerated */ }
// Qdrant
try {
if (class_exists('QdrantClient')) {
$qd = new QdrantClient();
$qd->deleteByFilter('bnl_client_chunks', [
'must' => [
['key' => 'client_id', 'match' => ['value' => $clientId]],
['key' => 'document_id', 'match' => ['value' => $docId]],
],
]);
}
} catch (Throwable $e) { /* tolerated */ }
// Disk
if ($path && is_file($path)) @unlink($path);
if ($path) {
$verDir = dirname($path) . '/' . $docId . '_versions';
if (is_dir($verDir)) {
foreach (glob($verDir . '/*') ?: [] as $f) @unlink($f);
@rmdir($verDir);
}
}
// Row
$db->prepare('DELETE FROM client_documents WHERE id = ? AND client_id = ?')->execute([$docId, $clientId]);
$purgedDocs++;
} catch (Throwable $e) {
$errors++;
fwrite(STDERR, "[dbn-dms-purge] doc {$docId}: " . $e->getMessage() . "\n");
}
}
if ($dryRun) {
$foldCount = $db->prepare("SELECT COUNT(*) FROM client_folders WHERE deleted_at IS NOT NULL AND deleted_at < (NOW() - INTERVAL ? DAY)");
$foldCount->execute([$cutoff]);
$skipped += (int)$foldCount->fetchColumn();
echo "DRY: would purge {$skipped} folders\n";
} else {
$fold = $db->prepare("DELETE FROM client_folders WHERE deleted_at IS NOT NULL AND deleted_at < (NOW() - INTERVAL ? DAY)");
$fold->execute([$cutoff]);
$purgedFolders = $fold->rowCount();
}
} catch (Throwable $e) {
fwrite(STDERR, "[dbn-dms-purge] fatal: " . $e->getMessage() . "\n");
exit(1);
}
$elapsed = round(microtime(true) - $start, 2);
$mode = $dryRun ? 'DRY-RUN' : 'live';
printf("[dbn-dms-purge] %s done in %ss: docs=%d folders=%d errors=%d skipped=%d\n",
$mode, $elapsed, $purgedDocs, $purgedFolders, $errors, $skipped);
+44 -1
View File
@@ -11,6 +11,22 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
</div> </div>
<section class="dash-card" style="display:flex; flex-direction:column; min-height:60vh;"> <section class="dash-card" style="display:flex; flex-direction:column; min-height:60vh;">
<div class="dms-filters" style="margin-bottom:8px;">
<label style="font-size:13px;display:inline-flex;gap:6px;align-items:center;">
📁 <span>Scope:</span>
<select id="chatFolderScope">
<option value="all">All folders (whole tenant)</option>
<option value="unassigned">Unassigned only</option>
</select>
</label>
<label style="font-size:13px;display:inline-flex;align-items:center;gap:4px;">
<input type="checkbox" id="chatIncludeSub" checked> Include subfolders
</label>
<label style="font-size:13px;display:inline-flex;align-items:center;gap:4px;">
<input type="checkbox" id="chatIncludeRelated" checked> Show related authorities (graph)
</label>
</div>
<div id="chatLog" class="chat-log" aria-live="polite"> <div id="chatLog" class="chat-log" aria-live="polite">
<div class="chat-empty" id="chatEmptyMsg"></div> <div class="chat-empty" id="chatEmptyMsg"></div>
</div> </div>
@@ -113,10 +129,19 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
let answer = ''; let answer = '';
try { try {
const folderScope = document.getElementById('chatFolderScope');
const includeSub = document.getElementById('chatIncludeSub');
const includeRel = document.getElementById('chatIncludeRelated');
const body = { question, history: history.slice(0, -1) };
if (folderScope && folderScope.value && folderScope.value !== 'all') {
body.folder_id = folderScope.value;
body.include_subfolders = includeSub && includeSub.checked;
}
if (includeRel && includeRel.checked) body.include_related = true;
const resp = await fetch(api + '/chat-stream.php', { const resp = await fetch(api + '/chat-stream.php', {
method: 'POST', credentials: 'same-origin', method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, history: history.slice(0, -1) }), body: JSON.stringify(body),
}); });
if (!resp.ok || !resp.body) throw new Error('HTTP ' + resp.status); if (!resp.ok || !resp.body) throw new Error('HTTP ' + resp.status);
@@ -152,6 +177,7 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
} else if (evName === 'done') { } else if (evName === 'done') {
history.push({ role: 'assistant', content: answer }); history.push({ role: 'assistant', content: answer });
renderSources(sources, payload.sources || []); renderSources(sources, payload.sources || []);
renderRelated(aiNode, payload.related_documents || []);
const chunksTmpl = (I18N.chat_passages_meta || '{n} passages').replace('{n}', payload.chunks_used || 0); const chunksTmpl = (I18N.chat_passages_meta || '{n} passages').replace('{n}', payload.chunks_used || 0);
meta.hidden = false; meta.hidden = false;
meta.textContent = chunksTmpl + ' · ' + (payload.model || 'auto') + ' · ' + (payload.response_time_ms || 0) + ' ms'; meta.textContent = chunksTmpl + ' · ' + (payload.model || 'auto') + ' · ' + (payload.response_time_ms || 0) + ' ms';
@@ -190,6 +216,23 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
}).join(''); }).join('');
} }
function renderRelated(node, related) {
if (!related || !related.length) return;
let rel = node.querySelector('.chat-related');
if (!rel) {
rel = document.createElement('div');
rel.className = 'chat-related';
rel.style.cssText = 'display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.4rem;';
const sources = node.querySelector('.chat-sources');
sources.parentNode.insertBefore(rel, sources.nextSibling);
}
const label = '<small style="opacity:.6;width:100%;display:block;">↳ Related authorities (graph):</small>';
rel.innerHTML = label + related.slice(0, 6).map(r =>
'<a class="chat-source-chip" href="/dashboard/document.php?id=' + r.doc_id + '" style="background:rgba(184,138,44,0.16);color:#6c5212;border-color:rgba(184,138,44,0.4)">'
+ safe(r.title || ('doc #' + r.doc_id)) + ' · ' + safe(r.shared) + '⋆</a>'
).join(' ');
}
function wireActions(node, question, answer) { function wireActions(node, question, answer) {
node.querySelector('.chat-copy').addEventListener('click', () => { node.querySelector('.chat-copy').addEventListener('click', () => {
navigator.clipboard.writeText(answer).then(() => { navigator.clipboard.writeText(answer).then(() => {
+130 -10
View File
@@ -77,15 +77,17 @@ $docId = (int)($_GET['id'] ?? 0);
+ '</div>' + '</div>'
+ '</div>' + '</div>'
+ '<nav class="dash-tabs" role="tablist">' + '<nav class="dash-tabs dms-tabs" role="tablist">'
+ '<button class="dash-tab is-active" data-tab="preview" role="tab">' + (I18N.tab_preview || 'Preview') + '</button>' + '<button class="dash-tab dms-tab is-active" data-tab="preview" role="tab">' + (I18N.tab_preview || 'Preview') + '</button>'
+ '<button class="dash-tab" data-tab="chunks" role="tab">' + (I18N.tab_chunks || 'Passages') + ' (' + fmtNum(doc.chunk_count) + ')</button>' + '<button class="dash-tab dms-tab" data-tab="chunks" role="tab">' + (I18N.tab_chunks || 'Passages') + '<span class="dms-tab__pill">' + fmtNum(doc.chunk_count) + '</span></button>'
+ '<button class="dash-tab" data-tab="related" role="tab">' + (I18N.tab_related || 'Related') + '</button>' + '<button class="dash-tab dms-tab" data-tab="related" role="tab">' + (I18N.tab_related || 'Related') + '</button>'
+ '<button class="dash-tab" data-tab="edit" role="tab">' + (I18N.tab_edit || 'Edit') + '</button>' + '<button class="dash-tab dms-tab" data-tab="versions" role="tab">Versions<span class="dms-tab__pill">v' + (doc.current_version || 1) + '</span></button>'
+ '<button class="dash-tab dms-tab" data-tab="permissions" role="tab">Access</button>'
+ '<button class="dash-tab dms-tab" data-tab="edit" role="tab">' + (I18N.tab_edit || 'Edit') + '</button>'
+ '</nav>' + '</nav>'
+ '<div class="dash-tab-panel is-active" data-panel="preview">' + '<div class="dash-tab-panel dms-tab-panel is-active" data-panel="preview">'
+ '<div class="dash-preview">' + safe(doc.content || (I18N.content_empty || '(empty)')) + '</div>' + renderPreviewPanel(doc)
+ '</div>' + '</div>'
+ '<div class="dash-tab-panel" data-panel="chunks">' + '<div class="dash-tab-panel" data-panel="chunks">'
@@ -97,12 +99,22 @@ $docId = (int)($_GET['id'] ?? 0);
: '<div class="dash-empty">' + (I18N.no_chunks || 'No passages indexed yet.') + '</div>') : '<div class="dash-empty">' + (I18N.no_chunks || 'No passages indexed yet.') + '</div>')
+ '</div>' + '</div>'
+ '<div class="dash-tab-panel" data-panel="related">' + '<div class="dash-tab-panel dms-tab-panel" data-panel="related">'
+ '<div class="dash-loading" id="relatedLoading">' + (I18N.loading_related || 'Loading related authorities from the graph…') + '</div>' + '<div class="dash-loading" id="relatedLoading">' + (I18N.loading_related || 'Loading related authorities from the graph…') + '</div>'
+ '<div class="dash-related" id="relatedList" hidden></div>' + '<div class="dash-related" id="relatedList" hidden></div>'
+ '</div>' + '</div>'
+ '<div class="dash-tab-panel" data-panel="edit">' + '<div class="dash-tab-panel dms-tab-panel" data-panel="versions">'
+ '<div class="dms-loading" id="versionsLoading">Loading versions…</div>'
+ '<div id="versionsList" hidden></div>'
+ '</div>'
+ '<div class="dash-tab-panel dms-tab-panel" data-panel="permissions">'
+ '<div class="dms-loading" id="permLoading">Loading access info…</div>'
+ '<div id="permPanel" hidden></div>'
+ '</div>'
+ '<div class="dash-tab-panel dms-tab-panel" data-panel="edit">'
+ '<form id="docEditForm" style="display:grid; gap:0.85rem; max-width:560px;">' + '<form id="docEditForm" style="display:grid; gap:0.85rem; max-width:560px;">'
+ '<label>' + (I18N.field_title || 'Title') + '<input name="title" value="' + safe(doc.title) + '" style="width:100%;padding:0.5rem;border:1px solid var(--dbn-line);border-radius:8px;"></label>' + '<label>' + (I18N.field_title || 'Title') + '<input name="title" value="' + safe(doc.title) + '" style="width:100%;padding:0.5rem;border:1px solid var(--dbn-line);border-radius:8px;"></label>'
+ '<label>' + (I18N.field_category || 'Category') + '<input name="category" value="' + safe(doc.category || '') + '" style="width:100%;padding:0.5rem;border:1px solid var(--dbn-line);border-radius:8px;"></label>' + '<label>' + (I18N.field_category || 'Category') + '<input name="category" value="' + safe(doc.category || '') + '" style="width:100%;padding:0.5rem;border:1px solid var(--dbn-line);border-radius:8px;"></label>'
@@ -117,11 +129,117 @@ $docId = (int)($_GET['id'] ?? 0);
+ '</section>'; + '</section>';
root.innerHTML = html; root.innerHTML = html;
window._dmsCurrentDoc = doc;
wireTabs(); wireTabs();
wireDelete(); wireDelete();
wireEdit(); wireEdit();
} }
function renderPreviewPanel(doc) {
const ext = (doc.original_filename || '').split('.').pop().toLowerCase();
const previewUrl = api + '/preview.php?id=' + doc.id;
if (doc.has_storage && ext === 'pdf') {
return '<iframe class="dms-preview-frame" src="' + previewUrl + '" title="PDF preview"></iframe>';
}
if (doc.has_storage && ['png','jpg','jpeg','webp','gif'].indexOf(ext) >= 0) {
return '<div style="text-align:center;padding:12px"><img src="' + previewUrl + '" style="max-width:100%;max-height:70vh;border-radius:10px;border:1px solid var(--dms-stroke)"></div>';
}
if (doc.has_storage && ['mp3','wav','m4a','ogg','flac','webm'].indexOf(ext) >= 0) {
return '<audio class="dms-preview-audio" controls src="' + previewUrl + '"></audio>'
+ '<details><summary>Transcript</summary><div class="dash-preview">' + safe(doc.content || '') + '</div></details>';
}
if (doc.has_storage && ext === 'docx') {
return '<div id="docxPreview" class="dms-preview-frame" style="padding:16px;overflow:auto;background:#fff"></div>'
+ '<script src="https://cdn.jsdelivr.net/npm/mammoth@1.6.0/mammoth.browser.min.js"><' + '/script>'
+ '<script>setTimeout(function(){fetch("' + previewUrl + '",{credentials:"same-origin"}).then(r=>r.arrayBuffer()).then(buf=>mammoth.convertToHtml({arrayBuffer:buf})).then(res=>{document.getElementById("docxPreview").innerHTML=res.value;}).catch(e=>{document.getElementById("docxPreview").textContent="Preview failed: "+e.message;});},10);<' + '/script>';
}
// Fallback: text preview from extracted content
return '<div class="dash-preview">' + safe(doc.content || (I18N.content_empty || '(empty)')) + '</div>'
+ (doc.has_storage ? '<p style="margin-top:8px"><a href="' + previewUrl + '&download=1" class="dash-btn">⬇ Download original</a></p>' : '');
}
let versionsLoaded = false;
function loadVersions() {
if (versionsLoaded) return;
versionsLoaded = true;
const wrap = document.getElementById('versionsList');
const loading = document.getElementById('versionsLoading');
fetch(api + '/document-versions.php?action=list&document_id=' + docId, { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
loading.hidden = true; wrap.hidden = false;
const versions = data.versions || [];
const cur = window._dmsCurrentDoc || {};
let html = '<div class="dms-version dms-version--current">'
+ '<div class="dms-version__num">v' + (cur.current_version || 1) + '</div>'
+ '<div><div class="dms-version__title">' + safe(cur.title) + '</div>'
+ '<div class="dms-version__meta">Current · ' + fmtDate(cur.updated_at || cur.created_at) + '</div></div>'
+ '<div class="dms-version__actions"></div></div>';
if (!versions.length) {
html += '<div class="dash-empty">No previous versions.</div>';
} else {
html += versions.map(v =>
'<div class="dms-version">'
+ '<div class="dms-version__num">v' + v.version_number + '</div>'
+ '<div><div class="dms-version__title">' + safe(v.title) + '</div>'
+ '<div class="dms-version__meta">' + fmtDate(v.created_at)
+ (v.uploaded_email ? ' · ' + safe(v.uploaded_email) : '')
+ (v.notes ? ' · ' + safe(v.notes) : '') + '</div></div>'
+ '<div class="dms-version__actions">'
+ '<button class="dash-btn" data-restore="' + v.id + '">Restore</button> '
+ '<button class="dash-btn dash-btn--danger" data-del-ver="' + v.id + '">✕</button>'
+ '</div></div>'
).join('');
}
wrap.innerHTML = html;
wrap.querySelectorAll('[data-restore]').forEach(b => b.addEventListener('click', () => restoreVersion(Number(b.dataset.restore))));
wrap.querySelectorAll('[data-del-ver]').forEach(b => b.addEventListener('click', () => deleteVersion(Number(b.dataset['delVer']))));
}).catch(e => { loading.textContent = 'Error: ' + e.message; });
}
function restoreVersion(vid) {
if (!confirm('Restore this version? Current version will be archived first.')) return;
fetch(api + '/document-versions.php?action=restore', {
method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ document_id: docId, version_id: vid })
}).then(r => r.json()).then(d => {
if (!d.ok) throw new Error(d.message || 'Restore failed');
location.reload();
}).catch(e => alert(e.message));
}
function deleteVersion(vid) {
if (!confirm('Delete this version permanently?')) return;
fetch(api + '/document-versions.php?action=delete', {
method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ version_id: vid })
}).then(r => r.json()).then(d => { versionsLoaded = false; loadVersions(); })
.catch(e => alert(e.message));
}
let permLoaded = false;
function loadPermissions() {
if (permLoaded) return;
permLoaded = true;
const loading = document.getElementById('permLoading');
const wrap = document.getElementById('permPanel');
fetch(api + '/documents.php?action=get&id=' + docId, { credentials: 'same-origin' })
.then(r => r.json()).then(d => {
loading.hidden = true; wrap.hidden = false;
const p = d.permissions || {};
const fid = (d.document && d.document.folder_id) || null;
let html = '<div class="dms-diag"><div class="dms-diag__row"><div class="dms-diag__label">Read</div><div></div><div>' + (p.can_read ? '<span class="dms-diag__status dms-diag__status--ok">allowed</span>' : '<span class="dms-diag__status dms-diag__status--err">denied</span>') + '</div></div>'
+ '<div class="dms-diag__row"><div class="dms-diag__label">Write</div><div></div><div>' + (p.can_write ? '<span class="dms-diag__status dms-diag__status--ok">allowed</span>' : '<span class="dms-diag__status dms-diag__status--warn">read-only</span>') + '</div></div>'
+ '<div class="dms-diag__row"><div class="dms-diag__label">Manage folder</div><div></div><div>' + (p.can_manage ? '<span class="dms-diag__status dms-diag__status--ok">allowed</span>' : '<span class="dms-diag__status dms-diag__status--warn">no</span>') + '</div></div></div>';
if (fid) {
html += '<p style="margin-top:12px"><a class="dash-btn" href="/dashboard/folders.php#' + fid + '">Manage access on parent folder →</a></p>';
} else {
html += '<p style="margin-top:12px;color:rgba(22,19,15,0.6)">This document is unassigned — move it into a folder to use folder ACLs.</p>';
}
wrap.innerHTML = html;
}).catch(e => { loading.textContent = 'Error: ' + e.message; });
}
function wireTabs() { function wireTabs() {
const tabs = root.querySelectorAll('.dash-tab'); const tabs = root.querySelectorAll('.dash-tab');
const panels = root.querySelectorAll('.dash-tab-panel'); const panels = root.querySelectorAll('.dash-tab-panel');
@@ -132,6 +250,8 @@ $docId = (int)($_GET['id'] ?? 0);
const panel = root.querySelector('[data-panel="' + t.dataset.tab + '"]'); const panel = root.querySelector('[data-panel="' + t.dataset.tab + '"]');
if (panel) panel.classList.add('is-active'); if (panel) panel.classList.add('is-active');
if (t.dataset.tab === 'related') loadRelated(); if (t.dataset.tab === 'related') loadRelated();
if (t.dataset.tab === 'versions') loadVersions();
if (t.dataset.tab === 'permissions') loadPermissions();
})); }));
} }
@@ -168,7 +288,7 @@ $docId = (int)($_GET['id'] ?? 0);
const btn = document.getElementById('docDelete'); const btn = document.getElementById('docDelete');
if (!btn) return; if (!btn) return;
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
if (!confirm(I18N.delete_doc_confirm || 'Delete this document permanently?')) return; if (!confirm('Move to trash? You can restore within 30 days.')) return;
btn.disabled = true; btn.disabled = true;
fetch(api + '/documents.php?action=delete', { fetch(api + '/documents.php?action=delete', {
method: 'POST', credentials: 'same-origin', method: 'POST', credentials: 'same-origin',
+177 -172
View File
@@ -6,27 +6,48 @@ $dashboardTitle = dbnToolsT('dash_title_docs', dbnToolsCurrentLanguage());
$dashboardLead = dbnToolsT('dash_lead_docs', dbnToolsCurrentLanguage()); $dashboardLead = dbnToolsT('dash_lead_docs', dbnToolsCurrentLanguage());
require_once __DIR__ . '/../includes/layout_dashboard.php'; require_once __DIR__ . '/../includes/layout_dashboard.php';
?> ?>
<section class="dash-card"> <section class="dms-shell">
<div class="dash-filters"> <aside class="dms-tree" id="dmsTree" aria-label="Folder tree">
<input type="search" id="docFilterQ" placeholder="<?= htmlspecialchars(dbnToolsT('dash_filter_q_ph', $uiLang)) ?>" autocomplete="off"> <div class="dms-loading"></div>
<select id="docFilterStatus"> </aside>
<option value=""><?= htmlspecialchars(dbnToolsT('dash_filter_all_status', $uiLang)) ?></option>
<option value="ready" id="optReady"></option>
<option value="pending" id="optPending"></option>
<option value="processing" id="optProcessing"></option>
<option value="error" id="optError"></option>
</select>
<button id="docBulkDelete" class="dash-btn dash-btn--danger" disabled><?= htmlspecialchars(dbnToolsT('dash_delete_selected', $uiLang)) ?></button>
<a href="/dashboard/upload.php" class="dash-btn dash-btn--primary" style="margin-left: auto;"><?= htmlspecialchars(dbnToolsT('dash_upload_btn_short', $uiLang)) ?></a>
</div>
<div id="docListWrap"><p class="dash-loading"></p></div> <div class="dms-main">
<div class="dms-toolbar">
<div class="dms-toolbar__crumbs" id="dmsCrumbs">
<a href="#" data-folder-id="all">🗂 <?= htmlspecialchars(dbnToolsT('dash_dms_all_files', $uiLang) ?: 'All files') ?></a>
</div>
<div class="dms-toolbar__actions">
<button class="dash-btn" id="dmsSaveSearch" type="button">★ <?= htmlspecialchars(dbnToolsT('dash_dms_save_view', $uiLang) ?: 'Save view') ?></button>
<a href="/dashboard/upload.php" class="dash-btn dash-btn--primary">+ <?= htmlspecialchars(dbnToolsT('dash_upload_btn_short', $uiLang)) ?></a>
</div>
</div>
<div class="dash-pager" id="docPager" hidden> <div class="dms-filters">
<span id="docPagerLabel"></span> <input type="search" id="dmsFilterQ" placeholder="<?= htmlspecialchars(dbnToolsT('dash_filter_q_ph', $uiLang)) ?>" autocomplete="off">
<div class="dash-pager__actions"> <select id="dmsFilterStatus">
<button class="dash-btn" id="docPagerPrev"><?= htmlspecialchars(dbnToolsT('dash_prev', $uiLang)) ?></button> <option value=""><?= htmlspecialchars(dbnToolsT('dash_filter_all_status', $uiLang)) ?></option>
<button class="dash-btn" id="docPagerNext"><?= htmlspecialchars(dbnToolsT('dash_next', $uiLang)) ?></button> <option value="ready">Ready</option>
<option value="pending">Pending</option>
<option value="processing">Processing</option>
<option value="error">Error</option>
</select>
<select id="dmsFilterCategory">
<option value=""><?= htmlspecialchars(dbnToolsT('dash_dms_all_categories', $uiLang) ?: 'All categories') ?></option>
</select>
<label style="font-size:13px;display:inline-flex;align-items:center;gap:4px;">
<input type="checkbox" id="dmsIncludeSub" checked>
<?= htmlspecialchars(dbnToolsT('dash_dms_include_sub', $uiLang) ?: 'Include subfolders') ?>
</label>
</div>
<div id="dmsListWrap"><div class="dms-loading"></div></div>
<div class="dash-pager" id="dmsPager" hidden>
<span id="dmsPagerLabel"></span>
<div class="dash-pager__actions">
<button class="dash-btn" id="dmsPagerPrev"><?= htmlspecialchars(dbnToolsT('dash_prev', $uiLang)) ?></button>
<button class="dash-btn" id="dmsPagerNext"><?= htmlspecialchars(dbnToolsT('dash_next', $uiLang)) ?></button>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -34,177 +55,161 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
const I18N = window.DBN_I18N || {}; if (!window.DBN_DMS) { console.error('dms.js missing'); return; }
const api = window.DBN_DASHBOARD.apiBase; const DMS = window.DBN_DMS;
const loc = I18N.locale || 'en-GB';
const PAGE = 25;
const optReady = document.getElementById('optReady'); const $tree = document.getElementById('dmsTree');
const optPend = document.getElementById('optPending'); const $list = document.getElementById('dmsListWrap');
const optProc = document.getElementById('optProcessing'); const $crumbs = document.getElementById('dmsCrumbs');
const optErr = document.getElementById('optError'); const $pager = document.getElementById('dmsPager');
if (optReady) optReady.textContent = I18N.status_ready || 'Ready'; const $pl = document.getElementById('dmsPagerLabel');
if (optPend) optPend.textContent = I18N.status_pending || 'Pending'; const $prev = document.getElementById('dmsPagerPrev');
if (optProc) optProc.textContent = I18N.status_processing || 'Processing'; const $next = document.getElementById('dmsPagerNext');
if (optErr) optErr.textContent = I18N.status_error || 'Error'; const $fq = document.getElementById('dmsFilterQ');
const $fs = document.getElementById('dmsFilterStatus');
const $fc = document.getElementById('dmsFilterCategory');
const $sub = document.getElementById('dmsIncludeSub');
const $save = document.getElementById('dmsSaveSearch');
const state = { offset: 0, total: 0, selected: new Set(), q: '', status: '' }; let debounce;
const $wrap = document.getElementById('docListWrap'); async function loadAll() {
const $pager = document.getElementById('docPager'); $list.innerHTML = '<div class="dms-loading"></div>';
const $pl = document.getElementById('docPagerLabel'); try {
const $prev = document.getElementById('docPagerPrev'); await DMS.Tree.load();
const $next = document.getElementById('docPagerNext'); DMS.Tree.render($tree, { activeFolderId: DMS.List.state.folderId });
const $bulk = document.getElementById('docBulkDelete'); await DMS.Smart.load();
const $fq = document.getElementById('docFilterQ'); await refreshCategories();
const $fs = document.getElementById('docFilterStatus'); await refreshList();
} catch (e) {
function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c])); } $list.innerHTML = '<div class="dms-list__empty"><strong>Error</strong><span>' + DMS.safe(e.message) + '</span></div>';
function fmtDate(s) {
if (!s) return '—';
try { return new Date(s.replace(' ', 'T') + 'Z').toLocaleDateString(loc, { day:'numeric', month:'short', year:'numeric' }); }
catch (_) { return s; }
}
function fmtNum(n) { return n == null ? '—' : Number(n).toLocaleString(loc); }
function statusPill(status) {
const cls = { ready:'dash-status--ready', pending:'dash-status--pending', processing:'dash-status--processing', error:'dash-status--error' }[status] || 'dash-status--pending';
const lbl = {
ready: I18N.status_ready || 'Ready', pending: I18N.status_pending || 'Pending',
processing: I18N.status_processing || 'Processing', error: I18N.status_error || 'Error',
}[status] || status;
return '<span class="dash-status ' + cls + '">' + lbl + '</span>';
}
function load() {
const qs = new URLSearchParams({ action:'list', offset:String(state.offset), limit:String(PAGE) });
if (state.q) qs.set('q', state.q);
if (state.status) qs.set('status', state.status);
$wrap.innerHTML = '<p class="dash-loading">' + (I18N.loading_docs || 'Loading documents…') + '</p>';
fetch(api + '/documents.php?' + qs, { credentials:'same-origin' })
.then(r => r.json())
.then(data => {
if (!data.ok) throw new Error(data.error?.message || 'Failed');
state.total = data.total;
render(data.documents || []);
})
.catch(err => {
$wrap.innerHTML = '<div class="dash-error">' + safe(err.message) + '</div>';
});
}
function render(docs) {
if (!docs.length) {
$wrap.innerHTML = '<div class="dash-empty"><span class="dash-empty__icon">📭</span>'
+ (state.q || state.status
? (I18N.empty_filter || 'No results for selected filter.')
: (I18N.empty_docs || 'No documents yet.') + ' <a href="/dashboard/upload.php">' + (I18N.empty_docs_link || 'Upload your first') + '</a>.')
+ '</div>';
$pager.hidden = true;
return;
} }
}
const table = document.createElement('table'); async function refreshCategories() {
table.className = 'dash-doctable'; try {
table.innerHTML = const data = await DMS.api('/categories.php?action=list');
'<thead><tr>' const opts = ['<option value="">All categories</option>'].concat(
+ '<th style="width:36px;"><input type="checkbox" id="docSelectAll"></th>' (data.categories || []).map(c => '<option value="' + DMS.safe(c.slug) + '">' + DMS.safe(c.label) + ' (' + c.doc_count + ')</option>')
+ '<th>' + (I18N.th_title || 'Title') + '</th>' );
+ '<th>' + (I18N.th_category || 'Category') + '</th>' const prev = $fc.value;
+ '<th>' + (I18N.th_status || 'Status') + '</th>' $fc.innerHTML = opts.join('');
+ '<th>' + (I18N.th_chunks || 'Passages') + '</th>' $fc.value = prev;
+ '<th>' + (I18N.th_added || 'Added') + '</th>' } catch (_) { /* ignored */ }
+ '</tr></thead>'; }
const tbody = document.createElement('tbody');
docs.forEach(doc => { async function refreshList() {
const tr = document.createElement('tr'); try {
tr.dataset.id = String(doc.id); const data = await DMS.List.load();
tr.innerHTML = DMS.List.render($list);
'<td><input type="checkbox" class="doc-check" value="' + doc.id + '"' + (state.selected.has(doc.id) ? ' checked' : '') + '></td>' updateCrumbs(data.folder);
+ '<td><span class="dash-doctable__title">' + safe(doc.title) + '</span>' updatePager(data);
+ (doc.source_tool ? '<div class="dash-doctable__meta">via ' + safe(doc.source_tool) + (doc.tags ? ' · ' + safe(doc.tags) : '') + '</div>' : (doc.tags ? '<div class="dash-doctable__meta">' + safe(doc.tags) + '</div>' : '')) } catch (e) {
+ '</td>' $list.innerHTML = '<div class="dms-list__empty"><strong>Error</strong><span>' + DMS.safe(e.message) + '</span></div>';
+ '<td>' + safe(doc.category || '—') + '</td>' }
+ '<td>' + statusPill(doc.status) + '</td>' }
+ '<td>' + fmtNum(doc.chunk_count) + '</td>'
+ '<td>' + fmtDate(doc.created_at) + '</td>'; function updateCrumbs(folder) {
tr.addEventListener('click', (e) => { let html = '<a href="#" data-folder-id="all">🗂 All files</a>';
if (e.target.matches('input[type="checkbox"]')) return; if (folder && folder.breadcrumb) {
location.href = '/dashboard/document.php?id=' + doc.id; folder.breadcrumb.forEach((b, i) => {
const isLast = i === folder.breadcrumb.length - 1;
html += '<span class="dms-toolbar__crumb-sep"></span>';
if (isLast) html += '<span class="dms-toolbar__crumb--current">' + DMS.safe(b.name) + '</span>';
else html += '<a href="#" data-folder-id="' + b.id + '">' + DMS.safe(b.name) + '</a>';
}); });
tbody.appendChild(tr); } else if (DMS.List.state.folderId === 'unassigned') {
}); html += '<span class="dms-toolbar__crumb-sep"></span><span class="dms-toolbar__crumb--current">Unassigned</span>';
table.appendChild(tbody); }
$wrap.innerHTML = ''; $crumbs.innerHTML = html;
$wrap.appendChild(table); }
const all = document.getElementById('docSelectAll'); function updatePager(data) {
all.addEventListener('change', () => { const total = data.total || 0;
tbody.querySelectorAll('.doc-check').forEach(c => { const limit = DMS.List.state.limit;
c.checked = all.checked; const offset = DMS.List.state.offset;
const id = parseInt(c.value, 10); if (total <= limit) { $pager.hidden = true; return; }
if (all.checked) state.selected.add(id); else state.selected.delete(id);
});
updateBulk();
});
tbody.querySelectorAll('.doc-check').forEach(c => {
c.addEventListener('change', (e) => {
const id = parseInt(e.target.value, 10);
if (e.target.checked) state.selected.add(id); else state.selected.delete(id);
updateBulk();
});
});
const from = state.offset + 1;
const to = Math.min(state.offset + docs.length, state.total);
const tmpl = I18N.pager_showing || 'Showing {from}{to} of {total}';
$pl.textContent = tmpl.replace('{from}', from).replace('{to}', to).replace('{total}', state.total);
$prev.disabled = state.offset === 0;
$next.disabled = state.offset + PAGE >= state.total;
$pager.hidden = false; $pager.hidden = false;
$pl.textContent = (offset + 1) + '' + Math.min(total, offset + limit) + ' / ' + total;
$prev.disabled = offset === 0;
$next.disabled = offset + limit >= total;
} }
function updateBulk() { document.addEventListener('dms:folder-changed', e => {
$bulk.disabled = state.selected.size === 0; const id = e.detail.folderId;
$bulk.textContent = state.selected.size > 0 if (id === 'trash') { window.location.href = '/dashboard/trash.php'; return; }
? (I18N.delete_n_selected || 'Delete selected ({n})').replace('{n}', state.selected.size) DMS.List.state.folderId = id;
: (I18N.delete_selected || 'Delete selected'); DMS.List.state.offset = 0;
} DMS.List.state.selected.clear();
DMS.Tree.state.activeFolderId = id;
DMS.Tree.render($tree, { activeFolderId: id });
refreshList();
});
document.addEventListener('dms:reload-required', () => {
DMS.Tree.load().then(() => DMS.Tree.render($tree, { activeFolderId: DMS.List.state.folderId }));
refreshList();
});
document.addEventListener('dms:reload-tree', () => {
DMS.Tree.load().then(() => DMS.Tree.render($tree, { activeFolderId: DMS.List.state.folderId }));
});
document.addEventListener('dms:apply-smart', e => {
const q = e.detail || {};
if ('q' in q) $fq.value = q.q || '';
if ('status' in q) $fs.value = q.status || '';
if ('category' in q) $fc.value = q.category || '';
if ('folder_id' in q) DMS.List.state.folderId = String(q.folder_id == null ? 'all' : q.folder_id);
if ('include_subfolders' in q) $sub.checked = !!q.include_subfolders;
DMS.List.state.q = $fq.value;
DMS.List.state.status = $fs.value;
DMS.List.state.category = $fc.value;
DMS.List.state.includeSubfolders = $sub.checked;
DMS.List.state.offset = 0;
refreshList();
});
$prev.addEventListener('click', () => { state.offset = Math.max(0, state.offset - PAGE); load(); }); $crumbs.addEventListener('click', e => {
$next.addEventListener('click', () => { state.offset += PAGE; load(); }); const a = e.target.closest('a[data-folder-id]');
if (!a) return;
e.preventDefault();
DMS.List.state.folderId = a.dataset.folderId;
DMS.List.state.offset = 0;
DMS.Tree.state.activeFolderId = a.dataset.folderId;
DMS.Tree.render($tree, { activeFolderId: a.dataset.folderId });
refreshList();
});
let filterTimer = null;
$fq.addEventListener('input', () => { $fq.addEventListener('input', () => {
clearTimeout(filterTimer); clearTimeout(debounce);
filterTimer = setTimeout(() => { state.q = $fq.value.trim(); state.offset = 0; load(); }, 250); debounce = setTimeout(() => {
DMS.List.state.q = $fq.value.trim();
DMS.List.state.offset = 0;
refreshList();
}, 200);
}); });
$fs.addEventListener('change', () => { state.status = $fs.value; state.offset = 0; load(); }); $fs.addEventListener('change', () => { DMS.List.state.status = $fs.value; DMS.List.state.offset = 0; refreshList(); });
$fc.addEventListener('change', () => { DMS.List.state.category = $fc.value; DMS.List.state.offset = 0; refreshList(); });
$sub.addEventListener('change', () => { DMS.List.state.includeSubfolders = $sub.checked; refreshList(); });
$bulk.addEventListener('click', () => { $prev.addEventListener('click', () => { DMS.List.state.offset = Math.max(0, DMS.List.state.offset - DMS.List.state.limit); refreshList(); });
if (!state.selected.size) return; $next.addEventListener('click', () => { DMS.List.state.offset += DMS.List.state.limit; refreshList(); });
const msg = (I18N.delete_docs_confirm || 'Delete {n} documents? This cannot be undone.').replace('{n}', state.selected.size);
if (!confirm(msg)) return; $save.addEventListener('click', () => {
const ids = Array.from(state.selected); DMS.Smart.save({
$bulk.disabled = true; q: $fq.value, status: $fs.value, category: $fc.value,
fetch(api + '/documents.php?action=delete', { folder_id: DMS.List.state.folderId,
method: 'POST', include_subfolders: $sub.checked,
credentials: 'same-origin', });
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
})
.then(r => r.json())
.then(data => {
if (!data.ok) throw new Error(data.error?.message || 'Delete failed');
state.selected.clear();
updateBulk();
load();
})
.catch(err => alert(err.message));
}); });
load(); DMS.installDropAnywhereUpload({ getFolderId: () => {
const id = DMS.List.state.folderId;
return (id && id !== 'all' && id !== 'unassigned' && id !== 'trash') ? id : null;
}});
const params = new URLSearchParams(location.search);
if (params.get('folder')) DMS.List.state.folderId = params.get('folder');
loadAll();
})(); })();
</script> </script>
+164
View File
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
$dashboardPage = 'folders';
$dashboardTitle = dbnToolsT('dash_title_folders', dbnToolsCurrentLanguage()) ?: 'Folders';
$dashboardLead = dbnToolsT('dash_lead_folders', dbnToolsCurrentLanguage()) ?: 'Organise documents and set per-folder access.';
require_once __DIR__ . '/../includes/layout_dashboard.php';
?>
<section class="dms-shell">
<aside class="dms-tree" id="dmsTree" aria-label="Folder tree">
<div class="dms-loading"></div>
</aside>
<div class="dms-main">
<div class="dms-toolbar">
<div class="dms-toolbar__crumbs" id="folderCrumbs"><span>Pick a folder on the left</span></div>
<div class="dms-toolbar__actions">
<button class="dash-btn dash-btn--primary" id="folderNewBtn" type="button">+ New folder</button>
</div>
</div>
<div id="folderDetail">
<div class="dms-list__empty">
<strong>Select a folder</strong>
<span>Use the tree on the left, or create a new folder.</span>
</div>
</div>
</div>
</section>
<script>
(function () {
'use strict';
const DMS = window.DBN_DMS;
const $tree = document.getElementById('dmsTree');
const $detail = document.getElementById('folderDetail');
const $crumbs = document.getElementById('folderCrumbs');
let selectedFolderId = null;
async function loadTree() {
await DMS.Tree.load();
DMS.Tree.render($tree, { activeFolderId: selectedFolderId });
}
async function showDetail(folderId) {
if (folderId === 'all' || folderId === 'unassigned' || folderId === 'trash') {
$detail.innerHTML = '<div class="dms-list__empty"><strong>System folder</strong><span>Cannot configure access for system folders.</span></div>';
return;
}
selectedFolderId = folderId;
$detail.innerHTML = '<div class="dms-loading"></div>';
try {
const [perms, crumbsData] = await Promise.all([
DMS.api('/folders.php?action=list_permissions&folder_id=' + folderId),
DMS.api('/folders.php?action=get_breadcrumb&folder_id=' + folderId),
]);
$crumbs.innerHTML = (crumbsData.breadcrumb || []).map((b, i, a) =>
(i ? '<span class="dms-toolbar__crumb-sep"></span>' : '')
+ (i === a.length - 1
? '<span class="dms-toolbar__crumb--current">' + DMS.safe(b.name) + '</span>'
: '<span>' + DMS.safe(b.name) + '</span>')
).join('');
const rows = (perms.permissions || []).map(p => {
const who = p.user_id
? '👤 ' + DMS.safe(p.user_email || ('user #' + p.user_id))
: '🛡 role ≥ ' + DMS.safe(p.min_role);
return '<div class="dms-perm-row">' +
'<div>' + who + '</div>' +
'<div>' + (p.can_read ? '✓' : '—') + ' read</div>' +
'<div>' + (p.can_write ? '✓' : '—') + ' write</div>' +
'<div>' + (p.can_manage ? '✓' : '—') + ' manage</div>' +
'<div><button class="dms-list__more" data-pid="' + p.id + '" type="button" aria-label="Remove">✕</button></div>' +
'</div>';
}).join('');
$detail.innerHTML =
'<div class="dms-toolbar"><div class="dms-toolbar__crumbs"><strong>Access control</strong></div>' +
'<div class="dms-toolbar__actions">' +
'<button class="dash-btn" type="button" id="folderRename">Rename</button>' +
'<button class="dash-btn dash-btn--danger" type="button" id="folderDelete">Delete</button>' +
'<button class="dash-btn dash-btn--primary" type="button" id="folderAddAcl">+ Add rule</button>' +
'</div></div>' +
'<div class="dms-list">' +
(rows ||
'<div class="dms-list__empty"><strong>Open to all in tenant</strong>' +
'<span>No rules set — add one to restrict access.</span></div>') +
(rows ? '' : '') +
'</div>';
$detail.querySelector('#folderRename').addEventListener('click', () => {
DMS.folderModal.open({ folderId, name: crumbsData.breadcrumb.slice(-1)[0]?.name || '' });
});
$detail.querySelector('#folderDelete').addEventListener('click', async () => {
if (!confirm('Soft-delete this folder? Documents inside will move to Trash.')) return;
try {
await DMS.api('/folders.php?action=delete', { method: 'POST', body: { folder_id: folderId } });
selectedFolderId = null;
$detail.innerHTML = '<div class="dms-list__empty"><strong>Deleted</strong></div>';
await loadTree();
} catch (e) { alert(e.message); }
});
$detail.querySelector('#folderAddAcl').addEventListener('click', () => openAclModal(folderId));
$detail.querySelectorAll('[data-pid]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Remove this access rule?')) return;
try {
await DMS.api('/folders.php?action=remove_permission', { method: 'POST', body: { permission_id: Number(btn.dataset.pid) } });
showDetail(folderId);
} catch (e) { alert(e.message); }
});
});
} catch (e) {
$detail.innerHTML = '<div class="dms-list__empty"><strong>Error</strong><span>' + DMS.safe(e.message) + '</span></div>';
}
}
function openAclModal(folderId) {
DMS.openModal({
title: 'Add access rule',
body:
'<div class="dms-field"><label>Grantee type</label>' +
'<select id="aclType"><option value="role">Role-based</option><option value="user">Specific user (by id)</option></select></div>' +
'<div class="dms-field" id="aclRoleField"><label>Minimum role</label>' +
'<select id="aclRole"><option>viewer</option><option>editor</option><option>admin</option><option>owner</option></select></div>' +
'<div class="dms-field" id="aclUserField" style="display:none"><label>User ID</label><input type="number" id="aclUser"></div>' +
'<div class="dms-field dms-field--inline"><label>Permissions</label>' +
'<label><input type="checkbox" id="aclRead" checked> read</label>' +
'<label><input type="checkbox" id="aclWrite"> write</label>' +
'<label><input type="checkbox" id="aclManage"> manage</label></div>',
actions: [
{ label: 'Cancel', cls: 'dash-btn', onclick: DMS.closeModal },
{ label: 'Add', cls: 'dash-btn dash-btn--primary', onclick: async () => {
const type = document.getElementById('aclType').value;
const body = { folder_id: folderId,
can_read: document.getElementById('aclRead').checked,
can_write: document.getElementById('aclWrite').checked,
can_manage: document.getElementById('aclManage').checked,
};
if (type === 'role') body.min_role = document.getElementById('aclRole').value;
else body.user_id = Number(document.getElementById('aclUser').value || 0);
try {
await DMS.api('/folders.php?action=set_permission', { method: 'POST', body });
DMS.closeModal();
showDetail(folderId);
} catch (e) { alert(e.message); }
}},
],
});
document.getElementById('aclType').addEventListener('change', e => {
document.getElementById('aclRoleField').style.display = e.target.value === 'role' ? '' : 'none';
document.getElementById('aclUserField').style.display = e.target.value === 'user' ? '' : 'none';
});
}
document.addEventListener('dms:folder-changed', e => showDetail(e.detail.folderId));
document.addEventListener('dms:reload-required', loadTree);
document.addEventListener('dms:reload-tree', loadTree);
document.getElementById('folderNewBtn').addEventListener('click', () => DMS.folderModal.open({}));
loadTree();
})();
</script>
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
+60
View File
@@ -40,6 +40,13 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
</div> </div>
</section> </section>
<section class="dms-kpis" id="dmsExtraKpis" aria-label="DMS overview">
<div class="dms-kpi"><p class="dms-kpi__label">Storage used</p><p class="dms-kpi__value" id="dmsStorage">—</p><p class="dms-kpi__hint">across all documents</p></div>
<div class="dms-kpi"><p class="dms-kpi__label">Folders</p><p class="dms-kpi__value" id="dmsFolders">—</p><p class="dms-kpi__hint">organising your library</p></div>
<div class="dms-kpi"><p class="dms-kpi__label">In trash</p><p class="dms-kpi__value" id="dmsTrash">—</p><p class="dms-kpi__hint">auto-purges after 30d</p></div>
<div class="dms-kpi"><p class="dms-kpi__label">Smart folders</p><p class="dms-kpi__value" id="dmsSmart">—</p><p class="dms-kpi__hint">saved views</p></div>
</section>
<section class="dash-card"> <section class="dash-card">
<div class="dash-card__head"> <div class="dash-card__head">
<h2 id="recentTitle"></h2> <h2 id="recentTitle"></h2>
@@ -50,6 +57,11 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
<div id="dashRecent" class="dash-loading"></div> <div id="dashRecent" class="dash-loading"></div>
</section> </section>
<section class="dash-card">
<div class="dash-card__head"><h2>Recent activity</h2></div>
<div id="dmsActivity" class="dms-activity"><div class="dms-loading"></div></div>
</section>
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
@@ -148,6 +160,54 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
recent.className = 'dash-error'; recent.className = 'dash-error';
recent.textContent = (I18N.error_loading || 'Could not load: ') + err.message; recent.textContent = (I18N.error_loading || 'Could not load: ') + err.message;
}); });
// DMS overview tiles
(async function loadDmsKpis() {
try {
const tree = await fetch(api + '/folders.php?action=list_tree', { credentials: 'same-origin' }).then(r => r.json());
const allDocs = await fetch(api + '/documents.php?action=list&limit=500', { credentials: 'same-origin' }).then(r => r.json());
const storage = (allDocs.documents || []).reduce((s, d) => s + (d.file_size_bytes || 0), 0);
document.getElementById('dmsStorage').textContent =
storage < 1024*1024 ? Math.round(storage/1024) + ' KB'
: storage < 1024*1024*1024 ? (storage/1024/1024).toFixed(1) + ' MB'
: (storage/1024/1024/1024).toFixed(2) + ' GB';
const countFolders = (nodes) => (nodes || []).reduce((n, x) => n + 1 + countFolders(x.children), 0);
document.getElementById('dmsFolders').textContent = countFolders(tree.tree || []);
document.getElementById('dmsTrash').textContent = tree.trash_count || 0;
} catch (_) { /* ignored */ }
try {
const ss = await fetch(api + '/saved-searches.php?action=list', { credentials: 'same-origin' }).then(r => r.json());
document.getElementById('dmsSmart').textContent = (ss.items || []).length;
} catch (_) { /* ignored */ }
})();
// Activity feed — gracefully degrades if endpoint absent
(async function loadActivity() {
const wrap = document.getElementById('dmsActivity');
try {
// We don't have a dedicated activity endpoint; fall back to /documents?action=list sorted by updated.
const data = await fetch(api + '/documents.php?action=list&limit=15&sort=updated_at&dir=desc', { credentials: 'same-origin' }).then(r => r.json());
const items = data.documents || [];
if (!items.length) {
wrap.innerHTML = '<div class="dms-list__empty"><strong>No activity yet</strong><span>Upload a document to get started.</span></div>';
return;
}
const safe = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
const rel = s => { if (!s) return '—'; const d = new Date(s.replace(' ', 'T') + 'Z'); const diff = (Date.now()-d)/1000;
if (diff<60) return 'just now'; if (diff<3600) return Math.floor(diff/60)+'m'; if (diff<86400) return Math.floor(diff/3600)+'h';
if (diff<86400*7) return Math.floor(diff/86400)+'d'; return d.toLocaleDateString(loc); };
const iconForStatus = { ready:'✓', pending:'⏳', processing:'⚙', error:'⚠' };
wrap.innerHTML = items.slice(0, 15).map(d =>
'<div class="dms-activity__row">' +
'<span class="dms-activity__icon">' + (iconForStatus[d.status] || '📄') + '</span>' +
'<div><a href="/dashboard/document.php?id=' + d.id + '">' + safe(d.title) + '</a>' +
(d.category ? ' <span class="dms-chip dms-chip--cat">' + safe(d.category) + '</span>' : '') + '</div>' +
'<span class="dms-activity__time">' + rel(d.updated_at || d.created_at) + '</span></div>'
).join('');
} catch (e) {
wrap.innerHTML = '<div class="dms-list__empty"><strong>Could not load</strong><span>' + e.message + '</span></div>';
}
})();
})(); })();
</script> </script>
+32 -1
View File
@@ -34,10 +34,20 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
<dt style="color:rgba(22,19,15,0.55);">Search method</dt> <dt style="color:rgba(22,19,15,0.55);">Search method</dt>
<dd>Hybrid (vector + keyword), reciprocal rank fusion, private boost 1.5×</dd> <dd>Hybrid (vector + keyword), reciprocal rank fusion, private boost 1.5×</dd>
<dt style="color:rgba(22,19,15,0.55);">Graph DB</dt> <dt style="color:rgba(22,19,15,0.55);">Graph DB</dt>
<dd><code>bnl_legal</code> in FalkorDB (Colin) — citation edges</dd> <dd><code>dbn_client_graph</code> in FalkorDB (Colin) — citation edges</dd>
</dl> </dl>
</section> </section>
<section class="dash-card">
<div class="dash-card__head">
<h2>Live diagnostics</h2>
<div class="dash-card__actions">
<button class="dash-btn" type="button" id="diagRefresh">↻ Refresh</button>
</div>
</div>
<div id="diagPanel" class="dms-diag"><div class="dms-loading"></div></div>
</section>
<section class="dash-card"> <section class="dash-card">
<div class="dash-card__head"> <div class="dash-card__head">
<h2><?= htmlspecialchars(dbnToolsT('dash_section_privacy', $uiLang)) ?></h2> <h2><?= htmlspecialchars(dbnToolsT('dash_section_privacy', $uiLang)) ?></h2>
@@ -59,6 +69,27 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
document.getElementById('setClientId').textContent = d.clientId || '—'; document.getElementById('setClientId').textContent = d.clientId || '—';
document.getElementById('setCorpusId').textContent = d.corpusId || '—'; document.getElementById('setCorpusId').textContent = d.corpusId || '—';
document.getElementById('setUserId').textContent = d.clientUserId || '—'; document.getElementById('setUserId').textContent = d.clientUserId || '—';
const $diag = document.getElementById('diagPanel');
async function loadDiag() {
$diag.innerHTML = '<div class="dms-loading"></div>';
try {
const r = await fetch((d.apiBase || '/api/dashboard') + '/diagnostics.php', { credentials:'same-origin' });
const data = await r.json();
if (!data.ok) throw new Error(data.message || 'Diagnostics unavailable');
const safe = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
$diag.innerHTML = data.sections.map(s =>
'<div class="dms-diag__row" title="' + safe(s.detail || '') + '">' +
'<div class="dms-diag__label">' + safe(s.label) + '</div>' +
'<div class="dms-diag__value">' + safe(s.value) + '</div>' +
'<div><span class="dms-diag__status dms-diag__status--' + safe(s.status || 'warn') + '">' + safe(s.status) + '</span></div></div>'
).join('');
} catch (e) {
$diag.innerHTML = '<div class="dms-list__empty"><strong>Could not run diagnostics</strong><span>' + e.message + '</span></div>';
}
}
document.getElementById('diagRefresh').addEventListener('click', loadDiag);
loadDiag();
})(); })();
</script> </script>
+129
View File
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
$dashboardPage = 'trash';
$dashboardTitle = dbnToolsT('dash_title_trash', dbnToolsCurrentLanguage()) ?: 'Trash';
$dashboardLead = dbnToolsT('dash_lead_trash', dbnToolsCurrentLanguage()) ?: 'Items here are auto-deleted after 30 days.';
require_once __DIR__ . '/../includes/layout_dashboard.php';
?>
<section class="dms-shell dms-shell--single">
<div class="dms-main">
<div class="dms-toolbar">
<div class="dms-toolbar__crumbs"><span>🗑 Trash</span></div>
<div class="dms-toolbar__actions">
<button class="dash-btn" type="button" id="trashRestoreSel" disabled>Restore selected</button>
<button class="dash-btn dash-btn--danger" type="button" id="trashPurgeAll">Empty trash (admin)</button>
</div>
</div>
<div id="trashList"><div class="dms-loading"></div></div>
</div>
</section>
<script>
(function () {
'use strict';
const DMS = window.DBN_DMS;
const $list = document.getElementById('trashList');
const $restore = document.getElementById('trashRestoreSel');
const $purge = document.getElementById('trashPurgeAll');
const selected = new Set();
async function load() {
$list.innerHTML = '<div class="dms-loading"></div>';
try {
const data = await DMS.api('/trash.php?action=list&limit=200');
const items = data.items || [];
if (!items.length) {
$list.innerHTML = '<div class="dms-list"><div class="dms-list__empty"><strong>Trash is empty</strong><span>Nothing to restore.</span></div></div>';
return;
}
const rows = items.map(it => {
const id = (it.kind === 'document' ? 'd' : 'f') + it.id;
const expires = (it.expires_in_days ?? 0) + 'd left';
return '<div class="dms-list__row" data-key="' + id + '" data-kind="' + it.kind + '" data-id="' + it.id + '">' +
'<input type="checkbox" class="dms-trash-sel">' +
'<div class="dms-list__title">' +
'<span class="dms-list__title-icon">' + (it.kind === 'folder' ? '📁' : DMS.fileIcon(it.source_type)) + '</span>' +
DMS.safe(it.title || it.name || '(untitled)') +
'</div>' +
'<div class="dms-list__cell dms-list__cell--muted">' + DMS.safe(it.kind) + '</div>' +
'<div class="dms-list__cell dms-list__cell--muted">' + DMS.fmtRelative(it.deleted_at) + '</div>' +
'<div class="dms-list__cell dms-list__cell--muted">' + expires + '</div>' +
'<div class="dms-list__cell"></div>' +
'<button class="dms-list__more" data-id="' + it.id + '" data-kind="' + it.kind + '" type="button">⋯</button>' +
'</div>';
}).join('');
$list.innerHTML = '<div class="dms-list">' +
'<div class="dms-list__head"><span></span><span>Title</span><span>Kind</span><span>Trashed</span><span>Expires</span><span></span><span></span></div>' +
rows + '</div>';
$list.querySelectorAll('.dms-trash-sel').forEach(cb => {
cb.addEventListener('change', e => {
const row = e.target.closest('.dms-list__row');
if (e.target.checked) selected.add(row.dataset.key); else selected.delete(row.dataset.key);
$restore.disabled = selected.size === 0;
});
});
$list.querySelectorAll('.dms-list__more').forEach(btn => {
btn.addEventListener('click', e => {
e.preventDefault();
const id = Number(btn.dataset.id);
const kind = btn.dataset.kind;
DMS.openCtxMenu(btn.getBoundingClientRect().left, btn.getBoundingClientRect().bottom, [
{ label: 'Restore', icon: '↩', onclick: () => restoreItems([{kind, id}]) },
'-',
{ label: 'Delete permanently', icon: '🔥', danger: true,
onclick: () => purgeItems([{kind, id}]) },
]);
});
});
} catch (e) {
$list.innerHTML = '<div class="dms-list__empty"><strong>Error</strong><span>' + DMS.safe(e.message) + '</span></div>';
}
}
async function restoreItems(items) {
const body = { document_ids: [], folder_ids: [] };
items.forEach(i => {
if (i.kind === 'folder') body.folder_ids.push(i.id);
else body.document_ids.push(i.id);
});
try {
await DMS.api('/trash.php?action=restore', { method: 'POST', body });
selected.clear();
$restore.disabled = true;
load();
} catch (e) { alert(e.message); }
}
async function purgeItems(items) {
if (!confirm('Permanently delete? This cannot be undone.')) return;
const body = { document_ids: [], folder_ids: [] };
items.forEach(i => {
if (i.kind === 'folder') body.folder_ids.push(i.id);
else body.document_ids.push(i.id);
});
try {
await DMS.api('/trash.php?action=purge', { method: 'POST', body });
load();
} catch (e) { alert(e.message); }
}
$restore.addEventListener('click', () => {
const items = Array.from(selected).map(k => ({ kind: k[0] === 'd' ? 'document' : 'folder', id: Number(k.slice(1)) }));
restoreItems(items);
});
$purge.addEventListener('click', async () => {
if (!confirm('Permanently delete ALL items in trash? This cannot be undone.')) return;
try {
await DMS.api('/trash.php?action=purge', { method: 'POST', body: { all: true } });
load();
} catch (e) { alert(e.message); }
});
load();
})();
</script>
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
+52 -5
View File
@@ -18,10 +18,19 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
<form id="upFileForm" class="upload-form" enctype="multipart/form-data" style="display:grid; gap:0.85rem;"> <form id="upFileForm" class="upload-form" enctype="multipart/form-data" style="display:grid; gap:0.85rem;">
<label class="upload-drop" id="upDrop"> <label class="upload-drop" id="upDrop">
<input type="file" name="file" id="upFile" accept=".pdf,.docx,.txt" hidden> <input type="file" name="file" id="upFile" accept=".pdf,.docx,.txt,.md,.html,.htm,.csv,.xlsx,.pptx,.json" hidden>
<span class="upload-drop__icon">📥</span> <span class="upload-drop__icon">📥</span>
<strong id="upDropHint"><?= htmlspecialchars(dbnToolsT('dash_upload_drop_strong', $uiLang)) ?></strong> <strong id="upDropHint"><?= htmlspecialchars(dbnToolsT('dash_upload_drop_strong', $uiLang)) ?></strong>
<small><?= htmlspecialchars(dbnToolsT('dash_upload_drop_small', $uiLang)) ?></small> <small><?= htmlspecialchars(dbnToolsT('dash_upload_drop_small', $uiLang)) ?></small>
<div style="margin-top:10px;display:flex;gap:6px;flex-wrap:wrap;">
<span class="dms-chip">PDF</span><span class="dms-chip">DOCX</span><span class="dms-chip">TXT</span>
<span class="dms-chip">MD</span><span class="dms-chip">HTML</span><span class="dms-chip">CSV</span>
<span class="dms-chip">XLSX</span><span class="dms-chip">PPTX</span><span class="dms-chip">JSON</span>
</div>
</label>
<label style="display:grid;gap:4px;font-size:13px;">
<span>📁 Destination folder</span>
<select name="folder_id" id="upFolderSel"><option value="">(Unassigned)</option></select>
</label> </label>
<div class="upload-meta"> <div class="upload-meta">
<label><?= htmlspecialchars(dbnToolsT('dash_upload_title_lbl', $uiLang)) ?><input name="title" placeholder="<?= htmlspecialchars(dbnToolsT('dash_upload_title_ph', $uiLang)) ?>"></label> <label><?= htmlspecialchars(dbnToolsT('dash_upload_title_lbl', $uiLang)) ?><input name="title" placeholder="<?= htmlspecialchars(dbnToolsT('dash_upload_title_ph', $uiLang)) ?>"></label>
@@ -139,13 +148,51 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c])); } function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c])); }
forms.file.addEventListener('submit', (e) => { // Populate folder picker from /api/dashboard/folders.php
e.preventDefault(); (async function loadFolders() {
try {
const data = await fetch(api + '/folders.php?action=list_tree', { credentials: 'same-origin' }).then(r => r.json());
const sel = document.getElementById('upFolderSel');
if (!sel || !data.tree) return;
const flat = [];
const walk = (nodes, prefix) => {
nodes.forEach(n => {
flat.push({ id: n.id, label: prefix + n.name });
if (n.children && n.children.length) walk(n.children, prefix + n.name + ' / ');
});
};
walk(data.tree || [], '');
const opts = ['<option value="">(Unassigned)</option>'].concat(
flat.map(f => '<option value="' + f.id + '">' + safe(f.label) + '</option>')
);
sel.innerHTML = opts.join('');
// Preselect from ?folder=N
const initial = new URLSearchParams(location.search).get('folder');
if (initial) sel.value = initial;
} catch (_) { /* ignored */ }
})();
async function postFile(versionAction) {
const fd = new FormData(forms.file); const fd = new FormData(forms.file);
if (versionAction) fd.set('version_action', versionAction);
const res = await fetch(api + '/upload.php', { method: 'POST', credentials: 'same-origin', body: fd });
const json = await res.json();
if (res.status === 409 && json && json.collision) {
const action = await (window.DBN_DMS ? DBN_DMS.chooseCollisionAction(fileInput.files[0]?.name || '') : null);
if (action) return postFile(action);
setStatus('Cancelled.', 'err'); return null;
}
return json;
}
forms.file.addEventListener('submit', async (e) => {
e.preventDefault();
if (!fileInput.files.length) { setStatus(I18N.upload_select_file || 'Select a file first.', 'err'); return; } if (!fileInput.files.length) { setStatus(I18N.upload_select_file || 'Select a file first.', 'err'); return; }
setStatus(I18N.upload_indexing || 'Uploading and indexing…'); setStatus(I18N.upload_indexing || 'Uploading and indexing…');
fetch(api + '/upload.php', { method: 'POST', credentials: 'same-origin', body: fd }) try {
.then(r => r.json()).then(handleResult).catch(err => setStatus('❌ ' + safe(err.message), 'err')); const data = await postFile();
if (data) handleResult(data);
} catch (err) { setStatus('❌ ' + safe(err.message), 'err'); }
}); });
forms.text.addEventListener('submit', (e) => { forms.text.addEventListener('submit', (e) => {
+15 -5
View File
@@ -891,7 +891,9 @@ function dbnToolsExcerpt(string $text, int $limit = 520): string
const DBN_TOOLS_EXTRACT_MAX_BYTES = 8 * 1024 * 1024; const DBN_TOOLS_EXTRACT_MAX_BYTES = 8 * 1024 * 1024;
const DBN_TOOLS_EXTRACT_TEXT_LIMIT = 128000; const DBN_TOOLS_EXTRACT_TEXT_LIMIT = 128000;
const DBN_TOOLS_TIMELINE_EXTRACT_TEXT_LIMIT = 600000; const DBN_TOOLS_TIMELINE_EXTRACT_TEXT_LIMIT = 600000;
const DBN_TOOLS_EXTRACT_ALLOWED_EXTS = ['txt', 'pdf', 'docx']; const DBN_TOOLS_EXTRACT_ALLOWED_EXTS = ['txt', 'pdf', 'docx', 'xlsx', 'pptx', 'html', 'htm', 'csv', 'md', 'json'];
const DBN_TOOLS_EXTRACT_AUDIO_EXTS = ['mp3', 'wav', 'm4a', 'ogg', 'flac', 'webm'];
const DBN_TOOLS_EXTRACT_IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'webp'];
function dbnToolsExtractUploadedFile(array $file, int $textLimit = DBN_TOOLS_EXTRACT_TEXT_LIMIT): array function dbnToolsExtractUploadedFile(array $file, int $textLimit = DBN_TOOLS_EXTRACT_TEXT_LIMIT): array
{ {
@@ -922,13 +924,19 @@ function dbnToolsExtractUploadedFile(array $file, int $textLimit = DBN_TOOLS_EXT
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
if (!in_array($ext, DBN_TOOLS_EXTRACT_ALLOWED_EXTS, true)) { if (!in_array($ext, DBN_TOOLS_EXTRACT_ALLOWED_EXTS, true)) {
dbnToolsAbort('Unsupported file type. Upload a .pdf, .docx, or .txt file.', 422, 'unsupported_type'); $allowed = strtoupper(implode(', .', DBN_TOOLS_EXTRACT_ALLOWED_EXTS));
dbnToolsAbort("Unsupported file type. Allowed: .{$allowed}.", 422, 'unsupported_type');
} }
$text = match ($ext) { $text = match ($ext) {
'txt' => dbnToolsExtractTxt($tmpPath), 'txt', 'md', 'json' => dbnToolsExtractTxt($tmpPath),
'pdf' => dbnToolsExtractPdf($tmpPath), 'pdf' => dbnToolsExtractPdf($tmpPath),
'docx' => dbnToolsExtractDocx($tmpPath), 'docx' => dbnToolsExtractDocx($tmpPath),
'html', 'htm' => dbnDmsExtractHtml($tmpPath),
'csv' => dbnDmsExtractCsv($tmpPath),
'xlsx' => dbnDmsExtractXlsx($tmpPath),
'pptx' => dbnDmsExtractPptx($tmpPath),
default => dbnToolsExtractTxt($tmpPath),
}; };
$text = trim($text); $text = trim($text);
@@ -1370,3 +1378,5 @@ function dbnToolsInjectDocContent(array $input, string $text): string
} }
return $docText . ($text !== '' ? "\n\n---\n\n" . $text : ''); return $docText . ($text !== '' ? "\n\n---\n\n" . $text : '');
} }
require_once __DIR__ . '/dms_helpers.php';
+471
View File
@@ -0,0 +1,471 @@
<?php
declare(strict_types=1);
/**
* DMS helpers: folder ACLs, storage paths, audit logging, version bookkeeping.
* Loaded from bootstrap.php so all dashboard pages + APIs get them implicitly.
*/
const DBN_DMS_MAX_FOLDER_DEPTH = 2; // Matches ai-portal app-layer cap. Raise here to unlock deeper nesting.
const DBN_DMS_MAX_VERSIONS_PER_DOC = 20; // Oldest auto-pruned beyond this.
const DBN_DMS_TRASH_RETENTION_DAYS = 30;
const DBN_DMS_DEFAULT_CATEGORIES = [
['slug' => 'uncategorized', 'label' => 'Uncategorized', 'color' => '#94a3b8', 'icon' => 'folder', 'sort_order' => 0],
['slug' => 'legal', 'label' => 'Legal', 'color' => '#1d4ed8', 'icon' => 'scale', 'sort_order' => 10],
['slug' => 'financial', 'label' => 'Financial', 'color' => '#047857', 'icon' => 'chart', 'sort_order' => 20],
['slug' => 'internal', 'label' => 'Internal', 'color' => '#7c3aed', 'icon' => 'building', 'sort_order' => 30],
['slug' => 'hr', 'label' => 'HR', 'color' => '#db2777', 'icon' => 'people', 'sort_order' => 40],
['slug' => 'marketing', 'label' => 'Marketing', 'color' => '#b88a2c', 'icon' => 'megaphone', 'sort_order' => 50],
];
/**
* Resolve the on-disk storage path for an uploaded document.
* Production: /home/dobetternorge/uploads/{client_id}/{document_id}.{ext}
* Local dev: DBN_TOOLS_UPLOAD_ROOT env override, else DBN_TOOLS_ROOT/uploads/...
*/
function dbnDmsStoragePath(int $clientId, int $documentId, string $ext, ?int $versionNumber = null): string
{
$root = dbnToolsEnv('DBN_TOOLS_UPLOAD_ROOT', '');
if ($root === '' || $root === null) {
$root = is_dir('/home/dobetternorge/uploads')
? '/home/dobetternorge/uploads'
: DBN_TOOLS_ROOT . '/uploads';
}
$ext = preg_replace('/[^a-z0-9]/', '', strtolower($ext)) ?: 'bin';
$clientDir = rtrim($root, '/') . '/' . $clientId;
if (!is_dir($clientDir)) {
@mkdir($clientDir, 0750, true);
}
if ($versionNumber !== null && $versionNumber > 0) {
$versionDir = $clientDir . '/' . $documentId . '_versions';
if (!is_dir($versionDir)) {
@mkdir($versionDir, 0750, true);
}
return $versionDir . '/v' . $versionNumber . '.' . $ext;
}
return $clientDir . '/' . $documentId . '.' . $ext;
}
/**
* Stream an uploaded file into permanent storage. Returns the storage_path string,
* or null if persistence is disabled (no upload root and not writable).
*/
function dbnDmsPersistFile(string $tmpPath, int $clientId, int $documentId, string $ext, ?int $versionNumber = null): ?string
{
$dest = dbnDmsStoragePath($clientId, $documentId, $ext, $versionNumber);
$dir = dirname($dest);
if (!is_dir($dir) || !is_writable($dir)) {
return null;
}
if (!@copy($tmpPath, $dest)) {
return null;
}
@chmod($dest, 0640);
return $dest;
}
/**
* Walk the folder tree starting at $folderId upward; returns the chain root→leaf.
* Returns [] if $folderId is null/0 (root).
*/
function dbnDmsFolderChain(?int $folderId, int $clientId): array
{
if (!$folderId) {
return [];
}
$db = dbnToolsDb();
$chain = [];
$current = $folderId;
$guard = 0;
while ($current && $guard++ < 50) {
$stmt = $db->prepare('SELECT id, name, parent_id, color FROM client_folders WHERE id = ? AND client_id = ? AND deleted_at IS NULL');
$stmt->execute([$current, $clientId]);
$row = $stmt->fetch();
if (!$row) {
break;
}
$chain[] = $row;
$current = $row['parent_id'] ? (int)$row['parent_id'] : 0;
}
return array_reverse($chain);
}
/**
* Resolve the breadcrumb for a folder as [{id, name}, …] starting at the root.
* Returns [] when at corpus root.
*/
function dbnDmsBreadcrumb(?int $folderId, int $clientId): array
{
return array_map(fn($r) => ['id' => (int)$r['id'], 'name' => (string)$r['name'], 'color' => $r['color'] ?? null],
dbnDmsFolderChain($folderId, $clientId));
}
/**
* Folder depth, where root-level = 1. Used to enforce DBN_DMS_MAX_FOLDER_DEPTH.
*/
function dbnDmsFolderDepth(?int $folderId, int $clientId): int
{
if (!$folderId) {
return 0;
}
return count(dbnDmsFolderChain($folderId, $clientId));
}
/**
* Check whether the current user can act on $folderId with $perm = 'read'|'write'|'manage'.
* Permission resolution:
* - tenant owner/admin role → always allowed
* - walk folder chain leaf→root; first matching ACL row wins
* - no ACL rows anywhere → open (default)
*/
function dbnDmsUserCanAccessFolder(?int $folderId, string $perm, int $clientId, int $userId, string $tenantRole = 'viewer'): bool
{
// Tenant root is always readable; only manage requires editor+.
if (!$folderId) {
if ($perm === 'manage' || $perm === 'write') {
return in_array($tenantRole, ['editor', 'admin', 'owner'], true);
}
return true;
}
if (in_array($tenantRole, ['admin', 'owner'], true)) {
return true;
}
$db = dbnToolsDb();
$chain = dbnDmsFolderChain($folderId, $clientId);
if (!$chain) {
return false;
}
$col = match ($perm) {
'write' => 'can_write',
'manage' => 'can_manage',
default => 'can_read',
};
$anyAcl = false;
foreach (array_reverse($chain) as $folder) {
$stmt = $db->prepare(
"SELECT min_role, user_id, can_read, can_write, can_manage
FROM client_folder_permissions
WHERE folder_id = ? AND client_id = ?"
);
$stmt->execute([(int)$folder['id'], $clientId]);
$rows = $stmt->fetchAll();
if (!$rows) {
continue;
}
$anyAcl = true;
foreach ($rows as $row) {
if ($row['user_id'] !== null && (int)$row['user_id'] === $userId) {
if ((int)$row[$col] === 1) return true;
}
if ($row['min_role'] !== null) {
if (dbnDmsRoleAtLeast($tenantRole, (string)$row['min_role']) && (int)$row[$col] === 1) {
return true;
}
}
}
// ACL at this level but user not granted — block (no inheritance past explicit restriction).
return false;
}
// No ACL rows anywhere → open per migration 119 convention.
return !$anyAcl;
}
function dbnDmsRoleAtLeast(string $userRole, string $minRole): bool
{
$rank = ['viewer' => 0, 'editor' => 1, 'admin' => 2, 'owner' => 3];
return ($rank[$userRole] ?? 0) >= ($rank[$minRole] ?? 0);
}
/**
* Append an audit row. Failure is swallowed — auditing must never break the request.
*/
function dbnDmsLogAudit(int $clientId, ?int $userId, string $action, array $details = [], ?int $documentId = null, ?int $folderId = null): void
{
try {
$db = dbnToolsDb();
$stmt = $db->prepare(
'INSERT INTO client_document_audit (client_id, user_id, document_id, folder_id, action, details, ip_addr, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())'
);
$stmt->execute([
$clientId,
$userId ?: null,
$documentId ?: null,
$folderId ?: null,
substr($action, 0, 40),
$details ? json_encode($details, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
substr((string)($_SERVER['REMOTE_ADDR'] ?? ''), 0, 45),
]);
} catch (Throwable $e) {
error_log('[dbn-dms] audit insert failed: ' . $e->getMessage());
}
}
/**
* Seed default categories for a tenant if their dictionary is empty.
* Idempotent — safe to call on every dashboard page load.
*/
function dbnDmsSeedDefaultCategoriesIfEmpty(int $clientId): void
{
try {
$db = dbnToolsDb();
$check = $db->prepare('SELECT COUNT(*) FROM client_categories WHERE client_id = ?');
$check->execute([$clientId]);
if ((int)$check->fetchColumn() > 0) {
return;
}
$ins = $db->prepare(
'INSERT INTO client_categories (client_id, slug, label, color, icon, sort_order, is_system)
VALUES (?, ?, ?, ?, ?, ?, 1)'
);
foreach (DBN_DMS_DEFAULT_CATEGORIES as $cat) {
$ins->execute([
$clientId,
$cat['slug'],
$cat['label'],
$cat['color'],
$cat['icon'],
$cat['sort_order'],
]);
}
} catch (Throwable $e) {
// Likely table doesn't exist yet (migration not applied).
error_log('[dbn-dms] seed categories failed: ' . $e->getMessage());
}
}
/**
* Snapshot a document into client_document_versions before overwriting.
* Returns the new version_number, or 0 on failure.
*/
function dbnDmsSnapshotVersion(int $documentId, int $clientId, ?int $userId, ?string $notes = null): int
{
$db = dbnToolsDb();
$doc = $db->prepare('SELECT * FROM client_documents WHERE id = ? AND client_id = ?');
$doc->execute([$documentId, $clientId]);
$row = $doc->fetch();
if (!$row) {
return 0;
}
$next = (int)($row['current_version'] ?? 1);
$ins = $db->prepare(
'INSERT INTO client_document_versions
(document_id, client_id, version_number, title, content, file_size_bytes,
original_filename, storage_path, word_count, uploaded_by, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())'
);
$ins->execute([
$documentId,
$clientId,
$next,
(string)$row['title'],
(string)($row['content'] ?? ''),
(int)($row['file_size_bytes'] ?? 0),
$row['original_filename'] ?? null,
$row['storage_path'] ?? null,
(int)($row['word_count'] ?? 0),
$userId ?: null,
$notes,
]);
// Prune oldest versions beyond cap.
$count = $db->prepare('SELECT COUNT(*) FROM client_document_versions WHERE document_id = ?');
$count->execute([$documentId]);
$total = (int)$count->fetchColumn();
if ($total > DBN_DMS_MAX_VERSIONS_PER_DOC) {
$prune = $db->prepare(
'DELETE FROM client_document_versions
WHERE document_id = ?
ORDER BY version_number ASC
LIMIT ' . ($total - DBN_DMS_MAX_VERSIONS_PER_DOC)
);
$prune->execute([$documentId]);
}
return $next;
}
/**
* Convenience: file extension from an upload array (original_filename) or filename string.
*/
function dbnDmsExtensionFromFilename(string $filename): string
{
$dot = strrpos($filename, '.');
if ($dot === false) {
return '';
}
return strtolower(substr($filename, $dot + 1));
}
/**
* Extract plain text from HTML (strip tags, decode entities).
*/
function dbnDmsExtractHtml(string $path): string
{
$raw = file_get_contents($path);
if ($raw === false) {
throw new DbnToolsHttpException('Unable to read HTML file.', 500, 'read_error');
}
$raw = mb_convert_encoding($raw, 'UTF-8', 'UTF-8, ISO-8859-1, Windows-1252');
$raw = preg_replace('#<script\b[^>]*>.*?</script>#is', '', $raw) ?? $raw;
$raw = preg_replace('#<style\b[^>]*>.*?</style>#is', '', $raw) ?? $raw;
$text = trim(html_entity_decode(strip_tags($raw), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
return preg_replace("/[\r\n]{3,}/", "\n\n", $text) ?? $text;
}
/**
* Extract CSV as readable text (header row repeated each line for context).
*/
function dbnDmsExtractCsv(string $path): string
{
$fh = @fopen($path, 'rb');
if (!$fh) {
throw new DbnToolsHttpException('Unable to read CSV file.', 500, 'read_error');
}
$lines = [];
$header = null;
$rowNum = 0;
while (($row = fgetcsv($fh, 0, ',', '"', '\\')) !== false) {
$row = array_map(fn($c) => (string)$c, $row);
if ($header === null) {
$header = $row;
$lines[] = implode(' | ', $header);
continue;
}
$pairs = [];
foreach ($row as $i => $cell) {
$col = $header[$i] ?? "col{$i}";
if ($cell !== '') {
$pairs[] = $col . ': ' . $cell;
}
}
$lines[] = '- ' . implode('; ', $pairs);
if (++$rowNum > 5000) {
$lines[] = '... (truncated)';
break;
}
}
fclose($fh);
return implode("\n", $lines);
}
/**
* Extract plain text from XLSX (concatenate sharedStrings + cell values).
* Lightweight — no PhpSpreadsheet dependency.
*/
function dbnDmsExtractXlsx(string $path): string
{
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
throw new DbnToolsHttpException('Unable to open XLSX file.', 422, 'xlsx_open_failed');
}
$shared = [];
$sharedXml = $zip->getFromName('xl/sharedStrings.xml');
if ($sharedXml !== false) {
if (preg_match_all('#<t[^>]*>(.*?)</t>#s', $sharedXml, $m)) {
foreach ($m[1] as $s) {
$shared[] = html_entity_decode(strip_tags($s), ENT_QUOTES | ENT_XML1, 'UTF-8');
}
}
}
$out = [];
for ($i = 1; $i < 100; $i++) {
$sheet = $zip->getFromName("xl/worksheets/sheet{$i}.xml");
if ($sheet === false) break;
// Inline strings + numeric/text values.
if (preg_match_all('#<c\b[^>]*?(?:\s+t="([^"]*)")?[^>]*>(.*?)</c>#s', $sheet, $m, PREG_SET_ORDER)) {
$cells = [];
foreach ($m as $cell) {
$type = $cell[1] ?? '';
$inner = $cell[2];
if ($type === 's') {
if (preg_match('#<v>(\d+)</v>#', $inner, $vm)) {
$idx = (int)$vm[1];
if (isset($shared[$idx])) $cells[] = $shared[$idx];
}
} elseif ($type === 'inlineStr') {
if (preg_match('#<t[^>]*>(.*?)</t>#s', $inner, $tm)) {
$cells[] = html_entity_decode(strip_tags($tm[1]), ENT_QUOTES | ENT_XML1, 'UTF-8');
}
} else {
if (preg_match('#<v>(.*?)</v>#', $inner, $vm)) {
$cells[] = $vm[1];
}
}
}
$out[] = "=== Sheet {$i} ===\n" . implode("\t", $cells);
}
}
$zip->close();
$text = implode("\n\n", $out);
if (trim($text) === '') {
throw new DbnToolsHttpException('No readable content in XLSX.', 422, 'xlsx_empty');
}
return $text;
}
/**
* Extract plain text from PPTX (slide notes + text frames).
*/
function dbnDmsExtractPptx(string $path): string
{
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
throw new DbnToolsHttpException('Unable to open PPTX file.', 422, 'pptx_open_failed');
}
$slides = [];
for ($i = 1; $i < 500; $i++) {
$xml = $zip->getFromName("ppt/slides/slide{$i}.xml");
if ($xml === false) break;
$text = [];
if (preg_match_all('#<a:t[^>]*>(.*?)</a:t>#s', $xml, $m)) {
foreach ($m[1] as $t) {
$text[] = html_entity_decode(strip_tags($t), ENT_QUOTES | ENT_XML1, 'UTF-8');
}
}
if ($text) {
$slides[] = "=== Slide {$i} ===\n" . implode("\n", $text);
}
}
$zip->close();
if (!$slides) {
throw new DbnToolsHttpException('No readable content in PPTX.', 422, 'pptx_empty');
}
return implode("\n\n", $slides);
}
/**
* Convenience: MIME type → safe content type for inline preview/download streaming.
*/
function dbnDmsContentTypeForExt(string $ext): string
{
return match (strtolower($ext)) {
'pdf' => 'application/pdf',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'txt' => 'text/plain; charset=utf-8',
'md' => 'text/markdown; charset=utf-8',
'csv' => 'text/csv; charset=utf-8',
'html', 'htm' => 'text/html; charset=utf-8',
'json' => 'application/json',
'mp3' => 'audio/mpeg',
'wav' => 'audio/wav',
'm4a' => 'audio/mp4',
'ogg' => 'audio/ogg',
'png' => 'image/png',
'jpg', 'jpeg' => 'image/jpeg',
'webp' => 'image/webp',
default => 'application/octet-stream',
};
}
+4
View File
@@ -45,8 +45,10 @@ if ($dashAuthUser !== null) {
$dashboardNav = [ $dashboardNav = [
'index' => ['url' => '/dashboard/', 'label' => dbnToolsT('dash_nav_overview', $uiLang), 'sub' => 'Overview'], 'index' => ['url' => '/dashboard/', 'label' => dbnToolsT('dash_nav_overview', $uiLang), 'sub' => 'Overview'],
'documents' => ['url' => '/dashboard/documents.php', 'label' => dbnToolsT('dash_nav_documents', $uiLang), 'sub' => 'Documents'], 'documents' => ['url' => '/dashboard/documents.php', 'label' => dbnToolsT('dash_nav_documents', $uiLang), 'sub' => 'Documents'],
'folders' => ['url' => '/dashboard/folders.php', 'label' => dbnToolsT('dash_nav_folders', $uiLang) ?: 'Folders', 'sub' => 'Folder tree & access'],
'upload' => ['url' => '/dashboard/upload.php', 'label' => dbnToolsT('dash_nav_upload', $uiLang), 'sub' => 'Upload'], 'upload' => ['url' => '/dashboard/upload.php', 'label' => dbnToolsT('dash_nav_upload', $uiLang), 'sub' => 'Upload'],
'chat' => ['url' => '/dashboard/chat.php', 'label' => dbnToolsT('dash_nav_ask', $uiLang), 'sub' => 'Ask'], 'chat' => ['url' => '/dashboard/chat.php', 'label' => dbnToolsT('dash_nav_ask', $uiLang), 'sub' => 'Ask'],
'trash' => ['url' => '/dashboard/trash.php', 'label' => dbnToolsT('dash_nav_trash', $uiLang) ?: 'Trash', 'sub' => 'Restore or purge'],
'settings' => ['url' => '/dashboard/settings.php', 'label' => dbnToolsT('dash_nav_settings', $uiLang), 'sub' => 'Settings'], 'settings' => ['url' => '/dashboard/settings.php', 'label' => dbnToolsT('dash_nav_settings', $uiLang), 'sub' => 'Settings'],
]; ];
?> ?>
@@ -59,6 +61,8 @@ $dashboardNav = [
<link rel="stylesheet" href="../assets/css/tools.css"> <link rel="stylesheet" href="../assets/css/tools.css">
<link rel="stylesheet" href="../assets/css/dashboard.css"> <link rel="stylesheet" href="../assets/css/dashboard.css">
<link rel="stylesheet" href="../assets/css/dbn-tools-redesign.css"> <link rel="stylesheet" href="../assets/css/dbn-tools-redesign.css">
<link rel="stylesheet" href="../assets/css/dms.css">
<script src="../assets/js/dashboard/dms.js" defer></script>
</head> </head>
<body data-authenticated="true" data-dashboard-page="<?= htmlspecialchars($dashboardPage) ?>"> <body data-authenticated="true" data-dashboard-page="<?= htmlspecialchars($dashboardPage) ?>">
<script> <script>
@@ -0,0 +1,49 @@
-- DBN DMS migration 001 — document versioning
-- Adds client_document_versions table + current_version/storage_path columns on client_documents.
-- Safe to re-run: uses IF NOT EXISTS / INFORMATION_SCHEMA guards.
CREATE TABLE IF NOT EXISTS client_document_versions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
document_id INT UNSIGNED NOT NULL,
client_id INT UNSIGNED NOT NULL,
version_number INT UNSIGNED NOT NULL,
title VARCHAR(500) NOT NULL,
content LONGTEXT NOT NULL,
file_size_bytes INT UNSIGNED DEFAULT 0,
original_filename VARCHAR(255) NULL,
storage_path VARCHAR(500) NULL,
word_count INT UNSIGNED DEFAULT 0,
uploaded_by INT UNSIGNED NULL,
notes VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_doc_ver (document_id, version_number),
KEY idx_client (client_id),
KEY idx_uploaded_by (uploaded_by),
CONSTRAINT fk_cdv_doc FOREIGN KEY (document_id) REFERENCES client_documents(id) ON DELETE CASCADE,
CONSTRAINT fk_cdv_client FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Per-document version history. Latest = client_documents.current_version.';
-- current_version column (guarded against re-run)
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'client_documents'
AND COLUMN_NAME = 'current_version'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE client_documents ADD COLUMN current_version INT UNSIGNED NOT NULL DEFAULT 1 AFTER chunk_count',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- storage_path column (guarded)
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'client_documents'
AND COLUMN_NAME = 'storage_path'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE client_documents ADD COLUMN storage_path VARCHAR(500) NULL AFTER original_filename',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+34
View File
@@ -0,0 +1,34 @@
-- DBN DMS migration 002 — soft-delete trash
-- Adds deleted_at / deleted_by columns to client_documents and client_folders.
-- client_documents.deleted_at
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'client_documents' AND COLUMN_NAME = 'deleted_at'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE client_documents
ADD COLUMN deleted_at DATETIME NULL AFTER updated_at,
ADD COLUMN deleted_by INT UNSIGNED NULL AFTER deleted_at,
ADD KEY idx_deleted (deleted_at)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- client_folders.deleted_at (folders table may not exist if migration 119 hasn't run; guard table too)
SET @tbl_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'client_folders'
);
SET @col_exists := IF(@tbl_exists = 0, 1,
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'client_folders' AND COLUMN_NAME = 'deleted_at')
);
SET @sql := IF(@tbl_exists = 1 AND @col_exists = 0,
'ALTER TABLE client_folders
ADD COLUMN deleted_at DATETIME NULL,
ADD COLUMN deleted_by INT UNSIGNED NULL,
ADD KEY idx_deleted (deleted_at)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -0,0 +1,20 @@
-- DBN DMS migration 003 — saved searches / smart folders
CREATE TABLE IF NOT EXISTS client_saved_searches (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
client_id INT UNSIGNED NOT NULL,
user_id INT UNSIGNED NOT NULL,
name VARCHAR(120) NOT NULL,
icon VARCHAR(40) NULL,
color VARCHAR(7) NULL,
query_json JSON NOT NULL COMMENT '{q, category, tags[], folder_id, source_type, status, include_subfolders}',
is_shared TINYINT(1) NOT NULL DEFAULT 0,
sort_order SMALLINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_client_user (client_id, user_id),
KEY idx_shared (client_id, is_shared),
CONSTRAINT fk_css_client FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE,
CONSTRAINT fk_css_user FOREIGN KEY (user_id) REFERENCES client_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Persisted filter sets ("smart folders"). is_shared=1 = visible to whole tenant.';
@@ -0,0 +1,19 @@
-- DBN DMS migration 004 — tenant-managed categories
-- client_documents.category continues to store the slug as VARCHAR (back-compat).
-- This table just describes display metadata + lets tenants curate the list.
CREATE TABLE IF NOT EXISTS client_categories (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
client_id INT UNSIGNED NOT NULL,
slug VARCHAR(50) NOT NULL,
label VARCHAR(100) NOT NULL,
color VARCHAR(7) NULL,
icon VARCHAR(40) NULL,
sort_order SMALLINT NOT NULL DEFAULT 0,
is_system TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = seeded default, cannot be deleted',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_client_slug (client_id, slug),
KEY idx_client (client_id),
CONSTRAINT fk_cc_client FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Per-tenant category dictionary. Slug is the value used in client_documents.category.';
+19
View File
@@ -0,0 +1,19 @@
-- DBN DMS migration 005 — audit log
-- Powers the "Recent activity" widget on the dashboard overview.
CREATE TABLE IF NOT EXISTS client_document_audit (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
client_id INT UNSIGNED NOT NULL,
user_id INT UNSIGNED NULL,
document_id INT UNSIGNED NULL,
folder_id INT UNSIGNED NULL,
action VARCHAR(40) NOT NULL
COMMENT 'upload|view|edit|move|rename|version|delete|restore|share|download|search|chat|category|tag',
details JSON NULL,
ip_addr VARCHAR(45) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_client_time (client_id, created_at),
KEY idx_doc (document_id),
KEY idx_user_time (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Append-only audit trail for DMS activity. Document/folder FKs intentionally not enforced — keep history after deletes.';