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:
@@ -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()];
|
||||
}
|
||||
@@ -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, 1–50, [a-z0-9-_]).', 422, 'invalid_slug');
|
||||
}
|
||||
if ($label === '' || mb_strlen($label, 'UTF-8') > 100) {
|
||||
dbnToolsError('Label is required (1–100).', 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)]);
|
||||
}
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (1–200 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 (1–200) 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 (1–120 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;
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user