From d779959e5430b79011e7f688fdab2658351b5721 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 29 Apr 2026 02:33:24 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lunch=20member=20reads=20=E2=80=94=20ge?= =?UTF-8?q?tEventForMember=20+=20getTeamPicks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/routers/lunch.ts | 64 ++++++++++++++++++ tests/unit/lunch-router.test.ts | 112 ++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) diff --git a/src/server/routers/lunch.ts b/src/server/routers/lunch.ts index 28f2f79..ffb84d8 100644 --- a/src/server/routers/lunch.ts +++ b/src/server/routers/lunch.ts @@ -225,6 +225,70 @@ export const lunchRouter = router({ return { ok: true as const } }), + // ─── Member reads ──────────────────────────────────────────────────────── + + /** + * Public-ish event view for the applicant dashboard banner. + * Returns null when the lunch event is disabled (banner hidden). + */ + getEventForMember: protectedProcedure + .input(z.object({ programId: z.string() })) + .query(async ({ ctx, input }) => { + const event = await ctx.prisma.lunchEvent.findUnique({ + where: { programId: input.programId }, + select: { + id: true, + enabled: true, + eventAt: true, + endAt: true, + venue: true, + notes: true, + changeCutoffHours: true, + }, + }) + if (!event || !event.enabled) return null + const changeDeadline = event.eventAt + ? new Date(event.eventAt.getTime() - event.changeCutoffHours * 3_600_000) + : null + return { ...event, changeDeadline } + }), + + /** + * All picks for the caller's team. Within-team transparency: every team + * member sees their teammates' picks (lunch picks aren't sensitive). + * Cross-team and admins go through the manifest endpoint instead, which + * has more detail. + */ + getTeamPicks: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }) => { + const userId = ctx.user.id + const role = ctx.user.role + const isAdmin = role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN' + if (!isAdmin) { + const tm = await ctx.prisma.teamMember.findFirst({ + where: { projectId: input.projectId, userId }, + }) + if (!tm) throw new TRPCError({ code: 'FORBIDDEN' }) + } + const ams = await ctx.prisma.attendingMember.findMany({ + where: { confirmation: { projectId: input.projectId } }, + include: { + user: { select: { id: true, name: true, email: true } }, + lunchPick: { include: { dish: true } }, + }, + }) + return ams.map((am) => ({ + attendingMemberId: am.id, + userId: am.user.id, + memberName: am.user.name ?? am.user.email, + dish: am.lunchPick?.dish ?? null, + allergens: am.lunchPick?.allergens ?? [], + allergenOther: am.lunchPick?.allergenOther ?? null, + hasPicked: !!am.lunchPick?.pickedAt, + })) + }), + // ─── Mixed-permission picker ───────────────────────────────────────────── /** diff --git a/tests/unit/lunch-router.test.ts b/tests/unit/lunch-router.test.ts index fa0477b..59768de 100644 --- a/tests/unit/lunch-router.test.ts +++ b/tests/unit/lunch-router.test.ts @@ -218,6 +218,118 @@ describe('external attendees CRUD', () => { }) }) +describe('lunch.getEventForMember', () => { + it('returns event details when enabled', async () => { + const program = await createTestProgram({ name: `efm-en-${uid()}` }) + programIds.push(program.id) + const member = await createTestUser('APPLICANT') + userIds.push(member.id) + await prisma.lunchEvent.create({ + data: { + programId: program.id, + enabled: true, + eventAt: new Date('2026-06-28T12:30:00Z'), + venue: 'Hôtel', + changeCutoffHours: 48, + }, + }) + const caller = createCaller(lunchRouter, { + id: member.id, + email: member.email, + role: 'APPLICANT', + }) + const result = await caller.getEventForMember({ programId: program.id }) + expect(result?.venue).toBe('Hôtel') + expect(result?.changeDeadline).toBeInstanceOf(Date) + }) + + it('returns null when event is disabled', async () => { + const program = await createTestProgram({ name: `efm-dis-${uid()}` }) + programIds.push(program.id) + const member = await createTestUser('APPLICANT') + userIds.push(member.id) + await prisma.lunchEvent.create({ + data: { programId: program.id, enabled: false }, + }) + const caller = createCaller(lunchRouter, { + id: member.id, + email: member.email, + role: 'APPLICANT', + }) + const result = await caller.getEventForMember({ programId: program.id }) + expect(result).toBeNull() + }) +}) + +describe('lunch.getTeamPicks', () => { + it('returns picks for all attending members of the caller team', async () => { + const program = await createTestProgram({ name: `tp-ok-${uid()}` }) + programIds.push(program.id) + const lead = await createTestUser('APPLICANT') + const m1 = await createTestUser('APPLICANT') + const m2 = await createTestUser('APPLICANT') + userIds.push(lead.id, m1.id, m2.id) + const project = await createTestProject(program.id, { + title: `tp-${uid()}`, + competitionCategory: 'STARTUP', + }) + await prisma.teamMember.createMany({ + data: [ + { projectId: project.id, userId: lead.id, role: 'LEAD' }, + { projectId: project.id, userId: m1.id, role: 'MEMBER' }, + { projectId: project.id, userId: m2.id, role: 'MEMBER' }, + ], + }) + 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: m1.id }, + }) + const am2 = await prisma.attendingMember.create({ + data: { confirmationId: conf.id, userId: m2.id }, + }) + const event = await prisma.lunchEvent.create({ + data: { programId: program.id, enabled: true }, + }) + const dish = await prisma.dish.create({ + data: { lunchEventId: event.id, name: 'X', dietaryTags: [] }, + }) + await prisma.memberLunchPick.create({ + data: { attendingMemberId: am1.id, dishId: dish.id, pickedAt: new Date() }, + }) + await prisma.memberLunchPick.create({ data: { attendingMemberId: am2.id } }) + + const caller = createCaller(lunchRouter, { + id: lead.id, email: lead.email, role: 'APPLICANT', + }) + const picks = await caller.getTeamPicks({ projectId: project.id }) + expect(picks).toHaveLength(2) + expect(picks.find((p) => p.userId === m1.id)?.hasPicked).toBe(true) + expect(picks.find((p) => p.userId === m2.id)?.hasPicked).toBe(false) + }) + + it('rejects non-team-member callers', async () => { + const program = await createTestProgram({ name: `tp-rej-${uid()}` }) + programIds.push(program.id) + const stranger = await createTestUser('APPLICANT') + userIds.push(stranger.id) + const project = await createTestProject(program.id, { + title: `tp-rej-${uid()}`, + competitionCategory: 'STARTUP', + }) + const caller = createCaller(lunchRouter, { + id: stranger.id, email: stranger.email, role: 'APPLICANT', + }) + await expect( + caller.getTeamPicks({ projectId: project.id }), + ).rejects.toThrow() + }) +}) + describe('lunch.updateEvent', () => { it('patches an arbitrary subset of fields', async () => { const program = await createTestProgram({ name: `lunch-upd-${uid()}` })