diff --git a/api/dashboard/bulk.php b/api/dashboard/bulk.php new file mode 100644 index 0000000..f892dcd --- /dev/null +++ b/api/dashboard/bulk.php @@ -0,0 +1,183 @@ +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()]; +} diff --git a/api/dashboard/categories.php b/api/dashboard/categories.php new file mode 100644 index 0000000..6a941ba --- /dev/null +++ b/api/dashboard/categories.php @@ -0,0 +1,209 @@ +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)]); +} diff --git a/api/dashboard/chat-stream.php b/api/dashboard/chat-stream.php index 23b1516..c4e6749 100644 --- a/api/dashboard/chat-stream.php +++ b/api/dashboard/chat-stream.php @@ -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()]); diff --git a/api/dashboard/diagnostics.php b/api/dashboard/diagnostics.php new file mode 100644 index 0000000..79b9620 --- /dev/null +++ b/api/dashboard/diagnostics.php @@ -0,0 +1,165 @@ +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, +]); diff --git a/api/dashboard/document-versions.php b/api/dashboard/document-versions.php new file mode 100644 index 0000000..4149390 --- /dev/null +++ b/api/dashboard/document-versions.php @@ -0,0 +1,206 @@ +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]); +} diff --git a/api/dashboard/documents.php b/api/dashboard/documents.php index fd82423..47783c7 100644 --- a/api/dashboard/documents.php +++ b/api/dashboard/documents.php @@ -1,17 +1,25 @@ 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; +} diff --git a/api/dashboard/folders.php b/api/dashboard/folders.php new file mode 100644 index 0000000..4877106 --- /dev/null +++ b/api/dashboard/folders.php @@ -0,0 +1,493 @@ +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); + } + } +} diff --git a/api/dashboard/preview.php b/api/dashboard/preview.php new file mode 100644 index 0000000..089e731 --- /dev/null +++ b/api/dashboard/preview.php @@ -0,0 +1,118 @@ + 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; diff --git a/api/dashboard/saved-searches.php b/api/dashboard/saved-searches.php new file mode 100644 index 0000000..3e206b7 --- /dev/null +++ b/api/dashboard/saved-searches.php @@ -0,0 +1,222 @@ +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; +} diff --git a/api/dashboard/trash.php b/api/dashboard/trash.php new file mode 100644 index 0000000..a0654b2 --- /dev/null +++ b/api/dashboard/trash.php @@ -0,0 +1,257 @@ +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); +} diff --git a/api/dashboard/upload.php b/api/dashboard/upload.php index d96521a..06f842d 100644 --- a/api/dashboard/upload.php +++ b/api/dashboard/upload.php @@ -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)); diff --git a/assets/css/dms.css b/assets/css/dms.css new file mode 100644 index 0000000..cc37544 --- /dev/null +++ b/assets/css/dms.css @@ -0,0 +1,719 @@ +/* ============================================================ + * DMS — Document Management System styles + * Two-pane Drive-style browser, folder tree, list, modals. + * Reuses dashboard.css design tokens (paper, navy, red, gold, line). + * ============================================================ */ + +:root { + --dms-tree-w: 240px; + --dms-row-h: 48px; + --dms-radius: 10px; + --dms-radius-sm: 6px; + --dms-stroke: rgba(22,19,15,0.16); + --dms-stroke-soft: rgba(22,19,15,0.08); + --dms-hover: rgba(0,32,91,0.06); + --dms-selected: rgba(184,138,44,0.16); + --dms-paper: #f6f2ea; + --dms-navy: #00205b; + --dms-red: #ba0c2f; + --dms-gold: #b88a2c; + --dms-shadow-soft: 0 1px 0 var(--dms-stroke); + --dms-shadow-modal: 0 12px 48px rgba(0,0,0,0.18); +} + +/* ─── Drive-style two-pane shell (lives inside .dash-main__body) ─── */ +.dms-shell { + display: grid; + grid-template-columns: var(--dms-tree-w) minmax(0, 1fr); + gap: 16px; + min-height: 60vh; +} +.dms-shell--single { grid-template-columns: 1fr; } + +/* ─── Tree sidebar ─── */ +.dms-tree { + background: #fff; + border: 1px solid var(--dms-stroke); + border-radius: var(--dms-radius); + padding: 8px 6px; + height: fit-content; + position: sticky; + top: 16px; + max-height: calc(100vh - 32px); + overflow-y: auto; +} +.dms-tree__section { + padding: 8px 8px 4px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(22,19,15,0.55); + font-weight: 600; +} +.dms-tree__list { list-style: none; margin: 0; padding: 0; } +.dms-tree__node { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: var(--dms-radius-sm); + cursor: pointer; + user-select: none; + font-size: 14px; + color: #1a1c2c; + line-height: 1.2; +} +.dms-tree__node:hover { background: var(--dms-hover); } +.dms-tree__node.is-active { + background: var(--dms-selected); + color: var(--dms-navy); + font-weight: 600; +} +.dms-tree__node.is-drop-target { + background: rgba(0,32,91,0.10); + outline: 2px dashed var(--dms-navy); + outline-offset: -2px; +} +.dms-tree__caret { + width: 14px; + height: 14px; + flex: 0 0 14px; + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 0.15s; + opacity: 0.6; +} +.dms-tree__caret.is-open { transform: rotate(90deg); } +.dms-tree__caret--empty { visibility: hidden; } +.dms-tree__icon { width: 16px; flex: 0 0 16px; } +.dms-tree__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--dms-stroke); + flex: 0 0 8px; +} +.dms-tree__label { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dms-tree__count { + font-size: 11px; + color: rgba(22,19,15,0.55); + background: var(--dms-stroke-soft); + padding: 1px 6px; + border-radius: 999px; +} +.dms-tree__node.is-active .dms-tree__count { + background: rgba(255,255,255,0.6); + color: var(--dms-navy); +} +.dms-tree__children { + list-style: none; + margin: 0; + padding-left: 18px; +} +.dms-tree__btn { + width: 100%; + background: none; + border: 0; + padding: 6px 8px; + text-align: left; + color: var(--dms-navy); + cursor: pointer; + border-radius: var(--dms-radius-sm); + font-weight: 600; + font-size: 13px; + margin-top: 4px; +} +.dms-tree__btn:hover { background: var(--dms-hover); } + +/* ─── Main pane ─── */ +.dms-main { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} +.dms-toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + background: #fff; + border: 1px solid var(--dms-stroke); + border-radius: var(--dms-radius); + padding: 10px 12px; +} +.dms-toolbar__crumbs { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + font-size: 14px; + color: #1a1c2c; + min-width: 0; +} +.dms-toolbar__crumbs a { + color: inherit; + text-decoration: none; + padding: 2px 6px; + border-radius: 4px; +} +.dms-toolbar__crumbs a:hover { background: var(--dms-hover); } +.dms-toolbar__crumb-sep { opacity: 0.4; font-size: 12px; } +.dms-toolbar__crumb--current { font-weight: 600; color: var(--dms-navy); } +.dms-toolbar__actions { + margin-left: auto; + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.dms-filters { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + padding: 8px 12px; + background: #fdfaf3; + border: 1px solid var(--dms-stroke-soft); + border-radius: var(--dms-radius); +} +.dms-filters input[type=search], +.dms-filters select { + border: 1px solid var(--dms-stroke); + border-radius: var(--dms-radius-sm); + padding: 6px 10px; + background: #fff; + font-size: 13px; + min-width: 0; +} +.dms-filters input[type=search] { flex: 1 1 240px; } + +/* Chips */ +.dms-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + background: var(--dms-stroke-soft); + font-size: 12px; + color: #1a1c2c; +} +.dms-chip--cat { background: rgba(0,32,91,0.10); color: var(--dms-navy); } +.dms-chip--tag { background: rgba(184,138,44,0.16); color: #6c5212; } +.dms-chip--folder{ background: rgba(255,255,255,0.6); color: var(--dms-navy); border: 1px solid var(--dms-stroke-soft); } +.dms-chip__x { + cursor: pointer; + opacity: 0.6; + margin-left: 2px; +} +.dms-chip__x:hover { opacity: 1; } + +/* ─── Doc list (table style) ─── */ +.dms-list { + background: #fff; + border: 1px solid var(--dms-stroke); + border-radius: var(--dms-radius); + overflow: hidden; +} +.dms-list__head, +.dms-list__row { + display: grid; + grid-template-columns: 36px minmax(0,3.5fr) 1.2fr 1.2fr 110px 90px 36px; + gap: 8px; + align-items: center; + padding: 8px 12px; +} +.dms-list__head { + background: #fdfaf3; + border-bottom: 1px solid var(--dms-stroke); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: rgba(22,19,15,0.65); + font-weight: 600; +} +.dms-list__head button { + background: none; + border: 0; + padding: 0; + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; + text-transform: inherit; + letter-spacing: inherit; +} +.dms-list__head button.is-sorted { color: var(--dms-navy); } +.dms-list__row { + border-bottom: 1px solid var(--dms-stroke-soft); + font-size: 14px; + cursor: pointer; + transition: background 0.1s; +} +.dms-list__row:hover { background: var(--dms-hover); } +.dms-list__row.is-selected { background: var(--dms-selected); } +.dms-list__row.is-dragging { opacity: 0.4; } +.dms-list__row[data-status="pending"] { color: #6c5212; } +.dms-list__row[data-status="processing"] { color: #6c5212; } +.dms-list__row[data-status="error"] { color: var(--dms-red); } +.dms-list__title { + font-weight: 600; + color: var(--dms-navy); + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.dms-list__title-icon { + width: 18px; + flex: 0 0 18px; + opacity: 0.7; +} +.dms-list__cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } +.dms-list__cell--muted { color: rgba(22,19,15,0.55); font-size: 13px; } +.dms-list__more { + background: none; + border: 0; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + opacity: 0.5; +} +.dms-list__more:hover { opacity: 1; background: var(--dms-hover); } +.dms-list__empty { + padding: 60px 24px; + text-align: center; + color: rgba(22,19,15,0.55); +} +.dms-list__empty strong { + display: block; + color: var(--dms-navy); + font-size: 16px; + margin-bottom: 6px; +} + +/* ─── Bulk-action bar (sticky bottom of list when selection > 0) ─── */ +.dms-bulk-bar { + position: sticky; + bottom: 12px; + margin-top: 8px; + background: var(--dms-navy); + color: #fff; + padding: 8px 12px; + border-radius: var(--dms-radius); + box-shadow: 0 6px 24px rgba(0,32,91,0.30); + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + z-index: 10; +} +.dms-bulk-bar__count { + font-weight: 600; + margin-right: 4px; +} +.dms-bulk-bar__btn { + background: rgba(255,255,255,0.10); + border: 1px solid rgba(255,255,255,0.20); + color: #fff; + padding: 4px 10px; + border-radius: var(--dms-radius-sm); + cursor: pointer; + font-size: 13px; +} +.dms-bulk-bar__btn:hover { background: rgba(255,255,255,0.20); } +.dms-bulk-bar__btn--danger { background: var(--dms-red); border-color: var(--dms-red); } +.dms-bulk-bar__btn--danger:hover { background: #8c0822; } +.dms-bulk-bar__cancel { + margin-left: auto; + background: none; + border: 0; + color: rgba(255,255,255,0.7); + cursor: pointer; + padding: 4px 8px; + font-size: 13px; +} + +/* ─── Context menu (right-click) ─── */ +.dms-ctx-menu { + position: fixed; + z-index: 50; + background: #fff; + border: 1px solid var(--dms-stroke); + border-radius: var(--dms-radius-sm); + box-shadow: var(--dms-shadow-modal); + padding: 4px; + min-width: 180px; + font-size: 13px; +} +.dms-ctx-menu__item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 4px; + cursor: pointer; + color: #1a1c2c; +} +.dms-ctx-menu__item:hover { background: var(--dms-hover); } +.dms-ctx-menu__item--danger { color: var(--dms-red); } +.dms-ctx-menu__sep { + height: 1px; + background: var(--dms-stroke-soft); + margin: 4px 0; +} + +/* ─── Modal ─── */ +.dms-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(10,15,30,0.40); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} +.dms-modal { + background: #fff; + border-radius: var(--dms-radius); + box-shadow: var(--dms-shadow-modal); + max-width: 520px; + width: 100%; + overflow: hidden; +} +.dms-modal--wide { max-width: 760px; } +.dms-modal__head { + padding: 16px 20px; + border-bottom: 1px solid var(--dms-stroke-soft); + display: flex; + align-items: center; + gap: 8px; +} +.dms-modal__title { + font-size: 17px; + font-weight: 600; + color: var(--dms-navy); + margin: 0; +} +.dms-modal__body { + padding: 16px 20px; + max-height: 70vh; + overflow-y: auto; +} +.dms-modal__foot { + padding: 12px 20px; + border-top: 1px solid var(--dms-stroke-soft); + display: flex; + gap: 8px; + justify-content: flex-end; + flex-wrap: wrap; +} +.dms-modal__close { + margin-left: auto; + background: none; + border: 0; + font-size: 22px; + line-height: 1; + color: rgba(22,19,15,0.55); + cursor: pointer; + padding: 0 6px; +} + +/* ─── Form bits ─── */ +.dms-field { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; +} +.dms-field label { + font-size: 12px; + font-weight: 600; + color: rgba(22,19,15,0.7); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.dms-field input, +.dms-field select, +.dms-field textarea { + border: 1px solid var(--dms-stroke); + border-radius: var(--dms-radius-sm); + padding: 8px 10px; + font-size: 14px; + background: #fff; + font-family: inherit; +} +.dms-field textarea { min-height: 80px; resize: vertical; } +.dms-field--inline { + flex-direction: row; + align-items: center; + gap: 8px; +} + +/* ─── Tabs (document.php) ─── */ +.dms-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--dms-stroke); + margin-bottom: 16px; +} +.dms-tab { + padding: 10px 16px; + border: 0; + background: none; + cursor: pointer; + font-size: 14px; + color: rgba(22,19,15,0.65); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + font-weight: 500; +} +.dms-tab.is-active { + color: var(--dms-navy); + border-bottom-color: var(--dms-navy); + font-weight: 600; +} +.dms-tab__pill { + display: inline-block; + background: var(--dms-stroke-soft); + color: rgba(22,19,15,0.7); + border-radius: 999px; + font-size: 11px; + padding: 1px 7px; + margin-left: 4px; +} +.dms-tab-panel { display: none; } +.dms-tab-panel.is-active { display: block; } + +/* ─── Version timeline ─── */ +.dms-version { + display: grid; + grid-template-columns: 60px 1fr auto; + gap: 12px; + align-items: start; + padding: 12px; + border: 1px solid var(--dms-stroke-soft); + border-radius: var(--dms-radius); + margin-bottom: 8px; +} +.dms-version__num { + background: var(--dms-navy); + color: #fff; + border-radius: var(--dms-radius-sm); + padding: 4px 8px; + text-align: center; + font-weight: 600; + font-size: 12px; +} +.dms-version__meta { + font-size: 13px; + color: rgba(22,19,15,0.7); +} +.dms-version__title { + color: var(--dms-navy); + font-weight: 600; + font-size: 14px; +} +.dms-version__actions { display: flex; gap: 6px; } +.dms-version--current { + border-left: 3px solid var(--dms-gold); + background: #fdfaf3; +} + +/* ─── Permissions panel ─── */ +.dms-perm-row { + display: grid; + grid-template-columns: 1fr 70px 70px 70px 32px; + gap: 8px; + align-items: center; + padding: 8px 12px; + border-bottom: 1px solid var(--dms-stroke-soft); + font-size: 13px; +} +.dms-perm-row__head { + background: #fdfaf3; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: rgba(22,19,15,0.65); + font-weight: 600; +} + +/* ─── Preview frames ─── */ +.dms-preview-frame { + width: 100%; + height: 70vh; + border: 1px solid var(--dms-stroke); + border-radius: var(--dms-radius); + background: #f9f7f1; +} +.dms-preview-audio { + width: 100%; + margin: 20px 0; +} + +/* ─── Drop overlay for upload drag-anywhere ─── */ +.dms-drop-overlay { + position: fixed; + inset: 0; + background: rgba(0,32,91,0.85); + color: #fff; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 12px; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s; + font-size: 24px; + font-weight: 600; +} +.dms-drop-overlay.is-visible { opacity: 1; } + +/* ─── KPI tiles ─── */ +.dms-kpis { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} +.dms-kpi { + background: #fff; + border: 1px solid var(--dms-stroke); + border-radius: var(--dms-radius); + padding: 14px 16px; +} +.dms-kpi__label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: rgba(22,19,15,0.6); + font-weight: 600; + margin: 0 0 6px; +} +.dms-kpi__value { + font-size: 28px; + line-height: 1; + color: var(--dms-navy); + font-weight: 700; + margin: 0; +} +.dms-kpi__hint { + font-size: 12px; + color: rgba(22,19,15,0.55); + margin: 6px 0 0; +} + +/* ─── Activity feed ─── */ +.dms-activity { + background: #fff; + border: 1px solid var(--dms-stroke); + border-radius: var(--dms-radius); + padding: 8px 0; +} +.dms-activity__row { + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 10px; + align-items: center; + padding: 8px 16px; + font-size: 13px; + border-bottom: 1px solid var(--dms-stroke-soft); +} +.dms-activity__row:last-child { border-bottom: 0; } +.dms-activity__icon { + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--dms-stroke-soft); + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--dms-navy); + font-size: 12px; +} +.dms-activity__time { + font-size: 11px; + color: rgba(22,19,15,0.5); +} + +/* ─── Settings diagnostics ─── */ +.dms-diag { + background: #fff; + border: 1px solid var(--dms-stroke); + border-radius: var(--dms-radius); + overflow: hidden; +} +.dms-diag__row { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: 12px; + padding: 10px 16px; + border-bottom: 1px solid var(--dms-stroke-soft); + font-size: 13px; + align-items: center; +} +.dms-diag__row:last-child { border-bottom: 0; } +.dms-diag__label { font-weight: 600; color: var(--dms-navy); } +.dms-diag__value { font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; font-size: 12px; } +.dms-diag__status { + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; +} +.dms-diag__status--ok { background: rgba(4,120,87,0.12); color: #047857; } +.dms-diag__status--warn { background: rgba(180,138,44,0.16); color: #6c5212; } +.dms-diag__status--err { background: rgba(186,12,47,0.12); color: var(--dms-red); } + +/* ─── Loading / empty states ─── */ +.dms-loading { + padding: 40px 16px; + text-align: center; + color: rgba(22,19,15,0.5); + font-size: 14px; +} +.dms-loading::before { + content: ''; + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid var(--dms-stroke); + border-top-color: var(--dms-navy); + border-radius: 50%; + animation: dms-spin 0.8s linear infinite; + margin-right: 8px; + vertical-align: middle; +} +@keyframes dms-spin { to { transform: rotate(360deg); } } + +/* ─── Responsive ─── */ +@media (max-width: 880px) { + .dms-shell { + grid-template-columns: 1fr; + } + .dms-tree { + position: relative; + max-height: 280px; + } + .dms-list__head, + .dms-list__row { + grid-template-columns: 36px 1fr 90px 36px; + } + .dms-list__head > :nth-child(n+4):nth-child(-n+6), + .dms-list__row > :nth-child(n+4):nth-child(-n+6) { + display: none; + } +} diff --git a/assets/js/dashboard/dms.js b/assets/js/dashboard/dms.js new file mode 100644 index 0000000..08a2c97 --- /dev/null +++ b/assets/js/dashboard/dms.js @@ -0,0 +1,696 @@ +/** + * dms.js — Drive-style DMS interactivity bundle. + * + * Exposes window.DBN_DMS with helpers used by documents.php, folders.php, + * trash.php, document.php. Vanilla JS, no build step. + */ +(function () { + 'use strict'; + const API = (window.DBN_DASHBOARD && window.DBN_DASHBOARD.apiBase) || '/api/dashboard'; + const I18N = window.DBN_I18N || {}; + const LOC = I18N.locale || 'en-GB'; + + /* ─── utils ─── */ + function $(sel, ctx) { return (ctx || document).querySelector(sel); } + function $$(sel, ctx) { return Array.from((ctx || document).querySelectorAll(sel)); } + function safe(s) { + return String(s == null ? '' : s) + .replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c])); + } + function fmtBytes(n) { + n = Number(n) || 0; + if (n < 1024) return n + ' B'; + if (n < 1024*1024) return (n/1024).toFixed(0) + ' KB'; + if (n < 1024*1024*1024) return (n/1024/1024).toFixed(1) + ' MB'; + return (n/1024/1024/1024).toFixed(2) + ' GB'; + } + function fmtDate(s) { + if (!s) return '—'; + try { return new Date(String(s).replace(' ', 'T') + 'Z') + .toLocaleDateString(LOC, { day:'numeric', month:'short', year:'numeric' }); } + catch (_) { return s; } + } + function fmtRelative(s) { + if (!s) return '—'; + const d = new Date(String(s).replace(' ', 'T') + 'Z'); + const diff = (Date.now() - d.getTime()) / 1000; + if (diff < 60) return I18N.just_now || 'just now'; + if (diff < 3600) return Math.floor(diff/60) + 'm'; + if (diff < 86400) return Math.floor(diff/3600) + 'h'; + if (diff < 86400*7) return Math.floor(diff/86400) + 'd'; + return fmtDate(s); + } + function fileIcon(srcType) { + const map = { pdf:'📄', docx:'📝', xlsx:'📊', pptx:'📑', csv:'📋', + html:'🌐', markdown:'📔', text:'📃', audio:'🔊', url:'🔗' }; + return map[srcType] || '📄'; + } + + async function api(path, opts) { + opts = opts || {}; + const headers = Object.assign({ 'Accept': 'application/json' }, opts.headers || {}); + let body = opts.body; + if (body && typeof body === 'object' && !(body instanceof FormData)) { + body = JSON.stringify(body); + headers['Content-Type'] = 'application/json'; + } + const res = await fetch(API + path, { + method: opts.method || 'GET', + credentials: 'same-origin', + headers, + body, + }); + let json = null; + try { json = await res.json(); } catch (_) { /* may be a stream */ } + if (!res.ok) { + const err = new Error((json && json.message) || ('HTTP ' + res.status)); + err.status = res.status; + err.payload = json; + throw err; + } + return json; + } + + /* ─── Folder tree ─── */ + const Tree = { + state: { tree: [], activeFolderId: null, unassignedCount: 0, trashCount: 0, maxDepth: 2 }, + + async load() { + const data = await api('/folders.php?action=list_tree'); + this.state.tree = data.tree || []; + this.state.unassignedCount = data.unassigned_count || 0; + this.state.trashCount = data.trash_count || 0; + this.state.maxDepth = data.max_depth || 2; + return this.state; + }, + + render(container, opts) { + opts = opts || {}; + const active = String(opts.activeFolderId == null ? '' : opts.activeFolderId); + const html = []; + html.push('
' + safe( + (I18N.dms_collision_body || 'A document with the same title already exists in this folder ({name}). What should we do?').replace('{name}', filename) + ) + '
', + actions: [ + { label: I18N.dms_cancel || 'Cancel', cls: 'dash-btn', onclick: () => { closeModal(); resolve(null); } }, + { label: I18N.dms_keep_both || 'Keep both', cls: 'dash-btn', + onclick: () => { closeModal(); resolve('force_separate'); } }, + { label: I18N.dms_save_version || 'Save as new version', cls: 'dash-btn dash-btn--primary', + onclick: () => { closeModal(); resolve('new'); } }, + ], + }); + }); + } + + /* ─── Public surface ─── */ + const DMS = { + api, safe, fmtDate, fmtRelative, fmtBytes, fileIcon, + Tree, List, Smart, + openModal, closeModal, openCtxMenu, closeCtxMenu, + folderModal, + pickFolder, installDropAnywhereUpload, uploadOne, chooseCollisionAction, + }; + window.DBN_DMS = DMS; +})(); diff --git a/cron/dbn-dms-trash-purge.php b/cron/dbn-dms-trash-purge.php new file mode 100644 index 0000000..0a5b850 --- /dev/null +++ b/cron/dbn-dms-trash-purge.php @@ -0,0 +1,122 @@ +> /home/dobetternorge/logs/dms-purge.log 2>&1 + */ + +declare(strict_types=1); + +if (PHP_SAPI !== 'cli') { + http_response_code(403); + exit("CLI only.\n"); +} + +require_once dirname(__DIR__) . '/includes/bootstrap.php'; + +$dryRun = in_array('--dry-run', $argv ?? [], true); +$cutoff = DBN_DMS_TRASH_RETENTION_DAYS; + +try { + dbnToolsBootCaveau(); + $db = getDb(); +} catch (Throwable $e) { + fwrite(STDERR, "[dbn-dms-purge] DB connect failed: " . $e->getMessage() . "\n"); + exit(1); +} + +$start = microtime(true); +$purgedDocs = 0; +$purgedFolders = 0; +$skipped = 0; +$errors = 0; + +try { + $docs = $db->prepare( + "SELECT id, client_id, storage_path + FROM client_documents + WHERE deleted_at IS NOT NULL AND deleted_at < (NOW() - INTERVAL ? DAY) + LIMIT 5000" + ); + $docs->execute([$cutoff]); + foreach ($docs->fetchAll(PDO::FETCH_ASSOC) as $row) { + $docId = (int)$row['id']; + $clientId = (int)$row['client_id']; + $path = $row['storage_path'] ?? null; + + if ($dryRun) { + echo "DRY: would purge document {$docId} (client {$clientId})\n"; + $skipped++; + continue; + } + + try { + // Version-file cleanup + $vr = $db->prepare('SELECT storage_path FROM client_document_versions WHERE document_id = ? AND client_id = ?'); + $vr->execute([$docId, $clientId]); + foreach ($vr->fetchAll() as $v) { + if (!empty($v['storage_path']) && is_file($v['storage_path'])) @unlink($v['storage_path']); + } + + // Chunks + try { + $db->prepare('DELETE FROM client_chunks WHERE client_id = ? AND document_id = ?')->execute([$clientId, $docId]); + } catch (Throwable $e) { /* tolerated */ } + + // Qdrant + try { + if (class_exists('QdrantClient')) { + $qd = new QdrantClient(); + $qd->deleteByFilter('bnl_client_chunks', [ + 'must' => [ + ['key' => 'client_id', 'match' => ['value' => $clientId]], + ['key' => 'document_id', 'match' => ['value' => $docId]], + ], + ]); + } + } catch (Throwable $e) { /* tolerated */ } + + // Disk + if ($path && is_file($path)) @unlink($path); + if ($path) { + $verDir = dirname($path) . '/' . $docId . '_versions'; + if (is_dir($verDir)) { + foreach (glob($verDir . '/*') ?: [] as $f) @unlink($f); + @rmdir($verDir); + } + } + + // Row + $db->prepare('DELETE FROM client_documents WHERE id = ? AND client_id = ?')->execute([$docId, $clientId]); + $purgedDocs++; + } catch (Throwable $e) { + $errors++; + fwrite(STDERR, "[dbn-dms-purge] doc {$docId}: " . $e->getMessage() . "\n"); + } + } + + if ($dryRun) { + $foldCount = $db->prepare("SELECT COUNT(*) FROM client_folders WHERE deleted_at IS NOT NULL AND deleted_at < (NOW() - INTERVAL ? DAY)"); + $foldCount->execute([$cutoff]); + $skipped += (int)$foldCount->fetchColumn(); + echo "DRY: would purge {$skipped} folders\n"; + } else { + $fold = $db->prepare("DELETE FROM client_folders WHERE deleted_at IS NOT NULL AND deleted_at < (NOW() - INTERVAL ? DAY)"); + $fold->execute([$cutoff]); + $purgedFolders = $fold->rowCount(); + } +} catch (Throwable $e) { + fwrite(STDERR, "[dbn-dms-purge] fatal: " . $e->getMessage() . "\n"); + exit(1); +} + +$elapsed = round(microtime(true) - $start, 2); +$mode = $dryRun ? 'DRY-RUN' : 'live'; +printf("[dbn-dms-purge] %s done in %ss: docs=%d folders=%d errors=%d skipped=%d\n", + $mode, $elapsed, $purgedDocs, $purgedFolders, $errors, $skipped); diff --git a/dashboard/chat.php b/dashboard/chat.php index a13d3a0..d7fcecd 100644 --- a/dashboard/chat.php +++ b/dashboard/chat.php @@ -11,6 +11,22 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';