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:
@@ -1,6 +1,9 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { expirePendingPastDeadline } from '@/server/services/finalist-confirmation'
|
||||
import {
|
||||
expirePendingPastDeadline,
|
||||
sendDueConfirmationReminders,
|
||||
} from '@/server/services/finalist-confirmation'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
@@ -8,8 +11,11 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
try {
|
||||
const result = await expirePendingPastDeadline(prisma)
|
||||
return NextResponse.json({ ok: true, ...result })
|
||||
const [expireResult, reminderResult] = await Promise.all([
|
||||
expirePendingPastDeadline(prisma),
|
||||
sendDueConfirmationReminders(prisma),
|
||||
])
|
||||
return NextResponse.json({ ok: true, ...expireResult, ...reminderResult })
|
||||
} catch (error) {
|
||||
console.error('[Cron] finalist-confirmations failed:', error)
|
||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export async function resetOrCreatePendingConfirmation(
|
||||
declinedAt: null,
|
||||
declineReason: null,
|
||||
expiredAt: null,
|
||||
reminderSentAt: null,
|
||||
},
|
||||
})
|
||||
return { id: existing.id, token, deadline, alreadyConfirmed: false }
|
||||
|
||||
Reference in New Issue
Block a user