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 { z } from "zod";
|
||||||
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
|
|
||||||
import { controllerProcedure, protectedProcedure } from "../trpc.js";
|
import { controllerProcedure, protectedProcedure } from "../trpc.js";
|
||||||
import {
|
import {
|
||||||
buildSelfServiceTimelineInput,
|
buildSelfServiceTimelineInput,
|
||||||
createTimelineDateRange,
|
createTimelineDateRange,
|
||||||
createTimelineFilters,
|
createTimelineFilters,
|
||||||
fmtDate,
|
|
||||||
loadTimelineEntriesReadModel,
|
|
||||||
TimelineEntriesDbClient,
|
|
||||||
TimelineEntriesFilters,
|
|
||||||
TimelineWindowFiltersSchema,
|
TimelineWindowFiltersSchema,
|
||||||
} from "./timeline-read-shared.js";
|
} from "./timeline-read-shared.js";
|
||||||
|
import {
|
||||||
|
loadTimelineHolidayOverlays,
|
||||||
|
} from "./timeline-holiday-load-support.js";
|
||||||
import {
|
import {
|
||||||
buildTimelineHolidayOverlayDetailResponse,
|
buildTimelineHolidayOverlayDetailResponse,
|
||||||
buildTimelineHolidayResourceIds,
|
|
||||||
buildTimelineHolidayResourceWhere,
|
|
||||||
formatTimelineHolidayOverlays,
|
formatTimelineHolidayOverlays,
|
||||||
summarizeTimelineHolidayOverlays,
|
summarizeTimelineHolidayOverlays,
|
||||||
type TimelineHolidayOverlayRecord,
|
type TimelineHolidayOverlayRecord,
|
||||||
} from "./timeline-holiday-support.js";
|
} from "./timeline-holiday-support.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
loadTimelineHolidayOverlays,
|
||||||
|
loadTimelineHolidayOverlaysForReadModel,
|
||||||
|
} from "./timeline-holiday-load-support.js";
|
||||||
|
|
||||||
export function formatHolidayOverlays(
|
export function formatHolidayOverlays(
|
||||||
overlays: TimelineHolidayOverlayRecord[],
|
overlays: TimelineHolidayOverlayRecord[],
|
||||||
) {
|
) {
|
||||||
@@ -34,94 +33,6 @@ export function summarizeHolidayOverlays(
|
|||||||
return summarizeTimelineHolidayOverlays(overlays);
|
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 = {
|
export const timelineHolidayReadProcedures = {
|
||||||
getHolidayOverlays: controllerProcedure
|
getHolidayOverlays: controllerProcedure
|
||||||
.input(TimelineWindowFiltersSchema)
|
.input(TimelineWindowFiltersSchema)
|
||||||
|
|||||||
Reference in New Issue
Block a user