searchApiInput.js 2.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. import { SEARCH_SCOPE } from "@/lib/frontend/search/urlState";
  2. function isNonEmptyString(value) {
  3. return typeof value === "string" && value.trim().length > 0;
  4. }
  5. /**
  6. * Build the apiClient.search(...) input from URL state + current user context.
  7. *
  8. * Why this exists:
  9. * - Search UI state is URL-driven and shareable.
  10. * - Cursor is intentionally kept out of the URL by default (client state only).
  11. * - Role/scoping rules must be enforced consistently (branch users are always single-branch).
  12. *
  13. * Return shape:
  14. * - input: object for apiClient.search(...) or null (no search yet / not ready)
  15. * - error: ApiClientError or null (local validation / fast-fail)
  16. *
  17. * UX policy for MULTI without branches:
  18. * - Treat it as "not ready" (input=null, error=null) instead of an error.
  19. * The UI should show a friendly hint (select at least one branch).
  20. *
  21. * @param {{
  22. * urlState: {
  23. * q: string|null,
  24. * scope: "single"|"multi"|"all",
  25. * branch: string|null,
  26. * branches: string[],
  27. * from: string|null,
  28. * to: string|null
  29. * },
  30. * routeBranch: string,
  31. * user: { role: string, branchId: string|null }|null,
  32. * cursor?: string|null,
  33. * limit?: number
  34. * }} args
  35. * @returns {{ input: any|null, error: any|null }}
  36. */
  37. export function buildSearchApiInput({
  38. urlState,
  39. routeBranch,
  40. user,
  41. cursor = null,
  42. limit = 100,
  43. }) {
  44. const q = isNonEmptyString(urlState?.q) ? urlState.q.trim() : null;
  45. // No query => no search request. UI should show an "idle" empty state.
  46. if (!q) return { input: null, error: null };
  47. const input = { q, limit };
  48. // Keep from/to as pass-through for RHL-025 (future).
  49. if (isNonEmptyString(urlState?.from)) input.from = urlState.from.trim();
  50. if (isNonEmptyString(urlState?.to)) input.to = urlState.to.trim();
  51. if (isNonEmptyString(cursor)) input.cursor = cursor.trim();
  52. const role = user?.role;
  53. // Branch users: always restricted to the current route branch.
  54. if (role === "branch") {
  55. input.branch = routeBranch;
  56. return { input, error: null };
  57. }
  58. // Admin/dev: respect scope rules from URL state.
  59. if (role === "admin" || role === "dev") {
  60. if (urlState.scope === SEARCH_SCOPE.ALL) {
  61. input.scope = "all";
  62. return { input, error: null };
  63. }
  64. if (urlState.scope === SEARCH_SCOPE.MULTI) {
  65. const branches = Array.isArray(urlState.branches)
  66. ? urlState.branches
  67. : [];
  68. // UX: missing branches is not an error, it's "not ready".
  69. if (branches.length === 0) {
  70. return { input: null, error: null };
  71. }
  72. input.scope = "multi";
  73. input.branches = branches;
  74. return { input, error: null };
  75. }
  76. // SINGLE
  77. input.branch = routeBranch;
  78. return { input, error: null };
  79. }
  80. // Unknown role: fail-safe to single-branch using the route context.
  81. input.branch = routeBranch;
  82. return { input, error: null };
  83. }