import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { type CreateApprovalRequestFn, type ListUsersByRoleFn, LEAVE_REQUEST_TARGET_ENTITY_TYPE, } from "../lib/_approvalDeps"; import { LeaveTypeNotFoundError, InsufficientBalanceError, DuplicateError, InvalidDateRangeError, InvalidLeaveUnitError, AssignmentNotEffectiveError, NotAssigneeError, ApprovalStepFailedError, ReasonRequiredError, } from "../lib/errors.generated"; // The erp-kit Result union, narrowed to what requestLeave reads. type Result = { ok: true; value: T } | { ok: false; error: { code: string } }; /** * Injected seams: the workforce Assignment the leave is filed against (+ its employment), and the * bundled approval-module wiring for the mirrored request (ADR-003). Declared as minimal structural * signatures (the `_approvalDeps.ts` precedent) so leave-management stays free of the other modules' * kysely types; the app composition adapts the real queries/commands onto these seams. */ export interface RequestLeaveDeps { getAssignment: ( db: Transaction, input: { id: string }, ctx: CommandContext, ) => Promise< Result<{ assignment: { workerEmploymentId: string; effectiveStart: Date; effectiveEnd: Date | null }; }> >; getWorkerEmployment: ( db: Transaction, input: { id: string }, ctx: CommandContext, ) => Promise>; /** Role whose holders approve leave requests (ADR-003 direct-mode assignee). */ leaveApproverRoleId: string; listUsersByRole: ListUsersByRoleFn; createApprovalRequest: CreateApprovalRequestFn; } export interface RequestLeaveInput { workerId: string; assignmentId: string; leaveTypeKey: string; startDate: Date; endDate: Date; reason: string; } function daysBetweenInclusive(start: Date, end: Date): number { const msPerDay = 24 * 60 * 60 * 1000; return Math.round((end.getTime() - start.getTime()) / msPerDay) + 1; } // startDate/endDate are date-only fields (db.date()); compare them by UTC calendar day rather // than exact timestamp so a single-day unit is not spuriously rejected by any time-of-day // component, consistent with daysBetweenInclusive rounding to whole days. function isSameUtcDay(a: Date, b: Date): boolean { return ( a.getUTCFullYear() === b.getUTCFullYear() && a.getUTCMonth() === b.getUTCMonth() && a.getUTCDate() === b.getUTCDate() ); } interface Allocation { grantId: string; take: number; remainingDays: number; } export async function run( db: Transaction, input: RequestLeaveInput, ctx: CommandContext, deps: RequestLeaveDeps, ) { // Validate the requested range before any work: an inverted range (startDate > endDate) would // make daysBetweenInclusive negative and let the FIFO allocation trivially "succeed" (needing // <= 0 days), creating a bogus request that bypasses the balance check. A single-day request has // startDate == endDate, so only a strict start > end is rejected. if (input.startDate > input.endDate) { return err(new InvalidDateRangeError(input.workerId)); } // `reason` is a mandatory, non-empty field on LeaveRequest (per the model) — enforce it here so // an API call cannot bypass the UI's required-field validation with an empty/whitespace string. if (input.reason.trim().length === 0) { return err(new ReasonRequiredError(input.workerId)); } // The assignment must be a workforce Assignment that covers the whole requested range and belongs // to the requesting worker (via its WorkerEmployment) — validated through the injected workforce // query seam (ADR precedent: cross-module reads go through injected queries, not FK-only). // (The MENSTRUAL gender-eligibility gate was dropped with LeaveType.genderEligibility / // Worker.gender in issue #39 and can be re-introduced with the same wiring.) const assignmentResult = await deps.getAssignment(db, { id: input.assignmentId }, ctx); if (!assignmentResult.ok) { return err(new AssignmentNotEffectiveError(input.assignmentId)); } const { assignment } = assignmentResult.value; const coversRange = assignment.effectiveStart.getTime() <= input.startDate.getTime() && (assignment.effectiveEnd === null || assignment.effectiveEnd.getTime() >= input.endDate.getTime()); if (!coversRange) { return err(new AssignmentNotEffectiveError(input.assignmentId)); } const employmentResult = await deps.getWorkerEmployment( db, { id: assignment.workerEmploymentId }, ctx, ); if ( !employmentResult.ok || !employmentResult.value.workerEmployment || employmentResult.value.workerEmployment.workerId !== input.workerId ) { return err(new AssignmentNotEffectiveError(input.assignmentId)); } const leaveType = await db .selectFrom("LeaveType") .selectAll() .where("key", "=", input.leaveTypeKey) .executeTakeFirst(); if (!leaveType || !leaveType.isActive) { return err(new LeaveTypeNotFoundError(input.leaveTypeKey)); } // The type's requestUnit drives the day computation. Absent (null) is treated as RANGE so // pre-existing catalog rows keep the date-range behavior (back-compat). FULL_DAY and HALF_DAY // are single-day units: the request must target exactly one calendar day (startDate == endDate; // the inverted-range guard above already rejected startDate > endDate). const unit = leaveType.requestUnit ?? "RANGE"; let daysRequested: number; if (unit === "RANGE") { daysRequested = daysBetweenInclusive(input.startDate, input.endDate); } else { if (!isSameUtcDay(input.startDate, input.endDate)) { return err(new InvalidLeaveUnitError(input.leaveTypeKey)); } daysRequested = unit === "FULL_DAY" ? 1 : 0.5; } // NOTE: SPECIAL leave has no per-request sub-type validation. LeaveType.specialSubtypeSet and a // per-request specialSubtype were dropped in issue #39 (configured but constrained nothing); a // follow-up can re-introduce both the LeaveType field and this check together when SPECIAL leave // sub-type plumbing is actually built. // Duplicate filing guard: at most one non-terminal request may exist per (workerId, calendar // date). Because a request spans [startDate, endDate], we reject a filing whose date range // OVERLAPS any existing non-terminal request for the same worker — two ranges [aStart, aEnd] and // [bStart, bEnd] overlap iff aStart <= bEnd AND aEnd >= bStart. (Matching only on startDate would // let a range that overlaps on later days slip through when its start differs.) REJECTED and // CANCELLED are terminal and intentionally excluded from this filter, so a previously // rejected/cancelled request never blocks a later filing. const duplicate = await db .selectFrom("LeaveRequest") .selectAll() .where("workerId", "=", input.workerId) .where("startDate", "<=", input.endDate) .where("endDate", ">=", input.startDate) .where("status", "in", ["PENDING", "APPROVED", "CANCEL_PENDING"]) .executeTakeFirst(); if (duplicate) { return err(new DuplicateError(input.workerId)); } const allocations: Allocation[] = []; if (leaveType.requiresBalance) { // Only grants actually in force for the request are consumable: granted on or before the // request starts, and not expired before it ends (grantedDate <= startDate <= endDate <= // expirationDate). This excludes future-dated grants and still-unexpired-but-lapsed grants // (whose expireLeaveGrants batch has not run yet), so the result never depends on batch timing. let grantsQuery = db .selectFrom("LeaveGrant") .selectAll() .where("workerId", "=", input.workerId) .where("leaveTypeKey", "=", input.leaveTypeKey) .where("expiredAt", "is", null) .where("grantedDate", "<=", input.startDate) .where("expirationDate", ">=", input.endDate) .orderBy("expirationDate", "asc") .forUpdate(); // A compensatory leave type draws only from COMPENSATORY grants, never STATUTORY/MANUAL, and // vice versa. This is an explicit per-type flag, not inferred from the category label. grantsQuery = leaveType.drawsFromCompensatoryGrants ? grantsQuery.where("grantType", "=", "COMPENSATORY") : grantsQuery.where("grantType", "!=", "COMPENSATORY"); const grants = await grantsQuery.execute(); let remainingNeed = daysRequested; for (const grant of grants) { if (remainingNeed <= 0) break; const available = Number(grant.remainingDays); if (available <= 0) continue; const take = Math.min(available, remainingNeed); allocations.push({ grantId: grant.id, take, remainingDays: available }); remainingNeed -= take; } if (remainingNeed > 0) { return err(new InsufficientBalanceError(input.workerId)); } } // Deadlock-avoidance pre-check (ADR-003): the mirrored request must have at least one eligible // approver other than the requester (ctx.actorId), or it could never be approved — so a // LeaveRequest is never filed into an unapprovable request. const approversResult = await deps.listUsersByRole(db, { roleId: deps.leaveApproverRoleId }, ctx); // A failed lookup (INSUFFICIENT_PERMISSION, query error, …) is not the same as "no approvers // exist" — treating it as an empty list would mislabel a genuine failure as NOT_ASSIGNEE. if (!approversResult.ok) { return err(new ApprovalStepFailedError(input.workerId)); } const eligibleApprovers = approversResult.value.users.filter((user) => user.id !== ctx.actorId); if (eligibleApprovers.length === 0) { return err(new NotAssigneeError(input.workerId)); } // The LeaveRequest id is generated up front so the mirrored approval request can target it, and // the request row can store the resulting approval-request id in `targetEntityId` in one insert. const leaveRequestId = crypto.randomUUID(); // Mirror the PENDING decision with a direct-mode approval request (one step, the leave-approver // role, quorum ANY), linked to this LeaveRequest by targetEntityId (ADR-003). ctx.actorId becomes // the requester, so the approval engine's self-decision guard blocks self-approval later. const requestResult = await deps.createApprovalRequest( db, { name: `Leave request ${leaveRequestId}`, purpose: "Leave request approval", targetEntityType: LEAVE_REQUEST_TARGET_ENTITY_TYPE, targetEntityId: leaveRequestId, steps: [ { stepOrder: 1, name: "Leave Approval", assignees: [{ roleId: deps.leaveApproverRoleId, roleQuorum: "ANY" }], }, ], }, ctx, ); if (!requestResult.ok) { return err(new ApprovalStepFailedError(input.workerId)); } const leaveRequest = await db .insertInto("LeaveRequest") .values({ id: leaveRequestId, workerId: input.workerId, assignmentId: input.assignmentId, leaveTypeKey: input.leaveTypeKey, startDate: input.startDate, endDate: input.endDate, reason: input.reason, status: "PENDING", resolvedAt: null, resolvedBy: null, targetEntityId: requestResult.value.approvalRequest.id, }) .returningAll() .executeTakeFirstOrThrow(); for (const allocation of allocations) { await db .insertInto("LeaveConsumption") .values({ leaveRequestId: leaveRequest.id, leaveGrantId: allocation.grantId, daysConsumed: allocation.take.toString(), restoredAt: null, }) .execute(); const newRemaining = (allocation.remainingDays - allocation.take).toString(); await db .updateTable("LeaveGrant") .set({ remainingDays: newRemaining }) .where("id", "=", allocation.grantId) .execute(); } return ok({ leaveRequest }); }