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:
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user