import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { TimeClockEventVoidAuthority, TimeClockEventVoidReasonCode } from "../generated/enums"; import type { Transaction } from "../generated/kysely-tailordb"; import { isCurrentlyVoided, latestVoidRecord } from "../lib/_voidStatus"; import { PunchNotFoundError, PunchNotVoidedError, ValidationErrorError, } from "../lib/errors.generated"; export interface UnvoidPunchInput { /** The assignment requesting the unvoid; must match the target event's Assignment. */ assignmentId: string; /** The id of the TimeClockEvent whose void is being retracted. */ targetEventId: string; /** The real-world instant the unvoid was recorded. */ occurredAt: Date; reasonCode: TimeClockEventVoidReasonCode; /** Mandatory, non-empty free-text reason (ADR-023). */ reasonNote: string; authority: TimeClockEventVoidAuthority; } export async function run(db: Transaction, input: UnvoidPunchInput, ctx: CommandContext) { // ADR-023: every unvoid must carry a non-empty reason. Enforce it here so non-UI callers // can't bypass the audit invariant with an empty/whitespace-only note. const reasonNote = input.reasonNote.trim(); if (reasonNote === "") { return err(new ValidationErrorError("reasonNote must not be empty")); } const target = await db .selectFrom("TimeClockEvent") .selectAll() .where("id", "=", input.targetEventId) .forUpdate() .executeTakeFirst(); if (!target || target.assignmentId !== input.assignmentId) { return err(new PunchNotFoundError(input.targetEventId)); } // ADR-023: "undo the void" is itself a new fact. Only a currently-voided event can be // unvoided; the UNVOID row supersedes the prior VOID by carrying a higher sequence. const latest = await latestVoidRecord(db, input.targetEventId); if (!isCurrentlyVoided(latest)) { return err(new PunchNotVoidedError(input.targetEventId)); } const voidRecord = await db .insertInto("TimeClockEventVoid") .values({ targetEventId: target.id, action: "UNVOID", sequence: (latest?.sequence ?? 0) + 1, // Audit actor is the authenticated caller (ctx), never a client-supplied field — an input // actorId would let a caller attribute the unvoid to someone else (ADR-023 audit integrity). actorId: ctx.actorId, occurredAt: input.occurredAt, reasonCode: input.reasonCode, reasonNote, authority: input.authority, }) .returningAll() .executeTakeFirstOrThrow(); return ok({ voidRecord, targetEvent: target }); }