Round system redesign: Phases 1-7 complete
Full pipeline/track/stage architecture replacing the legacy round system. Schema: 11 new models (Pipeline, Track, Stage, StageTransition, ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor, OverrideAction, AudienceVoter) + 8 new enums. Backend: 9 new routers (pipeline, stage, routing, stageFiltering, stageAssignment, cohort, live, decision, award) + 6 new services (stage-engine, routing-engine, stage-filtering, stage-assignment, stage-notifications, live-control). Frontend: Pipeline wizard (17 components), jury stage pages (7), applicant pipeline pages (3), public stage pages (2), admin pipeline pages (5), shared stage components (3), SSE route, live hook. Phase 6 refit: 23 routers/services migrated from roundId to stageId, all frontend components refitted. Deleted round.ts (985 lines), roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx, 10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs. Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing, TypeScript 0 errors, Next.js build succeeds, 13 integrity checks, legacy symbol sweep clean, auto-seed on first Docker startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
331
src/server/routers/cohort.ts
Normal file
331
src/server/routers/cohort.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
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 },
|
||||
})),
|
||||
}
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user