文件
hyb-oa/server/src/routes/search.js
T

497 行
20 KiB
JavaScript

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;