diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts index ee70c07..7070a2b 100644 --- a/src/server/routers/_app.ts +++ b/src/server/routers/_app.ts @@ -52,6 +52,7 @@ import { resultLockRouter } from './resultLock' // Grand-finale logistics import { finalistRouter } from './finalist' import { logisticsRouter } from './logistics' +import { lunchRouter } from './lunch' /** * Root tRPC router that combines all domain routers @@ -110,6 +111,7 @@ export const appRouter = router({ // Grand-finale logistics finalist: finalistRouter, logistics: logisticsRouter, + lunch: lunchRouter, }) export type AppRouter = typeof appRouter diff --git a/src/server/routers/lunch.ts b/src/server/routers/lunch.ts new file mode 100644 index 0000000..00e5175 --- /dev/null +++ b/src/server/routers/lunch.ts @@ -0,0 +1,81 @@ +import { z } from 'zod' +import { TRPCError } from '@trpc/server' +import { router, adminProcedure, protectedProcedure } from '../trpc' +import { logAudit } from '../utils/audit' + +// ─── Shared zod schemas ────────────────────────────────────────────────────── + +const dietaryTags = z.array( + z.enum(['VEGETARIAN', 'VEGAN', 'GLUTEN_FREE', 'PESCATARIAN']), +) + +const allergens = z.array( + z.enum([ + 'GLUTEN', 'CRUSTACEANS', 'EGGS', 'FISH', 'PEANUTS', 'SOYBEANS', 'MILK', + 'TREE_NUTS', 'CELERY', 'MUSTARD', 'SESAME', 'SULPHITES', 'LUPIN', 'MOLLUSCS', + ]), +) + +// ─── Router ────────────────────────────────────────────────────────────────── + +export const lunchRouter = router({ + /** + * Get-or-create the LunchEvent for a program. Lazy creation mirrors + * the hotel pattern: callers don't have to know whether the row + * already exists. + */ + getEvent: adminProcedure + .input(z.object({ programId: z.string() })) + .query(async ({ ctx, input }) => { + const existing = await ctx.prisma.lunchEvent.findUnique({ + where: { programId: input.programId }, + }) + if (existing) return existing + return ctx.prisma.lunchEvent.create({ data: { programId: input.programId } }) + }), + + /** Patch any subset of LunchEvent config fields. Audit-logged. */ + updateEvent: adminProcedure + .input( + z.object({ + programId: z.string(), + enabled: z.boolean().optional(), + eventAt: z.date().nullable().optional(), + endAt: z.date().nullable().optional(), + venue: z.string().nullable().optional(), + notes: z.string().nullable().optional(), + changeCutoffHours: z.number().int().min(0).max(720).optional(), + reminderHoursBeforeDeadline: z + .number() + .int() + .min(0) + .max(720) + .nullable() + .optional(), + cronEnabled: z.boolean().optional(), + extraRecipients: z.array(z.string().email()).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { programId, ...patch } = input + // Lazy-create before patching so updateEvent doubles as "create + update" + await ctx.prisma.lunchEvent.upsert({ + where: { programId }, + create: { programId }, + update: {}, + }) + const updated = await ctx.prisma.lunchEvent.update({ + where: { programId }, + data: patch, + }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'LUNCH_EVENT_UPDATED', + entityType: 'LunchEvent', + entityId: updated.id, + detailsJson: patch as Record, + }) + return updated + }), +}) diff --git a/tests/unit/lunch-router.test.ts b/tests/unit/lunch-router.test.ts new file mode 100644 index 0000000..e2cdffd --- /dev/null +++ b/tests/unit/lunch-router.test.ts @@ -0,0 +1,115 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, + createTestProgram, + createTestProject, + cleanupTestData, + uid, +} from '../helpers' +import { lunchRouter } from '@/server/routers/lunch' + +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.externalAttendee.deleteMany({ where: { lunchEvent: { programId } } }) + await prisma.dish.deleteMany({ where: { lunchEvent: { 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 } } }) + } +}) + +async function newAdminCaller() { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const caller = createCaller(lunchRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + return { admin, caller } +} + +describe('lunch.getEvent', () => { + it('lazily creates a LunchEvent on first call', async () => { + const program = await createTestProgram({ name: `lunch-get-${uid()}` }) + programIds.push(program.id) + const { caller } = await newAdminCaller() + const result = await caller.getEvent({ programId: program.id }) + expect(result.programId).toBe(program.id) + expect(result.enabled).toBe(false) + expect(result.changeCutoffHours).toBe(48) + const row = await prisma.lunchEvent.findUnique({ where: { programId: program.id } }) + expect(row).not.toBeNull() + }) + + it('returns the same row on subsequent calls', async () => { + const program = await createTestProgram({ name: `lunch-get-${uid()}` }) + programIds.push(program.id) + const { caller } = await newAdminCaller() + const a = await caller.getEvent({ programId: program.id }) + const b = await caller.getEvent({ programId: program.id }) + expect(a.id).toBe(b.id) + }) +}) + +describe('lunch.updateEvent', () => { + it('patches an arbitrary subset of fields', async () => { + const program = await createTestProgram({ name: `lunch-upd-${uid()}` }) + programIds.push(program.id) + const { caller } = await newAdminCaller() + await caller.getEvent({ programId: program.id }) + const updated = await caller.updateEvent({ + programId: program.id, + enabled: true, + eventAt: new Date('2026-06-28T12:30:00Z'), + venue: 'Hôtel Hermitage', + changeCutoffHours: 24, + extraRecipients: ['caterer@example.com'], + }) + expect(updated.enabled).toBe(true) + expect(updated.venue).toBe('Hôtel Hermitage') + expect(updated.changeCutoffHours).toBe(24) + expect(updated.extraRecipients).toEqual(['caterer@example.com']) + }) + + it('lazy-creates the event on first updateEvent', async () => { + const program = await createTestProgram({ name: `lunch-lazy-${uid()}` }) + programIds.push(program.id) + const { caller } = await newAdminCaller() + const updated = await caller.updateEvent({ + programId: program.id, + enabled: true, + }) + expect(updated.enabled).toBe(true) + expect(updated.programId).toBe(program.id) + }) + + it('rejects non-admin callers', async () => { + const program = await createTestProgram({ name: `lunch-rej-${uid()}` }) + programIds.push(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.updateEvent({ programId: program.id, enabled: true }), + ).rejects.toThrow() + }) +})