From 06b171b0d48918fda8f1a77f87f2645d92a4e776 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 29 Apr 2026 02:31:28 +0200 Subject: [PATCH] feat: external lunch attendees CRUD Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/routers/lunch.ts | 96 +++++++++++++++++++++++++++++++++ tests/unit/lunch-router.test.ts | 70 ++++++++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/src/server/routers/lunch.ts b/src/server/routers/lunch.ts index 0c1f817..7666af7 100644 --- a/src/server/routers/lunch.ts +++ b/src/server/routers/lunch.ts @@ -129,6 +129,102 @@ export const lunchRouter = router({ return { ok: true as const } }), + // ─── External attendees CRUD ───────────────────────────────────────────── + + listExternals: adminProcedure + .input(z.object({ lunchEventId: z.string() })) + .query(({ ctx, input }) => + ctx.prisma.externalAttendee.findMany({ + where: { lunchEventId: input.lunchEventId }, + orderBy: { createdAt: 'asc' }, + include: { project: { select: { id: true, title: true } } }, + }), + ), + + createExternal: adminProcedure + .input( + z.object({ + lunchEventId: z.string(), + name: z.string().min(1).max(200), + email: z.string().email().optional(), + projectId: z.string().nullable().optional(), + roleNote: z.string().max(500).optional(), + dishId: z.string().nullable().optional(), + allergens: allergens.optional(), + allergenOther: z.string().max(500).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const ext = await ctx.prisma.externalAttendee.create({ + data: { + lunchEventId: input.lunchEventId, + name: input.name, + email: input.email, + projectId: input.projectId ?? null, + roleNote: input.roleNote, + dishId: input.dishId ?? null, + allergens: input.allergens ?? [], + allergenOther: input.allergenOther, + }, + }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'LUNCH_EXTERNAL_CREATED', + entityType: 'ExternalAttendee', + entityId: ext.id, + detailsJson: { name: ext.name, projectId: ext.projectId }, + }) + return ext + }), + + updateExternal: adminProcedure + .input( + z.object({ + externalId: z.string(), + name: z.string().min(1).max(200).optional(), + email: z.string().email().nullable().optional(), + projectId: z.string().nullable().optional(), + roleNote: z.string().max(500).nullable().optional(), + dishId: z.string().nullable().optional(), + allergens: allergens.optional(), + allergenOther: z.string().max(500).nullable().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { externalId, ...patch } = input + const ext = await ctx.prisma.externalAttendee.update({ + where: { id: externalId }, + data: patch, + }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'LUNCH_EXTERNAL_UPDATED', + entityType: 'ExternalAttendee', + entityId: ext.id, + detailsJson: patch as Record, + }) + return ext + }), + + deleteExternal: adminProcedure + .input(z.object({ externalId: z.string() })) + .mutation(async ({ ctx, input }) => { + const ext = await ctx.prisma.externalAttendee.delete({ + where: { id: input.externalId }, + }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'LUNCH_EXTERNAL_DELETED', + entityType: 'ExternalAttendee', + entityId: ext.id, + detailsJson: { name: ext.name }, + }) + return { ok: true as const } + }), + /** Patch any subset of LunchEvent config fields. Audit-logged. */ updateEvent: adminProcedure .input( diff --git a/tests/unit/lunch-router.test.ts b/tests/unit/lunch-router.test.ts index cc0421b..fa0477b 100644 --- a/tests/unit/lunch-router.test.ts +++ b/tests/unit/lunch-router.test.ts @@ -148,6 +148,76 @@ describe('dish CRUD', () => { }) }) +describe('external attendees CRUD', () => { + it('listExternals returns standalone + project-attached entries', async () => { + const program = await createTestProgram({ name: `ext-list-${uid()}` }) + programIds.push(program.id) + const { caller } = await newAdminCaller() + const event = await caller.getEvent({ programId: program.id }) + const project = await createTestProject(program.id, { + title: `ext-${uid()}`, + competitionCategory: 'STARTUP', + }) + await caller.createExternal({ + lunchEventId: event.id, name: 'Princess Albert', + roleNote: 'Foundation rep', + }) + await caller.createExternal({ + lunchEventId: event.id, name: 'Speaker Smith', + projectId: project.id, email: 's@example.com', + }) + const list = await caller.listExternals({ lunchEventId: event.id }) + expect(list).toHaveLength(2) + expect(list.find((e) => e.name === 'Princess Albert')?.projectId).toBeNull() + expect(list.find((e) => e.name === 'Speaker Smith')?.projectId).toBe(project.id) + }) + + it('updateExternal patches fields including dishId + allergens', async () => { + const program = await createTestProgram({ name: `ext-upd-${uid()}` }) + programIds.push(program.id) + const { caller } = await newAdminCaller() + const event = await caller.getEvent({ programId: program.id }) + const dish = await caller.createDish({ + lunchEventId: event.id, name: 'Steak', dietaryTags: [], + }) + const ext = await caller.createExternal({ lunchEventId: event.id, name: 'X' }) + const updated = await caller.updateExternal({ + externalId: ext.id, dishId: dish.id, allergens: ['GLUTEN', 'TREE_NUTS'], + allergenOther: 'sulphites in red wine', + }) + expect(updated.dishId).toBe(dish.id) + expect(updated.allergens).toEqual(['GLUTEN', 'TREE_NUTS']) + }) + + it('deleteExternal removes the row', async () => { + const program = await createTestProgram({ name: `ext-del-${uid()}` }) + programIds.push(program.id) + const { caller } = await newAdminCaller() + const event = await caller.getEvent({ programId: program.id }) + const ext = await caller.createExternal({ lunchEventId: event.id, name: 'tmp' }) + await caller.deleteExternal({ externalId: ext.id }) + const list = await caller.listExternals({ lunchEventId: event.id }) + expect(list.find((e) => e.id === ext.id)).toBeUndefined() + }) + + it('rejects non-admin createExternal', async () => { + const program = await createTestProgram({ name: `ext-rej-${uid()}` }) + programIds.push(program.id) + const { caller } = await newAdminCaller() + const event = await caller.getEvent({ programId: program.id }) + const member = await createTestUser('APPLICANT') + userIds.push(member.id) + const memberCaller = createCaller(lunchRouter, { + id: member.id, + email: member.email, + role: 'APPLICANT', + }) + await expect( + memberCaller.createExternal({ lunchEventId: event.id, name: 'nope' }), + ).rejects.toThrow() + }) +}) + describe('lunch.updateEvent', () => { it('patches an arbitrary subset of fields', async () => { const program = await createTestProgram({ name: `lunch-upd-${uid()}` })