519 lines
20 KiB
TypeScript
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 },
|
|
});
|
|
}
|