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:
Matt
2026-04-29 02:39:51 +02:00
parent 829a7e457a
commit d4e5d54de2
3 changed files with 346 additions and 0 deletions

View 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 })
}

View 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 })
}