Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s

This commit is contained in:
Matt
2026-02-14 15:26:42 +01:00
parent e56e143a40
commit b5425e705e
374 changed files with 116737 additions and 111969 deletions

View File

@@ -1,331 +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 },
})),
}
}),
})
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 },
})),
}
}),
})