feat: auto-create MemberLunchPick on attendee writes

Adds ensureLunchPickForAttendingMember helper called from confirm,
adminConfirm, and editAttendees attendee-creation paths. No-ops when
the program has no LunchEvent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-29 02:28:51 +02:00
parent cdb18cc3d1
commit 1a0afd8c6e
3 changed files with 154 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ import {
} from '../services/in-app-notification'
import { sendFinalistConfirmationEmail } from '@/lib/email'
import { verifyFinalistToken } from '@/lib/finalist-token'
import { ensureLunchPickForAttendingMember } from '../services/lunch-pick-sync'
export const finalistRouter = router({
/** List all per-category finalist slot quotas for a program. */
@@ -351,6 +352,13 @@ export const finalistRouter = router({
data: created.map((m) => ({ attendingMemberId: m.id, status: 'REQUESTED' })),
})
}
const allMembers = await tx.attendingMember.findMany({
where: { confirmationId: confirmation.id, userId: { in: input.attendingUserIds } },
select: { id: true },
})
for (const m of allMembers) {
await ensureLunchPickForAttendingMember(tx, m.id)
}
})
await logAudit({
prisma: ctx.prisma,
@@ -505,6 +513,13 @@ export const finalistRouter = router({
data: created.map((m) => ({ attendingMemberId: m.id, status: 'REQUESTED' })),
})
}
const allMembers = await tx.attendingMember.findMany({
where: { confirmationId: confirmation.id, userId: { in: input.attendingUserIds } },
select: { id: true },
})
for (const m of allMembers) {
await ensureLunchPickForAttendingMember(tx, m.id)
}
})
await logAudit({
prisma: ctx.prisma,
@@ -1071,6 +1086,13 @@ export const finalistRouter = router({
data: created.map((m) => ({ attendingMemberId: m.id, status: 'REQUESTED' })),
})
}
const newMembers = await tx.attendingMember.findMany({
where: { confirmationId: confirmation.id, userId: { in: toCreate } },
select: { id: true },
})
for (const m of newMembers) {
await ensureLunchPickForAttendingMember(tx, m.id)
}
}
})

View File

@@ -0,0 +1,33 @@
import type { Prisma, PrismaClient } from '@prisma/client'
type PrismaLike = PrismaClient | Prisma.TransactionClient
/**
* Ensure a MemberLunchPick row exists for the given AttendingMember.
* No-ops when the parent program has no LunchEvent.
* Idempotent — safe to call repeatedly.
*/
export async function ensureLunchPickForAttendingMember(
prisma: PrismaLike,
attendingMemberId: string,
): Promise<void> {
const member = await prisma.attendingMember.findUnique({
where: { id: attendingMemberId },
select: {
id: true,
confirmation: { select: { project: { select: { programId: true } } } },
lunchPick: { select: { id: true } },
},
})
if (!member) return
if (member.lunchPick) return
const programId = member.confirmation.project.programId
const lunchEvent = await prisma.lunchEvent.findUnique({
where: { programId },
select: { id: true },
})
if (!lunchEvent) return
await prisma.memberLunchPick.create({
data: { attendingMemberId: member.id },
})
}

View File

@@ -0,0 +1,99 @@
import { afterAll, describe, expect, it } from 'vitest'
import { prisma } from '../setup'
import {
createTestUser,
createTestProgram,
createTestProject,
cleanupTestData,
uid,
} from '../helpers'
import { ensureLunchPickForAttendingMember } from '@/server/services/lunch-pick-sync'
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.user.deleteMany({ where: { id: { in: userIds } } })
}
})
async function setupConfirmedAttendee(programId: string) {
const user = await createTestUser('APPLICANT')
userIds.push(user.id)
const project = await createTestProject(programId, {
title: `pick-sync-${uid()}`,
competitionCategory: 'STARTUP',
})
const confirmation = await prisma.finalistConfirmation.create({
data: {
projectId: project.id,
category: 'STARTUP',
status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000),
token: `tok-${uid()}`,
},
})
const member = await prisma.attendingMember.create({
data: { confirmationId: confirmation.id, userId: user.id },
})
return member
}
describe('ensureLunchPickForAttendingMember', () => {
it('creates an empty MemberLunchPick when a LunchEvent exists', async () => {
const program = await createTestProgram({ name: `lunch-sync-${uid()}` })
programIds.push(program.id)
await prisma.lunchEvent.create({ data: { programId: program.id } })
const member = await setupConfirmedAttendee(program.id)
await ensureLunchPickForAttendingMember(prisma, member.id)
const pick = await prisma.memberLunchPick.findUnique({
where: { attendingMemberId: member.id },
})
expect(pick).not.toBeNull()
expect(pick?.dishId).toBeNull()
expect(pick?.pickedAt).toBeNull()
})
it('is idempotent — calling twice does not create a second pick', async () => {
const program = await createTestProgram({ name: `lunch-sync-${uid()}` })
programIds.push(program.id)
await prisma.lunchEvent.create({ data: { programId: program.id } })
const member = await setupConfirmedAttendee(program.id)
await ensureLunchPickForAttendingMember(prisma, member.id)
await ensureLunchPickForAttendingMember(prisma, member.id)
const picks = await prisma.memberLunchPick.findMany({
where: { attendingMemberId: member.id },
})
expect(picks).toHaveLength(1)
})
it('no-ops when no LunchEvent exists for the program', async () => {
const program = await createTestProgram({ name: `lunch-sync-${uid()}` })
programIds.push(program.id)
// Note: no LunchEvent created.
const member = await setupConfirmedAttendee(program.id)
await ensureLunchPickForAttendingMember(prisma, member.id)
const pick = await prisma.memberLunchPick.findUnique({
where: { attendingMemberId: member.id },
})
expect(pick).toBeNull()
})
})