|
|
@@ -1,24 +1,36 @@
|
|
|
import { SEARCH_SCOPE } from "@/lib/frontend/search/urlState";
|
|
|
+import { ApiClientError } from "@/lib/frontend/apiClient";
|
|
|
+import {
|
|
|
+ isValidIsoDateYmd,
|
|
|
+ isInvalidIsoDateRange,
|
|
|
+} from "@/lib/frontend/search/dateRange";
|
|
|
|
|
|
function isNonEmptyString(value) {
|
|
|
return typeof value === "string" && value.trim().length > 0;
|
|
|
}
|
|
|
|
|
|
+function toTrimmedOrNull(value) {
|
|
|
+ return isNonEmptyString(value) ? value.trim() : null;
|
|
|
+}
|
|
|
+
|
|
|
+function buildValidationError(code, message, details) {
|
|
|
+ return new ApiClientError({
|
|
|
+ status: 400,
|
|
|
+ code,
|
|
|
+ message,
|
|
|
+ details,
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Build the apiClient.search(...) input from URL state + current user context.
|
|
|
*
|
|
|
- * Why this exists:
|
|
|
- * - Search UI state is URL-driven and shareable.
|
|
|
- * - Cursor is intentionally kept out of the URL by default (client state only).
|
|
|
- * - Role/scoping rules must be enforced consistently (branch users are always single-branch).
|
|
|
- *
|
|
|
* Return shape:
|
|
|
* - input: object for apiClient.search(...) or null (no search yet / not ready)
|
|
|
* - error: ApiClientError or null (local validation / fast-fail)
|
|
|
*
|
|
|
* UX policy for MULTI without branches:
|
|
|
* - Treat it as "not ready" (input=null, error=null) instead of an error.
|
|
|
- * The UI should show a friendly hint (select at least one branch).
|
|
|
*
|
|
|
* @param {{
|
|
|
* urlState: {
|
|
|
@@ -45,14 +57,54 @@ export function buildSearchApiInput({
|
|
|
}) {
|
|
|
const q = isNonEmptyString(urlState?.q) ? urlState.q.trim() : null;
|
|
|
|
|
|
- // No query => no search request. UI should show an "idle" empty state.
|
|
|
+ // UI policy (RHL-024): q is required to trigger a search.
|
|
|
if (!q) return { input: null, error: null };
|
|
|
|
|
|
+ // --- Date range validation (RHL-025) ------------------------------------
|
|
|
+ const from = toTrimmedOrNull(urlState?.from);
|
|
|
+ const to = toTrimmedOrNull(urlState?.to);
|
|
|
+
|
|
|
+ // If provided, dates must be valid YYYY-MM-DD.
|
|
|
+ if (from && !isValidIsoDateYmd(from)) {
|
|
|
+ return {
|
|
|
+ input: null,
|
|
|
+ error: buildValidationError(
|
|
|
+ "VALIDATION_SEARCH_DATE",
|
|
|
+ "Invalid from date",
|
|
|
+ {
|
|
|
+ from,
|
|
|
+ }
|
|
|
+ ),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ if (to && !isValidIsoDateYmd(to)) {
|
|
|
+ return {
|
|
|
+ input: null,
|
|
|
+ error: buildValidationError("VALIDATION_SEARCH_DATE", "Invalid to date", {
|
|
|
+ to,
|
|
|
+ }),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Range order: from must not be after to.
|
|
|
+ // IMPORTANT: from === to is valid and represents a single-day search.
|
|
|
+ if (isInvalidIsoDateRange(from, to)) {
|
|
|
+ return {
|
|
|
+ input: null,
|
|
|
+ error: buildValidationError(
|
|
|
+ "VALIDATION_SEARCH_RANGE",
|
|
|
+ "Invalid date range",
|
|
|
+ { from, to }
|
|
|
+ ),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // --- Build input ---------------------------------------------------------
|
|
|
const input = { q, limit };
|
|
|
|
|
|
- // Keep from/to as pass-through for RHL-025 (future).
|
|
|
- if (isNonEmptyString(urlState?.from)) input.from = urlState.from.trim();
|
|
|
- if (isNonEmptyString(urlState?.to)) input.to = urlState.to.trim();
|
|
|
+ if (from) input.from = from;
|
|
|
+ if (to) input.to = to;
|
|
|
|
|
|
if (isNonEmptyString(cursor)) input.cursor = cursor.trim();
|
|
|
|