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:
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
40
src/server/services/finalist-confirmation.ts
Normal file
40
src/server/services/finalist-confirmation.ts
Normal 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 }
|
||||
}
|
||||
189
tests/unit/finalist-confirmation.test.ts
Normal file
189
tests/unit/finalist-confirmation.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user