feat: lunch cron endpoints — reminders + recap
Both endpoints follow the existing GET + x-cron-secret pattern. Per-event try/catch ensures one failing event does not poison the sweep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
69
src/app/api/cron/lunch-recap/route.ts
Normal file
69
src/app/api/cron/lunch-recap/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sendLunchRecapEmail } from '@/lib/email'
|
||||
import { buildRecapPayload } from '@/server/services/lunch-recap'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
/**
|
||||
* Cron: when a lunch event is past its change deadline and admins have
|
||||
* left auto-recap on (cronEnabled), send the recap to admins +
|
||||
* extraRecipients and stamp recapSentAt. Idempotent.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const now = new Date()
|
||||
const events = await prisma.lunchEvent.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
cronEnabled: true,
|
||||
recapSentAt: null,
|
||||
eventAt: { not: null },
|
||||
},
|
||||
})
|
||||
let sent = 0
|
||||
for (const event of events) {
|
||||
try {
|
||||
if (!event.eventAt) continue
|
||||
const deadline = new Date(
|
||||
event.eventAt.getTime() - event.changeCutoffHours * 3_600_000,
|
||||
)
|
||||
if (now < deadline) continue
|
||||
const payload = await buildRecapPayload(prisma, event.programId)
|
||||
const adminUsers = await prisma.user.findMany({
|
||||
where: {
|
||||
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
|
||||
email: { not: '' },
|
||||
},
|
||||
select: { email: true },
|
||||
})
|
||||
const recipients = [
|
||||
...adminUsers.map((u) => u.email).filter(Boolean),
|
||||
...event.extraRecipients,
|
||||
]
|
||||
try {
|
||||
await sendLunchRecapEmail(recipients, payload)
|
||||
} catch (e) {
|
||||
console.error('[lunch-recap] email send failed', event.id, e)
|
||||
}
|
||||
await prisma.lunchEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { recapSentAt: new Date() },
|
||||
})
|
||||
await logAudit({
|
||||
prisma,
|
||||
userId: null,
|
||||
action: 'LUNCH_RECAP_SENT',
|
||||
entityType: 'LunchEvent',
|
||||
entityId: event.id,
|
||||
detailsJson: { recipientCount: recipients.length, source: 'cron' },
|
||||
})
|
||||
sent++
|
||||
} catch (e) {
|
||||
console.error('[lunch-recap] event failed', event.id, e)
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ ok: true, sent, processedEvents: events.length })
|
||||
}
|
||||
73
src/app/api/cron/lunch-reminders/route.ts
Normal file
73
src/app/api/cron/lunch-reminders/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sendLunchReminderEmail } from '@/lib/email'
|
||||
|
||||
/**
|
||||
* Cron: send a single reminder email per attending member who hasn't picked
|
||||
* a lunch dish yet, when we're inside the reminder window
|
||||
* (deadline - reminderHoursBeforeDeadline) <= now < deadline.
|
||||
*
|
||||
* Idempotent — `LunchEvent.reminderSentAt` blocks repeat sends.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const now = new Date()
|
||||
const events = await prisma.lunchEvent.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
reminderSentAt: null,
|
||||
reminderHoursBeforeDeadline: { not: null },
|
||||
eventAt: { not: null },
|
||||
},
|
||||
})
|
||||
let sent = 0
|
||||
for (const event of events) {
|
||||
try {
|
||||
if (!event.eventAt || event.reminderHoursBeforeDeadline == null) continue
|
||||
const deadline = new Date(
|
||||
event.eventAt.getTime() - event.changeCutoffHours * 3_600_000,
|
||||
)
|
||||
const reminderAt = new Date(
|
||||
deadline.getTime() - event.reminderHoursBeforeDeadline * 3_600_000,
|
||||
)
|
||||
if (now < reminderAt || now >= deadline) continue
|
||||
|
||||
const ams = await prisma.attendingMember.findMany({
|
||||
where: {
|
||||
confirmation: {
|
||||
project: { programId: event.programId },
|
||||
status: 'CONFIRMED',
|
||||
},
|
||||
lunchPick: { is: { pickedAt: null } },
|
||||
},
|
||||
include: { user: { select: { name: true, email: true } } },
|
||||
})
|
||||
for (const am of ams) {
|
||||
if (!am.user.email) continue
|
||||
try {
|
||||
await sendLunchReminderEmail({
|
||||
to: am.user.email,
|
||||
memberName: am.user.name ?? am.user.email,
|
||||
eventAt: event.eventAt,
|
||||
venue: event.venue,
|
||||
changeDeadline: deadline,
|
||||
pickUrl: `${process.env.NEXTAUTH_URL ?? ''}/applicant`,
|
||||
})
|
||||
sent++
|
||||
} catch (e) {
|
||||
console.error('[lunch-reminders] send failed for', am.user.email, e)
|
||||
}
|
||||
}
|
||||
await prisma.lunchEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { reminderSentAt: new Date() },
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[lunch-reminders] event failed', event.id, e)
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ ok: true, sent, processedEvents: events.length })
|
||||
}
|
||||
204
tests/unit/lunch-cron.test.ts
Normal file
204
tests/unit/lunch-cron.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { prisma } from '../setup'
|
||||
import {
|
||||
createTestUser,
|
||||
createTestProgram,
|
||||
createTestProject,
|
||||
cleanupTestData,
|
||||
uid,
|
||||
} from '../helpers'
|
||||
|
||||
vi.mock('@/lib/email', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/email')>('@/lib/email')
|
||||
return {
|
||||
...actual,
|
||||
sendLunchReminderEmail: vi.fn(async () => undefined),
|
||||
sendLunchRecapEmail: vi.fn(async () => undefined),
|
||||
}
|
||||
})
|
||||
|
||||
const programIds: string[] = []
|
||||
const userIds: string[] = []
|
||||
|
||||
afterAll(async () => {
|
||||
for (const programId of programIds) {
|
||||
await prisma.memberLunchPick.deleteMany({
|
||||
where: { attendingMember: { confirmation: { project: { programId } } } },
|
||||
})
|
||||
await prisma.attendingMember.deleteMany({
|
||||
where: { confirmation: { project: { programId } } },
|
||||
})
|
||||
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
|
||||
await prisma.dish.deleteMany({ where: { lunchEvent: { programId } } })
|
||||
await prisma.lunchEvent.deleteMany({ where: { programId } })
|
||||
await cleanupTestData(programId, [])
|
||||
}
|
||||
if (userIds.length > 0) {
|
||||
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } })
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
|
||||
}
|
||||
})
|
||||
|
||||
async function callCron(
|
||||
path: 'lunch-reminders' | 'lunch-recap',
|
||||
secret = process.env.CRON_SECRET ?? '',
|
||||
) {
|
||||
const mod =
|
||||
path === 'lunch-reminders'
|
||||
? await import('@/app/api/cron/lunch-reminders/route')
|
||||
: await import('@/app/api/cron/lunch-recap/route')
|
||||
const req = new Request(`http://test.local/api/cron/${path}`, {
|
||||
method: 'GET',
|
||||
headers: secret ? { 'x-cron-secret': secret } : {},
|
||||
})
|
||||
return mod.GET(req as unknown as Parameters<typeof mod.GET>[0])
|
||||
}
|
||||
|
||||
describe('GET /api/cron/lunch-reminders', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
if (!process.env.CRON_SECRET) process.env.CRON_SECRET = 'test-secret'
|
||||
})
|
||||
|
||||
it('rejects without CRON_SECRET', async () => {
|
||||
const res = await callCron('lunch-reminders', '')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('skips events outside the reminder window', async () => {
|
||||
const program = await createTestProgram({ name: `cron-rmd-out-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
await prisma.lunchEvent.create({
|
||||
data: {
|
||||
programId: program.id,
|
||||
enabled: true,
|
||||
eventAt: new Date(Date.now() + 30 * 86_400_000),
|
||||
changeCutoffHours: 48,
|
||||
reminderHoursBeforeDeadline: 24,
|
||||
},
|
||||
})
|
||||
const res = await callCron('lunch-reminders')
|
||||
expect(res.status).toBe(200)
|
||||
const { sendLunchReminderEmail } = await import('@/lib/email')
|
||||
expect(sendLunchReminderEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends reminders for unpicked attendees inside the window and stamps reminderSentAt', async () => {
|
||||
const program = await createTestProgram({ name: `cron-rmd-in-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const u = await createTestUser('APPLICANT')
|
||||
userIds.push(u.id)
|
||||
const project = await createTestProject(program.id, {
|
||||
title: `cron-${uid()}`, competitionCategory: 'STARTUP',
|
||||
})
|
||||
const conf = await prisma.finalistConfirmation.create({
|
||||
data: {
|
||||
projectId: project.id, category: 'STARTUP', status: 'CONFIRMED',
|
||||
deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}`,
|
||||
},
|
||||
})
|
||||
const am = await prisma.attendingMember.create({
|
||||
data: { confirmationId: conf.id, userId: u.id },
|
||||
})
|
||||
await prisma.memberLunchPick.create({ data: { attendingMemberId: am.id } })
|
||||
const eventAt = new Date(Date.now() + 25 * 3_600_000) // 25h from now
|
||||
await prisma.lunchEvent.create({
|
||||
data: {
|
||||
programId: program.id,
|
||||
enabled: true,
|
||||
eventAt, // deadline = eventAt - 24h cutoff = ~1h from now
|
||||
changeCutoffHours: 24,
|
||||
reminderHoursBeforeDeadline: 4, // window: deadline-4h .. deadline → spans now
|
||||
},
|
||||
})
|
||||
const res = await callCron('lunch-reminders')
|
||||
expect(res.status).toBe(200)
|
||||
const { sendLunchReminderEmail } = await import('@/lib/email')
|
||||
expect(sendLunchReminderEmail).toHaveBeenCalledTimes(1)
|
||||
const row = await prisma.lunchEvent.findUnique({ where: { programId: program.id } })
|
||||
expect(row?.reminderSentAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('is idempotent — re-running with reminderSentAt set does not resend', async () => {
|
||||
const program = await createTestProgram({ name: `cron-rmd-idem-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
await prisma.lunchEvent.create({
|
||||
data: {
|
||||
programId: program.id,
|
||||
enabled: true,
|
||||
eventAt: new Date(Date.now() + 25 * 3_600_000),
|
||||
changeCutoffHours: 24,
|
||||
reminderHoursBeforeDeadline: 4,
|
||||
reminderSentAt: new Date(),
|
||||
},
|
||||
})
|
||||
const res = await callCron('lunch-reminders')
|
||||
expect(res.status).toBe(200)
|
||||
const { sendLunchReminderEmail } = await import('@/lib/email')
|
||||
expect(sendLunchReminderEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/cron/lunch-recap', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
if (!process.env.CRON_SECRET) process.env.CRON_SECRET = 'test-secret'
|
||||
})
|
||||
|
||||
it('rejects without CRON_SECRET', async () => {
|
||||
const res = await callCron('lunch-recap', '')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('skips events with cronEnabled=false', async () => {
|
||||
const program = await createTestProgram({ name: `cron-rec-off-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
await prisma.lunchEvent.create({
|
||||
data: {
|
||||
programId: program.id, enabled: true, cronEnabled: false,
|
||||
eventAt: new Date(Date.now() - 86_400_000),
|
||||
changeCutoffHours: 24,
|
||||
},
|
||||
})
|
||||
const res = await callCron('lunch-recap')
|
||||
expect(res.status).toBe(200)
|
||||
const { sendLunchRecapEmail } = await import('@/lib/email')
|
||||
expect(sendLunchRecapEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips events with recapSentAt already set', async () => {
|
||||
const program = await createTestProgram({ name: `cron-rec-sent-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
await prisma.lunchEvent.create({
|
||||
data: {
|
||||
programId: program.id, enabled: true, cronEnabled: true,
|
||||
eventAt: new Date(Date.now() - 86_400_000),
|
||||
changeCutoffHours: 24, recapSentAt: new Date(),
|
||||
},
|
||||
})
|
||||
const res = await callCron('lunch-recap')
|
||||
expect(res.status).toBe(200)
|
||||
const { sendLunchRecapEmail } = await import('@/lib/email')
|
||||
expect(sendLunchRecapEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends recap once and stamps recapSentAt when past deadline', async () => {
|
||||
const program = await createTestProgram({ name: `cron-rec-go-${uid()}` })
|
||||
programIds.push(program.id)
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
await prisma.lunchEvent.create({
|
||||
data: {
|
||||
programId: program.id, enabled: true, cronEnabled: true,
|
||||
eventAt: new Date(Date.now() - 86_400_000),
|
||||
changeCutoffHours: 24,
|
||||
},
|
||||
})
|
||||
const res = await callCron('lunch-recap')
|
||||
expect(res.status).toBe(200)
|
||||
const { sendLunchRecapEmail } = await import('@/lib/email')
|
||||
expect(sendLunchRecapEmail).toHaveBeenCalledTimes(1)
|
||||
const row = await prisma.lunchEvent.findUnique({ where: { programId: program.id } })
|
||||
expect(row?.recapSentAt).not.toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user