From 3ea36296b99488579628abfb28a447c9a33606e2 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 17:52:22 +0200 Subject: [PATCH] feat: per-category finalist slot quotas with confirmed-count guard --- src/server/routers/_app.ts | 4 + src/server/routers/finalist.ts | 64 +++++++++++ tests/unit/finalist-quotas.test.ts | 163 +++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 src/server/routers/finalist.ts create mode 100644 tests/unit/finalist-quotas.test.ts diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts index c396514..1e07ad5 100644 --- a/src/server/routers/_app.ts +++ b/src/server/routers/_app.ts @@ -49,6 +49,8 @@ import { roundEngineRouter } from './roundEngine' import { roundAssignmentRouter } from './roundAssignment' import { deliberationRouter } from './deliberation' import { resultLockRouter } from './resultLock' +// Grand-finale logistics +import { finalistRouter } from './finalist' /** * Root tRPC router that combines all domain routers @@ -104,6 +106,8 @@ export const appRouter = router({ roundAssignment: roundAssignmentRouter, deliberation: deliberationRouter, resultLock: resultLockRouter, + // Grand-finale logistics + finalist: finalistRouter, }) export type AppRouter = typeof appRouter diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts new file mode 100644 index 0000000..c969b12 --- /dev/null +++ b/src/server/routers/finalist.ts @@ -0,0 +1,64 @@ +import { z } from 'zod' +import { TRPCError } from '@trpc/server' +import { CompetitionCategory } from '@prisma/client' +import { router, adminProcedure } from '../trpc' +import { logAudit } from '../utils/audit' + +export const finalistRouter = router({ + /** + * Set the finalist slot quota for a category in a program. Mutable mid-flight, + * but blocked when reducing below the count of already-CONFIRMED finalists in + * that category — admin must un-confirm a team first. + */ + setQuota: adminProcedure + .input( + z.object({ + programId: z.string(), + category: z.nativeEnum(CompetitionCategory), + quota: z.number().int().min(0).max(100), + }), + ) + .mutation(async ({ ctx, input }) => { + const confirmedCount = await ctx.prisma.finalistConfirmation.count({ + where: { + project: { programId: input.programId }, + category: input.category, + status: 'CONFIRMED', + }, + }) + if (input.quota < confirmedCount) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Cannot reduce ${input.category} quota to ${input.quota} — ${confirmedCount} teams have already confirmed. Un-confirm one team first, then retry.`, + }) + } + const quota = await ctx.prisma.finalistSlotQuota.upsert({ + where: { + programId_category: { + programId: input.programId, + category: input.category, + }, + }, + create: { + programId: input.programId, + category: input.category, + quota: input.quota, + }, + update: { quota: input.quota }, + }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'FINALIST_QUOTA_SET', + entityType: 'FinalistSlotQuota', + entityId: quota.id, + detailsJson: { + programId: input.programId, + category: input.category, + quota: input.quota, + previousConfirmedCount: confirmedCount, + }, + }) + return quota + }), +}) diff --git a/tests/unit/finalist-quotas.test.ts b/tests/unit/finalist-quotas.test.ts new file mode 100644 index 0000000..192a7df --- /dev/null +++ b/tests/unit/finalist-quotas.test.ts @@ -0,0 +1,163 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, + createTestProgram, + createTestProject, + cleanupTestData, + uid, +} from '../helpers' +import { finalistRouter } from '../../src/server/routers/finalist' + +describe('finalist.setQuota', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await prisma.attendingMember.deleteMany({ + where: { confirmation: { project: { programId } } }, + }) + await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } }) + await prisma.waitlistEntry.deleteMany({ where: { programId } }) + await prisma.finalistSlotQuota.deleteMany({ where: { programId } }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('creates a new quota row', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `quota-${uid()}` }) + programIds.push(program.id) + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const q = await caller.setQuota({ + programId: program.id, + category: 'STARTUP', + quota: 3, + }) + expect(q.quota).toBe(3) + expect(q.category).toBe('STARTUP') + }) + + it('updates an existing quota row', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `quota-update-${uid()}` }) + programIds.push(program.id) + await prisma.finalistSlotQuota.create({ + data: { programId: program.id, category: 'STARTUP', quota: 3 }, + }) + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const q = await caller.setQuota({ programId: program.id, category: 'STARTUP', quota: 5 }) + expect(q.quota).toBe(5) + }) + + it('blocks decreasing quota below confirmed count', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `quota-block-${uid()}` }) + programIds.push(program.id) + // 3 confirmed Startup finalists + for (let i = 0; i < 3; i++) { + const project = await createTestProject(program.id, { + title: `Confirmed ${i}`, + competitionCategory: 'STARTUP', + }) + await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: 'CONFIRMED', + deadline: new Date(Date.now() + 86400000), + token: `tok_block_${uid()}_${i}`, + confirmedAt: new Date(), + }, + }) + } + await prisma.finalistSlotQuota.create({ + data: { programId: program.id, category: 'STARTUP', quota: 3 }, + }) + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + await expect( + caller.setQuota({ programId: program.id, category: 'STARTUP', quota: 2 }), + ).rejects.toThrow(/3 teams have already confirmed/i) + }) + + it('allows decreasing if confirmed count already fits', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `quota-decrease-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { + title: 'One', + competitionCategory: 'STARTUP', + }) + await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: 'CONFIRMED', + deadline: new Date(Date.now() + 86400000), + token: `tok_dec_${uid()}`, + confirmedAt: new Date(), + }, + }) + await prisma.finalistSlotQuota.create({ + data: { programId: program.id, category: 'STARTUP', quota: 3 }, + }) + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const q = await caller.setQuota({ programId: program.id, category: 'STARTUP', quota: 1 }) + expect(q.quota).toBe(1) + }) + + it('only counts CONFIRMED status, not PENDING/DECLINED/EXPIRED', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `quota-status-${uid()}` }) + programIds.push(program.id) + const statuses = ['PENDING', 'DECLINED', 'EXPIRED'] as const + for (let i = 0; i < statuses.length; i++) { + const project = await createTestProject(program.id, { + title: `Status ${statuses[i]}`, + competitionCategory: 'STARTUP', + }) + await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: statuses[i], + deadline: new Date(Date.now() + 86400000), + token: `tok_status_${uid()}_${i}`, + }, + }) + } + const caller = createCaller(finalistRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + // 0 confirmed → quota of 0 should succeed + const q = await caller.setQuota({ programId: program.id, category: 'STARTUP', quota: 0 }) + expect(q.quota).toBe(0) + }) +})