import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { ShiftPatternNotFoundError, InvalidKindError, SegmentRequiredError, SegmentKindMismatchError, SegmentTimeInvalidError, SegmentBreakInvalidError, SegmentGapInvalidError, } from "../lib/errors.generated"; // Must stay in step with db/shiftPattern.ts and createShiftPattern: OFF (ε…¬δΌ‘) is a creatable // kind, so it has to be a reachable target for an update too. const SHIFT_PATTERN_KINDS = ["DAY", "EARLY", "LATE", "NIGHT", "ON_CALL", "OFF"] as const; type ShiftPatternKind = (typeof SHIFT_PATTERN_KINDS)[number]; export interface UpdateShiftPatternSegmentInput { startTime: number; endTime: number; breakMinutes: number; } export interface UpdateShiftPatternInput { shiftPatternId: string; name?: string; kind?: string; segments?: UpdateShiftPatternSegmentInput[]; } /** * Function: updateShiftPattern * Description: Adjusts a ShiftPattern's kind, display name, or its full * segment set in place. ShiftPattern is reference/master data, not * effective-dated β€” the edit applies immediately to the template. `code` * is immutable and is not part of this command's input. Existing Shifts * already instantiated from this pattern are unaffected, since Shifts * copy segment data independently at creation time. */ export async function run>( db: Transaction, input: UpdateShiftPatternInput & Partial, _ctx: CommandContext, ) { const { shiftPatternId, name, kind: kindInput, segments: segmentsInput, ...customFields } = input; const shiftPattern = await db .selectFrom("ShiftPattern") .selectAll() .where("id", "=", shiftPatternId) .forUpdate() .executeTakeFirst(); if (!shiftPattern) { return err(new ShiftPatternNotFoundError(shiftPatternId)); } if (kindInput !== undefined && !(SHIFT_PATTERN_KINDS as readonly string[]).includes(kindInput)) { return err(new InvalidKindError(kindInput)); } let preparedSegments: | { sequence: number; startTime: number; endTime: number; breakMinutes: number; spansMidnight: boolean; }[] | undefined; if (segmentsInput !== undefined) { // An empty replacement set is not rejected outright: it is how a pattern becomes OFF. Whether // zero segments is admissible depends on the resulting kind, checked after this block. const segments: { sequence: number; startTime: number; endTime: number; breakMinutes: number; spansMidnight: boolean; }[] = []; for (let index = 0; index < segmentsInput.length; index++) { const { startTime, endTime, breakMinutes } = segmentsInput[index]; if (startTime < 0 || startTime >= 1440 || endTime < 0 || endTime >= 1440) { return err(new SegmentTimeInvalidError(`segment[${index}]`)); } const spansMidnight = endTime <= startTime; const grossSpan = spansMidnight ? 1440 - startTime + endTime : endTime - startTime; if (breakMinutes < 0 || breakMinutes >= grossSpan) { return err(new SegmentBreakInvalidError(`segment[${index}]`)); } segments.push({ sequence: index + 1, startTime, endTime, breakMinutes, spansMidnight }); } for (let i = 0; i < segments.length - 1; i++) { const current = segments[i]; const next = segments[i + 1]; const gap = current.spansMidnight ? next.startTime - current.endTime : next.startTime >= current.endTime ? next.startTime - current.endTime : 1440 - current.endTime + next.startTime; if (gap <= 0) { return err(new SegmentGapInvalidError(`segment[${i}]-segment[${i + 1}]`)); } } preparedSegments = segments; } // The invariant is on the *resulting* record, not on the patch: kind and segments can each be // omitted, so validate the post-update pair. Without this, patching only `kind` on an OFF // pattern produced a DAY pattern with zero segments, and patching only `kind` to OFF left the // old segments in place β€” both breaking "empty only when kind = OFF" (docs/model/ShiftPattern.md). const resultingKind = (kindInput ?? shiftPattern.kind) as ShiftPatternKind; const resultingSegmentCount = preparedSegments !== undefined ? preparedSegments.length : shiftPattern.segments.length; if (resultingKind === "OFF") { if (resultingSegmentCount > 0) { return err(new SegmentKindMismatchError(shiftPatternId)); } } else if (resultingSegmentCount === 0) { return err(new SegmentRequiredError(shiftPatternId)); } // Strip reserved model columns from the caller's custom fields before writing. `code` is the // one that matters most: it is documented immutable and is not in the explicit patch below, so // an untyped payload could otherwise rename a pattern's stable key through this channel. const RESERVED_KEYS = new Set([ "id", "code", "scopeId", "name", "kind", "segments", "createdAt", "updatedAt", ]); const safeCustomFields: Record = {}; for (const [key, value] of Object.entries(customFields as Record)) { if (!RESERVED_KEYS.has(key)) { safeCustomFields[key] = value; } } // Replace the whole embedded segments array (add / reorder / update / remove) when supplied, // together with any name/kind change, in a single ShiftPattern update. const updates: { name?: string; kind?: ShiftPatternKind; segments?: { sequence: number; startTime: number; endTime: number; breakMinutes: number; spansMidnight: boolean; }[]; } = {}; if (name !== undefined) updates.name = name; if (kindInput !== undefined) updates.kind = kindInput as ShiftPatternKind; if (preparedSegments !== undefined) { updates.segments = preparedSegments.map((segment) => ({ sequence: segment.sequence, startTime: segment.startTime, endTime: segment.endTime, spansMidnight: segment.spansMidnight, breakMinutes: segment.breakMinutes, })); } let updatedShiftPattern = shiftPattern; // Custom fields count as a change of their own: a caller patching only extension fields must // not be treated as a no-op update. if (Object.keys(updates).length > 0 || Object.keys(safeCustomFields).length > 0) { updatedShiftPattern = await db .updateTable("ShiftPattern") .set({ ...safeCustomFields, ...updates }) .where("id", "=", shiftPatternId) .returningAll() .executeTakeFirstOrThrow(); } return ok({ shiftPattern: updatedShiftPattern }); }