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>
494 lines
20 KiB
PHP
494 lines
20 KiB
PHP
<?php
|
||
/**
|
||
* /api/dashboard/folders.php — folder tree CRUD + per-folder ACLs.
|
||
*
|
||
* GET ?action=list_tree → { ok, tree: [...] }
|
||
* GET ?action=get_breadcrumb&folder_id=X → { ok, breadcrumb: [...] }
|
||
* POST ?action=create body: { parent_id?, name, color?, description? }
|
||
* POST ?action=rename body: { folder_id, name }
|
||
* POST ?action=recolor body: { folder_id, color }
|
||
* POST ?action=move body: { folder_id, parent_id|null }
|
||
* POST ?action=delete body: { folder_id } (soft delete — docs become Unassigned via SET NULL)
|
||
* POST ?action=set_permission body: { folder_id, min_role?, user_id?, can_read?, can_write?, can_manage? }
|
||
* POST ?action=remove_permission body: { permission_id }
|
||
* GET ?action=list_permissions&folder_id=X → { ok, permissions: [...] }
|
||
*
|
||
* Tenant-scoped via dbnToolsEnsureDashboardTenant().
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
|
||
|
||
dbnToolsRequireAuth();
|
||
|
||
try {
|
||
$tenant = dbnToolsEnsureDashboardTenant();
|
||
} catch (DbnToolsHttpException $e) {
|
||
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
|
||
}
|
||
|
||
$clientId = (int)$tenant['client_id'];
|
||
$corpusId = (int)$tenant['corpus_id'];
|
||
$clientUser = (int)($tenant['client_user_id'] ?? 0);
|
||
$tenantRole = (string)($tenant['role'] ?? 'editor');
|
||
|
||
$db = dbnToolsDb();
|
||
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||
$action = (string)($_GET['action'] ?? ($method === 'POST' ? '' : 'list_tree'));
|
||
|
||
try {
|
||
switch ($action) {
|
||
case 'list_tree':
|
||
dbnToolsRequireMethod('GET');
|
||
respondTree($db, $clientId, $corpusId, $clientUser, $tenantRole);
|
||
break;
|
||
case 'get_breadcrumb':
|
||
dbnToolsRequireMethod('GET');
|
||
$fid = (int)($_GET['folder_id'] ?? 0);
|
||
dbnToolsRespond(['ok' => true, 'breadcrumb' => dbnDmsBreadcrumb($fid ?: null, $clientId)]);
|
||
break;
|
||
case 'create':
|
||
dbnToolsRequireMethod('POST');
|
||
respondCreate($db, $clientId, $corpusId, $clientUser, $tenantRole);
|
||
break;
|
||
case 'rename':
|
||
dbnToolsRequireMethod('POST');
|
||
respondRename($db, $clientId, $clientUser, $tenantRole);
|
||
break;
|
||
case 'recolor':
|
||
dbnToolsRequireMethod('POST');
|
||
respondRecolor($db, $clientId, $clientUser, $tenantRole);
|
||
break;
|
||
case 'move':
|
||
dbnToolsRequireMethod('POST');
|
||
respondMove($db, $clientId, $clientUser, $tenantRole);
|
||
break;
|
||
case 'delete':
|
||
dbnToolsRequireMethod('POST');
|
||
respondDelete($db, $clientId, $clientUser, $tenantRole);
|
||
break;
|
||
case 'list_permissions':
|
||
dbnToolsRequireMethod('GET');
|
||
respondListPermissions($db, $clientId, $clientUser, $tenantRole);
|
||
break;
|
||
case 'set_permission':
|
||
dbnToolsRequireMethod('POST');
|
||
respondSetPermission($db, $clientId, $clientUser, $tenantRole);
|
||
break;
|
||
case 'remove_permission':
|
||
dbnToolsRequireMethod('POST');
|
||
respondRemovePermission($db, $clientId, $clientUser, $tenantRole);
|
||
break;
|
||
default:
|
||
dbnToolsError('Unknown action.', 400, 'unknown_action');
|
||
}
|
||
} catch (DbnToolsHttpException $e) {
|
||
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
|
||
} catch (Throwable $e) {
|
||
error_log('[dbn-dms/folders] ' . $e->getMessage());
|
||
dbnToolsError('Folder operation failed.', 500, 'folder_op_failed');
|
||
}
|
||
|
||
function respondTree(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole): void
|
||
{
|
||
$stmt = $db->prepare(
|
||
"SELECT f.id, f.parent_id, f.name, f.slug, f.color, f.description, f.sort_order, f.created_at,
|
||
COALESCE(c.cnt, 0) AS doc_count
|
||
FROM client_folders f
|
||
LEFT JOIN (
|
||
SELECT folder_id, COUNT(*) AS cnt
|
||
FROM client_documents
|
||
WHERE client_id = ? AND deleted_at IS NULL
|
||
GROUP BY folder_id
|
||
) c ON c.folder_id = f.id
|
||
WHERE f.client_id = ? AND f.corpus_id = ? AND f.deleted_at IS NULL
|
||
ORDER BY f.sort_order ASC, f.name ASC"
|
||
);
|
||
$stmt->execute([$clientId, $clientId, $corpusId]);
|
||
$rows = $stmt->fetchAll();
|
||
|
||
// Filter by read ACL.
|
||
$visible = [];
|
||
foreach ($rows as $row) {
|
||
if (dbnDmsUserCanAccessFolder((int)$row['id'], 'read', $clientId, $userId, $tenantRole)) {
|
||
$visible[(int)$row['id']] = [
|
||
'id' => (int)$row['id'],
|
||
'parent_id' => $row['parent_id'] ? (int)$row['parent_id'] : null,
|
||
'name' => (string)$row['name'],
|
||
'slug' => (string)$row['slug'],
|
||
'color' => $row['color'] ?? null,
|
||
'description'=> $row['description'] ?? null,
|
||
'sort_order' => (int)$row['sort_order'],
|
||
'doc_count' => (int)$row['doc_count'],
|
||
'children' => [],
|
||
];
|
||
}
|
||
}
|
||
// Tree assembly.
|
||
$roots = [];
|
||
foreach ($visible as $id => &$node) {
|
||
$pid = $node['parent_id'];
|
||
if ($pid && isset($visible[$pid])) {
|
||
$visible[$pid]['children'][] = &$node;
|
||
} else {
|
||
$roots[] = &$node;
|
||
}
|
||
}
|
||
unset($node);
|
||
|
||
// Unassigned bucket count.
|
||
$unassigned = $db->prepare(
|
||
'SELECT COUNT(*) FROM client_documents WHERE client_id = ? AND folder_id IS NULL AND deleted_at IS NULL'
|
||
);
|
||
$unassigned->execute([$clientId]);
|
||
|
||
// Trash count.
|
||
$trash = $db->prepare(
|
||
'SELECT COUNT(*) FROM client_documents WHERE client_id = ? AND deleted_at IS NOT NULL'
|
||
);
|
||
$trash->execute([$clientId]);
|
||
|
||
dbnToolsRespond([
|
||
'ok' => true,
|
||
'tree' => $roots,
|
||
'unassigned_count' => (int)$unassigned->fetchColumn(),
|
||
'trash_count' => (int)$trash->fetchColumn(),
|
||
'max_depth' => DBN_DMS_MAX_FOLDER_DEPTH,
|
||
]);
|
||
}
|
||
|
||
function respondCreate(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole): void
|
||
{
|
||
$input = dbnToolsJsonInput(20_000);
|
||
$name = trim((string)($input['name'] ?? ''));
|
||
$parentId = isset($input['parent_id']) && $input['parent_id'] !== null && $input['parent_id'] !== ''
|
||
? (int)$input['parent_id'] : null;
|
||
$color = trim((string)($input['color'] ?? ''));
|
||
$desc = trim((string)($input['description'] ?? ''));
|
||
|
||
if ($name === '' || mb_strlen($name, 'UTF-8') > 200) {
|
||
dbnToolsError('Folder name is required (1–200 chars).', 422, 'invalid_name');
|
||
}
|
||
if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
|
||
dbnToolsError('Color must be a #RRGGBB hex value.', 422, 'invalid_color');
|
||
}
|
||
if (mb_strlen($desc, 'UTF-8') > 1000) {
|
||
dbnToolsError('Description is too long (max 1000 chars).', 422, 'description_too_long');
|
||
}
|
||
|
||
$parentDepth = dbnDmsFolderDepth($parentId, $clientId);
|
||
if ($parentDepth + 1 > DBN_DMS_MAX_FOLDER_DEPTH) {
|
||
dbnToolsError("Folder depth limit reached (max " . DBN_DMS_MAX_FOLDER_DEPTH . " levels).", 422, 'depth_exceeded');
|
||
}
|
||
if (!dbnDmsUserCanAccessFolder($parentId, 'manage', $clientId, $userId, $tenantRole)) {
|
||
dbnToolsError('You do not have permission to create folders here.', 403, 'forbidden');
|
||
}
|
||
if ($parentId !== null) {
|
||
$parentCheck = $db->prepare('SELECT id FROM client_folders WHERE id = ? AND client_id = ? AND deleted_at IS NULL');
|
||
$parentCheck->execute([$parentId, $clientId]);
|
||
if (!$parentCheck->fetchColumn()) {
|
||
dbnToolsError('Parent folder not found.', 404, 'parent_not_found');
|
||
}
|
||
}
|
||
|
||
$slug = dbnDmsUniqueSlug($db, $clientId, $corpusId, $name);
|
||
$stmt = $db->prepare(
|
||
'INSERT INTO client_folders
|
||
(client_id, corpus_id, parent_id, name, slug, description, color, sort_order, created_by, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, NOW())'
|
||
);
|
||
$stmt->execute([
|
||
$clientId, $corpusId, $parentId, $name, $slug,
|
||
$desc !== '' ? $desc : null,
|
||
$color !== '' ? $color : null,
|
||
$userId ?: null,
|
||
]);
|
||
$id = (int)$db->lastInsertId();
|
||
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_create', ['name' => $name, 'parent_id' => $parentId], null, $id);
|
||
|
||
dbnToolsRespond(['ok' => true, 'folder_id' => $id, 'slug' => $slug], 201);
|
||
}
|
||
|
||
function respondRename(PDO $db, int $clientId, int $userId, string $tenantRole): void
|
||
{
|
||
$input = dbnToolsJsonInput(10_000);
|
||
$fid = (int)($input['folder_id'] ?? 0);
|
||
$name = trim((string)($input['name'] ?? ''));
|
||
if ($fid <= 0 || $name === '' || mb_strlen($name, 'UTF-8') > 200) {
|
||
dbnToolsError('folder_id and a valid name (1–200) are required.', 422, 'invalid_input');
|
||
}
|
||
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
|
||
dbnToolsError('You do not have permission to rename this folder.', 403, 'forbidden');
|
||
}
|
||
$stmt = $db->prepare('UPDATE client_folders SET name = ?, updated_at = NOW() WHERE id = ? AND client_id = ?');
|
||
$stmt->execute([$name, $fid, $clientId]);
|
||
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_rename', ['name' => $name], null, $fid);
|
||
dbnToolsRespond(['ok' => true]);
|
||
}
|
||
|
||
function respondRecolor(PDO $db, int $clientId, int $userId, string $tenantRole): void
|
||
{
|
||
$input = dbnToolsJsonInput(2_000);
|
||
$fid = (int)($input['folder_id'] ?? 0);
|
||
$color = trim((string)($input['color'] ?? ''));
|
||
if ($fid <= 0) {
|
||
dbnToolsError('folder_id is required.', 422, 'invalid_input');
|
||
}
|
||
if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
|
||
dbnToolsError('Color must be a #RRGGBB hex value.', 422, 'invalid_color');
|
||
}
|
||
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
|
||
dbnToolsError('Forbidden.', 403, 'forbidden');
|
||
}
|
||
$stmt = $db->prepare('UPDATE client_folders SET color = ?, updated_at = NOW() WHERE id = ? AND client_id = ?');
|
||
$stmt->execute([$color !== '' ? $color : null, $fid, $clientId]);
|
||
dbnToolsRespond(['ok' => true]);
|
||
}
|
||
|
||
function respondMove(PDO $db, int $clientId, int $userId, string $tenantRole): void
|
||
{
|
||
$input = dbnToolsJsonInput(5_000);
|
||
$fid = (int)($input['folder_id'] ?? 0);
|
||
$parentId = isset($input['parent_id']) && $input['parent_id'] !== null && $input['parent_id'] !== ''
|
||
? (int)$input['parent_id'] : null;
|
||
if ($fid <= 0) {
|
||
dbnToolsError('folder_id is required.', 422, 'invalid_input');
|
||
}
|
||
if ($parentId !== null && $parentId === $fid) {
|
||
dbnToolsError('Folder cannot be its own parent.', 422, 'invalid_parent');
|
||
}
|
||
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
|
||
dbnToolsError('Forbidden on source.', 403, 'forbidden_source');
|
||
}
|
||
if ($parentId !== null && !dbnDmsUserCanAccessFolder($parentId, 'manage', $clientId, $userId, $tenantRole)) {
|
||
dbnToolsError('Forbidden on destination.', 403, 'forbidden_dest');
|
||
}
|
||
// Cycle + depth checks.
|
||
if ($parentId !== null) {
|
||
$chain = dbnDmsFolderChain($parentId, $clientId);
|
||
foreach ($chain as $c) {
|
||
if ((int)$c['id'] === $fid) {
|
||
dbnToolsError('Cannot move a folder into one of its own descendants.', 422, 'invalid_cycle');
|
||
}
|
||
}
|
||
$newDepth = count($chain) + 1; // +1 for the moved folder itself
|
||
$childDepth = dbnDmsSubtreeMaxDepth($db, $fid);
|
||
if ($newDepth + ($childDepth - 1) > DBN_DMS_MAX_FOLDER_DEPTH) {
|
||
dbnToolsError('Move would exceed max folder depth.', 422, 'depth_exceeded');
|
||
}
|
||
}
|
||
$stmt = $db->prepare('UPDATE client_folders SET parent_id = ?, updated_at = NOW() WHERE id = ? AND client_id = ?');
|
||
$stmt->execute([$parentId, $fid, $clientId]);
|
||
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_move', ['parent_id' => $parentId], null, $fid);
|
||
dbnToolsRespond(['ok' => true]);
|
||
}
|
||
|
||
function respondDelete(PDO $db, int $clientId, int $userId, string $tenantRole): void
|
||
{
|
||
$input = dbnToolsJsonInput(5_000);
|
||
$fid = (int)($input['folder_id'] ?? 0);
|
||
if ($fid <= 0) {
|
||
dbnToolsError('folder_id is required.', 422, 'invalid_input');
|
||
}
|
||
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
|
||
dbnToolsError('Forbidden.', 403, 'forbidden');
|
||
}
|
||
// Soft delete folder + cascade soft-delete on descendant folders.
|
||
$db->beginTransaction();
|
||
try {
|
||
$allIds = dbnDmsCollectSubtreeIds($db, $fid, $clientId);
|
||
$allIds[] = $fid;
|
||
$placeholders = implode(',', array_fill(0, count($allIds), '?'));
|
||
$stmt = $db->prepare(
|
||
"UPDATE client_folders SET deleted_at = NOW(), deleted_by = ?
|
||
WHERE client_id = ? AND id IN ({$placeholders})"
|
||
);
|
||
$stmt->execute(array_merge([$userId ?: null, $clientId], $allIds));
|
||
// Documents inside: also soft-delete (they appear in Trash).
|
||
$docStmt = $db->prepare(
|
||
"UPDATE client_documents SET deleted_at = NOW(), deleted_by = ?
|
||
WHERE client_id = ? AND folder_id IN ({$placeholders}) AND deleted_at IS NULL"
|
||
);
|
||
$docStmt->execute(array_merge([$userId ?: null, $clientId], $allIds));
|
||
$db->commit();
|
||
} catch (Throwable $e) {
|
||
$db->rollBack();
|
||
throw $e;
|
||
}
|
||
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_delete', [], null, $fid);
|
||
dbnToolsRespond(['ok' => true]);
|
||
}
|
||
|
||
function respondListPermissions(PDO $db, int $clientId, int $userId, string $tenantRole): void
|
||
{
|
||
$fid = (int)($_GET['folder_id'] ?? 0);
|
||
if ($fid <= 0) {
|
||
dbnToolsError('folder_id is required.', 422, 'invalid_input');
|
||
}
|
||
if (!dbnDmsUserCanAccessFolder($fid, 'read', $clientId, $userId, $tenantRole)) {
|
||
dbnToolsError('Forbidden.', 403, 'forbidden');
|
||
}
|
||
$stmt = $db->prepare(
|
||
"SELECT p.id, p.folder_id, p.min_role, p.user_id, p.can_read, p.can_write, p.can_manage,
|
||
p.created_at, u.email AS user_email, u.full_name AS user_name
|
||
FROM client_folder_permissions p
|
||
LEFT JOIN client_users u ON u.id = p.user_id
|
||
WHERE p.folder_id = ? AND p.client_id = ?
|
||
ORDER BY p.id ASC"
|
||
);
|
||
$stmt->execute([$fid, $clientId]);
|
||
dbnToolsRespond(['ok' => true, 'permissions' => $stmt->fetchAll()]);
|
||
}
|
||
|
||
function respondSetPermission(PDO $db, int $clientId, int $userId, string $tenantRole): void
|
||
{
|
||
$input = dbnToolsJsonInput(10_000);
|
||
$fid = (int)($input['folder_id'] ?? 0);
|
||
$minRole = trim((string)($input['min_role'] ?? ''));
|
||
$targetUid= isset($input['user_id']) && $input['user_id'] ? (int)$input['user_id'] : null;
|
||
$canRead = !empty($input['can_read']) ? 1 : 0;
|
||
$canWrite = !empty($input['can_write']) ? 1 : 0;
|
||
$canManage= !empty($input['can_manage']) ? 1 : 0;
|
||
|
||
if ($fid <= 0) {
|
||
dbnToolsError('folder_id is required.', 422, 'invalid_input');
|
||
}
|
||
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
|
||
dbnToolsError('Forbidden.', 403, 'forbidden');
|
||
}
|
||
$validRoles = ['viewer','editor','admin','owner'];
|
||
if ($minRole !== '' && !in_array($minRole, $validRoles, true)) {
|
||
dbnToolsError('Invalid min_role.', 422, 'invalid_role');
|
||
}
|
||
if (($minRole === '' && $targetUid === null) || ($minRole !== '' && $targetUid !== null)) {
|
||
dbnToolsError('Exactly one of min_role or user_id must be set.', 422, 'invalid_grantee');
|
||
}
|
||
|
||
// UPSERT on the appropriate unique key.
|
||
if ($minRole !== '') {
|
||
$stmt = $db->prepare(
|
||
'INSERT INTO client_folder_permissions
|
||
(folder_id, client_id, min_role, can_read, can_write, can_manage, created_by, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
|
||
ON DUPLICATE KEY UPDATE
|
||
can_read = VALUES(can_read),
|
||
can_write = VALUES(can_write),
|
||
can_manage = VALUES(can_manage)'
|
||
);
|
||
$stmt->execute([$fid, $clientId, $minRole, $canRead, $canWrite, $canManage, $userId ?: null]);
|
||
} else {
|
||
$stmt = $db->prepare(
|
||
'INSERT INTO client_folder_permissions
|
||
(folder_id, client_id, user_id, can_read, can_write, can_manage, created_by, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
|
||
ON DUPLICATE KEY UPDATE
|
||
can_read = VALUES(can_read),
|
||
can_write = VALUES(can_write),
|
||
can_manage = VALUES(can_manage)'
|
||
);
|
||
$stmt->execute([$fid, $clientId, $targetUid, $canRead, $canWrite, $canManage, $userId ?: null]);
|
||
}
|
||
|
||
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_acl_set', [
|
||
'min_role' => $minRole ?: null,
|
||
'user_id' => $targetUid,
|
||
'can_read' => $canRead, 'can_write' => $canWrite, 'can_manage' => $canManage,
|
||
], null, $fid);
|
||
|
||
dbnToolsRespond(['ok' => true]);
|
||
}
|
||
|
||
function respondRemovePermission(PDO $db, int $clientId, int $userId, string $tenantRole): void
|
||
{
|
||
$input = dbnToolsJsonInput(2_000);
|
||
$pid = (int)($input['permission_id'] ?? 0);
|
||
if ($pid <= 0) {
|
||
dbnToolsError('permission_id is required.', 422, 'invalid_input');
|
||
}
|
||
// Look up the folder to ACL-check.
|
||
$row = $db->prepare('SELECT folder_id FROM client_folder_permissions WHERE id = ? AND client_id = ?');
|
||
$row->execute([$pid, $clientId]);
|
||
$fid = (int)$row->fetchColumn();
|
||
if (!$fid) {
|
||
dbnToolsError('Permission not found.', 404, 'not_found');
|
||
}
|
||
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
|
||
dbnToolsError('Forbidden.', 403, 'forbidden');
|
||
}
|
||
$del = $db->prepare('DELETE FROM client_folder_permissions WHERE id = ? AND client_id = ?');
|
||
$del->execute([$pid, $clientId]);
|
||
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_acl_remove', ['permission_id' => $pid], null, $fid);
|
||
dbnToolsRespond(['ok' => true]);
|
||
}
|
||
|
||
function dbnDmsCollectSubtreeIds(PDO $db, int $rootId, int $clientId): array
|
||
{
|
||
$collected = [];
|
||
$stack = [$rootId];
|
||
$guard = 0;
|
||
while ($stack && $guard++ < 1000) {
|
||
$batch = $stack;
|
||
$stack = [];
|
||
$placeholders = implode(',', array_fill(0, count($batch), '?'));
|
||
$stmt = $db->prepare(
|
||
"SELECT id FROM client_folders
|
||
WHERE client_id = ? AND parent_id IN ({$placeholders}) AND deleted_at IS NULL"
|
||
);
|
||
$stmt->execute(array_merge([$clientId], $batch));
|
||
foreach ($stmt->fetchAll() as $row) {
|
||
$cid = (int)$row['id'];
|
||
$collected[] = $cid;
|
||
$stack[] = $cid;
|
||
}
|
||
}
|
||
return $collected;
|
||
}
|
||
|
||
function dbnDmsSubtreeMaxDepth(PDO $db, int $rootId): int
|
||
{
|
||
// Depth of the subtree starting at rootId, where rootId itself counts as 1.
|
||
$depth = 1;
|
||
$current = [$rootId];
|
||
$guard = 0;
|
||
while ($current && $guard++ < 20) {
|
||
$placeholders = implode(',', array_fill(0, count($current), '?'));
|
||
$stmt = $db->prepare(
|
||
"SELECT id FROM client_folders
|
||
WHERE parent_id IN ({$placeholders}) AND deleted_at IS NULL"
|
||
);
|
||
$stmt->execute($current);
|
||
$next = array_map(fn($r) => (int)$r['id'], $stmt->fetchAll());
|
||
if (!$next) {
|
||
break;
|
||
}
|
||
$current = $next;
|
||
$depth++;
|
||
}
|
||
return $depth;
|
||
}
|
||
|
||
function dbnDmsUniqueSlug(PDO $db, int $clientId, int $corpusId, string $name): string
|
||
{
|
||
$base = strtolower(trim($name));
|
||
$base = preg_replace('/[^a-z0-9]+/u', '-', $base) ?: 'folder';
|
||
$base = trim($base, '-');
|
||
if ($base === '') {
|
||
$base = 'folder';
|
||
}
|
||
$base = substr($base, 0, 180);
|
||
$slug = $base;
|
||
$check = $db->prepare('SELECT 1 FROM client_folders WHERE client_id = ? AND corpus_id = ? AND slug = ? LIMIT 1');
|
||
$n = 2;
|
||
while (true) {
|
||
$check->execute([$clientId, $corpusId, $slug]);
|
||
if (!$check->fetchColumn()) {
|
||
return $slug;
|
||
}
|
||
$slug = $base . '-' . $n++;
|
||
if ($n > 999) {
|
||
return $base . '-' . substr(bin2hex(random_bytes(3)), 0, 6);
|
||
}
|
||
}
|
||
}
|