| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149 |
- 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.
- *
- * 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.
- *
- * @param {{
- * urlState: {
- * q: string|null,
- * scope: "single"|"multi"|"all",
- * branch: string|null,
- * branches: string[],
- * from: string|null,
- * to: string|null
- * },
- * routeBranch: string,
- * user: { role: string, branchId: string|null }|null,
- * cursor?: string|null,
- * limit?: number
- * }} args
- * @returns {{ input: any|null, error: any|null }}
- */
- export function buildSearchApiInput({
- urlState,
- routeBranch,
- user,
- cursor = null,
- limit = 100,
- }) {
- const q = isNonEmptyString(urlState?.q) ? urlState.q.trim() : null;
- // 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 };
- if (from) input.from = from;
- if (to) input.to = to;
- if (isNonEmptyString(cursor)) input.cursor = cursor.trim();
- const role = user?.role;
- // Branch users: always restricted to the current route branch.
- if (role === "branch") {
- input.branch = routeBranch;
- return { input, error: null };
- }
- // Admin/dev: respect scope rules from URL state.
- if (role === "admin" || role === "dev") {
- if (urlState.scope === SEARCH_SCOPE.ALL) {
- input.scope = "all";
- return { input, error: null };
- }
- if (urlState.scope === SEARCH_SCOPE.MULTI) {
- const branches = Array.isArray(urlState.branches)
- ? urlState.branches
- : [];
- // UX: missing branches is not an error, it's "not ready".
- if (branches.length === 0) {
- return { input: null, error: null };
- }
- input.scope = "multi";
- input.branches = branches;
- return { input, error: null };
- }
- // SINGLE
- input.branch = routeBranch;
- return { input, error: null };
- }
- // Unknown role: fail-safe to single-branch using the route context.
- input.branch = routeBranch;
- return { input, error: null };
- }
|