|
@@ -0,0 +1,247 @@
|
|
|
|
|
+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]" }
|
|
|
|
|
+);
|