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'); listItems($db, $clientId, $userId); break; case 'create': dbnToolsRequireMethod('POST'); createItem($db, $clientId, $userId, $tenantRole); break; case 'update': dbnToolsRequireMethod('POST'); updateItem($db, $clientId, $userId, $tenantRole); break; case 'delete': dbnToolsRequireMethod('POST'); deleteItem($db, $clientId, $userId, $tenantRole); break; case 'reorder': dbnToolsRequireMethod('POST'); reorderItems($db, $clientId, $userId); 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/saved-searches] ' . $e->getMessage()); dbnToolsError('Saved-search operation failed.', 500, 'op_failed'); } function listItems(PDO $db, int $clientId, int $userId): void { $stmt = $db->prepare( 'SELECT id, name, icon, color, query_json, is_shared, sort_order, user_id, created_at, updated_at FROM client_saved_searches WHERE client_id = ? AND (user_id = ? OR is_shared = 1) ORDER BY sort_order ASC, name ASC' ); $stmt->execute([$clientId, $userId]); $rows = $stmt->fetchAll(); foreach ($rows as &$r) { $r['query'] = json_decode((string)$r['query_json'], true) ?? []; $r['is_mine'] = (int)$r['user_id'] === $userId; $r['is_shared'] = (int)$r['is_shared'] === 1; unset($r['query_json']); } dbnToolsRespond(['ok' => true, 'items' => $rows]); } function createItem(PDO $db, int $clientId, int $userId, string $tenantRole): void { $input = dbnToolsJsonInput(20_000); $name = trim((string)($input['name'] ?? '')); $query = $input['query'] ?? []; $isShared = !empty($input['is_shared']); $color = trim((string)($input['color'] ?? '')); $icon = trim((string)($input['icon'] ?? '')); if ($name === '' || mb_strlen($name, 'UTF-8') > 120) { dbnToolsError('Name is required (1–120 chars).', 422, 'invalid_name'); } if (!is_array($query) || !$query) { dbnToolsError('Query payload is required.', 422, 'invalid_query'); } if ($isShared && !in_array($tenantRole, ['editor','admin','owner'], true)) { dbnToolsError('Only editors+ can share smart folders.', 403, 'forbidden'); } if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) { dbnToolsError('Invalid color.', 422, 'invalid_color'); } $stmt = $db->prepare( 'INSERT INTO client_saved_searches (client_id, user_id, name, icon, color, query_json, is_shared, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 0, NOW(), NOW())' ); $stmt->execute([ $clientId, $userId, $name, $icon !== '' ? substr($icon, 0, 40) : null, $color !== '' ? $color : null, json_encode(sanitizeQuery($query), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), $isShared ? 1 : 0, ]); $id = (int)$db->lastInsertId(); dbnDmsLogAudit($clientId, $userId, 'saved_search_create', ['name' => $name, 'id' => $id]); dbnToolsRespond(['ok' => true, 'id' => $id], 201); } function updateItem(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'); } $row = $db->prepare('SELECT user_id, is_shared FROM client_saved_searches WHERE id = ? AND client_id = ?'); $row->execute([$id, $clientId]); $existing = $row->fetch(); if (!$existing) { dbnToolsError('Not found.', 404, 'not_found'); } $isMine = (int)$existing['user_id'] === $userId; $canEdit = $isMine || in_array($tenantRole, ['admin','owner'], true); if (!$canEdit) { dbnToolsError('Forbidden.', 403, 'forbidden'); } $fields = []; $params = []; if (array_key_exists('name', $input)) { $name = trim((string)$input['name']); if ($name === '' || mb_strlen($name, 'UTF-8') > 120) { dbnToolsError('Invalid name.', 422, 'invalid_name'); } $fields[] = 'name = ?'; $params[] = $name; } if (array_key_exists('query', $input)) { if (!is_array($input['query']) || !$input['query']) { dbnToolsError('Invalid query.', 422, 'invalid_query'); } $fields[] = 'query_json = ?'; $params[] = json_encode(sanitizeQuery($input['query']), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } if (array_key_exists('is_shared', $input)) { $fields[] = 'is_shared = ?'; $params[] = !empty($input['is_shared']) ? 1 : 0; } if (array_key_exists('color', $input)) { $color = trim((string)$input['color']); if ($color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $color)) { dbnToolsError('Invalid color.', 422, 'invalid_color'); } $fields[] = 'color = ?'; $params[] = $color !== '' ? $color : null; } if (array_key_exists('icon', $input)) { $icon = trim((string)$input['icon']); $fields[] = 'icon = ?'; $params[] = $icon !== '' ? substr($icon, 0, 40) : null; } if (!$fields) { dbnToolsError('Nothing to update.', 400, 'no_fields'); } $params[] = $id; $params[] = $clientId; $stmt = $db->prepare( 'UPDATE client_saved_searches SET ' . implode(', ', $fields) . ', updated_at = NOW() WHERE id = ? AND client_id = ?' ); $stmt->execute($params); dbnDmsLogAudit($clientId, $userId, 'saved_search_update', ['id' => $id]); dbnToolsRespond(['ok' => true]); } function deleteItem(PDO $db, int $clientId, int $userId, string $tenantRole): void { $input = dbnToolsJsonInput(2_000); $id = (int)($input['id'] ?? 0); if ($id <= 0) { dbnToolsError('id is required.', 400, 'missing_id'); } $row = $db->prepare('SELECT user_id FROM client_saved_searches WHERE id = ? AND client_id = ?'); $row->execute([$id, $clientId]); $existing = $row->fetch(); if (!$existing) { dbnToolsError('Not found.', 404, 'not_found'); } $isMine = (int)$existing['user_id'] === $userId; if (!$isMine && !in_array($tenantRole, ['admin','owner'], true)) { dbnToolsError('Forbidden.', 403, 'forbidden'); } $del = $db->prepare('DELETE FROM client_saved_searches WHERE id = ? AND client_id = ?'); $del->execute([$id, $clientId]); dbnDmsLogAudit($clientId, $userId, 'saved_search_delete', ['id' => $id]); dbnToolsRespond(['ok' => true]); } function reorderItems(PDO $db, int $clientId, int $userId): void { $input = dbnToolsJsonInput(20_000); $ids = $input['ids'] ?? []; if (!is_array($ids)) { dbnToolsError('ids array is required.', 400, 'missing_ids'); } $ids = array_values(array_filter(array_map('intval', $ids), fn($v) => $v > 0)); $upd = $db->prepare('UPDATE client_saved_searches SET sort_order = ? WHERE id = ? AND client_id = ? AND (user_id = ? OR is_shared = 1)'); foreach ($ids as $i => $id) { $upd->execute([$i, $id, $clientId, $userId]); } dbnToolsRespond(['ok' => true, 'reordered' => count($ids)]); } function sanitizeQuery(array $query): array { $allowed = ['q', 'category', 'status', 'source_type', 'folder_id', 'include_subfolders', 'tags', 'sort', 'dir']; $clean = []; foreach ($allowed as $key) { if (array_key_exists($key, $query)) { $clean[$key] = is_array($query[$key]) ? array_slice(array_values($query[$key]), 0, 50) : $query[$key]; } } return $clean; }