refactor(api): extract holiday calendar resolution reads

This commit is contained in:
2026-03-31 09:16:37 +02:00
parent 5ff76e6aa2
commit 71b94d0ad1
2 changed files with 349 additions and 343 deletions
@@ -0,0 +1,345 @@
import { PreviewResolvedHolidaysSchema } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import { protectedProcedure } from "../trpc.js";
import { type HolidayReadContext } from "./holiday-calendar-shared.js";
function canManageHolidayResourceReads(ctx: HolidayReadContext): boolean {
const role = ctx.dbUser?.systemRole;
return role === "ADMIN" || role === "MANAGER";
}
async function findOwnedHolidayResourceId(ctx: HolidayReadContext): Promise<string | null> {
if (!ctx.dbUser?.id) {
return null;
}
if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") {
return null;
}
const resource = await ctx.db.resource.findFirst({
where: { userId: ctx.dbUser.id },
select: { id: true },
});
return resource?.id ?? null;
}
async function assertCanReadHolidayResource(
ctx: HolidayReadContext,
resourceId: string,
): Promise<void> {
if (canManageHolidayResourceReads(ctx)) {
return;
}
const ownedResourceId = await findOwnedHolidayResourceId(ctx);
if (!ownedResourceId || ownedResourceId !== resourceId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only view holiday data for your own resource",
});
}
}
function formatResolvedHolidayDetail(holiday: {
date: string;
name: string;
scopeType: string;
calendarName: string;
sourceType: string;
}) {
return {
date: holiday.date,
name: holiday.name,
scope: holiday.scopeType,
calendarName: holiday.calendarName,
sourceType: holiday.sourceType,
};
}
function summarizeResolvedHolidaysDetail(holidays: Array<{
date: string;
name: string;
scope: string;
calendarName: string;
sourceType: string;
}>) {
const byScope = new Map<string, number>();
const bySourceType = new Map<string, number>();
const byCalendar = new Map<string, number>();
for (const holiday of holidays) {
byScope.set(holiday.scope, (byScope.get(holiday.scope) ?? 0) + 1);
bySourceType.set(holiday.sourceType, (bySourceType.get(holiday.sourceType) ?? 0) + 1);
byCalendar.set(holiday.calendarName, (byCalendar.get(holiday.calendarName) ?? 0) + 1);
}
return {
byScope: [...byScope.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([scope, count]) => ({ scope, count })),
bySourceType: [...bySourceType.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([sourceType, count]) => ({ sourceType, count })),
byCalendar: [...byCalendar.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([calendarName, count]) => ({ calendarName, count })),
};
}
const ResolveHolidaysInputSchema = z.object({
periodStart: z.coerce.date(),
periodEnd: z.coerce.date(),
countryId: z.string().optional(),
countryCode: z.string().trim().min(1).optional(),
stateCode: z.string().trim().min(1).optional(),
metroCityId: z.string().optional(),
metroCityName: z.string().trim().min(1).optional(),
}).superRefine((input, issueCtx) => {
if (!input.countryId && !input.countryCode) {
issueCtx.addIssue({
code: z.ZodIssueCode.custom,
message: "Either countryId or countryCode is required.",
path: ["countryId"],
});
}
if (input.periodEnd < input.periodStart) {
issueCtx.addIssue({
code: z.ZodIssueCode.custom,
message: "periodEnd must be on or after periodStart.",
path: ["periodEnd"],
});
}
});
const ResolveResourceHolidaysInputSchema = z.object({
resourceId: z.string(),
periodStart: z.coerce.date(),
periodEnd: z.coerce.date(),
}).superRefine((input, issueCtx) => {
if (input.periodEnd < input.periodStart) {
issueCtx.addIssue({
code: z.ZodIssueCode.custom,
message: "periodEnd must be on or after periodStart.",
path: ["periodEnd"],
});
}
});
async function readPreviewResolvedHolidaysSnapshot(
ctx: HolidayReadContext,
input: z.infer<typeof PreviewResolvedHolidaysSchema>,
) {
const country = await findUniqueOrThrow(
ctx.db.country.findUnique({
where: { id: input.countryId },
select: { id: true, code: true, name: true },
}),
"Country",
);
const metroCity = input.metroCityId
? await ctx.db.metroCity.findUnique({
where: { id: input.metroCityId },
select: { id: true, name: true, countryId: true },
})
: null;
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`),
periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`),
countryId: input.countryId,
countryCode: country.code,
federalState: input.stateCode?.trim().toUpperCase() ?? null,
metroCityId: input.metroCityId ?? null,
metroCityName: metroCity?.name ?? null,
});
return {
locationContext: {
countryId: input.countryId,
countryCode: country.code,
stateCode: input.stateCode?.trim().toUpperCase() ?? null,
metroCityId: input.metroCityId ?? null,
metroCity: metroCity?.name ?? null,
year: input.year,
},
holidays: resolved.map((holiday) => ({
date: holiday.date,
name: holiday.name,
scopeType: holiday.scope,
calendarName: holiday.calendarName,
sourceType: holiday.sourceType,
})),
};
}
async function readResolvedHolidaysSnapshot(
ctx: HolidayReadContext,
input: z.infer<typeof ResolveHolidaysInputSchema>,
) {
let resolvedCountryCode = input.countryCode?.trim().toUpperCase() ?? null;
if (!resolvedCountryCode && input.countryId) {
const country = await findUniqueOrThrow(
ctx.db.country.findUnique({
where: { id: input.countryId },
select: { code: true },
}),
"Country",
);
resolvedCountryCode = country.code;
}
const metroCityName = input.metroCityId
? (await ctx.db.metroCity.findUnique({
where: { id: input.metroCityId },
select: { name: true },
}))?.name ?? null
: input.metroCityName?.trim() ?? null;
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: input.periodStart,
periodEnd: input.periodEnd,
countryId: input.countryId ?? null,
countryCode: resolvedCountryCode,
federalState: input.stateCode?.trim().toUpperCase() ?? null,
metroCityId: input.metroCityId ?? null,
metroCityName,
});
return {
periodStart: input.periodStart.toISOString().slice(0, 10),
periodEnd: input.periodEnd.toISOString().slice(0, 10),
locationContext: {
countryId: input.countryId ?? null,
countryCode: resolvedCountryCode,
federalState: input.stateCode?.trim().toUpperCase() ?? null,
metroCityId: input.metroCityId ?? null,
metroCity: metroCityName,
},
holidays: resolved.map((holiday) => ({
date: holiday.date,
name: holiday.name,
scopeType: holiday.scope,
calendarName: holiday.calendarName,
sourceType: holiday.sourceType,
})),
};
}
async function readResolvedResourceHolidaysSnapshot(
ctx: HolidayReadContext,
input: z.infer<typeof ResolveResourceHolidaysInputSchema>,
) {
await assertCanReadHolidayResource(ctx, input.resourceId);
const resource = await findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: {
id: true,
eid: true,
displayName: true,
federalState: true,
countryId: true,
metroCityId: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
},
}),
"Resource",
);
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: input.periodStart,
periodEnd: input.periodEnd,
countryId: resource.countryId ?? null,
countryCode: resource.country?.code ?? null,
federalState: resource.federalState ?? null,
metroCityId: resource.metroCityId ?? null,
metroCityName: resource.metroCity?.name ?? null,
});
return {
periodStart: input.periodStart.toISOString().slice(0, 10),
periodEnd: input.periodEnd.toISOString().slice(0, 10),
resource: {
id: resource.id,
eid: resource.eid,
name: resource.displayName,
country: resource.country?.name ?? resource.country?.code ?? null,
countryCode: resource.country?.code ?? null,
federalState: resource.federalState ?? null,
metroCity: resource.metroCity?.name ?? null,
},
holidays: resolved.map((holiday) => ({
date: holiday.date,
name: holiday.name,
scopeType: holiday.scope,
calendarName: holiday.calendarName,
sourceType: holiday.sourceType,
})),
};
}
export const holidayCalendarResolutionReadProcedures = {
previewResolvedHolidays: protectedProcedure
.input(PreviewResolvedHolidaysSchema)
.query(async ({ ctx, input }) => (await readPreviewResolvedHolidaysSnapshot(ctx, input)).holidays),
previewResolvedHolidaysDetail: protectedProcedure
.input(PreviewResolvedHolidaysSchema)
.query(async ({ ctx, input }) => {
const resolved = await readPreviewResolvedHolidaysSnapshot(ctx, input);
const holidays = resolved.holidays.map(formatResolvedHolidayDetail);
return {
count: holidays.length,
locationContext: resolved.locationContext,
summary: summarizeResolvedHolidaysDetail(holidays),
holidays,
};
}),
resolveHolidays: protectedProcedure
.input(ResolveHolidaysInputSchema)
.query(async ({ ctx, input }) => readResolvedHolidaysSnapshot(ctx, input)),
resolveHolidaysDetail: protectedProcedure
.input(ResolveHolidaysInputSchema)
.query(async ({ ctx, input }) => {
const resolved = await readResolvedHolidaysSnapshot(ctx, input);
const holidays = resolved.holidays.map(formatResolvedHolidayDetail);
return {
periodStart: resolved.periodStart,
periodEnd: resolved.periodEnd,
locationContext: resolved.locationContext,
count: holidays.length,
summary: summarizeResolvedHolidaysDetail(holidays),
holidays,
};
}),
resolveResourceHolidays: protectedProcedure
.input(ResolveResourceHolidaysInputSchema)
.query(async ({ ctx, input }) => readResolvedResourceHolidaysSnapshot(ctx, input)),
resolveResourceHolidaysDetail: protectedProcedure
.input(ResolveResourceHolidaysInputSchema)
.query(async ({ ctx, input }) => {
const resolved = await readResolvedResourceHolidaysSnapshot(ctx, input);
const holidays = resolved.holidays.map(formatResolvedHolidayDetail);
return {
periodStart: resolved.periodStart,
periodEnd: resolved.periodEnd,
resource: resolved.resource,
count: holidays.length,
summary: summarizeResolvedHolidaysDetail(holidays),
holidays,
};
}),
};