Full DMS: folders + ACLs, versioning, trash, bulk ops, preview, smart folders
Rebuild the dashboard as a Drive-style document management system on top of the existing CaveauAI hybrid RAG pipeline. Backend: - 5 migrations (versions, trash soft-delete, saved searches, categories, audit) - DMS helpers (folder ACL walker, disk storage, audit, version snapshot, XLSX/PPTX/HTML/CSV/MD extractors) - New APIs: folders, document-versions, trash, bulk, preview, saved-searches, categories, diagnostics - Extended APIs: documents (folder_id, soft-delete, ACL filter, sort), upload (9 file types, version-collision detection with replace/new/keep-both, disk persistence), chat-stream (folder scoping + graph related-documents) - 30-day trash purge cron with Qdrant + disk + graph cleanup Frontend: - Drive-style two-pane browser with folder tree, drag-drop, bulk-action bar, right-click context menu, multi-select - New pages: folders (tree + per-folder ACL editor), trash (restore/purge) - Extended pages: upload (folder picker, version-collision modal, 9 file type chips), document (Preview/Versions/Permissions tabs with PDF.js + mammoth.js + audio), index (DMS KPIs + activity feed), settings (live diagnostics ping MariaDB/Qdrant/LiteLLM/FalkorDB/disk), chat (folder scope chips + related-authorities chips) - New CSS (dms.css) + JS bundle (dms.js) exposing window.DBN_DMS - Sidebar nav adds Folders + Trash items All routes return HTTP 200 in local smoke test; all 32 files lint clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
<?php
|
||||
/**
|
||||
* /api/dashboard/folders.php — folder tree CRUD + per-folder ACLs.
|
||||
*
|
||||
* GET ?action=list_tree → { ok, tree: [...] }
|
||||
* GET ?action=get_breadcrumb&folder_id=X → { ok, breadcrumb: [...] }
|
||||
* POST ?action=create body: { parent_id?, name, color?, description? }
|
||||
* POST ?action=rename body: { folder_id, name }
|
||||
* POST ?action=recolor body: { folder_id, color }
|
||||
* POST ?action=move body: { folder_id, parent_id|null }
|
||||
* POST ?action=delete body: { folder_id } (soft delete — docs become Unassigned via SET NULL)
|
||||
* POST ?action=set_permission body: { folder_id, min_role?, user_id?, can_read?, can_write?, can_manage? }
|
||||
* POST ?action=remove_permission body: { permission_id }
|
||||
* GET ?action=list_permissions&folder_id=X → { ok, permissions: [...] }
|
||||
*
|
||||
* Tenant-scoped via dbnToolsEnsureDashboardTenant().
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
|
||||
|
||||
dbnToolsRequireAuth();
|
||||
|
||||
try {
|
||||
$tenant = dbnToolsEnsureDashboardTenant();
|
||||
} catch (DbnToolsHttpException $e) {
|
||||
dbnToolsError($e->getMessage(), $e->status, $e->errorCode);
|
||||
}
|
||||
|
||||
$clientId = (int)$tenant['client_id'];
|
||||
$corpusId = (int)$tenant['corpus_id'];
|
||||
$clientUser = (int)($tenant['client_user_id'] ?? 0);
|
||||
$tenantRole = (string)($tenant['role'] ?? 'editor');
|
||||
|
||||
$db = dbnToolsDb();
|
||||
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
$action = (string)($_GET['action'] ?? ($method === 'POST' ? '' : 'list_tree'));
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'list_tree':
|
||||
dbnToolsRequireMethod('GET');
|
||||
respondTree($db, $clientId, $corpusId, $clientUser, $tenantRole);
|
||||
break;
|
||||
case 'get_breadcrumb':
|
||||
dbnToolsRequireMethod('GET');
|
||||
$fid = (int)($_GET['folder_id'] ?? 0);
|
||||
dbnToolsRespond(['ok' => true, 'breadcrumb' => dbnDmsBreadcrumb($fid ?: null, $clientId)]);
|
||||
break;
|
||||
case 'create':
|
||||
dbnToolsRequireMethod('POST');
|
||||
respondCreate($db, $clientId, $corpusId, $clientUser, $tenantRole);
|
||||
break;
|
||||
case 'rename':
|
||||
dbnToolsRequireMethod('POST');
|
||||
respondRename($db, $clientId, $clientUser, $tenantRole);
|
||||
break;
|
||||
case 'recolor':
|
||||
dbnToolsRequireMethod('POST');
|
||||
respondRecolor($db, $clientId, $clientUser, $tenantRole);
|
||||
break;
|
||||
case 'move':
|
||||
dbnToolsRequireMethod('POST');
|
||||
respondMove($db, $clientId, $clientUser, $tenantRole);
|
||||
break;
|
||||
case 'delete':
|
||||
dbnToolsRequireMethod('POST');
|
||||
respondDelete($db, $clientId, $clientUser, $tenantRole);
|
||||
break;
|
||||
case 'list_permissions':
|
||||
dbnToolsRequireMethod('GET');
|
||||
respondListPermissions($db, $clientId, $clientUser, $tenantRole);
|
||||
break;
|
||||
case 'set_permission':
|
||||
dbnToolsRequireMethod('POST');
|
||||
respondSetPermission($db, $clientId, $clientUser, $tenantRole);
|
||||
break;
|
||||
case 'remove_permission':
|
||||
dbnToolsRequireMethod('POST');
|
||||
respondRemovePermission($db, $clientId, $clientUser, $tenantRole);
|
||||
break;
|
||||
default:
|
||||
dbnToolsError('Unknown action.', 400, 'unknown_action');
|
||||
}
|
||||
} catch (DbnToolsHttpException $e) {
|
||||
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[dbn-dms/folders] ' . $e->getMessage());
|
||||
dbnToolsError('Folder operation failed.', 500, 'folder_op_failed');
|
||||
}
|
||||
|
||||
function respondTree(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole): void
|
||||
{
|
||||
$stmt = $db->prepare(
|
||||
"SELECT f.id, f.parent_id, f.name, f.slug, f.color, f.description, f.sort_order, f.created_at,
|
||||
COALESCE(c.cnt, 0) AS doc_count
|
||||
FROM client_folders f
|
||||
LEFT JOIN (
|
||||
SELECT folder_id, COUNT(*) AS cnt
|
||||
FROM client_documents
|
||||
WHERE client_id = ? AND deleted_at IS NULL
|
||||
GROUP BY folder_id
|
||||
) c ON c.folder_id = f.id
|
||||
WHERE f.client_id = ? AND f.corpus_id = ? AND f.deleted_at IS NULL
|
||||
ORDER BY f.sort_order ASC, f.name ASC"
|
||||
);
|
||||
$stmt->execute([$clientId, $clientId, $corpusId]);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
// Filter by read ACL.
|
||||
$visible = [];
|
||||
foreach ($rows as $row) {
|
||||
if (dbnDmsUserCanAccessFolder((int)$row['id'], 'read', $clientId, $userId, $tenantRole)) {
|
||||
$visible[(int)$row['id']] = [
|
||||
'id' => (int)$row['id'],
|
||||
'parent_id' => $row['parent_id'] ? (int)$row['parent_id'] : null,
|
||||
'name' => (string)$row['name'],
|
||||
'slug' => (string)$row['slug'],
|
||||
'color' => $row['color'] ?? null,
|
||||
'description'=> $row['description'] ?? null,
|
||||
'sort_order' => (int)$row['sort_order'],
|
||||
'doc_count' => (int)$row['doc_count'],
|
||||
'children' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
// Tree assembly.
|
||||
$roots = [];
|
||||
foreach ($visible as $id => &$node) {
|
||||
$pid = $node['parent_id'];
|
||||
if ($pid && isset($visible[$pid])) {
|
||||
$visible[$pid]['children'][] = &$node;
|
||||
} else {
|
||||
$roots[] = &$node;
|
||||
}
|
||||
}
|
||||
unset($node);
|
||||
|
||||
// Unassigned bucket count.
|
||||
$unassigned = $db->prepare(
|
||||
'SELECT COUNT(*) FROM client_documents WHERE client_id = ? AND folder_id IS NULL AND deleted_at IS NULL'
|
||||
);
|
||||
$unassigned->execute([$clientId]);
|
||||
|
||||
// Trash count.
|
||||
$trash = $db->prepare(
|
||||
'SELECT COUNT(*) FROM client_documents WHERE client_id = ? AND deleted_at IS NOT NULL'
|
||||
);
|
||||
$trash->execute([$clientId]);
|
||||
|
||||
dbnToolsRespond([
|
||||
'ok' => true,
|
||||
'tree' => $roots,
|
||||
'unassigned_count' => (int)$unassigned->fetchColumn(),
|
||||
'trash_count' => (int)$trash->fetchColumn(),
|
||||
'max_depth' => DBN_DMS_MAX_FOLDER_DEPTH,
|
||||
]);
|
||||
}
|
||||
|
||||
function respondCreate(PDO $db, int $clientId, int $corpusId, int $userId, string $tenantRole): void
|
||||
{
|
||||
$input = dbnToolsJsonInput(20_000);
|
||||
$name = trim((string)($input['name'] ?? ''));
|
||||
$parentId = isset($input['parent_id']) && $input['parent_id'] !== null && $input['parent_id'] !== ''
|
||||
? (int)$input['parent_id'] : null;
|
||||
$color = trim((string)($input['color'] ?? ''));
|
||||
$desc = trim((string)($input['description'] ?? ''));
|
||||
|
||||
if ($name === '' || mb_strlen($name, 'UTF-8') > 200) {
|
||||
dbnToolsError('Folder name is required (1–200 chars).', 422, 'invalid_name');
|
||||
}
|
||||
if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
|
||||
dbnToolsError('Color must be a #RRGGBB hex value.', 422, 'invalid_color');
|
||||
}
|
||||
if (mb_strlen($desc, 'UTF-8') > 1000) {
|
||||
dbnToolsError('Description is too long (max 1000 chars).', 422, 'description_too_long');
|
||||
}
|
||||
|
||||
$parentDepth = dbnDmsFolderDepth($parentId, $clientId);
|
||||
if ($parentDepth + 1 > DBN_DMS_MAX_FOLDER_DEPTH) {
|
||||
dbnToolsError("Folder depth limit reached (max " . DBN_DMS_MAX_FOLDER_DEPTH . " levels).", 422, 'depth_exceeded');
|
||||
}
|
||||
if (!dbnDmsUserCanAccessFolder($parentId, 'manage', $clientId, $userId, $tenantRole)) {
|
||||
dbnToolsError('You do not have permission to create folders here.', 403, 'forbidden');
|
||||
}
|
||||
if ($parentId !== null) {
|
||||
$parentCheck = $db->prepare('SELECT id FROM client_folders WHERE id = ? AND client_id = ? AND deleted_at IS NULL');
|
||||
$parentCheck->execute([$parentId, $clientId]);
|
||||
if (!$parentCheck->fetchColumn()) {
|
||||
dbnToolsError('Parent folder not found.', 404, 'parent_not_found');
|
||||
}
|
||||
}
|
||||
|
||||
$slug = dbnDmsUniqueSlug($db, $clientId, $corpusId, $name);
|
||||
$stmt = $db->prepare(
|
||||
'INSERT INTO client_folders
|
||||
(client_id, corpus_id, parent_id, name, slug, description, color, sort_order, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, NOW())'
|
||||
);
|
||||
$stmt->execute([
|
||||
$clientId, $corpusId, $parentId, $name, $slug,
|
||||
$desc !== '' ? $desc : null,
|
||||
$color !== '' ? $color : null,
|
||||
$userId ?: null,
|
||||
]);
|
||||
$id = (int)$db->lastInsertId();
|
||||
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_create', ['name' => $name, 'parent_id' => $parentId], null, $id);
|
||||
|
||||
dbnToolsRespond(['ok' => true, 'folder_id' => $id, 'slug' => $slug], 201);
|
||||
}
|
||||
|
||||
function respondRename(PDO $db, int $clientId, int $userId, string $tenantRole): void
|
||||
{
|
||||
$input = dbnToolsJsonInput(10_000);
|
||||
$fid = (int)($input['folder_id'] ?? 0);
|
||||
$name = trim((string)($input['name'] ?? ''));
|
||||
if ($fid <= 0 || $name === '' || mb_strlen($name, 'UTF-8') > 200) {
|
||||
dbnToolsError('folder_id and a valid name (1–200) are required.', 422, 'invalid_input');
|
||||
}
|
||||
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
|
||||
dbnToolsError('You do not have permission to rename this folder.', 403, 'forbidden');
|
||||
}
|
||||
$stmt = $db->prepare('UPDATE client_folders SET name = ?, updated_at = NOW() WHERE id = ? AND client_id = ?');
|
||||
$stmt->execute([$name, $fid, $clientId]);
|
||||
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_rename', ['name' => $name], null, $fid);
|
||||
dbnToolsRespond(['ok' => true]);
|
||||
}
|
||||
|
||||
function respondRecolor(PDO $db, int $clientId, int $userId, string $tenantRole): void
|
||||
{
|
||||
$input = dbnToolsJsonInput(2_000);
|
||||
$fid = (int)($input['folder_id'] ?? 0);
|
||||
$color = trim((string)($input['color'] ?? ''));
|
||||
if ($fid <= 0) {
|
||||
dbnToolsError('folder_id is required.', 422, 'invalid_input');
|
||||
}
|
||||
if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
|
||||
dbnToolsError('Color must be a #RRGGBB hex value.', 422, 'invalid_color');
|
||||
}
|
||||
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
|
||||
dbnToolsError('Forbidden.', 403, 'forbidden');
|
||||
}
|
||||
$stmt = $db->prepare('UPDATE client_folders SET color = ?, updated_at = NOW() WHERE id = ? AND client_id = ?');
|
||||
$stmt->execute([$color !== '' ? $color : null, $fid, $clientId]);
|
||||
dbnToolsRespond(['ok' => true]);
|
||||
}
|
||||
|
||||
function respondMove(PDO $db, int $clientId, int $userId, string $tenantRole): void
|
||||
{
|
||||
$input = dbnToolsJsonInput(5_000);
|
||||
$fid = (int)($input['folder_id'] ?? 0);
|
||||
$parentId = isset($input['parent_id']) && $input['parent_id'] !== null && $input['parent_id'] !== ''
|
||||
? (int)$input['parent_id'] : null;
|
||||
if ($fid <= 0) {
|
||||
dbnToolsError('folder_id is required.', 422, 'invalid_input');
|
||||
}
|
||||
if ($parentId !== null && $parentId === $fid) {
|
||||
dbnToolsError('Folder cannot be its own parent.', 422, 'invalid_parent');
|
||||
}
|
||||
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
|
||||
dbnToolsError('Forbidden on source.', 403, 'forbidden_source');
|
||||
}
|
||||
if ($parentId !== null && !dbnDmsUserCanAccessFolder($parentId, 'manage', $clientId, $userId, $tenantRole)) {
|
||||
dbnToolsError('Forbidden on destination.', 403, 'forbidden_dest');
|
||||
}
|
||||
// Cycle + depth checks.
|
||||
if ($parentId !== null) {
|
||||
$chain = dbnDmsFolderChain($parentId, $clientId);
|
||||
foreach ($chain as $c) {
|
||||
if ((int)$c['id'] === $fid) {
|
||||
dbnToolsError('Cannot move a folder into one of its own descendants.', 422, 'invalid_cycle');
|
||||
}
|
||||
}
|
||||
$newDepth = count($chain) + 1; // +1 for the moved folder itself
|
||||
$childDepth = dbnDmsSubtreeMaxDepth($db, $fid);
|
||||
if ($newDepth + ($childDepth - 1) > DBN_DMS_MAX_FOLDER_DEPTH) {
|
||||
dbnToolsError('Move would exceed max folder depth.', 422, 'depth_exceeded');
|
||||
}
|
||||
}
|
||||
$stmt = $db->prepare('UPDATE client_folders SET parent_id = ?, updated_at = NOW() WHERE id = ? AND client_id = ?');
|
||||
$stmt->execute([$parentId, $fid, $clientId]);
|
||||
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_move', ['parent_id' => $parentId], null, $fid);
|
||||
dbnToolsRespond(['ok' => true]);
|
||||
}
|
||||
|
||||
function respondDelete(PDO $db, int $clientId, int $userId, string $tenantRole): void
|
||||
{
|
||||
$input = dbnToolsJsonInput(5_000);
|
||||
$fid = (int)($input['folder_id'] ?? 0);
|
||||
if ($fid <= 0) {
|
||||
dbnToolsError('folder_id is required.', 422, 'invalid_input');
|
||||
}
|
||||
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
|
||||
dbnToolsError('Forbidden.', 403, 'forbidden');
|
||||
}
|
||||
// Soft delete folder + cascade soft-delete on descendant folders.
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$allIds = dbnDmsCollectSubtreeIds($db, $fid, $clientId);
|
||||
$allIds[] = $fid;
|
||||
$placeholders = implode(',', array_fill(0, count($allIds), '?'));
|
||||
$stmt = $db->prepare(
|
||||
"UPDATE client_folders SET deleted_at = NOW(), deleted_by = ?
|
||||
WHERE client_id = ? AND id IN ({$placeholders})"
|
||||
);
|
||||
$stmt->execute(array_merge([$userId ?: null, $clientId], $allIds));
|
||||
// Documents inside: also soft-delete (they appear in Trash).
|
||||
$docStmt = $db->prepare(
|
||||
"UPDATE client_documents SET deleted_at = NOW(), deleted_by = ?
|
||||
WHERE client_id = ? AND folder_id IN ({$placeholders}) AND deleted_at IS NULL"
|
||||
);
|
||||
$docStmt->execute(array_merge([$userId ?: null, $clientId], $allIds));
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
$db->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_delete', [], null, $fid);
|
||||
dbnToolsRespond(['ok' => true]);
|
||||
}
|
||||
|
||||
function respondListPermissions(PDO $db, int $clientId, int $userId, string $tenantRole): void
|
||||
{
|
||||
$fid = (int)($_GET['folder_id'] ?? 0);
|
||||
if ($fid <= 0) {
|
||||
dbnToolsError('folder_id is required.', 422, 'invalid_input');
|
||||
}
|
||||
if (!dbnDmsUserCanAccessFolder($fid, 'read', $clientId, $userId, $tenantRole)) {
|
||||
dbnToolsError('Forbidden.', 403, 'forbidden');
|
||||
}
|
||||
$stmt = $db->prepare(
|
||||
"SELECT p.id, p.folder_id, p.min_role, p.user_id, p.can_read, p.can_write, p.can_manage,
|
||||
p.created_at, u.email AS user_email, u.full_name AS user_name
|
||||
FROM client_folder_permissions p
|
||||
LEFT JOIN client_users u ON u.id = p.user_id
|
||||
WHERE p.folder_id = ? AND p.client_id = ?
|
||||
ORDER BY p.id ASC"
|
||||
);
|
||||
$stmt->execute([$fid, $clientId]);
|
||||
dbnToolsRespond(['ok' => true, 'permissions' => $stmt->fetchAll()]);
|
||||
}
|
||||
|
||||
function respondSetPermission(PDO $db, int $clientId, int $userId, string $tenantRole): void
|
||||
{
|
||||
$input = dbnToolsJsonInput(10_000);
|
||||
$fid = (int)($input['folder_id'] ?? 0);
|
||||
$minRole = trim((string)($input['min_role'] ?? ''));
|
||||
$targetUid= isset($input['user_id']) && $input['user_id'] ? (int)$input['user_id'] : null;
|
||||
$canRead = !empty($input['can_read']) ? 1 : 0;
|
||||
$canWrite = !empty($input['can_write']) ? 1 : 0;
|
||||
$canManage= !empty($input['can_manage']) ? 1 : 0;
|
||||
|
||||
if ($fid <= 0) {
|
||||
dbnToolsError('folder_id is required.', 422, 'invalid_input');
|
||||
}
|
||||
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
|
||||
dbnToolsError('Forbidden.', 403, 'forbidden');
|
||||
}
|
||||
$validRoles = ['viewer','editor','admin','owner'];
|
||||
if ($minRole !== '' && !in_array($minRole, $validRoles, true)) {
|
||||
dbnToolsError('Invalid min_role.', 422, 'invalid_role');
|
||||
}
|
||||
if (($minRole === '' && $targetUid === null) || ($minRole !== '' && $targetUid !== null)) {
|
||||
dbnToolsError('Exactly one of min_role or user_id must be set.', 422, 'invalid_grantee');
|
||||
}
|
||||
|
||||
// UPSERT on the appropriate unique key.
|
||||
if ($minRole !== '') {
|
||||
$stmt = $db->prepare(
|
||||
'INSERT INTO client_folder_permissions
|
||||
(folder_id, client_id, min_role, can_read, can_write, can_manage, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
can_read = VALUES(can_read),
|
||||
can_write = VALUES(can_write),
|
||||
can_manage = VALUES(can_manage)'
|
||||
);
|
||||
$stmt->execute([$fid, $clientId, $minRole, $canRead, $canWrite, $canManage, $userId ?: null]);
|
||||
} else {
|
||||
$stmt = $db->prepare(
|
||||
'INSERT INTO client_folder_permissions
|
||||
(folder_id, client_id, user_id, can_read, can_write, can_manage, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
can_read = VALUES(can_read),
|
||||
can_write = VALUES(can_write),
|
||||
can_manage = VALUES(can_manage)'
|
||||
);
|
||||
$stmt->execute([$fid, $clientId, $targetUid, $canRead, $canWrite, $canManage, $userId ?: null]);
|
||||
}
|
||||
|
||||
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_acl_set', [
|
||||
'min_role' => $minRole ?: null,
|
||||
'user_id' => $targetUid,
|
||||
'can_read' => $canRead, 'can_write' => $canWrite, 'can_manage' => $canManage,
|
||||
], null, $fid);
|
||||
|
||||
dbnToolsRespond(['ok' => true]);
|
||||
}
|
||||
|
||||
function respondRemovePermission(PDO $db, int $clientId, int $userId, string $tenantRole): void
|
||||
{
|
||||
$input = dbnToolsJsonInput(2_000);
|
||||
$pid = (int)($input['permission_id'] ?? 0);
|
||||
if ($pid <= 0) {
|
||||
dbnToolsError('permission_id is required.', 422, 'invalid_input');
|
||||
}
|
||||
// Look up the folder to ACL-check.
|
||||
$row = $db->prepare('SELECT folder_id FROM client_folder_permissions WHERE id = ? AND client_id = ?');
|
||||
$row->execute([$pid, $clientId]);
|
||||
$fid = (int)$row->fetchColumn();
|
||||
if (!$fid) {
|
||||
dbnToolsError('Permission not found.', 404, 'not_found');
|
||||
}
|
||||
if (!dbnDmsUserCanAccessFolder($fid, 'manage', $clientId, $userId, $tenantRole)) {
|
||||
dbnToolsError('Forbidden.', 403, 'forbidden');
|
||||
}
|
||||
$del = $db->prepare('DELETE FROM client_folder_permissions WHERE id = ? AND client_id = ?');
|
||||
$del->execute([$pid, $clientId]);
|
||||
dbnDmsLogAudit($clientId, $userId ?: null, 'folder_acl_remove', ['permission_id' => $pid], null, $fid);
|
||||
dbnToolsRespond(['ok' => true]);
|
||||
}
|
||||
|
||||
function dbnDmsCollectSubtreeIds(PDO $db, int $rootId, int $clientId): array
|
||||
{
|
||||
$collected = [];
|
||||
$stack = [$rootId];
|
||||
$guard = 0;
|
||||
while ($stack && $guard++ < 1000) {
|
||||
$batch = $stack;
|
||||
$stack = [];
|
||||
$placeholders = implode(',', array_fill(0, count($batch), '?'));
|
||||
$stmt = $db->prepare(
|
||||
"SELECT id FROM client_folders
|
||||
WHERE client_id = ? AND parent_id IN ({$placeholders}) AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->execute(array_merge([$clientId], $batch));
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$cid = (int)$row['id'];
|
||||
$collected[] = $cid;
|
||||
$stack[] = $cid;
|
||||
}
|
||||
}
|
||||
return $collected;
|
||||
}
|
||||
|
||||
function dbnDmsSubtreeMaxDepth(PDO $db, int $rootId): int
|
||||
{
|
||||
// Depth of the subtree starting at rootId, where rootId itself counts as 1.
|
||||
$depth = 1;
|
||||
$current = [$rootId];
|
||||
$guard = 0;
|
||||
while ($current && $guard++ < 20) {
|
||||
$placeholders = implode(',', array_fill(0, count($current), '?'));
|
||||
$stmt = $db->prepare(
|
||||
"SELECT id FROM client_folders
|
||||
WHERE parent_id IN ({$placeholders}) AND deleted_at IS NULL"
|
||||
);
|
||||
$stmt->execute($current);
|
||||
$next = array_map(fn($r) => (int)$r['id'], $stmt->fetchAll());
|
||||
if (!$next) {
|
||||
break;
|
||||
}
|
||||
$current = $next;
|
||||
$depth++;
|
||||
}
|
||||
return $depth;
|
||||
}
|
||||
|
||||
function dbnDmsUniqueSlug(PDO $db, int $clientId, int $corpusId, string $name): string
|
||||
{
|
||||
$base = strtolower(trim($name));
|
||||
$base = preg_replace('/[^a-z0-9]+/u', '-', $base) ?: 'folder';
|
||||
$base = trim($base, '-');
|
||||
if ($base === '') {
|
||||
$base = 'folder';
|
||||
}
|
||||
$base = substr($base, 0, 180);
|
||||
$slug = $base;
|
||||
$check = $db->prepare('SELECT 1 FROM client_folders WHERE client_id = ? AND corpus_id = ? AND slug = ? LIMIT 1');
|
||||
$n = 2;
|
||||
while (true) {
|
||||
$check->execute([$clientId, $corpusId, $slug]);
|
||||
if (!$check->fetchColumn()) {
|
||||
return $slug;
|
||||
}
|
||||
$slug = $base . '-' . $n++;
|
||||
if ($n > 999) {
|
||||
return $base . '-' . substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user