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

519 lines
20 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): approving a request creates an internal all-day
// calendar event for the employee (source hivegrid_internal, busy for
// availability) and stores its id in `calendarEventId`. Approval is terminal in
// this state machine (reject/withdraw only leave submitted/clarification), so
// there is no post-approval cleanup path. R-P9b keeps reading the conflict feed
// from approved requests directly — the event mirrors, it does not replace.
import { CatalogError, foundationErrors, notify } from "@hivegrid/foundation";
import { logActivity } from "@hivegrid/activity";
import { createAuditEvent } from "@hivegrid/db/audit-events.server";
import { publishOutboxEvent, type OutboxPublisherTransaction } from "@hivegrid/db/outbox";
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
import { buildCalendarCreatedWorkflowEvent } from "~/services/automation/automation.producer-events.server";
import { HrServiceError } from "./hrService.server";
import type { HrM05Values } from "./hrM05Forms";
import { createSystemHrSubmissionWithTx } from "./hrM05Submission.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 withTenantRLS(input.tenantId, (tx) =>
notify(
{
tenantId: input.tenantId,
userId: input.userId,
channel: "IN_APP",
category: "hr",
messageKey: input.messageKey,
sourceKey: input.sourceKey,
payloadMetadata: input.payloadMetadata,
},
tx as unknown as Parameters<typeof notify>[1]
)
).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, (tx) => listApprovedAbsencesForRangeWithTx(tx, tenantId, from, to));
}
/**
* R-UX3 team view. Unlike the stable Plantafel feed above, this always applies
* an employee object scope: direct reports for a team lead, or the tenant when
* an explicit HR-wide permission was already established by the route.
*/
export async function listApprovedAbsencesForTeamRange(input: {
tenantId: string;
viewerUserId: string;
canReadAll: boolean;
from: Date;
to: Date;
}): Promise<ApprovedAbsence[]> {
return withTenantRLS(input.tenantId, async (tx) => {
const employeeUserIds = input.canReadAll
? null
: (
await tx.hrEmployeeProfile.findMany({
where: { tenantId: input.tenantId, managerUserId: input.viewerUserId },
select: { userId: true },
})
).map(({ userId }) => userId);
if (employeeUserIds?.length === 0) return [];
return listApprovedAbsencesForRangeWithTx(tx, input.tenantId, input.from, input.to, employeeUserIds ?? undefined);
});
}
/** Reuse the caller's tenant transaction for race-free schedule conflict checks. */
export async function listApprovedAbsencesForRangeWithTx(
tx: TenantRlsClient,
tenantId: string,
from: Date,
to: Date,
employeeUserIds?: readonly string[]
): Promise<ApprovedAbsence[]> {
const employeeScope = employeeUserIds ? { employeeUserId: { in: [...employeeUserIds] } } : {};
const [vacations, sick] = await Promise.all([
tx.employeeVacationRequest.findMany({
where: { tenantId, ...employeeScope, status: "approved", startDate: { lte: to }, endDate: { gte: from } },
select: { id: true, employeeUserId: true, startDate: true, endDate: true, requestKind: true },
}),
tx.employeeSickLeaveReport.findMany({
where: {
tenantId,
...employeeScope,
status: { not: "cancelled" },
startDate: { lte: to },
OR: [
{ returnDate: { gte: from } },
{ returnDate: null, expectedReturnDate: { gte: from } },
{ returnDate: null, expectedReturnDate: null, startDate: { gte: from } },
],
},
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;
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;
formValues?: HrM05Values;
};
/** 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, async (tx) => {
const request = await 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,
},
});
if (input.formValues) {
await createSystemHrSubmissionWithTx(tx, {
tenantId: input.tenantId,
actorUserId: input.employeeUserId,
process: "leave",
linkedEntityType: "hr_leave_request",
linkedEntityId: request.id,
values: input.formValues,
});
}
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.employeeUserId,
actorKind: "user",
activityType: "form_submitted",
subjectType: "tenant",
subjectId: input.tenantId,
summaryText: "activity.runtime.hr.leaveRequested",
metadata: { requestId: request.id, process: "leave" },
},
{ client: tx }
);
if (request.assignedApproverUserId) {
await notify(
{
tenantId: input.tenantId,
userId: request.assignedApproverUserId,
channel: "IN_APP",
category: "hr",
messageKey: "hr.notify.leave.submitted",
sourceKey: `hr-leave-submit:${request.id}`,
payloadMetadata: { requestId: request.id },
},
tx as unknown as Parameters<typeof notify>[1]
).catch(() => undefined);
}
return request;
})) as VacationRequestRow;
await auditTransition({
tenantId: input.tenantId,
actorId: input.employeeUserId,
action: "HR_LEAVE_SUBMIT",
targetId: created.id,
metadata: { requestKind: input.requestKind },
});
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;
/** Resolved, locale-aware calendar titles per request kind (the service has
* no i18n access — the route passes them, mirroring booking's pageTitle). */
absenceTitleByKind?: Partial<Record<HrVacationRequestKind, string>>;
}): Promise<void> {
const row = await loadDecidable(input);
await withTenantRLS(input.tenantId, async (tx) => {
// P16/M25 sync: the approved absence becomes an internal all-day event so
// calendar/availability surfaces block the window (same convention as the
// booking module: source hivegrid_internal + internalEntityLink).
const event = await tx.calendarEvent.create({
data: {
tenantId: input.tenantId,
userId: row.employeeUserId,
source: "hivegrid_internal",
title: input.absenceTitleByKind?.[row.requestKind] ?? null,
startsAt: row.startDate,
endsAt: row.endDate,
isAllDay: true,
isBusyForAvailability: true,
internalEntityLink: { type: "hr_vacation_request", id: row.id },
isDemoData: (row as VacationRequestRow & { isDemoData?: boolean }).isDemoData ?? false,
},
select: { id: true },
});
await tx.employeeVacationRequest.update({
where: { id: row.id },
data: {
status: "approved",
decidedByUserId: input.deciderUserId,
decidedAt: new Date(),
decisionNote: normaliseNote(input.decisionNote),
calendarEventId: event.id,
},
});
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildCalendarCreatedWorkflowEvent({
tenantId: input.tenantId,
calendarEventId: event.id,
userId: input.deciderUserId,
calendarKey: "urlaubskalender",
eventKind: "termin",
source: "hr_leave",
sourceEntityId: row.id,
startsAt: row.startDate,
endsAt: row.endDate,
})
);
});
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 },
});
}