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;
$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
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache, no-transform');
@@ -75,6 +84,37 @@ try {
'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(
$question,
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', [
'ok' => true,
'chunks_used' => (int)($result['chunks_used'] ?? count($sources)),
'model' => (string)($result['model'] ?? ''),
'response_time_ms'=> (int)($result['response_time_ms'] ?? 0),
'sources' => $sources,
'related_documents' => $related,
'scope' => [
'folder_id' => $folderScope,
'include_subfolders'=> $includeSubfolders,
],
]);
} catch (Throwable $e) {
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
/**
* /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
* → { ok, document: {...}, chunks: [...], versions: [...], permissions: {...} }
*
* POST ?action=update body: { id, title?, category?, tags?, language?, author?, folder_id? }
* → { ok, document: {...} }
* POST ?action=update body: { id, title?, category?, tags?, language?, author? }
* → { ok, document: {...} }
* POST ?action=delete body: { ids: [1,2,3] }
*
* POST ?action=delete body: { ids: [1,2,3], hard_delete?: false }
* → { 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);
@@ -25,53 +33,105 @@ try {
} catch (DbnToolsHttpException $e) {
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'));
$action = (string)($_GET['action'] ?? ($method === 'POST' ? '' : 'list'));
$db = dbnToolsDb();
switch ($action) {
case 'list':
dbnToolsRequireMethod('GET');
respondList($db, $clientId);
break;
case 'get':
dbnToolsRequireMethod('GET');
respondGet($db, $clientId);
break;
case 'update':
dbnToolsRequireMethod('POST');
respondUpdate($db, $clientId);
break;
case 'delete':
dbnToolsRequireMethod('POST');
respondDelete($db, $clientId);
break;
default:
dbnToolsError('Unknown action.', 400, 'unknown_action');
try {
switch ($action) {
case 'list':
dbnToolsRequireMethod('GET');
respondList($db, $clientId, $userId, $tenantRole);
break;
case 'get':
dbnToolsRequireMethod('GET');
respondGet($db, $clientId, $userId, $tenantRole);
break;
case 'update':
dbnToolsRequireMethod('POST');
respondUpdate($db, $clientId, $userId, $tenantRole);
break;
case 'delete':
dbnToolsRequireMethod('POST');
respondDelete($db, $clientId, $userId, $tenantRole);
break;
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));
$limit = max(1, min(100, (int)($_GET['limit'] ?? 20)));
$q = trim((string)($_GET['q'] ?? ''));
$status = trim((string)($_GET['status'] ?? ''));
$limit = max(1, min(200, (int)($_GET['limit'] ?? 25)));
$q = trim((string)($_GET['q'] ?? ''));
$status = trim((string)($_GET['status'] ?? ''));
$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 = ?'];
$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 !== '') {
$where[] = '(title LIKE ? OR tags LIKE ?)';
$like = '%' . str_replace(['%', '_'], ['\%', '\_'], $q) . '%';
$where[] = '(title LIKE ? OR tags LIKE ?)';
$like = '%' . str_replace(['%','_'], ['\%','\_'], $q) . '%';
$params[] = $like;
$params[] = $like;
}
$allowedStatus = ['pending', 'processing', 'ready', 'error'];
if ($status !== '' && in_array($status, $allowedStatus, true)) {
if ($status !== '' && in_array($status, ['pending','processing','ready','error'], true)) {
$where[] = 'status = ?';
$params[] = $status;
}
@@ -79,77 +139,138 @@ function respondList(PDO $db, int $clientId): void
$where[] = 'category = ?';
$params[] = $category;
}
$sourceType = trim((string)($_GET['source_type'] ?? ''));
$allowedSourceTypes = ['text', 'audio', 'url', 'tool-output', 'upload'];
if ($sourceType !== '' && in_array($sourceType, $allowedSourceTypes, true)) {
if ($sourceType !== '' && in_array($sourceType, ['text','audio','url','tool-output','upload','pdf','docx'], true)) {
$where[] = 'source_type = ?';
$params[] = $sourceType;
}
$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->execute($params);
$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,
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
FROM client_documents
{$whereSql}
ORDER BY id DESC
ORDER BY {$sortCol} {$dir}, id DESC
LIMIT {$limit} OFFSET {$offset}";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$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([
'ok' => true,
'total' => $total,
'offset' => $offset,
'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);
if ($id <= 0) {
dbnToolsError('id is required.', 400, 'missing_id');
}
$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]);
$doc = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$doc) {
dbnToolsError('Document not found.', 404, 'not_found');
}
$chunks = $db->prepare(
'SELECT id, content, section_title
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 = [];
$fid = $doc['folder_id'] ? (int)$doc['folder_id'] : 0;
if (!dbnDmsUserCanAccessFolder($fid ?: null, 'read', $clientId, $userId, $tenantRole)) {
dbnToolsError('Forbidden.', 403, 'forbidden');
}
$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([
'ok' => true,
'document' => shapeDoc($doc) + ['content' => (string)$doc['content']],
'chunks' => $chunkRows,
'ok' => true,
'document' => shapeDoc($doc) + [
'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);
$id = (int)($input['id'] ?? 0);
@@ -157,16 +278,29 @@ function respondUpdate(PDO $db, int $clientId): void
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 = [];
$params = [];
$allowed = [
'title' => ['VARCHAR', 500],
'category' => ['VARCHAR', 50],
'tags' => ['VARCHAR', 500],
'language' => ['VARCHAR', 10],
'author' => ['VARCHAR', 200],
];
foreach ($allowed as $col => [$kind, $max]) {
foreach ($allowed as $col => $max) {
if (!array_key_exists($col, $input)) {
continue;
}
@@ -177,6 +311,26 @@ function respondUpdate(PDO $db, int $clientId): void
$fields[] = "{$col} = ?";
$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) {
dbnToolsError('No editable fields supplied.', 400, 'no_fields');
}
@@ -189,6 +343,12 @@ function respondUpdate(PDO $db, int $clientId): void
);
$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->execute([$id, $clientId]);
$doc = $stmt->fetch(PDO::FETCH_ASSOC);
@@ -196,7 +356,65 @@ function respondUpdate(PDO $db, int $clientId): void
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);
$ids = $input['ids'] ?? [];
@@ -208,48 +426,78 @@ function respondDelete(PDO $db, int $clientId): void
if (!$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), '?'));
$stmt = $db->prepare(
"DELETE FROM client_documents
WHERE client_id = ? AND id IN ({$placeholders})"
);
$stmt->execute(array_merge([$clientId], $ids));
try {
$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
$rows = $db->prepare("SELECT id, folder_id FROM client_documents WHERE client_id = ? AND id IN ({$placeholders}) AND deleted_at IS NOT NULL");
$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'];
}
}
dbnToolsRespond(['ok' => true, 'deleted' => $stmt->rowCount()]);
if (!$allowedIds) {
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
{
return [
'id' => (int)($row['id'] ?? 0),
'title' => (string)($row['title'] ?? ''),
'source_type' => (string)($row['source_type'] ?? ''),
'language' => (string)($row['language'] ?? ''),
'category' => (string)($row['category'] ?? ''),
'tags' => (string)($row['tags'] ?? ''),
'author' => $row['author'] ?? null,
'source_url' => $row['source_url'] ?? null,
'source_tool' => $row['source_tool'] ?? null,
'import_method' => (string)($row['import_method'] ?? ''),
'status' => (string)($row['status'] ?? ''),
'word_count' => (int)($row['word_count'] ?? 0),
'chunk_count' => (int)($row['chunk_count'] ?? 0),
'file_size_bytes'=> (int)($row['file_size_bytes'] ?? 0),
'error_message' => $row['error_message'] ?? null,
'created_at' => (string)($row['created_at'] ?? ''),
'updated_at' => (string)($row['updated_at'] ?? ''),
'id' => (int)($row['id'] ?? 0),
'folder_id' => isset($row['folder_id']) && $row['folder_id'] !== null ? (int)$row['folder_id'] : null,
'title' => (string)($row['title'] ?? ''),
'source_type' => (string)($row['source_type'] ?? ''),
'language' => (string)($row['language'] ?? ''),
'category' => (string)($row['category'] ?? ''),
'tags' => (string)($row['tags'] ?? ''),
'author' => $row['author'] ?? null,
'source_url' => $row['source_url'] ?? null,
'source_tool' => $row['source_tool'] ?? null,
'import_method' => (string)($row['import_method'] ?? ''),
'status' => (string)($row['status'] ?? ''),
'word_count' => (int)($row['word_count'] ?? 0),
'chunk_count' => (int)($row['chunk_count'] ?? 0),
'file_size_bytes' => (int)($row['file_size_bytes'] ?? 0),
'original_filename'=> $row['original_filename'] ?? null,
'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
*
* Three input modes:
* - multipart/form-data with `file` field (PDF/DOCX/TXT, <= 8 MB)
* - JSON body { "kind":"text", "title":..., "content":..., "category"?, "tags"?, "author"?, "language"? }
* - JSON body { "kind":"url", "title":..., "url":... } (fetched via ClientUniversalScraper; queued)
* - multipart/form-data with `file` field
* Allowed: pdf, docx, txt, md, html, htm, csv, xlsx, pptx, json (≤8 MB)
* 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,
* returns { ok, document_id, chunks, status }
* 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.
* - JSON body { "kind":"text", "title":..., "content":..., "category"?, "tags"?,
* "author"?, "language"?, "folder_id"?, "version_action"? }
*
* 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);
@@ -27,8 +30,11 @@ try {
} catch (DbnToolsHttpException $e) {
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();
$db = getDb();
@@ -38,13 +44,13 @@ $isMultipart = stripos($contentType, 'multipart/form-data') === 0;
try {
if ($isMultipart) {
$result = handleFileUpload($db, $clientId, $corpusId);
$result = handleFileUpload($db, $clientId, $corpusId, $userId, $tenantRole);
} else {
$input = dbnToolsJsonInput(2_500_000);
$kind = (string)($input['kind'] ?? 'text');
$result = match ($kind) {
'text' => handleTextPaste($db, $clientId, $corpusId, $input),
'url' => handleUrlImport($db, $clientId, $corpusId, $input),
'text' => handleTextPaste($db, $clientId, $corpusId, $userId, $tenantRole, $input),
'url' => handleUrlImport($db, $clientId, $corpusId, $userId, $tenantRole, $input),
default => dbnToolsError('Unknown kind: ' . $kind, 400, 'unknown_kind'),
};
}
@@ -57,12 +63,19 @@ try {
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'])) {
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']);
$text = (string)$extract['text'];
$filename = (string)$extract['filename'];
@@ -71,17 +84,22 @@ function handleFileUpload(PDO $db, int $clientId, int $corpusId): array
$sourceType = match ($ext) {
'pdf' => 'pdf',
'docx' => 'docx',
'xlsx' => 'xlsx',
'pptx' => 'pptx',
'html', 'htm' => 'html',
'csv' => 'csv',
'md' => 'markdown',
default => 'text',
};
$importMethod = 'dbn_upload';
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')) {
$text = $ocrText;
$text = $ocrText;
$importMethod = 'ocr_scan';
}
}
$importMethod = $importMethod ?? 'dbn_upload';
$title = trim((string)($_POST['title'] ?? '')) ?: pathinfo($filename, PATHINFO_FILENAME);
$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;
$language = trim((string)($_POST['language'] ?? 'no')) ?: 'no';
return persistAndIngest($db, $clientId, $corpusId, [
$doc = [
'title' => $title,
'source_type' => $sourceType,
'content' => $text,
@@ -101,18 +119,29 @@ function handleFileUpload(PDO $db, int $clientId, int $corpusId): array
'original_filename' => $filename,
'file_size_bytes' => (int)($_FILES['file']['size'] ?? 0),
'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'] ?? ''));
$content = trim((string)($input['content'] ?? ''));
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 ($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') > 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,
'source_type' => 'text',
'content' => $content,
@@ -122,10 +151,12 @@ function handleTextPaste(PDO $db, int $clientId, int $corpusId, array $input): a
'language' => trim((string)($input['language'] ?? 'no')) ?: 'no',
'import_method' => 'manual',
'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'] ?? ''));
$title = trim((string)($input['title'] ?? ''));
@@ -138,42 +169,155 @@ function handleUrlImport(PDO $db, int $clientId, int $corpusId, array $input): a
}
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("
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)
VALUES (?, ?, ?, 'url', ?, '', ?, ?, ?, 'url', 'dashboard-url', 'pending')
VALUES (?, ?, ?, ?, 'url', ?, '', ?, ?, ?, 'url', 'dashboard-url', 'pending')
");
$stmt->execute([
$clientId, $corpusId, $title, $url,
$clientId, $corpusId, $folderId, $title, $url,
sanitizeCategory((string)($input['category'] ?? 'uncategorized')),
sanitizeTagsCsv((string)($input['tags'] ?? '')),
trim((string)($input['language'] ?? 'no')) ?: 'no',
]);
$docId = (int)$db->lastInsertId();
dbnDmsLogAudit($clientId, $userId ?: null, 'upload', ['mode' => 'url', 'url' => $url], $docId, $folderId);
return [
'ok' => true,
'document_id' => (int)$db->lastInsertId(),
'document_id' => $docId,
'status' => 'pending',
'chunks' => 0,
'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("
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,
import_method, source_tool, word_count, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
import_method, source_tool, word_count, status, current_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 1)
");
$stmt->execute([
$clientId,
$corpusId,
$doc['folder_id'] ?? null,
$doc['title'],
$doc['source_type'],
$doc['original_filename'] ?? null,
@@ -189,9 +333,21 @@ function persistAndIngest(PDO $db, int $clientId, int $corpusId, array $doc): ar
]);
$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 {
$rag = new ClientRagPipeline($clientId);
$chunks = $rag->ingestDocument($docId);
dbnDmsLogAudit($clientId, $userId ?: null, 'upload',
['source_type' => $doc['source_type'], 'word_count' => $wordCount],
$docId, $doc['folder_id'] ?? null);
return [
'ok' => true,
'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
{
$cat = strtolower(trim($cat));