searchApiInput.js 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. import { SEARCH_SCOPE } from "@/lib/frontend/search/urlState";
  2. import { ApiClientError } from "@/lib/frontend/apiClient";
  3. import {
  4. isValidIsoDateYmd,
  5. isInvalidIsoDateRange,
  6. } from "@/lib/frontend/search/dateRange";
  7. function isNonEmptyString(value) {
  8. return typeof value === "string" && value.trim().length > 0;
  9. }
  10. function toTrimmedOrNull(value) {
  11. return isNonEmptyString(value) ? value.trim() : null;
  12. }
  13. function buildValidationError(code, message, details) {
  14. return new ApiClientError({
  15. status: 400,
  16. code,
  17. message,
  18. details,
  19. });
  20. }
  21. /**
  22. * Build the apiClient.search(...) input from URL state + current user context.
  23. *
  24. * Return shape:
  25. * - input: object for apiClient.search(...) or null (no search yet / not ready)
  26. * - error: ApiClientError or null (local validation / fast-fail)
  27. *
  28. * UX policy for MULTI without branches:
  29. * - Treat it as "not ready" (input=null, error=null) instead of an error.
  30. *
  31. * @param {{
  32. * urlState: {
  33. * q: string|null,
  34. * scope: "single"|"multi"|"all",
  35. * branch: string|null,
  36. * branches: string[],
  37. * from: string|null,
  38. * to: string|null
  39. * },
  40. * routeBranch: string,
  41. * user: { role: string, branchId: string|null }|null,
  42. * cursor?: string|null,
  43. * limit?: number
  44. * }} args
  45. * @returns {{ input: any|null, error: any|null }}
  46. */
  47. export function buildSearchApiInput({
  48. urlState,
  49. routeBranch,
  50. user,
  51. cursor = null,
  52. limit = 100,
  53. }) {
  54. const q = isNonEmptyString(urlState?.q) ? urlState.q.trim() : null;
  55. // UI policy (RHL-024): q is required to trigger a search.
  56. if (!q) return { input: null, error: null };
  57. // --- Date range validation (RHL-025) ------------------------------------
  58. const from = toTrimmedOrNull(urlState?.from);
  59. const to = toTrimmedOrNull(urlState?.to);
  60. // If provided, dates must be valid YYYY-MM-DD.
  61. if (from && !isValidIsoDateYmd(from)) {
  62. return {
  63. input: null,
  64. error: buildValidationError(
  65. "VALIDATION_SEARCH_DATE",
  66. "Invalid from date",
  67. {
  68. from,
  69. }
  70. ),
  71. };
  72. }
  73. if (to && !isValidIsoDateYmd(to)) {
  74. return {
  75. input: null,
  76. error: buildValidationError("VALIDATION_SEARCH_DATE", "Invalid to date", {
  77. to,
  78. }),
  79. };
  80. }
  81. // Range order: from must not be after to.
  82. // IMPORTANT: from === to is valid and represents a single-day search.
  83. if (isInvalidIsoDateRange(from, to)) {
  84. return {
  85. input: null,
  86. error: buildValidationError(
  87. "VALIDATION_SEARCH_RANGE",
  88. "Invalid date range",
  89. { from, to }
  90. ),
  91. };
  92. }
  93. // --- Build input ---------------------------------------------------------
  94. const input = { q, limit };
  95. if (from) input.from = from;
  96. if (to) input.to = to;
  97. if (isNonEmptyString(cursor)) input.cursor = cursor.trim();
  98. const role = user?.role;
  99. // Branch users: always restricted to the current route branch.
  100. if (role === "branch") {
  101. input.branch = routeBranch;
  102. return { input, error: null };
  103. }
  104. // Admin/dev: respect scope rules from URL state.
  105. if (role === "admin" || role === "dev") {
  106. if (urlState.scope === SEARCH_SCOPE.ALL) {
  107. input.scope = "all";
  108. return { input, error: null };
  109. }
  110. if (urlState.scope === SEARCH_SCOPE.MULTI) {
  111. const branches = Array.isArray(urlState.branches)
  112. ? urlState.branches
  113. : [];
  114. // UX: missing branches is not an error, it's "not ready".
  115. if (branches.length === 0) {
  116. return { input: null, error: null };
  117. }
  118. input.scope = "multi";
  119. input.branches = branches;
  120. return { input, error: null };
  121. }
  122. // SINGLE
  123. input.branch = routeBranch;
  124. return { input, error: null };
  125. }
  126. // Unknown role: fail-safe to single-branch using the route context.
  127. input.branch = routeBranch;
  128. return { input, error: null };
  129. }