289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
// P17 HR-Hub workflows (AK-P17b) — sick-leave domain service.
|
|
//
|
|
// An employee files a sick-leave report themselves, or HR/office files it on
|
|
// their behalf (`reportedByUserId`). The AU certificate ("AU-Schein") is
|
|
// sensitive (AD-8): we envelope-encrypt the certificate File REFERENCE through
|
|
// the hrFieldCrypto seam and never return it without an explicit,
|
|
// permission-checked + audited reveal (mirrors hrService.revealEmergencyContact).
|
|
// NO React/UI imports. Every tenant access runs inside withTenantRLS().
|
|
|
|
import { CatalogError, foundationErrors, notify } from "@hivegrid/foundation";
|
|
import { logActivity } from "@hivegrid/activity";
|
|
import { createAuditEvent } from "@hivegrid/db/audit-events.server";
|
|
import { withTenantRLS } from "~/utils/db/.server/tenantRls.server";
|
|
import { getHrFieldCrypto, type HrFieldCrypto } from "./hrFieldCrypto.server";
|
|
import { HrServiceError } from "./hrService.server";
|
|
import type { HrM05Values } from "./hrM05Forms";
|
|
import { createSystemHrSubmissionWithTx } from "./hrM05Submission.server";
|
|
|
|
const HR_AUDIT_MODULE = "hr";
|
|
|
|
export type HrSickLeaveStatus = "reported" | "closed" | "cancelled";
|
|
|
|
export type SickLeaveRow = {
|
|
id: string;
|
|
tenantId: string;
|
|
employeeUserId: string;
|
|
reportedByUserId: string;
|
|
startDate: Date;
|
|
expectedReturnDate: Date | null;
|
|
returnDate: Date | null;
|
|
certificateRefCiphertext: string | null;
|
|
status: HrSickLeaveStatus;
|
|
note: string | null;
|
|
calendarEventId: string | null;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
};
|
|
|
|
export type ReportSickLeaveInput = {
|
|
tenantId: string;
|
|
employeeUserId: string;
|
|
/** The acting user — equals employeeUserId for self-reports, an HR/office user
|
|
* for on-behalf reports. */
|
|
reportedByUserId: string;
|
|
startDate: Date;
|
|
expectedReturnDate?: Date | null;
|
|
note?: string | null;
|
|
isDemoData?: boolean;
|
|
formValues?: HrM05Values;
|
|
};
|
|
|
|
/** File a sick-leave report (self or on behalf of an employee). */
|
|
export async function reportSickLeave(input: ReportSickLeaveInput): Promise<SickLeaveRow> {
|
|
if (input.expectedReturnDate && input.expectedReturnDate < input.startDate) {
|
|
throw new HrServiceError("hr.error.sickRangeInvalid");
|
|
}
|
|
const created = (await withTenantRLS(input.tenantId, async (tx) => {
|
|
const report = await tx.employeeSickLeaveReport.create({
|
|
data: {
|
|
tenantId: input.tenantId,
|
|
employeeUserId: input.employeeUserId,
|
|
reportedByUserId: input.reportedByUserId,
|
|
startDate: input.startDate,
|
|
expectedReturnDate: input.expectedReturnDate ?? null,
|
|
note: input.note?.trim().slice(0, 2000) || null,
|
|
status: "reported",
|
|
isDemoData: input.isDemoData ?? false,
|
|
},
|
|
});
|
|
if (input.formValues) {
|
|
await createSystemHrSubmissionWithTx(tx, {
|
|
tenantId: input.tenantId,
|
|
actorUserId: input.reportedByUserId,
|
|
process: "sick",
|
|
linkedEntityType: "hr_sick_leave",
|
|
linkedEntityId: report.id,
|
|
values: input.formValues,
|
|
});
|
|
}
|
|
await logActivity(
|
|
{
|
|
tenantId: input.tenantId,
|
|
actorUserId: input.reportedByUserId,
|
|
actorKind: "user",
|
|
activityType: "form_submitted",
|
|
subjectType: "tenant",
|
|
subjectId: input.tenantId,
|
|
summaryText: "activity.runtime.hr.sickLeaveRecorded",
|
|
metadata: { reportId: report.id, process: "sick" },
|
|
},
|
|
{ client: tx },
|
|
);
|
|
if (input.reportedByUserId !== input.employeeUserId) {
|
|
await notify(
|
|
{
|
|
tenantId: input.tenantId,
|
|
userId: input.employeeUserId,
|
|
channel: "IN_APP",
|
|
category: "hr",
|
|
messageKey: "hr.notify.sick.reportedFor",
|
|
sourceKey: `hr-sick-reported:${report.id}`,
|
|
},
|
|
tx as unknown as Parameters<typeof notify>[1],
|
|
).catch(() => undefined);
|
|
}
|
|
return report;
|
|
})) as SickLeaveRow;
|
|
await createAuditEvent({
|
|
tenantId: input.tenantId,
|
|
actorId: input.reportedByUserId,
|
|
actorType: "user",
|
|
action: input.reportedByUserId === input.employeeUserId ? "HR_SICK_REPORT_SELF" : "HR_SICK_REPORT_OFFICE",
|
|
targetType: "EmployeeSickLeaveReport",
|
|
targetId: created.id,
|
|
sourceModule: HR_AUDIT_MODULE,
|
|
outcome: "success",
|
|
metadata: { employeeUserId: input.employeeUserId },
|
|
});
|
|
return created;
|
|
}
|
|
|
|
async function getReportOrThrow(tenantId: string, reportId: string): Promise<SickLeaveRow> {
|
|
const row = await withTenantRLS(tenantId, (tx) => tx.employeeSickLeaveReport.findFirst({ where: { id: reportId, tenantId } }));
|
|
if (!row) {
|
|
throw new CatalogError(foundationErrors.notFound, { reportId });
|
|
}
|
|
return row as SickLeaveRow;
|
|
}
|
|
|
|
function requireReportActor(
|
|
row: SickLeaveRow,
|
|
input: { actorUserId: string; canManageAny: boolean; reportId: string },
|
|
): void {
|
|
if (row.employeeUserId !== input.actorUserId && !input.canManageAny) {
|
|
throw new CatalogError(foundationErrors.notFound, { reportId: input.reportId });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Attach (or replace) the AU certificate, storing the File reference
|
|
* ENVELOPE-ENCRYPTED. The plaintext fileId never lands in the column.
|
|
*/
|
|
export async function attachCertificate(
|
|
input: { tenantId: string; reportId: string; actorUserId: string; certificateFileId: string; canManageAny: boolean },
|
|
crypto: HrFieldCrypto = getHrFieldCrypto(),
|
|
): Promise<void> {
|
|
const row = await getReportOrThrow(input.tenantId, input.reportId);
|
|
requireReportActor(row, input);
|
|
const ciphertext = await crypto.encrypt(input.tenantId, input.certificateFileId);
|
|
await withTenantRLS(input.tenantId, async (tx) => {
|
|
const file = await tx.file.findFirst({
|
|
where: { id: input.certificateFileId, tenantId: input.tenantId, archivedAt: null },
|
|
select: { id: true },
|
|
});
|
|
if (!file) {
|
|
throw new CatalogError(foundationErrors.notFound, { fileId: input.certificateFileId });
|
|
}
|
|
await tx.employeeSickLeaveReport.update({ where: { id: row.id }, data: { certificateRefCiphertext: ciphertext } });
|
|
await tx.fileEntityLink.upsert({
|
|
where: {
|
|
fileId_entityType_entityId: {
|
|
fileId: input.certificateFileId,
|
|
entityType: "user",
|
|
entityId: row.employeeUserId,
|
|
},
|
|
},
|
|
update: { linkRole: "hr_sick_certificate" },
|
|
create: {
|
|
tenantId: input.tenantId,
|
|
createdByUserId: input.actorUserId,
|
|
fileId: input.certificateFileId,
|
|
entityType: "user",
|
|
entityId: row.employeeUserId,
|
|
linkRole: "hr_sick_certificate",
|
|
},
|
|
});
|
|
});
|
|
await createAuditEvent({
|
|
tenantId: input.tenantId,
|
|
actorId: input.actorUserId,
|
|
actorType: "user",
|
|
action: "HR_SICK_CERTIFICATE_ATTACH",
|
|
targetType: "EmployeeSickLeaveReport",
|
|
targetId: row.id,
|
|
sourceModule: HR_AUDIT_MODULE,
|
|
dataSensitivity: "pii",
|
|
regulatoryScope: ["gdpr"],
|
|
outcome: "success",
|
|
metadata: { employeeUserId: row.employeeUserId },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Reveal (decrypt) the AU certificate File id ON DEMAND. Writes a pii/gdpr
|
|
* audit event BEFORE returning. The caller must already have established that
|
|
* `revealerUserId` is the employee themselves or holds hr.read_all (route).
|
|
*/
|
|
export async function revealCertificateFileId(
|
|
input: { tenantId: string; reportId: string; revealerUserId: string; canManageAny: boolean },
|
|
crypto: HrFieldCrypto = getHrFieldCrypto(),
|
|
): Promise<string | null> {
|
|
const row = await getReportOrThrow(input.tenantId, input.reportId);
|
|
requireReportActor(row, {
|
|
actorUserId: input.revealerUserId,
|
|
canManageAny: input.canManageAny,
|
|
reportId: input.reportId,
|
|
});
|
|
if (!row.certificateRefCiphertext) {
|
|
return null;
|
|
}
|
|
await createAuditEvent({
|
|
tenantId: input.tenantId,
|
|
actorId: input.revealerUserId,
|
|
actorType: "user",
|
|
action: "HR_SICK_CERTIFICATE_REVEAL",
|
|
targetType: "EmployeeSickLeaveReport",
|
|
targetId: row.id,
|
|
sourceModule: HR_AUDIT_MODULE,
|
|
dataSensitivity: "pii",
|
|
regulatoryScope: ["gdpr"],
|
|
outcome: "success",
|
|
metadata: { employeeUserId: row.employeeUserId },
|
|
});
|
|
return crypto.decrypt(input.tenantId, row.certificateRefCiphertext);
|
|
}
|
|
|
|
/** Mark a sick-leave report closed with an actual return date. */
|
|
export async function closeSickLeave(input: { tenantId: string; reportId: string; actorUserId: string; returnDate: Date; canManageAny: boolean }): Promise<void> {
|
|
const row = await getReportOrThrow(input.tenantId, input.reportId);
|
|
requireReportActor(row, input);
|
|
if (row.status === "cancelled") {
|
|
throw new HrServiceError("hr.error.sickNotUpdatable");
|
|
}
|
|
if (input.returnDate < row.startDate) {
|
|
throw new HrServiceError("hr.error.sickRangeInvalid");
|
|
}
|
|
await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.employeeSickLeaveReport.update({ where: { id: row.id }, data: { status: "closed", returnDate: input.returnDate } }),
|
|
);
|
|
await createAuditEvent({
|
|
tenantId: input.tenantId,
|
|
actorId: input.actorUserId,
|
|
actorType: "user",
|
|
action: "HR_SICK_CLOSE",
|
|
targetType: "EmployeeSickLeaveReport",
|
|
targetId: row.id,
|
|
sourceModule: HR_AUDIT_MODULE,
|
|
outcome: "success",
|
|
});
|
|
}
|
|
|
|
/** Cancel an erroneous sick-leave report (removes it from the absence feed). */
|
|
export async function cancelSickLeave(input: { tenantId: string; reportId: string; actorUserId: string; canManageAny: boolean }): Promise<void> {
|
|
const row = await getReportOrThrow(input.tenantId, input.reportId);
|
|
requireReportActor(row, input);
|
|
await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.employeeSickLeaveReport.update({ where: { id: row.id }, data: { status: "cancelled" } }),
|
|
);
|
|
await createAuditEvent({
|
|
tenantId: input.tenantId,
|
|
actorId: input.actorUserId,
|
|
actorType: "user",
|
|
action: "HR_SICK_CANCEL",
|
|
targetType: "EmployeeSickLeaveReport",
|
|
targetId: row.id,
|
|
sourceModule: HR_AUDIT_MODULE,
|
|
outcome: "success",
|
|
});
|
|
}
|
|
|
|
/** List the caller's own sick-leave reports, newest first. */
|
|
export async function listOwnSickLeave(tenantId: string, employeeUserId: string): Promise<SickLeaveRow[]> {
|
|
const rows = await withTenantRLS(tenantId, (tx) =>
|
|
tx.employeeSickLeaveReport.findMany({ where: { tenantId, employeeUserId }, orderBy: { startDate: "desc" } }),
|
|
);
|
|
return rows as SickLeaveRow[];
|
|
}
|
|
|
|
/** HR/office view of all sick-leave reports (optionally only currently open). */
|
|
export async function listSickLeaveForOffice(tenantId: string, opts?: { onlyOpen?: boolean }): Promise<SickLeaveRow[]> {
|
|
const rows = await withTenantRLS(tenantId, (tx) =>
|
|
tx.employeeSickLeaveReport.findMany({
|
|
where: { tenantId, ...(opts?.onlyOpen ? { status: "reported" } : {}) },
|
|
orderBy: { startDate: "desc" },
|
|
}),
|
|
);
|
|
return rows as SickLeaveRow[];
|
|
}
|