From d4e5d54de29e05887ddc07c17608a645a19badc4 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 29 Apr 2026 02:39:51 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lunch=20cron=20endpoints=20=E2=80=94=20?= =?UTF-8?q?reminders=20+=20recap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/api/cron/lunch-recap/route.ts | 69 ++++++++ src/app/api/cron/lunch-reminders/route.ts | 73 ++++++++ tests/unit/lunch-cron.test.ts | 204 ++++++++++++++++++++++ 3 files changed, 346 insertions(+) create mode 100644 src/app/api/cron/lunch-recap/route.ts create mode 100644 src/app/api/cron/lunch-reminders/route.ts create mode 100644 tests/unit/lunch-cron.test.ts diff --git a/src/app/api/cron/lunch-recap/route.ts b/src/app/api/cron/lunch-recap/route.ts new file mode 100644 index 0000000..85cf5e0 --- /dev/null +++ b/src/app/api/cron/lunch-recap/route.ts @@ -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 { + 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 }) +} diff --git a/src/app/api/cron/lunch-reminders/route.ts b/src/app/api/cron/lunch-reminders/route.ts new file mode 100644 index 0000000..82f11ed --- /dev/null +++ b/src/app/api/cron/lunch-reminders/route.ts @@ -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 { + 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 }) +} diff --git a/tests/unit/lunch-cron.test.ts b/tests/unit/lunch-cron.test.ts new file mode 100644 index 0000000..08e1708 --- /dev/null +++ b/tests/unit/lunch-cron.test.ts @@ -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('@/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[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() + }) +})