import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import { shiftPlacementLifecycle } from "../db/shiftPlacement.lifecycle.generated"; import type { Transaction } from "../generated/kysely-tailordb"; import { ShiftPlacementNotFoundError, ShiftPlacementNotActiveError } from "../lib/errors.generated"; export interface ReleaseShiftPlacementInput { id: string; } /** * Function: releaseShiftPlacement * Description: Unstaffs a slot by moving the placement to CANCELLED. Replaces the former * deleteShiftPlacement: a placement is never physically removed, because "X was planned on * this slot until it was released" is exactly the fact a confirmed shiftSchedule has to be able to * answer afterwards. Releasing leaves the slot open again (it has no ACTIVE placement), which * is what listOpenShifts keys on. */ export async function run( db: Transaction, input: ReleaseShiftPlacementInput, _ctx: CommandContext, ) { const shiftPlacement = await db .selectFrom("ShiftPlacement") .selectAll() .where("id", "=", input.id) .forUpdate() .executeTakeFirst(); if (!shiftPlacement) { return err(new ShiftPlacementNotFoundError(input.id)); } // Release is the ACTIVE -> CANCELLED edge of the generated lifecycle (docs/model/ // ShiftPlacement.md's State Transitions table). The module's own error is kept: a caller told // "not active" learns why the release was refused, which a generic invalid-transition code hides. const nextStatus = shiftPlacementLifecycle.tryTransition(shiftPlacement.status, "release"); if (!nextStatus) { return err(new ShiftPlacementNotActiveError(input.id)); } const updated = await db .updateTable("ShiftPlacement") .set({ status: nextStatus, releasedAt: new Date() }) .where("id", "=", input.id) .returningAll() .executeTakeFirstOrThrow(); return ok({ shiftPlacement: updated }); }