import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { isEffectiveOn, type GetAssignmentDep } from "../lib/_workforceDeps"; import { ShiftNotFoundError, ShiftCancelledError, MissingRequiredFieldError, AssignmentNotEffectiveError, ShiftPlacementAlreadyExistsError, } from "../lib/errors.generated"; export interface CreateShiftPlacementInput { shiftId: string; assignmentId: string; // Origin of this placement. Defaults to MANUAL (a person is placing this Assignment); bulk // generation logic instead calls createShiftPlacements with provenance GENERATED. provenance?: "GENERATED" | "MANUAL"; } export async function run>( db: Transaction, input: CreateShiftPlacementInput & CF, ctx: CommandContext, deps: GetAssignmentDep, ) { const { shiftId, assignmentId, provenance = "MANUAL", ...customFields } = input; if (!shiftId || !assignmentId) { return err(new MissingRequiredFieldError(shiftId ? "assignmentId" : "shiftId")); } const shift = await db .selectFrom("Shift") .selectAll() .where("id", "=", shiftId) .forUpdate() .executeTakeFirst(); if (!shift) { return err(new ShiftNotFoundError(shiftId)); } if (shift.cancelledAt !== null) { return err(new ShiftCancelledError(shiftId)); } // Whether the Assignment exists and covers the shift's date is a workforce question, read // through the injected getAssignment. Leaving it to the FK alone would let a placement land // on a date the person was not yet (or no longer) posted to. const assignmentResult = await deps.getAssignment(db, { id: assignmentId }, ctx); if (!assignmentResult.ok || !isEffectiveOn(assignmentResult.value.assignment, shift.date)) { return err(new AssignmentNotEffectiveError(assignmentId)); } // At most one ACTIVE placement per (shift, assignment): a second one would double-count the // person in personal schedules and in getShiftVariance, which maps over placements. SUPERSEDED // and CANCELLED rows are deliberately not matched, so someone released or swapped out can be // placed back on the same slot. This cannot be a DB unique constraint — the platform's index // API takes only (fields, unique) with no predicate, and an unconditional unique on // (shiftId, assignmentId) would reject exactly that legitimate re-placement. The Shift row is // already locked above, so concurrent placements onto one slot serialise behind it. const existing = await db .selectFrom("ShiftPlacement") .select("id") .where("shiftId", "=", shiftId) .where("assignmentId", "=", assignmentId) .where("status", "=", "ACTIVE") .executeTakeFirst(); if (existing) { return err(new ShiftPlacementAlreadyExistsError(`${shiftId}:${assignmentId}`)); } const shiftPlacement = await db .insertInto("ShiftPlacement") .values({ ...(customFields as Record), shiftId, assignmentId, status: "ACTIVE", supersededById: null, assignedAt: new Date(), releasedAt: null, provenance, }) .returningAll() .executeTakeFirstOrThrow(); return ok({ shiftPlacement }); }