// 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; }): Promise { 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[1] ) ).catch(() => undefined); } async function auditTransition(input: { tenantId: string; actorId: string; action: string; targetId: string; metadata?: Record; }): Promise { 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 { 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 { 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 { 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 { 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 { 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): Promise { 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 { 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 { 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 { 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[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 { 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>; }): Promise { 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 { 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 { 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 { 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 { 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 }, }); }