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(), }); const ENTITY_PRIORITY = { employee: 0, announcement: 1, asset: 2, project: 3, contract_registry: 4, confidential_doc_ref: 5, leave: 6, reimbursement: 7, weekly_report: 8, todo: 9, purchase_request: 10, travel_request: 11, }; 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 isFlagEnabled(fastify, request, flagKey) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey, departmentId: request.employee.department_id ?? null, strictDepartment: false, }); return gate.enabled; } 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 isAdmin = fastify.hasPermission(request.employee, "*"); const featureEnabled = { announcements: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.announcements), weeklyReport: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.weeklyReport), todoBoard: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.todoBoard), projectDomain: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain), projectLinking: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking), confidentialDocRefs: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.confidentialDocRefs), }; const tasks = []; tasks.push( fastify.prisma.employee.findMany({ where: { status: "active", OR: [ { employee_no: { contains: q, mode: "insensitive" } }, { name: { contains: q, mode: "insensitive" } }, { email: { contains: q, mode: "insensitive" } }, ], }, take: limit, orderBy: { updated_at: "desc" }, select: { id: true, employee_no: true, name: true, email: true, title: true, updated_at: true }, }).then((rows) => rows.map((item) => ({ entityType: "employee", entityId: item.id, title: item.name, subtitle: `${item.employee_no} · ${item.title ?? item.email ?? "成员"}`, status: "active", href: `/people/${item.id}`, matchedBy: matchByValue(q, item.employee_no, item.name, item.email), updatedAt: item.updated_at?.toISOString?.() ?? null, }))), ); if (featureEnabled.announcements) { tasks.push( fastify.prisma.portalAnnouncement.findMany({ where: { enabled: true, OR: [ { title: { contains: q, mode: "insensitive" } }, { summary: { contains: q, mode: "insensitive" } }, ], }, take: limit, orderBy: { updated_at: "desc" }, select: { id: true, title: true, summary: true, updated_at: true }, }).then((rows) => rows.map((item) => ({ entityType: "announcement", entityId: item.id, title: item.title, subtitle: item.summary, status: null, href: "/announcements", matchedBy: matchByValue(q, item.title, item.summary), 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, }))), ); } 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, "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 (featureEnabled.weeklyReport) { tasks.push( fastify.prisma.weeklyReport.findMany({ where: { AND: [ fastify.hasPermission(request.employee, "report.dashboard.view") || fastify.hasPermission(request.employee, "system.users") || isAdmin ? {} : { employee_id: request.employee.id }, { OR: [ { highlights_text: { contains: q, mode: "insensitive" } }, { blockers_text: { contains: q, mode: "insensitive" } }, { next_week_text: { contains: q, mode: "insensitive" } }, ], }, ], }, take: limit, orderBy: { updated_at: "desc" }, include: { employee: { select: { name: true } } }, }).then((rows) => rows.map((item) => ({ entityType: "weekly_report", entityId: item.id, title: `${item.employee?.name ?? "成员"} 周报 ${item.week_start?.toISOString?.()?.slice(0, 10) ?? ""}`, subtitle: item.highlights_text || item.next_week_text || item.blockers_text || null, status: item.status, href: "/weekly-reports", matchedBy: matchByValue(q, item.highlights_text, item.blockers_text, item.next_week_text), updatedAt: item.updated_at?.toISOString?.() ?? null, }))), ); } if (featureEnabled.projectDomain && (fastify.hasPermission(request.employee, "project.view_own") || fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage"))) { tasks.push( fastify.prisma.project.findMany({ where: { AND: [ fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || isAdmin ? {} : { OR: [ { owner_employee_id: request.employee.id }, { manager_employee_id: request.employee.id }, { members: { some: { employee_id: request.employee.id, left_at: null } } }, ], }, { OR: [ { project_no: { contains: q, mode: "insensitive" } }, { name: { contains: q, mode: "insensitive" } }, { client_name: { contains: q, mode: "insensitive" } }, { client_alias: { contains: q, mode: "insensitive" } }, ], }, ], }, take: limit, orderBy: { updated_at: "desc" }, select: { id: true, project_no: true, name: true, client_name: true, client_alias: true, is_confidential: true, updated_at: true }, }).then((rows) => rows.map((item) => ({ entityType: "project", entityId: item.id, title: `${item.project_no} · ${item.name}`, subtitle: item.is_confidential ? "涉密项目" : item.client_alias || item.client_name || null, status: null, href: `/projects/${item.id}`, matchedBy: matchByValue(q, item.project_no, item.name, item.client_name, item.client_alias), updatedAt: item.updated_at?.toISOString?.() ?? null, }))), ); } if (featureEnabled.projectLinking && (fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || fastify.hasPermission(request.employee, "system.settings") || isAdmin)) { tasks.push( fastify.prisma.contractRegistry.findMany({ where: { OR: [ { contract_no: { contains: q, mode: "insensitive" } }, { AND: [ { confidentiality_level: { in: ["public", "internal"] } }, { OR: [ { counterparty_name: { contains: q, mode: "insensitive" } }, { summary: { contains: q, mode: "insensitive" } }, ], }, ], }, { project: { is: { project_no: { contains: q, mode: "insensitive" } } } }, ], }, take: limit, orderBy: { updated_at: "desc" }, select: { id: true, contract_no: true, counterparty_name: true, confidentiality_level: true, summary: true, updated_at: true, project: { select: { project_no: true } } }, }).then((rows) => rows.map((item) => { const isSensitive = ["confidential", "restricted"].includes(item.confidentiality_level); return { entityType: "contract_registry", entityId: item.id, title: item.contract_no, subtitle: isSensitive ? "涉密合同台账" : item.counterparty_name, status: item.confidentiality_level, href: "/contracts-registry", matchedBy: isSensitive ? matchByValue(q, item.contract_no, item.project?.project_no) : matchByValue(q, item.contract_no, item.counterparty_name, item.summary, item.project?.project_no), updatedAt: item.updated_at?.toISOString?.() ?? null, }; })), ); } if (featureEnabled.confidentialDocRefs && (fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || fastify.hasPermission(request.employee, "system.settings") || isAdmin)) { tasks.push( fastify.prisma.confidentialDocumentRef.findMany({ where: { OR: [ { document_no: { contains: q, mode: "insensitive" } }, { offline_archive_no: { contains: q, mode: "insensitive" } }, { project: { is: { project_no: { contains: q, mode: "insensitive" } } } }, { AND: [ { confidentiality_level: { in: ["public", "internal"] } }, { title: { contains: q, mode: "insensitive" } }, ], }, ], }, take: limit, orderBy: { updated_at: "desc" }, select: { id: true, title: true, document_no: true, offline_archive_no: true, confidentiality_level: true, updated_at: true, project: { select: { project_no: true } } }, }).then((rows) => rows.map((item) => { const isSensitive = ["confidential", "restricted"].includes(item.confidentiality_level); return { entityType: "confidential_doc_ref", entityId: item.id, title: item.document_no || item.title, subtitle: isSensitive ? "涉密资料引用" : item.title, status: item.confidentiality_level, href: "/contracts-registry", matchedBy: isSensitive ? matchByValue(q, item.document_no, item.offline_archive_no, item.project?.project_no) : matchByValue(q, item.title, item.document_no, item.offline_archive_no, item.project?.project_no), updatedAt: item.updated_at?.toISOString?.() ?? null, }; })), ); } if (featureEnabled.todoBoard) { tasks.push( fastify.prisma.todoItem.findMany({ where: { AND: [ fastify.hasPermission(request.employee, "report.dashboard.view") || fastify.hasPermission(request.employee, "system.users") || isAdmin ? {} : { OR: [{ assignee_employee_id: request.employee.id }, { created_by: request.employee.id }] }, { OR: [ { title: { contains: q, mode: "insensitive" } }, { description: { contains: q, mode: "insensitive" } }, ], }, ], }, take: limit, orderBy: { updated_at: "desc" }, select: { id: true, title: true, description: true, status: true, updated_at: true }, }).then((rows) => rows.map((item) => ({ entityType: "todo", entityId: item.id, title: item.title, subtitle: item.description ?? null, status: item.status, href: "/todos", matchedBy: matchByValue(q, item.title, item.description), 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, }))), ); } const items = (await Promise.all(tasks)).flat(); items.sort((left, right) => { const matchedDiff = compareMatchedBy(left.matchedBy, right.matchedBy); if (matchedDiff !== 0) return matchedDiff; const entityDiff = (ENTITY_PRIORITY[left.entityType] ?? 999) - (ENTITY_PRIORITY[right.entityType] ?? 999); if (entityDiff !== 0) return entityDiff; return String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")); }); return sendData(reply, request, { items: items.slice(0, limit) }); }); } module.exports = searchRoutes;