import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { type CancelApprovalRequestFn, type FindPendingApproverAssigneeFn, type GetActiveApprovalRequestFn, type SendBackApprovalStepFn, TIMECARD_TARGET_ENTITY_TYPE, type WithdrawApprovalRequestFn, } from "../lib/_approvalDeps"; import { ApprovalStepFailedError, TimecardNotFoundError, InvalidStatusTransitionError, ReopenReasonRequiredError, } from "../lib/errors.generated"; export interface ReopenTimecardInput { id: string; reason?: string; } export interface ReopenTimecardDeps { getActiveApprovalRequest: GetActiveApprovalRequestFn; findPendingApproverAssignee: FindPendingApproverAssigneeFn; withdrawApprovalRequest: WithdrawApprovalRequestFn; sendBackApprovalStep: SendBackApprovalStepFn; cancelApprovalRequest: CancelApprovalRequestFn; } const REOPENABLE_STATUSES = ["SUBMITTED", "APPROVED", "LOCKED"] as const; // Approval commands that persist a decision reason reject an empty string; a reopen from // SUBMITTED may carry no reason, so supply a neutral default for the mirrored resolution. const DEFAULT_SENDBACK_REASON = "Reopened for correction"; const DEFAULT_CANCEL_REASON = "Reopened by administrator"; /** * Function: reopenTimecard * Description: Returns a SUBMITTED, APPROVED, or LOCKED Timecard back to OPEN for rework and, * when an approval request is still in-flight (a SUBMITTED card whose request is PENDING / * REVISION_REQUESTED), resolves it in the same transaction (ADR-003): the submitter withdraws * it, an assignee sends it back, and any other (administrative) actor cancels it. Reopening * from APPROVED or LOCKED has no active request to resolve — its request already resolved — * so only the Timecard state is reset. * * Reset behavior per docs/command/ReopenTimecard.md: reopening from LOCKED preserves the prior * sign-off trail (approvedAt/approvedBy are not erased) and sets historicalCorrection = true, * since the period was previously closed. Reopening from SUBMITTED or APPROVED is a normal * send-back for rework, so the (not-yet-finalized) submittedAt/approvedAt/approvedBy trail from * that in-flight cycle is cleared instead. * * 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: ReopenTimecardInput, ctx: CommandContext, deps: ReopenTimecardDeps, ) { const timecard = await db .selectFrom("Timecard") .selectAll() .where("id", "=", input.id) .forUpdate() .executeTakeFirst(); if (!timecard) { return err(new TimecardNotFoundError(input.id)); } if (!REOPENABLE_STATUSES.includes(timecard.status as (typeof REOPENABLE_STATUSES)[number])) { return err(new InvalidStatusTransitionError(input.id)); } const reopeningFromLocked = timecard.status === "LOCKED"; // A reopen from LOCKED must carry a non-empty reason (whitespace-only is empty) — the period was // already signed off and consumed downstream, so the reason is recorded below. const reopenReason = input.reason?.trim() ?? ""; if (reopeningFromLocked && reopenReason === "") { return err(new ReopenReasonRequiredError(input.id)); } // Resolve any in-flight mirrored approval request (a SUBMITTED card's request is still // PENDING/REVISION_REQUESTED; an APPROVED/LOCKED card's request has already resolved and is // not returned as active). Who the actor is decides how it's resolved (ADR-003). const requestResult = await deps.getActiveApprovalRequest(db, { targetEntityType: TIMECARD_TARGET_ENTITY_TYPE, targetEntityId: input.id, }); // A failed lookup must not be silently read as "no active request": that would let the // Timecard reopen while its mirrored ApprovalRequest stays PENDING, breaking the // same-transaction invariant (ADR-003). Fail the reopen instead. if (!requestResult.ok) { return err(new ApprovalStepFailedError(input.id)); } const request = requestResult.value.approvalRequest; if (request) { // Each mirrored-resolution command must succeed before we reset the Timecard, or the // two records diverge. Any non-ok result aborts the reopen with APPROVAL_STEP_FAILED. if (request.requesterId === ctx.actorId) { const withdrawResult = await deps.withdrawApprovalRequest( db, { approvalRequestId: request.id, comment: input.reason }, ctx, ); if (!withdrawResult.ok) { return err(new ApprovalStepFailedError(input.id)); } } else { const assignee = await deps.findPendingApproverAssignee(db, { approvalRequestId: request.id, userId: ctx.actorId, }); if (assignee) { const sendBackResult = await deps.sendBackApprovalStep( db, { approvalStepAssigneeId: assignee.approvalStepAssigneeId, reason: input.reason ?? DEFAULT_SENDBACK_REASON, }, ctx, ); if (!sendBackResult.ok) { return err(new ApprovalStepFailedError(input.id)); } } else { const cancelResult = await deps.cancelApprovalRequest( db, { approvalRequestId: request.id, reason: input.reason ?? DEFAULT_CANCEL_REASON }, ctx, ); if (!cancelResult.ok) { return err(new ApprovalStepFailedError(input.id)); } } } } // Reopening a LOCKED period is a historical correction: record the reason in TimeCorrectionLog in // the same transaction, consistent with the historical-correction discipline (ADR-014). The reopen // is logged as a status change LOCKED → OPEN so the audit trail explains why a signed-off period // was reopened. SUBMITTED/APPROVED reopens are ordinary send-backs and are not logged this way. if (reopeningFromLocked) { await db .insertInto("TimeCorrectionLog") .values({ timecardId: input.id, targetReportedBlockId: null, field: "status", previousValue: "LOCKED", newValue: "OPEN", reason: reopenReason, correctedBy: ctx.actorId, correctedAt: new Date(), }) .execute(); } // Reopening from LOCKED clears the lock markers (lockedAt/lockedBy) — an OPEN Timecard must never // carry them (that combination is impossible in the lifecycle); lockTimecard re-stamps them on the // next lock. The prior approval trail (approvedAt/approvedBy) is preserved and historicalCorrection // records that the period was reopened after sign-off. Reopening from SUBMITTED/APPROVED is an // ordinary send-back that clears the in-flight submit/approval trail instead. (Two explicit update // shapes rather than one conditional object, so each .set() keeps a clean literal type.) if (reopeningFromLocked) { const updated = await db .updateTable("Timecard") .set({ status: "OPEN", historicalCorrection: true, lockedAt: null, lockedBy: null }) .where("id", "=", input.id) .returningAll() .executeTakeFirstOrThrow(); return ok({ timecard: updated }); } const updated = await db .updateTable("Timecard") .set({ status: "OPEN", submittedAt: null, approvedAt: null, approvedBy: null }) .where("id", "=", input.id) .returningAll() .executeTakeFirstOrThrow(); return ok({ timecard: updated }); }