| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247 |
- import { getSession } from "@/lib/auth/session";
- import { canAccessBranch } from "@/lib/auth/permissions";
- import {
- withErrorHandling,
- json,
- badRequest,
- unauthorized,
- forbidden,
- } from "@/lib/api/errors";
- import { search as searchBackend } from "@/lib/search";
- /**
- * Search API (RHL-016)
- *
- * - Sync-first (Qsirch /search/) with cursor-based pagination.
- * - Provider abstraction allows later switch to async-search without changing this endpoint.
- *
- * Query params:
- * - scope: "branch" | "multi" | "all" (admin/dev only; branch users are forced to their own branch)
- * - branch: NLxx (for scope=branch)
- * - branches: comma-separated NLxx list (for scope=multi)
- * - q: optional text query
- * - from/to: optional inclusive ISO date (YYYY-MM-DD)
- * - limit: optional (default 100, allowed 50..200)
- * - cursor: optional opaque string
- */
- export const dynamic = "force-dynamic";
- const BRANCH_RE = /^NL\d+$/;
- const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
- function isValidIsoDate(value) {
- if (!ISO_DATE_RE.test(value)) return false;
- const [y, m, d] = value.split("-").map((x) => Number(x));
- if (!Number.isInteger(y) || !Number.isInteger(m) || !Number.isInteger(d))
- return false;
- // Basic calendar sanity (month/day ranges; does not validate month-length precisely,
- // but rejects obvious invalid formats and keeps behavior predictable).
- if (m < 1 || m > 12) return false;
- if (d < 1 || d > 31) return false;
- return true;
- }
- function parseLimitOrThrow(raw) {
- if (raw === null || raw === undefined || raw === "") return 100;
- if (!/^\d+$/.test(String(raw))) {
- throw badRequest("VALIDATION_SEARCH_LIMIT", "Invalid limit parameter", {
- limit: raw,
- });
- }
- const n = Number(raw);
- if (!Number.isInteger(n) || n < 50 || n > 200) {
- throw badRequest("VALIDATION_SEARCH_LIMIT", "Invalid limit parameter", {
- limit: n,
- min: 50,
- max: 200,
- });
- }
- return n;
- }
- function parseScope(raw) {
- const s = (raw || "").trim().toLowerCase();
- if (!s) return null;
- if (s === "branch" || s === "multi" || s === "all") return s;
- throw badRequest("VALIDATION_SEARCH_SCOPE", "Invalid scope parameter", {
- scope: raw,
- });
- }
- function parseBranchesCsv(raw) {
- if (!raw) return [];
- return String(raw)
- .split(",")
- .map((x) => x.trim())
- .filter(Boolean);
- }
- function unique(items) {
- return Array.from(new Set(items));
- }
- export const GET = withErrorHandling(
- async function GET(request) {
- const session = await getSession();
- if (!session) {
- throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
- }
- const { searchParams } = new URL(request.url);
- const rawScope = searchParams.get("scope");
- const scope = parseScope(rawScope);
- const qRaw = searchParams.get("q");
- const q = typeof qRaw === "string" && qRaw.trim() ? qRaw.trim() : null;
- const fromRaw = searchParams.get("from");
- const toRaw = searchParams.get("to");
- const from =
- typeof fromRaw === "string" && fromRaw.trim() ? fromRaw.trim() : null;
- const to = typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : null;
- if (from && !isValidIsoDate(from)) {
- throw badRequest("VALIDATION_SEARCH_DATE", "Invalid from date", { from });
- }
- if (to && !isValidIsoDate(to)) {
- throw badRequest("VALIDATION_SEARCH_DATE", "Invalid to date", { to });
- }
- if (from && to && from > to) {
- throw badRequest("VALIDATION_SEARCH_RANGE", "Invalid date range", {
- from,
- to,
- });
- }
- // At least one of q or date range must be provided to avoid "match everything"
- // queries by accident (especially dangerous for global admin searches).
- if (!q && !from && !to) {
- throw badRequest(
- "VALIDATION_SEARCH_MISSING_FILTER",
- "At least one of q or date range must be provided"
- );
- }
- const limit = parseLimitOrThrow(searchParams.get("limit"));
- const cursor = searchParams.get("cursor");
- const branchParam = searchParams.get("branch");
- const branchesParam = searchParams.get("branches");
- const requestedSingleBranch =
- typeof branchParam === "string" && branchParam.trim()
- ? branchParam.trim()
- : null;
- const requestedBranches =
- branchesParam && String(branchesParam).trim()
- ? unique(parseBranchesCsv(branchesParam))
- : [];
- // RBAC: branch users are forced to their own branch.
- if (session.role === "branch") {
- if (!session.branchId) {
- throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
- }
- // If the caller attempts to query other branches, reject.
- if (requestedSingleBranch && requestedSingleBranch !== session.branchId) {
- throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
- }
- if (requestedBranches.length > 0) {
- const hasForeign = requestedBranches.some(
- (b) => b !== session.branchId
- );
- if (hasForeign) throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
- }
- if (scope === "all") {
- throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
- }
- return json(
- await searchBackend({
- mode: "branch",
- branches: [session.branchId],
- q,
- from,
- to,
- limit,
- cursor: cursor ? String(cursor) : null,
- }),
- 200
- );
- }
- // Admin/dev: scope can be branch/multi/all
- let mode;
- let branches = null;
- if (scope === "all") {
- mode = "all";
- branches = null;
- } else if (requestedBranches.length > 0 || scope === "multi") {
- mode = "multi";
- branches = requestedBranches;
- if (!Array.isArray(branches) || branches.length === 0) {
- throw badRequest(
- "VALIDATION_SEARCH_BRANCHES",
- "Missing branches parameter for multi scope"
- );
- }
- } else {
- // Default to branch scope
- mode = "branch";
- if (!requestedSingleBranch) {
- throw badRequest(
- "VALIDATION_SEARCH_BRANCH",
- "Missing branch parameter for branch scope"
- );
- }
- branches = [requestedSingleBranch];
- }
- // Validate branch patterns (even for admin/dev) to avoid weird inputs.
- if (mode === "branch" || mode === "multi") {
- for (const b of branches) {
- if (!BRANCH_RE.test(b)) {
- throw badRequest(
- "VALIDATION_SEARCH_BRANCH",
- "Invalid branch parameter",
- {
- branch: b,
- }
- );
- }
- // Enforce RBAC helper semantics consistently, even though admin/dev will pass.
- if (!canAccessBranch(session, b)) {
- throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
- }
- }
- }
- const result = await searchBackend({
- mode,
- branches,
- q,
- from,
- to,
- limit,
- cursor: cursor ? String(cursor) : null,
- });
- return json(result, 200);
- },
- { logPrefix: "[api/search]" }
- );
|