import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { EffectiveStartNotAdvancingError, InvalidEmploymentTypeReferenceError, InvalidWorkRegimeReferenceError, WorkerEmploymentNotFoundError, } from "../lib/errors.generated"; export type UpdateWorkerEmploymentInput = { id: string; } & { /** EmploymentType catalog id; must be an ACTIVE entry in the employment's company. */ employmentTypeId?: string; /** WorkRegime catalog id; must be an ACTIVE entry in the employment's company. */ workRegimeId?: string; effectiveStart: Date; }; // Builtin columns of WorkerEmployment; any other key on a fetched row is a custom field (CF). const WORKER_EMPLOYMENT_BUILTIN_COLUMNS = new Set([ "id", "workerId", "companyId", "employmentTypeId", "workRegimeId", "hireDate", "terminationDate", "effectiveStart", "effectiveEnd", "versionOf", "createdAt", "updatedAt", ]); /** * Function: updateWorkerEmployment * Description: Records a change to employment type or work type as a new * effective-dated generation — closes the current open generation (effectiveEnd * = new effectiveStart - 1 day) and inserts a new generation sharing the same * versionOf, rather than patching the row in place. Custom fields carry forward * from the closed generation and are overridden by any provided in the input. */ export async function run>( db: Transaction, input: UpdateWorkerEmploymentInput & Partial, _ctx: CommandContext, ) { const { id, employmentTypeId, workRegimeId, effectiveStart, ...inputCustomFields } = input; const current = await db .selectFrom("WorkerEmployment") .selectAll() .where("id", "=", id) .forUpdate() .executeTakeFirst(); if (!current) return err(new WorkerEmploymentNotFoundError(id)); // Only the open generation can be superseded, and the new generation must start strictly after // the current one. Otherwise closing the current generation at `effectiveStart - 1` would invert // its range (effectiveEnd < effectiveStart) or overlap the successor of an already-closed // generation — both break the non-overlap invariant (M01). if ( current.effectiveEnd !== null || effectiveStart.getTime() <= current.effectiveStart.getTime() ) { return err(new EffectiveStartNotAdvancingError(id)); } // A changed catalog reference must exist, belong to the same company, and be ACTIVE. if (employmentTypeId !== undefined && employmentTypeId !== current.employmentTypeId) { const et = await db .selectFrom("EmploymentType") .selectAll() .where("id", "=", employmentTypeId) .executeTakeFirst(); if (!et || et.companyId !== current.companyId || et.status !== "ACTIVE") { return err(new InvalidEmploymentTypeReferenceError(employmentTypeId)); } } if (workRegimeId !== undefined && workRegimeId !== current.workRegimeId) { const wr = await db .selectFrom("WorkRegime") .selectAll() .where("id", "=", workRegimeId) .executeTakeFirst(); if (!wr || wr.companyId !== current.companyId || wr.status !== "ACTIVE") { return err(new InvalidWorkRegimeReferenceError(workRegimeId)); } } const newEffectiveEnd = new Date(effectiveStart); newEffectiveEnd.setUTCDate(newEffectiveEnd.getUTCDate() - 1); await db .updateTable("WorkerEmployment") .set({ effectiveEnd: newEffectiveEnd }) .where("id", "=", current.id) .execute(); // Carry the closed generation's custom fields onto the new generation. const carriedCustomFields: Record = {}; for (const [key, value] of Object.entries(current as Record)) { if (!WORKER_EMPLOYMENT_BUILTIN_COLUMNS.has(key)) { carriedCustomFields[key] = value; } } const inserted = await db .insertInto("WorkerEmployment") .values({ // Carried-forward CF first, then input CF; explicit builtin columns always win. ...carriedCustomFields, ...(inputCustomFields as Record), workerId: current.workerId, companyId: current.companyId, employmentTypeId: employmentTypeId ?? current.employmentTypeId, workRegimeId: workRegimeId ?? current.workRegimeId, hireDate: current.hireDate, terminationDate: current.terminationDate, effectiveStart, effectiveEnd: null, versionOf: current.versionOf, }) .returningAll() .executeTakeFirstOrThrow(); return ok({ workerEmployment: inserted }); }