searchApiInput.js 3.2 KB

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