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 }
}),
})