From 1a0afd8c6e44640d1879459e52f19a86005e43a8 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 29 Apr 2026 02:28:51 +0200 Subject: [PATCH] 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) --- src/server/routers/finalist.ts | 22 ++++++ src/server/services/lunch-pick-sync.ts | 33 +++++++++ tests/unit/lunch-pick-sync.test.ts | 99 ++++++++++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 src/server/services/lunch-pick-sync.ts create mode 100644 tests/unit/lunch-pick-sync.test.ts diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index 27d9ef2..8834d0e 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -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) + } } }) diff --git a/src/server/services/lunch-pick-sync.ts b/src/server/services/lunch-pick-sync.ts new file mode 100644 index 0000000..0641392 --- /dev/null +++ b/src/server/services/lunch-pick-sync.ts @@ -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 { + 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 }, + }) +} diff --git a/tests/unit/lunch-pick-sync.test.ts b/tests/unit/lunch-pick-sync.test.ts new file mode 100644 index 0000000..3cd4325 --- /dev/null +++ b/tests/unit/lunch-pick-sync.test.ts @@ -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() + }) +})