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 { LeaveRequestNotFoundError, InvalidStateTransitionError, NotRequesterError, NotAssigneeError, ApprovalStepFailedError, } from "../lib/errors.generated"; export interface RequestLeaveCancelInput { id: string; requestedBy: string; } export interface RequestLeaveCancelDeps { leaveApproverRoleId: string; listUsersByRole: ListUsersByRoleFn; createApprovalRequest: CreateApprovalRequestFn; } export async function run( db: Transaction, input: RequestLeaveCancelInput, ctx: CommandContext, deps: RequestLeaveCancelDeps, ) { const leaveRequest = await db .selectFrom("LeaveRequest") .selectAll() .where("id", "=", input.id) .forUpdate() .executeTakeFirst(); if (!leaveRequest) { return err(new LeaveRequestNotFoundError(input.id)); } // requestedBy is compared against this same-module request's own workerId field; no // cross-module lookup is needed to enforce that only the original requester may cancel. if (leaveRequest.workerId !== input.requestedBy) { return err(new NotRequesterError(input.id)); } if (leaveRequest.status !== "APPROVED") { return err(new InvalidStateTransitionError(input.id)); } // Deadlock-avoidance pre-check (ADR-003): the cancellation decision needs at least one eligible // approver other than the requester (ctx.actorId), or it could never be resolved. const approversResult = await deps.listUsersByRole(db, { roleId: deps.leaveApproverRoleId }, ctx); if (!approversResult.ok) { return err(new ApprovalStepFailedError(input.id)); } const eligibleApprovers = approversResult.value.users.filter((user) => user.id !== ctx.actorId); if (eligibleApprovers.length === 0) { return err(new NotAssigneeError(input.id)); } // Mirror the cancellation decision with a second direct-mode approval request (one step, the // leave-approver role, quorum ANY), linked to this LeaveRequest by id. Its id becomes the // request's current-decision targetEntityId (the original approval request is already resolved). const requestResult = await deps.createApprovalRequest( db, { name: `Leave cancellation ${input.id}`, purpose: "Leave cancellation approval", targetEntityType: LEAVE_REQUEST_TARGET_ENTITY_TYPE, targetEntityId: input.id, steps: [ { stepOrder: 1, name: "Leave Cancellation Approval", assignees: [{ roleId: deps.leaveApproverRoleId, roleQuorum: "ANY" }], }, ], }, ctx, ); if (!requestResult.ok) { return err(new ApprovalStepFailedError(input.id)); } const updated = await db .updateTable("LeaveRequest") .set({ status: "CANCEL_PENDING", targetEntityId: requestResult.value.approvalRequest.id, }) .where("id", "=", input.id) .returningAll() .executeTakeFirstOrThrow(); // LeaveConsumption rows and the previously emitted time-tracking day status are left untouched // while CANCEL_PENDING (per doc) — they are only touched by the subsequent approve/reject decision. return ok({ leaveRequest: updated }); }