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, PunchAlreadyVoidedError, ValidationErrorError, } from "../lib/errors.generated"; export interface VoidPunchInput { /** The assignment requesting the void; must match the target event's Assignment. */ assignmentId: string; /** The id of the TimeClockEvent being retracted. */ targetEventId: string; /** The real-world instant the void 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: VoidPunchInput, ctx: CommandContext) { // ADR-023: every void 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")); } // Lock the target for the read-then-write cycle so the "already voided" check and the // sequence assignment stay consistent under concurrency. 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 append-only: the target event is never mutated. Effective void state is decided // by the highest-sequence TimeClockEventVoid row; a VOID on top of an already-voided event // is rejected. const latest = await latestVoidRecord(db, input.targetEventId); if (isCurrentlyVoided(latest)) { return err(new PunchAlreadyVoidedError(input.targetEventId)); } const voidRecord = await db .insertInto("TimeClockEventVoid") .values({ targetEventId: target.id, action: "VOID", 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 void 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 }); }