From 3f25ba112b685d5619de77821ea00e7d5ca92863 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 4 Jun 2026 16:24:01 +0200 Subject: [PATCH] fix(lunch): reminder filter, recap failure surfacing, manual send-reminders - Extract selectUnpickedAttendees helper with OR filter (is null OR pickedAt null) to fix cron missing attendees with no MemberLunchPick row at all - Update cron route to use the helper - sendRecap now throws TRPCError on email failure instead of silently stamping success - Add lunch.sendReminders adminProcedure for manual on-demand reminder sends - Add "Send reminders now" AlertDialog button to LunchRecapActions - Tests: lunch-reminder-filter.test.ts (2 new), all 5 lunch test files pass (40 tests) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/cron/lunch-reminders/route.ts | 12 +- .../admin/logistics/lunch-recap-actions.tsx | 49 +++++- src/components/admin/logistics/lunch-tab.tsx | 1 + src/server/routers/lunch.ts | 48 +++++- src/server/services/lunch-reminders.ts | 32 ++++ tests/unit/lunch-reminder-filter.test.ts | 140 ++++++++++++++++++ 6 files changed, 269 insertions(+), 13 deletions(-) create mode 100644 src/server/services/lunch-reminders.ts create mode 100644 tests/unit/lunch-reminder-filter.test.ts diff --git a/src/app/api/cron/lunch-reminders/route.ts b/src/app/api/cron/lunch-reminders/route.ts index 82f11ed..a64c9b6 100644 --- a/src/app/api/cron/lunch-reminders/route.ts +++ b/src/app/api/cron/lunch-reminders/route.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from 'next/server' import { prisma } from '@/lib/prisma' import { sendLunchReminderEmail } from '@/lib/email' +import { selectUnpickedAttendees } from '@/server/services/lunch-reminders' /** * Cron: send a single reminder email per attending member who hasn't picked @@ -35,16 +36,7 @@ export async function GET(request: NextRequest): Promise { ) 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 } } }, - }) + const ams = await selectUnpickedAttendees(prisma, event) for (const am of ams) { if (!am.user.email) continue try { diff --git a/src/components/admin/logistics/lunch-recap-actions.tsx b/src/components/admin/logistics/lunch-recap-actions.tsx index 61626ba..37fb4b4 100644 --- a/src/components/admin/logistics/lunch-recap-actions.tsx +++ b/src/components/admin/logistics/lunch-recap-actions.tsx @@ -11,15 +11,28 @@ import { DialogTitle, DialogFooter, } from '@/components/ui/dialog' -import { Send, Eye } from 'lucide-react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { Send, Eye, Bell } from 'lucide-react' import { toast } from 'sonner' export function LunchRecapActions({ programId, + lunchEventId, recapSentAt, extraRecipientCount, }: { programId: string + lunchEventId: string recapSentAt: Date | null extraRecipientCount: number }) { @@ -46,6 +59,15 @@ export function LunchRecapActions({ }, }) + const sendReminders = trpc.lunch.sendReminders.useMutation({ + onSuccess: (data) => { + toast.success(`Reminders sent to ${data.sent} attendee${data.sent === 1 ? '' : 's'}`) + }, + onError: (e) => { + toast.error(`Failed to send reminders: ${e.message}`) + }, + }) + const { data: preview, isLoading: loadingPreview } = trpc.lunch.getRecapPreview.useQuery( { programId }, @@ -68,6 +90,31 @@ export function LunchRecapActions({ > Send recap now + + + + + + + Send lunch pick reminders? + + This will send a reminder email to all confirmed attendees who + haven't picked a lunch dish yet. You can do this multiple + times — it won't affect the automatic reminder window. + + + + Cancel + sendReminders.mutate({ lunchEventId })} + > + Send reminders + + + +

{recapSentAt diff --git a/src/components/admin/logistics/lunch-tab.tsx b/src/components/admin/logistics/lunch-tab.tsx index 43056a1..fd859a2 100644 --- a/src/components/admin/logistics/lunch-tab.tsx +++ b/src/components/admin/logistics/lunch-tab.tsx @@ -32,6 +32,7 @@ export function LunchTab({ programId }: { programId: string }) { /> diff --git a/src/server/routers/lunch.ts b/src/server/routers/lunch.ts index 2855066..23371c6 100644 --- a/src/server/routers/lunch.ts +++ b/src/server/routers/lunch.ts @@ -3,7 +3,8 @@ import { TRPCError } from '@trpc/server' import { router, adminProcedure, protectedProcedure } from '../trpc' import { logAudit } from '../utils/audit' import { buildManifest, buildRecapPayload } from '../services/lunch-recap' -import { sendLunchRecapEmail } from '@/lib/email' +import { selectUnpickedAttendees } from '../services/lunch-reminders' +import { sendLunchRecapEmail, sendLunchReminderEmail } from '@/lib/email' import { csvCell } from '@/lib/csv' // ─── Shared zod schemas ────────────────────────────────────────────────────── @@ -346,7 +347,11 @@ export const lunchRouter = router({ await sendLunchRecapEmail(recipients, payload) } catch (e) { console.error('[lunch.sendRecap] email send failed', e) - // Continue — we still stamp recapSentAt and audit so admins see what happened. + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Recap email failed to send: ${e instanceof Error ? e.message : String(e)}`, + cause: e, + }) } const updated = await ctx.prisma.lunchEvent.update({ where: { programId: input.programId }, @@ -570,6 +575,45 @@ export const lunchRouter = router({ return pick }), + /** + * Manually send lunch pick reminders to all unpicked attendees for a given + * LunchEvent. Unlike the cron, this does NOT gate on reminderSentAt and does + * NOT stamp it — manual sends are repeatable. Returns { sent } count. + */ + sendReminders: adminProcedure + .input(z.object({ lunchEventId: z.string() })) + .mutation(async ({ ctx, input }) => { + const event = await ctx.prisma.lunchEvent.findUnique({ + where: { id: input.lunchEventId }, + }) + if (!event) throw new TRPCError({ code: 'NOT_FOUND', message: 'Lunch event not found' }) + + const ams = await selectUnpickedAttendees(ctx.prisma, event) + + const deadline = event.eventAt + ? new Date(event.eventAt.getTime() - event.changeCutoffHours * 3_600_000) + : new Date(Date.now() + 48 * 3_600_000) + + let sent = 0 + 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 ?? new Date(), + venue: event.venue, + changeDeadline: deadline, + pickUrl: `${process.env.NEXTAUTH_URL ?? ''}/applicant`, + }) + sent++ + } catch (e) { + console.error('[lunch.sendReminders] send failed for', am.user.email, e) + } + } + return { sent } + }), + /** Patch any subset of LunchEvent config fields. Audit-logged. */ updateEvent: adminProcedure .input( diff --git a/src/server/services/lunch-reminders.ts b/src/server/services/lunch-reminders.ts new file mode 100644 index 0000000..9c35863 --- /dev/null +++ b/src/server/services/lunch-reminders.ts @@ -0,0 +1,32 @@ +import type { PrismaClient } from '@prisma/client' + +/** + * Return all AttendingMember rows (with user) that: + * - belong to a CONFIRMED FinalistConfirmation in the given program + * - have NOT yet picked a lunch dish (no MemberLunchPick row OR pick row with pickedAt=null) + * + * This is extracted from the cron so it can be unit-tested independently and + * reused by the manual `sendReminders` admin action. + * + * Bug context: Prisma `lunchPick: { is: { pickedAt: null } }` only matches rows + * that EXIST but have pickedAt=null. Attendees with no pick row at all fall + * through. The correct filter is the OR form below. + */ +export async function selectUnpickedAttendees( + prisma: PrismaClient, + event: { id: string; programId: string }, +) { + return prisma.attendingMember.findMany({ + where: { + confirmation: { + project: { programId: event.programId }, + status: 'CONFIRMED', + }, + OR: [ + { lunchPick: { is: null } }, + { lunchPick: { is: { pickedAt: null } } }, + ], + }, + include: { user: { select: { name: true, email: true } } }, + }) +} diff --git a/tests/unit/lunch-reminder-filter.test.ts b/tests/unit/lunch-reminder-filter.test.ts new file mode 100644 index 0000000..21a7e73 --- /dev/null +++ b/tests/unit/lunch-reminder-filter.test.ts @@ -0,0 +1,140 @@ +/** + * Regression: selectUnpickedAttendees must return attendees with NO MemberLunchPick + * row at all, not just attendees whose pick row has pickedAt=null. + * + * Bug: the old cron filter used `lunchPick: { is: { pickedAt: null } }` which + * only matches rows that exist but have pickedAt=null. Attendees with no pick + * row at all were silently skipped. + */ +import { afterAll, describe, expect, it } from 'vitest' +import { prisma } from '../setup' +import { + createTestUser, + createTestProgram, + createTestProject, + cleanupTestData, + uid, +} from '../helpers' +import { selectUnpickedAttendees } from '@/server/services/lunch-reminders' + +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.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 } } }) + } +}) + +describe('selectUnpickedAttendees', () => { + it('returns attendees with no pick row AND unpicked rows; excludes picked', async () => { + const program = await createTestProgram({ name: `rfilter-${uid()}` }) + programIds.push(program.id) + + // Three confirmed attendees on the same program + const u1 = await createTestUser('APPLICANT') // no MemberLunchPick row at all + const u2 = await createTestUser('APPLICANT') // MemberLunchPick with pickedAt=null + const u3 = await createTestUser('APPLICANT') // MemberLunchPick with pickedAt set (PICKED) + userIds.push(u1.id, u2.id, u3.id) + + const project = await createTestProject(program.id, { + title: `rfilter-proj-${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 am1 = await prisma.attendingMember.create({ + data: { confirmationId: conf.id, userId: u1.id }, + }) + const am2 = await prisma.attendingMember.create({ + data: { confirmationId: conf.id, userId: u2.id }, + }) + const am3 = await prisma.attendingMember.create({ + data: { confirmationId: conf.id, userId: u3.id }, + }) + + // am1: NO pick row + // am2: pick row exists but pickedAt=null + await prisma.memberLunchPick.create({ + data: { attendingMemberId: am2.id }, + }) + // am3: pick row with pickedAt set (has picked) + await prisma.memberLunchPick.create({ + data: { attendingMemberId: am3.id, pickedAt: new Date() }, + }) + + const event = await prisma.lunchEvent.create({ + data: { programId: program.id, enabled: true }, + }) + + const result = await selectUnpickedAttendees(prisma, { + id: event.id, + programId: program.id, + }) + + const returnedIds = result.map((am) => am.id).sort() + expect(returnedIds).toContain(am1.id) + expect(returnedIds).toContain(am2.id) + expect(returnedIds).not.toContain(am3.id) + expect(result).toHaveLength(2) + }) + + it('excludes non-CONFIRMED confirmations', async () => { + const program = await createTestProgram({ name: `rfilter-nc-${uid()}` }) + programIds.push(program.id) + + const u = await createTestUser('APPLICANT') + userIds.push(u.id) + + const project = await createTestProject(program.id, { + title: `rfilter-nc-${uid()}`, + competitionCategory: 'STARTUP', + }) + + const conf = await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: 'PENDING', // NOT confirmed + deadline: new Date(Date.now() + 86_400_000), + token: `tok-${uid()}`, + }, + }) + + await prisma.attendingMember.create({ + data: { confirmationId: conf.id, userId: u.id }, + }) + + const event = await prisma.lunchEvent.create({ + data: { programId: program.id, enabled: true }, + }) + + const result = await selectUnpickedAttendees(prisma, { + id: event.id, + programId: program.id, + }) + + expect(result).toHaveLength(0) + }) +})