import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, adminProcedure, protectedProcedure } from '../trpc' import { logAudit } from '@/server/utils/audit' export const competitionRouter = router({ /** * Create a new competition for a program */ create: adminProcedure .input( z.object({ programId: z.string(), name: z.string().min(1).max(255), slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), categoryMode: z.string().default('SHARED'), startupFinalistCount: z.number().int().positive().default(3), conceptFinalistCount: z.number().int().positive().default(3), notifyOnRoundAdvance: z.boolean().default(true), notifyOnDeadlineApproach: z.boolean().default(true), deadlineReminderDays: z.array(z.number().int().positive()).default([7, 3, 1]), }) ) .mutation(async ({ ctx, input }) => { const existing = await ctx.prisma.competition.findUnique({ where: { slug: input.slug }, }) if (existing) { throw new TRPCError({ code: 'CONFLICT', message: `A competition with slug "${input.slug}" already exists`, }) } await ctx.prisma.program.findUniqueOrThrow({ where: { id: input.programId }, }) const competition = await ctx.prisma.$transaction(async (tx) => { const created = await tx.competition.create({ data: input, }) await logAudit({ prisma: tx, userId: ctx.user.id, action: 'CREATE', entityType: 'Competition', entityId: created.id, detailsJson: { name: input.name, programId: input.programId }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return created }) return competition }), /** * Get competition by ID with rounds, jury groups, and submission windows */ getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const competition = await ctx.prisma.competition.findUnique({ where: { id: input.id }, include: { rounds: { orderBy: { sortOrder: 'asc' }, select: { id: true, name: true, slug: true, roundType: true, status: true, sortOrder: true, windowOpenAt: true, windowCloseAt: true, }, }, juryGroups: { orderBy: { sortOrder: 'asc' }, select: { id: true, name: true, slug: true, sortOrder: true, defaultMaxAssignments: true, defaultCapMode: true, _count: { select: { members: true } }, }, }, submissionWindows: { orderBy: { sortOrder: 'asc' }, select: { id: true, name: true, slug: true, roundNumber: true, windowOpenAt: true, windowCloseAt: true, isLocked: true, _count: { select: { fileRequirements: true, projectFiles: true } }, }, }, }, }) if (!competition) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Competition not found' }) } return competition }), /** * List competitions for a program */ list: protectedProcedure .input(z.object({ programId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.competition.findMany({ where: { programId: input.programId }, orderBy: { createdAt: 'desc' }, include: { _count: { select: { rounds: true, juryGroups: true, submissionWindows: true }, }, }, }) }), /** * Update competition settings */ update: adminProcedure .input( z.object({ id: z.string(), name: z.string().min(1).max(255).optional(), slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(), status: z.enum(['DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED']).optional(), categoryMode: z.string().optional(), startupFinalistCount: z.number().int().positive().optional(), conceptFinalistCount: z.number().int().positive().optional(), notifyOnRoundAdvance: z.boolean().optional(), notifyOnDeadlineApproach: z.boolean().optional(), deadlineReminderDays: z.array(z.number().int().positive()).optional(), }) ) .mutation(async ({ ctx, input }) => { const { id, ...data } = input if (data.slug) { const existing = await ctx.prisma.competition.findFirst({ where: { slug: data.slug, NOT: { id } }, }) if (existing) { throw new TRPCError({ code: 'CONFLICT', message: `A competition with slug "${data.slug}" already exists`, }) } } const competition = await ctx.prisma.$transaction(async (tx) => { const previous = await tx.competition.findUniqueOrThrow({ where: { id } }) const updated = await tx.competition.update({ where: { id }, data, }) await logAudit({ prisma: tx, userId: ctx.user.id, action: 'UPDATE', entityType: 'Competition', entityId: id, detailsJson: { changes: data, previous: { name: previous.name, status: previous.status, slug: previous.slug, }, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return updated }) return competition }), /** * Delete (archive) a competition */ delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const competition = await ctx.prisma.$transaction(async (tx) => { const updated = await tx.competition.update({ where: { id: input.id }, data: { status: 'ARCHIVED' }, }) await logAudit({ prisma: tx, userId: ctx.user.id, action: 'DELETE', entityType: 'Competition', entityId: input.id, detailsJson: { action: 'archived' }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return updated }) return competition }), /** * Get competitions where the current user is a jury group member */ getMyCompetitions: protectedProcedure.query(async ({ ctx }) => { // Find competitions where the user is a jury group member const memberships = await ctx.prisma.juryGroupMember.findMany({ where: { userId: ctx.user.id }, select: { juryGroup: { select: { competitionId: true } } }, }) const competitionIds = [...new Set(memberships.map((m) => m.juryGroup.competitionId))] if (competitionIds.length === 0) return [] return ctx.prisma.competition.findMany({ where: { id: { in: competitionIds }, status: { not: 'ARCHIVED' } }, include: { rounds: { orderBy: { sortOrder: 'asc' }, select: { id: true, name: true, roundType: true, status: true }, }, _count: { select: { rounds: true, juryGroups: true } }, }, orderBy: { createdAt: 'desc' }, }) }), })