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 { NextResponse, type NextRequest } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { sendLunchReminderEmail } from '@/lib/email'
|
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
|
* 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
|
if (now < reminderAt || now >= deadline) continue
|
||||||
|
|
||||||
const ams = await prisma.attendingMember.findMany({
|
const ams = await selectUnpickedAttendees(prisma, event)
|
||||||
where: {
|
|
||||||
confirmation: {
|
|
||||||
project: { programId: event.programId },
|
|
||||||
status: 'CONFIRMED',
|
|
||||||
},
|
|
||||||
lunchPick: { is: { pickedAt: null } },
|
|
||||||
},
|
|
||||||
include: { user: { select: { name: true, email: true } } },
|
|
||||||
})
|
|
||||||
for (const am of ams) {
|
for (const am of ams) {
|
||||||
if (!am.user.email) continue
|
if (!am.user.email) continue
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -11,15 +11,28 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog'
|
} 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'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export function LunchRecapActions({
|
export function LunchRecapActions({
|
||||||
programId,
|
programId,
|
||||||
|
lunchEventId,
|
||||||
recapSentAt,
|
recapSentAt,
|
||||||
extraRecipientCount,
|
extraRecipientCount,
|
||||||
}: {
|
}: {
|
||||||
programId: string
|
programId: string
|
||||||
|
lunchEventId: string
|
||||||
recapSentAt: Date | null
|
recapSentAt: Date | null
|
||||||
extraRecipientCount: number
|
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 } =
|
const { data: preview, isLoading: loadingPreview } =
|
||||||
trpc.lunch.getRecapPreview.useQuery(
|
trpc.lunch.getRecapPreview.useQuery(
|
||||||
{ programId },
|
{ programId },
|
||||||
@@ -68,6 +90,31 @@ export function LunchRecapActions({
|
|||||||
>
|
>
|
||||||
<Send className="mr-2 h-4 w-4" /> Send recap now
|
<Send className="mr-2 h-4 w-4" /> Send recap now
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{recapSentAt
|
{recapSentAt
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function LunchTab({ programId }: { programId: string }) {
|
|||||||
/>
|
/>
|
||||||
<LunchRecapActions
|
<LunchRecapActions
|
||||||
programId={programId}
|
programId={programId}
|
||||||
|
lunchEventId={event.id}
|
||||||
recapSentAt={event.recapSentAt}
|
recapSentAt={event.recapSentAt}
|
||||||
extraRecipientCount={event.extraRecipients.length}
|
extraRecipientCount={event.extraRecipients.length}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { TRPCError } from '@trpc/server'
|
|||||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
import { buildManifest, buildRecapPayload } from '../services/lunch-recap'
|
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'
|
import { csvCell } from '@/lib/csv'
|
||||||
|
|
||||||
// ─── Shared zod schemas ──────────────────────────────────────────────────────
|
// ─── Shared zod schemas ──────────────────────────────────────────────────────
|
||||||
@@ -346,7 +347,11 @@ export const lunchRouter = router({
|
|||||||
await sendLunchRecapEmail(recipients, payload)
|
await sendLunchRecapEmail(recipients, payload)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[lunch.sendRecap] email send failed', 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({
|
const updated = await ctx.prisma.lunchEvent.update({
|
||||||
where: { programId: input.programId },
|
where: { programId: input.programId },
|
||||||
@@ -570,6 +575,45 @@ export const lunchRouter = router({
|
|||||||
return pick
|
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. */
|
/** Patch any subset of LunchEvent config fields. Audit-logged. */
|
||||||
updateEvent: adminProcedure
|
updateEvent: adminProcedure
|
||||||
.input(
|
.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