Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/hrSickLeaveService.server.ts
T

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[];
}