Add sendDueConfirmationReminders() to finalist-confirmation.ts: queries PENDING confirmations with no reminderSentAt whose deadline is within the per-program LIVE_FINAL round reminderHoursBeforeDeadline window (default 12h), sends a FINALIST_REMINDER in-app notification (+ email via pipeline) to the team LEAD, then stamps reminderSentAt for idempotency. Wire into the finalist-confirmations cron route alongside expirePendingPastDeadline. Also clear reminderSentAt on re-invite in resetOrCreatePendingConfirmation so re-invited teams get a fresh reminder window. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
314 lines
10 KiB
TypeScript
314 lines
10 KiB
TypeScript
import type { CompetitionCategory, PrismaClient } from '@prisma/client'
|
|
import { signFinalistToken } from '@/lib/finalist-token'
|
|
import { sendFinalistConfirmationEmail } from '@/lib/email'
|
|
import { logAudit } from '@/server/utils/audit'
|
|
import { notifyAdmins, createNotification, NotificationTypes } from './in-app-notification'
|
|
|
|
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,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Admin alert — best-effort, never throws
|
|
try {
|
|
const projectTitle = project?.title ?? entry.projectId
|
|
await notifyAdmins({
|
|
type: NotificationTypes.FINALIST_WAITLIST_PROMOTED,
|
|
title: 'Waitlist entry promoted',
|
|
message: `"${projectTitle}" (${args.category}) was promoted from the waitlist.`,
|
|
linkUrl: '/admin/logistics',
|
|
metadata: {
|
|
projectId: entry.projectId,
|
|
projectTitle,
|
|
category: args.category,
|
|
},
|
|
})
|
|
} catch (err) {
|
|
console.error(
|
|
`[promoteNextWaitlistEntry] failed to send admin notification 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 },
|
|
})
|
|
// Admin alert — best-effort, never throws
|
|
try {
|
|
// Resolve project title for a meaningful notification message
|
|
const proj = await prisma.project.findUnique({
|
|
where: { id: c.projectId },
|
|
select: { title: true },
|
|
})
|
|
const projectTitle = proj?.title ?? c.projectId
|
|
await notifyAdmins({
|
|
type: NotificationTypes.FINALIST_EXPIRED,
|
|
title: 'Finalist confirmation expired',
|
|
message: `"${projectTitle}" (${c.category}) did not confirm in time — confirmation expired.`,
|
|
linkUrl: '/admin/logistics',
|
|
metadata: {
|
|
projectId: c.projectId,
|
|
projectTitle,
|
|
category: c.category,
|
|
},
|
|
})
|
|
} catch (err) {
|
|
console.error(
|
|
`[expirePendingPastDeadline] failed to send admin notification for project ${c.projectId}:`,
|
|
err,
|
|
)
|
|
}
|
|
// 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 }
|
|
}
|
|
|
|
/**
|
|
* Cron entrypoint: send pre-deadline confirmation reminders.
|
|
*
|
|
* For each PENDING confirmation that has not yet received a reminder
|
|
* (reminderSentAt IS NULL) and whose deadline is still in the future but
|
|
* within the program's configured `reminderHoursBeforeDeadline` window
|
|
* (default 12 h), send a FINALIST_REMINDER in-app notification (+ email via
|
|
* the notification pipeline) to the project's LEAD team member, then stamp
|
|
* `reminderSentAt` so the row is never processed again.
|
|
*
|
|
* Best-effort per row — a failure on one row never aborts the rest.
|
|
*/
|
|
export async function sendDueConfirmationReminders(
|
|
prisma: PrismaClient,
|
|
): Promise<{ remindersSent: number }> {
|
|
const now = new Date()
|
|
|
|
// Load all candidates: PENDING, no reminder sent yet, deadline still future.
|
|
const candidates = await prisma.finalistConfirmation.findMany({
|
|
where: {
|
|
status: 'PENDING',
|
|
reminderSentAt: null,
|
|
deadline: { gt: now },
|
|
},
|
|
include: {
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
programId: true,
|
|
teamMembers: {
|
|
where: { role: 'LEAD' },
|
|
take: 1,
|
|
select: {
|
|
userId: true,
|
|
user: { select: { name: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if (candidates.length === 0) return { remindersSent: 0 }
|
|
|
|
// Cache reminderHoursBeforeDeadline per programId to avoid repeat queries.
|
|
const reminderHoursCache = new Map<string, number>()
|
|
|
|
async function getReminderHours(programId: string): Promise<number> {
|
|
if (reminderHoursCache.has(programId)) {
|
|
return reminderHoursCache.get(programId)!
|
|
}
|
|
const round = await prisma.round.findFirst({
|
|
where: {
|
|
competition: { programId },
|
|
roundType: 'LIVE_FINAL',
|
|
},
|
|
orderBy: { sortOrder: 'desc' },
|
|
select: { configJson: true },
|
|
})
|
|
const cfg = (round?.configJson ?? {}) as { reminderHoursBeforeDeadline?: number }
|
|
const hours = cfg.reminderHoursBeforeDeadline ?? 12
|
|
reminderHoursCache.set(programId, hours)
|
|
return hours
|
|
}
|
|
|
|
const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
|
|
let remindersSent = 0
|
|
|
|
for (const row of candidates) {
|
|
try {
|
|
const reminderHours = await getReminderHours(row.project.programId)
|
|
const windowMs = reminderHours * 3_600_000
|
|
const isDue = row.deadline.getTime() <= now.getTime() + windowMs
|
|
|
|
if (!isDue) continue
|
|
|
|
const lead = row.project.teamMembers[0]
|
|
if (!lead) continue
|
|
|
|
const confirmUrl = `${baseUrl}/finalist/confirm/${row.token}`
|
|
const title = row.project.title
|
|
|
|
await createNotification({
|
|
userId: lead.userId,
|
|
type: NotificationTypes.FINALIST_REMINDER,
|
|
title: 'Reminder: confirm your grand-finale attendance',
|
|
message: `Please confirm attendance for "${title}" before the deadline.`,
|
|
linkUrl: confirmUrl,
|
|
metadata: {
|
|
projectTitle: title,
|
|
projectId: row.project.id,
|
|
deadline: row.deadline.toISOString(),
|
|
},
|
|
})
|
|
|
|
await prisma.finalistConfirmation.update({
|
|
where: { id: row.id },
|
|
data: { reminderSentAt: new Date() },
|
|
})
|
|
|
|
remindersSent++
|
|
} catch (err) {
|
|
console.error(
|
|
`[sendDueConfirmationReminders] failed for confirmation ${row.id} (project ${row.projectId}):`,
|
|
err,
|
|
)
|
|
}
|
|
}
|
|
|
|
return { remindersSent }
|
|
}
|