217 lines
6.1 KiB
TypeScript
217 lines
6.1 KiB
TypeScript
import { z } from 'zod'
|
|
import { ForbiddenError } from '@pikku/core/errors'
|
|
import { pikkuFunc } from '#pikku'
|
|
import {
|
|
BookingZ,
|
|
ClientZ,
|
|
ParticipantZ,
|
|
ParticipantAllergyZ,
|
|
InvoiceZ,
|
|
} from '#pikku/db/zod.gen.js'
|
|
|
|
export const GetBookingDetailInput = z.object({
|
|
bookingId: z.string(),
|
|
})
|
|
|
|
const ParticipantRow = ParticipantZ
|
|
.pick({
|
|
participantId: true,
|
|
name: true,
|
|
email: true,
|
|
age: true,
|
|
kind: true,
|
|
dietaryTag: true,
|
|
notes: true,
|
|
})
|
|
.extend({
|
|
roomId: z.string().nullable(),
|
|
roomNumber: z.number().nullable(),
|
|
allergies: z.array(ParticipantAllergyZ.pick({ allergen: true, severity: true, note: true })),
|
|
})
|
|
|
|
const InvoiceRow = InvoiceZ.pick({
|
|
invoiceId: true,
|
|
invoiceNumber: true,
|
|
kind: true,
|
|
amountCents: true,
|
|
status: true,
|
|
pdfUrl: true,
|
|
}).extend({
|
|
issuedOn: z.date(),
|
|
dueOn: z.date().nullable(),
|
|
paidOn: z.date().nullable(),
|
|
})
|
|
|
|
export const GetBookingDetailOutput = z.object({
|
|
booking: BookingZ.pick({
|
|
bookingId: true,
|
|
eventName: true,
|
|
status: true,
|
|
halfHouse: true,
|
|
expectedPersons: true,
|
|
eventOutline: true,
|
|
description: true,
|
|
mealTimeBreakfast: true,
|
|
mealTimeLunch: true,
|
|
mealTimeDinner: true,
|
|
arrivalTime: true,
|
|
departureTime: true,
|
|
dailyPlan: true,
|
|
notes: true,
|
|
}).extend({
|
|
startDate: z.date().nullable(),
|
|
endDate: z.date().nullable(),
|
|
}),
|
|
client: ClientZ.pick({ clientId: true, name: true }),
|
|
participants: z.array(ParticipantRow),
|
|
invoices: z.array(InvoiceRow),
|
|
dietaryBreakdown: z.array(z.object({
|
|
tag: z.string(),
|
|
count: z.number(),
|
|
})),
|
|
})
|
|
|
|
export const getBookingDetail = pikkuFunc({
|
|
expose: true,
|
|
description: 'Booking detail — booking + org + participants + invoices + dietary breakdown.',
|
|
input: GetBookingDetailInput,
|
|
output: GetBookingDetailOutput,
|
|
func: async ({ kysely, content }, { bookingId }, { session }) => {
|
|
const booking = await kysely
|
|
.selectFrom('booking')
|
|
.where('bookingId', '=', bookingId)
|
|
.selectAll()
|
|
.executeTakeFirstOrThrow()
|
|
|
|
if (session.role !== 'admin' && session.role !== 'owner') {
|
|
const membership = await kysely
|
|
.selectFrom('clientMember')
|
|
.where('userId', '=', session.userId)
|
|
.where('clientId', '=', booking.clientId)
|
|
.select('userId')
|
|
.executeTakeFirst()
|
|
if (!membership) {
|
|
throw new ForbiddenError()
|
|
}
|
|
}
|
|
|
|
const client = await kysely
|
|
.selectFrom('client')
|
|
.where('clientId', '=', booking.clientId)
|
|
.select(['clientId', 'name'])
|
|
.executeTakeFirstOrThrow()
|
|
|
|
const participants = await kysely
|
|
.selectFrom('participant')
|
|
.leftJoin('roomAssignment', 'roomAssignment.participantId', 'participant.participantId')
|
|
.leftJoin('room', 'room.roomId', 'roomAssignment.roomId')
|
|
.where('participant.bookingId', '=', bookingId)
|
|
.select([
|
|
'participant.participantId',
|
|
'participant.name',
|
|
'participant.email',
|
|
'participant.age',
|
|
'participant.kind',
|
|
'participant.dietaryTag',
|
|
'participant.notes',
|
|
'roomAssignment.roomId as roomId',
|
|
'room.number as roomNumber',
|
|
])
|
|
.orderBy('participant.name', 'asc')
|
|
.execute()
|
|
|
|
const participantIds = participants.map((p) => p.participantId)
|
|
const allergies = participantIds.length
|
|
? await kysely
|
|
.selectFrom('participantAllergy')
|
|
.where('participantId', 'in', participantIds)
|
|
.selectAll()
|
|
.execute()
|
|
: []
|
|
const allergiesByParticipant = new Map<string, typeof allergies>()
|
|
for (const a of allergies) {
|
|
const list = allergiesByParticipant.get(a.participantId) ?? []
|
|
list.push(a)
|
|
allergiesByParticipant.set(a.participantId, list)
|
|
}
|
|
|
|
const dietaryRows = await kysely
|
|
.selectFrom('participant')
|
|
.where('bookingId', '=', bookingId)
|
|
.select(({ fn }) => ['dietaryTag', fn.countAll<number>().as('count')])
|
|
.groupBy('dietaryTag')
|
|
.execute()
|
|
const dietaryBreakdown = dietaryRows.map((r) => ({
|
|
tag: r.dietaryTag ?? 'unknown',
|
|
count: Number(r.count),
|
|
}))
|
|
|
|
const invoices = await kysely
|
|
.selectFrom('invoice')
|
|
.where('bookingId', '=', bookingId)
|
|
.orderBy('issuedOn', 'desc')
|
|
.selectAll()
|
|
.execute()
|
|
|
|
const signedInvoices = await Promise.all(
|
|
invoices.map(async (i) => ({
|
|
invoiceId: i.invoiceId,
|
|
invoiceNumber: i.invoiceNumber,
|
|
kind: i.kind,
|
|
amountCents: i.amountCents,
|
|
status: i.status,
|
|
issuedOn: i.issuedOn,
|
|
dueOn: i.dueOn,
|
|
paidOn: i.paidOn,
|
|
pdfUrl: i.pdfUrl && content
|
|
? await content.signContentKey({
|
|
bucket: 'invoices',
|
|
contentKey: i.pdfUrl,
|
|
dateLessThan: new Date(Date.now() + 60 * 60 * 1000),
|
|
})
|
|
: null,
|
|
})),
|
|
)
|
|
|
|
return {
|
|
booking: {
|
|
bookingId: booking.bookingId,
|
|
eventName: booking.eventName,
|
|
startDate: booking.startDate ? new Date(booking.startDate) : null,
|
|
endDate: booking.endDate ? new Date(booking.endDate) : null,
|
|
status: booking.status,
|
|
halfHouse: booking.halfHouse,
|
|
expectedPersons: booking.expectedPersons,
|
|
eventOutline: booking.eventOutline,
|
|
description: booking.description,
|
|
mealTimeBreakfast: booking.mealTimeBreakfast,
|
|
mealTimeLunch: booking.mealTimeLunch,
|
|
mealTimeDinner: booking.mealTimeDinner,
|
|
arrivalTime: booking.arrivalTime,
|
|
departureTime: booking.departureTime,
|
|
dailyPlan: booking.dailyPlan,
|
|
notes: booking.notes,
|
|
},
|
|
client,
|
|
participants: participants.map((p) => ({
|
|
participantId: p.participantId,
|
|
name: p.name,
|
|
email: p.email,
|
|
age: p.age,
|
|
kind: p.kind,
|
|
dietaryTag: p.dietaryTag,
|
|
notes: p.notes,
|
|
roomId: p.roomId,
|
|
roomNumber: p.roomNumber,
|
|
allergies: (allergiesByParticipant.get(p.participantId) ?? []).map((a) => ({
|
|
allergen: a.allergen,
|
|
severity: a.severity,
|
|
note: a.note,
|
|
})),
|
|
})),
|
|
invoices: signedInvoices,
|
|
dietaryBreakdown,
|
|
}
|
|
},
|
|
})
|