332 lines
8.9 KiB
TypeScript
332 lines
8.9 KiB
TypeScript
|
|
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 },
|
||
|
|
})),
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
})
|