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

@@ -2567,3 +2567,79 @@ export async function sendMentorOnboardingEmail(email: string, name: string | nu
const template = getMentorOnboardingTemplate(name || '', baseUrl)
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
}
function getFinalistConfirmationTemplate(
name: string,
projectTitle: string,
deadlineIso: string,
confirmUrl: string,
): EmailTemplate {
const subject = `Grand Finale: confirm your attendance for "${projectTitle}"`
const greeting = name ? `Hi ${name},` : 'Hi,'
const text = [
greeting,
'',
`Congratulations — your project "${projectTitle}" has been selected as a finalist`,
'for the Monaco Ocean Protection Challenge grand finale.',
'',
`Please confirm your team's attendance by ${deadlineIso}.`,
'On the confirmation page you will:',
' • Choose which team members will attend',
' • Indicate who needs visa support',
'',
`Confirm here: ${confirmUrl}`,
'',
'If your team cannot attend, please use the same link to decline so',
'we can offer the slot to a waitlisted team in time.',
'',
'The MOPC team',
].join('\n')
const html = `
<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
<div style="background:#053d57;padding:24px 28px;color:#fefefe;">
<h1 style="margin:0;font-size:20px;font-weight:600;">You're a Grand Finale finalist</h1>
</div>
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
<p style="margin-top:0;">${greeting}</p>
<p>Congratulations — your project <strong>${escapeHtml(projectTitle)}</strong> has been selected as a finalist for the Monaco Ocean Protection Challenge grand finale.</p>
<p style="margin-top:20px;padding:12px 16px;background:#fef3c7;border-left:3px solid #d97706;border-radius:4px;">
<strong>Confirm by ${escapeHtml(deadlineIso)}.</strong>
</p>
<p>On the confirmation page you'll choose which team members will attend and indicate who needs visa support.</p>
<p style="margin-top:24px;">
<a href="${confirmUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Confirm Attendance</a>
</p>
<p style="margin-top:24px;color:#64748b;font-size:12px;">
If your team cannot attend, please use the same link to decline so we can offer the slot to a waitlisted team in time.
</p>
</div>
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
Monaco Ocean Protection Challenge
</div>
</div>
</body>
</html>
`.trim()
return { subject, text, html }
}
/**
* Send a finalist confirmation email. Failures are intentionally not awaited
* inside any DB transaction — the calling tRPC mutation logs failures but
* does not roll back the confirmation row creation.
*/
export async function sendFinalistConfirmationEmail(
email: string,
name: string | null,
projectTitle: string,
deadline: Date,
confirmUrl: string,
): Promise<void> {
const template = getFinalistConfirmationTemplate(name || '', projectTitle, deadline.toISOString(), confirmUrl)
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
}

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