Files
MOPC-Portal/src/server/services/finalist-confirmation.ts

154 lines
5.0 KiB
TypeScript
Raw Normal View History

import type { CompetitionCategory, PrismaClient } from '@prisma/client'
import { signFinalistToken } from '@/lib/finalist-token'
import { sendFinalistConfirmationEmail } from '@/lib/email'
import { logAudit } from '@/server/utils/audit'
type AnyPrisma = Pick<PrismaClient, 'finalistConfirmation' | 'waitlistEntry' | 'project'>
/**
* Create a PENDING FinalistConfirmation row with a signed token. Caller is
* responsible for sending the notification email separately.
*/
export async function createPendingConfirmation(
prisma: Pick<PrismaClient, 'finalistConfirmation'>,
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 }
}
/**
* Promote the lowest-ranked WAITING waitlist entry in the given category to
* PROMOTED, create a fresh PENDING confirmation for the project, and send
* the notification email. No-op if no WAITING entry exists.
*/
export async function promoteNextWaitlistEntry(
prisma: AnyPrisma,
args: { programId: string; category: CompetitionCategory; windowHours: number },
): Promise<{ promoted: boolean; entryId?: string; confirmationId?: string }> {
const entry = await prisma.waitlistEntry.findFirst({
where: {
programId: args.programId,
category: args.category,
status: 'WAITING',
},
orderBy: { rank: 'asc' },
})
if (!entry) return { promoted: false }
await prisma.waitlistEntry.update({
where: { id: entry.id },
data: { status: 'PROMOTED' },
})
const { id: confirmationId, token, deadline } = await createPendingConfirmation(prisma, {
projectId: entry.projectId,
category: args.category,
windowHours: args.windowHours,
promotedFromWaitlistEntryId: entry.id,
})
// Send email — log and continue on failure.
const project = await prisma.project.findUnique({
where: { id: entry.projectId },
select: {
title: true,
teamMembers: {
where: { role: 'LEAD' },
take: 1,
select: { user: { select: { email: true, name: true } } },
},
},
})
const lead = project?.teamMembers[0]?.user
if (lead?.email && project) {
const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
const confirmUrl = `${baseUrl}/finalist/confirm/${token}`
try {
await sendFinalistConfirmationEmail(
lead.email,
lead.name ?? null,
project.title,
deadline,
confirmUrl,
)
} catch (err) {
console.error(
`[promoteNextWaitlistEntry] failed to send email for project ${entry.projectId}:`,
err,
)
}
}
return { promoted: true, entryId: entry.id, confirmationId }
}
/**
* Cron entrypoint: find every PENDING confirmation past its deadline, mark
* each EXPIRED, and promote the next waitlist entry per affected category.
*/
export async function expirePendingPastDeadline(
prisma: PrismaClient,
): Promise<{ expired: number; promoted: number }> {
const expired = await prisma.finalistConfirmation.findMany({
where: { status: 'PENDING', deadline: { lt: new Date() } },
include: { project: { select: { programId: true } } },
})
let promoted = 0
for (const c of expired) {
await prisma.finalistConfirmation.update({
where: { id: c.id },
data: { status: 'EXPIRED', expiredAt: new Date() },
})
await logAudit({
prisma,
action: 'FINALIST_EXPIRED',
entityType: 'FinalistConfirmation',
entityId: c.id,
detailsJson: { projectId: c.projectId, category: c.category },
})
// Resolve windowHours for this program's grand-finale round
const round = await prisma.round.findFirst({
where: {
competition: { programId: c.project.programId },
roundType: 'LIVE_FINAL',
},
orderBy: { sortOrder: 'desc' },
select: { configJson: true },
})
const cfg = (round?.configJson ?? {}) as { confirmationWindowHours?: number }
const windowHours = cfg.confirmationWindowHours ?? 24
const result = await promoteNextWaitlistEntry(prisma, {
programId: c.project.programId,
category: c.category,
windowHours,
})
if (result.promoted) promoted++
}
return { expired: expired.length, promoted }
}