import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { isWithinPeriod } from "../lib/_shiftSchedulePeriod"; import { resolvePatternSegment, validateSegments, isShiftTypeConsistent, sortSegments, type ResolvedSegment, type ShiftType, } from "../lib/_shiftSegments"; import { MissingRequiredFieldError, ShiftPatternNotFoundError, SegmentRequiredError, SegmentGapInvalidError, SegmentBreakInvalidError, ShiftTypeSegmentMismatchError, ShiftScheduleNotFoundError, ShiftDateOutOfPeriodError, } from "../lib/errors.generated"; export interface CreateShiftSegmentInput { plannedStartAt: Date; plannedEndAt: Date; breakMinutes: number; } export interface CreateShiftInput { // Every Shift belongs to a shiftSchedule period; the Shift has no lifecycle of its own. shiftScheduleId: string; date: Date; shiftType: ShiftType; shiftPatternId?: string | null; // Cross-module FK (organization::Site). There is no cross-module query injection wired into // module.ts yet, so referential integrity is enforced by the DB-level FK constraint set up via // type injection in module.ts. "Who staffs this slot" is not a Shift field at all — it is the // ShiftPlacement placement, which is the single authoritative record of staffing. siteId?: string | null; segments?: CreateShiftSegmentInput[]; } export async function run>( db: Transaction, input: CreateShiftInput & CF, _ctx: CommandContext, ) { const { shiftScheduleId, date, shiftType, shiftPatternId = null, siteId = null, segments = [], ...customFields } = input; if (!shiftScheduleId || !date || !shiftType) { return err(new MissingRequiredFieldError("shiftScheduleId/date/shiftType")); } const shiftSchedule = await db .selectFrom("ShiftSchedule") .selectAll() .where("id", "=", shiftScheduleId) .executeTakeFirst(); if (!shiftSchedule) { return err(new ShiftScheduleNotFoundError(shiftScheduleId)); } if (!isWithinPeriod(date, shiftSchedule.startDate, shiftSchedule.endDate)) { return err(new ShiftDateOutOfPeriodError(date.toISOString())); } let resolvedSegments: ResolvedSegment[] = segments.map((s) => ({ plannedStartAt: s.plannedStartAt, plannedEndAt: s.plannedEndAt, breakMinutes: s.breakMinutes, })); if (shiftPatternId) { const pattern = await db .selectFrom("ShiftPattern") .selectAll() .where("id", "=", shiftPatternId) .executeTakeFirst(); if (!pattern) { return err(new ShiftPatternNotFoundError(shiftPatternId)); } if (resolvedSegments.length === 0) { const patternSegments = [...(pattern.segments ?? [])].sort((a, b) => a.sequence - b.sequence); resolvedSegments = patternSegments.map((seg) => resolvePatternSegment(date, seg)); } } if (resolvedSegments.length === 0) { return err(new SegmentRequiredError(shiftPatternId ?? "createShift")); } const validation = validateSegments(resolvedSegments); if (!validation.ok) { if (validation.error === "BREAK") { return err(new SegmentBreakInvalidError("createShift")); } return err(new SegmentGapInvalidError("createShift")); } if (!isShiftTypeConsistent(shiftType, resolvedSegments.length)) { return err(new ShiftTypeSegmentMismatchError(shiftType)); } const sorted = sortSegments(resolvedSegments); const plannedStartAt = sorted[0].plannedStartAt; const plannedEndAt = sorted[sorted.length - 1].plannedEndAt; const shift = await db .insertInto("Shift") .values({ ...(customFields as Record), shiftScheduleId, date, shiftType, cancelledAt: null, plannedStartAt, plannedEndAt, shiftPatternId, siteId, // Embedded segments (ADR-022), 1-based sequence in time order segments: sorted.map((seg, index) => ({ sequence: index + 1, plannedStartAt: seg.plannedStartAt, plannedEndAt: seg.plannedEndAt, breakMinutes: seg.breakMinutes, })), }) .returningAll() .executeTakeFirstOrThrow(); return ok({ shift }); }