2141 行
64 KiB
JavaScript
2141 行
64 KiB
JavaScript
const crypto = require("node:crypto");
|
|
const path = require("node:path");
|
|
const { Readable } = require("node:stream");
|
|
|
|
const ExcelJS = require("exceljs");
|
|
const { z } = require("zod");
|
|
|
|
const {
|
|
EMPLOYMENT_STATUS_VALUES,
|
|
buildEmployeeDirectoryWhere,
|
|
parseEmployeeDirectoryFilters,
|
|
} = require("../services/employeeDirectoryFilters");
|
|
const { applyEmployeeTransfer } = require("../services/employeeLifecycle");
|
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
|
const { AppError } = require("../utils/AppError");
|
|
const { sendData } = require("../utils/reply");
|
|
const { buildAuditLogData } = require("../utils/audit");
|
|
const { hashPassword } = require("../utils/crypto");
|
|
const { validatePassword } = require("../utils/passwordPolicy");
|
|
const {
|
|
decryptStringOrThrow,
|
|
decryptStringSafe,
|
|
encryptStringOrThrow,
|
|
maskBankAccount,
|
|
maskIdNumber,
|
|
} = require("../services/crypto");
|
|
const { currentDateString } = require("../utils/date");
|
|
|
|
const roleCodeSchema = z.enum([
|
|
"employee",
|
|
"manager",
|
|
"ceo",
|
|
"finance",
|
|
"finance_mgr",
|
|
"administration",
|
|
"hr",
|
|
"admin",
|
|
]);
|
|
|
|
const listPaginationSchema = z.object({
|
|
page: z.coerce.number().int().min(1).optional(),
|
|
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
|
});
|
|
|
|
const createBodySchema = z.object({
|
|
employeeNo: z.string().min(1).max(50),
|
|
name: z.string().min(1).max(100),
|
|
phone: z.string().min(6).max(30),
|
|
email: z.string().email().max(200),
|
|
departmentId: z.string().uuid().optional(),
|
|
title: z.string().min(1).max(100).optional(),
|
|
directManagerId: z.string().uuid().nullable().optional(),
|
|
hireDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
roles: z.array(roleCodeSchema).min(1).optional(),
|
|
password: z.string().min(1),
|
|
});
|
|
|
|
const updateBodySchema = z.object({
|
|
employeeNo: z.string().trim().min(1).max(50).optional(),
|
|
name: z.string().trim().min(1).max(100).optional(),
|
|
phone: z.string().trim().min(6).max(30).optional(),
|
|
email: z.string().trim().email().max(200).optional(),
|
|
departmentId: z.string().uuid().optional(),
|
|
title: z.string().trim().min(1).max(100).optional(),
|
|
directManagerId: z.string().uuid().nullable().optional(),
|
|
hireDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
});
|
|
|
|
const setRolesBodySchema = z.object({
|
|
roles: z.array(roleCodeSchema).min(1),
|
|
});
|
|
|
|
const resetPasswordBodySchema = z.object({
|
|
newPassword: z.string().min(1).optional(),
|
|
});
|
|
|
|
const sensitiveBodySchema = z
|
|
.object({
|
|
bankName: z.string().trim().max(100).nullable().optional(),
|
|
bankAccount: z.string().trim().max(100).nullable().optional(),
|
|
idNumber: z.string().trim().max(64).nullable().optional(),
|
|
})
|
|
.strict();
|
|
|
|
const USER_IMPORT_REQUIRED_HEADERS = ["employeeNo", "name", "phone", "email", "departmentName"];
|
|
const USER_IMPORT_OPTIONAL_HEADERS = ["title", "directManagerEmployeeNo", "roles", "hireDate"];
|
|
const USER_IMPORT_ALLOWED_EXTENSIONS = new Set([".csv", ".xlsx"]);
|
|
const USER_IMPORT_ALLOWED_MIME_TYPES = new Set([
|
|
"text/csv",
|
|
"application/csv",
|
|
"application/vnd.ms-excel",
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
]);
|
|
const USER_IMPORT_MAX_ROWS = 500;
|
|
const BATCH_EMPLOYEE_IDS_SCHEMA = z.array(z.string().uuid()).min(1);
|
|
const BATCH_TRANSFER_DEPARTMENT_SCHEMA = z.object({
|
|
employeeIds: BATCH_EMPLOYEE_IDS_SCHEMA,
|
|
departmentId: z.string().uuid(),
|
|
}).strict();
|
|
const BATCH_TOGGLE_SCHEMA = z.object({
|
|
employeeIds: BATCH_EMPLOYEE_IDS_SCHEMA,
|
|
}).strict();
|
|
const BATCH_SET_MANAGER_SCHEMA = z.object({
|
|
employeeIds: BATCH_EMPLOYEE_IDS_SCHEMA,
|
|
managerId: z.string().uuid(),
|
|
}).strict();
|
|
|
|
function parseUserId(params) {
|
|
const parsed = z.string().uuid().safeParse(params?.id);
|
|
if (!parsed.success) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: { id: "invalid_uuid" },
|
|
});
|
|
}
|
|
return parsed.data;
|
|
}
|
|
|
|
function parseListQuery(query, options = {}) {
|
|
const parsed = listPaginationSchema.safeParse(query ?? {});
|
|
if (!parsed.success) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: parsed.error.flatten(),
|
|
});
|
|
}
|
|
|
|
return {
|
|
page: parsed.data.page ?? 1,
|
|
pageSize: parsed.data.pageSize ?? 20,
|
|
...parseEmployeeDirectoryFilters(query, options),
|
|
};
|
|
}
|
|
|
|
function parseCreateBody(body) {
|
|
const parsed = createBodySchema.safeParse(body ?? {});
|
|
if (!parsed.success) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: parsed.error.flatten(),
|
|
});
|
|
}
|
|
|
|
const errors = validatePassword(parsed.data.password);
|
|
if (errors.length) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: { password: errors },
|
|
});
|
|
}
|
|
|
|
return {
|
|
...parsed.data,
|
|
roles: parsed.data.roles ?? ["employee"],
|
|
};
|
|
}
|
|
|
|
function parseUpdateBody(body) {
|
|
const parsed = updateBodySchema.safeParse(body ?? {});
|
|
if (!parsed.success) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: parsed.error.flatten(),
|
|
});
|
|
}
|
|
|
|
if (!Object.keys(parsed.data).length) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: { body: "empty" },
|
|
});
|
|
}
|
|
|
|
return parsed.data;
|
|
}
|
|
|
|
function mapEmployeeSummary(row) {
|
|
return {
|
|
id: row.id,
|
|
employeeNo: row.employee_no,
|
|
name: row.name,
|
|
phone: row.phone,
|
|
email: row.email,
|
|
status: row.status,
|
|
departmentId: row.department_id,
|
|
department: row.department ? { id: row.department.id, name: row.department.name } : null,
|
|
directManagerId: row.direct_manager_id ?? null,
|
|
directManager: row.direct_manager
|
|
? {
|
|
id: row.direct_manager.id,
|
|
employeeNo: row.direct_manager.employee_no,
|
|
name: row.direct_manager.name,
|
|
}
|
|
: null,
|
|
title: row.title,
|
|
employmentStatus: row.employment_status ?? null,
|
|
hireDate: row.hire_date?.toISOString?.()?.slice(0, 10) ?? null,
|
|
avatarUrl: row.avatar_path ? `/api/avatars/${row.id}` : null,
|
|
roleCodes: row.roles.map((item) => item.role_code),
|
|
createdAt: row.created_at?.toISOString?.() ?? null,
|
|
updatedAt: row.updated_at?.toISOString?.() ?? null,
|
|
};
|
|
}
|
|
|
|
function mapEmployeeDetail(row, options = {}) {
|
|
const config = options.config ?? null;
|
|
const logger = options.logger ?? null;
|
|
|
|
const bankAccountPlain = decryptStringSafe(config, row.bank_account_encrypted, logger);
|
|
const idNumberPlain = decryptStringSafe(config, row.id_number_encrypted, logger);
|
|
|
|
return {
|
|
...mapEmployeeSummary(row),
|
|
directManagerId: row.direct_manager_id ?? null,
|
|
hireDate: row.hire_date?.toISOString?.()?.slice(0, 10) ?? null,
|
|
archivedAt: row.archived_at?.toISOString?.() ?? null,
|
|
bankName: row.bank_name ? String(row.bank_name) : null,
|
|
bankAccountMasked: bankAccountPlain ? maskBankAccount(bankAccountPlain) : null,
|
|
idNumberMasked: idNumberPlain ? maskIdNumber(idNumberPlain) : null,
|
|
};
|
|
}
|
|
|
|
function normalizeUniqueErrorDetails(error) {
|
|
const meta = error?.meta ?? {};
|
|
const target = meta.target ?? meta?.cause?.constraint?.fields ?? null;
|
|
const fields = Array.isArray(target) ? target : typeof target === "string" ? [target] : [];
|
|
|
|
const details = {};
|
|
for (const field of fields) {
|
|
if (typeof field === "string" && field.trim()) {
|
|
details[field] = "duplicate";
|
|
}
|
|
}
|
|
|
|
return Object.keys(details).length ? details : { unique: "duplicate" };
|
|
}
|
|
|
|
function parseSensitiveBody(body) {
|
|
const parsed = sensitiveBodySchema.safeParse(body ?? {});
|
|
if (!parsed.success) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: parsed.error.flatten(),
|
|
});
|
|
}
|
|
|
|
if (!Object.keys(parsed.data).length) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: { body: "empty" },
|
|
});
|
|
}
|
|
|
|
return parsed.data;
|
|
}
|
|
|
|
function buildValidationError(details, statusCode = 400) {
|
|
return new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode,
|
|
details,
|
|
});
|
|
}
|
|
|
|
function parseBatchBody(schema, body) {
|
|
const parsed = schema.safeParse(body ?? {});
|
|
if (!parsed.success) {
|
|
throw buildValidationError(parsed.error.flatten());
|
|
}
|
|
|
|
const employeeIds = [...new Set(parsed.data.employeeIds)];
|
|
if (employeeIds.length > 50) {
|
|
throw buildValidationError({ employeeIds: "too_many" }, 409);
|
|
}
|
|
|
|
return {
|
|
...parsed.data,
|
|
employeeIds,
|
|
};
|
|
}
|
|
|
|
function ensureHasAnyPermission(fastify, request, permissionCodes) {
|
|
if (permissionCodes.some((code) => fastify.hasPermission(request.employee, code))) {
|
|
return;
|
|
}
|
|
|
|
throw new AppError({
|
|
code: "PERM_001",
|
|
message: "无权限",
|
|
statusCode: 403,
|
|
});
|
|
}
|
|
|
|
async function ensureLifecycleFeatureEnabled(fastify, request) {
|
|
const gate = await evaluateFeatureFlag({
|
|
prisma: fastify.prisma,
|
|
config: fastify.config,
|
|
flagKey: FEATURE_FLAG_KEYS.employeeLifecycle,
|
|
departmentId: request.employee.department_id ?? null,
|
|
strictDepartment: false,
|
|
});
|
|
|
|
if (!gate.enabled) {
|
|
throw buildValidationError({ featureFlag: "employee_lifecycle_v1_disabled", reason: gate.reason }, 409);
|
|
}
|
|
}
|
|
|
|
async function ensureDepartmentUsable(prisma, departmentId, fieldName = "departmentId") {
|
|
const department = await prisma.department.findUnique({
|
|
where: { id: departmentId },
|
|
select: { id: true, archived_at: true },
|
|
});
|
|
|
|
if (!department) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: { [fieldName]: "not_found" },
|
|
});
|
|
}
|
|
|
|
if (department.archived_at) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { [fieldName]: "archived" },
|
|
});
|
|
}
|
|
}
|
|
|
|
async function ensureManagerUsable(prisma, managerId, options = {}) {
|
|
const fieldName = options.fieldName ?? "directManagerId";
|
|
const disallowedEmployeeIds = options.disallowedEmployeeIds ?? [];
|
|
|
|
if (disallowedEmployeeIds.includes(managerId)) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { [fieldName]: "self_not_allowed" },
|
|
});
|
|
}
|
|
|
|
const manager = await prisma.employee.findUnique({
|
|
where: { id: managerId },
|
|
select: { id: true, status: true },
|
|
});
|
|
|
|
if (!manager) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: { [fieldName]: "not_found" },
|
|
});
|
|
}
|
|
|
|
if (manager.status !== "active") {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { [fieldName]: "inactive" },
|
|
});
|
|
}
|
|
}
|
|
|
|
async function ensureEmployeesExist(prisma, employeeIds) {
|
|
const employees = await prisma.employee.findMany({
|
|
where: { id: { in: employeeIds } },
|
|
select: { id: true },
|
|
});
|
|
|
|
if (employees.length !== employeeIds.length) {
|
|
const existingIds = new Set(employees.map((item) => item.id));
|
|
const missingId = employeeIds.find((item) => !existingIds.has(item));
|
|
throw new AppError({
|
|
code: "SYS_404",
|
|
message: "Not Found",
|
|
statusCode: 404,
|
|
details: { employeeId: missingId ?? null },
|
|
});
|
|
}
|
|
}
|
|
|
|
async function setEmployeeAccountStatus(tx, request, options) {
|
|
const updated = await tx.employee.update({
|
|
where: { id: options.employeeId },
|
|
data: {
|
|
status: options.status,
|
|
archived_at: options.status === "archived" ? options.now : null,
|
|
token_version: { increment: 1 },
|
|
updated_by: options.actorId,
|
|
},
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
token_version: true,
|
|
},
|
|
});
|
|
|
|
await tx.authSession.updateMany({
|
|
where: { employee_id: options.employeeId, revoked_at: null },
|
|
data: { revoked_at: options.now },
|
|
});
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: options.actorId,
|
|
action: options.status === "archived" ? "SYSTEM_USER_DISABLED" : "SYSTEM_USER_ENABLED",
|
|
targetType: "employee",
|
|
targetId: options.employeeId,
|
|
extra: {},
|
|
}),
|
|
});
|
|
|
|
return updated;
|
|
}
|
|
|
|
function generateOneTimePassword() {
|
|
const password = `ResetA1${crypto.randomBytes(6).toString("hex")}`;
|
|
const errors = validatePassword(password);
|
|
if (errors.length) {
|
|
throw buildValidationError({ password: errors });
|
|
}
|
|
return password;
|
|
}
|
|
|
|
function normalizeImportHeader(value) {
|
|
return String(value ?? "").replace(/^\ufeff/, "").trim();
|
|
}
|
|
|
|
function normalizeImportCell(value) {
|
|
if (value == null) {
|
|
return "";
|
|
}
|
|
if (value instanceof Date) {
|
|
return value.toISOString().slice(0, 10);
|
|
}
|
|
if (typeof value === "object") {
|
|
if (Array.isArray(value.richText)) {
|
|
return value.richText.map((item) => item?.text ?? "").join("").trim();
|
|
}
|
|
if (typeof value.text === "string") {
|
|
return value.text.trim();
|
|
}
|
|
if (value.result != null) {
|
|
return normalizeImportCell(value.result);
|
|
}
|
|
}
|
|
return String(value).trim();
|
|
}
|
|
|
|
function normalizeImportRoles(rawValue) {
|
|
const raw = normalizeImportCell(rawValue);
|
|
if (!raw) {
|
|
return ["employee"];
|
|
}
|
|
const values = raw
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
return values.length ? values : ["employee"];
|
|
}
|
|
|
|
function buildImportRowDraft(rawRow, defaultHireDate) {
|
|
const normalized = {
|
|
employeeNo: normalizeImportCell(rawRow.data.employeeNo),
|
|
name: normalizeImportCell(rawRow.data.name),
|
|
phone: normalizeImportCell(rawRow.data.phone),
|
|
email: normalizeImportCell(rawRow.data.email),
|
|
departmentName: normalizeImportCell(rawRow.data.departmentName),
|
|
title: normalizeImportCell(rawRow.data.title) || "员工",
|
|
directManagerEmployeeNo: normalizeImportCell(rawRow.data.directManagerEmployeeNo),
|
|
roles: normalizeImportRoles(rawRow.data.roles),
|
|
hireDate: normalizeImportCell(rawRow.data.hireDate) || defaultHireDate,
|
|
};
|
|
|
|
return {
|
|
rowNumber: rawRow.rowNumber,
|
|
employeeNo: normalized.employeeNo,
|
|
name: normalized.name,
|
|
normalized,
|
|
errors: [],
|
|
resolvedDepartmentId: null,
|
|
resolvedDirectManagerId: null,
|
|
resolvedImportedManagerEmployeeNo: null,
|
|
};
|
|
}
|
|
|
|
function pushImportRowError(row, field, message) {
|
|
const key = `${field}:${message}`;
|
|
if (row.errors.some((item) => `${item.field}:${item.message}` === key)) {
|
|
return;
|
|
}
|
|
row.errors.push({ field, message });
|
|
}
|
|
|
|
function markDuplicateImportValues(rows, key, field) {
|
|
const byValue = new Map();
|
|
for (const row of rows) {
|
|
const value = row.normalized[key];
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
const bucket = byValue.get(value) ?? [];
|
|
bucket.push(row);
|
|
byValue.set(value, bucket);
|
|
}
|
|
|
|
for (const bucket of byValue.values()) {
|
|
if (bucket.length <= 1) {
|
|
continue;
|
|
}
|
|
for (const row of bucket) {
|
|
pushImportRowError(row, field, "duplicate_in_file");
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveWorksheetRows(worksheet) {
|
|
if (!worksheet) {
|
|
return { headers: [], rows: [] };
|
|
}
|
|
|
|
const headerRow = worksheet.getRow(1);
|
|
const headers = headerRow.values.slice(1).map((value) => normalizeImportHeader(value));
|
|
const rows = [];
|
|
|
|
worksheet.eachRow((row, rowNumber) => {
|
|
if (rowNumber === 1) {
|
|
return;
|
|
}
|
|
const data = {};
|
|
headers.forEach((header, index) => {
|
|
if (!header) {
|
|
return;
|
|
}
|
|
data[header] = normalizeImportCell(row.getCell(index + 1).value);
|
|
});
|
|
|
|
if (Object.values(data).some((value) => value !== "")) {
|
|
rows.push({ rowNumber, data });
|
|
}
|
|
});
|
|
|
|
return { headers, rows };
|
|
}
|
|
|
|
async function parseUserImportRowsFromBuffer(buffer, extension) {
|
|
const workbook = new ExcelJS.Workbook();
|
|
if (extension === ".csv") {
|
|
const worksheet = await workbook.csv.read(Readable.from([buffer]));
|
|
return resolveWorksheetRows(worksheet);
|
|
}
|
|
|
|
await workbook.xlsx.load(buffer);
|
|
return resolveWorksheetRows(workbook.worksheets[0] ?? null);
|
|
}
|
|
|
|
async function readUserImportPayload(request) {
|
|
let filePart = null;
|
|
try {
|
|
filePart = await request.file();
|
|
} catch (error) {
|
|
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
|
|
throw buildValidationError({ file: "file_too_large" });
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (!filePart) {
|
|
throw buildValidationError({ file: "missing" });
|
|
}
|
|
|
|
const extension = path.extname(filePart.filename ?? "").toLowerCase();
|
|
if (!USER_IMPORT_ALLOWED_EXTENSIONS.has(extension)) {
|
|
throw buildValidationError({ file: "unsupported_extension" });
|
|
}
|
|
|
|
if (!USER_IMPORT_ALLOWED_MIME_TYPES.has(filePart.mimetype)) {
|
|
throw buildValidationError({ file: "unsupported_mime_type" });
|
|
}
|
|
|
|
const buffer = await filePart.toBuffer();
|
|
const parsed = await parseUserImportRowsFromBuffer(buffer, extension);
|
|
const missingHeaders = USER_IMPORT_REQUIRED_HEADERS.filter((header) => !parsed.headers.includes(header));
|
|
if (missingHeaders.length) {
|
|
throw buildValidationError({ file: "missing_headers", missingHeaders });
|
|
}
|
|
|
|
if (!parsed.rows.length) {
|
|
throw buildValidationError({ file: "empty" });
|
|
}
|
|
|
|
if (parsed.rows.length > USER_IMPORT_MAX_ROWS) {
|
|
throw buildValidationError({ file: "row_limit_exceeded", limit: USER_IMPORT_MAX_ROWS });
|
|
}
|
|
|
|
return {
|
|
filename: filePart.filename ?? "users-import",
|
|
rows: parsed.rows,
|
|
};
|
|
}
|
|
|
|
function validateImportRowFields(row) {
|
|
const { normalized } = row;
|
|
|
|
if (!normalized.employeeNo) {
|
|
pushImportRowError(row, "employeeNo", "required");
|
|
} else if (normalized.employeeNo.length > 50) {
|
|
pushImportRowError(row, "employeeNo", "too_long");
|
|
}
|
|
|
|
if (!normalized.name) {
|
|
pushImportRowError(row, "name", "required");
|
|
} else if (normalized.name.length > 100) {
|
|
pushImportRowError(row, "name", "too_long");
|
|
}
|
|
|
|
if (!normalized.phone) {
|
|
pushImportRowError(row, "phone", "required");
|
|
} else if (normalized.phone.length < 6) {
|
|
pushImportRowError(row, "phone", "too_short");
|
|
} else if (normalized.phone.length > 30) {
|
|
pushImportRowError(row, "phone", "too_long");
|
|
}
|
|
|
|
if (!normalized.email) {
|
|
pushImportRowError(row, "email", "required");
|
|
} else {
|
|
const parsedEmail = z.string().trim().email().max(200).safeParse(normalized.email);
|
|
if (!parsedEmail.success) {
|
|
pushImportRowError(row, "email", "invalid");
|
|
}
|
|
}
|
|
|
|
if (!normalized.departmentName) {
|
|
pushImportRowError(row, "departmentName", "required");
|
|
} else if (normalized.departmentName.length > 100) {
|
|
pushImportRowError(row, "departmentName", "too_long");
|
|
}
|
|
|
|
if (!normalized.title) {
|
|
pushImportRowError(row, "title", "required");
|
|
} else if (normalized.title.length > 100) {
|
|
pushImportRowError(row, "title", "too_long");
|
|
}
|
|
|
|
if (normalized.directManagerEmployeeNo && normalized.directManagerEmployeeNo.length > 50) {
|
|
pushImportRowError(row, "directManagerEmployeeNo", "too_long");
|
|
}
|
|
|
|
const parsedHireDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).safeParse(normalized.hireDate);
|
|
if (!parsedHireDate.success) {
|
|
pushImportRowError(row, "hireDate", "invalid");
|
|
}
|
|
|
|
if (!normalized.roles.length) {
|
|
pushImportRowError(row, "roles", "required");
|
|
}
|
|
for (const roleCode of normalized.roles) {
|
|
if (!roleCodeSchema.safeParse(roleCode).success) {
|
|
pushImportRowError(row, "roles", `invalid_role:${roleCode}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function createDepartmentResolutionMap(departments) {
|
|
const map = new Map();
|
|
for (const item of departments) {
|
|
const bucket = map.get(item.name) ?? [];
|
|
bucket.push(item);
|
|
map.set(item.name, bucket);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function createEmployeeLookupMaps(employees) {
|
|
return {
|
|
byEmployeeNo: new Map(employees.map((item) => [item.employee_no, item])),
|
|
byPhone: new Map(employees.map((item) => [item.phone, item])),
|
|
byEmail: new Map(employees.map((item) => [item.email, item])),
|
|
};
|
|
}
|
|
|
|
async function buildUserImportPreview(prisma, rawRows, options = {}) {
|
|
const defaultHireDate = currentDateString(options.timeZone ?? "Asia/Shanghai");
|
|
const previewRows = rawRows.map((row) => buildImportRowDraft(row, defaultHireDate));
|
|
|
|
for (const row of previewRows) {
|
|
validateImportRowFields(row);
|
|
}
|
|
|
|
markDuplicateImportValues(previewRows, "employeeNo", "employeeNo");
|
|
markDuplicateImportValues(previewRows, "phone", "phone");
|
|
markDuplicateImportValues(previewRows, "email", "email");
|
|
|
|
const departmentNames = Array.from(
|
|
new Set(previewRows.map((row) => row.normalized.departmentName).filter(Boolean)),
|
|
);
|
|
const employeeNos = Array.from(
|
|
new Set(
|
|
previewRows.flatMap((row) =>
|
|
[row.normalized.employeeNo, row.normalized.directManagerEmployeeNo].filter(Boolean),
|
|
),
|
|
),
|
|
);
|
|
const phones = Array.from(new Set(previewRows.map((row) => row.normalized.phone).filter(Boolean)));
|
|
const emails = Array.from(new Set(previewRows.map((row) => row.normalized.email).filter(Boolean)));
|
|
|
|
const [departments, employees] = await Promise.all([
|
|
departmentNames.length
|
|
? prisma.department.findMany({
|
|
where: { name: { in: departmentNames } },
|
|
select: { id: true, name: true, archived_at: true },
|
|
})
|
|
: Promise.resolve([]),
|
|
employeeNos.length || phones.length || emails.length
|
|
? prisma.employee.findMany({
|
|
where: {
|
|
OR: [
|
|
...(employeeNos.length ? [{ employee_no: { in: employeeNos } }] : []),
|
|
...(phones.length ? [{ phone: { in: phones } }] : []),
|
|
...(emails.length ? [{ email: { in: emails } }] : []),
|
|
],
|
|
},
|
|
select: {
|
|
id: true,
|
|
employee_no: true,
|
|
phone: true,
|
|
email: true,
|
|
status: true,
|
|
},
|
|
})
|
|
: Promise.resolve([]),
|
|
]);
|
|
|
|
const departmentsByName = createDepartmentResolutionMap(departments);
|
|
const existingLookup = createEmployeeLookupMaps(employees);
|
|
|
|
for (const row of previewRows) {
|
|
const departmentMatches = departmentsByName.get(row.normalized.departmentName) ?? [];
|
|
const activeDepartmentMatches = departmentMatches.filter((item) => !item.archived_at);
|
|
if (!departmentMatches.length) {
|
|
pushImportRowError(row, "departmentName", "not_found");
|
|
} else if (!activeDepartmentMatches.length) {
|
|
pushImportRowError(row, "departmentName", "archived");
|
|
} else if (activeDepartmentMatches.length > 1) {
|
|
pushImportRowError(row, "departmentName", "ambiguous");
|
|
} else {
|
|
row.resolvedDepartmentId = activeDepartmentMatches[0].id;
|
|
}
|
|
|
|
const employeeNoConflict = existingLookup.byEmployeeNo.get(row.normalized.employeeNo);
|
|
if (employeeNoConflict) {
|
|
pushImportRowError(row, "employeeNo", "duplicate_existing");
|
|
}
|
|
|
|
const phoneConflict = existingLookup.byPhone.get(row.normalized.phone);
|
|
if (phoneConflict) {
|
|
pushImportRowError(row, "phone", "duplicate_existing");
|
|
}
|
|
|
|
const emailConflict = existingLookup.byEmail.get(row.normalized.email);
|
|
if (emailConflict) {
|
|
pushImportRowError(row, "email", "duplicate_existing");
|
|
}
|
|
|
|
if (!row.normalized.directManagerEmployeeNo) {
|
|
continue;
|
|
}
|
|
|
|
if (row.normalized.directManagerEmployeeNo === row.normalized.employeeNo) {
|
|
pushImportRowError(row, "directManagerEmployeeNo", "self_not_allowed");
|
|
continue;
|
|
}
|
|
|
|
const existingManager = existingLookup.byEmployeeNo.get(row.normalized.directManagerEmployeeNo);
|
|
if (existingManager) {
|
|
if (existingManager.status !== "active") {
|
|
pushImportRowError(row, "directManagerEmployeeNo", "inactive");
|
|
} else {
|
|
row.resolvedDirectManagerId = existingManager.id;
|
|
}
|
|
}
|
|
}
|
|
|
|
const importRowsByEmployeeNo = new Map(
|
|
previewRows
|
|
.filter((row) => row.normalized.employeeNo && !row.errors.some((item) => item.field === "employeeNo"))
|
|
.map((row) => [row.normalized.employeeNo, row]),
|
|
);
|
|
const resolvedRows = new Set();
|
|
const resolvingRows = new Set();
|
|
|
|
function resolveImportedManager(row) {
|
|
if (!row.normalized.directManagerEmployeeNo || row.resolvedDirectManagerId) {
|
|
resolvedRows.add(row);
|
|
return;
|
|
}
|
|
if (resolvedRows.has(row)) {
|
|
return;
|
|
}
|
|
if (resolvingRows.has(row)) {
|
|
pushImportRowError(row, "directManagerEmployeeNo", "cycle_not_allowed");
|
|
return;
|
|
}
|
|
|
|
const importedManager = importRowsByEmployeeNo.get(row.normalized.directManagerEmployeeNo);
|
|
if (!importedManager) {
|
|
pushImportRowError(row, "directManagerEmployeeNo", "not_found");
|
|
resolvedRows.add(row);
|
|
return;
|
|
}
|
|
|
|
resolvingRows.add(row);
|
|
resolveImportedManager(importedManager);
|
|
resolvingRows.delete(row);
|
|
|
|
if (importedManager.errors.length) {
|
|
pushImportRowError(row, "directManagerEmployeeNo", "referenced_row_invalid");
|
|
} else {
|
|
row.resolvedImportedManagerEmployeeNo = importedManager.normalized.employeeNo;
|
|
}
|
|
resolvedRows.add(row);
|
|
}
|
|
|
|
for (const row of previewRows) {
|
|
resolveImportedManager(row);
|
|
}
|
|
|
|
return {
|
|
summary: {
|
|
totalRows: previewRows.length,
|
|
validRows: previewRows.filter((row) => row.errors.length === 0).length,
|
|
invalidRows: previewRows.filter((row) => row.errors.length > 0).length,
|
|
},
|
|
rows: previewRows.map((row) => ({
|
|
rowNumber: row.rowNumber,
|
|
employeeNo: row.employeeNo,
|
|
name: row.name,
|
|
normalized: row.normalized,
|
|
errors: row.errors,
|
|
})),
|
|
validRows: previewRows.filter((row) => row.errors.length === 0),
|
|
};
|
|
}
|
|
|
|
async function createSystemUser(tx, options) {
|
|
const now = options.now ?? new Date();
|
|
const passwordHash = await hashPassword(options.password);
|
|
|
|
const employee = await tx.employee.create({
|
|
data: {
|
|
employee_no: options.employeeNo,
|
|
name: options.name,
|
|
phone: options.phone,
|
|
email: options.email,
|
|
department_id: options.departmentId,
|
|
title: options.title ?? "员工",
|
|
direct_manager_id: options.directManagerId ?? null,
|
|
hire_date: options.hireDate,
|
|
status: "active",
|
|
bank_account_encrypted: "",
|
|
bank_name: "",
|
|
password_hash: passwordHash,
|
|
must_change_password: true,
|
|
created_at: now,
|
|
created_by: options.actorId,
|
|
updated_at: now,
|
|
updated_by: options.actorId,
|
|
},
|
|
select: {
|
|
id: true,
|
|
employee_no: true,
|
|
name: true,
|
|
phone: true,
|
|
email: true,
|
|
status: true,
|
|
department_id: true,
|
|
direct_manager_id: true,
|
|
title: true,
|
|
employment_status: true,
|
|
hire_date: true,
|
|
created_at: true,
|
|
updated_at: true,
|
|
},
|
|
});
|
|
|
|
await tx.employeeRole.createMany({
|
|
data: options.roles.map((roleCode) => ({
|
|
employee_id: employee.id,
|
|
role_code: roleCode,
|
|
created_at: now,
|
|
created_by: options.actorId,
|
|
})),
|
|
skipDuplicates: true,
|
|
});
|
|
|
|
return employee;
|
|
}
|
|
|
|
async function usersRoutes(fastify, opts = {}) {
|
|
const basePathRaw =
|
|
typeof opts.basePath === "string" && opts.basePath.trim() ? opts.basePath.trim() : "/users";
|
|
const basePath = basePathRaw.startsWith("/") ? basePathRaw : `/${basePathRaw}`;
|
|
const routeBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
|
|
const allowDepartmentFilter = opts.allowDepartmentFilter === true;
|
|
const listPermissionCodes =
|
|
Array.isArray(opts.listPermissionCodes) && opts.listPermissionCodes.length
|
|
? [...new Set(opts.listPermissionCodes)]
|
|
: ["system.users"];
|
|
const enableBatchActions = routeBasePath === "/users" && opts.enableBatchActions !== false;
|
|
|
|
fastify.get(
|
|
routeBasePath,
|
|
{
|
|
preHandler: fastify.authenticate,
|
|
},
|
|
async (request, reply) => {
|
|
ensureHasAnyPermission(fastify, request, listPermissionCodes);
|
|
const query = parseListQuery(request.query, { allowDepartmentFilter });
|
|
const skip = (query.page - 1) * query.pageSize;
|
|
const where = buildEmployeeDirectoryWhere(query);
|
|
|
|
const [items, total] = await Promise.all([
|
|
fastify.prisma.employee.findMany({
|
|
where,
|
|
orderBy: { created_at: "desc" },
|
|
skip,
|
|
take: query.pageSize,
|
|
select: {
|
|
id: true,
|
|
employee_no: true,
|
|
name: true,
|
|
phone: true,
|
|
email: true,
|
|
status: true,
|
|
department_id: true,
|
|
direct_manager_id: true,
|
|
title: true,
|
|
employment_status: true,
|
|
hire_date: true,
|
|
avatar_path: true,
|
|
created_at: true,
|
|
updated_at: true,
|
|
department: { select: { id: true, name: true } },
|
|
direct_manager: { select: { id: true, employee_no: true, name: true } },
|
|
roles: { select: { role_code: true } },
|
|
},
|
|
}),
|
|
fastify.prisma.employee.count({ where }),
|
|
]);
|
|
|
|
return sendData(reply, request, {
|
|
page: query.page,
|
|
pageSize: query.pageSize,
|
|
total,
|
|
items: items.map(mapEmployeeSummary),
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.get(
|
|
`${routeBasePath}/:id`,
|
|
{
|
|
preHandler: fastify.authenticate,
|
|
},
|
|
async (request, reply) => {
|
|
const userId = parseUserId(request.params);
|
|
const canManage = fastify.hasPermission(request.employee, "system.users");
|
|
|
|
if (!canManage) {
|
|
const canViewOwn = fastify.hasPermission(request.employee, "expense.view_own");
|
|
const isSelf = request.employee.id === userId;
|
|
if (!canViewOwn || !isSelf) {
|
|
throw new AppError({
|
|
code: "PERM_001",
|
|
message: "无权限",
|
|
statusCode: 403,
|
|
});
|
|
}
|
|
}
|
|
|
|
const employee = await fastify.prisma.employee.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
id: true,
|
|
employee_no: true,
|
|
name: true,
|
|
phone: true,
|
|
email: true,
|
|
status: true,
|
|
department_id: true,
|
|
title: true,
|
|
direct_manager_id: true,
|
|
hire_date: true,
|
|
bank_account_encrypted: true,
|
|
bank_name: true,
|
|
id_number_encrypted: true,
|
|
archived_at: true,
|
|
created_at: true,
|
|
updated_at: true,
|
|
roles: { select: { role_code: true } },
|
|
},
|
|
});
|
|
|
|
if (!employee) {
|
|
throw new AppError({
|
|
code: "SYS_404",
|
|
message: "Not Found",
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
return sendData(
|
|
reply,
|
|
request,
|
|
mapEmployeeDetail(employee, { config: fastify.config, logger: request.log ?? fastify.log }),
|
|
);
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
routeBasePath,
|
|
{
|
|
preHandler: fastify.requirePermission("system.users"),
|
|
},
|
|
async (request, reply) => {
|
|
const body = parseCreateBody(request.body);
|
|
const hireDate = body.hireDate ? new Date(`${body.hireDate}T00:00:00.000Z`) : new Date();
|
|
const departmentId = body.departmentId ?? request.employee.department_id;
|
|
|
|
await ensureDepartmentUsable(fastify.prisma, departmentId);
|
|
|
|
if (typeof body.directManagerId === "string") {
|
|
await ensureManagerUsable(fastify.prisma, body.directManagerId);
|
|
}
|
|
|
|
const now = new Date();
|
|
|
|
let created = null;
|
|
|
|
try {
|
|
created = await fastify.prisma.$transaction(async (tx) => {
|
|
const employee = await createSystemUser(tx, {
|
|
employeeNo: body.employeeNo,
|
|
name: body.name,
|
|
phone: body.phone,
|
|
email: body.email,
|
|
departmentId,
|
|
title: body.title ?? "员工",
|
|
directManagerId: body.directManagerId ?? null,
|
|
hireDate,
|
|
password: body.password,
|
|
roles: body.roles,
|
|
actorId: request.employee.id,
|
|
now,
|
|
});
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "SYSTEM_USER_CREATED",
|
|
targetType: "employee",
|
|
targetId: employee.id,
|
|
extra: {
|
|
employeeNo: employee.employee_no,
|
|
email: employee.email,
|
|
phone: employee.phone,
|
|
roles: body.roles,
|
|
},
|
|
}),
|
|
});
|
|
|
|
return employee;
|
|
});
|
|
} catch (error) {
|
|
if (error?.code === "P2002") {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: normalizeUniqueErrorDetails(error),
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const roles = await fastify.prisma.employeeRole.findMany({
|
|
where: { employee_id: created.id },
|
|
select: { role_code: true },
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
...mapEmployeeSummary({ ...created, roles }),
|
|
});
|
|
},
|
|
);
|
|
|
|
if (enableBatchActions) {
|
|
fastify.post(
|
|
`${routeBasePath}/batch/transfer-department`,
|
|
{
|
|
preHandler: fastify.requirePermission("system.users"),
|
|
},
|
|
async (request, reply) => {
|
|
await ensureLifecycleFeatureEnabled(fastify, request);
|
|
const body = parseBatchBody(BATCH_TRANSFER_DEPARTMENT_SCHEMA, request.body);
|
|
await ensureDepartmentUsable(fastify.prisma, body.departmentId);
|
|
|
|
await fastify.prisma.$transaction(async (tx) => {
|
|
await ensureEmployeesExist(tx, body.employeeIds);
|
|
|
|
for (const employeeId of body.employeeIds) {
|
|
await applyEmployeeTransfer(tx, {
|
|
employeeId,
|
|
actorId: request.employee.id,
|
|
departmentId: body.departmentId,
|
|
});
|
|
}
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "SYSTEM_USERS_BATCH_TRANSFER_DEPARTMENT",
|
|
targetType: "employee_batch",
|
|
extra: {
|
|
employeeIds: body.employeeIds,
|
|
departmentId: body.departmentId,
|
|
summary: {
|
|
total: body.employeeIds.length,
|
|
updated: body.employeeIds.length,
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
|
|
employeeIds: body.employeeIds,
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
`${routeBasePath}/batch/set-manager`,
|
|
{
|
|
preHandler: fastify.requirePermission("system.users"),
|
|
},
|
|
async (request, reply) => {
|
|
await ensureLifecycleFeatureEnabled(fastify, request);
|
|
const body = parseBatchBody(BATCH_SET_MANAGER_SCHEMA, request.body);
|
|
await ensureManagerUsable(fastify.prisma, body.managerId, {
|
|
fieldName: "managerId",
|
|
disallowedEmployeeIds: body.employeeIds,
|
|
});
|
|
|
|
await fastify.prisma.$transaction(async (tx) => {
|
|
await ensureEmployeesExist(tx, body.employeeIds);
|
|
|
|
for (const employeeId of body.employeeIds) {
|
|
await applyEmployeeTransfer(tx, {
|
|
employeeId,
|
|
actorId: request.employee.id,
|
|
directManagerId: body.managerId,
|
|
});
|
|
}
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "SYSTEM_USERS_BATCH_SET_MANAGER",
|
|
targetType: "employee_batch",
|
|
extra: {
|
|
employeeIds: body.employeeIds,
|
|
managerId: body.managerId,
|
|
summary: {
|
|
total: body.employeeIds.length,
|
|
updated: body.employeeIds.length,
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
|
|
employeeIds: body.employeeIds,
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
`${routeBasePath}/batch/disable`,
|
|
{
|
|
preHandler: fastify.requirePermission("system.users"),
|
|
},
|
|
async (request, reply) => {
|
|
const body = parseBatchBody(BATCH_TOGGLE_SCHEMA, request.body);
|
|
if (body.employeeIds.includes(request.employee.id)) {
|
|
throw buildValidationError({ user: "self_disable_not_allowed" }, 409);
|
|
}
|
|
|
|
const now = new Date();
|
|
await fastify.prisma.$transaction(async (tx) => {
|
|
await ensureEmployeesExist(tx, body.employeeIds);
|
|
|
|
for (const employeeId of body.employeeIds) {
|
|
await setEmployeeAccountStatus(tx, request, {
|
|
employeeId,
|
|
actorId: request.employee.id,
|
|
status: "archived",
|
|
now,
|
|
});
|
|
}
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "SYSTEM_USERS_BATCH_DISABLED",
|
|
targetType: "employee_batch",
|
|
extra: {
|
|
employeeIds: body.employeeIds,
|
|
summary: {
|
|
total: body.employeeIds.length,
|
|
updated: body.employeeIds.length,
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
|
|
employeeIds: body.employeeIds,
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
`${routeBasePath}/batch/enable`,
|
|
{
|
|
preHandler: fastify.requirePermission("system.users"),
|
|
},
|
|
async (request, reply) => {
|
|
const body = parseBatchBody(BATCH_TOGGLE_SCHEMA, request.body);
|
|
const now = new Date();
|
|
|
|
await fastify.prisma.$transaction(async (tx) => {
|
|
await ensureEmployeesExist(tx, body.employeeIds);
|
|
|
|
for (const employeeId of body.employeeIds) {
|
|
await setEmployeeAccountStatus(tx, request, {
|
|
employeeId,
|
|
actorId: request.employee.id,
|
|
status: "active",
|
|
now,
|
|
});
|
|
}
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "SYSTEM_USERS_BATCH_ENABLED",
|
|
targetType: "employee_batch",
|
|
extra: {
|
|
employeeIds: body.employeeIds,
|
|
summary: {
|
|
total: body.employeeIds.length,
|
|
updated: body.employeeIds.length,
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
|
|
employeeIds: body.employeeIds,
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
if (routeBasePath === "/users") {
|
|
fastify.post(
|
|
`${routeBasePath}/import/dry-run`,
|
|
{
|
|
preHandler: fastify.requirePermission("system.users"),
|
|
},
|
|
async (request, reply) => {
|
|
const { filename, rows } = await readUserImportPayload(request);
|
|
const preview = await buildUserImportPreview(fastify.prisma, rows, {
|
|
timeZone: fastify.config.appTimezone,
|
|
});
|
|
|
|
await fastify.prisma.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "SYSTEM_USERS_IMPORT_DRY_RUN",
|
|
targetType: "employee_import",
|
|
extra: {
|
|
sourceFilename: filename,
|
|
...preview.summary,
|
|
},
|
|
}),
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
sourceFilename: filename,
|
|
summary: preview.summary,
|
|
rows: preview.rows,
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
`${routeBasePath}/import/apply`,
|
|
{
|
|
preHandler: fastify.requirePermission("system.users"),
|
|
},
|
|
async (request, reply) => {
|
|
const { filename, rows } = await readUserImportPayload(request);
|
|
|
|
let result = null;
|
|
try {
|
|
result = await fastify.prisma.$transaction(async (tx) => {
|
|
const preview = await buildUserImportPreview(tx, rows, {
|
|
timeZone: fastify.config.appTimezone,
|
|
});
|
|
|
|
if (preview.summary.invalidRows > 0) {
|
|
throw buildValidationError(
|
|
{
|
|
summary: preview.summary,
|
|
rows: preview.rows,
|
|
},
|
|
409,
|
|
);
|
|
}
|
|
|
|
const now = new Date();
|
|
const createdRows = [];
|
|
const createdByEmployeeNo = new Map();
|
|
|
|
for (const row of preview.validRows) {
|
|
const oneTimePassword = generateOneTimePassword();
|
|
const employee = await createSystemUser(tx, {
|
|
employeeNo: row.normalized.employeeNo,
|
|
name: row.normalized.name,
|
|
phone: row.normalized.phone,
|
|
email: row.normalized.email,
|
|
departmentId: row.resolvedDepartmentId,
|
|
title: row.normalized.title,
|
|
directManagerId: row.resolvedImportedManagerEmployeeNo ? null : row.resolvedDirectManagerId,
|
|
hireDate: new Date(`${row.normalized.hireDate}T00:00:00.000Z`),
|
|
password: oneTimePassword,
|
|
roles: row.normalized.roles,
|
|
actorId: request.employee.id,
|
|
now,
|
|
});
|
|
|
|
const item = {
|
|
employee,
|
|
row,
|
|
oneTimePassword,
|
|
};
|
|
createdRows.push(item);
|
|
createdByEmployeeNo.set(row.normalized.employeeNo, item);
|
|
}
|
|
|
|
for (const item of createdRows) {
|
|
if (!item.row.resolvedImportedManagerEmployeeNo) {
|
|
continue;
|
|
}
|
|
const manager = createdByEmployeeNo.get(item.row.resolvedImportedManagerEmployeeNo);
|
|
await tx.employee.update({
|
|
where: { id: item.employee.id },
|
|
data: {
|
|
direct_manager_id: manager.employee.id,
|
|
updated_at: now,
|
|
updated_by: request.employee.id,
|
|
},
|
|
});
|
|
}
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "SYSTEM_USERS_IMPORTED",
|
|
targetType: "employee_import",
|
|
extra: {
|
|
sourceFilename: filename,
|
|
importedCount: createdRows.length,
|
|
employeeNos: createdRows.map((item) => item.employee.employee_no),
|
|
},
|
|
}),
|
|
});
|
|
|
|
return {
|
|
sourceFilename: filename,
|
|
summary: preview.summary,
|
|
imported: createdRows.map((item) => ({
|
|
employeeId: item.employee.id,
|
|
employeeNo: item.employee.employee_no,
|
|
name: item.employee.name,
|
|
email: item.employee.email,
|
|
oneTimePassword: item.oneTimePassword,
|
|
})),
|
|
};
|
|
});
|
|
} catch (error) {
|
|
if (error?.code === "P2002") {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: normalizeUniqueErrorDetails(error),
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
return sendData(reply, request, result);
|
|
},
|
|
);
|
|
}
|
|
|
|
fastify.patch(
|
|
`${routeBasePath}/:id`,
|
|
{
|
|
preHandler: fastify.requirePermission("system.users"),
|
|
},
|
|
async (request, reply) => {
|
|
const userId = parseUserId(request.params);
|
|
const body = parseUpdateBody(request.body);
|
|
const now = new Date();
|
|
|
|
const hireDate =
|
|
typeof body.hireDate === "string" ? new Date(`${body.hireDate}T00:00:00.000Z`) : null;
|
|
if (hireDate && Number.isNaN(hireDate.getTime())) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: { hireDate: "invalid_date" },
|
|
});
|
|
}
|
|
|
|
if (typeof body.departmentId === "string") {
|
|
await ensureDepartmentUsable(fastify.prisma, body.departmentId);
|
|
}
|
|
|
|
if (Object.prototype.hasOwnProperty.call(body, "directManagerId")) {
|
|
if (typeof body.directManagerId === "string") {
|
|
await ensureManagerUsable(fastify.prisma, body.directManagerId, {
|
|
disallowedEmployeeIds: [userId],
|
|
});
|
|
}
|
|
}
|
|
|
|
let updated = null;
|
|
try {
|
|
updated = await fastify.prisma.$transaction(async (tx) => {
|
|
const current = await tx.employee.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
id: true,
|
|
employee_no: true,
|
|
name: true,
|
|
phone: true,
|
|
email: true,
|
|
status: true,
|
|
department_id: true,
|
|
title: true,
|
|
direct_manager_id: true,
|
|
hire_date: true,
|
|
bank_account_encrypted: true,
|
|
bank_name: true,
|
|
id_number_encrypted: true,
|
|
archived_at: true,
|
|
created_at: true,
|
|
updated_at: true,
|
|
roles: { select: { role_code: true } },
|
|
},
|
|
});
|
|
if (!current) {
|
|
throw new AppError({
|
|
code: "SYS_404",
|
|
message: "Not Found",
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
const changes = {};
|
|
if (typeof body.employeeNo === "string" && body.employeeNo !== current.employee_no) {
|
|
changes.employeeNo = { before: current.employee_no, after: body.employeeNo };
|
|
}
|
|
if (typeof body.name === "string" && body.name !== current.name) {
|
|
changes.name = { before: current.name, after: body.name };
|
|
}
|
|
if (typeof body.phone === "string" && body.phone !== current.phone) {
|
|
changes.phone = { before: current.phone, after: body.phone };
|
|
}
|
|
if (typeof body.email === "string" && body.email !== current.email) {
|
|
changes.email = { before: current.email, after: body.email };
|
|
}
|
|
if (typeof body.departmentId === "string" && body.departmentId !== current.department_id) {
|
|
changes.departmentId = { before: current.department_id, after: body.departmentId };
|
|
}
|
|
if (typeof body.title === "string" && body.title !== current.title) {
|
|
changes.title = { before: current.title, after: body.title };
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(body, "directManagerId")) {
|
|
changes.directManagerId = { before: current.direct_manager_id, after: body.directManagerId ?? null };
|
|
}
|
|
if (typeof body.hireDate === "string") {
|
|
const currentHireDate = current.hire_date?.toISOString?.()?.slice(0, 10) ?? null;
|
|
if (body.hireDate !== currentHireDate) {
|
|
changes.hireDate = { before: currentHireDate, after: body.hireDate };
|
|
}
|
|
}
|
|
|
|
const employee = await tx.employee.update({
|
|
where: { id: userId },
|
|
data: {
|
|
...(typeof body.employeeNo === "string" ? { employee_no: body.employeeNo } : {}),
|
|
...(typeof body.name === "string" ? { name: body.name } : {}),
|
|
...(typeof body.phone === "string" ? { phone: body.phone } : {}),
|
|
...(typeof body.email === "string" ? { email: body.email } : {}),
|
|
...(typeof body.departmentId === "string" ? { department_id: body.departmentId } : {}),
|
|
...(typeof body.title === "string" ? { title: body.title } : {}),
|
|
...(Object.prototype.hasOwnProperty.call(body, "directManagerId")
|
|
? { direct_manager_id: body.directManagerId ?? null }
|
|
: {}),
|
|
...(typeof body.hireDate === "string" ? { hire_date: hireDate } : {}),
|
|
updated_at: now,
|
|
updated_by: request.employee.id,
|
|
},
|
|
select: {
|
|
id: true,
|
|
employee_no: true,
|
|
name: true,
|
|
phone: true,
|
|
email: true,
|
|
status: true,
|
|
department_id: true,
|
|
title: true,
|
|
direct_manager_id: true,
|
|
hire_date: true,
|
|
bank_account_encrypted: true,
|
|
bank_name: true,
|
|
id_number_encrypted: true,
|
|
archived_at: true,
|
|
created_at: true,
|
|
updated_at: true,
|
|
roles: { select: { role_code: true } },
|
|
},
|
|
});
|
|
|
|
if (Object.keys(changes).length) {
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "SYSTEM_USER_UPDATED",
|
|
targetType: "employee",
|
|
targetId: userId,
|
|
extra: { changes },
|
|
}),
|
|
});
|
|
}
|
|
|
|
return employee;
|
|
});
|
|
} catch (error) {
|
|
if (error?.code === "P2002") {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: normalizeUniqueErrorDetails(error),
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
return sendData(
|
|
reply,
|
|
request,
|
|
mapEmployeeDetail(updated, { config: fastify.config, logger: request.log ?? fastify.log }),
|
|
);
|
|
},
|
|
);
|
|
|
|
fastify.get(
|
|
`${routeBasePath}/:id/sensitive`,
|
|
{
|
|
preHandler: fastify.requirePermission("system.users_sensitive"),
|
|
},
|
|
async (request, reply) => {
|
|
const userId = parseUserId(request.params);
|
|
|
|
const employee = await fastify.prisma.employee.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
id: true,
|
|
bank_name: true,
|
|
bank_account_encrypted: true,
|
|
id_number_encrypted: true,
|
|
},
|
|
});
|
|
|
|
if (!employee) {
|
|
throw new AppError({
|
|
code: "SYS_404",
|
|
message: "Not Found",
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
let bankAccount = null;
|
|
let idNumber = null;
|
|
try {
|
|
bankAccount = decryptStringOrThrow(fastify.config, employee.bank_account_encrypted);
|
|
idNumber = decryptStringOrThrow(fastify.config, employee.id_number_encrypted);
|
|
} catch (error) {
|
|
fastify.log.error({ err: error, requestId: request.id }, "FIELD_ENCRYPTION_KEY misconfigured");
|
|
throw new AppError({
|
|
code: "SYS_001",
|
|
message: "服务器内部错误",
|
|
statusCode: 500,
|
|
details: {},
|
|
});
|
|
}
|
|
|
|
await fastify.prisma.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "SYSTEM_USER_SENSITIVE_VIEWED",
|
|
targetType: "employee",
|
|
targetId: userId,
|
|
extra: {},
|
|
}),
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
bankName: employee.bank_name ? String(employee.bank_name) : null,
|
|
bankAccount,
|
|
idNumber,
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.patch(
|
|
`${routeBasePath}/:id/sensitive`,
|
|
{
|
|
preHandler: fastify.requirePermission("system.users_sensitive"),
|
|
},
|
|
async (request, reply) => {
|
|
const userId = parseUserId(request.params);
|
|
const body = parseSensitiveBody(request.body);
|
|
const now = new Date();
|
|
|
|
let updated = null;
|
|
try {
|
|
updated = await fastify.prisma.$transaction(async (tx) => {
|
|
const current = await tx.employee.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
id: true,
|
|
bank_name: true,
|
|
bank_account_encrypted: true,
|
|
id_number_encrypted: true,
|
|
employee_no: true,
|
|
name: true,
|
|
phone: true,
|
|
email: true,
|
|
status: true,
|
|
department_id: true,
|
|
title: true,
|
|
direct_manager_id: true,
|
|
hire_date: true,
|
|
archived_at: true,
|
|
created_at: true,
|
|
updated_at: true,
|
|
roles: { select: { role_code: true } },
|
|
},
|
|
});
|
|
if (!current) {
|
|
throw new AppError({
|
|
code: "SYS_404",
|
|
message: "Not Found",
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
const changes = {};
|
|
const data = {
|
|
updated_at: now,
|
|
updated_by: request.employee.id,
|
|
};
|
|
|
|
if (Object.prototype.hasOwnProperty.call(body, "bankName")) {
|
|
const nextBankName =
|
|
typeof body.bankName === "string" ? body.bankName.trim() : body.bankName;
|
|
data.bank_name = nextBankName ? String(nextBankName) : "";
|
|
changes.bankName = {
|
|
before: current.bank_name ? String(current.bank_name) : null,
|
|
after: data.bank_name || null,
|
|
};
|
|
}
|
|
|
|
if (Object.prototype.hasOwnProperty.call(body, "bankAccount")) {
|
|
const nextBankAccount =
|
|
typeof body.bankAccount === "string" ? body.bankAccount.trim() : body.bankAccount;
|
|
data.bank_account_encrypted = nextBankAccount
|
|
? encryptStringOrThrow(fastify.config, nextBankAccount)
|
|
: "";
|
|
changes.bankAccountMasked = nextBankAccount ? maskBankAccount(nextBankAccount) : null;
|
|
}
|
|
|
|
if (Object.prototype.hasOwnProperty.call(body, "idNumber")) {
|
|
const nextIdNumber =
|
|
typeof body.idNumber === "string" ? body.idNumber.trim() : body.idNumber;
|
|
data.id_number_encrypted = nextIdNumber
|
|
? encryptStringOrThrow(fastify.config, nextIdNumber)
|
|
: null;
|
|
changes.idNumberMasked = nextIdNumber ? maskIdNumber(nextIdNumber) : null;
|
|
}
|
|
|
|
const employee = await tx.employee.update({
|
|
where: { id: userId },
|
|
data,
|
|
select: {
|
|
id: true,
|
|
employee_no: true,
|
|
name: true,
|
|
phone: true,
|
|
email: true,
|
|
status: true,
|
|
department_id: true,
|
|
title: true,
|
|
direct_manager_id: true,
|
|
hire_date: true,
|
|
bank_account_encrypted: true,
|
|
bank_name: true,
|
|
id_number_encrypted: true,
|
|
archived_at: true,
|
|
created_at: true,
|
|
updated_at: true,
|
|
roles: { select: { role_code: true } },
|
|
},
|
|
});
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "SYSTEM_USER_SENSITIVE_UPDATED",
|
|
targetType: "employee",
|
|
targetId: userId,
|
|
extra: { changes },
|
|
}),
|
|
});
|
|
|
|
return employee;
|
|
});
|
|
} catch (error) {
|
|
if (String(error?.message ?? "").includes("FIELD_ENCRYPTION_KEY")) {
|
|
fastify.log.error({ err: error, requestId: request.id }, "FIELD_ENCRYPTION_KEY misconfigured");
|
|
throw new AppError({
|
|
code: "SYS_001",
|
|
message: "服务器内部错误",
|
|
statusCode: 500,
|
|
details: {},
|
|
});
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
return sendData(
|
|
reply,
|
|
request,
|
|
mapEmployeeDetail(updated, { config: fastify.config, logger: request.log ?? fastify.log }),
|
|
);
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
`${routeBasePath}/:id/archive`,
|
|
{
|
|
preHandler: fastify.requirePermission("system.users"),
|
|
},
|
|
async (request, reply) => {
|
|
const userId = parseUserId(request.params);
|
|
if (userId === request.employee.id) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { user: "self_disable_not_allowed" },
|
|
});
|
|
}
|
|
|
|
const now = new Date();
|
|
const employee = await fastify.prisma.$transaction(async (tx) => {
|
|
const updated = await tx.employee.update({
|
|
where: { id: userId },
|
|
data: {
|
|
status: "archived",
|
|
archived_at: now,
|
|
token_version: { increment: 1 },
|
|
updated_by: request.employee.id,
|
|
},
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
token_version: true,
|
|
},
|
|
});
|
|
|
|
await tx.authSession.updateMany({
|
|
where: { employee_id: userId, revoked_at: null },
|
|
data: { revoked_at: now },
|
|
});
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "SYSTEM_USER_DISABLED",
|
|
targetType: "employee",
|
|
targetId: userId,
|
|
extra: {},
|
|
}),
|
|
});
|
|
|
|
return updated;
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
id: employee.id,
|
|
status: employee.status,
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
`${routeBasePath}/:id/disable`,
|
|
{
|
|
preHandler: fastify.requirePermission("system.users"),
|
|
},
|
|
async (request, reply) => {
|
|
const userId = parseUserId(request.params);
|
|
if (userId === request.employee.id) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { user: "self_disable_not_allowed" },
|
|
});
|
|
}
|
|
|
|
const now = new Date();
|
|
const employee = await fastify.prisma.$transaction(async (tx) => {
|
|
return setEmployeeAccountStatus(tx, request, {
|
|
employeeId: userId,
|
|
actorId: request.employee.id,
|
|
status: "archived",
|
|
now,
|
|
});
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
id: employee.id,
|
|
status: employee.status,
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
`${routeBasePath}/:id/enable`,
|
|
{
|
|
preHandler: fastify.requirePermission("system.users"),
|
|
},
|
|
async (request, reply) => {
|
|
const userId = parseUserId(request.params);
|
|
const now = new Date();
|
|
|
|
const employee = await fastify.prisma.$transaction(async (tx) => {
|
|
return setEmployeeAccountStatus(tx, request, {
|
|
employeeId: userId,
|
|
actorId: request.employee.id,
|
|
status: "active",
|
|
now,
|
|
});
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
id: employee.id,
|
|
status: employee.status,
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
`${routeBasePath}/:id/reset-password`,
|
|
{
|
|
preHandler: fastify.requirePermission("system.users"),
|
|
},
|
|
async (request, reply) => {
|
|
const userId = parseUserId(request.params);
|
|
const parsed = resetPasswordBodySchema.safeParse(request.body ?? {});
|
|
if (!parsed.success) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: parsed.error.flatten(),
|
|
});
|
|
}
|
|
|
|
const newPassword =
|
|
typeof parsed.data.newPassword === "string" && parsed.data.newPassword.trim()
|
|
? parsed.data.newPassword.trim()
|
|
: null;
|
|
|
|
const oneTimePassword = newPassword ?? `ResetA1${crypto.randomBytes(6).toString("hex")}`;
|
|
const errors = validatePassword(oneTimePassword);
|
|
if (errors.length) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: { newPassword: errors },
|
|
});
|
|
}
|
|
|
|
const passwordHash = await hashPassword(oneTimePassword);
|
|
const now = new Date();
|
|
|
|
await fastify.prisma.$transaction(async (tx) => {
|
|
await tx.employee.update({
|
|
where: { id: userId },
|
|
data: {
|
|
password_hash: passwordHash,
|
|
must_change_password: true,
|
|
login_failed_count: 0,
|
|
login_failed_first_at: null,
|
|
login_locked_until: null,
|
|
token_version: { increment: 1 },
|
|
updated_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
await tx.authSession.updateMany({
|
|
where: { employee_id: userId, revoked_at: null },
|
|
data: { revoked_at: now },
|
|
});
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "SYSTEM_USER_PASSWORD_RESET",
|
|
targetType: "employee",
|
|
targetId: userId,
|
|
extra: {},
|
|
}),
|
|
});
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
id: userId,
|
|
oneTimePassword: oneTimePassword,
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
`${routeBasePath}/:id/unlock`,
|
|
{
|
|
preHandler: fastify.requirePermission("system.users"),
|
|
},
|
|
async (request, reply) => {
|
|
const userId = parseUserId(request.params);
|
|
const now = new Date();
|
|
|
|
const result = await fastify.prisma.$transaction(async (tx) => {
|
|
const current = await tx.employee.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
id: true,
|
|
login_locked_until: true,
|
|
login_failed_count: true,
|
|
},
|
|
});
|
|
if (!current) {
|
|
throw new AppError({
|
|
code: "SYS_404",
|
|
message: "Not Found",
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
const wasLocked = Boolean(current.login_locked_until && current.login_locked_until > now);
|
|
|
|
await tx.employee.update({
|
|
where: { id: userId },
|
|
data: {
|
|
login_failed_count: 0,
|
|
login_failed_first_at: null,
|
|
login_locked_until: null,
|
|
updated_by: request.employee.id,
|
|
updated_at: now,
|
|
},
|
|
});
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "SYSTEM_USER_UNLOCKED",
|
|
targetType: "employee",
|
|
targetId: userId,
|
|
extra: {
|
|
previousLockedUntil: current.login_locked_until
|
|
? current.login_locked_until.toISOString()
|
|
: null,
|
|
previousFailedCount: current.login_failed_count ?? 0,
|
|
},
|
|
}),
|
|
});
|
|
|
|
return { wasLocked };
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
id: userId,
|
|
outcome: result.wasLocked ? "unlocked" : "already_unlocked",
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
`${routeBasePath}/:id/roles`,
|
|
{
|
|
preHandler: fastify.requirePermission("system.users"),
|
|
},
|
|
async (request, reply) => {
|
|
const userId = parseUserId(request.params);
|
|
if (userId === request.employee.id) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { user: "self_role_change_not_allowed" },
|
|
});
|
|
}
|
|
|
|
const parsed = setRolesBodySchema.safeParse(request.body ?? {});
|
|
if (!parsed.success) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: parsed.error.flatten(),
|
|
});
|
|
}
|
|
|
|
const roles = parsed.data.roles;
|
|
const now = new Date();
|
|
|
|
await fastify.prisma.$transaction(async (tx) => {
|
|
await tx.employeeRole.deleteMany({
|
|
where: { employee_id: userId },
|
|
});
|
|
|
|
await tx.employeeRole.createMany({
|
|
data: roles.map((roleCode) => ({
|
|
employee_id: userId,
|
|
role_code: roleCode,
|
|
created_at: now,
|
|
created_by: request.employee.id,
|
|
})),
|
|
skipDuplicates: true,
|
|
});
|
|
|
|
await tx.employee.update({
|
|
where: { id: userId },
|
|
data: {
|
|
token_version: { increment: 1 },
|
|
updated_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
await tx.authSession.updateMany({
|
|
where: { employee_id: userId, revoked_at: null },
|
|
data: { revoked_at: now },
|
|
});
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "SYSTEM_USER_ROLES_SET",
|
|
targetType: "employee",
|
|
targetId: userId,
|
|
extra: { roles },
|
|
}),
|
|
});
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
id: userId,
|
|
roleCodes: roles,
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
module.exports = usersRoutes;
|