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'
|
2026-04-29 02:38:00 +02:00
|
|
|
import { buildManifest, buildRecapPayload } from '../services/lunch-recap'
|
|
|
|
|
import { sendLunchRecapEmail } from '@/lib/email'
|
2026-04-29 02:30:06 +02:00
|
|
|
|
|
|
|
|
// ─── 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(),
|
2026-04-29 02:44:38 +02:00
|
|
|
allergenOther: z.string().max(500).nullable().optional(),
|
2026-04-29 02:31:28 +02:00
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.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:49:08 +02:00
|
|
|
// ─── Single-row pick read (used by per-row picker UI) ────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Read the current MemberLunchPick for an AttendingMember plus the dishes
|
|
|
|
|
* for the parent event. Permission: any user with a TeamMember row on the
|
|
|
|
|
* project, OR the AttendingMember.userId itself, OR admin.
|
|
|
|
|
*/
|
|
|
|
|
getMemberPick: protectedProcedure
|
|
|
|
|
.input(z.object({ attendingMemberId: z.string() }))
|
|
|
|
|
.query(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 } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
lunchPick: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
if (!am) throw new TRPCError({ code: 'NOT_FOUND' })
|
|
|
|
|
const userId = ctx.user.id
|
|
|
|
|
const role = ctx.user.role
|
|
|
|
|
const isAdmin = role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN'
|
|
|
|
|
const isOnTeam = am.confirmation.project.teamMembers.some(
|
|
|
|
|
(tm) => tm.userId === userId,
|
|
|
|
|
)
|
|
|
|
|
if (!isAdmin && !isOnTeam && am.userId !== userId) {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN' })
|
|
|
|
|
}
|
|
|
|
|
return { pick: am.lunchPick }
|
|
|
|
|
}),
|
|
|
|
|
|
2026-04-29 02:34:24 +02:00
|
|
|
// ─── Manifest + CSV export ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
getManifest: adminProcedure
|
|
|
|
|
.input(z.object({ programId: z.string() }))
|
|
|
|
|
.query(({ ctx, input }) => buildManifest(ctx.prisma, input.programId)),
|
|
|
|
|
|
|
|
|
|
exportManifestCsv: adminProcedure
|
|
|
|
|
.input(z.object({ programId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const m = await buildManifest(ctx.prisma, input.programId)
|
|
|
|
|
const escape = (s: string | null | undefined) => {
|
|
|
|
|
const v = s ?? ''
|
|
|
|
|
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v
|
|
|
|
|
}
|
|
|
|
|
const lines = [
|
|
|
|
|
'Type,Team,Name,Email,Dish,Allergens,Allergen notes',
|
|
|
|
|
...m.members.map((row) =>
|
|
|
|
|
[
|
|
|
|
|
'Member',
|
|
|
|
|
escape(row.project?.name),
|
|
|
|
|
escape(row.name),
|
|
|
|
|
escape(row.email),
|
|
|
|
|
escape(row.dish?.name),
|
|
|
|
|
escape(row.allergens.join(';')),
|
|
|
|
|
escape(row.allergenOther),
|
|
|
|
|
].join(','),
|
|
|
|
|
),
|
|
|
|
|
...m.externals.map((row) =>
|
|
|
|
|
[
|
|
|
|
|
'External',
|
|
|
|
|
escape(row.project?.name),
|
|
|
|
|
escape(row.name),
|
|
|
|
|
escape(row.email),
|
|
|
|
|
escape(row.dish?.name),
|
|
|
|
|
escape(row.allergens.join(';')),
|
|
|
|
|
escape(row.allergenOther),
|
|
|
|
|
].join(','),
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
return lines.join('\n')
|
|
|
|
|
}),
|
|
|
|
|
|
2026-04-29 02:38:00 +02:00
|
|
|
// ─── Recap preview + send ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
getRecapPreview: adminProcedure
|
|
|
|
|
.input(z.object({ programId: z.string() }))
|
|
|
|
|
.query(({ ctx, input }) => buildRecapPayload(ctx.prisma, input.programId)),
|
|
|
|
|
|
|
|
|
|
sendRecap: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
programId: z.string(),
|
|
|
|
|
forceUpdate: z.boolean().optional(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const event = await ctx.prisma.lunchEvent.findUnique({
|
|
|
|
|
where: { programId: input.programId },
|
|
|
|
|
})
|
|
|
|
|
if (!event) throw new TRPCError({ code: 'NOT_FOUND', message: 'Lunch event not found' })
|
|
|
|
|
if (event.recapSentAt && !input.forceUpdate) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'PRECONDITION_FAILED',
|
|
|
|
|
message: 'Recap already sent. Pass forceUpdate=true to resend.',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
const payload = await buildRecapPayload(ctx.prisma, input.programId)
|
|
|
|
|
const adminUsers = await ctx.prisma.user.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
|
|
|
|
|
email: { not: '' },
|
|
|
|
|
},
|
|
|
|
|
select: { email: true },
|
|
|
|
|
})
|
|
|
|
|
const recipients = [
|
|
|
|
|
...adminUsers.map((u) => u.email).filter(Boolean),
|
|
|
|
|
...event.extraRecipients,
|
|
|
|
|
]
|
|
|
|
|
try {
|
|
|
|
|
await sendLunchRecapEmail(recipients, payload)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('[lunch.sendRecap] email send failed', e)
|
|
|
|
|
// Continue — we still stamp recapSentAt and audit so admins see what happened.
|
|
|
|
|
}
|
|
|
|
|
const updated = await ctx.prisma.lunchEvent.update({
|
|
|
|
|
where: { programId: input.programId },
|
|
|
|
|
data: { recapSentAt: new Date() },
|
|
|
|
|
})
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'LUNCH_RECAP_SENT',
|
|
|
|
|
entityType: 'LunchEvent',
|
|
|
|
|
entityId: event.id,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
recipientCount: recipients.length,
|
|
|
|
|
forceUpdate: !!input.forceUpdate,
|
|
|
|
|
source: 'manual',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
return updated
|
|
|
|
|
}),
|
|
|
|
|
|
2026-04-29 02:33:24 +02:00
|
|
|
// ─── Member reads ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Public-ish event view for the applicant dashboard banner.
|
|
|
|
|
* Returns null when the lunch event is disabled (banner hidden).
|
|
|
|
|
*/
|
|
|
|
|
getEventForMember: protectedProcedure
|
|
|
|
|
.input(z.object({ programId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const event = await ctx.prisma.lunchEvent.findUnique({
|
|
|
|
|
where: { programId: input.programId },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
enabled: true,
|
|
|
|
|
eventAt: true,
|
|
|
|
|
endAt: true,
|
|
|
|
|
venue: true,
|
|
|
|
|
notes: true,
|
|
|
|
|
changeCutoffHours: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
if (!event || !event.enabled) return null
|
|
|
|
|
const changeDeadline = event.eventAt
|
|
|
|
|
? new Date(event.eventAt.getTime() - event.changeCutoffHours * 3_600_000)
|
|
|
|
|
: null
|
|
|
|
|
return { ...event, changeDeadline }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* All picks for the caller's team. Within-team transparency: every team
|
|
|
|
|
* member sees their teammates' picks (lunch picks aren't sensitive).
|
|
|
|
|
* Cross-team and admins go through the manifest endpoint instead, which
|
|
|
|
|
* has more detail.
|
|
|
|
|
*/
|
|
|
|
|
getTeamPicks: protectedProcedure
|
|
|
|
|
.input(z.object({ projectId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const userId = ctx.user.id
|
|
|
|
|
const role = ctx.user.role
|
|
|
|
|
const isAdmin = role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN'
|
|
|
|
|
if (!isAdmin) {
|
|
|
|
|
const tm = await ctx.prisma.teamMember.findFirst({
|
|
|
|
|
where: { projectId: input.projectId, userId },
|
|
|
|
|
})
|
|
|
|
|
if (!tm) throw new TRPCError({ code: 'FORBIDDEN' })
|
|
|
|
|
}
|
|
|
|
|
const ams = await ctx.prisma.attendingMember.findMany({
|
|
|
|
|
where: { confirmation: { projectId: input.projectId } },
|
|
|
|
|
include: {
|
|
|
|
|
user: { select: { id: true, name: true, email: true } },
|
|
|
|
|
lunchPick: { include: { dish: true } },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
return ams.map((am) => ({
|
|
|
|
|
attendingMemberId: am.id,
|
|
|
|
|
userId: am.user.id,
|
|
|
|
|
memberName: am.user.name ?? am.user.email,
|
|
|
|
|
dish: am.lunchPick?.dish ?? null,
|
|
|
|
|
allergens: am.lunchPick?.allergens ?? [],
|
|
|
|
|
allergenOther: am.lunchPick?.allergenOther ?? null,
|
|
|
|
|
hasPicked: !!am.lunchPick?.pickedAt,
|
|
|
|
|
}))
|
|
|
|
|
}),
|
|
|
|
|
|
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
|
|
|
|
|
}),
|
|
|
|
|
})
|