import { buildPaginatedResult, DEFAULT_PAGE_SIZE, err, ok, type PaginationInput, type ReadonlyDB, } from "@tailor-platform/erp-kit/core"; import type { DB } from "../generated/kysely-tailordb"; import { InvalidDateRangeError, InvalidPaginationError } from "../lib/errors.generated"; type ShiftPlacementByAssignmentOrderByField = "date" | "assignedAt"; // The listing joins Shift, so every sort column has to be qualified; callers still pass the bare // field name. `date` lives on Shift, `assignedAt` on ShiftPlacement. const ORDER_BY_COLUMNS = { id: "ShiftPlacement.id", date: "Shift.date", assignedAt: "ShiftPlacement.assignedAt", } as const; export interface ListShiftPlacementsByAssignmentInput extends PaginationInput { assignmentId: string; startDate?: Date; endDate?: Date; /** Include SUPERSEDED / CANCELLED placements (the swap history). Defaults to false. */ includeInactive?: boolean; } /** * Function: listShiftPlacementsByAssignment * Description: Returns where a given workforce Assignment is placed over a * date range, paginated — supports a worker's personal schedule view and * coverage audits, including 応援 and multi-site placements where the * Assignment's home post differs from the shift's Site. */ export async function run(db: ReadonlyDB, input: ListShiftPlacementsByAssignmentInput) { // Cross-module FK: assignmentId refers to the workforce module's Assignment. No injected // getAssignment query is wired up for this module yet, so — following createWorker.ts's // precedent (modules/workforce/command/createWorker.ts) — we do not invent a fictitious // existence check here and simply filter by the given id. if ( input.startDate !== undefined && input.endDate !== undefined && input.endDate < input.startDate ) { return err( new InvalidDateRangeError(`${input.startDate.toISOString()}..${input.endDate.toISOString()}`), ); } const limit = input.limit ?? DEFAULT_PAGE_SIZE; const offset = input.offset ?? 0; if (!Number.isInteger(limit) || limit < 1) { return err(new InvalidPaginationError(`limit=${input.limit}`)); } if (!Number.isInteger(offset) || offset < 0) { return err(new InvalidPaginationError(`offset=${input.offset}`)); } let query = db .selectFrom("ShiftPlacement") .innerJoin("Shift", "Shift.id", "ShiftPlacement.shiftId") .selectAll("ShiftPlacement") .where("ShiftPlacement.assignmentId", "=", input.assignmentId); // An effective placement is ACTIVE *and* on a slot that still exists: withdrawing a shift // does not touch its placements, so checking only the placement would keep reporting a // cancelled slot as this person's current assignment. if (input.includeInactive !== true) { query = query .where("ShiftPlacement.status", "=", "ACTIVE") .where("Shift.cancelledAt", "is", null); } if (input.startDate !== undefined) { query = query.where("Shift.date", ">=", input.startDate); } if (input.endDate !== undefined) { query = query.where("Shift.date", "<=", input.endDate); } // A personal schedule reads chronologically, so `date` stays the default rather than `id`; the // `id` tiebreaker makes the order total, which same-date placements (multi-segment days, 応援) // otherwise leave to the engine and break paging with. const shiftPlacements = await query .orderBy(ORDER_BY_COLUMNS[input.orderBy ?? "date"], input.orderDirection ?? "asc") .orderBy("ShiftPlacement.id", "asc") .limit(limit + 1) .offset(offset) .execute(); return ok(buildPaginatedResult(shiftPlacements, limit)); }