2e2b0b45fa
Rebuild the dashboard as a Drive-style document management system on top of the existing CaveauAI hybrid RAG pipeline. Backend: - 5 migrations (versions, trash soft-delete, saved searches, categories, audit) - DMS helpers (folder ACL walker, disk storage, audit, version snapshot, XLSX/PPTX/HTML/CSV/MD extractors) - New APIs: folders, document-versions, trash, bulk, preview, saved-searches, categories, diagnostics - Extended APIs: documents (folder_id, soft-delete, ACL filter, sort), upload (9 file types, version-collision detection with replace/new/keep-both, disk persistence), chat-stream (folder scoping + graph related-documents) - 30-day trash purge cron with Qdrant + disk + graph cleanup Frontend: - Drive-style two-pane browser with folder tree, drag-drop, bulk-action bar, right-click context menu, multi-select - New pages: folders (tree + per-folder ACL editor), trash (restore/purge) - Extended pages: upload (folder picker, version-collision modal, 9 file type chips), document (Preview/Versions/Permissions tabs with PDF.js + mammoth.js + audio), index (DMS KPIs + activity feed), settings (live diagnostics ping MariaDB/Qdrant/LiteLLM/FalkorDB/disk), chat (folder scope chips + related-authorities chips) - New CSS (dms.css) + JS bundle (dms.js) exposing window.DBN_DMS - Sidebar nav adds Folders + Trash items All routes return HTTP 200 in local smoke test; all 32 files lint clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
504 lines
19 KiB
PHP
504 lines
19 KiB
PHP
<?php
|
|
/**
|
|
* /api/dashboard/documents.php — CRUD for the current tenant's documents.
|
|
*
|
|
* GET ?action=list &offset=&limit=&q=&status=&category=&source_type=
|
|
* &folder_id=(int|'unassigned'|'all')&include_subfolders=1
|
|
* &trashed=0|1&sort=updated_at|title|file_size_bytes&dir=asc|desc
|
|
* → { ok, total, documents: [...], folder?: {...} }
|
|
*
|
|
* GET ?action=get&id=123
|
|
* → { ok, document: {...}, chunks: [...], versions: [...], permissions: {...} }
|
|
*
|
|
* POST ?action=update body: { id, title?, category?, tags?, language?, author?, folder_id? }
|
|
* → { ok, document: {...} }
|
|
*
|
|
* POST ?action=delete body: { ids: [1,2,3], hard_delete?: false }
|
|
* → { ok, deleted: N }
|
|
*
|
|
* POST ?action=restore body: { ids: [1,2,3] }
|
|
* → { ok, restored: N }
|
|
*
|
|
* All tenant-isolated via dbnToolsEnsureDashboardTenant().
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
|
|
|
|
dbnToolsRequireAuth();
|
|
|
|
try {
|
|
$tenant = dbnToolsEnsureDashboardTenant();
|
|
} catch (DbnToolsHttpException $e) {
|
|
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
|
|
}
|
|
|
|
$clientId = (int)$tenant['client_id'];
|
|
$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();
|
|
|
|
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, int $userId, string $tenantRole): void
|
|
{
|
|
$offset = max(0, (int)($_GET['offset'] ?? 0));
|
|
$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) . '%';
|
|
$params[] = $like;
|
|
$params[] = $like;
|
|
}
|
|
if ($status !== '' && in_array($status, ['pending','processing','ready','error'], true)) {
|
|
$where[] = 'status = ?';
|
|
$params[] = $status;
|
|
}
|
|
if ($category !== '') {
|
|
$where[] = 'category = ?';
|
|
$params[] = $category;
|
|
}
|
|
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, folder_id, title, source_type, language, category, tags, author,
|
|
source_tool, import_method, status, word_count, chunk_count,
|
|
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 {$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' => $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, 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->execute([$id, $clientId]);
|
|
$doc = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
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');
|
|
}
|
|
|
|
$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'] ?? ''),
|
|
'breadcrumb' => dbnDmsBreadcrumb($fid ?: null, $clientId),
|
|
],
|
|
'chunks' => $chunkRows,
|
|
'versions' => $versions,
|
|
'permissions' => $permissions,
|
|
]);
|
|
}
|
|
|
|
function respondUpdate(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');
|
|
}
|
|
|
|
// 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 = [];
|
|
foreach ($allowed as $col => $max) {
|
|
if (!array_key_exists($col, $input)) {
|
|
continue;
|
|
}
|
|
$val = trim((string)$input[$col]);
|
|
if (mb_strlen($val, 'UTF-8') > $max) {
|
|
dbnToolsError("Field {$col} exceeds {$max} chars.", 422, 'field_too_long');
|
|
}
|
|
$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');
|
|
}
|
|
$params[] = $id;
|
|
$params[] = $clientId;
|
|
|
|
$stmt = $db->prepare(
|
|
'UPDATE client_documents SET ' . implode(', ', $fields)
|
|
. ', updated_at = NOW() WHERE id = ? AND client_id = ?'
|
|
);
|
|
$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);
|
|
|
|
dbnToolsRespond(['ok' => true, 'document' => shapeDoc($doc ?: [])]);
|
|
}
|
|
|
|
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'] ?? [];
|
|
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');
|
|
}
|
|
|
|
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
|
$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'];
|
|
}
|
|
}
|
|
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),
|
|
'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;
|
|
}
|