const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const QUERY_SCHEMA = z.object({ q: z.string().trim().min(2).max(100), limit: z.coerce.number().int().min(1).max(20).optional(), }); function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); } async function ensureFeatureEnabled(fastify, request) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.globalSearch, departmentId: request.employee.department_id ?? null, strictDepartment: false, }); if (!gate.enabled) { throw buildValidationError({ featureFlag: "global_search_v1_disabled", reason: gate.reason }, 409); } } function matchByValue(query, ...values) { const lower = query.toLowerCase(); const normalized = values.filter(Boolean).map((item) => String(item).toLowerCase()); if (normalized.some((item) => item === lower)) return "exact"; if (normalized.some((item) => item.startsWith(lower))) return "prefix"; return "contains"; } function compareMatchedBy(left, right) { const score = { exact: 0, prefix: 1, contains: 2 }; return score[left] - score[right]; } async function searchRoutes(fastify) { fastify.get("/search", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const parsed = QUERY_SCHEMA.safeParse(request.query ?? {}); if (!parsed.success) throw buildValidationError(parsed.error.flatten()); const q = parsed.data.q.trim(); const limit = parsed.data.limit ?? 10; const tasks = []; const isAdmin = fastify.hasPermission(request.employee, "*"); if (fastify.hasPermission(request.employee, "expense.view_own") || fastify.hasPermission(request.employee, "expense.view_all")) { tasks.push( fastify.prisma.reimbursementForm.findMany({ where: { AND: [ fastify.hasPermission(request.employee, "expense.view_all") || isAdmin ? {} : { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] }, { OR: [ { form_number: { contains: q, mode: "insensitive" } }, { description: { contains: q, mode: "insensitive" } }, ], }, ], }, take: limit, orderBy: { updated_at: "desc" }, select: { id: true, form_number: true, description: true, status: true, updated_at: true }, }).then((rows) => rows.map((item) => ({ entityType: "reimbursement", entityId: item.id, title: item.form_number ?? item.id, subtitle: item.description ?? null, status: item.status, href: `/reimbursements/${item.id}`, matchedBy: matchByValue(q, item.form_number, item.description), updatedAt: item.updated_at?.toISOString?.() ?? null, }))), ); } if (fastify.hasPermission(request.employee, "leave.view_own") || fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage")) { tasks.push( fastify.prisma.leaveRequest.findMany({ where: { AND: [ fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage") || isAdmin ? {} : { owner_id: request.employee.id }, { OR: [{ reason: { contains: q, mode: "insensitive" } }] }, ], }, take: limit, orderBy: { updated_at: "desc" }, select: { id: true, reason: true, status: true, start_date: true, end_date: true, updated_at: true }, }).then((rows) => rows.map((item) => ({ entityType: "leave", entityId: item.id, title: `请假 ${item.start_date?.toISOString?.()?.slice(0, 10) ?? ""} ~ ${item.end_date?.toISOString?.()?.slice(0, 10) ?? ""}`, subtitle: item.reason ?? null, status: item.status, href: "/leave-requests", matchedBy: matchByValue(q, item.reason), updatedAt: item.updated_at?.toISOString?.() ?? null, }))), ); } if (fastify.hasPermission(request.employee, "procurement.view_own") || fastify.hasPermission(request.employee, "procurement.view_all") || fastify.hasPermission(request.employee, "procurement.manage")) { tasks.push( fastify.prisma.purchaseRequest.findMany({ where: { AND: [ fastify.hasPermission(request.employee, "procurement.view_all") || fastify.hasPermission(request.employee, "procurement.manage") || isAdmin ? {} : { applicant_id: request.employee.id }, { OR: [ { request_no: { contains: q, mode: "insensitive" } }, { title: { contains: q, mode: "insensitive" } }, ], }, ], }, take: limit, orderBy: { updated_at: "desc" }, select: { id: true, request_no: true, title: true, status: true, updated_at: true }, }).then((rows) => rows.map((item) => ({ entityType: "purchase_request", entityId: item.id, title: item.request_no ?? item.title, subtitle: item.title, status: item.status, href: `/purchase-requests?id=${item.id}`, matchedBy: matchByValue(q, item.request_no, item.title), updatedAt: item.updated_at?.toISOString?.() ?? null, }))), ); } if (fastify.hasPermission(request.employee, "travel.view_own") || fastify.hasPermission(request.employee, "travel.view_all") || fastify.hasPermission(request.employee, "travel.manage")) { tasks.push( fastify.prisma.travelRequest.findMany({ where: { AND: [ fastify.hasPermission(request.employee, "travel.view_all") || fastify.hasPermission(request.employee, "travel.manage") || isAdmin ? {} : { OR: [{ applicant_employee_id: request.employee.id }, { submitter_id: request.employee.id }] }, { OR: [ { request_no: { contains: q, mode: "insensitive" } }, { purpose: { contains: q, mode: "insensitive" } }, ], }, ], }, take: limit, orderBy: { updated_at: "desc" }, select: { id: true, request_no: true, purpose: true, status: true, updated_at: true }, }).then((rows) => rows.map((item) => ({ entityType: "travel_request", entityId: item.id, title: item.request_no ?? item.purpose, subtitle: item.purpose, status: item.status, href: `/travel-requests?id=${item.id}`, matchedBy: matchByValue(q, item.request_no, item.purpose), updatedAt: item.updated_at?.toISOString?.() ?? null, }))), ); } if ( fastify.hasPermission(request.employee, "asset.view_own") || fastify.hasPermission(request.employee, "asset.view_all") || fastify.hasPermission(request.employee, "asset.manage") || fastify.hasPermission(request.employee, "asset.transfer") || fastify.hasPermission(request.employee, "asset.inventory") ) { tasks.push( fastify.prisma.assetCard.findMany({ where: { AND: [ fastify.hasPermission(request.employee, "asset.view_all") || fastify.hasPermission(request.employee, "asset.manage") || fastify.hasPermission(request.employee, "asset.transfer") || fastify.hasPermission(request.employee, "asset.inventory") || isAdmin ? {} : { assignee_employee_id: request.employee.id }, { OR: [ { asset_no: { contains: q, mode: "insensitive" } }, { serial_no: { contains: q, mode: "insensitive" } }, { name: { contains: q, mode: "insensitive" } }, ], }, ], }, take: limit, orderBy: { updated_at: "desc" }, select: { id: true, asset_no: true, serial_no: true, name: true, status: true, updated_at: true }, }).then((rows) => rows.map((item) => ({ entityType: "asset", entityId: item.id, title: item.asset_no, subtitle: item.name ?? item.serial_no ?? null, status: item.status, href: "/assets", matchedBy: matchByValue(q, item.asset_no, item.serial_no, item.name), updatedAt: item.updated_at?.toISOString?.() ?? null, }))), ); } const items = (await Promise.all(tasks)).flat(); items.sort((left, right) => { const matchedDiff = compareMatchedBy(left.matchedBy, right.matchedBy); if (matchedDiff !== 0) return matchedDiff; return String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")); }); return sendData(reply, request, { items: items.slice(0, limit) }); }); } module.exports = searchRoutes;