refactor(api): extract timeline holiday load support
This commit is contained in:
@@ -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 } },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof buildSplitAllocationReadModel>,
|
||||
) {
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -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<typeof buildSplitAllocationReadModel>,
|
||||
) {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user