import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { AssignmentNotEffectiveError, WorkScheduleNotFoundError, InvalidEffectiveDateError, NegativeMinutesError, } from "../lib/errors.generated"; export interface UpdateWorkScheduleInput { assignmentId: string; effectiveStart: Date; scheduledDailyMinutes: number; scheduledWeeklyMinutes: number; } // Whether `date` falls within [start, end] on a whole-day granularity; a null end is open-ended. function isEffectiveOn(date: Date, start: Date, end: Date | null): boolean { const day = (d: Date) => Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); const on = day(date); return day(start) <= on && (end === null || day(end) >= on); } // Builtin columns of WorkSchedule; any other key on a fetched row is a custom field (CF). const WORK_SCHEDULE_BUILTIN_COLUMNS = new Set([ "id", "assignmentId", "scheduledDailyMinutes", "scheduledWeeklyMinutes", "effectiveStart", "effectiveEnd", "versionOf", "createdAt", "updatedAt", ]); /** * Function: run * Description: Records a scheduled-minutes change for an Assignment as a new * effective-dated generation (ADR-013) — closes the current open generation * (effectiveEnd = effectiveStart(new) - 1 day) and inserts a new generation * sharing the same versionOf, rather than patching the row in place. */ export async function run>( db: Transaction, input: UpdateWorkScheduleInput & Partial, _ctx: CommandContext, ) { const { assignmentId, effectiveStart, scheduledDailyMinutes, scheduledWeeklyMinutes, ...inputCustomFields } = input; const current = await db .selectFrom("WorkSchedule") .selectAll() .where("assignmentId", "=", assignmentId) .where("effectiveEnd", "is", null) .forUpdate() .executeTakeFirst(); if (!current) return err(new WorkScheduleNotFoundError(assignmentId)); if (!(effectiveStart > current.effectiveStart)) { return err(new InvalidEffectiveDateError(assignmentId)); } if (scheduledDailyMinutes < 0 || scheduledWeeklyMinutes < 0) { return err(new NegativeMinutesError(assignmentId)); } // The successor generation must start on a date the Assignment still covers — the check the // cross-module version could not make, now a plain read from the same schema. const assignment = await db .selectFrom("Assignment") .selectAll() .where("id", "=", assignmentId) .executeTakeFirst(); if ( !assignment || !isEffectiveOn(effectiveStart, assignment.effectiveStart, assignment.effectiveEnd) ) { return err(new AssignmentNotEffectiveError(assignmentId)); } const newEffectiveEnd = new Date(effectiveStart); newEffectiveEnd.setUTCDate(newEffectiveEnd.getUTCDate() - 1); await db .updateTable("WorkSchedule") .set({ effectiveEnd: newEffectiveEnd }) .where("id", "=", current.id) .execute(); // Carry the closed generation's custom fields onto the new generation, since a revision // supersedes the whole row rather than patching it; input values win. const carriedCustomFields: Record = {}; for (const [key, value] of Object.entries(current as Record)) { if (!WORK_SCHEDULE_BUILTIN_COLUMNS.has(key)) { carriedCustomFields[key] = value; } } // The same column set strips reserved keys out of the caller's custom fields: the db layer's // NoReservedFields guard covers typed callers, but a resolver forwarding an untyped payload // must not reach a builtin column through the extension channel. const safeInputCustomFields: Record = {}; for (const [key, value] of Object.entries(inputCustomFields as Record)) { if (!WORK_SCHEDULE_BUILTIN_COLUMNS.has(key)) { safeInputCustomFields[key] = value; } } const inserted = await db .insertInto("WorkSchedule") .values({ ...carriedCustomFields, ...safeInputCustomFields, assignmentId, scheduledDailyMinutes, scheduledWeeklyMinutes, effectiveStart, effectiveEnd: null, versionOf: current.versionOf, }) .returningAll() .executeTakeFirstOrThrow(); return ok({ workSchedule: inserted }); }