import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { APPROVAL_NOT_ACTIVE_ASSIGNEE, APPROVAL_SELF_DECISION_NOT_ALLOWED, type ApproveApprovalStepFn, type FindPendingApproverAssigneeFn, type GetActiveApprovalRequestFn, TIMECARD_TARGET_ENTITY_TYPE, } from "../lib/_approvalDeps"; import { TimecardNotFoundError, InvalidStatusTransitionError, NotAssigneeError, SelfApprovalError, ApprovalStepFailedError, } from "../lib/errors.generated"; export interface ApproveTimecardInput { id: string; } export interface ApproveTimecardDeps { getActiveApprovalRequest: GetActiveApprovalRequestFn; findPendingApproverAssignee: FindPendingApproverAssigneeFn; approveApprovalStep: ApproveApprovalStepFn; } /** * Function: approveTimecard * Description: Approves a SUBMITTED Timecard by delegating the decision to the bundled * approval module's approveApprovalStep (ADR-003 wrapper), then syncing the Timecard to * APPROVED in the same transaction with approvedAt/approvedBy = the approver (ctx.actorId). * * The approver is the actor (ctx.actorId) — the same identity the approval engine matches * against the step's assignee row and against the requester for its self-decision guard. * The self-approval check is therefore delegated to the engine (SELF_DECISION_NOT_ALLOWED), * not re-implemented here. Wrapper error mapping (ADR-003): NOT_ACTIVE_ASSIGNEE -> NOT_ASSIGNEE, * SELF_DECISION_NOT_ALLOWED -> SELF_APPROVAL, any other non-ok result -> APPROVAL_STEP_FAILED. * * No timecard.lifecycle.generated.ts exists for this hand-rolled status enum, so the * transition is validated and applied manually rather than via executeTransition/tryTransition. */ export async function run( db: Transaction, input: ApproveTimecardInput, ctx: CommandContext, deps: ApproveTimecardDeps, ) { const timecard = await db .selectFrom("Timecard") .selectAll() .where("id", "=", input.id) .forUpdate() .executeTakeFirst(); if (!timecard) { return err(new TimecardNotFoundError(input.id)); } if (timecard.status !== "SUBMITTED") { return err(new InvalidStatusTransitionError(input.id)); } // Locate the mirrored approval request by this Timecard's id, then the assignee row the // approver (ctx.actorId) owns on its active step. No active request or no owned assignee // means the actor is not an eligible approver for this card. const requestResult = await deps.getActiveApprovalRequest(db, { targetEntityType: TIMECARD_TARGET_ENTITY_TYPE, targetEntityId: input.id, }); const request = requestResult.ok ? requestResult.value.approvalRequest : null; if (!request) { return err(new ApprovalStepFailedError(input.id)); } const assignee = await deps.findPendingApproverAssignee(db, { approvalRequestId: request.id, userId: ctx.actorId, }); if (!assignee) { return err(new NotAssigneeError(input.id)); } const decision = await deps.approveApprovalStep( db, { approvalStepAssigneeId: assignee.approvalStepAssigneeId }, ctx, ); if (!decision.ok) { switch (decision.error.code) { case APPROVAL_NOT_ACTIVE_ASSIGNEE: return err(new NotAssigneeError(input.id)); case APPROVAL_SELF_DECISION_NOT_ALLOWED: return err(new SelfApprovalError(input.id)); default: return err(new ApprovalStepFailedError(input.id)); } } const updated = await db .updateTable("Timecard") .set({ status: "APPROVED", approvedAt: new Date(), approvedBy: ctx.actorId }) .where("id", "=", input.id) .returningAll() .executeTakeFirstOrThrow(); return ok({ timecard: updated }); }