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:
@@ -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 {
|
||||
|
||||
@@ -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't picked a lunch dish yet. You can do this multiple
|
||||
times — it won'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
|
||||
|
||||
@@ -32,6 +32,7 @@ export function LunchTab({ programId }: { programId: string }) {
|
||||
/>
|
||||
<LunchRecapActions
|
||||
programId={programId}
|
||||
lunchEventId={event.id}
|
||||
recapSentAt={event.recapSentAt}
|
||||
extraRecipientCount={event.extraRecipients.length}
|
||||
/>
|
||||
|
||||
@@ -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(
|
||||
|
||||
32
src/server/services/lunch-reminders.ts
Normal file
32
src/server/services/lunch-reminders.ts
Normal 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 } } },
|
||||
})
|
||||
}
|
||||
140
tests/unit/lunch-reminder-filter.test.ts
Normal file
140
tests/unit/lunch-reminder-filter.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user