From 895be9367833c598a1400c09e88b456744a29ed1 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 17:55:09 +0200 Subject: [PATCH] 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. --- src/lib/email.ts | 76 ++++++++ src/server/routers/finalist.ts | 114 +++++++++++ src/server/services/finalist-confirmation.ts | 40 ++++ tests/unit/finalist-confirmation.test.ts | 189 +++++++++++++++++++ 4 files changed, 419 insertions(+) create mode 100644 src/server/services/finalist-confirmation.ts create mode 100644 tests/unit/finalist-confirmation.test.ts 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) + }) +})