import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { type CreateApprovalRequestFn, type ListUsersByRoleFn, TIMECARD_TARGET_ENTITY_TYPE, } from "../lib/_approvalDeps"; import { TimecardNotFoundError, InvalidStatusTransitionError, NoEligibleApproverError, ApprovalStepFailedError, } from "../lib/errors.generated"; export interface SubmitTimecardInput { id: string; } export interface SubmitTimecardDeps { /** Role whose holders approve Timecards (ADR-003 direct-mode assignee). */ timecardApproverRoleId: string; listUsersByRole: ListUsersByRoleFn; createApprovalRequest: CreateApprovalRequestFn; } /** * Function: submitTimecard * Description: Transitions an OPEN Timecard to SUBMITTED and, in the same transaction, * mirrors it with a bundled approval-module request (direct mode: one step, the * timecard-approver role as assignee, quorum ANY), linked by targetEntityId (ADR-003). * * The submitter (ctx.actorId) becomes the approval request's requester, so the approval * engine's self-decision guard blocks them from later approving their own card. Because * the engine does not itself check that a non-requester approver exists at creation time, * this command pre-checks it and rejects with NO_ELIGIBLE_APPROVER, so a Timecard is never * submitted into an unapprovable request. * * 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: SubmitTimecardInput, ctx: CommandContext, deps: SubmitTimecardDeps, ) { const timecard = await db .selectFrom("Timecard") .selectAll() .where("id", "=", input.id) .forUpdate() .executeTakeFirst(); if (!timecard) { return err(new TimecardNotFoundError(input.id)); } if (timecard.status !== "OPEN") { return err(new InvalidStatusTransitionError(input.id)); } // Deadlock-avoidance pre-check (ADR-003): the mirrored request must have at least one // eligible approver other than the requester (the submitter), or it could never be approved. const approversResult = await deps.listUsersByRole( db, { roleId: deps.timecardApproverRoleId }, 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 NO_ELIGIBLE_APPROVER. Fail the submit with APPROVAL_STEP_FAILED instead. 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 NoEligibleApproverError(input.id)); } // Mirror the SUBMITTED decision with a direct-mode approval request (one step, the // timecard-approver role, quorum ANY), linked back to this Timecard by targetEntityId. const requestResult = await deps.createApprovalRequest( db, { name: `Timecard ${input.id}`, purpose: "Timecard approval", targetEntityType: TIMECARD_TARGET_ENTITY_TYPE, targetEntityId: input.id, steps: [ { stepOrder: 1, name: "Timecard Approval", assignees: [{ roleId: deps.timecardApproverRoleId, roleQuorum: "ANY" }], }, ], }, ctx, ); if (!requestResult.ok) { return err(new ApprovalStepFailedError(input.id)); } const updated = await db .updateTable("Timecard") .set({ status: "SUBMITTED", submittedAt: new Date() }) .where("id", "=", input.id) .returningAll() .executeTakeFirstOrThrow(); return ok({ timecard: updated }); }