diff --git a/src/server/routers/lunch.ts b/src/server/routers/lunch.ts index 00e5175..0c1f817 100644 --- a/src/server/routers/lunch.ts +++ b/src/server/routers/lunch.ts @@ -34,6 +34,101 @@ export const lunchRouter = router({ 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 } + }), + /** 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 e2cdffd..cc0421b 100644 --- a/tests/unit/lunch-router.test.ts +++ b/tests/unit/lunch-router.test.ts @@ -66,6 +66,88 @@ describe('lunch.getEvent', () => { }) }) +describe('dish CRUD', () => { + it('createDish + listDishes returns dishes ordered by sortOrder', async () => { + const program = await createTestProgram({ name: `dish-list-${uid()}` }) + programIds.push(program.id) + const { caller } = await newAdminCaller() + const event = await caller.getEvent({ programId: program.id }) + await caller.createDish({ + lunchEventId: event.id, name: 'Sea bass', dietaryTags: ['PESCATARIAN'], sortOrder: 1, + }) + await caller.createDish({ + lunchEventId: event.id, name: 'Risotto', dietaryTags: ['VEGETARIAN'], sortOrder: 0, + }) + const dishes = await caller.listDishes({ lunchEventId: event.id }) + expect(dishes.map((d) => d.name)).toEqual(['Risotto', 'Sea bass']) + }) + + it('updateDish patches name + tags', async () => { + const program = await createTestProgram({ name: `dish-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: 'A', dietaryTags: [] }) + const updated = await caller.updateDish({ + dishId: dish.id, name: 'B', dietaryTags: ['VEGAN'], + }) + expect(updated.name).toBe('B') + expect(updated.dietaryTags).toEqual(['VEGAN']) + }) + + it('deleteDish sets dishId=null on existing picks', async () => { + const program = await createTestProgram({ name: `dish-del-${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: 'X', dietaryTags: [] }) + const user = await createTestUser('APPLICANT') + userIds.push(user.id) + const project = await createTestProject(program.id, { + title: `dish-del-proj-${uid()}`, + competitionCategory: 'STARTUP', + }) + 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 member = await prisma.attendingMember.create({ + data: { confirmationId: conf.id, userId: user.id }, + }) + await prisma.memberLunchPick.create({ + data: { attendingMemberId: member.id, dishId: dish.id, pickedAt: new Date() }, + }) + await caller.deleteDish({ dishId: dish.id }) + const pick = await prisma.memberLunchPick.findUnique({ + where: { attendingMemberId: member.id }, + }) + expect(pick?.dishId).toBeNull() + }) + + it('reorderDishes commits new sortOrder values', async () => { + const program = await createTestProgram({ name: `dish-reorder-${uid()}` }) + programIds.push(program.id) + const { caller } = await newAdminCaller() + const event = await caller.getEvent({ programId: program.id }) + const a = await caller.createDish({ + lunchEventId: event.id, name: 'a', dietaryTags: [], sortOrder: 0, + }) + const b = await caller.createDish({ + lunchEventId: event.id, name: 'b', dietaryTags: [], sortOrder: 1, + }) + await caller.reorderDishes({ + ordered: [ + { dishId: b.id, sortOrder: 0 }, + { dishId: a.id, sortOrder: 1 }, + ], + }) + const dishes = await caller.listDishes({ lunchEventId: event.id }) + expect(dishes.map((d) => d.name)).toEqual(['b', 'a']) + }) +}) + describe('lunch.updateEvent', () => { it('patches an arbitrary subset of fields', async () => { const program = await createTestProgram({ name: `lunch-upd-${uid()}` })