Files
MOPC-Portal/src/server/routers/finalist.ts

367 lines
12 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { CompetitionCategory } from '@prisma/client'
import { router, adminProcedure, publicProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import {
createPendingConfirmation,
promoteNextWaitlistEntry,
} from '../services/finalist-confirmation'
import { sendFinalistConfirmationEmail } from '@/lib/email'
import { verifyFinalistToken } from '@/lib/finalist-token'
export const finalistRouter = router({
/**
* Set the finalist slot quota for a category in a program. Mutable mid-flight,
* but blocked when reducing below the count of already-CONFIRMED finalists in
* that category admin must un-confirm a team first.
*/
setQuota: adminProcedure
.input(
z.object({
programId: z.string(),
category: z.nativeEnum(CompetitionCategory),
quota: z.number().int().min(0).max(100),
}),
)
.mutation(async ({ ctx, input }) => {
const confirmedCount = await ctx.prisma.finalistConfirmation.count({
where: {
project: { programId: input.programId },
category: input.category,
status: 'CONFIRMED',
},
})
if (input.quota < confirmedCount) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Cannot reduce ${input.category} quota to ${input.quota}${confirmedCount} teams have already confirmed. Un-confirm one team first, then retry.`,
})
}
const quota = await ctx.prisma.finalistSlotQuota.upsert({
where: {
programId_category: {
programId: input.programId,
category: input.category,
},
},
create: {
programId: input.programId,
category: input.category,
quota: input.quota,
},
update: { quota: input.quota },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FINALIST_QUOTA_SET',
entityType: 'FinalistSlotQuota',
entityId: quota.id,
detailsJson: {
programId: input.programId,
category: input.category,
quota: input.quota,
previousConfirmedCount: confirmedCount,
},
})
return quota
}),
/**
* Send finalist confirmation emails to a set of selected projects in a
* category. Reads the confirmation window from the round's configJson.
* Validates category match + quota before creating any rows.
*/
selectFinalists: adminProcedure
.input(
z.object({
programId: z.string(),
category: z.nativeEnum(CompetitionCategory),
projectIds: z.array(z.string()).min(1),
roundId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { id: true, configJson: true },
})
const cfg = (round.configJson ?? {}) as { confirmationWindowHours?: number }
const windowHours = cfg.confirmationWindowHours ?? 24
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.projectIds }, programId: input.programId },
select: {
id: true,
title: true,
competitionCategory: true,
teamMembers: {
where: { role: 'LEAD' },
take: 1,
select: { user: { select: { email: true, name: true } } },
},
},
})
if (projects.length !== input.projectIds.length) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'One or more project IDs not found in this program',
})
}
const mismatched = projects.filter((p) => p.competitionCategory !== input.category)
if (mismatched.length > 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Category mismatch: ${mismatched
.map((p) => p.title)
.join(', ')} are not in ${input.category}`,
})
}
const quota = await ctx.prisma.finalistSlotQuota.findUnique({
where: {
programId_category: {
programId: input.programId,
category: input.category,
},
},
})
if (quota && input.projectIds.length > quota.quota) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Selection exceeds quota: ${input.projectIds.length} selected, ${quota.quota} available in ${input.category}`,
})
}
const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
let created = 0
for (const project of projects) {
const { token, deadline } = await createPendingConfirmation(ctx.prisma, {
projectId: project.id,
category: input.category,
windowHours,
})
created++
// Send notification email — never throw inside the loop; log failures.
const lead = project.teamMembers[0]?.user
if (lead?.email) {
const confirmUrl = `${baseUrl}/finalist/confirm/${token}`
try {
await sendFinalistConfirmationEmail(
lead.email,
lead.name ?? null,
project.title,
deadline,
confirmUrl,
)
} catch (err) {
console.error(
`[finalist.selectFinalists] failed to send email to ${lead.email} for project ${project.id}:`,
err,
)
}
}
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FINALIST_SELECT',
entityType: 'Program',
entityId: input.programId,
detailsJson: {
category: input.category,
projectIds: input.projectIds,
windowHours,
roundId: input.roundId,
},
})
return { created }
}),
/**
* Look up a confirmation by its public token. Surface the data needed to
* render the confirmation page: project, team members, current state.
*/
getByToken: publicProcedure
.input(z.object({ token: z.string() }))
.query(async ({ ctx, input }) => {
const payload = verifyFinalistToken(input.token) // throws on bad sig / expired
const confirmation = await ctx.prisma.finalistConfirmation.findUnique({
where: { id: payload.confirmationId },
include: {
project: {
select: {
id: true,
title: true,
programId: true,
competitionCategory: true,
program: { select: { defaultAttendeeCap: true, name: true } },
teamMembers: {
select: {
userId: true,
role: true,
user: { select: { id: true, name: true, email: true } },
},
},
},
},
attendingMembers: { select: { userId: true, needsVisa: true } },
},
})
if (!confirmation) throw new TRPCError({ code: 'NOT_FOUND' })
if (confirmation.token !== input.token) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Token mismatch' })
}
return confirmation
}),
/**
* Public confirm. Validates that all selected userIds are team members of
* the project, that the count is within the program's defaultAttendeeCap,
* and that the confirmation is still PENDING. Atomically writes
* status=CONFIRMED + AttendingMember rows.
*/
confirm: publicProcedure
.input(
z.object({
token: z.string(),
attendingUserIds: z.array(z.string()).min(1),
visaFlags: z.record(z.string(), z.boolean()).default({}),
}),
)
.mutation(async ({ ctx, input }) => {
const payload = verifyFinalistToken(input.token)
const confirmation = await ctx.prisma.finalistConfirmation.findUnique({
where: { id: payload.confirmationId },
include: {
project: {
select: {
id: true,
programId: true,
program: { select: { defaultAttendeeCap: true } },
teamMembers: { select: { userId: true } },
},
},
},
})
if (!confirmation) throw new TRPCError({ code: 'NOT_FOUND' })
if (confirmation.token !== input.token) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
if (confirmation.status !== 'PENDING') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Confirmation is ${confirmation.status}, not PENDING`,
})
}
const cap = confirmation.project.program.defaultAttendeeCap
if (input.attendingUserIds.length > cap) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Selection exceeds attendee cap of ${cap}`,
})
}
const teamUserIds = new Set(confirmation.project.teamMembers.map((tm) => tm.userId))
for (const uid of input.attendingUserIds) {
if (!teamUserIds.has(uid)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `User ${uid} is not a team member of this project`,
})
}
}
await ctx.prisma.$transaction([
ctx.prisma.finalistConfirmation.update({
where: { id: confirmation.id },
data: { status: 'CONFIRMED', confirmedAt: new Date() },
}),
ctx.prisma.attendingMember.createMany({
data: input.attendingUserIds.map((userId) => ({
confirmationId: confirmation.id,
userId,
needsVisa: input.visaFlags[userId] ?? false,
})),
}),
])
await logAudit({
prisma: ctx.prisma,
action: 'FINALIST_CONFIRMED',
entityType: 'FinalistConfirmation',
entityId: confirmation.id,
detailsJson: {
projectId: confirmation.projectId,
attendingUserIds: input.attendingUserIds,
},
})
return { ok: true }
}),
/**
* Public decline. Captures an optional reason. Triggers waitlist promotion
* for the same category. The freshly-promoted waitlist team gets its own
* fresh 24h-ish window (read from the round configJson; the round id is
* resolved via the project's most-recent grand-finale round, since the
* decliner won't pass it back).
*/
decline: publicProcedure
.input(z.object({ token: z.string(), reason: z.string().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
const payload = verifyFinalistToken(input.token)
const confirmation = await ctx.prisma.finalistConfirmation.findUnique({
where: { id: payload.confirmationId },
include: { project: { select: { programId: true } } },
})
if (!confirmation) throw new TRPCError({ code: 'NOT_FOUND' })
if (confirmation.token !== input.token) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
if (confirmation.status !== 'PENDING') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Confirmation is ${confirmation.status}, not PENDING`,
})
}
await ctx.prisma.finalistConfirmation.update({
where: { id: confirmation.id },
data: {
status: 'DECLINED',
declinedAt: new Date(),
declineReason: input.reason ?? null,
},
})
await logAudit({
prisma: ctx.prisma,
action: 'FINALIST_DECLINED',
entityType: 'FinalistConfirmation',
entityId: confirmation.id,
detailsJson: {
projectId: confirmation.projectId,
reason: input.reason ?? null,
},
})
// Promote next waitlist entry in same category. windowHours pulled from
// the live grand-finale round in the program (LIVE_FINAL roundType).
const round = await ctx.prisma.round.findFirst({
where: {
competition: { programId: confirmation.project.programId },
roundType: 'LIVE_FINAL',
},
orderBy: { sortOrder: 'desc' },
select: { configJson: true },
})
const cfg = (round?.configJson ?? {}) as { confirmationWindowHours?: number }
const windowHours = cfg.confirmationWindowHours ?? 24
await promoteNextWaitlistEntry(ctx.prisma, {
programId: confirmation.project.programId,
category: confirmation.category,
windowHours,
})
return { ok: true }
}),
})