import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { isShiftTypeConsistent, sortSegments, validateSegments, type ResolvedSegment, type ShiftType, } from "../lib/_shiftSegments"; import { ShiftNotFoundError, ShiftNotEditableError, ShiftAlreadyCancelledError, SegmentRequiredError, SegmentGapInvalidError, SegmentBreakInvalidError, ShiftTypeSegmentMismatchError, } from "../lib/errors.generated"; export interface UpdateShiftSegmentInput { plannedStartAt: Date; plannedEndAt: Date; breakMinutes: number; } export type UpdateShiftInput = { id: string; shiftType?: ShiftType; // Cross-module FK (organization::Site); referential integrity is left to the DB-level FK // constraint set up via type injection in module.ts. Staffing is not editable here — it lives // on ShiftPlacement, which stays changeable after the shiftSchedule is confirmed. siteId?: string | null; segments?: UpdateShiftSegmentInput[]; }; export async function run>( db: Transaction, input: UpdateShiftInput & Partial, _ctx: CommandContext, ) { const { id, shiftType: shiftTypeInput, siteId, segments, ...customFields } = input; const shift = await db .selectFrom("Shift") .selectAll() .where("id", "=", id) .forUpdate() .executeTakeFirst(); if (!shift) { return err(new ShiftNotFoundError(id)); } if (shift.cancelledAt !== null) { return err(new ShiftAlreadyCancelledError(id)); } // Editability is the shiftSchedule's property, not the shift's: a DRAFT period is a working draft, // a CONFIRMED one is what workers are already reading. This is the check that used to be // `shift.status !== "PLANNED"`. // // Locked, not merely read: without the lock, confirmShiftSchedule can commit the period between this // check and the UPDATE below, letting an edit land on a shiftSchedule workers are already reading. const shiftSchedule = await db .selectFrom("ShiftSchedule") .selectAll() .where("id", "=", shift.shiftScheduleId) .forUpdate() .executeTakeFirst(); if (!shiftSchedule || shiftSchedule.status !== "DRAFT") { return err(new ShiftNotEditableError(id)); } const effectiveShiftType = shiftTypeInput ?? shift.shiftType; let plannedStartAt = shift.plannedStartAt; let plannedEndAt = shift.plannedEndAt; let nextSegments = shift.segments ?? []; let effectiveSegmentCount: number; if (segments !== undefined) { if (segments.length === 0) { return err(new SegmentRequiredError(id)); } const resolved: ResolvedSegment[] = segments.map((s) => ({ plannedStartAt: s.plannedStartAt, plannedEndAt: s.plannedEndAt, breakMinutes: s.breakMinutes, })); const validation = validateSegments(resolved); if (!validation.ok) { if (validation.error === "BREAK") { return err(new SegmentBreakInvalidError(id)); } return err(new SegmentGapInvalidError(id)); } effectiveSegmentCount = resolved.length; if (!isShiftTypeConsistent(effectiveShiftType, effectiveSegmentCount)) { return err(new ShiftTypeSegmentMismatchError(effectiveShiftType)); } const sorted = sortSegments(resolved); plannedStartAt = sorted[0].plannedStartAt; plannedEndAt = sorted[sorted.length - 1].plannedEndAt; // Replace the whole embedded array (add / reorder / update / remove), re-sequenced in time order nextSegments = sorted.map((seg, index) => ({ sequence: index + 1, plannedStartAt: seg.plannedStartAt, plannedEndAt: seg.plannedEndAt, breakMinutes: seg.breakMinutes, })); } else { effectiveSegmentCount = nextSegments.length; if (!isShiftTypeConsistent(effectiveShiftType, effectiveSegmentCount)) { return err(new ShiftTypeSegmentMismatchError(effectiveShiftType)); } } // Strip reserved model columns from the caller's custom fields before writing. The db layer's // NoReservedFields guard covers typed callers, but a resolver forwarding an untyped payload // could otherwise reach a builtin this command does not expose — `cancelledAt` (withdrawal is // cancelShift's job), `date`, or the parent `shiftScheduleId`. const RESERVED_KEYS = new Set([ "id", "shiftScheduleId", "date", "shiftType", "cancelledAt", "plannedStartAt", "plannedEndAt", "shiftPatternId", "siteId", "segments", "createdAt", "updatedAt", ]); const safeCustomFields: Record = {}; for (const [key, value] of Object.entries(customFields as Record)) { if (!RESERVED_KEYS.has(key)) { safeCustomFields[key] = value; } } const updated = await db .updateTable("Shift") .set({ ...safeCustomFields, shiftType: effectiveShiftType, siteId: siteId !== undefined ? siteId : shift.siteId, plannedStartAt, plannedEndAt, segments: nextSegments, }) .where("id", "=", id) .returningAll() .executeTakeFirstOrThrow(); return ok({ shift: updated }); }