Files
CapaKraken/packages/api/src/router/vacation-public-holidays.ts
T
Hartmut 1204c186ef perf(api): eliminate N+1 queries, add query guards and missing indexes
- Notification fan-out: replace sequential for loops with Promise.all (allocation-effects, notification-broadcast, create-notification)
- Public holiday batch: group resources by location combo, resolve holidays once per group, replace per-holiday delete/findFirst/create with 3 batched queries (~18K → ~5 queries)
- Add take guards to unbounded findMany calls (resource-analytics: 5000, resource-marketplace: 2000, resource-capacity: 1000, chargeability-report: 2000)
- auto-staffing: add select with only needed fields + take: 5000
- schema.prisma: add 5 missing indexes (ManagementLevel.groupId, Blueprint.isActive/target, Comment.parentId, Vacation.requestedById, Resource.managementLevelGroupId)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 08:35:13 +02:00

163 lines
5.1 KiB
TypeScript

import { VacationStatus, VacationType } from "@capakraken/db";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import type { TRPCContext } from "../trpc.js";
type VacationDb = TRPCContext["db"];
export type BatchCreatePublicHolidaysInput = {
year: number;
federalState?: string | undefined;
chapter?: string | undefined;
replaceExisting: boolean;
};
export async function batchCreatePublicHolidayVacations(
db: VacationDb,
input: BatchCreatePublicHolidaysInput,
adminUserId: string,
): Promise<{ created: number; holidays: number; resources: number }> {
const resources = await db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
select: {
id: true,
federalState: true,
countryId: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
});
if (resources.length === 0) {
return { created: 0, holidays: 0, resources: 0 };
}
const periodStart = new Date(`${input.year}-01-01T00:00:00.000Z`);
const periodEnd = new Date(`${input.year}-12-31T00:00:00.000Z`);
// Step 1: Group resources by their holiday-resolution key so we call
// getResolvedCalendarHolidays once per unique location combo instead of
// once per resource (~5-10 calls vs. 500+).
type HolidayGroup = {
countryId: string | null;
countryCode: string | null | undefined;
federalState: string | null | undefined;
metroCityId: string | null;
metroCityName: string | null | undefined;
resourceIds: string[];
};
const groups = new Map<string, HolidayGroup>();
for (const resource of resources) {
const effectiveFederalState = input.federalState ?? resource.federalState;
const key = [
resource.countryId ?? "",
resource.country?.code ?? "",
effectiveFederalState ?? "",
resource.metroCityId ?? "",
].join("|");
if (!groups.has(key)) {
groups.set(key, {
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: effectiveFederalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
resourceIds: [],
});
}
groups.get(key)!.resourceIds.push(resource.id);
}
// Step 2: Resolve holidays once per group (parallel).
const resourceHolidays = new Map<string, Array<{ date: string; name: string }>>();
await Promise.all(
[...groups.values()].map(async (group) => {
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), {
periodStart,
periodEnd,
countryId: group.countryId,
countryCode: group.countryCode,
federalState: group.federalState,
metroCityId: group.metroCityId,
metroCityName: group.metroCityName,
});
for (const resourceId of group.resourceIds) {
resourceHolidays.set(resourceId, holidays);
}
}),
);
// Step 3: Build the full list of (resourceId, date) pairs.
const allPairs: Array<{ resourceId: string; startDate: Date; endDate: Date; name: string }> = [];
for (const resource of resources) {
const holidays = resourceHolidays.get(resource.id) ?? [];
for (const holiday of holidays) {
const date = new Date(holiday.date);
allPairs.push({ resourceId: resource.id, startDate: date, endDate: date, name: holiday.name });
}
}
const holidayCount = allPairs.length;
if (holidayCount === 0) {
return { created: 0, holidays: 0, resources: resources.length };
}
const resourceIds = resources.map((r) => r.id);
// Step 4: Batch delete for the whole period if replaceExisting (1 query).
if (input.replaceExisting) {
await db.vacation.deleteMany({
where: {
resourceId: { in: resourceIds },
type: VacationType.PUBLIC_HOLIDAY,
startDate: { gte: periodStart, lte: periodEnd },
},
});
}
// Step 5: Batch fetch all existing entries for the period (1 query).
const existing = await db.vacation.findMany({
where: {
resourceId: { in: resourceIds },
type: VacationType.PUBLIC_HOLIDAY,
startDate: { gte: periodStart, lte: periodEnd },
},
select: { resourceId: true, startDate: true },
});
const existingSet = new Set(
existing.map((e) => `${e.resourceId}::${new Date(e.startDate).toISOString()}`),
);
// Step 6: createMany for all new entries (1 query).
const toCreate = allPairs.filter(
(p) => !existingSet.has(`${p.resourceId}::${p.startDate.toISOString()}`),
);
if (toCreate.length > 0) {
const now = new Date();
await db.vacation.createMany({
data: toCreate.map((p) => ({
resourceId: p.resourceId,
type: VacationType.PUBLIC_HOLIDAY,
status: VacationStatus.APPROVED,
startDate: p.startDate,
endDate: p.endDate,
note: p.name,
requestedById: adminUserId,
approvedById: adminUserId,
approvedAt: now,
})),
skipDuplicates: true,
});
}
return { created: toCreate.length, holidays: holidayCount, resources: resources.length };
}