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:32:42 +02:00
|
|
|
// ─── Mixed-permission picker ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Upsert a MemberLunchPick. Permission:
|
|
|
|
|
* - admin (SUPER_ADMIN / PROGRAM_ADMIN): always allowed, no deadline cap
|
|
|
|
|
* - team lead of the parent project: allowed before deadline
|
|
|
|
|
* - the AttendingMember.userId themselves: allowed before deadline
|
|
|
|
|
* - everyone else: FORBIDDEN
|
|
|
|
|
* Audit-logged with the actor role (SELF / TEAM_LEAD / ADMIN).
|
|
|
|
|
*/
|
|
|
|
|
upsertPick: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
attendingMemberId: z.string(),
|
|
|
|
|
dishId: z.string().nullable(),
|
|
|
|
|
allergens,
|
|
|
|
|
allergenOther: z.string().max(500).nullable(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const am = await ctx.prisma.attendingMember.findUnique({
|
|
|
|
|
where: { id: input.attendingMemberId },
|
|
|
|
|
include: {
|
|
|
|
|
confirmation: {
|
|
|
|
|
select: {
|
|
|
|
|
project: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
programId: true,
|
|
|
|
|
teamMembers: { select: { userId: true, role: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
lunchPick: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
if (!am) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Attending member not found',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const userId = ctx.user.id
|
|
|
|
|
const userRole = ctx.user.role
|
|
|
|
|
const isAdmin =
|
|
|
|
|
userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN'
|
|
|
|
|
const isSelf = am.userId === userId
|
|
|
|
|
const isLead = am.confirmation.project.teamMembers.some(
|
|
|
|
|
(tm) => tm.userId === userId && tm.role === 'LEAD',
|
|
|
|
|
)
|
|
|
|
|
if (!isAdmin && !isSelf && !isLead) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Not allowed to edit this pick',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cutoff check (admins skip)
|
|
|
|
|
if (!isAdmin) {
|
|
|
|
|
const event = await ctx.prisma.lunchEvent.findUnique({
|
|
|
|
|
where: { programId: am.confirmation.project.programId },
|
|
|
|
|
select: { eventAt: true, changeCutoffHours: true },
|
|
|
|
|
})
|
|
|
|
|
if (event?.eventAt) {
|
|
|
|
|
const deadline = new Date(
|
|
|
|
|
event.eventAt.getTime() - event.changeCutoffHours * 3_600_000,
|
|
|
|
|
)
|
|
|
|
|
if (new Date() > deadline) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'PRECONDITION_FAILED',
|
|
|
|
|
message: 'Past lunch change deadline. Contact an admin.',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const actorRole: 'SELF' | 'TEAM_LEAD' | 'ADMIN' = isAdmin
|
|
|
|
|
? 'ADMIN'
|
|
|
|
|
: isLead && !isSelf
|
|
|
|
|
? 'TEAM_LEAD'
|
|
|
|
|
: 'SELF'
|
|
|
|
|
|
|
|
|
|
const pick = await ctx.prisma.memberLunchPick.upsert({
|
|
|
|
|
where: { attendingMemberId: input.attendingMemberId },
|
|
|
|
|
create: {
|
|
|
|
|
attendingMemberId: input.attendingMemberId,
|
|
|
|
|
dishId: input.dishId,
|
|
|
|
|
allergens: input.allergens,
|
|
|
|
|
allergenOther: input.allergenOther,
|
|
|
|
|
pickedAt: input.dishId ? new Date() : null,
|
|
|
|
|
},
|
|
|
|
|
update: {
|
|
|
|
|
dishId: input.dishId,
|
|
|
|
|
allergens: input.allergens,
|
|
|
|
|
allergenOther: input.allergenOther,
|
|
|
|
|
pickedAt: input.dishId ? new Date() : null,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId,
|
|
|
|
|
action: 'LUNCH_PICK_UPDATED',
|
|
|
|
|
entityType: 'MemberLunchPick',
|
|
|
|
|
entityId: pick.id,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
actorRole,
|
|
|
|
|
dishId: input.dishId,
|
|
|
|
|
allergenCount: input.allergens.length,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
return pick
|
|
|
|
|
}),
|
|
|
|
|
|
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
|
|
|
|
|
}),
|
|
|
|
|
})
|