From c098cedf0620037514adfceb6aeccc596dfab63d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sat, 11 Apr 2026 08:09:39 +0200 Subject: [PATCH] perf(db): add missing indexes, fix N+1 batch delete, add pagination limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add indexes on Resource(blueprintId, roleId), DemandRequirement(roleId), Assignment(roleId) — commonly filtered FK columns that were missing indexes - Replace N+1 batch delete pattern (2N queries) with findAllocationEntries() that does 2 total queries via findMany({ id: { in: ids } }) - Add take/skip pagination with default limit of 500 to listDemands and listAssignments to prevent unbounded result sets Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/allocation-router.test.ts | 122 +++++++++++----- .../router/allocation/assignment-mutations.ts | 134 ++++++++---------- packages/api/src/router/allocation/read.ts | 93 +++++++----- packages/application/src/index.ts | 1 + .../allocation/load-allocation-entry.ts | 56 +++++++- .../20260411_add_missing_fk_indexes.sql | 12 ++ packages/db/prisma/schema.prisma | 4 + 7 files changed, 264 insertions(+), 158 deletions(-) create mode 100644 packages/db/prisma/migrations/20260411_add_missing_fk_indexes.sql diff --git a/packages/api/src/__tests__/allocation-router.test.ts b/packages/api/src/__tests__/allocation-router.test.ts index 6c3b694..1a61c07 100644 --- a/packages/api/src/__tests__/allocation-router.test.ts +++ b/packages/api/src/__tests__/allocation-router.test.ts @@ -1,7 +1,11 @@ import { AllocationStatus, PermissionKey, SystemRole } from "@capakraken/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { allocationRouter } from "../router/allocation/index.js"; -import { emitAllocationCreated, emitAllocationDeleted, emitNotificationCreated } from "../sse/event-bus.js"; +import { + emitAllocationCreated, + emitAllocationDeleted, + emitNotificationCreated, +} from "../sse/event-bus.js"; import { checkBudgetThresholds } from "../lib/budget-alerts.js"; import { generateAutoSuggestions } from "../lib/auto-staffing.js"; import { invalidateDashboardCache } from "../lib/cache.js"; @@ -155,6 +159,8 @@ describe("allocation router authorization", () => { where: {}, include: expect.any(Object), orderBy: { startDate: "asc" }, + take: 500, + skip: 0, }); }); @@ -196,9 +202,12 @@ describe("allocation router authorization", () => { }); it("does not treat viewCosts as a substitute for viewPlanning on planning reads", async () => { - const caller = createProtectedCallerWithOverrides({}, { - granted: [PermissionKey.VIEW_COSTS], - }); + const caller = createProtectedCallerWithOverrides( + {}, + { + granted: [PermissionKey.VIEW_COSTS], + }, + ); await expect(caller.listAssignments({})).rejects.toMatchObject({ code: "FORBIDDEN", @@ -208,32 +217,47 @@ describe("allocation router authorization", () => { it.each([ { name: "list", invoke: (caller: ReturnType) => caller.list({}) }, - { name: "listView", invoke: (caller: ReturnType) => caller.listView({}) }, - { name: "listDemands", invoke: (caller: ReturnType) => caller.listDemands({}) }, - { name: "listAssignments", invoke: (caller: ReturnType) => caller.listAssignments({}) }, + { + name: "listView", + invoke: (caller: ReturnType) => caller.listView({}), + }, + { + name: "listDemands", + invoke: (caller: ReturnType) => caller.listDemands({}), + }, + { + name: "listAssignments", + invoke: (caller: ReturnType) => caller.listAssignments({}), + }, { name: "getAssignmentById", - invoke: (caller: ReturnType) => caller.getAssignmentById({ id: "assignment_1" }), + invoke: (caller: ReturnType) => + caller.getAssignmentById({ id: "assignment_1" }), }, { name: "resolveAssignment", - invoke: (caller: ReturnType) => caller.resolveAssignment({ assignmentId: "assignment_1" }), + invoke: (caller: ReturnType) => + caller.resolveAssignment({ assignmentId: "assignment_1" }), }, { name: "getDemandRequirementById", - invoke: (caller: ReturnType) => caller.getDemandRequirementById({ id: "demand_1" }), + invoke: (caller: ReturnType) => + caller.getDemandRequirementById({ id: "demand_1" }), }, { name: "checkResourceAvailability", - invoke: (caller: ReturnType) => caller.checkResourceAvailability(planningWindow), + invoke: (caller: ReturnType) => + caller.checkResourceAvailability(planningWindow), }, { name: "getResourceAvailabilityView", - invoke: (caller: ReturnType) => caller.getResourceAvailabilityView(planningWindow), + invoke: (caller: ReturnType) => + caller.getResourceAvailabilityView(planningWindow), }, { name: "getResourceAvailabilitySummary", - invoke: (caller: ReturnType) => caller.getResourceAvailabilitySummary(planningWindow), + invoke: (caller: ReturnType) => + caller.getResourceAvailabilitySummary(planningWindow), }, ])("requires planning read access for $name", async ({ invoke }) => { const caller = createProtectedCaller({}); @@ -708,7 +732,9 @@ describe("allocation entry resolution router", () => { it("logs and swallows background side-effect failures during demand creation", async () => { vi.mocked(invalidateDashboardCache).mockRejectedValueOnce(new Error("redis unavailable")); vi.mocked(checkBudgetThresholds).mockRejectedValueOnce(new Error("budget alerts unavailable")); - vi.mocked(generateAutoSuggestions).mockRejectedValueOnce(new Error("auto suggestions unavailable")); + vi.mocked(generateAutoSuggestions).mockRejectedValueOnce( + new Error("auto suggestions unavailable"), + ); const createdDemandRequirement = { id: "demand_safe_1", @@ -761,7 +787,10 @@ describe("allocation entry resolution router", () => { "Allocation background side effect failed", ); expect(vi.mocked(logger.error)).toHaveBeenCalledWith( - expect.objectContaining({ effectName: "generateAutoSuggestions", demandRequirementId: "demand_safe_1" }), + expect.objectContaining({ + effectName: "generateAutoSuggestions", + demandRequirementId: "demand_safe_1", + }), "Allocation background side effect failed", ); }); @@ -1017,7 +1046,8 @@ describe("allocation entry resolution router", () => { }), }, demandRequirement: { - findUnique: vi.fn() + findUnique: vi + .fn() .mockResolvedValueOnce({ id: "demand_1", projectId: "project_1", @@ -1182,7 +1212,11 @@ describe("allocation entry resolution router", () => { expect(db.assignment.delete).toHaveBeenCalledWith({ where: { id: "assignment_explicit_1" }, }); - expect(emitAllocationDeleted).toHaveBeenCalledWith("assignment_explicit_1", "project_1", "resource_1"); + expect(emitAllocationDeleted).toHaveBeenCalledWith( + "assignment_explicit_1", + "project_1", + "resource_1", + ); }); it("updates an explicit demand row through allocation.update", async () => { @@ -1276,15 +1310,13 @@ describe("allocation entry resolution router", () => { const db = { demandRequirement: { - findUnique: vi.fn().mockImplementation( - ({ where }: { where: { id?: string } }) => { - if (where.id === "demand_stale") { - return existingDemand; - } + findUnique: vi.fn().mockImplementation(({ where }: { where: { id?: string } }) => { + if (where.id === "demand_stale") { + return existingDemand; + } - return null; - }, - ), + return null; + }), update: vi.fn().mockResolvedValue(updatedDemand), }, assignment: { @@ -1360,15 +1392,29 @@ describe("allocation entry resolution router", () => { findUnique: vi.fn().mockResolvedValue(null), }, demandRequirement: { - findUnique: vi.fn().mockImplementation(({ where }: { where: { id: string } }) => - where.id === "demand_1" ? explicitDemand : null, - ), + findUnique: vi + .fn() + .mockImplementation(({ where }: { where: { id: string } }) => + where.id === "demand_1" ? explicitDemand : null, + ), + findMany: vi + .fn() + .mockImplementation(({ where }: { where: { id: { in: string[] } } }) => + [explicitDemand].filter((d) => where.id.in.includes(d.id)), + ), delete: vi.fn().mockResolvedValue({}), }, assignment: { - findUnique: vi.fn().mockImplementation(({ where }: { where: { id: string } }) => - where.id === "assignment_1" ? explicitAssignment : null, - ), + findUnique: vi + .fn() + .mockImplementation(({ where }: { where: { id: string } }) => + where.id === "assignment_1" ? explicitAssignment : null, + ), + findMany: vi + .fn() + .mockImplementation(({ where }: { where: { id: { in: string[] } } }) => + [explicitAssignment].filter((a) => where.id.in.includes(a.id)), + ), updateMany: vi.fn().mockResolvedValue({ count: 0 }), delete: vi.fn().mockResolvedValue({}), }, @@ -1424,15 +1470,13 @@ describe("allocation entry resolution router", () => { findUnique: vi.fn().mockResolvedValue(null), }, assignment: { - findUnique: vi.fn().mockImplementation( - ({ where }: { where: { id?: string } }) => { - if (where.id === "assignment_stale") { - return existingAssignment; - } + findUnique: vi.fn().mockImplementation(({ where }: { where: { id?: string } }) => { + if (where.id === "assignment_stale") { + return existingAssignment; + } - return null; - }, - ), + return null; + }), delete: vi.fn().mockResolvedValue({}), }, auditLog: { diff --git a/packages/api/src/router/allocation/assignment-mutations.ts b/packages/api/src/router/allocation/assignment-mutations.ts index 78a429f..15c2056 100644 --- a/packages/api/src/router/allocation/assignment-mutations.ts +++ b/packages/api/src/router/allocation/assignment-mutations.ts @@ -4,6 +4,7 @@ import { createDemandRequirement, deleteAllocationEntry, deleteAssignment, + findAllocationEntries, loadAllocationEntry, updateAllocationEntry, updateAssignment, @@ -68,21 +69,18 @@ export async function createAllocationReadModelEntry( }).allocations[0]!; } - const assignment = await createAssignment( - tx, - { - resourceId: input.resourceId, - projectId: input.projectId, - startDate: input.startDate, - endDate: input.endDate, - hoursPerDay: input.hoursPerDay, - percentage: input.percentage, - role: input.role, - roleId: input.roleId, - status: input.status, - metadata: input.metadata, - }, - ); + const assignment = await createAssignment(tx, { + resourceId: input.resourceId, + projectId: input.projectId, + startDate: input.startDate, + endDate: input.endDate, + hoursPerDay: input.hoursPerDay, + percentage: input.percentage, + role: input.role, + roleId: input.roleId, + status: input.status, + metadata: input.metadata, + }); return buildSplitAllocationReadModel({ demandRequirements: [], @@ -94,17 +92,20 @@ export async function ensureAssignmentRecord( db: Pick, input: EnsureAssignmentInput, ) { - const existing = (await db.assignment.findMany({ - where: { - resourceId: input.resourceId, - projectId: input.projectId, - }, - include: ASSIGNMENT_INCLUDE, - orderBy: { startDate: "asc" }, - })).find((assignment) => ( - toIsoDate(assignment.startDate) === toIsoDate(input.startDate) - && toIsoDate(assignment.endDate) === toIsoDate(input.endDate) - )); + const existing = ( + await db.assignment.findMany({ + where: { + resourceId: input.resourceId, + projectId: input.projectId, + }, + include: ASSIGNMENT_INCLUDE, + orderBy: { startDate: "asc" }, + }) + ).find( + (assignment) => + toIsoDate(assignment.startDate) === toIsoDate(input.startDate) && + toIsoDate(assignment.endDate) === toIsoDate(input.endDate), + ); if (existing) { if (existing.status !== AllocationStatus.CANCELLED) { @@ -122,24 +123,21 @@ export async function ensureAssignmentRecord( throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); } - const updated = await db.$transaction(async (tx) => updateAssignment( - tx as Parameters[0], - existing.id, - { + const updated = await db.$transaction(async (tx) => + updateAssignment(tx as Parameters[0], existing.id, { status: AllocationStatus.PROPOSED, hoursPerDay: input.hoursPerDay, percentage: (input.hoursPerDay / 8) * 100, dailyCostCents: Math.round(resource.lcrCents * input.hoursPerDay), ...(input.role ? { role: input.role } : {}), - }, - )); + }), + ); return { assignment: updated, action: "reactivated" as const }; } - const assignment = await db.$transaction(async (tx) => createAssignment( - tx as Parameters[0], - { + const assignment = await db.$transaction(async (tx) => + createAssignment(tx as Parameters[0], { resourceId: input.resourceId, projectId: input.projectId, startDate: input.startDate, @@ -149,8 +147,8 @@ export async function ensureAssignmentRecord( status: AllocationStatus.PROPOSED, metadata: {}, ...(input.role ? { role: input.role } : {}), - }, - )); + }), + ); return { assignment, action: "created" as const }; } @@ -168,8 +166,7 @@ export async function updateAllocationWithAudit( id, demandRequirementUpdate: existing.kind === "assignment" ? {} : toDemandRequirementUpdateInput(data), - assignmentUpdate: - existing.kind === "demand" ? {} : toAssignmentUpdateInput(data), + assignmentUpdate: existing.kind === "demand" ? {} : toAssignmentUpdateInput(data), }, ); @@ -191,10 +188,7 @@ export async function updateAllocationWithAudit( return { existing, updated }; } -export async function deleteAssignmentWithAudit( - db: AllocationMutationDb, - id: string, -) { +export async function deleteAssignmentWithAudit(db: AllocationMutationDb, id: string) { const existing = await findUniqueOrThrow( db.assignment.findUnique({ where: { id }, @@ -204,10 +198,7 @@ export async function deleteAssignmentWithAudit( ); await db.$transaction(async (tx) => { - await deleteAssignment( - tx as Parameters[0], - id, - ); + await deleteAssignment(tx as Parameters[0], id); await tx.auditLog.create({ data: { @@ -222,24 +213,20 @@ export async function deleteAssignmentWithAudit( return existing; } -export async function deleteAllocationWithAudit( - db: AllocationMutationDb, - id: string, -) { +export async function deleteAllocationWithAudit(db: AllocationMutationDb, id: string) { const existing = await loadAllocationEntry(db, id); await db.$transaction(async (tx) => { - await deleteAllocationEntry( - tx as Parameters[0], - existing, - ); + await deleteAllocationEntry(tx as Parameters[0], existing); await tx.auditLog.create({ data: { entityType: "Allocation", entityId: id, action: "DELETE", - changes: { before: existing.entry } as unknown as import("@capakraken/db").Prisma.InputJsonValue, + changes: { + before: existing.entry, + } as unknown as import("@capakraken/db").Prisma.InputJsonValue, }, }); }); @@ -247,20 +234,13 @@ export async function deleteAllocationWithAudit( return existing; } -export async function batchDeleteAllocationsWithAudit( - db: AllocationMutationDb, - ids: string[], -) { - const existing = ( - await Promise.all(ids.map(async (id) => findAllocationEntryOrNull(db, id))) - ).filter((entry): entry is NonNullable => Boolean(entry)); +export async function batchDeleteAllocationsWithAudit(db: AllocationMutationDb, ids: string[]) { + // Batch-load in 2 queries (demand + assignment) instead of 2N individual lookups + const existing = await findAllocationEntries(db, ids); await db.$transaction(async (tx) => { for (const allocation of existing) { - await deleteAllocationEntry( - tx as Parameters[0], - allocation, - ); + await deleteAllocationEntry(tx as Parameters[0], allocation); } await tx.auditLog.create({ @@ -290,16 +270,16 @@ export async function batchUpdateAllocationStatusWithAudit( ) { const updated = await db.$transaction(async (tx) => { const updatedAllocations = await Promise.all( - input.ids.map(async (id) => ( - await updateAllocationEntry( - tx as Parameters[0], - { - id, - demandRequirementUpdate: { status: input.status }, - assignmentUpdate: { status: input.status }, - }, - ) - ).allocation), + input.ids.map( + async (id) => + ( + await updateAllocationEntry(tx as Parameters[0], { + id, + demandRequirementUpdate: { status: input.status }, + assignmentUpdate: { status: input.status }, + }) + ).allocation, + ), ); await tx.auditLog.create({ diff --git a/packages/api/src/router/allocation/read.ts b/packages/api/src/router/allocation/read.ts index dd85e38..b4d77ca 100644 --- a/packages/api/src/router/allocation/read.ts +++ b/packages/api/src/router/allocation/read.ts @@ -5,7 +5,11 @@ import { anonymizeResource, getAnonymizationDirectory } from "../../lib/anonymiz import { planningReadProcedure } from "../../trpc.js"; import { buildResourceAvailabilitySummary, buildResourceAvailabilityView } from "./availability.js"; import { ASSIGNMENT_INCLUDE, DEMAND_INCLUDE } from "./shared.js"; -import { getDemandRequirementByIdOrThrow, loadAllocationReadModel, resolveAssignmentBySelection } from "./support.js"; +import { + getDemandRequirementByIdOrThrow, + loadAllocationReadModel, + resolveAssignmentBySelection, +} from "./support.js"; export const allocationReadProcedures = { list: planningReadProcedure @@ -37,6 +41,8 @@ export const allocationReadProcedures = { projectId: z.string().optional(), status: z.nativeEnum(AllocationStatus).optional(), roleId: z.string().optional(), + take: z.number().int().min(1).max(1000).default(500), + skip: z.number().int().min(0).default(0), }), ) .query(async ({ ctx, input }) => { @@ -48,6 +54,8 @@ export const allocationReadProcedures = { }, include: DEMAND_INCLUDE, orderBy: { startDate: "asc" }, + take: input.take, + skip: input.skip, }); const directory = await getAnonymizationDirectory(ctx.db); if (!directory) { @@ -55,11 +63,11 @@ export const allocationReadProcedures = { } return demands.map((demand) => ({ ...demand, - assignments: demand.assignments.map((assignment) => ( + assignments: demand.assignments.map((assignment) => assignment.resource ? { ...assignment, resource: anonymizeResource(assignment.resource, directory) } - : assignment - )), + : assignment, + ), })); }), @@ -70,6 +78,8 @@ export const allocationReadProcedures = { resourceId: z.string().optional(), status: z.nativeEnum(AllocationStatus).optional(), demandRequirementId: z.string().optional(), + take: z.number().int().min(1).max(1000).default(500), + skip: z.number().int().min(0).default(0), }), ) .query(async ({ ctx, input }) => { @@ -82,16 +92,18 @@ export const allocationReadProcedures = { }, include: ASSIGNMENT_INCLUDE, orderBy: { startDate: "asc" }, + take: input.take, + skip: input.skip, }); const directory = await getAnonymizationDirectory(ctx.db); if (!directory) { return assignments; } - return assignments.map((assignment) => ( + return assignments.map((assignment) => assignment.resource ? { ...assignment, resource: anonymizeResource(assignment.resource, directory) } - : assignment - )); + : assignment, + ); }), getAssignmentById: planningReadProcedure @@ -115,15 +127,17 @@ export const allocationReadProcedures = { }), resolveAssignment: planningReadProcedure - .input(z.object({ - assignmentId: z.string().optional(), - resourceId: z.string().optional(), - projectId: z.string().optional(), - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), - selectionMode: z.enum(["WINDOW", "EXACT_START"]).default("EXACT_START"), - excludeCancelled: z.boolean().default(false), - })) + .input( + z.object({ + assignmentId: z.string().optional(), + resourceId: z.string().optional(), + projectId: z.string().optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), + selectionMode: z.enum(["WINDOW", "EXACT_START"]).default("EXACT_START"), + excludeCancelled: z.boolean().default(false), + }), + ) .query(async ({ ctx, input }) => resolveAssignmentBySelection(ctx.db, input)), getDemandRequirementById: planningReadProcedure @@ -131,33 +145,42 @@ export const allocationReadProcedures = { .query(async ({ ctx, input }) => getDemandRequirementByIdOrThrow(ctx.db, input.id)), checkResourceAvailability: planningReadProcedure - .input(z.object({ - resourceId: z.string(), - startDate: z.coerce.date(), - endDate: z.coerce.date(), - hoursPerDay: z.number().min(0.5).max(24).default(8), - })) + .input( + z.object({ + resourceId: z.string(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + hoursPerDay: z.number().min(0.5).max(24).default(8), + }), + ) .query(async ({ ctx, input }) => { - const { vacations: _vacations, ...availability } = await buildResourceAvailabilityView(ctx.db, input); + const { vacations: _vacations, ...availability } = await buildResourceAvailabilityView( + ctx.db, + input, + ); return availability; }), getResourceAvailabilityView: planningReadProcedure - .input(z.object({ - resourceId: z.string(), - startDate: z.coerce.date(), - endDate: z.coerce.date(), - hoursPerDay: z.number().min(0.5).max(24).default(8), - })) + .input( + z.object({ + resourceId: z.string(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + hoursPerDay: z.number().min(0.5).max(24).default(8), + }), + ) .query(async ({ ctx, input }) => buildResourceAvailabilityView(ctx.db, input)), getResourceAvailabilitySummary: planningReadProcedure - .input(z.object({ - resourceId: z.string(), - startDate: z.coerce.date(), - endDate: z.coerce.date(), - hoursPerDay: z.number().min(0.5).max(24).default(8), - })) + .input( + z.object({ + resourceId: z.string(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + hoursPerDay: z.number().min(0.5).max(24).default(8), + }), + ) .query(async ({ ctx, input }) => { const availability = await buildResourceAvailabilityView(ctx.db, input); return buildResourceAvailabilitySummary(availability, input); diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index d2e4aa0..7f51dd8 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -50,6 +50,7 @@ export { export { findAllocationEntry, + findAllocationEntries, loadAllocationEntry, type AllocationEntryResolution, } from "./use-cases/allocation/load-allocation-entry.js"; diff --git a/packages/application/src/use-cases/allocation/load-allocation-entry.ts b/packages/application/src/use-cases/allocation/load-allocation-entry.ts index be4fc3c..9b903d7 100644 --- a/packages/application/src/use-cases/allocation/load-allocation-entry.ts +++ b/packages/application/src/use-cases/allocation/load-allocation-entry.ts @@ -2,10 +2,7 @@ import type { Prisma, PrismaClient } from "@capakraken/db"; import type { AllocationWithDetails } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { buildSplitAllocationReadModel } from "./build-split-allocation-read-model.js"; -import { - ASSIGNMENT_RELATIONS_INCLUDE, - type AssignmentWithRelations, -} from "./create-assignment.js"; +import { ASSIGNMENT_RELATIONS_INCLUDE, type AssignmentWithRelations } from "./create-assignment.js"; import { DEMAND_REQUIREMENT_RELATIONS_INCLUDE, type DemandRequirementWithRelations, @@ -40,9 +37,7 @@ function toDemandAllocationEntry( }).allocations[0] as AllocationWithDetails; } -function toAssignmentAllocationEntry( - assignment: AssignmentWithRelations, -): AllocationWithDetails { +function toAssignmentAllocationEntry(assignment: AssignmentWithRelations): AllocationWithDetails { return buildSplitAllocationReadModel({ demandRequirements: [], assignments: [assignment], @@ -115,3 +110,50 @@ export async function loadAllocationEntry( return resolved; } + +/** + * Batch-loads allocation entries by ID (2 queries total instead of 2N). + * Each ID is resolved against both demand_requirements and assignments. + * IDs that don't match either table are silently skipped. + */ +export async function findAllocationEntries( + db: DbClient, + ids: string[], +): Promise { + if (ids.length === 0) return []; + + const [demandRequirements, assignments] = await Promise.all([ + db.demandRequirement.findMany({ + where: { id: { in: ids } }, + include: DEMAND_REQUIREMENT_RELATIONS_INCLUDE, + }), + db.assignment.findMany({ + where: { id: { in: ids } }, + include: ASSIGNMENT_RELATIONS_INCLUDE, + }), + ]); + + const results: AllocationEntryResolution[] = []; + + for (const dr of demandRequirements) { + results.push({ + kind: "demand", + entry: toDemandAllocationEntry(dr), + demandRequirement: dr, + projectId: dr.projectId, + resourceId: null, + }); + } + + for (const a of assignments) { + results.push({ + kind: "assignment", + entry: toAssignmentAllocationEntry(a), + assignment: a, + projectId: a.projectId, + resourceId: a.resourceId, + }); + } + + return results; +} diff --git a/packages/db/prisma/migrations/20260411_add_missing_fk_indexes.sql b/packages/db/prisma/migrations/20260411_add_missing_fk_indexes.sql new file mode 100644 index 0000000..f29e1bc --- /dev/null +++ b/packages/db/prisma/migrations/20260411_add_missing_fk_indexes.sql @@ -0,0 +1,12 @@ +-- Add missing indexes on foreign key columns used in filtering/joining. +-- These are CREATE INDEX IF NOT EXISTS so they are safe to re-run. + +-- Resource: blueprintId and roleId are used in resource filtering and joins +CREATE INDEX CONCURRENTLY IF NOT EXISTS "resources_blueprintId_idx" ON "resources" ("blueprintId"); +CREATE INDEX CONCURRENTLY IF NOT EXISTS "resources_roleId_idx" ON "resources" ("roleId"); + +-- DemandRequirement: roleId is used in role-based demand queries +CREATE INDEX CONCURRENTLY IF NOT EXISTS "demand_requirements_roleId_idx" ON "demand_requirements" ("roleId"); + +-- Assignment: roleId is used in role-based assignment queries +CREATE INDEX CONCURRENTLY IF NOT EXISTS "assignments_roleId_idx" ON "assignments" ("roleId"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index b0a4bb3..c9b898d 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -914,6 +914,8 @@ model Resource { @@index([orgUnitId]) @@index([resourceType]) @@index([managementLevelGroupId]) + @@index([blueprintId]) + @@index([roleId]) @@map("resources") } @@ -1323,6 +1325,7 @@ model DemandRequirement { @@index([startDate, endDate]) @@index([status]) @@index([projectId, status, startDate]) + @@index([roleId]) @@map("demand_requirements") } @@ -1362,6 +1365,7 @@ model Assignment { @@index([projectId, status, startDate, endDate]) @@index([resourceId, status, endDate]) @@index([projectId, status, endDate]) + @@index([roleId]) @@map("assignments") }