diff --git a/packages/api/src/__tests__/timeline-holiday-load-support.test.ts b/packages/api/src/__tests__/timeline-holiday-load-support.test.ts new file mode 100644 index 0000000..d29da45 --- /dev/null +++ b/packages/api/src/__tests__/timeline-holiday-load-support.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("../lib/holiday-availability.js", () => ({ + asHolidayResolverDb: vi.fn((db) => db), + getResolvedCalendarHolidays: vi.fn(), +})); + +vi.mock("../router/timeline-read-shared.js", () => ({ + loadTimelineEntriesReadModel: vi.fn(), +})); + +import { + asHolidayResolverDb, + getResolvedCalendarHolidays, +} from "../lib/holiday-availability.js"; +import { loadTimelineEntriesReadModel } from "../router/timeline-read-shared.js"; +import { + loadTimelineHolidayOverlays, + loadTimelineHolidayOverlaysForReadModel, +} from "../router/timeline-holiday-load-support.js"; + +const asHolidayResolverDbMock = vi.mocked(asHolidayResolverDb); +const getResolvedCalendarHolidaysMock = vi.mocked(getResolvedCalendarHolidays); +const loadTimelineEntriesReadModelMock = vi.mocked(loadTimelineEntriesReadModel); + +describe("timeline holiday load support", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("loads the timeline read model before building holiday overlays", async () => { + const resourceFindMany = vi + .fn() + .mockResolvedValueOnce([ + { + id: "resource_1", + countryId: "country_1", + federalState: "BY", + metroCityId: "city_1", + country: { code: "DE", name: "Germany" }, + metroCity: { name: "Munich" }, + }, + ]); + const db = { resource: { findMany: resourceFindMany } } as never; + const input = { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-07T00:00:00.000Z"), + }; + + loadTimelineEntriesReadModelMock.mockResolvedValueOnce({ + assignments: [{ resourceId: "resource_1" }], + } as never); + getResolvedCalendarHolidaysMock.mockResolvedValueOnce([ + { + date: "2026-04-03", + name: "Holiday", + scope: "COUNTRY", + calendarName: "System", + sourceType: "BUILTIN", + }, + ] as never); + + await expect(loadTimelineHolidayOverlays(db, input)).resolves.toEqual([ + expect.objectContaining({ + id: "calendar-holiday:resource_1:2026-04-03", + resourceId: "resource_1", + note: "Holiday", + }), + ]); + + expect(loadTimelineEntriesReadModelMock).toHaveBeenCalledWith(db, input); + expect(asHolidayResolverDbMock).toHaveBeenCalledWith(db); + }); + + it("returns an empty list when no resource ids can be resolved", async () => { + const resourceFindMany = vi.fn(); + const db = { resource: { findMany: resourceFindMany } } as never; + + await expect( + loadTimelineHolidayOverlaysForReadModel( + db, + { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-07T00:00:00.000Z"), + }, + { + assignments: [{ resourceId: null }], + } as never, + ), + ).resolves.toEqual([]); + + expect(resourceFindMany).not.toHaveBeenCalled(); + expect(getResolvedCalendarHolidaysMock).not.toHaveBeenCalled(); + }); + + it("resolves holidays for matching resources and returns overlays sorted by resource and date", async () => { + const resourceFindMany = vi + .fn() + .mockResolvedValueOnce([{ id: "resource_2" }]) + .mockResolvedValueOnce([ + { + id: "resource_2", + countryId: "country_2", + federalState: null, + metroCityId: null, + country: { code: "US", name: "United States" }, + metroCity: null, + }, + { + id: "resource_1", + countryId: "country_1", + federalState: "BY", + metroCityId: "city_1", + country: { code: "DE", name: "Germany" }, + metroCity: { name: "Munich" }, + }, + ]); + const db = { resource: { findMany: resourceFindMany } } as never; + + getResolvedCalendarHolidaysMock + .mockResolvedValueOnce([ + { + date: "2026-04-04", + name: "Holiday B", + scope: "COUNTRY", + calendarName: "System", + sourceType: "BUILTIN", + }, + ] as never) + .mockResolvedValueOnce([ + { + date: "2026-04-03", + name: "Holiday A", + scope: "CITY", + calendarName: "Munich", + sourceType: "CUSTOM", + }, + ] as never); + + await expect( + loadTimelineHolidayOverlaysForReadModel( + db, + { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-07T00:00:00.000Z"), + chapters: ["3D"], + }, + { + assignments: [{ resourceId: "resource_1" }], + } as never, + ), + ).resolves.toEqual([ + expect.objectContaining({ + id: "calendar-holiday:resource_1:2026-04-03", + resourceId: "resource_1", + note: "Holiday A", + countryCode: "DE", + }), + expect.objectContaining({ + id: "calendar-holiday:resource_2:2026-04-04", + resourceId: "resource_2", + note: "Holiday B", + countryCode: "US", + }), + ]); + + expect(resourceFindMany).toHaveBeenNthCalledWith(1, { + where: { chapter: { in: ["3D"] } }, + select: { id: true }, + }); + expect(resourceFindMany).toHaveBeenNthCalledWith(2, { + where: { id: { in: ["resource_1", "resource_2"] } }, + select: { + id: true, + countryId: true, + federalState: true, + metroCityId: true, + country: { select: { code: true, name: true } }, + metroCity: { select: { name: true } }, + }, + }); + }); +}); diff --git a/packages/api/src/router/timeline-holiday-load-support.ts b/packages/api/src/router/timeline-holiday-load-support.ts new file mode 100644 index 0000000..fb689de --- /dev/null +++ b/packages/api/src/router/timeline-holiday-load-support.ts @@ -0,0 +1,100 @@ +import { buildSplitAllocationReadModel } from "@capakraken/application"; +import { VacationType } from "@capakraken/db"; +import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js"; +import { + buildTimelineHolidayResourceIds, + buildTimelineHolidayResourceWhere, +} from "./timeline-holiday-support.js"; +import { + loadTimelineEntriesReadModel, + TimelineEntriesDbClient, + TimelineEntriesFilters, +} from "./timeline-read-shared.js"; + +export async function loadTimelineHolidayOverlays( + db: TimelineEntriesDbClient, + input: TimelineEntriesFilters, +) { + const readModel = await loadTimelineEntriesReadModel(db, input); + return loadTimelineHolidayOverlaysForReadModel(db, input, readModel); +} + +export async function loadTimelineHolidayOverlaysForReadModel( + db: TimelineEntriesDbClient, + input: TimelineEntriesFilters, + readModel: ReturnType, +) { + const resourceWhere = buildTimelineHolidayResourceWhere({ + chapters: input.chapters, + eids: input.eids, + countryCodes: input.countryCodes, + }); + const matchingResources = resourceWhere + ? await db.resource.findMany({ + where: resourceWhere, + select: { id: true }, + }) + : []; + const resourceIds = buildTimelineHolidayResourceIds({ + assignmentResourceIds: readModel.assignments.map((assignment) => assignment.resourceId), + requestedResourceIds: input.resourceIds, + matchingFilterResourceIds: matchingResources.map((resource) => resource.id), + }); + + if (resourceIds.length === 0) { + return []; + } + + const resources = await db.resource.findMany({ + where: { id: { in: resourceIds } }, + select: { + id: true, + countryId: true, + federalState: true, + metroCityId: true, + country: { select: { code: true, name: true } }, + metroCity: { select: { name: true } }, + }, + }); + + const overlays = await Promise.all( + resources.map(async (resource) => { + const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), { + periodStart: input.startDate, + periodEnd: input.endDate, + countryId: resource.countryId, + countryCode: resource.country?.code ?? null, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name ?? null, + }); + + return holidays.map((holiday) => { + const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`); + return { + id: `calendar-holiday:${resource.id}:${holiday.date}`, + resourceId: resource.id, + type: VacationType.PUBLIC_HOLIDAY, + status: "APPROVED" as const, + startDate: holidayDate, + endDate: holidayDate, + note: holiday.name, + scope: holiday.scope, + calendarName: holiday.calendarName, + sourceType: holiday.sourceType, + countryCode: resource.country?.code ?? null, + countryName: resource.country?.name ?? null, + federalState: resource.federalState ?? null, + metroCityName: resource.metroCity?.name ?? null, + }; + }); + }), + ); + + return overlays.flat().sort((left, right) => { + if (left.resourceId !== right.resourceId) { + return left.resourceId.localeCompare(right.resourceId); + } + return left.startDate.getTime() - right.startDate.getTime(); + }); +} diff --git a/packages/api/src/router/timeline-holiday-read.ts b/packages/api/src/router/timeline-holiday-read.ts index 141203b..a294a69 100644 --- a/packages/api/src/router/timeline-holiday-read.ts +++ b/packages/api/src/router/timeline-holiday-read.ts @@ -1,27 +1,26 @@ -import { buildSplitAllocationReadModel } from "@capakraken/application"; -import { Prisma, VacationType } from "@capakraken/db"; import { z } from "zod"; -import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js"; import { controllerProcedure, protectedProcedure } from "../trpc.js"; import { buildSelfServiceTimelineInput, createTimelineDateRange, createTimelineFilters, - fmtDate, - loadTimelineEntriesReadModel, - TimelineEntriesDbClient, - TimelineEntriesFilters, TimelineWindowFiltersSchema, } from "./timeline-read-shared.js"; +import { + loadTimelineHolidayOverlays, +} from "./timeline-holiday-load-support.js"; import { buildTimelineHolidayOverlayDetailResponse, - buildTimelineHolidayResourceIds, - buildTimelineHolidayResourceWhere, formatTimelineHolidayOverlays, summarizeTimelineHolidayOverlays, type TimelineHolidayOverlayRecord, } from "./timeline-holiday-support.js"; +export { + loadTimelineHolidayOverlays, + loadTimelineHolidayOverlaysForReadModel, +} from "./timeline-holiday-load-support.js"; + export function formatHolidayOverlays( overlays: TimelineHolidayOverlayRecord[], ) { @@ -34,94 +33,6 @@ export function summarizeHolidayOverlays( return summarizeTimelineHolidayOverlays(overlays); } -export async function loadTimelineHolidayOverlays( - db: TimelineEntriesDbClient, - input: TimelineEntriesFilters, -) { - const readModel = await loadTimelineEntriesReadModel(db, input); - return loadTimelineHolidayOverlaysForReadModel(db, input, readModel); -} - -export async function loadTimelineHolidayOverlaysForReadModel( - db: TimelineEntriesDbClient, - input: TimelineEntriesFilters, - readModel: ReturnType, -) { - const resourceWhere = buildTimelineHolidayResourceWhere({ - chapters: input.chapters, - eids: input.eids, - countryCodes: input.countryCodes, - }); - const matchingResources = resourceWhere - ? await db.resource.findMany({ - where: resourceWhere, - select: { id: true }, - }) - : []; - const resourceIds = buildTimelineHolidayResourceIds({ - assignmentResourceIds: readModel.assignments.map((assignment) => assignment.resourceId), - requestedResourceIds: input.resourceIds, - matchingFilterResourceIds: matchingResources.map((resource) => resource.id), - }); - - if (resourceIds.length === 0) { - return []; - } - - const resources = await db.resource.findMany({ - where: { id: { in: resourceIds } }, - select: { - id: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { code: true, name: true } }, - metroCity: { select: { name: true } }, - }, - }); - - const overlays = await Promise.all( - resources.map(async (resource) => { - const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), { - periodStart: input.startDate, - periodEnd: input.endDate, - countryId: resource.countryId, - countryCode: resource.country?.code ?? null, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name ?? null, - }); - - return holidays.map((holiday) => { - const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`); - return { - id: `calendar-holiday:${resource.id}:${holiday.date}`, - resourceId: resource.id, - type: VacationType.PUBLIC_HOLIDAY, - status: "APPROVED" as const, - startDate: holidayDate, - endDate: holidayDate, - note: holiday.name, - scope: holiday.scope, - calendarName: holiday.calendarName, - sourceType: holiday.sourceType, - countryCode: resource.country?.code ?? null, - countryName: resource.country?.name ?? null, - federalState: resource.federalState ?? null, - metroCityName: resource.metroCity?.name ?? null, - }; - }); - }), - ); - - return overlays.flat().sort((left, right) => { - if (left.resourceId !== right.resourceId) { - return left.resourceId.localeCompare(right.resourceId); - } - return left.startDate.getTime() - right.startDate.getTime(); - }); -} - export const timelineHolidayReadProcedures = { getHolidayOverlays: controllerProcedure .input(TimelineWindowFiltersSchema)