497 行
20 KiB
JavaScript
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;
|