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; }