import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, protectedProcedure, adminProcedure } from '../trpc' import { logAudit } from '@/server/utils/audit' export const cohortRouter = router({ /** * Create a new cohort within a stage */ create: adminProcedure .input( z.object({ stageId: z.string(), name: z.string().min(1).max(255), votingMode: z.enum(['simple', 'criteria', 'ranked']).default('simple'), windowOpenAt: z.date().optional(), windowCloseAt: z.date().optional(), }) ) .mutation(async ({ ctx, input }) => { // Verify stage exists and is of a type that supports cohorts const stage = await ctx.prisma.stage.findUniqueOrThrow({ where: { id: input.stageId }, }) if (stage.stageType !== 'LIVE_FINAL' && stage.stageType !== 'SELECTION') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cohorts can only be created in LIVE_FINAL or SELECTION stages', }) } // Validate window dates if (input.windowOpenAt && input.windowCloseAt) { if (input.windowCloseAt <= input.windowOpenAt) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Window close date must be after open date', }) } } const cohort = await ctx.prisma.$transaction(async (tx) => { const created = await tx.cohort.create({ data: { stageId: input.stageId, name: input.name, votingMode: input.votingMode, windowOpenAt: input.windowOpenAt ?? null, windowCloseAt: input.windowCloseAt ?? null, }, }) await logAudit({ prisma: tx, userId: ctx.user.id, action: 'CREATE', entityType: 'Cohort', entityId: created.id, detailsJson: { stageId: input.stageId, name: input.name, votingMode: input.votingMode, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return created }) return cohort }), /** * Assign projects to a cohort */ assignProjects: adminProcedure .input( z.object({ cohortId: z.string(), projectIds: z.array(z.string()).min(1).max(200), }) ) .mutation(async ({ ctx, input }) => { // Verify cohort exists const cohort = await ctx.prisma.cohort.findUniqueOrThrow({ where: { id: input.cohortId }, }) if (cohort.isOpen) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Cannot modify projects while voting is open', }) } // Get current max sortOrder const maxOrder = await ctx.prisma.cohortProject.aggregate({ where: { cohortId: input.cohortId }, _max: { sortOrder: true }, }) let nextOrder = (maxOrder._max.sortOrder ?? -1) + 1 // Create cohort project entries (skip duplicates) const created = await ctx.prisma.cohortProject.createMany({ data: input.projectIds.map((projectId) => ({ cohortId: input.cohortId, projectId, sortOrder: nextOrder++, })), skipDuplicates: true, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'COHORT_PROJECTS_ASSIGNED', entityType: 'Cohort', entityId: input.cohortId, detailsJson: { projectCount: created.count, requested: input.projectIds.length, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { assigned: created.count, requested: input.projectIds.length } }), /** * Open voting for a cohort */ openVoting: adminProcedure .input( z.object({ cohortId: z.string(), durationMinutes: z.number().int().min(1).max(1440).optional(), }) ) .mutation(async ({ ctx, input }) => { const cohort = await ctx.prisma.cohort.findUniqueOrThrow({ where: { id: input.cohortId }, include: { _count: { select: { projects: true } } }, }) if (cohort.isOpen) { throw new TRPCError({ code: 'CONFLICT', message: 'Voting is already open for this cohort', }) } if (cohort._count.projects === 0) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Cohort must have at least one project before opening voting', }) } const now = new Date() const closeAt = input.durationMinutes ? new Date(now.getTime() + input.durationMinutes * 60 * 1000) : cohort.windowCloseAt const updated = await ctx.prisma.$transaction(async (tx) => { const result = await tx.cohort.update({ where: { id: input.cohortId }, data: { isOpen: true, windowOpenAt: now, windowCloseAt: closeAt, }, }) await logAudit({ prisma: tx, userId: ctx.user.id, action: 'COHORT_VOTING_OPENED', entityType: 'Cohort', entityId: input.cohortId, detailsJson: { openedAt: now.toISOString(), closesAt: closeAt?.toISOString() ?? null, projectCount: cohort._count.projects, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return result }) return updated }), /** * Close voting for a cohort */ closeVoting: adminProcedure .input(z.object({ cohortId: z.string() })) .mutation(async ({ ctx, input }) => { const cohort = await ctx.prisma.cohort.findUniqueOrThrow({ where: { id: input.cohortId }, }) if (!cohort.isOpen) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Voting is not currently open for this cohort', }) } const now = new Date() const updated = await ctx.prisma.$transaction(async (tx) => { const result = await tx.cohort.update({ where: { id: input.cohortId }, data: { isOpen: false, windowCloseAt: now, }, }) await logAudit({ prisma: tx, userId: ctx.user.id, action: 'COHORT_VOTING_CLOSED', entityType: 'Cohort', entityId: input.cohortId, detailsJson: { closedAt: now.toISOString(), wasOpenSince: cohort.windowOpenAt?.toISOString(), }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return result }) return updated }), /** * List cohorts for a stage */ list: protectedProcedure .input(z.object({ stageId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.cohort.findMany({ where: { stageId: input.stageId }, orderBy: { createdAt: 'asc' }, include: { _count: { select: { projects: true } }, }, }) }), /** * Get cohort with projects and vote summary */ get: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const cohort = await ctx.prisma.cohort.findUniqueOrThrow({ where: { id: input.id }, include: { stage: { select: { id: true, name: true, stageType: true, track: { select: { id: true, name: true, pipeline: { select: { id: true, name: true } }, }, }, }, }, projects: { orderBy: { sortOrder: 'asc' }, include: { project: { select: { id: true, title: true, teamName: true, tags: true, description: true, }, }, }, }, }, }) // Get vote counts per project in the cohort's stage session const projectIds = cohort.projects.map((p) => p.projectId) const voteSummary = projectIds.length > 0 ? await ctx.prisma.liveVote.groupBy({ by: ['projectId'], where: { projectId: { in: projectIds }, session: { stageId: cohort.stage.id }, }, _count: true, _avg: { score: true }, }) : [] const voteMap = new Map( voteSummary.map((v) => [ v.projectId, { voteCount: v._count, avgScore: v._avg?.score ?? 0 }, ]) ) return { ...cohort, projects: cohort.projects.map((cp) => ({ ...cp, votes: voteMap.get(cp.projectId) ?? { voteCount: 0, avgScore: 0 }, })), } }), })