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]" } );