2026-04-29 02:30:06 +02:00
|
|
|
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 } })
|
|
|
|
|
}),
|
|
|
|
|
|
2026-04-29 02:30:49 +02:00
|
|
|
// ─── 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<string, unknown>,
|
|
|
|
|
})
|
|
|
|
|
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 }
|
|
|
|
|
}),
|
|
|
|
|
|
2026-04-29 02:31:28 +02:00
|
|
|
// ─── 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<string, unknown>,
|
|
|
|
|
})
|
|
|
|
|
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 }
|
|
|
|
|
}),
|
|
|
|
|
|
2026-04-29 02:30:06 +02:00
|
|
|
/** 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<string, unknown>,
|
|
|
|
|
})
|
|
|
|
|
return updated
|
|
|
|
|
}),
|
|
|
|
|
})
|