feat(finalist): deadline reminder emails via cron

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>
This commit is contained in:
Matt
2026-06-04 16:17:19 +02:00
parent d501624c56
commit 1b4ab6be18
4 changed files with 339 additions and 4 deletions

View File

@@ -2,7 +2,7 @@ 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, NotificationTypes } from './in-app-notification'
import { notifyAdmins, createNotification, NotificationTypes } from './in-app-notification'
type AnyPrisma = Pick<PrismaClient, 'finalistConfirmation' | 'waitlistEntry' | 'project'>
@@ -198,3 +198,116 @@ export async function expirePendingPastDeadline(
}
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 }
}

View File

@@ -43,6 +43,7 @@ export async function resetOrCreatePendingConfirmation(
declinedAt: null,
declineReason: null,
expiredAt: null,
reminderSentAt: null,
},
})
return { id: existing.id, token, deadline, alreadyConfirmed: false }