searchApiInput.js 2.7 KB

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