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