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)
|
const template = getMentorOnboardingTemplate(name || '', baseUrl)
|
||||||
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
|
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 { CompetitionCategory } from '@prisma/client'
|
||||||
import { router, adminProcedure } from '../trpc'
|
import { router, adminProcedure } from '../trpc'
|
||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
|
import { createPendingConfirmation } from '../services/finalist-confirmation'
|
||||||
|
import { sendFinalistConfirmationEmail } from '@/lib/email'
|
||||||
|
|
||||||
export const finalistRouter = router({
|
export const finalistRouter = router({
|
||||||
/**
|
/**
|
||||||
@@ -61,4 +63,116 @@ export const finalistRouter = router({
|
|||||||
})
|
})
|
||||||
return quota
|
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