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, BulkInputEmptyError, BulkInputTooLargeError, ShiftPatternNotFoundError, SegmentRequiredError, SegmentGapInvalidError, SegmentBreakInvalidError, ShiftTypeSegmentMismatchError, ShiftScheduleNotFoundError, ShiftDateOutOfPeriodError, } from "../lib/errors.generated"; import type { CreateShiftSegmentInput } from "./createShift"; export const MAX_BULK_INPUT_SIZE = 5000; export interface CreateShiftsEntryInput { date: Date; shiftType: ShiftType; shiftPatternId?: string | null; siteId?: string | null; segments?: CreateShiftSegmentInput[]; } export interface CreateShiftsInput { // One shiftSchedule for the whole batch: a generation run is one period, so its shifts share a ShiftSchedule. shiftScheduleId: string; shifts: CreateShiftsEntryInput[]; } interface ResolvedEntry { entry: CreateShiftsEntryInput; segments: ResolvedSegment[]; } /** * Function: createShifts * Description: Bulk-creates many Shift records in a single all-or-nothing transaction, sharing * per-entry validation with createShift so the two commands cannot drift on what makes a shift * valid. Referenced ShiftPatterns are batch-loaded once rather than once per entry. */ export async function run>( db: Transaction, // Custom fields are per entry: a batch writes one Shift row per entry, so each entry carries // its own extension values (the header itself is only the shared ShiftSchedule id). input: Omit & { shifts: (CreateShiftsEntryInput & CF)[] }, _ctx: CommandContext, ) { if (!input.shiftScheduleId) { return err(new MissingRequiredFieldError("shiftScheduleId")); } if (!input.shifts) { return err(new MissingRequiredFieldError("shifts")); } if (input.shifts.length === 0) { return err(new BulkInputEmptyError("createShifts")); } if (input.shifts.length > MAX_BULK_INPUT_SIZE) { return err(new BulkInputTooLargeError(`createShifts:${input.shifts.length}`)); } const shiftSchedule = await db .selectFrom("ShiftSchedule") .selectAll() .where("id", "=", input.shiftScheduleId) .executeTakeFirst(); if (!shiftSchedule) { return err(new ShiftScheduleNotFoundError(input.shiftScheduleId)); } for (const [index, entry] of input.shifts.entries()) { if (!entry.date || !entry.shiftType) { return err(new MissingRequiredFieldError(`shifts[${index}].date/shiftType`)); } if (!isWithinPeriod(entry.date, shiftSchedule.startDate, shiftSchedule.endDate)) { return err(new ShiftDateOutOfPeriodError(`shifts[${index}].${entry.date.toISOString()}`)); } } // Batch-load every referenced ShiftPattern once, rather than once per entry. const patternIds = [ ...new Set(input.shifts.map((s) => s.shiftPatternId).filter((id): id is string => !!id)), ]; const patterns = patternIds.length > 0 ? await db.selectFrom("ShiftPattern").selectAll().where("id", "in", patternIds).execute() : []; const patternById = new Map(patterns.map((p) => [p.id, p])); const resolved: ResolvedEntry[] = []; for (const [index, entry] of input.shifts.entries()) { let segments: ResolvedSegment[] = (entry.segments ?? []).map((s) => ({ plannedStartAt: s.plannedStartAt, plannedEndAt: s.plannedEndAt, breakMinutes: s.breakMinutes, })); if (entry.shiftPatternId) { const pattern = patternById.get(entry.shiftPatternId); if (!pattern) { return err(new ShiftPatternNotFoundError(`shifts[${index}].${entry.shiftPatternId}`)); } if (segments.length === 0) { const patternSegments = [...(pattern.segments ?? [])].sort( (a, b) => a.sequence - b.sequence, ); segments = patternSegments.map((seg) => resolvePatternSegment(entry.date, seg)); } } if (segments.length === 0) { return err(new SegmentRequiredError(`shifts[${index}]`)); } const validation = validateSegments(segments); if (!validation.ok) { if (validation.error === "BREAK") { return err(new SegmentBreakInvalidError(`shifts[${index}]`)); } return err(new SegmentGapInvalidError(`shifts[${index}]`)); } if (!isShiftTypeConsistent(entry.shiftType, segments.length)) { return err(new ShiftTypeSegmentMismatchError(`shifts[${index}].${entry.shiftType}`)); } resolved.push({ entry, segments: sortSegments(segments) }); } // Validation succeeded for every entry: insert all rows in one transaction (all-or-nothing). const values = resolved.map(({ entry, segments }) => { const { date, shiftType, shiftPatternId = null, siteId = null, segments: _inputSegments, ...entryCustomFields } = entry; return { ...(entryCustomFields as Record), shiftScheduleId: input.shiftScheduleId, date, shiftType, cancelledAt: null, plannedStartAt: segments[0].plannedStartAt, plannedEndAt: segments[segments.length - 1].plannedEndAt, shiftPatternId, siteId, segments: segments.map((seg, index) => ({ sequence: index + 1, plannedStartAt: seg.plannedStartAt, plannedEndAt: seg.plannedEndAt, breakMinutes: seg.breakMinutes, })), }; }); const shifts = await db.insertInto("Shift").values(values).returningAll().execute(); return ok({ shifts }); }