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) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-04 16:24:01 +02:00
parent 884c96c710
commit 3f25ba112b
6 changed files with 269 additions and 13 deletions

View File

@@ -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<NextResponse> {
)
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 {

View File

@@ -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 className="mr-2 h-4 w-4" /> Send recap now
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" disabled={sendReminders.isPending}>
<Bell className="mr-2 h-4 w-4" /> Send reminders now
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Send lunch pick reminders?</AlertDialogTitle>
<AlertDialogDescription>
This will send a reminder email to all confirmed attendees who
haven&apos;t picked a lunch dish yet. You can do this multiple
times it won&apos;t affect the automatic reminder window.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => sendReminders.mutate({ lunchEventId })}
>
Send reminders
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<p className="text-muted-foreground text-xs">
{recapSentAt

View File

@@ -32,6 +32,7 @@ export function LunchTab({ programId }: { programId: string }) {
/>
<LunchRecapActions
programId={programId}
lunchEventId={event.id}
recapSentAt={event.recapSentAt}
extraRecipientCount={event.extraRecipients.length}
/>

View File

@@ -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(

View File

@@ -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 } } },
})
}