diff --git a/src/lib/email.ts b/src/lib/email.ts
index ea0abc8..c0fc5e1 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -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 = `
+
+
+
+
+
+
You're a Grand Finale finalist
+
+
+
${greeting}
+
Congratulations — your project ${escapeHtml(projectTitle)} has been selected as a finalist for the Monaco Ocean Protection Challenge grand finale.
+
+ Confirm by ${escapeHtml(deadlineIso)}.
+
+
On the confirmation page you'll choose which team members will attend and indicate who needs visa support.
+
+ Confirm Attendance
+
+
+ If your team cannot attend, please use the same link to decline so we can offer the slot to a waitlisted team in time.
+
+
+
+ Monaco Ocean Protection Challenge
+
+
+
+
+ `.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 {
+ const template = getFinalistConfirmationTemplate(name || '', projectTitle, deadline.toISOString(), confirmUrl)
+ await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
+}
diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts
index c969b12..8f339b8 100644
--- a/src/server/routers/finalist.ts
+++ b/src/server/routers/finalist.ts
@@ -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 }
+ }),
})
diff --git a/src/server/services/finalist-confirmation.ts b/src/server/services/finalist-confirmation.ts
new file mode 100644
index 0000000..3446ee9
--- /dev/null
+++ b/src/server/services/finalist-confirmation.ts
@@ -0,0 +1,40 @@
+import type { CompetitionCategory, PrismaClient } from '@prisma/client'
+import { signFinalistToken } from '@/lib/finalist-token'
+
+type AnyPrisma = Pick
+
+/**
+ * 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 }
+}
diff --git a/tests/unit/finalist-confirmation.test.ts b/tests/unit/finalist-confirmation.test.ts
new file mode 100644
index 0000000..7572f74
--- /dev/null
+++ b/tests/unit/finalist-confirmation.test.ts
@@ -0,0 +1,189 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { prisma, createCaller } from '../setup'
+import {
+ createTestUser,
+ createTestProgram,
+ createTestProject,
+ createTestCompetition,
+ createTestRound,
+ cleanupTestData,
+ uid,
+} from '../helpers'
+import { finalistRouter } from '../../src/server/routers/finalist'
+
+beforeAll(() => {
+ process.env.NEXTAUTH_SECRET = 'test-secret-for-finalist-tokens'
+ process.env.NEXTAUTH_URL = 'http://localhost:3001'
+})
+
+describe('finalist.selectFinalists', () => {
+ const programIds: string[] = []
+ const userIds: string[] = []
+
+ afterAll(async () => {
+ for (const programId of programIds) {
+ await prisma.attendingMember.deleteMany({
+ where: { confirmation: { project: { programId } } },
+ })
+ await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
+ await prisma.finalistSlotQuota.deleteMany({ where: { programId } })
+ await cleanupTestData(programId, [])
+ }
+ if (userIds.length > 0) {
+ await prisma.user.deleteMany({ where: { id: { in: userIds } } })
+ }
+ })
+
+ it('creates PENDING confirmations with unique tokens for each selected project', async () => {
+ const admin = await createTestUser('SUPER_ADMIN')
+ userIds.push(admin.id)
+ const program = await createTestProgram({ name: `select-${uid()}` })
+ programIds.push(program.id)
+ const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
+ const round = await createTestRound(competition.id, {
+ roundType: 'LIVE_FINAL',
+ configJson: { confirmationWindowHours: 24 },
+ })
+ const p1 = await createTestProject(program.id, { title: 'P1', competitionCategory: 'STARTUP' })
+ const p2 = await createTestProject(program.id, { title: 'P2', competitionCategory: 'STARTUP' })
+ const caller = createCaller(finalistRouter, {
+ id: admin.id,
+ email: admin.email,
+ role: 'SUPER_ADMIN',
+ })
+ const result = await caller.selectFinalists({
+ programId: program.id,
+ category: 'STARTUP',
+ projectIds: [p1.id, p2.id],
+ roundId: round.id,
+ })
+ expect(result.created).toBe(2)
+ const confirmations = await prisma.finalistConfirmation.findMany({
+ where: { project: { programId: program.id } },
+ })
+ expect(confirmations).toHaveLength(2)
+ expect(new Set(confirmations.map((c) => c.token)).size).toBe(2)
+ for (const c of confirmations) {
+ expect(c.status).toBe('PENDING')
+ expect(c.deadline.getTime()).toBeGreaterThan(Date.now() + 23 * 3_600_000)
+ expect(c.deadline.getTime()).toBeLessThan(Date.now() + 25 * 3_600_000)
+ }
+ })
+
+ it('uses round configJson.confirmationWindowHours when configured', async () => {
+ const admin = await createTestUser('SUPER_ADMIN')
+ userIds.push(admin.id)
+ const program = await createTestProgram({ name: `select-window-${uid()}` })
+ programIds.push(program.id)
+ const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
+ const round = await createTestRound(competition.id, {
+ roundType: 'LIVE_FINAL',
+ configJson: { confirmationWindowHours: 48 },
+ })
+ const p1 = await createTestProject(program.id, { title: 'P1', competitionCategory: 'STARTUP' })
+ const caller = createCaller(finalistRouter, {
+ id: admin.id,
+ email: admin.email,
+ role: 'SUPER_ADMIN',
+ })
+ await caller.selectFinalists({
+ programId: program.id,
+ category: 'STARTUP',
+ projectIds: [p1.id],
+ roundId: round.id,
+ })
+ const confirmation = await prisma.finalistConfirmation.findFirstOrThrow({
+ where: { projectId: p1.id },
+ })
+ expect(confirmation.deadline.getTime()).toBeGreaterThan(Date.now() + 47 * 3_600_000)
+ expect(confirmation.deadline.getTime()).toBeLessThan(Date.now() + 49 * 3_600_000)
+ })
+
+ it('defaults to 24h when confirmationWindowHours is not in configJson', async () => {
+ const admin = await createTestUser('SUPER_ADMIN')
+ userIds.push(admin.id)
+ const program = await createTestProgram({ name: `select-default-${uid()}` })
+ programIds.push(program.id)
+ const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
+ const round = await createTestRound(competition.id, {
+ roundType: 'LIVE_FINAL',
+ configJson: {},
+ })
+ const p1 = await createTestProject(program.id, { title: 'P1', competitionCategory: 'STARTUP' })
+ const caller = createCaller(finalistRouter, {
+ id: admin.id,
+ email: admin.email,
+ role: 'SUPER_ADMIN',
+ })
+ await caller.selectFinalists({
+ programId: program.id,
+ category: 'STARTUP',
+ projectIds: [p1.id],
+ roundId: round.id,
+ })
+ const confirmation = await prisma.finalistConfirmation.findFirstOrThrow({
+ where: { projectId: p1.id },
+ })
+ expect(confirmation.deadline.getTime()).toBeGreaterThan(Date.now() + 23 * 3_600_000)
+ expect(confirmation.deadline.getTime()).toBeLessThan(Date.now() + 25 * 3_600_000)
+ })
+
+ it('rejects selecting more projects than the category quota', async () => {
+ const admin = await createTestUser('SUPER_ADMIN')
+ userIds.push(admin.id)
+ const program = await createTestProgram({ name: `select-quota-${uid()}` })
+ programIds.push(program.id)
+ const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
+ const round = await createTestRound(competition.id, {
+ roundType: 'LIVE_FINAL',
+ configJson: { confirmationWindowHours: 24 },
+ })
+ await prisma.finalistSlotQuota.create({
+ data: { programId: program.id, category: 'STARTUP', quota: 1 },
+ })
+ const p1 = await createTestProject(program.id, { title: 'P1', competitionCategory: 'STARTUP' })
+ const p2 = await createTestProject(program.id, { title: 'P2', competitionCategory: 'STARTUP' })
+ const caller = createCaller(finalistRouter, {
+ id: admin.id,
+ email: admin.email,
+ role: 'SUPER_ADMIN',
+ })
+ await expect(
+ caller.selectFinalists({
+ programId: program.id,
+ category: 'STARTUP',
+ projectIds: [p1.id, p2.id],
+ roundId: round.id,
+ }),
+ ).rejects.toThrow(/exceeds quota/i)
+ })
+
+ it('rejects projects whose category does not match', async () => {
+ const admin = await createTestUser('SUPER_ADMIN')
+ userIds.push(admin.id)
+ const program = await createTestProgram({ name: `select-cat-${uid()}` })
+ programIds.push(program.id)
+ const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
+ const round = await createTestRound(competition.id, {
+ roundType: 'LIVE_FINAL',
+ configJson: { confirmationWindowHours: 24 },
+ })
+ const p1 = await createTestProject(program.id, {
+ title: 'P1',
+ competitionCategory: 'BUSINESS_CONCEPT',
+ })
+ const caller = createCaller(finalistRouter, {
+ id: admin.id,
+ email: admin.email,
+ role: 'SUPER_ADMIN',
+ })
+ await expect(
+ caller.selectFinalists({
+ programId: program.id,
+ category: 'STARTUP',
+ projectIds: [p1.id],
+ roundId: round.id,
+ }),
+ ).rejects.toThrow(/category mismatch/i)
+ })
+})