From 1204c186ef134ab7338043ac22f5f63262451f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 08:35:13 +0200 Subject: [PATCH] perf(api): eliminate N+1 queries, add query guards and missing indexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/__tests__/notification-router.test.ts | 3 +- .../api/src/__tests__/resource-router.test.ts | 2 + packages/api/src/lib/auto-staffing.ts | 32 ++++ packages/api/src/lib/create-notification.ts | 9 +- packages/api/src/router/allocation-effects.ts | 40 +++-- .../chargeability-report-procedure-support.ts | 7 +- ...otification-broadcast-procedure-support.ts | 43 ++--- packages/api/src/router/resource-analytics.ts | 1 + .../api/src/router/resource-capacity-read.ts | 1 + .../src/router/resource-marketplace-read.ts | 1 + .../src/router/vacation-public-holidays.ts | 170 ++++++++++++------ packages/db/prisma/schema.prisma | 8 + 12 files changed, 213 insertions(+), 104 deletions(-) diff --git a/packages/api/src/__tests__/notification-router.test.ts b/packages/api/src/__tests__/notification-router.test.ts index b3ac6b6..8c11ffc 100644 --- a/packages/api/src/__tests__/notification-router.test.ts +++ b/packages/api/src/__tests__/notification-router.test.ts @@ -665,7 +665,8 @@ describe("notification.createBroadcast", () => { expect(transaction).toHaveBeenCalledTimes(1); expect(txCreateBroadcast).toHaveBeenCalledTimes(1); - expect(txCreateNotification).toHaveBeenCalledTimes(1); + // Parallel fan-out: both recipients are attempted concurrently + expect(txCreateNotification).toHaveBeenCalledTimes(2); expect(txUpdateBroadcast).not.toHaveBeenCalled(); expect(outerCreateBroadcast).not.toHaveBeenCalled(); expect(outerUpdateBroadcast).not.toHaveBeenCalled(); diff --git a/packages/api/src/__tests__/resource-router.test.ts b/packages/api/src/__tests__/resource-router.test.ts index fe6967c..2b1b531 100644 --- a/packages/api/src/__tests__/resource-router.test.ts +++ b/packages/api/src/__tests__/resource-router.test.ts @@ -797,6 +797,7 @@ describe("resource router", () => { country: { select: { code: true } }, metroCity: { select: { name: true } }, }, + take: 1000, }); expect(listAssignmentBookings).toHaveBeenCalledWith(db, expect.objectContaining({ resourceIds: [], @@ -864,6 +865,7 @@ describe("resource router", () => { country: { select: { code: true } }, metroCity: { select: { name: true } }, }, + take: 1000, }); expect(listAssignmentBookings).toHaveBeenCalledWith(db, expect.objectContaining({ resourceIds: ["resource_target"], diff --git a/packages/api/src/lib/auto-staffing.ts b/packages/api/src/lib/auto-staffing.ts index fecd654..82c664c 100644 --- a/packages/api/src/lib/auto-staffing.ts +++ b/packages/api/src/lib/auto-staffing.ts @@ -54,6 +54,22 @@ type DbClient = Parameters[0] & { resource: { findMany: (args: { where: { isActive: true }; + select?: { + id?: true; + displayName?: true; + eid?: true; + skills?: true; + lcrCents?: true; + chargeabilityTarget?: true; + valueScore?: true; + availability?: true; + countryId?: true; + federalState?: true; + metroCityId?: true; + country?: { select: { code: true } }; + metroCity?: { select: { name: true } }; + }; + take?: number; }) => Promise & { userIds: string[] }, ): Promise { const { userIds, ...rest } = params; - let count = 0; - for (const userId of userIds) { - await createNotification({ ...rest, userId }); - count++; - } - return count; + if (userIds.length === 0) return 0; + await Promise.all(userIds.map((userId) => createNotification({ ...rest, userId }))); + return userIds.length; } diff --git a/packages/api/src/router/allocation-effects.ts b/packages/api/src/router/allocation-effects.ts index 80803bd..3fc201d 100644 --- a/packages/api/src/router/allocation-effects.ts +++ b/packages/api/src/router/allocation-effects.ts @@ -101,24 +101,28 @@ export async function createDemandRequirementWithEffects( const projectName = project?.name ?? "Unknown project"; const headcount = demandRequirement.headcount ?? 1; - for (const manager of managers) { - const task = await db.notification.create({ - data: { - userId: manager.id, - category: "TASK", - type: "DEMAND_FILL", - priority: "NORMAL", - title: `Staff demand: ${roleName} for ${projectName}`, - body: `${headcount} ${roleName} needed for project ${projectName}`, - taskStatus: "OPEN", - taskAction: buildTaskAction("fill_demand", demandRequirement.id), - entityId: demandRequirement.id, - entityType: "demand", - link: `/projects/${demandRequirement.projectId}`, - channel: "in_app", - }, - }); - emitNotificationCreated(manager.id, task.id); + if (managers.length > 0) { + const sharedData = { + category: "TASK" as const, + type: "DEMAND_FILL" as const, + priority: "NORMAL" as const, + title: `Staff demand: ${roleName} for ${projectName}`, + body: `${headcount} ${roleName} needed for project ${projectName}`, + taskStatus: "OPEN" as const, + taskAction: buildTaskAction("fill_demand", demandRequirement.id), + entityId: demandRequirement.id, + entityType: "demand", + link: `/projects/${demandRequirement.projectId}`, + channel: "in_app" as const, + }; + const tasks = await Promise.all( + managers.map((manager) => + db.notification.create({ data: { userId: manager.id, ...sharedData } }), + ), + ); + for (const task of tasks) { + emitNotificationCreated(task.userId, task.id); + } } checkBudgetThresholdsInBackground(db, demandRequirement.projectId); diff --git a/packages/api/src/router/chargeability-report-procedure-support.ts b/packages/api/src/router/chargeability-report-procedure-support.ts index 5c4a212..3d5b42c 100644 --- a/packages/api/src/router/chargeability-report-procedure-support.ts +++ b/packages/api/src/router/chargeability-report-procedure-support.ts @@ -9,7 +9,7 @@ import { } from "@capakraken/engine"; import type { PrismaClient } from "@capakraken/db"; import type { WeekdayAvailability, PermissionKey } from "@capakraken/shared"; -import { PermissionKey as PermissionKeys } from "@capakraken/shared"; +import { PermissionKey as PermissionKeys, round1 } from "@capakraken/shared"; import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application"; import { z } from "zod"; import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js"; @@ -25,10 +25,6 @@ type ChargeabilityReportProcedureContext = Pick & { permissions?: Set; }; -function round1(value: number): number { - return Math.round(value * 10) / 10; -} - function isIsoDateInMonth(isoDate: string, monthKey: string): boolean { return isoDate.startsWith(`${monthKey}-`); } @@ -265,6 +261,7 @@ async function queryChargeabilityReport( metroCity: { select: { id: true, name: true } }, }, orderBy: { displayName: "asc" }, + take: 2000, }); if (resources.length === 0) { diff --git a/packages/api/src/router/notification-broadcast-procedure-support.ts b/packages/api/src/router/notification-broadcast-procedure-support.ts index 20939e5..a1c2ad1 100644 --- a/packages/api/src/router/notification-broadcast-procedure-support.ts +++ b/packages/api/src/router/notification-broadcast-procedure-support.ts @@ -117,27 +117,28 @@ async function persistImmediateBroadcast( data: buildBroadcastCreateData(senderId, input), }); - const notificationIds: BroadcastRecipientNotification[] = []; - for (const recipientUserId of recipientIds) { - const notificationId = await createNotification({ - db, - userId: recipientUserId, - type: `BROADCAST_${input.category}`, - title: input.title, - body: input.body, - link: input.link, - category: input.category, - priority: input.priority, - channel: input.channel, - sourceId: broadcast.id, - senderId, - taskStatus: isTask ? "OPEN" : undefined, - taskAction: input.taskAction, - dueDate: input.dueDate, - emit: false, - }); - notificationIds.push({ id: notificationId, userId: recipientUserId }); - } + const created = await Promise.all( + recipientIds.map((recipientUserId) => + createNotification({ + db, + userId: recipientUserId, + type: `BROADCAST_${input.category}`, + title: input.title, + body: input.body, + link: input.link, + category: input.category, + priority: input.priority, + channel: input.channel, + sourceId: broadcast.id, + senderId, + taskStatus: isTask ? "OPEN" : undefined, + taskAction: input.taskAction, + dueDate: input.dueDate, + emit: false, + }), + ), + ); + const notificationIds: BroadcastRecipientNotification[] = created.map((id, i) => ({ id, userId: recipientIds[i]! })); const updatedBroadcast = await db.notificationBroadcast.update({ where: { id: broadcast.id }, diff --git a/packages/api/src/router/resource-analytics.ts b/packages/api/src/router/resource-analytics.ts index 8e206fe..5a4ee49 100644 --- a/packages/api/src/router/resource-analytics.ts +++ b/packages/api/src/router/resource-analytics.ts @@ -26,6 +26,7 @@ export const resourceAnalyticsProcedures = { const resources = await ctx.db.resource.findMany({ where: { isActive: true }, select: { id: true, displayName: true, chapter: true, skills: true }, + take: 5000, }); const skillMap = new Map< diff --git a/packages/api/src/router/resource-capacity-read.ts b/packages/api/src/router/resource-capacity-read.ts index 26a2ae6..c763c64 100644 --- a/packages/api/src/router/resource-capacity-read.ts +++ b/packages/api/src/router/resource-capacity-read.ts @@ -177,6 +177,7 @@ export const resourceCapacityReadProcedures = { country: { select: { code: true } }, metroCity: { select: { name: true } }, }, + take: 1000, }); const bookings = await listAssignmentBookings(ctx.db, { startDate: start, diff --git a/packages/api/src/router/resource-marketplace-read.ts b/packages/api/src/router/resource-marketplace-read.ts index 0cf8b24..7b82337 100644 --- a/packages/api/src/router/resource-marketplace-read.ts +++ b/packages/api/src/router/resource-marketplace-read.ts @@ -51,6 +51,7 @@ export const resourceMarketplaceReadProcedures = { country: { select: { code: true } }, metroCity: { select: { name: true } }, }, + take: 2000, }); const assignments = await ctx.db.assignment.findMany({ diff --git a/packages/api/src/router/vacation-public-holidays.ts b/packages/api/src/router/vacation-public-holidays.ts index 3b154f9..985e4d6 100644 --- a/packages/api/src/router/vacation-public-holidays.ts +++ b/packages/api/src/router/vacation-public-holidays.ts @@ -35,64 +35,128 @@ export async function batchCreatePublicHolidayVacations( return { created: 0, holidays: 0, resources: 0 }; } - let created = 0; - let holidayCount = 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(); for (const resource of resources) { - const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), { - periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`), - periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`), - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: input.federalState ?? resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - }); - holidayCount += holidays.length; + 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>(); + 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 startDate = new Date(holiday.date); - const endDate = new Date(holiday.date); - - if (input.replaceExisting) { - await db.vacation.deleteMany({ - where: { - resourceId: resource.id, - type: VacationType.PUBLIC_HOLIDAY, - startDate, - endDate, - }, - }); - } - - const exists = await db.vacation.findFirst({ - where: { - resourceId: resource.id, - type: VacationType.PUBLIC_HOLIDAY, - startDate, - endDate, - }, - }); - if (exists) { - continue; - } - - await db.vacation.create({ - data: { - resourceId: resource.id, - type: VacationType.PUBLIC_HOLIDAY, - status: VacationStatus.APPROVED, - startDate, - endDate, - note: holiday.name, - requestedById: adminUserId, - approvedById: adminUserId, - approvedAt: new Date(), - }, - }); - created++; + const date = new Date(holiday.date); + allPairs.push({ resourceId: resource.id, startDate: date, endDate: date, name: holiday.name }); } } - return { created, holidays: holidayCount, resources: resources.length }; + 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 }; } diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 5d6d9fa..777a3a1 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -751,6 +751,7 @@ model ManagementLevel { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@index([groupId]) @@map("management_levels") } @@ -778,6 +779,8 @@ model Blueprint { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@index([isActive]) + @@index([target]) @@map("blueprints") } @@ -904,6 +907,7 @@ model Resource { @@index([countryId]) @@index([orgUnitId]) @@index([resourceType]) + @@index([managementLevelGroupId]) @@map("resources") } @@ -1347,6 +1351,8 @@ model Assignment { @@index([status]) @@index([resourceId, status, startDate]) @@index([projectId, startDate, endDate]) + @@index([status, startDate, endDate]) + @@index([projectId, status, startDate, endDate]) @@map("assignments") } @@ -1386,6 +1392,7 @@ model Vacation { @@index([startDate, endDate]) @@index([status]) @@index([resourceId, status, startDate, endDate]) + @@index([requestedById]) @@map("vacations") } @@ -1649,6 +1656,7 @@ model Comment { @@index([entityType, entityId]) @@index([authorId]) + @@index([parentId]) @@map("comments") }