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 } }) }), // ─── Dish CRUD ──────────────────────────────────────────────────────────── listDishes: adminProcedure .input(z.object({ lunchEventId: z.string() })) .query(({ ctx, input }) => ctx.prisma.dish.findMany({ where: { lunchEventId: input.lunchEventId }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], }), ), createDish: adminProcedure .input( z.object({ lunchEventId: z.string(), name: z.string().min(1).max(200), dietaryTags, sortOrder: z.number().int().optional(), }), ) .mutation(async ({ ctx, input }) => { const dish = await ctx.prisma.dish.create({ data: { lunchEventId: input.lunchEventId, name: input.name, dietaryTags: input.dietaryTags, sortOrder: input.sortOrder ?? 0, }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LUNCH_DISH_CREATED', entityType: 'Dish', entityId: dish.id, detailsJson: { name: dish.name, dietaryTags: dish.dietaryTags }, }) return dish }), updateDish: adminProcedure .input( z.object({ dishId: z.string(), name: z.string().min(1).max(200).optional(), dietaryTags: dietaryTags.optional(), sortOrder: z.number().int().optional(), }), ) .mutation(async ({ ctx, input }) => { const { dishId, ...patch } = input const dish = await ctx.prisma.dish.update({ where: { id: dishId }, data: patch }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LUNCH_DISH_UPDATED', entityType: 'Dish', entityId: dish.id, detailsJson: patch as Record, }) return dish }), deleteDish: adminProcedure .input(z.object({ dishId: z.string() })) .mutation(async ({ ctx, input }) => { const dish = await ctx.prisma.dish.delete({ where: { id: input.dishId } }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LUNCH_DISH_DELETED', entityType: 'Dish', entityId: dish.id, detailsJson: { name: dish.name }, }) return { ok: true as const } }), reorderDishes: adminProcedure .input( z.object({ ordered: z.array( z.object({ dishId: z.string(), sortOrder: z.number().int() }), ), }), ) .mutation(async ({ ctx, input }) => { await ctx.prisma.$transaction( input.ordered.map(({ dishId, sortOrder }) => ctx.prisma.dish.update({ where: { id: dishId }, data: { sortOrder } }), ), ) 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( 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 }), })