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 { NextResponse, type NextRequest } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
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> {
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||||
const cronSecret = request.headers.get('x-cron-secret')
|
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 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await expirePendingPastDeadline(prisma)
|
const [expireResult, reminderResult] = await Promise.all([
|
||||||
return NextResponse.json({ ok: true, ...result })
|
expirePendingPastDeadline(prisma),
|
||||||
|
sendDueConfirmationReminders(prisma),
|
||||||
|
])
|
||||||
|
return NextResponse.json({ ok: true, ...expireResult, ...reminderResult })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Cron] finalist-confirmations failed:', error)
|
console.error('[Cron] finalist-confirmations failed:', error)
|
||||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
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 { signFinalistToken } from '@/lib/finalist-token'
|
||||||
import { sendFinalistConfirmationEmail } from '@/lib/email'
|
import { sendFinalistConfirmationEmail } from '@/lib/email'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
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'>
|
type AnyPrisma = Pick<PrismaClient, 'finalistConfirmation' | 'waitlistEntry' | 'project'>
|
||||||
|
|
||||||
@@ -198,3 +198,116 @@ export async function expirePendingPastDeadline(
|
|||||||
}
|
}
|
||||||
return { expired: expired.length, 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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export async function resetOrCreatePendingConfirmation(
|
|||||||
declinedAt: null,
|
declinedAt: null,
|
||||||
declineReason: null,
|
declineReason: null,
|
||||||
expiredAt: null,
|
expiredAt: null,
|
||||||
|
reminderSentAt: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return { id: existing.id, token, deadline, alreadyConfirmed: false }
|
return { id: existing.id, token, deadline, alreadyConfirmed: false }
|
||||||
|
|||||||
215
tests/unit/finalist-reminders.test.ts
Normal file
215
tests/unit/finalist-reminders.test.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* Task 5: Confirmation reminder cron
|
||||||
|
*
|
||||||
|
* Tests that sendDueConfirmationReminders:
|
||||||
|
* - sends a FINALIST_REMINDER notification for leads whose deadline is within the window
|
||||||
|
* - stamps reminderSentAt so the second call is idempotent
|
||||||
|
* - skips rows whose deadline is further away than reminderHoursBeforeDeadline
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { prisma } from '../setup'
|
||||||
|
import {
|
||||||
|
createTestProgram,
|
||||||
|
createTestCompetition,
|
||||||
|
createTestRound,
|
||||||
|
createTestProject,
|
||||||
|
cleanupTestData,
|
||||||
|
uid,
|
||||||
|
} from '../helpers'
|
||||||
|
import { sendDueConfirmationReminders } from '../../src/server/services/finalist-confirmation'
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.NEXTAUTH_SECRET = 'test-secret-for-finalist-tokens'
|
||||||
|
process.env.NEXTAUTH_URL = 'http://localhost:3001'
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendDueConfirmationReminders', () => {
|
||||||
|
const programIds: string[] = []
|
||||||
|
const userIds: string[] = []
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const programId of programIds) {
|
||||||
|
await prisma.inAppNotification.deleteMany({
|
||||||
|
where: { metadata: { path: ['projectId'], string_contains: '' } },
|
||||||
|
})
|
||||||
|
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
|
||||||
|
await cleanupTestData(programId, [])
|
||||||
|
}
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends FINALIST_REMINDER for a lead whose deadline is within the reminder window', async () => {
|
||||||
|
const program = await createTestProgram({ name: `reminder-due-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||||
|
// LIVE_FINAL round with 12h reminder window
|
||||||
|
await createTestRound(competition.id, {
|
||||||
|
roundType: 'LIVE_FINAL',
|
||||||
|
configJson: { reminderHoursBeforeDeadline: 12 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const lead = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: uid('user'),
|
||||||
|
email: `lead-reminder-${uid()}@test.local`,
|
||||||
|
name: 'Reminder Lead',
|
||||||
|
role: 'APPLICANT',
|
||||||
|
roles: ['APPLICANT'],
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
userIds.push(lead.id)
|
||||||
|
|
||||||
|
const project = await createTestProject(program.id, {
|
||||||
|
title: 'Reminder Project',
|
||||||
|
competitionCategory: 'STARTUP',
|
||||||
|
})
|
||||||
|
await prisma.teamMember.create({
|
||||||
|
data: { projectId: project.id, userId: lead.id, role: 'LEAD' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deadline 6 hours from now — within the 12h window
|
||||||
|
const deadline = new Date(Date.now() + 6 * 3_600_000)
|
||||||
|
const token = `tok_reminder_${uid()}`
|
||||||
|
await prisma.finalistConfirmation.create({
|
||||||
|
data: {
|
||||||
|
projectId: project.id,
|
||||||
|
category: 'STARTUP',
|
||||||
|
status: 'PENDING',
|
||||||
|
deadline,
|
||||||
|
token,
|
||||||
|
reminderSentAt: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await sendDueConfirmationReminders(prisma)
|
||||||
|
expect(result.remindersSent).toBe(1)
|
||||||
|
|
||||||
|
// Notification created for the lead
|
||||||
|
const notification = await prisma.inAppNotification.findFirst({
|
||||||
|
where: { userId: lead.id, type: 'FINALIST_REMINDER' },
|
||||||
|
})
|
||||||
|
expect(notification).not.toBeNull()
|
||||||
|
expect(notification?.metadata).toMatchObject({ projectTitle: 'Reminder Project' })
|
||||||
|
|
||||||
|
// reminderSentAt is stamped
|
||||||
|
const updated = await prisma.finalistConfirmation.findUniqueOrThrow({
|
||||||
|
where: { projectId: project.id },
|
||||||
|
})
|
||||||
|
expect(updated.reminderSentAt).not.toBeNull()
|
||||||
|
|
||||||
|
// Clean up notification so it doesn't interfere with idempotency test
|
||||||
|
await prisma.inAppNotification.deleteMany({ where: { userId: lead.id, type: 'FINALIST_REMINDER' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is idempotent — second call sends 0 reminders for the same row', async () => {
|
||||||
|
// Reuse the row created above — reminderSentAt is now set
|
||||||
|
const program = await createTestProgram({ name: `reminder-idempotent-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||||
|
await createTestRound(competition.id, {
|
||||||
|
roundType: 'LIVE_FINAL',
|
||||||
|
configJson: { reminderHoursBeforeDeadline: 12 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const lead = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: uid('user'),
|
||||||
|
email: `lead-idempotent-${uid()}@test.local`,
|
||||||
|
name: 'Idempotent Lead',
|
||||||
|
role: 'APPLICANT',
|
||||||
|
roles: ['APPLICANT'],
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
userIds.push(lead.id)
|
||||||
|
|
||||||
|
const project = await createTestProject(program.id, {
|
||||||
|
title: 'Idempotent Project',
|
||||||
|
competitionCategory: 'STARTUP',
|
||||||
|
})
|
||||||
|
await prisma.teamMember.create({
|
||||||
|
data: { projectId: project.id, userId: lead.id, role: 'LEAD' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const deadline = new Date(Date.now() + 6 * 3_600_000)
|
||||||
|
const token = `tok_idempotent_${uid()}`
|
||||||
|
await prisma.finalistConfirmation.create({
|
||||||
|
data: {
|
||||||
|
projectId: project.id,
|
||||||
|
category: 'STARTUP',
|
||||||
|
status: 'PENDING',
|
||||||
|
deadline,
|
||||||
|
token,
|
||||||
|
reminderSentAt: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// First call — should send 1
|
||||||
|
const first = await sendDueConfirmationReminders(prisma)
|
||||||
|
expect(first.remindersSent).toBeGreaterThanOrEqual(1)
|
||||||
|
|
||||||
|
// Second call — same row, reminderSentAt is now set → 0
|
||||||
|
const second = await sendDueConfirmationReminders(prisma)
|
||||||
|
expect(second.remindersSent).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT send for a PENDING row whose deadline is outside the reminder window (48h from now, 12h window)', async () => {
|
||||||
|
const program = await createTestProgram({ name: `reminder-notdue-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||||
|
await createTestRound(competition.id, {
|
||||||
|
roundType: 'LIVE_FINAL',
|
||||||
|
configJson: { reminderHoursBeforeDeadline: 12 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const lead = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: uid('user'),
|
||||||
|
email: `lead-notdue-${uid()}@test.local`,
|
||||||
|
name: 'Not-Due Lead',
|
||||||
|
role: 'APPLICANT',
|
||||||
|
roles: ['APPLICANT'],
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
userIds.push(lead.id)
|
||||||
|
|
||||||
|
const project = await createTestProject(program.id, {
|
||||||
|
title: 'Not Due Project',
|
||||||
|
competitionCategory: 'STARTUP',
|
||||||
|
})
|
||||||
|
await prisma.teamMember.create({
|
||||||
|
data: { projectId: project.id, userId: lead.id, role: 'LEAD' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deadline 48 hours from now — far outside the 12h window
|
||||||
|
const deadline = new Date(Date.now() + 48 * 3_600_000)
|
||||||
|
const token = `tok_notdue_${uid()}`
|
||||||
|
await prisma.finalistConfirmation.create({
|
||||||
|
data: {
|
||||||
|
projectId: project.id,
|
||||||
|
category: 'STARTUP',
|
||||||
|
status: 'PENDING',
|
||||||
|
deadline,
|
||||||
|
token,
|
||||||
|
reminderSentAt: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await sendDueConfirmationReminders(prisma)
|
||||||
|
// The only unflagged row in this program has deadline 48h out, should not be sent
|
||||||
|
const notification = await prisma.inAppNotification.findFirst({
|
||||||
|
where: { userId: lead.id, type: 'FINALIST_REMINDER' },
|
||||||
|
})
|
||||||
|
expect(notification).toBeNull()
|
||||||
|
// reminderSentAt still null
|
||||||
|
const row = await prisma.finalistConfirmation.findUniqueOrThrow({
|
||||||
|
where: { projectId: project.id },
|
||||||
|
})
|
||||||
|
expect(row.reminderSentAt).toBeNull()
|
||||||
|
void result // result.remindersSent may be > 0 from other programs' rows already in DB
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user