Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/hrLeaveService.server.ts
T
2026-07-08 16:15:37 +02:00

321 lines
15 KiB
TypeScript

// P17 HR-Hub workflows (AK-P17b) — leave/vacation approval domain service.
//
// Multi-step approval state machine for vacation/leave requests:
// submitted ⇄ clarification → approved | rejected, or → withdrawn (requester).
// Only `approved` requests count as an absence and are exposed to the R-P9b
// plantafel conflict feed (listApprovedAbsencesForRange). NO React/UI imports.
// Every tenant access runs inside withTenantRLS(); every state transition writes
// an audit event (M001) and best-effort notifications (M011, fail-open).
//
// Tenant isolation is twofold (mirrors hrService.server.ts): app-level
// `where: { tenantId }` + the RLS policies from prisma/rls/hr-isolation.sql.
// CalendarEvent sync (P16/M25) is deferred — `calendarEventId` stays null and
// R-P9b reads the conflict feed directly from approved requests meanwhile.
import { CatalogError, foundationErrors } from "@hivegrid/foundation";
import { createAuditEvent } from "@hivegrid/db/audit-events.server";
import { createNotificationDelivery } from "@hivegrid/db/notifications.server";
import { withTenantRLS } from "~/utils/db/.server/tenantRls.server";
import { HrServiceError } from "./hrService.server";
const HR_AUDIT_MODULE = "hr";
const MAX_NOTE_LENGTH = 2000;
export type HrVacationRequestKind = "vacation" | "special_leave" | "unpaid_leave" | "overtime_compensation";
export type HrLeaveRequestStatus = "submitted" | "clarification" | "approved" | "rejected" | "withdrawn";
const VACATION_REQUEST_KINDS: readonly HrVacationRequestKind[] = ["vacation", "special_leave", "unpaid_leave", "overtime_compensation"];
export type VacationRequestRow = {
id: string;
tenantId: string;
employeeUserId: string;
requestKind: HrVacationRequestKind;
startDate: Date;
endDate: Date;
note: string | null;
status: HrLeaveRequestStatus;
assignedApproverUserId: string | null;
clarificationNote: string | null;
decidedByUserId: string | null;
decidedAt: Date | null;
decisionNote: string | null;
withdrawnAt: Date | null;
calendarEventId: string | null;
createdAt: Date;
updatedAt: Date;
};
/** A single approved absence (vacation OR sick) for the R-P9b conflict feed. */
export type ApprovedAbsence = {
source: "vacation" | "sick";
id: string;
employeeUserId: string;
startDate: Date;
/** Inclusive end of the blocked window (sick uses returnDate ?? expectedReturnDate ?? startDate). */
endDate: Date;
kind: string;
};
/** Fire-and-forget IN_APP notification; a delivery failure must never fail the
* triggering HR action (fail-open, mirrors appsEngineService). */
async function notifyQuiet(input: { tenantId: string; userId: string | null; messageKey: string; sourceKey: string; payloadMetadata?: Record<string, string> }): Promise<void> {
if (!input.userId) {
return;
}
await createNotificationDelivery({
tenantId: input.tenantId,
userId: input.userId,
channel: "IN_APP",
category: "hr",
messageKey: input.messageKey,
sourceKey: input.sourceKey,
payloadMetadata: input.payloadMetadata ?? null,
}).catch(() => undefined);
}
async function auditTransition(input: { tenantId: string; actorId: string; action: string; targetId: string; metadata?: Record<string, string> }): Promise<void> {
await createAuditEvent({
tenantId: input.tenantId,
actorId: input.actorId,
actorType: "user",
action: input.action,
targetType: "EmployeeVacationRequest",
targetId: input.targetId,
sourceModule: HR_AUDIT_MODULE,
outcome: "success",
metadata: input.metadata ?? null,
});
}
function normaliseNote(note: string | null | undefined): string | null {
const trimmed = note?.trim();
return trimmed ? trimmed.slice(0, MAX_NOTE_LENGTH) : null;
}
// --- Reads ----------------------------------------------------------------
/** List the caller's OWN vacation requests, newest first. */
export async function listOwnVacationRequests(tenantId: string, employeeUserId: string): Promise<VacationRequestRow[]> {
const rows = await withTenantRLS(tenantId, (tx) =>
tx.employeeVacationRequest.findMany({ where: { tenantId, employeeUserId }, orderBy: { createdAt: "desc" } }),
);
return rows as VacationRequestRow[];
}
/** List requests awaiting THIS approver's decision (submitted/clarification). */
export async function listForApprover(tenantId: string, approverUserId: string): Promise<VacationRequestRow[]> {
const rows = await withTenantRLS(tenantId, (tx) =>
tx.employeeVacationRequest.findMany({
where: { tenantId, assignedApproverUserId: approverUserId, status: { in: ["submitted", "clarification"] } },
orderBy: { startDate: "asc" },
}),
);
return rows as VacationRequestRow[];
}
/**
* R-P9b CONTRACT — approved absences overlapping [from, to]. Returns vacation
* AND sick-leave windows. Only `approved` vacation and non-cancelled sick
* reports count; submitted/rejected/withdrawn never block. This is the stable
* shape the plantafel conflict detection consumes.
*/
export async function listApprovedAbsencesForRange(tenantId: string, from: Date, to: Date): Promise<ApprovedAbsence[]> {
return withTenantRLS(tenantId, async (tx) => {
const vacations = await tx.employeeVacationRequest.findMany({
where: { tenantId, status: "approved", startDate: { lte: to }, endDate: { gte: from } },
select: { id: true, employeeUserId: true, startDate: true, endDate: true, requestKind: true },
});
const sick = await tx.employeeSickLeaveReport.findMany({
where: { tenantId, status: { not: "cancelled" }, startDate: { lte: to } },
select: { id: true, employeeUserId: true, startDate: true, returnDate: true, expectedReturnDate: true },
});
const out: ApprovedAbsence[] = vacations.map((v) => ({
source: "vacation",
id: v.id,
employeeUserId: v.employeeUserId,
startDate: v.startDate,
endDate: v.endDate,
kind: v.requestKind,
}));
for (const s of sick) {
const end = s.returnDate ?? s.expectedReturnDate ?? s.startDate;
if (end >= from) {
out.push({ source: "sick", id: s.id, employeeUserId: s.employeeUserId, startDate: s.startDate, endDate: end, kind: "sick" });
}
}
return out;
});
}
/**
* For an approver row, count OTHER approved vacation requests whose window
* overlaps the given request (the "vorhandener Urlaub" conflict hint). Plantafel
* (schedule) conflicts are added by R-P9b once it lands.
*/
export async function countOverlappingApproved(tenantId: string, request: Pick<VacationRequestRow, "id" | "startDate" | "endDate">): Promise<number> {
return withTenantRLS(tenantId, (tx) =>
tx.employeeVacationRequest.count({
where: {
tenantId,
status: "approved",
id: { not: request.id },
startDate: { lte: request.endDate },
endDate: { gte: request.startDate },
},
}),
);
}
async function getRequestOrThrow(tenantId: string, requestId: string): Promise<VacationRequestRow> {
const row = await withTenantRLS(tenantId, (tx) => tx.employeeVacationRequest.findFirst({ where: { id: requestId, tenantId } }));
if (!row) {
throw new CatalogError(foundationErrors.notFound, { requestId });
}
return row as VacationRequestRow;
}
/** Load a request the caller is allowed to see (own, assigned approver, or has
* the approve capability). 404 otherwise (never 403). */
export async function getRequestForUser(input: { tenantId: string; requestId: string; userId: string; canApproveAny: boolean }): Promise<VacationRequestRow> {
const row = await getRequestOrThrow(input.tenantId, input.requestId);
const isOwner = row.employeeUserId === input.userId;
const isApprover = row.assignedApproverUserId === input.userId || input.canApproveAny;
if (!isOwner && !isApprover) {
throw new CatalogError(foundationErrors.notFound, { requestId: input.requestId });
}
return row;
}
// --- Writes / state machine ----------------------------------------------
export type SubmitVacationInput = {
tenantId: string;
employeeUserId: string;
requestKind: HrVacationRequestKind;
startDate: Date;
endDate: Date;
note?: string | null;
assignedApproverUserId?: string | null;
isDemoData?: boolean;
};
/** Submit a new vacation request (status = submitted). Notifies the approver. */
export async function submitVacationRequest(input: SubmitVacationInput): Promise<VacationRequestRow> {
if (!VACATION_REQUEST_KINDS.includes(input.requestKind)) {
throw new HrServiceError("hr.error.leaveKindInvalid");
}
if (input.endDate < input.startDate) {
throw new HrServiceError("hr.error.leaveRangeInvalid");
}
const created = (await withTenantRLS(input.tenantId, (tx) =>
tx.employeeVacationRequest.create({
data: {
tenantId: input.tenantId,
employeeUserId: input.employeeUserId,
requestKind: input.requestKind,
startDate: input.startDate,
endDate: input.endDate,
note: normaliseNote(input.note),
status: "submitted",
assignedApproverUserId: input.assignedApproverUserId ?? null,
isDemoData: input.isDemoData ?? false,
},
}),
)) as VacationRequestRow;
await auditTransition({ tenantId: input.tenantId, actorId: input.employeeUserId, action: "HR_LEAVE_SUBMIT", targetId: created.id, metadata: { requestKind: input.requestKind } });
await notifyQuiet({
tenantId: input.tenantId,
userId: created.assignedApproverUserId,
messageKey: "hr.notify.leave.submitted",
sourceKey: `hr-leave-submit:${created.id}`,
payloadMetadata: { requestId: created.id },
});
return created;
}
/** Common guard: a request must be in a decidable state and the caller must be
* its assigned approver or hold the approve capability. */
async function loadDecidable(input: { tenantId: string; requestId: string; deciderUserId: string; canApproveAny: boolean }): Promise<VacationRequestRow> {
const row = await getRequestOrThrow(input.tenantId, input.requestId);
const isApprover = row.assignedApproverUserId === input.deciderUserId || input.canApproveAny;
if (!isApprover) {
throw new CatalogError(foundationErrors.notFound, { requestId: input.requestId });
}
if (row.status !== "submitted" && row.status !== "clarification") {
throw new HrServiceError("hr.error.leaveNotDecidable");
}
return row;
}
export async function approveRequest(input: { tenantId: string; requestId: string; deciderUserId: string; canApproveAny: boolean; decisionNote?: string | null }): Promise<void> {
const row = await loadDecidable(input);
await withTenantRLS(input.tenantId, (tx) =>
tx.employeeVacationRequest.update({
where: { id: row.id },
data: { status: "approved", decidedByUserId: input.deciderUserId, decidedAt: new Date(), decisionNote: normaliseNote(input.decisionNote) },
}),
);
await auditTransition({ tenantId: input.tenantId, actorId: input.deciderUserId, action: "HR_LEAVE_APPROVE", targetId: row.id });
await notifyQuiet({ tenantId: input.tenantId, userId: row.employeeUserId, messageKey: "hr.notify.leave.approved", sourceKey: `hr-leave-approve:${row.id}`, payloadMetadata: { requestId: row.id } });
}
export async function rejectRequest(input: { tenantId: string; requestId: string; deciderUserId: string; canApproveAny: boolean; decisionNote?: string | null }): Promise<void> {
const row = await loadDecidable(input);
await withTenantRLS(input.tenantId, (tx) =>
tx.employeeVacationRequest.update({
where: { id: row.id },
data: { status: "rejected", decidedByUserId: input.deciderUserId, decidedAt: new Date(), decisionNote: normaliseNote(input.decisionNote) },
}),
);
await auditTransition({ tenantId: input.tenantId, actorId: input.deciderUserId, action: "HR_LEAVE_REJECT", targetId: row.id });
await notifyQuiet({ tenantId: input.tenantId, userId: row.employeeUserId, messageKey: "hr.notify.leave.rejected", sourceKey: `hr-leave-reject:${row.id}`, payloadMetadata: { requestId: row.id } });
}
export async function requestClarification(input: { tenantId: string; requestId: string; deciderUserId: string; canApproveAny: boolean; question: string }): Promise<void> {
const row = await loadDecidable(input);
const question = normaliseNote(input.question);
if (!question) {
throw new HrServiceError("hr.error.leaveClarificationEmpty");
}
await withTenantRLS(input.tenantId, (tx) =>
tx.employeeVacationRequest.update({ where: { id: row.id }, data: { status: "clarification", clarificationNote: question } }),
);
await auditTransition({ tenantId: input.tenantId, actorId: input.deciderUserId, action: "HR_LEAVE_CLARIFY", targetId: row.id });
await notifyQuiet({ tenantId: input.tenantId, userId: row.employeeUserId, messageKey: "hr.notify.leave.clarification", sourceKey: `hr-leave-clarify:${row.id}`, payloadMetadata: { requestId: row.id } });
}
/** Requester replies to a clarification → back to `submitted` for re-decision. */
export async function replyToClarification(input: { tenantId: string; requestId: string; employeeUserId: string; reply: string }): Promise<void> {
const row = await getRequestOrThrow(input.tenantId, input.requestId);
if (row.employeeUserId !== input.employeeUserId) {
throw new CatalogError(foundationErrors.notFound, { requestId: input.requestId });
}
if (row.status !== "clarification") {
throw new HrServiceError("hr.error.leaveNotClarifiable");
}
const reply = normaliseNote(input.reply);
const merged = reply ? `${row.note ? `${row.note}\n` : ""}↪ ${reply}` : row.note;
await withTenantRLS(input.tenantId, (tx) =>
tx.employeeVacationRequest.update({ where: { id: row.id }, data: { status: "submitted", note: merged?.slice(0, MAX_NOTE_LENGTH) ?? null } }),
);
await auditTransition({ tenantId: input.tenantId, actorId: input.employeeUserId, action: "HR_LEAVE_REPLY", targetId: row.id });
await notifyQuiet({ tenantId: input.tenantId, userId: row.assignedApproverUserId, messageKey: "hr.notify.leave.replied", sourceKey: `hr-leave-reply:${row.id}:${row.updatedAt.getTime()}`, payloadMetadata: { requestId: row.id } });
}
/** Requester withdraws a still-open request. */
export async function withdrawRequest(input: { tenantId: string; requestId: string; employeeUserId: string }): Promise<void> {
const row = await getRequestOrThrow(input.tenantId, input.requestId);
if (row.employeeUserId !== input.employeeUserId) {
throw new CatalogError(foundationErrors.notFound, { requestId: input.requestId });
}
if (row.status !== "submitted" && row.status !== "clarification") {
throw new HrServiceError("hr.error.leaveNotWithdrawable");
}
await withTenantRLS(input.tenantId, (tx) =>
tx.employeeVacationRequest.update({ where: { id: row.id }, data: { status: "withdrawn", withdrawnAt: new Date() } }),
);
await auditTransition({ tenantId: input.tenantId, actorId: input.employeeUserId, action: "HR_LEAVE_WITHDRAW", targetId: row.id });
await notifyQuiet({ tenantId: input.tenantId, userId: row.assignedApproverUserId, messageKey: "hr.notify.leave.withdrawn", sourceKey: `hr-leave-withdraw:${row.id}`, payloadMetadata: { requestId: row.id } });
}