feat: selectFinalists creates PENDING confirmations and sends emails

- New service module createPendingConfirmation: writes a PENDING
  FinalistConfirmation row with a signed token whose exp matches the
  computed deadline.
- selectFinalists admin mutation: reads windowHours from the round's
  configJson.confirmationWindowHours (default 24), validates category
  match + quota, then creates one confirmation per selected project
  and sends a notification email to the team lead. Email failures are
  logged but never roll back the row creation.
- New email helpers: getFinalistConfirmationTemplate +
  sendFinalistConfirmationEmail.
This commit is contained in:
Matt
2026-04-28 17:55:09 +02:00
parent 3ea36296b9
commit 895be93678
4 changed files with 419 additions and 0 deletions

View File

@@ -3,6 +3,8 @@ import { TRPCError } from '@trpc/server'
import { CompetitionCategory } from '@prisma/client'
import { router, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import { createPendingConfirmation } from '../services/finalist-confirmation'
import { sendFinalistConfirmationEmail } from '@/lib/email'
export const finalistRouter = router({
/**
@@ -61,4 +63,116 @@ export const finalistRouter = router({
})
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 }
}),
})

View File

@@ -0,0 +1,40 @@
import type { CompetitionCategory, PrismaClient } from '@prisma/client'
import { signFinalistToken } from '@/lib/finalist-token'
type AnyPrisma = Pick<PrismaClient, 'finalistConfirmation'>
/**
* Create a PENDING FinalistConfirmation row with a signed token. Caller is
* responsible for sending the notification email separately.
*/
export async function createPendingConfirmation(
prisma: AnyPrisma,
args: {
projectId: string
category: CompetitionCategory
windowHours: number
promotedFromWaitlistEntryId?: string
},
): Promise<{ id: string; token: string; deadline: Date }> {
const deadline = new Date(Date.now() + args.windowHours * 3_600_000)
// Generate the row ID up front so we can sign it into the token before
// writing the row (token is unique-indexed; embedding the ID gives the
// public verify path a stable lookup key).
const id = `cmfc_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
const token = signFinalistToken({
confirmationId: id,
exp: Math.floor(deadline.getTime() / 1000),
})
await prisma.finalistConfirmation.create({
data: {
id,
projectId: args.projectId,
category: args.category,
status: 'PENDING',
deadline,
token,
promotedFromWaitlistEntryId: args.promotedFromWaitlistEntryId ?? null,
},
})
return { id, token, deadline }
}