chore: seminarhof customer project

This commit is contained in:
e2e
2026-06-21 23:19:18 +02:00
commit dca4c76a13
161 changed files with 18517 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
{
"name": "@project/functions-sdk",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
"./pikku/api.gen": "./src/pikku/api.gen.ts",
"./pikku/pikku-fetch.gen": "./src/pikku/pikku-fetch.gen.ts",
"./pikku/pikku-rpc.gen": "./src/pikku/pikku-rpc.gen.ts",
"./pikku/rpc-map.gen": "./src/pikku/rpc-map.gen.d.ts",
"./pikku/http-map.gen": "./src/pikku/http-map.gen.d.ts",
"./pikku/*": "./src/pikku/*"
},
"dependencies": {
"@pikku/fetch": "^0.12.3",
"@pikku/react": "^0.12.3"
}
}

View File

@@ -0,0 +1,27 @@
{
"name": "@project/functions",
"version": "0.0.1",
"type": "module",
"private": true,
"imports": {
"#pikku": "./.pikku/pikku-types.gen.ts",
"#pikku/*": "./.pikku/*"
},
"dependencies": {
"@cloudflare/workers-types": "^4.20241218.0",
"@pikku/better-auth": "^0.12.9",
"@pikku/cloudflare": "^0.12.10",
"@pikku/core": "^0.12.35",
"@pikku/jose": "^0.12.3",
"@pikku/kysely": "^0.12.16",
"@pikku/kysely-sqlite": "^0.12.6",
"@pikku/schedule": "^0.12.2",
"@pikku/schema-cfworker": "^0.12.2",
"better-auth": "^1.6.19",
"kysely": "^0.28.12",
"zod": "^4.3.6"
},
"devDependencies": {
"@pikku/cli": "^0.12.45"
}
}

View File

@@ -0,0 +1,25 @@
import type {
CoreServices,
CoreSingletonServices,
CoreConfig,
CoreUserSession,
ContentService,
} from "@pikku/core"
import type { Kysely } from "kysely"
import type { DB } from "./types/db.types.js"
import type { EmailService } from "./services/email.js"
export interface UserSession extends CoreUserSession {
userId: string
role: string
}
export interface Config extends CoreConfig {}
export interface SingletonServices extends CoreSingletonServices<Config> {
kysely: Kysely<DB>
email: EmailService
content: ContentService
}
export interface Services extends CoreServices<SingletonServices> {}

View File

@@ -0,0 +1,10 @@
import { pikkuConfig } from "../.pikku/pikku-types.gen.js"
export const createConfig = pikkuConfig(async () => ({
dev: {
db: process.env.PIKKU_DEV_DB_FILE
? { file: process.env.PIKKU_DEV_DB_FILE }
: true,
content: true,
},
}))

View File

@@ -0,0 +1,138 @@
/**
* Domain-specific error classes for the Seminarhof API.
*
* Convention (see /CLAUDE.md): caught / expected exceptions must throw one of
* these defined errors — never a raw `new Error('string')`. Each extends a
* Pikku core error and is registered with `addErrors` below so it maps to the
* right HTTP status and a stable, client-facing message.
*
* This module registers its errors as an import side-effect; importing it from
* any function that throws is enough to wire the mapping at startup.
*/
import {
BadRequestError,
ConflictError,
UnauthorizedError,
addErrors,
} from '@pikku/core/errors'
// ─── Auth ────────────────────────────────────────────────────────────
export class InvalidCredentialsError extends UnauthorizedError {
constructor(message = 'Invalid email or password.') {
super(message)
}
}
// ─── Bookings / scheduling ───────────────────────────────────────────
export class InvalidDateRangeError extends BadRequestError {
constructor(message = 'Start date must be on or before the end date.') {
super(message)
}
}
export class InvalidBookingTransitionError extends ConflictError {
constructor(from: string, to: string) {
super(`Cannot move a booking from "${from}" to "${to}".`)
}
}
// ─── Contract lifecycle ──────────────────────────────────────────────
export class ContractRequiresReservedError extends ConflictError {
constructor(status: string) {
super(`A contract can only be sent for a reserved booking (was "${status}").`)
}
}
export class ContractAlreadySentError extends ConflictError {
constructor(message = 'A contract has already been sent for this booking.') {
super(message)
}
}
export class ContractNotSentError extends ConflictError {
constructor(message = 'No contract has been sent for this booking yet.') {
super(message)
}
}
export class ContractResponseRequiresReservedError extends ConflictError {
constructor(status: string) {
super(`A contract response can only be recorded for a reserved booking (was "${status}").`)
}
}
export class DepositRequiresReservedError extends ConflictError {
constructor(status: string) {
super(`A deposit can only be recorded for a reserved booking (was "${status}").`)
}
}
// ─── Enquiries ───────────────────────────────────────────────────────
export class EnquiryNotPendingError extends ConflictError {
constructor(status: string) {
super(`This enquiry is "${status}" and can no longer be approved, waitlisted or declined.`)
}
}
export class EnquiryDatesRequiredError extends BadRequestError {
constructor(message = 'Start and end dates are required for this action.') {
super(message)
}
}
export class ClientWithEmailExistsError extends ConflictError {
constructor(email: string) {
super(`A client with the email "${email}" already exists — select it instead of creating a new one.`)
}
}
// ─── Booking form / magic link ───────────────────────────────────────
export class InvalidFormLinkError extends UnauthorizedError {
constructor(message = 'This booking-form link is invalid or has expired.') {
super(message)
}
}
export class BookingFormUnavailableError extends ConflictError {
constructor(status: string) {
super(`This booking can no longer be completed via the form (status "${status}").`)
}
}
export class ConsentRequiredError extends BadRequestError {
constructor(message = 'You must accept the terms and the privacy policy to submit.') {
super(message)
}
}
// ─── Invoicing ───────────────────────────────────────────────────────
export class InvoiceAlreadyPaidError extends ConflictError {
constructor(message = 'This invoice has already been paid.') {
super(message)
}
}
export class InvoiceCancelledError extends ConflictError {
constructor(message = 'This invoice has been cancelled.') {
super(message)
}
}
addErrors([
[InvalidCredentialsError, { status: 401, message: 'Invalid email or password.' }],
[InvalidDateRangeError, { status: 400, message: 'Start date must be on or before the end date.' }],
[InvalidBookingTransitionError, { status: 409, message: 'Illegal booking status transition.' }],
[ContractRequiresReservedError, { status: 409, message: 'A contract can only be sent for a reserved booking.' }],
[ContractAlreadySentError, { status: 409, message: 'A contract has already been sent for this booking.' }],
[ContractNotSentError, { status: 409, message: 'No contract has been sent for this booking yet.' }],
[ContractResponseRequiresReservedError, { status: 409, message: 'A contract response can only be recorded for a reserved booking.' }],
[DepositRequiresReservedError, { status: 409, message: 'A deposit can only be recorded for a reserved booking.' }],
[EnquiryNotPendingError, { status: 409, message: 'This enquiry can no longer be acted on.' }],
[EnquiryDatesRequiredError, { status: 400, message: 'Start and end dates are required for this action.' }],
[ClientWithEmailExistsError, { status: 409, message: 'A client with this email already exists.' }],
[InvalidFormLinkError, { status: 401, message: 'This booking-form link is invalid or has expired.' }],
[BookingFormUnavailableError, { status: 409, message: 'This booking can no longer be completed via the form.' }],
[ConsentRequiredError, { status: 400, message: 'You must accept the terms and the privacy policy to submit.' }],
[InvoiceAlreadyPaidError, { status: 409, message: 'This invoice has already been paid.' }],
[InvoiceCancelledError, { status: 409, message: 'This invoice has been cancelled.' }],
])

View File

@@ -0,0 +1,465 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { BookingZ, ClientZ, RoomZ } from '#pikku/db/zod.gen.js'
import { isAdminOrOwner } from '../lib/permissions.js'
import { InvalidDateRangeError } from '../errors.js'
export const GetAdminBookingDetailInput = z.object({
bookingId: z.string(),
})
const RoomCard = RoomZ
.pick({
roomId: true,
number: true,
beds: true,
roomType: true,
building: true,
floor: true,
wheelchair: true,
groundFloor: true,
quiet: true,
sharedBath: true,
dogsAllowed: true,
doubleBed_140: true,
allergyFriendly: true,
})
.extend({
assignedParticipants: z.array(z.object({
participantId: z.string(),
name: z.string(),
})),
occupiedByOtherBooking: z.boolean(),
})
export const GetAdminBookingDetailOutput = z.object({
booking: BookingZ.pick({
bookingId: true,
eventName: true,
status: true,
halfHouse: true,
onlineAd: true,
expectedPersons: true,
eventOutline: true,
description: true,
coverImageUrl: true,
organiserWebsite: true,
mealTimeBreakfast: true,
mealTimeLunch: true,
mealTimeDinner: true,
arrivalTime: true,
departureTime: true,
dailyPlan: true,
notes: true,
paymentMethod: true,
}).extend({
startDate: z.date().nullable(),
endDate: z.date().nullable(),
// Lifecycle milestone dates — CoercionPlugin delivers these as real Date objects
contractSentAt: z.date().nullable(),
contractResponse: z.string().nullable(),
contractResponseAt: z.date().nullable(),
depositReminderSentAt: z.date().nullable(),
flaggedAt: z.date().nullable(),
flagReason: z.string().nullable(),
// Status transition dates sourced from audit log
enquiryAt: z.date(),
reservedAt: z.date().nullable(),
confirmedAt: z.date().nullable(),
endedAt: z.date().nullable(),
cancelledAt: z.date().nullable(),
}),
client: ClientZ.pick({
clientId: true,
name: true,
contactEmail: true,
contactPhone: true,
billingAddress: true,
website: true,
isStammgruppe: true,
}),
participants: z.array(z.object({
participantId: z.string(),
name: z.string(),
kind: z.string(),
dietaryTag: z.string().nullable(),
roomId: z.string().nullable(),
roomNumber: z.number().nullable(),
allergies: z.array(z.object({
allergen: z.string(),
severity: z.string(),
note: z.string().nullable(),
})),
})),
rooms: z.array(RoomCard),
invoices: z.array(z.object({
invoiceId: z.string(),
invoiceNumber: z.string(),
kind: z.string(),
amountCents: z.number(),
issuedOn: z.date(),
dueOn: z.date().nullable(),
paidOn: z.date().nullable(),
status: z.string(),
pdfUrl: z.string().nullable(),
})),
})
export const getAdminBookingDetail = pikkuFunc({
expose: true,
permissions: { isAdminOrOwner },
description: 'Admin booking detail — booking, org, participants, full room map.',
input: GetAdminBookingDetailInput,
output: GetAdminBookingDetailOutput,
func: async ({ kysely, content }, { bookingId }) => {
const booking = await kysely
.selectFrom('booking')
.where('bookingId', '=', bookingId)
.selectAll()
.executeTakeFirstOrThrow()
// For enquiry-derived bookings, the "Enquiry" milestone should show when the
// enquiry was actually submitted — not when an admin approved it (which is
// what booking.createdAt records). Fall back to booking.createdAt otherwise.
const enquiry = booking.enquiryId
? await kysely
.selectFrom('enquiry')
.where('enquiryId', '=', booking.enquiryId)
.select(['createdAt'])
.executeTakeFirst()
: undefined
const client = await kysely
.selectFrom('client')
.where('clientId', '=', booking.clientId)
.select([
'clientId',
'name',
'contactEmail',
'contactPhone',
'billingAddress',
'website',
'isStammgruppe',
])
.executeTakeFirstOrThrow()
const participantRows = 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.kind',
'participant.dietaryTag',
'roomAssignment.roomId as roomId',
'room.number as roomNumber',
])
.orderBy('participant.name', 'asc')
.execute()
const allergyRows = participantRows.length
? await kysely
.selectFrom('participantAllergy')
.where(
'participantId',
'in',
participantRows.map((p: { participantId: string }) => p.participantId),
)
.select(['participantId', 'allergen', 'severity', 'note'])
.execute()
: []
const allergiesByParticipant = new Map<
string,
Array<{ allergen: string; severity: string; note: string | null }>
>()
for (const a of allergyRows) {
const list = allergiesByParticipant.get(a.participantId) ?? []
list.push({ allergen: a.allergen, severity: a.severity, note: a.note })
allergiesByParticipant.set(a.participantId, list)
}
const participants = participantRows.map(
(p: {
participantId: string
name: string
kind: string
dietaryTag: string | null
roomId: string | null
roomNumber: number | null
}) => ({
...p,
allergies: allergiesByParticipant.get(p.participantId) ?? [],
}),
)
const rooms = await kysely
.selectFrom('room')
.where('venueId', '=', booking.venueId)
.selectAll()
.orderBy('number', 'asc')
.execute()
const roomAssignments = await kysely
.selectFrom('roomAssignment')
.innerJoin('booking', 'booking.bookingId', 'roomAssignment.bookingId')
.innerJoin('participant', 'participant.participantId', 'roomAssignment.participantId')
.where('booking.endDate', '>=', booking.startDate)
.where('booking.startDate', '<=', booking.endDate)
.where('booking.status', 'not in', ['cancelled', 'enquiry'])
.select([
'roomAssignment.roomId',
'roomAssignment.bookingId',
'roomAssignment.participantId',
'participant.name as participantName',
])
.execute()
const assignmentsByRoom = new Map<string, Array<{
bookingId: string
participantId: string
participantName: string
}>>()
for (const a of roomAssignments) {
const list = assignmentsByRoom.get(a.roomId) ?? []
list.push({
bookingId: a.bookingId,
participantId: a.participantId,
participantName: a.participantName,
})
assignmentsByRoom.set(a.roomId, list)
}
const roomCards = rooms.map((r) => {
const all = assignmentsByRoom.get(r.roomId) ?? []
const ours = all.filter((a) => a.bookingId === bookingId)
const others = all.filter((a) => a.bookingId !== bookingId)
return {
roomId: r.roomId,
number: r.number,
beds: r.beds,
roomType: r.roomType,
building: r.building,
floor: r.floor,
wheelchair: r.wheelchair,
groundFloor: r.groundFloor,
quiet: r.quiet,
sharedBath: r.sharedBath,
dogsAllowed: r.dogsAllowed,
doubleBed_140: r.doubleBed_140,
allergyFriendly: r.allergyFriendly,
assignedParticipants: ours.map((a) => ({
participantId: a.participantId,
name: a.participantName,
})),
occupiedByOtherBooking: others.length > 0,
}
})
const invoices = await kysely
.selectFrom('invoice')
.where('bookingId', '=', bookingId)
.select([
'invoiceId',
'invoiceNumber',
'kind',
'amountCents',
'issuedOn',
'dueOn',
'paidOn',
'status',
'pdfUrl',
])
.orderBy('issuedOn', 'asc')
.execute()
const signedInvoices = await Promise.all(
invoices.map(async (invoice) => ({
...invoice,
pdfUrl: invoice.pdfUrl && content
? await content.signContentKey({
bucket: 'invoices',
contentKey: invoice.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,
onlineAd: booking.onlineAd,
expectedPersons: booking.expectedPersons,
eventOutline: booking.eventOutline,
description: booking.description,
coverImageUrl: booking.coverImageUrl,
organiserWebsite: booking.organiserWebsite,
mealTimeBreakfast: booking.mealTimeBreakfast,
mealTimeLunch: booking.mealTimeLunch,
mealTimeDinner: booking.mealTimeDinner,
arrivalTime: booking.arrivalTime,
departureTime: booking.departureTime,
dailyPlan: booking.dailyPlan,
notes: booking.notes,
paymentMethod: booking.paymentMethod,
contractSentAt: booking.contractSentAt ?? null,
contractResponse: booking.contractResponse,
contractResponseAt: booking.contractResponseAt ?? null,
depositReminderSentAt: booking.depositReminderSentAt ?? null,
flaggedAt: booking.flaggedAt ?? null,
flagReason: booking.flagReason,
enquiryAt: new Date(enquiry?.createdAt ?? booking.createdAt),
reservedAt: booking.reservedAt ?? null,
confirmedAt: booking.confirmedAt ?? null,
endedAt: booking.endedAt ?? null,
cancelledAt: booking.cancelledAt ?? null,
},
client,
participants,
rooms: roomCards,
invoices: signedInvoices,
}
},
})
export const UpdateAdminBookingInput = z.object({
bookingId: z.string(),
patch: z.object({
eventName: z.string().optional(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
halfHouse: z.boolean().optional(),
onlineAd: z.boolean().optional(),
expectedPersons: z.number().int().positive().optional().nullable(),
eventOutline: z.string().optional().nullable(),
description: z.string().optional().nullable(),
coverImageUrl: z.string().optional().nullable(),
organiserWebsite: z.string().optional().nullable(),
mealTimeBreakfast: z.string().optional().nullable(),
mealTimeLunch: z.string().optional().nullable(),
mealTimeDinner: z.string().optional().nullable(),
arrivalTime: z.string().optional().nullable(),
departureTime: z.string().optional().nullable(),
dailyPlan: z.string().optional().nullable(),
notes: z.string().optional().nullable(),
}),
})
export const UpdateAdminBookingOutput = z.object({ ok: z.literal(true) })
export const updateAdminBooking = pikkuFunc({
expose: true,
permissions: { isAdminOrOwner },
description: 'Admin: edit booking metadata (does NOT change status — use setBookingStatus).',
input: UpdateAdminBookingInput,
output: UpdateAdminBookingOutput,
func: async ({ kysely }, { bookingId, patch }, { session }) => {
const current = await kysely
.selectFrom('booking')
.where('bookingId', '=', bookingId)
.selectAll()
.executeTakeFirstOrThrow()
const dbPatch: Record<string, unknown> = { updatedAt: new Date().toISOString() }
for (const [k, v] of Object.entries(patch)) {
if (v === undefined) continue
if (k === 'halfHouse' || k === 'onlineAd') {
dbPatch[k] = v ? 1 : 0
} else {
dbPatch[k] = v
}
}
if (patch.startDate && patch.endDate && patch.startDate > patch.endDate) {
throw new InvalidDateRangeError()
}
await kysely
.updateTable('booking')
.set(dbPatch)
.where('bookingId', '=', bookingId)
.execute()
const now = new Date().toISOString()
const auditRows = Object.entries(patch)
.filter(([, v]) => v !== undefined)
.map(([field, afterRaw]) => {
const beforeRaw = (current as Record<string, unknown>)[field]
const before = beforeRaw == null ? null : String(beforeRaw)
const after = afterRaw == null ? null : String(afterRaw)
if (before === after) return null
return {
auditId: `audit_${Date.now()}_${Math.random().toString(36).slice(2, 8)}_${field}`,
venueId: current.venueId,
userId: session.userId,
entity: 'booking' as const,
entityId: bookingId,
field,
beforeValue: before,
afterValue: after,
action: 'update' as const,
at: now,
}
})
.filter((r): r is NonNullable<typeof r> => r !== null)
if (auditRows.length > 0) {
await kysely.insertInto('auditLog').values(auditRows).execute()
}
return { ok: true as const }
},
})
export const AssignRoomInput = z.object({
bookingId: z.string(),
participantId: z.string(),
roomId: z.string(),
})
export const AssignRoomOutput = z.object({ ok: z.literal(true) })
export const assignRoom = pikkuFunc({
expose: true,
permissions: { isAdminOrOwner },
description: 'Admin: assign a participant to a room for a booking.',
input: AssignRoomInput,
output: AssignRoomOutput,
func: async ({ kysely }, { bookingId, participantId, roomId }) => {
await kysely
.deleteFrom('roomAssignment')
.where('participantId', '=', participantId)
.execute()
await kysely
.insertInto('roomAssignment')
.values({ bookingId, participantId, roomId })
.execute()
return { ok: true as const }
},
})
export const UnassignRoomInput = z.object({
participantId: z.string(),
})
export const UnassignRoomOutput = z.object({ ok: z.literal(true) })
export const unassignRoom = pikkuFunc({
expose: true,
permissions: { isAdminOrOwner },
description: 'Admin: remove a room assignment for a participant.',
input: UnassignRoomInput,
output: UnassignRoomOutput,
func: async ({ kysely }, { participantId }) => {
await kysely
.deleteFrom('roomAssignment')
.where('participantId', '=', participantId)
.execute()
return { ok: true as const }
},
})

View File

@@ -0,0 +1,161 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { isAdminOrOwner } from '../lib/permissions.js'
import {
ClientWithEmailExistsError,
EnquiryDatesRequiredError,
EnquiryNotPendingError,
InvalidDateRangeError,
} from '../errors.js'
const uid = () =>
globalThis.crypto?.randomUUID?.() ??
`id_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
export const ApproveEnquiryInput = z.object({
enquiryId: z.string(),
// Required to create the booking. Pre-filled from the enquiry in the dialog,
// but the admin can adjust them.
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
// Either link an existing client (clientId) or create a new one.
client: z.object({
clientId: z.string().optional(),
name: z.string().optional(),
contactEmail: z.string().email().optional(),
contactPhone: z.string().optional(),
billingAddress: z.string().optional(),
website: z.string().optional(),
}),
})
export const ApproveEnquiryOutput = z.object({
bookingId: z.string(),
clientId: z.string(),
})
export const approveEnquiry = pikkuFunc({
expose: true,
description: 'Admin: approve an enquiry — link/create a client and create a reserved booking.',
permissions: { isAdminOrOwner },
input: ApproveEnquiryInput,
output: ApproveEnquiryOutput,
func: async ({ kysely }, input, { session }) => {
const enquiry = await kysely
.selectFrom('enquiry')
.where('enquiryId', '=', input.enquiryId)
.selectAll()
.executeTakeFirstOrThrow()
if (enquiry.status !== 'pending') {
throw new EnquiryNotPendingError(enquiry.status)
}
if (!input.startDate || !input.endDate) {
throw new EnquiryDatesRequiredError()
}
if (input.startDate > input.endDate) {
throw new InvalidDateRangeError()
}
// Resolve the client: existing by id, or create a new one. When creating,
// refuse if a client with that email already exists (the admin should pick
// it instead) — keeps the email-as-identity invariant from submitEnquiry.
let clientId = input.client.clientId
let createClient: { contactEmail: string } | null = null
if (!clientId) {
const contactEmail = input.client.contactEmail ?? enquiry.contactEmail
const existing = await kysely
.selectFrom('client')
.where('venueId', '=', enquiry.venueId)
.where('contactEmail', '=', contactEmail)
.select('clientId')
.executeTakeFirst()
if (existing) {
throw new ClientWithEmailExistsError(contactEmail)
}
clientId = uid()
createClient = { contactEmail }
} else {
// Verify the supplied client belongs to this venue.
await kysely
.selectFrom('client')
.where('clientId', '=', clientId)
.where('venueId', '=', enquiry.venueId)
.select('clientId')
.executeTakeFirstOrThrow()
}
const bookingId = uid()
const now = new Date().toISOString()
await kysely.transaction().execute(async (trx) => {
if (createClient) {
await trx
.insertInto('client')
.values({
clientId: clientId!,
venueId: enquiry.venueId,
name: input.client.name ?? enquiry.contactName ?? null,
isStammgruppe: 0,
billingAddress: input.client.billingAddress ?? null,
contactEmail: createClient.contactEmail,
contactPhone: input.client.contactPhone ?? enquiry.contactPhone ?? null,
website: input.client.website ?? enquiry.website ?? null,
notes: null,
})
.execute()
}
// Approved enquiry → booking starts at 'reserved': the admin has
// committed, and the lifecycle's contract dispatch fires from reserved.
// The literal enquiry phase lives in the enquiry table, not here.
await trx
.insertInto('booking')
.values({
bookingId,
venueId: enquiry.venueId,
clientId: clientId!,
enquiryId: enquiry.enquiryId,
eventName: enquiry.eventName,
eventOutline: enquiry.eventOutline,
description: enquiry.description,
startDate: input.startDate,
endDate: input.endDate,
status: 'reserved',
reservedAt: now,
halfHouse: enquiry.halfHouse,
expectedPersons: enquiry.expectedPersons,
organiserWebsite: enquiry.website,
paymentMethod: 'transfer',
agbYear: null,
agbAcceptedAt: null,
privacyAcceptedAt: enquiry.privacyAcceptedAt,
notes: enquiry.notes,
})
.execute()
await trx
.updateTable('enquiry')
.set({ status: 'approved', approvedAt: now })
.where('enquiryId', '=', enquiry.enquiryId)
.execute()
await trx
.insertInto('auditLog')
.values({
auditId: uid(),
venueId: enquiry.venueId,
userId: session.userId,
entity: 'enquiry',
entityId: enquiry.enquiryId,
field: 'status',
beforeValue: 'pending',
afterValue: 'approved',
action: 'update',
})
.execute()
})
return { bookingId, clientId: clientId! }
},
})

View File

@@ -0,0 +1,36 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
export const MeOutput = z.object({
userId: z.string(),
email: z.string(),
name: z.string(),
role: z.string(),
clients: z.array(z.object({
clientId: z.string(),
name: z.string().nullable(),
role: z.string(),
})),
})
export const me = pikkuFunc({
expose: true,
description: 'Returns the current session user with their org memberships.',
output: MeOutput,
func: async ({ kysely }, _input, { session }) => {
const user = await kysely
.selectFrom('user')
.where('id', '=', session.userId)
.select(['id as userId', 'email', 'name', 'role'])
.executeTakeFirstOrThrow()
const orgs = await kysely
.selectFrom('clientMember')
.innerJoin('client', 'client.clientId', 'clientMember.clientId')
.where('clientMember.userId', '=', session.userId)
.select(['client.clientId', 'client.name', 'clientMember.role'])
.execute()
return { ...user, clients: orgs }
},
})

View File

@@ -0,0 +1,339 @@
import { z } from 'zod'
import { ForbiddenError } from '@pikku/core/errors'
import { pikkuFunc, pikkuSessionlessFunc } from '#pikku'
import { isAdminOrOwner } from '../lib/permissions.js'
import { provisionClientLogin } from '../lib/provision-client-login.js'
import { createUserSession, uid } from '../lib/session.js'
import {
InvalidFormLinkError,
BookingFormUnavailableError,
ConsentRequiredError,
} from '../errors.js'
// Magic-link token: a short-lived signed JWT (no DB token table). Carries the
// provisioned client user + the booking it grants access to.
const TOKEN_PURPOSE = 'booking_login'
const TOKEN_TTL = { value: 7, unit: 'day' as const } // matches the deposit window
type FormLinkToken = {
purpose: typeof TOKEN_PURPOSE
userId: string
bookingId: string
}
const PaymentMethod = z.enum(['cash', 'transfer'])
/** Booking can only be filled while it's a live (non-terminal) reservation. */
function assertFormable(status: string) {
if (status === 'cancelled' || status === 'ended') {
throw new BookingFormUnavailableError(status)
}
}
/** A client may only touch bookings of a client org they belong to. */
async function assertOrgAccess(
kysely: any,
bookingId: string,
userId: string,
role: string,
) {
if (role === 'admin' || role === 'owner') return
const row = await kysely
.selectFrom('booking')
.innerJoin('clientMember', 'clientMember.clientId', 'booking.clientId')
.where('booking.bookingId', '=', bookingId)
.where('clientMember.userId', '=', userId)
.select('booking.bookingId')
.executeTakeFirst()
if (!row) throw new ForbiddenError()
}
// ─── 1. Admin: create a magic link to the binding booking form ─────────
export const CreateBookingFormLinkInput = z.object({ bookingId: z.string() })
export const CreateBookingFormLinkOutput = z.object({
url: z.string(),
token: z.string(),
email: z.string(),
})
export const createBookingFormLink = pikkuFunc({
expose: true,
description:
'Admin: provision a client login and mint a passwordless magic link to the binding booking form.',
permissions: { isAdminOrOwner },
input: CreateBookingFormLinkInput,
output: CreateBookingFormLinkOutput,
func: async ({ kysely, jwt, variables, logger }, { bookingId }) => {
if (!jwt) throw new Error('jwt service not configured')
const booking = await kysely
.selectFrom('booking')
.innerJoin('client', 'client.clientId', 'booking.clientId')
.where('booking.bookingId', '=', bookingId)
.select([
'booking.bookingId',
'booking.status',
'booking.clientId',
'client.contactEmail',
'client.name as clientName',
])
.executeTakeFirstOrThrow()
assertFormable(booking.status)
if (!booking.contactEmail) {
// No email = no one to provision / send to.
throw new BookingFormUnavailableError('no_contact_email')
}
const userId = await provisionClientLogin(kysely, {
clientId: booking.clientId,
email: booking.contactEmail,
name: booking.clientName,
})
const token = await jwt.encode<FormLinkToken>(TOKEN_TTL, {
purpose: TOKEN_PURPOSE,
userId,
bookingId,
})
const baseUrl =
(await variables.get('APP_BASE_URL')) ?? 'http://localhost:5001'
const url = `${baseUrl.replace(/\/$/, '')}/booking-form/${token}`
logger.info(`[booking-form] link minted for booking ${bookingId}`)
return { url, token, email: booking.contactEmail }
},
})
// ─── 2. Public: consume the magic link → log the client in ─────────────
export const ConsumeBookingFormLinkInput = z.object({ token: z.string() })
export const ConsumeBookingFormLinkOutput = z.object({ bookingId: z.string() })
export const consumeBookingFormLink = pikkuSessionlessFunc({
expose: true,
description:
'Verify a booking-form magic link, then issue a client session cookie.',
input: ConsumeBookingFormLinkInput,
output: ConsumeBookingFormLinkOutput,
func: async ({ kysely, jwt }, { token }, { http }) => {
if (!jwt) throw new Error('jwt service not configured')
let payload: FormLinkToken
try {
payload = await jwt.decode<FormLinkToken>(token)
} catch {
throw new InvalidFormLinkError()
}
if (payload?.purpose !== TOKEN_PURPOSE || !payload.userId || !payload.bookingId) {
throw new InvalidFormLinkError()
}
const booking = await kysely
.selectFrom('booking')
.where('bookingId', '=', payload.bookingId)
.select(['bookingId', 'status', 'clientId'])
.executeTakeFirst()
if (!booking) throw new InvalidFormLinkError()
assertFormable(booking.status)
// Defence-in-depth: the token's user must still be a member of the
// booking's client org (membership could have been revoked).
const member = await kysely
.selectFrom('clientMember')
.where('userId', '=', payload.userId)
.where('clientId', '=', booking.clientId)
.select('userId')
.executeTakeFirst()
if (!member) throw new InvalidFormLinkError()
await createUserSession(kysely, http, payload.userId)
return { bookingId: booking.bookingId }
},
})
// ─── 3. Client/admin: prefill data for the form ────────────────────────
export const GetBookingFormDataInput = z.object({ bookingId: z.string() })
export const GetBookingFormDataOutput = z.object({
bookingId: z.string(),
status: z.string(),
eventName: z.string(),
startDate: z.string().nullable(),
endDate: z.string().nullable(),
expectedPersons: z.number().nullable(),
arrivalTime: z.string().nullable(),
departureTime: z.string().nullable(),
dailyPlan: z.string().nullable(),
onlineAd: z.boolean(),
notes: z.string().nullable(),
paymentMethod: z.string(),
contractResponse: z.string().nullable(),
client: z.object({
name: z.string().nullable(),
billingAddress: z.string().nullable(),
contactEmail: z.string().nullable(),
contactPhone: z.string().nullable(),
website: z.string().nullable(),
}),
})
export const getBookingFormData = pikkuFunc({
expose: true,
description: 'Prefill values for the binding booking form (org-access guarded).',
input: GetBookingFormDataInput,
output: GetBookingFormDataOutput,
func: async ({ kysely }, { bookingId }, { session }) => {
await assertOrgAccess(kysely, bookingId, session.userId, session.role)
const b = await kysely
.selectFrom('booking')
.innerJoin('client', 'client.clientId', 'booking.clientId')
.where('booking.bookingId', '=', bookingId)
.select([
'booking.bookingId',
'booking.status',
'booking.eventName',
'booking.startDate',
'booking.endDate',
'booking.expectedPersons',
'booking.arrivalTime',
'booking.departureTime',
'booking.dailyPlan',
'booking.onlineAd',
'booking.notes',
'booking.paymentMethod',
'booking.contractResponse',
'client.name as clientName',
'client.billingAddress',
'client.contactEmail',
'client.contactPhone',
'client.website',
])
.executeTakeFirstOrThrow()
return {
bookingId: b.bookingId,
status: b.status,
eventName: b.eventName,
startDate: b.startDate,
endDate: b.endDate,
expectedPersons: b.expectedPersons,
arrivalTime: b.arrivalTime,
departureTime: b.departureTime,
dailyPlan: b.dailyPlan,
onlineAd: !!b.onlineAd,
notes: b.notes,
paymentMethod: b.paymentMethod,
contractResponse: b.contractResponse,
client: {
name: b.clientName,
billingAddress: b.billingAddress,
contactEmail: b.contactEmail,
contactPhone: b.contactPhone,
website: b.website,
},
}
},
})
// ─── 4. Client/admin: submit the binding booking form ──────────────────
export const SubmitBookingFormInput = z.object({
bookingId: z.string(),
// Billing (client)
name: z.string().min(1),
billingAddress: z.string().min(1),
contactPhone: z.string().optional().nullable(),
website: z.string().optional().nullable(),
paymentMethod: PaymentMethod,
// Event details (booking)
expectedPersons: z.number().int().positive().optional().nullable(),
arrivalTime: z.string().optional().nullable(),
departureTime: z.string().optional().nullable(),
dailyPlan: z.string().optional().nullable(),
onlineAd: z.boolean(),
notes: z.string().optional().nullable(),
// Binding consents (the form IS the contract = AGB acceptance)
acceptTerms: z.boolean(),
acceptPrivacy: z.boolean(),
})
export const SubmitBookingFormOutput = z.object({ ok: z.literal(true) })
export const submitBookingForm = pikkuFunc({
expose: true,
description:
'Submit the binding booking form: saves billing/event details and records contract approval (AGB acceptance).',
input: SubmitBookingFormInput,
output: SubmitBookingFormOutput,
func: async ({ kysely }, input, { session }) => {
await assertOrgAccess(kysely, input.bookingId, session.userId, session.role)
if (!input.acceptTerms || !input.acceptPrivacy) {
throw new ConsentRequiredError()
}
const booking = await kysely
.selectFrom('booking')
.where('bookingId', '=', input.bookingId)
.select(['bookingId', 'status', 'clientId', 'venueId'])
.executeTakeFirstOrThrow()
assertFormable(booking.status)
const now = new Date().toISOString()
await kysely.transaction().execute(async (trx) => {
await trx
.updateTable('client')
.set({
name: input.name,
billingAddress: input.billingAddress,
contactPhone: input.contactPhone ?? null,
website: input.website ?? null,
})
.where('clientId', '=', booking.clientId)
.execute()
await trx
.updateTable('booking')
.set({
expectedPersons: input.expectedPersons ?? null,
arrivalTime: input.arrivalTime ?? null,
departureTime: input.departureTime ?? null,
dailyPlan: input.dailyPlan ?? null,
onlineAd: input.onlineAd ? 1 : 0,
notes: input.notes ?? null,
paymentMethod: input.paymentMethod,
// Submission is the binding acceptance: stamp the contract response +
// AGB/privacy. Status bucket stays `reserved` (deposit drives
// confirmation). Decoupled from the admin contractSentAt flow.
contractResponse: 'approved',
contractResponseAt: now,
agbAcceptedAt: now,
agbYear: Number(now.slice(0, 4)),
privacyAcceptedAt: now,
flaggedAt: null,
flagReason: null,
updatedAt: now,
})
.where('bookingId', '=', input.bookingId)
.execute()
await trx
.insertInto('auditLog')
.values({
auditId: uid(),
venueId: booking.venueId,
userId: session.userId,
entity: 'booking',
entityId: input.bookingId,
field: 'bookingForm',
beforeValue: null,
afterValue: 'submitted',
action: 'update',
})
.execute()
})
return { ok: true as const }
},
})

View File

@@ -0,0 +1,37 @@
import { z } from 'zod'
import { pikkuFunc, pikkuVoidFunc } from '#pikku'
import { runBookingLifecycle } from '../lib/booking-lifecycle.js'
import { isAdminOrOwner } from '../lib/permissions.js'
/**
* Daily scheduled sweep (wired in wirings/scheduled.wiring.ts). Runs the
* lifecycle rules R1R4; idempotent, so re-running is harmless.
*/
export const bookingLifecycleDaily = pikkuVoidFunc(async (services) => {
await runBookingLifecycle(services)
})
export const RunBookingLifecycleInput = z.object({})
export const RunBookingLifecycleOutput = z.object({
date: z.string(),
contractsSent: z.number(),
reminders: z.number(),
flagged: z.number(),
ended: z.number(),
})
/**
* Admin/dev: run the daily sweep on demand (the scheduler only fires at 06:00),
* returning what changed — handy for testing the lifecycle without waiting.
*/
export const runBookingLifecycleNow = pikkuFunc({
expose: true,
description: 'Admin/dev: run the booking-lifecycle sweep now and return a counts summary.',
permissions: { isAdminOrOwner },
input: RunBookingLifecycleInput,
output: RunBookingLifecycleOutput,
func: async (services, _data) => {
return runBookingLifecycle(services)
},
})

View File

@@ -0,0 +1,63 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { InvoiceAlreadyPaidError } from '../errors.js'
import { isAdminOrOwner } from '../lib/permissions.js'
export const CancelInvoiceInput = z.object({
invoiceId: z.string(),
})
export const CancelInvoiceOutput = z.object({
invoiceId: z.string(),
status: z.literal('cancelled'),
})
const uid = () =>
globalThis.crypto?.randomUUID?.() ??
`audit_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
export const cancelInvoice = pikkuFunc({
expose: true,
description: 'Admin/owner: cancel an outstanding invoice (typo, duplicate, etc.).',
permissions: { isAdminOrOwner },
input: CancelInvoiceInput,
output: CancelInvoiceOutput,
func: async ({ kysely }, { invoiceId }, { session }) => {
const inv = await kysely
.selectFrom('invoice')
.innerJoin('booking', 'booking.bookingId', 'invoice.bookingId')
.where('invoice.invoiceId', '=', invoiceId)
.select(['invoice.invoiceId', 'invoice.status', 'booking.venueId'])
.executeTakeFirstOrThrow()
if (inv.status === 'paid') {
throw new InvoiceAlreadyPaidError()
}
if (inv.status === 'cancelled') {
return { invoiceId, status: 'cancelled' as const }
}
await kysely
.updateTable('invoice')
.set({ status: 'cancelled' })
.where('invoiceId', '=', invoiceId)
.execute()
await kysely
.insertInto('auditLog')
.values({
auditId: uid(),
venueId: inv.venueId,
userId: session.userId,
entity: 'invoice',
entityId: invoiceId,
field: 'status',
beforeValue: inv.status,
afterValue: 'cancelled',
action: 'update',
})
.execute()
return { invoiceId, status: 'cancelled' as const }
},
})

View File

@@ -0,0 +1,71 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { isAdminOrOwner } from '../lib/permissions.js'
const INVOICE_KIND = ['deposit', 'final'] as const
export const CreateInvoiceInput = z.object({
bookingId: z.string(),
kind: z.enum(INVOICE_KIND),
invoiceNumber: z.string().min(1),
issuedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
dueOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
amountCents: z.number().int().positive(),
pdfAssetKey: z.string().min(1).optional(),
})
export const CreateInvoiceOutput = z.object({
invoiceId: z.string(),
invoiceNumber: z.string(),
status: z.literal('outstanding'),
})
const uid = () =>
globalThis.crypto?.randomUUID?.() ??
`inv_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
export const createInvoice = pikkuFunc({
expose: true,
description: 'Admin/owner: issue a deposit or final invoice for a booking.',
permissions: { isAdminOrOwner },
input: CreateInvoiceInput,
output: CreateInvoiceOutput,
func: async ({ kysely }, input, { session }) => {
const booking = await kysely
.selectFrom('booking')
.where('bookingId', '=', input.bookingId)
.select(['bookingId', 'venueId'])
.executeTakeFirstOrThrow()
const invoiceId = uid()
await kysely
.insertInto('invoice')
.values({
invoiceId,
bookingId: input.bookingId,
kind: input.kind,
invoiceNumber: input.invoiceNumber,
issuedOn: input.issuedOn,
dueOn: input.dueOn ?? null,
amountCents: input.amountCents,
status: 'outstanding',
pdfUrl: input.pdfAssetKey ?? null,
})
.execute()
await kysely
.insertInto('auditLog')
.values({
auditId: uid(),
venueId: booking.venueId,
userId: session.userId,
entity: 'invoice',
entityId: invoiceId,
action: 'create',
afterValue: input.invoiceNumber,
})
.execute()
return { invoiceId, invoiceNumber: input.invoiceNumber, status: 'outstanding' as const }
},
})

View File

@@ -0,0 +1,59 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { isAdminOrOwner } from '../lib/permissions.js'
import { EnquiryNotPendingError } from '../errors.js'
const uid = () =>
globalThis.crypto?.randomUUID?.() ??
`id_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
export const DeclineEnquiryInput = z.object({
enquiryId: z.string(),
})
export const DeclineEnquiryOutput = z.object({ ok: z.literal(true) })
export const declineEnquiry = pikkuFunc({
expose: true,
description: 'Admin: decline a pending enquiry.',
permissions: { isAdminOrOwner },
input: DeclineEnquiryInput,
output: DeclineEnquiryOutput,
func: async ({ kysely }, input, { session }) => {
const enquiry = await kysely
.selectFrom('enquiry')
.where('enquiryId', '=', input.enquiryId)
.select(['venueId', 'status'])
.executeTakeFirstOrThrow()
if (enquiry.status !== 'pending') {
throw new EnquiryNotPendingError(enquiry.status)
}
const now = new Date().toISOString()
await kysely.transaction().execute(async (trx) => {
await trx
.updateTable('enquiry')
.set({ status: 'declined', declinedAt: now })
.where('enquiryId', '=', input.enquiryId)
.execute()
await trx
.insertInto('auditLog')
.values({
auditId: uid(),
venueId: enquiry.venueId,
userId: session.userId,
entity: 'enquiry',
entityId: input.enquiryId,
field: 'status',
beforeValue: 'pending',
afterValue: 'declined',
action: 'update',
})
.execute()
})
return { ok: true as const }
},
})

View File

@@ -0,0 +1,166 @@
import { z } from 'zod'
import { pikkuSessionlessFunc } from '#pikku'
import { BookingZ } from '#pikku/db/zod.gen.js'
export const GetAdminBookingsInput = z.object({
venueSlug: z.string().default('drawehn'),
years: z.array(z.number().int()).optional(),
status: z.array(z.string()).optional(),
search: z.string().optional(),
clientId: z.string().optional(),
halfHouseOnly: z.boolean().optional(),
})
const Row = BookingZ
.pick({
bookingId: true,
eventName: true,
status: true,
halfHouse: true,
expectedPersons: true,
})
.extend({
startDate: z.date().nullable(),
endDate: z.date().nullable(),
clientName: z.string().nullable(),
isStammgruppe: z.boolean(),
participantCount: z.number(),
hasDeposit: z.boolean(),
hasFinal: z.boolean(),
depositPaid: z.boolean(),
finalPaid: z.boolean(),
})
export const GetAdminBookingsOutput = z.object({
bookings: z.array(Row),
totalCount: z.number(),
yearOptions: z.array(z.number()),
})
export const getAdminBookings = pikkuSessionlessFunc({
expose: true,
description: 'Admin booking list with filters — replaces Buchungsübersicht spreadsheet.',
input: GetAdminBookingsInput,
output: GetAdminBookingsOutput,
func: async ({ kysely }, input) => {
const venue = await kysely
.selectFrom('venue')
.where('slug', '=', input.venueSlug)
.select('venueId')
.executeTakeFirstOrThrow()
let q = kysely
.selectFrom('booking')
.innerJoin('client', 'client.clientId', 'booking.clientId')
.where('booking.venueId', '=', venue.venueId)
if (input.years?.length) {
const years = input.years.map(String)
q = q.where(
(eb) => eb.fn<string>('substr', ['booking.startDate', eb.lit(1), eb.lit(4)]),
'in',
years,
)
}
if (input.status?.length) {
q = q.where('booking.status', 'in', input.status)
}
if (input.clientId) {
q = q.where('booking.clientId', '=', input.clientId)
}
if (input.halfHouseOnly) {
q = q.where('booking.halfHouse', '=', 1)
}
if (input.search?.trim()) {
const term = `%${input.search.trim()}%`
q = q.where((eb) =>
eb.or([
eb('booking.eventName', 'like', term),
eb('client.name', 'like', term),
]),
)
}
const rows = await q
.orderBy('booking.startDate', 'desc')
.select([
'booking.bookingId',
'booking.eventName',
'booking.startDate',
'booking.endDate',
'booking.status',
'booking.halfHouse',
'booking.expectedPersons',
'client.name as clientName',
'client.isStammgruppe',
])
.execute()
const bookingIds = rows.map((r) => r.bookingId)
const participantCounts = bookingIds.length
? await kysely
.selectFrom('participant')
.where('bookingId', 'in', bookingIds)
.select(({ fn }) => ['bookingId', fn.countAll<number>().as('count')])
.groupBy('bookingId')
.execute()
: []
const countByBooking = new Map(participantCounts.map((r) => [r.bookingId, Number(r.count)]))
const invoices = bookingIds.length
? await kysely
.selectFrom('invoice')
.where('bookingId', 'in', bookingIds)
.select(['bookingId', 'kind', 'status'])
.execute()
: []
const invoicesByBooking = new Map<string, typeof invoices>()
for (const inv of invoices) {
const list = invoicesByBooking.get(inv.bookingId) ?? []
list.push(inv)
invoicesByBooking.set(inv.bookingId, list)
}
const bookings = rows.map((r) => {
const invs = invoicesByBooking.get(r.bookingId) ?? []
const deposit = invs.find((i) => i.kind === 'deposit')
const final = invs.find((i) => i.kind === 'final')
return {
bookingId: r.bookingId,
eventName: r.eventName,
startDate: r.startDate ? new Date(r.startDate) : null,
endDate: r.endDate ? new Date(r.endDate) : null,
status: r.status,
halfHouse: r.halfHouse,
expectedPersons: r.expectedPersons,
clientName: r.clientName,
isStammgruppe: !!r.isStammgruppe,
participantCount: countByBooking.get(r.bookingId) ?? 0,
hasDeposit: !!deposit,
hasFinal: !!final,
depositPaid: deposit?.status === 'paid',
finalPaid: final?.status === 'paid',
}
})
// Year facet — derived from full venue, not filtered set, so the
// chips reflect real data even after filtering.
const yearRows = await kysely
.selectFrom('booking')
.where('venueId', '=', venue.venueId)
.select((eb) => eb.fn<string>('substr', ['booking.startDate', eb.lit(1), eb.lit(4)]).as('year'))
.distinct()
.execute()
const yearOptions = yearRows
.map((r) => Number(r.year))
.filter((y) => Number.isFinite(y))
.sort((a, b) => b - a)
return {
bookings,
totalCount: bookings.length,
yearOptions,
}
},
})

View File

@@ -0,0 +1,202 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { isAdminOrOwner } from '../lib/permissions.js'
export const GetAdminDashboardInput = z.object({
venueSlug: z.string().default('drawehn'),
})
// Cap each action queue so a busy quarter can't bury the "at a glance" intent.
// `count` always reflects the true total; `items` is the capped preview.
const ITEM_CAP = 12
// Upcoming window for confirmed events (days from today).
const UPCOMING_DAYS = 30
const ActionBooking = z.object({
bookingId: z.string(),
eventName: z.string(),
clientName: z.string().nullable(),
startDate: z.date().nullable(),
endDate: z.date().nullable(),
expectedPersons: z.number().nullable(),
})
const PendingEnquiry = z.object({
enquiryId: z.string(),
eventName: z.string(),
contactName: z.string().nullable(),
startDate: z.date().nullable(),
endDate: z.date().nullable(),
waitlisted: z.boolean(),
})
const Bucket = <T extends z.ZodTypeAny>(item: T) =>
z.object({ count: z.number(), items: z.array(item) })
export const GetAdminDashboardOutput = z.object({
pendingEnquiries: Bucket(PendingEnquiry),
contractsToSend: Bucket(ActionBooking),
contractsAwaitingResponse: Bucket(ActionBooking),
depositsOutstanding: Bucket(ActionBooking),
upcomingEvents: Bucket(ActionBooking),
})
const DAY_MS = 86_400_000
const isoDay = (d: Date) => d.toISOString().slice(0, 10)
const toDate = (iso: string | null) => (iso ? new Date(iso + 'T00:00:00Z') : null)
export const getAdminDashboard = pikkuFunc({
expose: true,
description: 'Admin dashboard — required-actions hub plus upcoming events.',
permissions: { isAdminOrOwner },
input: GetAdminDashboardInput,
output: GetAdminDashboardOutput,
func: async ({ kysely }, { venueSlug }) => {
const venue = await kysely
.selectFrom('venue')
.where('slug', '=', venueSlug)
.select('venueId')
.executeTakeFirstOrThrow()
const today = isoDay(new Date())
const horizon = isoDay(new Date(Date.now() + UPCOMING_DAYS * DAY_MS))
// --- Pending enquiries (untriaged or parked on the waitlist) -----------
const enquiryRows = await kysely
.selectFrom('enquiry')
.where('venueId', '=', venue.venueId)
.where('status', '=', 'pending')
.orderBy('createdAt', 'desc')
.select(['enquiryId', 'eventName', 'contactName', 'waitlistedAt'])
.execute()
// The card shows each enquiry's preferred (lowest-priority) date option.
const shownEnquiries = enquiryRows.slice(0, ITEM_CAP)
const shownIds = shownEnquiries.map((r) => r.enquiryId)
const optionRows = shownIds.length
? await kysely
.selectFrom('enquiryDateOption')
.where('enquiryId', 'in', shownIds)
.orderBy('priority', 'asc')
.select(['enquiryId', 'startDate', 'endDate'])
.execute()
: []
const topOption = new Map<string, { startDate: string; endDate: string }>()
for (const o of optionRows) {
if (!topOption.has(o.enquiryId)) {
topOption.set(o.enquiryId, { startDate: o.startDate, endDate: o.endDate })
}
}
const pendingEnquiries = {
count: enquiryRows.length,
items: shownEnquiries.map((r) => {
const top = topOption.get(r.enquiryId)
return {
enquiryId: r.enquiryId,
eventName: r.eventName,
contactName: r.contactName,
startDate: top ? toDate(top.startDate) : null,
endDate: top ? toDate(top.endDate) : null,
waitlisted: !!r.waitlistedAt,
}
}),
}
// --- Reserved bookings → contract / deposit sub-states -----------------
// The reserved bucket is where all pre-confirmation paperwork lives, so we
// pull it once and split it in JS by contract progress.
const reserved = await kysely
.selectFrom('booking')
.innerJoin('client', 'client.clientId', 'booking.clientId')
.where('booking.venueId', '=', venue.venueId)
.where('booking.status', '=', 'reserved')
.orderBy('booking.startDate', 'asc')
.select([
'booking.bookingId',
'booking.eventName',
'booking.startDate',
'booking.endDate',
'booking.expectedPersons',
'booking.contractSentAt',
'booking.contractResponse',
'client.name as clientName',
])
.execute()
// Which reserved bookings already have a *paid* deposit invoice.
const reservedIds = reserved.map((r) => r.bookingId)
const paidDeposits = reservedIds.length
? await kysely
.selectFrom('invoice')
.where('bookingId', 'in', reservedIds)
.where('kind', '=', 'deposit')
.where('status', '=', 'paid')
.select('bookingId')
.execute()
: []
const depositPaid = new Set(paidDeposits.map((r) => r.bookingId))
const asBooking = (r: (typeof reserved)[number]) => ({
bookingId: r.bookingId,
eventName: r.eventName,
clientName: r.clientName,
startDate: toDate(r.startDate),
endDate: toDate(r.endDate),
expectedPersons: r.expectedPersons,
})
const toSend = reserved.filter((r) => r.contractSentAt == null)
const awaiting = reserved.filter(
(r) => r.contractSentAt != null && r.contractResponse == null,
)
// "Outstanding", not "overdue": the client has approved the contract but the
// deposit hasn't landed. The 14-day overdue deadline is E7, not modelled yet.
const depositOut = reserved.filter(
(r) => r.contractResponse === 'approved' && !depositPaid.has(r.bookingId),
)
const bucket = (rows: typeof reserved) => ({
count: rows.length,
items: rows.slice(0, ITEM_CAP).map(asBooking),
})
// --- Upcoming confirmed events (next 30 days) --------------------------
const upcoming = await kysely
.selectFrom('booking')
.innerJoin('client', 'client.clientId', 'booking.clientId')
.where('booking.venueId', '=', venue.venueId)
.where('booking.status', '=', 'confirmed')
.where('booking.startDate', '>=', today)
.where('booking.startDate', '<=', horizon)
.orderBy('booking.startDate', 'asc')
.select([
'booking.bookingId',
'booking.eventName',
'booking.startDate',
'booking.endDate',
'booking.expectedPersons',
'client.name as clientName',
])
.execute()
return {
pendingEnquiries,
contractsToSend: bucket(toSend),
contractsAwaitingResponse: bucket(awaiting),
depositsOutstanding: bucket(depositOut),
upcomingEvents: {
count: upcoming.length,
items: upcoming.slice(0, ITEM_CAP).map((r) => ({
bookingId: r.bookingId,
eventName: r.eventName,
clientName: r.clientName,
startDate: toDate(r.startDate),
endDate: toDate(r.endDate),
expectedPersons: r.expectedPersons,
})),
},
}
},
})

View File

@@ -0,0 +1,162 @@
import { z } from 'zod'
import { pikkuSessionlessFunc } from '#pikku'
export const GetAvailabilityInput = z.object({
venueSlug: z.string().default('drawehn'),
// ISO dates (yyyy-mm-dd). Default = today, +12 months.
from: z.string().optional(),
to: z.string().optional(),
})
const DAY_STATUS = z.enum(['free', 'enquiry', 'reserved', 'confirmed'])
// One occupying booking on a given day (after status mapping + name gating).
const DAY_BOOKING = z.object({
status: DAY_STATUS,
eventName: z.string().nullable(), // populated only when online_ad=true
halfHouse: z.boolean(),
})
export const GetAvailabilityOutput = z.object({
from: z.date(),
to: z.date(),
days: z.array(z.object({
date: z.date(),
// Collapsed day summary (highest-priority status, first public name) —
// drives the cell colour, legend and range-selection logic.
status: DAY_STATUS,
eventName: z.string().nullable(),
halfHouse: z.boolean(),
// Per-booking breakdown, ordered deterministically (startDate, bookingId).
// A day with two half-house bookings yields two entries → the client
// renders the diagonal split + dual hover.
bookings: z.array(DAY_BOOKING),
})),
})
// Booking status → public day-status. Internal pre-confirm states all
// surface as `enquiry` for outsiders.
const STATUS_MAP: Record<string, 'enquiry' | 'reserved' | 'confirmed'> = {
enquiry: 'enquiry',
reserved: 'reserved',
confirmed: 'confirmed',
ended: 'confirmed',
}
const DAY_MS = 86_400_000
function addDays(iso: string, days: number) {
const d = new Date(iso + 'T00:00:00Z')
return new Date(d.getTime() + days * DAY_MS).toISOString().slice(0, 10)
}
export const getAvailability = pikkuSessionlessFunc({
expose: true,
description: 'Public availability calendar — day status for a date range.',
input: GetAvailabilityInput,
output: GetAvailabilityOutput,
func: async ({ kysely }, { venueSlug, from, to }, { session }) => {
// Session is resolved on this public route too (global cookie middleware).
// Anonymous visitors must not see `enquiry`-state holds — those aren't
// real commitments. Admins/owners see them so they can manage overlaps.
const isStaff = session?.role === 'admin' || session?.role === 'owner'
const today = new Date().toISOString().slice(0, 10)
const fromDate = from ?? today
const toDate = to ?? addDays(fromDate, 365)
const venue = await kysely
.selectFrom('venue')
.where('slug', '=', venueSlug)
.select('venueId')
.executeTakeFirstOrThrow()
// Bookings that overlap the window. `cancelled` excluded.
const bookings = await kysely
.selectFrom('booking')
.where('venueId', '=', venue.venueId)
.where('status', '!=', 'cancelled')
.where('startDate', '<=', toDate)
.where('endDate', '>=', fromDate)
.select(['bookingId', 'startDate', 'endDate', 'status', 'eventName', 'onlineAd', 'halfHouse'])
.execute()
type DayStatus = 'free' | 'enquiry' | 'reserved' | 'confirmed'
// One booking's contribution to a single day, plus sort keys (stripped on
// output) so the diagonal-split triangle order is deterministic.
type Cell = { status: DayStatus; eventName: string | null; halfHouse: boolean; bookingId: string; startDate: string }
// Bucket per ISO date: a collapsed summary (highest-priority status, first
// public name) plus the list of occupying bookings.
const days: Record<string, {
status: DayStatus
eventName: string | null
halfHouse: boolean
list: Cell[]
}> = {}
const priority = { free: 0, enquiry: 1, reserved: 2, confirmed: 3 } as const
// Non-staff viewers don't see enquiry-state bookings at all — those days
// fall through to `free`.
const visible = isStaff ? bookings : bookings.filter((b) => b.status !== 'enquiry')
for (const b of visible) {
// Date-less enquiries can't occupy calendar days; the SQL range filter
// already drops NULLs — this guard also narrows the types below.
if (b.startDate == null || b.endDate == null) continue
const publicStatus = STATUS_MAP[b.status] ?? 'enquiry'
const eventName = b.onlineAd ? b.eventName : null // never leak private names
const halfHouse = !!b.halfHouse
const start = b.startDate < fromDate ? fromDate : b.startDate
const end = b.endDate > toDate ? toDate : b.endDate
let cursor = start
while (cursor <= end) {
const cur = days[cursor] ?? { status: 'free' as const, eventName: null, halfHouse: false, list: [] }
if (priority[publicStatus] > priority[cur.status]) {
cur.status = publicStatus
}
if (eventName && !cur.eventName) {
cur.eventName = eventName
}
if (halfHouse) cur.halfHouse = true
cur.list.push({ status: publicStatus, eventName, halfHouse, bookingId: b.bookingId, startDate: b.startDate })
days[cursor] = cur
cursor = addDays(cursor, 1)
}
}
// Materialise every date in the window so the client can render free days
// without inferring gaps. Each day's bookings are sorted (startDate then
// bookingId) for stable triangle ordering across renders.
const out: Array<{
date: string
status: DayStatus
eventName: string | null
halfHouse: boolean
bookings: Array<{ status: DayStatus; eventName: string | null; halfHouse: boolean }>
}> = []
let cursor = fromDate
while (cursor <= toDate) {
const cur = days[cursor]
const list = (cur?.list ?? [])
.slice()
.sort((a, b) => (a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : a.bookingId < b.bookingId ? -1 : 1))
.map(({ status, eventName, halfHouse }) => ({ status, eventName, halfHouse }))
out.push({
date: cursor,
status: cur?.status ?? 'free',
eventName: cur?.eventName ?? null,
halfHouse: cur?.halfHouse ?? false,
bookings: list,
})
cursor = addDays(cursor, 1)
}
return {
from: new Date(fromDate + 'T00:00:00Z'),
to: new Date(toDate + 'T00:00:00Z'),
days: out.map(d => ({ ...d, date: new Date(d.date + 'T00:00:00Z') })),
}
},
})

View File

@@ -0,0 +1,60 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
export const GetBookingAuditLogInput = z.object({
bookingId: z.string(),
})
const AuditEntry = z.object({
auditId: z.string(),
action: z.string(),
field: z.string().nullable(),
beforeValue: z.string().nullable(),
afterValue: z.string().nullable(),
at: z.date(),
userName: z.string().nullable(),
userEmail: z.string().nullable(),
})
export const GetBookingAuditLogOutput = z.object({
entries: z.array(AuditEntry),
})
export const getBookingAuditLog = pikkuFunc({
expose: true,
description: 'Returns audit log entries for a booking, newest first.',
input: GetBookingAuditLogInput,
output: GetBookingAuditLogOutput,
func: async ({ kysely }, { bookingId }) => {
const rows = await kysely
.selectFrom('auditLog')
.leftJoin('user', 'user.id', 'auditLog.userId')
.where('auditLog.entity', '=', 'booking')
.where('auditLog.entityId', '=', bookingId)
.orderBy('auditLog.at', 'desc')
.select([
'auditLog.auditId',
'auditLog.action',
'auditLog.field',
'auditLog.beforeValue',
'auditLog.afterValue',
'auditLog.at',
'user.name as userName',
'user.email as userEmail',
])
.execute()
return {
entries: rows.map((r) => ({
auditId: r.auditId,
action: r.action,
field: r.field,
beforeValue: r.beforeValue,
afterValue: r.afterValue,
at: new Date(r.at),
userName: r.userName ?? null,
userEmail: r.userEmail ?? null,
})),
}
},
})

View File

@@ -0,0 +1,216 @@
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,
}
},
})

View File

@@ -0,0 +1,136 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { BookingZ, ClientZ, InvoiceZ } from '#pikku/db/zod.gen.js'
const BookingCard = BookingZ
.pick({ bookingId: true, eventName: true, status: true, expectedPersons: true })
.extend({
startDate: z.date().nullable(),
endDate: z.date().nullable(),
participantCount: z.number(),
completenessPercent: z.number(),
isCurrentEvent: z.boolean(),
})
const InvoicePreview = InvoiceZ
.pick({ invoiceId: true, bookingId: true, invoiceNumber: true, kind: true, amountCents: true, status: true, pdfUrl: true })
.extend({
issuedOn: z.date(),
bookingName: z.string(),
})
export const GetClientOverviewOutput = z.object({
clients: z.array(ClientZ.pick({ clientId: true, name: true })),
upcomingBookings: z.array(BookingCard),
recentInvoices: z.array(InvoicePreview),
})
const COMPLETENESS_FIELDS = 6 // orga-mail, deposit, room plan, dietary, headcount, schedule
export const getClientOverview = pikkuFunc({
expose: true,
description: 'Client portal landing — orgs, upcoming bookings, recent invoices.',
output: GetClientOverviewOutput,
func: async ({ kysely, content }, _input, { session }) => {
const userId = session.userId
const orgs = await kysely
.selectFrom('clientMember')
.innerJoin('client', 'client.clientId', 'clientMember.clientId')
.where('clientMember.userId', '=', userId)
.select(['client.clientId', 'client.name'])
.execute()
if (orgs.length === 0) {
return { clients: [], upcomingBookings: [], recentInvoices: [] }
}
const orgIds = orgs.map((o) => o.clientId)
const today = new Date().toISOString().slice(0, 10)
const bookings = await kysely
.selectFrom('booking')
.where('clientId', 'in', orgIds)
.where('endDate', '>=', today)
.where('status', '!=', 'cancelled')
.orderBy('startDate', 'asc')
.selectAll()
.execute()
const bookingIds = bookings.map((b) => b.bookingId)
const participantCounts = bookingIds.length
? await kysely
.selectFrom('participant')
.where('bookingId', 'in', bookingIds)
.select(({ fn }) => ['bookingId', fn.countAll<number>().as('count')])
.groupBy('bookingId')
.execute()
: []
const countByBooking = new Map(participantCounts.map((r) => [r.bookingId, Number(r.count)]))
const upcomingBookings = bookings.map((b) => {
const participantCount = countByBooking.get(b.bookingId) ?? 0
const headcountTarget = b.expectedPersons ?? 0
const headcountComplete = headcountTarget > 0 && participantCount >= headcountTarget
const dietaryComplete = false // computed properly once dietary % per booking is exposed
const scheduleComplete = !!(b.mealTimeBreakfast && b.mealTimeLunch && b.mealTimeDinner)
const orgaMailComplete = !!b.agbAcceptedAt
const depositComplete = b.status === 'confirmed' || b.status === 'ended'
const roomPlanComplete = false // computed once room_assignments are populated
const checks = [orgaMailComplete, depositComplete, roomPlanComplete, dietaryComplete, headcountComplete, scheduleComplete]
const completenessPercent = Math.round((checks.filter(Boolean).length / COMPLETENESS_FIELDS) * 100)
return {
bookingId: b.bookingId,
eventName: b.eventName,
startDate: b.startDate ? new Date(b.startDate) : null,
endDate: b.endDate ? new Date(b.endDate) : null,
status: b.status,
expectedPersons: b.expectedPersons,
participantCount,
completenessPercent,
isCurrentEvent: !!b.startDate && !!b.endDate && b.startDate <= today && b.endDate >= today,
}
})
const recentInvoices = bookingIds.length
? await kysely
.selectFrom('invoice')
.innerJoin('booking', 'booking.bookingId', 'invoice.bookingId')
.where('invoice.bookingId', 'in', bookingIds)
.orderBy('invoice.issuedOn', 'desc')
.limit(5)
.select([
'invoice.invoiceId',
'invoice.bookingId',
'booking.eventName as bookingName',
'invoice.invoiceNumber',
'invoice.kind',
'invoice.amountCents',
'invoice.status',
'invoice.issuedOn',
'invoice.pdfUrl',
])
.execute()
: []
const signedRecentInvoices = await Promise.all(
recentInvoices.map(async (invoice) => ({
...invoice,
pdfUrl: invoice.pdfUrl && content
? await content.signContentKey({
bucket: 'invoices',
contentKey: invoice.pdfUrl,
dateLessThan: new Date(Date.now() + 60 * 60 * 1000),
})
: null,
})),
)
return {
clients: orgs,
upcomingBookings,
recentInvoices: signedRecentInvoices,
}
},
})

View File

@@ -0,0 +1,57 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { isAdminOrOwner } from '../lib/permissions.js'
export const GetClientsInput = z.object({
venueSlug: z.string().default('drawehn'),
// Substring filter on name / email for the approve-dialog autosuggest.
q: z.string().optional(),
limit: z.number().int().positive().max(50).optional(),
})
export const GetClientsOutput = z.object({
clients: z.array(
z.object({
clientId: z.string(),
name: z.string().nullable(),
contactEmail: z.string().nullable(),
}),
),
})
export const getClients = pikkuFunc({
expose: true,
description: 'Admin: search clients by name/email for selection (e.g. approving an enquiry).',
permissions: { isAdminOrOwner },
input: GetClientsInput,
output: GetClientsOutput,
func: async ({ kysely }, input) => {
const venue = await kysely
.selectFrom('venue')
.where('slug', '=', input.venueSlug)
.select('venueId')
.executeTakeFirstOrThrow()
let q = kysely
.selectFrom('client')
.where('venueId', '=', venue.venueId)
if (input.q?.trim()) {
const term = `%${input.q.trim()}%`
q = q.where((eb) =>
eb.or([
eb('name', 'like', term),
eb('contactEmail', 'like', term),
]),
)
}
const clients = await q
.orderBy('name', 'asc')
.limit(input.limit ?? 20)
.select(['clientId', 'name', 'contactEmail'])
.execute()
return { clients }
},
})

View File

@@ -0,0 +1,136 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { isAdminOrOwner } from '../lib/permissions.js'
export const GetEnquiriesInput = z.object({
venueSlug: z.string().default('drawehn'),
status: z.array(z.string()).optional(),
})
const DateOption = z.object({
optionId: z.string(),
startDate: z.string(),
endDate: z.string(),
priority: z.number(),
})
const Row = z.object({
enquiryId: z.string(),
contactName: z.string().nullable(),
contactEmail: z.string(),
contactPhone: z.string().nullable(),
website: z.string().nullable(),
eventName: z.string(),
eventOutline: z.string().nullable(),
description: z.string().nullable(),
// Prioritized date options (E6.1), priority-ascending (first = preferred).
// Empty when the visitor gave no dates.
dateOptions: z.array(DateOption),
expectedPersons: z.number().nullable(),
halfHouse: z.boolean(),
notes: z.string().nullable(),
status: z.string(),
waitlistedAt: z.date().nullable(),
approvedAt: z.date().nullable(),
declinedAt: z.date().nullable(),
createdAt: z.date(),
bookingId: z.string().nullable(),
})
export const GetEnquiriesOutput = z.object({
enquiries: z.array(Row),
})
export const getEnquiries = pikkuFunc({
expose: true,
description: 'Admin: list public enquiries awaiting triage, newest first.',
permissions: { isAdminOrOwner },
input: GetEnquiriesInput,
output: GetEnquiriesOutput,
func: async ({ kysely }, input) => {
const venue = await kysely
.selectFrom('venue')
.where('slug', '=', input.venueSlug)
.select('venueId')
.executeTakeFirstOrThrow()
let q = kysely
.selectFrom('enquiry')
// The booking that was created when this enquiry was approved (if any).
.leftJoin('booking', 'booking.enquiryId', 'enquiry.enquiryId')
.where('enquiry.venueId', '=', venue.venueId)
if (input.status?.length) {
q = q.where('enquiry.status', 'in', input.status)
}
const rows = await q
.orderBy('enquiry.createdAt', 'desc')
.select([
'enquiry.enquiryId',
'enquiry.contactName',
'enquiry.contactEmail',
'enquiry.contactPhone',
'enquiry.website',
'enquiry.eventName',
'enquiry.eventOutline',
'enquiry.description',
'enquiry.expectedPersons',
'enquiry.halfHouse',
'enquiry.notes',
'enquiry.status',
'enquiry.waitlistedAt',
'enquiry.approvedAt',
'enquiry.declinedAt',
'enquiry.createdAt',
'booking.bookingId',
])
.execute()
// Fetch all date options for the returned enquiries in one query, then
// group by enquiry (priority-ascending).
const enquiryIds = rows.map((r) => r.enquiryId)
const optionRows = enquiryIds.length
? await kysely
.selectFrom('enquiryDateOption')
.where('enquiryId', 'in', enquiryIds)
.orderBy('priority', 'asc')
.select(['optionId', 'enquiryId', 'startDate', 'endDate', 'priority'])
.execute()
: []
const optionsByEnquiry = new Map<string, typeof optionRows>()
for (const o of optionRows) {
const list = optionsByEnquiry.get(o.enquiryId) ?? []
list.push(o)
optionsByEnquiry.set(o.enquiryId, list)
}
const enquiries = rows.map((r) => ({
enquiryId: r.enquiryId,
contactName: r.contactName,
contactEmail: r.contactEmail,
contactPhone: r.contactPhone,
website: r.website,
eventName: r.eventName,
eventOutline: r.eventOutline,
description: r.description,
dateOptions: (optionsByEnquiry.get(r.enquiryId) ?? []).map((o) => ({
optionId: o.optionId,
startDate: o.startDate,
endDate: o.endDate,
priority: o.priority,
})),
expectedPersons: r.expectedPersons,
halfHouse: !!r.halfHouse,
notes: r.notes,
status: r.status,
waitlistedAt: r.waitlistedAt ? new Date(r.waitlistedAt) : null,
approvedAt: r.approvedAt ? new Date(r.approvedAt) : null,
declinedAt: r.declinedAt ? new Date(r.declinedAt) : null,
createdAt: new Date(r.createdAt),
bookingId: r.bookingId ?? null,
}))
return { enquiries }
},
})

View File

@@ -0,0 +1,102 @@
import { z } from 'zod'
import { pikkuSessionlessFunc } from '#pikku'
import { BookingZ } from '#pikku/db/zod.gen.js'
export const GetEventsListingInput = z.object({
venueSlug: z.string().default('drawehn'),
filter: z.enum(['this_year', 'next_year', 'all']).default('all'),
search: z.string().optional(),
})
const Card = BookingZ
.pick({
bookingId: true,
eventName: true,
eventOutline: true,
description: true,
coverImageUrl: true,
organiserWebsite: true,
})
.extend({
startDate: z.date(),
endDate: z.date(),
clientName: z.string().nullable(),
})
export const GetEventsListingOutput = z.object({
events: z.array(Card),
})
export const getEventsListing = pikkuSessionlessFunc({
expose: true,
description: 'Public events listing — opted-in bookings only.',
input: GetEventsListingInput,
output: GetEventsListingOutput,
func: async ({ kysely }, { venueSlug, filter, search }) => {
const venue = await kysely
.selectFrom('venue')
.where('slug', '=', venueSlug)
.select('venueId')
.executeTakeFirstOrThrow()
const today = new Date().toISOString().slice(0, 10)
const year = Number(today.slice(0, 4))
let q = kysely
.selectFrom('booking')
.innerJoin('client', 'client.clientId', 'booking.clientId')
.where('booking.venueId', '=', venue.venueId)
.where('booking.onlineAd', '=', 1)
.where('booking.status', 'not in', ['cancelled', 'enquiry'])
.where('booking.endDate', '>=', today)
if (filter === 'this_year') {
q = q.where('booking.startDate', '<=', `${year}-12-31`)
} else if (filter === 'next_year') {
q = q.where('booking.startDate', '>=', `${year + 1}-01-01`)
.where('booking.startDate', '<=', `${year + 1}-12-31`)
}
if (search?.trim()) {
const term = `%${search.trim()}%`
q = q.where((eb) =>
eb.or([
eb('booking.eventName', 'like', term),
eb('client.name', 'like', term),
]),
)
}
const rows = await q
.orderBy('booking.startDate', 'asc')
.select([
'booking.bookingId',
'booking.eventName',
'booking.startDate',
'booking.endDate',
'booking.eventOutline',
'booking.description',
'booking.coverImageUrl',
'booking.organiserWebsite',
'client.name as clientName',
])
.execute()
const events = rows
.filter((r): r is typeof r & { startDate: string; endDate: string } =>
r.startDate != null && r.endDate != null,
)
.map((r) => ({
bookingId: r.bookingId,
eventName: r.eventName,
startDate: new Date(r.startDate),
endDate: new Date(r.endDate),
eventOutline: r.eventOutline,
description: r.description,
coverImageUrl: r.coverImageUrl,
organiserWebsite: r.organiserWebsite,
clientName: r.clientName,
}))
return { events }
},
})

View File

@@ -0,0 +1,73 @@
import { randomUUID } from 'node:crypto'
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { isAdminOrOwner } from '../lib/permissions.js'
export const GetUploadUrlInput = z.object({
purpose: z.enum(['invoicePdf']),
bookingId: z.string().optional(),
fileName: z.string().min(1),
contentType: z.string().min(1),
size: z.number().int().positive().optional(),
})
export const GetUploadUrlOutput = z.object({
uploadUrl: z.string(),
contentKey: z.string(),
expiresAt: z.string(),
uploadMethod: z.enum(['PUT', 'POST']).optional(),
uploadHeaders: z.record(z.string(), z.string()).optional(),
})
const sanitizeFileName = (fileName: string) =>
fileName.replace(/[^a-zA-Z0-9._-]/g, '_')
export const getUploadUrl = pikkuFunc({
expose: true,
description: 'Return a presigned upload URL for an allowed upload purpose.',
permissions: { isAdminOrOwner },
input: GetUploadUrlInput,
output: GetUploadUrlOutput,
func: async ({ kysely, content }, input) => {
if (!content) {
throw new Error('Content service not configured')
}
switch (input.purpose) {
case 'invoicePdf': {
if (!input.bookingId) {
throw new Error('bookingId is required for invoicePdf uploads')
}
if (
input.contentType !== 'application/pdf' &&
!input.fileName.toLowerCase().endsWith('.pdf')
) {
throw new Error('Invoice uploads must be PDFs')
}
await kysely
.selectFrom('booking')
.where('bookingId', '=', input.bookingId)
.select('bookingId')
.executeTakeFirstOrThrow()
const contentKey =
`${input.bookingId}/invoices/${randomUUID()}-${sanitizeFileName(input.fileName)}`
const { uploadUrl, uploadMethod, uploadHeaders } = await content.getUploadURL({
bucket: 'invoices',
fileKey: contentKey,
contentType: input.contentType,
size: input.size,
})
return {
uploadUrl,
contentKey,
uploadMethod,
uploadHeaders,
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
}
}
}
},
})

View File

@@ -0,0 +1,90 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { ForbiddenError } from '@pikku/core/errors'
const Severity = z.enum(['standard', 'separate_prep', 'severe'])
async function assertOrgAccess(
kysely: any,
participantId: string,
userId: string,
role: string,
) {
if (role === 'admin' || role === 'owner') return
const row = await kysely
.selectFrom('participant')
.innerJoin('booking', 'booking.bookingId', 'participant.bookingId')
.innerJoin('clientMember', 'clientMember.clientId', 'booking.clientId')
.where('participant.participantId', '=', participantId)
.where('clientMember.userId', '=', userId)
.select('participant.participantId')
.executeTakeFirst()
if (!row) throw new ForbiddenError()
}
export const AddParticipantAllergyInput = z.object({
participantId: z.string(),
allergen: z.string().min(1),
severity: Severity.default('standard'),
note: z.string().nullable().optional(),
})
export const AddParticipantAllergyOutput = z.object({ ok: z.literal(true) })
export const addParticipantAllergy = pikkuFunc({
expose: true,
description: 'Add an allergy to a participant. Idempotent on (participantId, allergen).',
input: AddParticipantAllergyInput,
output: AddParticipantAllergyOutput,
func: async ({ kysely }, input, { session }) => {
await assertOrgAccess(kysely, input.participantId, session.userId, session.role)
const existing = await kysely
.selectFrom('participantAllergy')
.where('participantId', '=', input.participantId)
.where('allergen', '=', input.allergen)
.select('allergen')
.executeTakeFirst()
if (existing) {
await kysely
.updateTable('participantAllergy')
.set({ severity: input.severity, note: input.note ?? null })
.where('participantId', '=', input.participantId)
.where('allergen', '=', input.allergen)
.execute()
} else {
await kysely
.insertInto('participantAllergy')
.values({
participantId: input.participantId,
allergen: input.allergen,
severity: input.severity,
note: input.note ?? null,
})
.execute()
}
return { ok: true as const }
},
})
export const RemoveParticipantAllergyInput = z.object({
participantId: z.string(),
allergen: z.string().min(1),
})
export const RemoveParticipantAllergyOutput = z.object({ ok: z.literal(true) })
export const removeParticipantAllergy = pikkuFunc({
expose: true,
description: 'Remove an allergy from a participant.',
input: RemoveParticipantAllergyInput,
output: RemoveParticipantAllergyOutput,
func: async ({ kysely }, input, { session }) => {
await assertOrgAccess(kysely, input.participantId, session.userId, session.role)
await kysely
.deleteFrom('participantAllergy')
.where('participantId', '=', input.participantId)
.where('allergen', '=', input.allergen)
.execute()
return { ok: true as const }
},
})

View File

@@ -0,0 +1,140 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { ForbiddenError } from '@pikku/core/errors'
import { ParticipantInsertZ, ParticipantPatchZ } from '#pikku/db/zod.gen.js'
const uid = () =>
(globalThis.crypto?.randomUUID?.() ??
Math.random().toString(16).slice(2) + Math.random().toString(16).slice(2))
const DietaryTag = z.enum([
'omnivore',
'vegetarian',
'vegan',
'gluten_free',
'lactose_free',
'pescatarian',
'unknown',
])
const ParticipantKind = z.enum(['overnight', 'day_guest'])
async function assertOrgAccess(
kysely: any,
bookingId: string,
userId: string,
role: string,
) {
if (role === 'admin' || role === 'owner') return
const row = await kysely
.selectFrom('booking')
.innerJoin('clientMember', 'clientMember.clientId', 'booking.clientId')
.where('booking.bookingId', '=', bookingId)
.where('clientMember.userId', '=', userId)
.select('booking.bookingId')
.executeTakeFirst()
if (!row) throw new ForbiddenError()
}
export const AddParticipantInput = ParticipantInsertZ
.omit({ participantId: true, createdAt: true, selfFormToken: true, selfFormSubmittedAt: true, freeText: true })
.extend({
kind: ParticipantKind.default('overnight'),
dietaryTag: DietaryTag.optional().nullable(),
})
export const AddParticipantOutput = z.object({ participantId: z.string() })
export const addParticipant = pikkuFunc({
expose: true,
description: 'Add a participant to a booking.',
input: AddParticipantInput,
output: AddParticipantOutput,
func: async ({ kysely }, input, { session }) => {
await assertOrgAccess(kysely, input.bookingId, session.userId, session.role)
const participantId = `p_${uid().slice(0, 8)}`
await kysely
.insertInto('participant')
.values({
participantId,
bookingId: input.bookingId,
name: input.name,
email: input.email ?? null,
age: input.age ?? null,
kind: input.kind,
dietaryTag: input.dietaryTag ?? null,
notes: input.notes ?? null,
})
.execute()
return { participantId }
},
})
export const UpdateParticipantInput = ParticipantPatchZ
.pick({ name: true, email: true, age: true, kind: true, dietaryTag: true, notes: true })
.extend({
participantId: z.string(),
kind: ParticipantKind.optional(),
dietaryTag: DietaryTag.optional().nullable(),
})
export const UpdateParticipantOutput = z.object({ ok: z.boolean() })
export const updateParticipant = pikkuFunc({
expose: true,
description: 'Update participant fields.',
input: UpdateParticipantInput,
output: UpdateParticipantOutput,
func: async ({ kysely }, input, { session }) => {
const existing = await kysely
.selectFrom('participant')
.where('participantId', '=', input.participantId)
.select('bookingId')
.executeTakeFirstOrThrow()
await assertOrgAccess(kysely, existing.bookingId, session.userId, session.role)
const patch: Record<string, unknown> = {}
if (input.name !== undefined) patch.name = input.name
if (input.email !== undefined) patch.email = input.email
if (input.age !== undefined) patch.age = input.age
if (input.kind !== undefined) patch.kind = input.kind
if (input.dietaryTag !== undefined) patch.dietaryTag = input.dietaryTag
if (input.notes !== undefined) patch.notes = input.notes
if (Object.keys(patch).length === 0) return { ok: true }
await kysely
.updateTable('participant')
.set(patch)
.where('participantId', '=', input.participantId)
.execute()
return { ok: true }
},
})
export const RemoveParticipantInput = z.object({
participantId: z.string(),
})
export const RemoveParticipantOutput = z.object({ ok: z.boolean() })
export const removeParticipant = pikkuFunc({
expose: true,
description: 'Remove a participant from a booking.',
input: RemoveParticipantInput,
output: RemoveParticipantOutput,
func: async ({ kysely }, input, { session }) => {
const existing = await kysely
.selectFrom('participant')
.where('participantId', '=', input.participantId)
.select('bookingId')
.executeTakeFirstOrThrow()
await assertOrgAccess(kysely, existing.bookingId, session.userId, session.role)
await kysely
.deleteFrom('participantAllergy')
.where('participantId', '=', input.participantId)
.execute()
await kysely
.deleteFrom('roomAssignment')
.where('participantId', '=', input.participantId)
.execute()
await kysely
.deleteFrom('participant')
.where('participantId', '=', input.participantId)
.execute()
return { ok: true }
},
})

View File

@@ -0,0 +1,77 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { ContractResponseRequiresReservedError, ContractNotSentError } from '../errors.js'
import { isAdminOrOwner } from '../lib/permissions.js'
export const RecordContractResponseInput = z.object({
bookingId: z.string(),
response: z.enum(['approved', 'declined']),
})
export const RecordContractResponseOutput = z.object({ ok: z.literal(true) })
/**
* Admin records the client's response to the contract. Approved stamps the AGB
* acceptance (the contract IS the AGB) and clears any prior decline flag;
* declined flags the booking for admin review (the soft reservation stays —
* the admin decides whether to cancel). Does not move the status bucket.
*/
export const recordContractResponse = pikkuFunc({
expose: true,
description: 'Admin records the client contract response (approved stamps AGB; declined flags for review).',
permissions: { isAdminOrOwner },
input: RecordContractResponseInput,
output: RecordContractResponseOutput,
func: async ({ kysely }, { bookingId, response }, { session }) => {
const booking = await kysely
.selectFrom('booking')
.where('bookingId', '=', bookingId)
.select(['bookingId', 'status', 'contractSentAt', 'venueId'])
.executeTakeFirstOrThrow()
if (booking.status !== 'reserved') {
throw new ContractResponseRequiresReservedError(booking.status)
}
if (!booking.contractSentAt) {
throw new ContractNotSentError()
}
const now = new Date().toISOString()
await kysely
.updateTable('booking')
.set({
contractResponse: response,
contractResponseAt: now,
updatedAt: now,
...(response === 'approved'
? {
agbAcceptedAt: now,
agbYear: Number(now.slice(0, 4)),
flaggedAt: null,
flagReason: null,
}
: { flaggedAt: now, flagReason: 'contract_declined' }),
})
.where('bookingId', '=', bookingId)
.execute()
await kysely
.insertInto('auditLog')
.values({
auditId:
globalThis.crypto?.randomUUID?.() ??
`audit_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
venueId: booking.venueId,
userId: session.userId,
entity: 'booking',
entityId: bookingId,
field: 'contractResponse',
beforeValue: null,
afterValue: response,
action: 'update',
})
.execute()
return { ok: true as const }
},
})

View File

@@ -0,0 +1,56 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { DepositRequiresReservedError } from '../errors.js'
import { applyBookingTransition } from '../lib/booking-status.js'
import { isAdminOrOwner } from '../lib/permissions.js'
export const RecordDepositReceivedInput = z.object({
bookingId: z.string(),
})
export const RecordDepositReceivedOutput = z.object({ ok: z.literal(true) })
/**
* Admin records the deposit as received: marks the deposit invoice paid, clears
* any deposit-overdue flag, and confirms the booking (reserved → confirmed,
* which fires the confirmation email). Guarded to `reserved` bookings only.
*/
export const recordDepositReceived = pikkuFunc({
expose: true,
description: 'Admin records deposit received → invoice paid, flag cleared, booking confirmed.',
permissions: { isAdminOrOwner },
input: RecordDepositReceivedInput,
output: RecordDepositReceivedOutput,
func: async ({ kysely, email }, { bookingId }, { session }) => {
const booking = await kysely
.selectFrom('booking')
.where('bookingId', '=', bookingId)
.select(['status'])
.executeTakeFirstOrThrow()
if (booking.status !== 'reserved') {
throw new DepositRequiresReservedError(booking.status)
}
const today = new Date().toISOString().slice(0, 10)
await kysely
.updateTable('invoice')
.set({ paidOn: today, status: 'paid' })
.where('bookingId', '=', bookingId)
.where('kind', '=', 'deposit')
.where('paidOn', 'is', null)
.execute()
await kysely
.updateTable('booking')
.set({ flaggedAt: null, flagReason: null })
.where('bookingId', '=', bookingId)
.execute()
await applyBookingTransition(
{ kysely, email },
{ bookingId, to: 'confirmed', userId: session.userId },
)
return { ok: true as const }
},
})

View File

@@ -0,0 +1,65 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { InvoiceAlreadyPaidError, InvoiceCancelledError } from '../errors.js'
import { isAdminOrOwner } from '../lib/permissions.js'
export const RecordInvoicePaymentInput = z.object({
invoiceId: z.string(),
paidOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
})
export const RecordInvoicePaymentOutput = z.object({
invoiceId: z.string(),
status: z.literal('paid'),
paidOn: z.string(),
})
const uid = () =>
globalThis.crypto?.randomUUID?.() ??
`audit_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
export const recordInvoicePayment = pikkuFunc({
expose: true,
description: 'Admin/owner: mark an outstanding invoice as paid on a given date.',
permissions: { isAdminOrOwner },
input: RecordInvoicePaymentInput,
output: RecordInvoicePaymentOutput,
func: async ({ kysely }, { invoiceId, paidOn }, { session }) => {
const inv = await kysely
.selectFrom('invoice')
.innerJoin('booking', 'booking.bookingId', 'invoice.bookingId')
.where('invoice.invoiceId', '=', invoiceId)
.select(['invoice.invoiceId', 'invoice.status', 'booking.venueId'])
.executeTakeFirstOrThrow()
if (inv.status === 'paid') {
throw new InvoiceAlreadyPaidError()
}
if (inv.status === 'cancelled') {
throw new InvoiceCancelledError()
}
await kysely
.updateTable('invoice')
.set({ status: 'paid', paidOn })
.where('invoiceId', '=', invoiceId)
.execute()
await kysely
.insertInto('auditLog')
.values({
auditId: uid(),
venueId: inv.venueId,
userId: session.userId,
entity: 'invoice',
entityId: invoiceId,
field: 'status',
beforeValue: inv.status,
afterValue: 'paid',
action: 'update',
})
.execute()
return { invoiceId, status: 'paid' as const, paidOn }
},
})

View File

@@ -0,0 +1,42 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { BOOKING_STATUSES, applyBookingTransition } from '../lib/booking-status.js'
import { isAdminOrOwner } from '../lib/permissions.js'
export const SetBookingStatusInput = z.object({
bookingId: z.string(),
status: z.enum(BOOKING_STATUSES),
})
export const SetBookingStatusOutput = z.object({
bookingId: z.string(),
status: z.enum(BOOKING_STATUSES),
})
export const setBookingStatus = pikkuFunc({
expose: true,
description:
'Admin status transition — enforces the lifecycle transition map and fires the email that transition owns.',
permissions: { isAdminOrOwner },
input: SetBookingStatusInput,
output: SetBookingStatusOutput,
func: async ({ kysely, email }, { bookingId, status }, { session }) => {
const newStatus = await applyBookingTransition(
{ kysely, email },
{ bookingId, to: status, userId: session.userId },
)
// Cancelling voids any open (unpaid) deposit invoice for the booking.
if (newStatus === 'cancelled') {
await kysely
.updateTable('invoice')
.set({ status: 'cancelled' })
.where('bookingId', '=', bookingId)
.where('kind', '=', 'deposit')
.where('paidOn', 'is', null)
.execute()
}
return { bookingId, status: newStatus }
},
})

View File

@@ -0,0 +1,107 @@
import { z } from 'zod'
import { pikkuSessionlessFunc } from '#pikku'
import { InvalidDateRangeError } from '../errors.js'
const uid = () =>
globalThis.crypto?.randomUUID?.() ??
`id_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
export const SubmitEnquiryInput = z.object({
venueSlug: z.string().default('drawehn'),
// Contact details only — no client row is created at submit time. An admin
// selects or creates a client when the enquiry is approved.
contact: z.object({
name: z.string().optional(),
contactEmail: z.string().email(),
contactPhone: z.string().optional(),
website: z.string().optional(),
}),
event: z.object({
eventName: z.string().min(1),
eventOutline: z.string().optional(),
description: z.string().optional(),
expectedPersons: z.number().int().positive().optional(),
// .optional() (not .default) so the public API can omit it without the
// codegen marking it required — see CLAUDE.md.
halfHouse: z.boolean().optional(),
}),
// Prioritized date options (E6.1): 03 ranges, in priority order (first =
// preferred). Optional — a visitor may submit with no dates at all.
dateOptions: z
.array(
z.object({
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
}),
)
.max(3)
.optional(),
notes: z.string().optional(),
privacyAccepted: z.literal(true),
})
export const SubmitEnquiryOutput = z.object({
enquiryId: z.string(),
})
export const submitEnquiry = pikkuSessionlessFunc({
expose: true,
description: 'Public enquiry submission — creates a pending enquiry for admin triage.',
input: SubmitEnquiryInput,
output: SubmitEnquiryOutput,
func: async ({ kysely }, input) => {
const venue = await kysely
.selectFrom('venue')
.where('slug', '=', input.venueSlug)
.select('venueId')
.executeTakeFirstOrThrow()
const dateOptions = input.dateOptions ?? []
for (const opt of dateOptions) {
if (opt.startDate > opt.endDate) {
throw new InvalidDateRangeError()
}
}
const enquiryId = uid()
const now = new Date().toISOString()
await kysely.transaction().execute(async (trx) => {
await trx
.insertInto('enquiry')
.values({
enquiryId,
venueId: venue.venueId,
contactName: input.contact.name ?? null,
contactEmail: input.contact.contactEmail,
contactPhone: input.contact.contactPhone ?? null,
website: input.contact.website ?? null,
eventName: input.event.eventName,
eventOutline: input.event.eventOutline ?? null,
description: input.event.description ?? null,
expectedPersons: input.event.expectedPersons ?? null,
halfHouse: input.event.halfHouse ? 1 : 0,
notes: input.notes ?? null,
privacyAcceptedAt: now,
status: 'pending',
})
.execute()
if (dateOptions.length) {
await trx
.insertInto('enquiryDateOption')
.values(
dateOptions.map((opt, i) => ({
optionId: uid(),
enquiryId,
startDate: opt.startDate,
endDate: opt.endDate,
priority: i,
})),
)
.execute()
}
})
return { enquiryId }
},
})

View File

@@ -0,0 +1,139 @@
import { z } from 'zod'
import { sql } from 'kysely'
import { pikkuSessionlessFunc } from '#pikku'
import { ForbiddenError } from '@pikku/core/errors'
const addDays = (d: Date, n: number): string => {
const r = new Date(d)
r.setUTCDate(r.getUTCDate() + n)
return r.toISOString().slice(0, 10)
}
export const TestResetOutput = z.object({ ok: z.literal(true) })
/**
* Wipes mutable per-test state (sessions, bookings + dependents, audit_log)
* and re-inserts the demo bookings/participants/invoices the e2e suite expects.
*
* Reference data (venue, rooms, bathrooms, seminar rooms, app_user, org) is
* preserved — it never changes between scenarios and is expensive to re-seed.
*
* Gated by the E2E_TEST_RESET env var so it's a no-op in any environment that
* doesn't explicitly opt in. Never expose this in prod.
*/
export const testReset = pikkuSessionlessFunc({
expose: true,
auth: false,
description: 'E2E-only: reset booking + session state to a known baseline.',
output: TestResetOutput,
func: async ({ kysely, variables }) => {
if (variables.get('E2E_TEST_RESET') !== '1') {
throw new ForbiddenError()
}
const today = new Date()
// Lifecycle test booking — dates computed as offsets from today so the
// lifecycle rules always fire correctly regardless of when the tests run.
const lcStart = addDays(today, 200) // within 365 days → R1 fires
const lcEnd = addDays(today, 207) // 7-day retreat
const lcContractSent = addDays(today, -8) // 8 days ago → R2 fires
const lcCompleteEnd = addDays(today, -1) // ended yesterday → R4 fires
const lcCompleteStart = addDays(today, -8)
await kysely.transaction().execute(async (trx) => {
await sql`DELETE FROM room_assignment`.execute(trx)
await sql`DELETE FROM participant_allergy`.execute(trx)
await sql`DELETE FROM participant`.execute(trx)
await sql`DELETE FROM invoice`.execute(trx)
await sql`DELETE FROM booking_extra`.execute(trx)
await sql`DELETE FROM booking`.execute(trx)
await sql`DELETE FROM booking_session`.execute(trx)
await sql`DELETE FROM audit_log`.execute(trx)
await sql`
INSERT INTO booking
(booking_id, venue_id, client_id, event_name, start_date, end_date,
status, half_house, online_ad, expected_persons, description,
meal_time_breakfast, meal_time_lunch, meal_time_dinner, payment_method,
agb_year, agb_accepted_at, privacy_accepted_at)
VALUES
('b_yoga_2026_summer', 'v_drawehn', 'client_yoga',
'Summer Yoga Retreat 2026', '2026-07-12', '2026-07-19',
'confirmed', 0, 1, 28,
'A week of yoga, breath work, and quiet meals in Lower Saxony.',
'08:00', '13:00', '19:00', 'transfer',
2026, '2026-02-14T10:30:00Z', '2026-02-14T10:30:00Z'),
('b_yoga_2026_winter', 'v_drawehn', 'client_yoga',
'Winter Stille 2026', '2026-12-27', '2027-01-02',
'confirmed', 0, 0, 18,
'Year-end silent retreat.',
'08:30', '13:00', '18:30', 'transfer',
2026, '2026-04-01T09:00:00Z', '2026-04-01T09:00:00Z'),
-- Lifecycle test bookings (dates relative to today, computed in TypeScript above)
('b_lc_enquiry', 'v_drawehn', 'client_yoga',
'Lifecycle Test Retreat', ${lcStart}, ${lcEnd},
'enquiry', 0, 0, 10, 'Lifecycle e2e test booking.',
'08:00', '13:00', '19:00', 'transfer',
NULL, NULL, NULL),
('b_lc_reserved', 'v_drawehn', 'client_yoga',
'Lifecycle Test Retreat (Reserved)', ${lcStart}, ${lcEnd},
'reserved', 0, 0, 10, 'Reserved — contract not yet sent.',
'08:00', '13:00', '19:00', 'transfer',
NULL, NULL, NULL),
('b_lc_contract_sent', 'v_drawehn', 'client_yoga',
'Lifecycle Test Retreat (Contract Sent)', ${lcStart}, ${lcEnd},
'reserved', 0, 0, 10, 'Reserved — contract sent 8 days ago.',
'08:00', '13:00', '19:00', 'transfer',
NULL, NULL, NULL),
('b_lc_confirmed', 'v_drawehn', 'client_yoga',
'Lifecycle Test Retreat (Confirmed)', ${lcCompleteStart}, ${lcCompleteEnd},
'confirmed', 0, 0, 10, 'Confirmed — ended yesterday, ready for auto-complete.',
'08:00', '13:00', '19:00', 'transfer',
NULL, NULL, NULL)
`.execute(trx)
// Set contractSentAt for the contract-sent booking so R2 fires
await sql`
UPDATE booking
SET contract_sent_at = ${lcContractSent}
WHERE booking_id = 'b_lc_contract_sent'
`.execute(trx)
// Add a deposit invoice for the contract-sent booking (unpaid → R2 fires)
await sql`
INSERT INTO invoice
(invoice_id, booking_id, kind, invoice_number, issued_on, due_on, paid_on, amount_cents, status)
VALUES
('inv_lc_dep', 'b_lc_contract_sent', 'deposit', 'LC-DEP-001', ${lcContractSent}, ${addDays(today, 6)}, NULL, 30000, 'outstanding')
`.execute(trx)
await sql`
INSERT INTO participant (participant_id, booking_id, name, kind, dietary_tag) VALUES
('p_s_01', 'b_yoga_2026_summer', 'Anja Weber', 'overnight', 'vegan'),
('p_s_02', 'b_yoga_2026_summer', 'Bernd Krause', 'overnight', 'vegetarian'),
('p_s_03', 'b_yoga_2026_summer', 'Clara Hoffmann', 'overnight', 'vegan'),
('p_s_04', 'b_yoga_2026_summer', 'Dieter Lange', 'overnight', 'omnivore'),
('p_s_05', 'b_yoga_2026_summer', 'Eva Richter', 'overnight', 'gluten_free'),
('p_s_06', 'b_yoga_2026_summer', 'Frank Müller', 'overnight', NULL),
('p_s_07', 'b_yoga_2026_summer', 'Greta Schulz', 'overnight', 'vegan'),
('p_s_08', 'b_yoga_2026_summer', 'Hans Becker', 'day_guest', 'vegetarian')
`.execute(trx)
await sql`
INSERT INTO participant_allergy (participant_id, allergen, severity) VALUES
('p_s_01', 'nuts', 'standard'),
('p_s_05', 'gluten_strict', 'separate_prep')
`.execute(trx)
await sql`
INSERT INTO invoice
(invoice_id, booking_id, kind, invoice_number, issued_on, due_on, paid_on, amount_cents, status)
VALUES
('inv_s_dep', 'b_yoga_2026_summer', 'deposit', '2026-001', '2026-02-15', '2026-03-01', '2026-02-22', 30000, 'paid'),
('inv_w_dep', 'b_yoga_2026_winter', 'deposit', '2026-002', '2026-04-02', '2026-04-16', '2026-04-10', 30000, 'paid')
`.execute(trx)
})
return { ok: true as const }
},
})

View File

@@ -0,0 +1,43 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { ContractRequiresReservedError, ContractAlreadySentError } from '../errors.js'
import { berlinDate, dispatchContract } from '../lib/booking-lifecycle.js'
import { isAdminOrOwner } from '../lib/permissions.js'
export const TriggerContractEmailInput = z.object({
bookingId: z.string(),
})
export const TriggerContractEmailOutput = z.object({ ok: z.literal(true) })
/**
* Admin: send the contract+deposit email now (runs lifecycle rule R1 for one
* booking immediately). Useful for early sends and for testing without waiting
* for the booking to reach the ~1-year-before-start mark. Guarded to reserved
* bookings that haven't already had the contract sent.
*/
export const triggerContractEmail = pikkuFunc({
expose: true,
description: 'Admin: send the contract + deposit email now (manual lifecycle R1 for one booking).',
permissions: { isAdminOrOwner },
input: TriggerContractEmailInput,
output: TriggerContractEmailOutput,
func: async ({ kysely, email }, { bookingId }) => {
const booking = await kysely
.selectFrom('booking')
.where('bookingId', '=', bookingId)
.select(['bookingId', 'venueId', 'status', 'contractSentAt'])
.executeTakeFirstOrThrow()
if (booking.status !== 'reserved') {
throw new ContractRequiresReservedError(booking.status)
}
if (booking.contractSentAt) {
throw new ContractAlreadySentError()
}
await dispatchContract({ kysely, email }, booking, berlinDate())
return { ok: true as const }
},
})

View File

@@ -0,0 +1,71 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { isAdminOrOwner } from '../lib/permissions.js'
export const UpdateClientInput = z.object({
clientId: z.string(),
patch: z.object({
name: z.string().min(1).optional(),
contactEmail: z.string().email().nullable().optional(),
contactPhone: z.string().nullable().optional(),
billingAddress: z.string().nullable().optional(),
website: z.string().url().nullable().optional(),
}),
})
export const UpdateClientOutput = z.object({ ok: z.literal(true) })
const uid = () =>
globalThis.crypto?.randomUUID?.() ??
`audit_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
export const updateClient = pikkuFunc({
expose: true,
description: 'Admin: edit client contact fields. Logs each changed field.',
permissions: { isAdminOrOwner },
input: UpdateClientInput,
output: UpdateClientOutput,
func: async ({ kysely }, { clientId, patch }, { session }) => {
const before = await kysely
.selectFrom('client')
.where('clientId', '=', clientId)
.select(['venueId', 'name', 'contactEmail', 'contactPhone', 'billingAddress', 'website'])
.executeTakeFirstOrThrow()
const dbPatch: Record<string, unknown> = {}
for (const [k, v] of Object.entries(patch)) {
if (v !== undefined) dbPatch[k] = v
}
if (Object.keys(dbPatch).length === 0) return { ok: true as const }
await kysely
.updateTable('client')
.set(dbPatch)
.where('clientId', '=', clientId)
.execute()
const now = new Date().toISOString()
for (const [k, v] of Object.entries(patch)) {
if (v === undefined) continue
const beforeValue = (before as Record<string, unknown>)[k]
if (beforeValue === v) continue
await kysely
.insertInto('auditLog')
.values({
auditId: uid(),
venueId: before.venueId,
userId: session.userId,
entity: 'client',
entityId: clientId,
field: k,
beforeValue: beforeValue == null ? null : String(beforeValue),
afterValue: v == null ? null : String(v),
action: 'update',
at: now,
})
.execute()
}
return { ok: true as const }
},
})

View File

@@ -0,0 +1,113 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { InvoiceCancelledError } from '../errors.js'
import { isAdminOrOwner } from '../lib/permissions.js'
export const UpdateInvoiceInput = z.object({
invoiceId: z.string(),
patch: z.object({
invoiceNumber: z.string().min(1).optional(),
issuedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
dueOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
amountCents: z.number().int().positive().optional(),
pdfAssetKey: z.string().min(1).nullable().optional(),
}),
})
export const UpdateInvoiceOutput = z.object({
invoiceId: z.string(),
})
const uid = () =>
globalThis.crypto?.randomUUID?.() ??
`audit_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
export const updateInvoice = pikkuFunc({
expose: true,
description: 'Admin/owner: edit invoice metadata (number, dates, amount) for typo correction.',
permissions: { isAdminOrOwner },
input: UpdateInvoiceInput,
output: UpdateInvoiceOutput,
func: async ({ kysely }, { invoiceId, patch }, { session }) => {
const inv = await kysely
.selectFrom('invoice')
.innerJoin('booking', 'booking.bookingId', 'invoice.bookingId')
.where('invoice.invoiceId', '=', invoiceId)
.select([
'invoice.invoiceId',
'invoice.status',
'invoice.invoiceNumber',
'invoice.issuedOn',
'invoice.dueOn',
'invoice.amountCents',
'invoice.pdfUrl',
'booking.venueId',
])
.executeTakeFirstOrThrow()
if (inv.status === 'cancelled') {
throw new InvoiceCancelledError()
}
const set: Record<string, unknown> = {}
const audits: Array<{ field: string; before: string | null; after: string | null }> = []
if (patch.invoiceNumber !== undefined && patch.invoiceNumber !== inv.invoiceNumber) {
set.invoiceNumber = patch.invoiceNumber
audits.push({ field: 'invoiceNumber', before: inv.invoiceNumber, after: patch.invoiceNumber })
}
if (patch.issuedOn !== undefined && patch.issuedOn !== inv.issuedOn) {
set.issuedOn = patch.issuedOn
audits.push({ field: 'issuedOn', before: inv.issuedOn, after: patch.issuedOn })
}
if (patch.dueOn !== undefined && patch.dueOn !== inv.dueOn) {
set.dueOn = patch.dueOn
audits.push({ field: 'dueOn', before: inv.dueOn ?? null, after: patch.dueOn ?? null })
}
if (patch.amountCents !== undefined && patch.amountCents !== inv.amountCents) {
set.amountCents = patch.amountCents
audits.push({
field: 'amountCents',
before: String(inv.amountCents),
after: String(patch.amountCents),
})
}
if (patch.pdfAssetKey !== undefined && patch.pdfAssetKey !== inv.pdfUrl) {
set.pdfUrl = patch.pdfAssetKey
audits.push({
field: 'pdfUrl',
before: inv.pdfUrl ? '[pdf]' : null,
after: patch.pdfAssetKey ? '[pdf]' : null,
})
}
if (Object.keys(set).length === 0) {
return { invoiceId }
}
await kysely
.updateTable('invoice')
.set(set)
.where('invoiceId', '=', invoiceId)
.execute()
for (const a of audits) {
await kysely
.insertInto('auditLog')
.values({
auditId: uid(),
venueId: inv.venueId,
userId: session.userId,
entity: 'invoice',
entityId: invoiceId,
field: a.field,
beforeValue: a.before,
afterValue: a.after,
action: 'update',
})
.execute()
}
return { invoiceId }
},
})

View File

@@ -0,0 +1,73 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { isAdminOrOwner } from '../lib/permissions.js'
import {
EnquiryDatesRequiredError,
EnquiryNotPendingError,
} from '../errors.js'
const uid = () =>
globalThis.crypto?.randomUUID?.() ??
`id_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
export const WaitlistEnquiryInput = z.object({
enquiryId: z.string(),
})
export const WaitlistEnquiryOutput = z.object({ ok: z.literal(true) })
export const waitlistEnquiry = pikkuFunc({
expose: true,
description: 'Admin: waitlist a pending enquiry (sets waitlisted_at; stays pending).',
permissions: { isAdminOrOwner },
input: WaitlistEnquiryInput,
output: WaitlistEnquiryOutput,
func: async ({ kysely }, input, { session }) => {
const enquiry = await kysely
.selectFrom('enquiry')
.where('enquiryId', '=', input.enquiryId)
.select(['venueId', 'status'])
.executeTakeFirstOrThrow()
if (enquiry.status !== 'pending') {
throw new EnquiryNotPendingError(enquiry.status)
}
// Waitlisting needs at least one concrete date option, so the enquiry can
// be slotted against the calendar once a clashing booking frees up.
const optionCount = await kysely
.selectFrom('enquiryDateOption')
.where('enquiryId', '=', input.enquiryId)
.select(({ fn }) => fn.countAll<number>().as('count'))
.executeTakeFirst()
if (!optionCount || optionCount.count === 0) {
throw new EnquiryDatesRequiredError()
}
const now = new Date().toISOString()
await kysely.transaction().execute(async (trx) => {
await trx
.updateTable('enquiry')
.set({ waitlistedAt: now })
.where('enquiryId', '=', input.enquiryId)
.execute()
await trx
.insertInto('auditLog')
.values({
auditId: uid(),
venueId: enquiry.venueId,
userId: session.userId,
entity: 'enquiry',
entityId: input.enquiryId,
field: 'waitlistedAt',
beforeValue: null,
afterValue: now,
action: 'update',
})
.execute()
})
return { ok: true as const }
},
})

View File

@@ -0,0 +1,231 @@
import type { Kysely } from 'kysely'
import type { DB } from '../types/db.types.js'
import type { EmailService } from '../services/email.js'
import { applyBookingTransition } from './booking-status.js'
/**
* The booking lifecycle's time-driven rules, factored out so the daily cron,
* the manual "run now" RPC, and `triggerContractEmail` all share one
* implementation. All calendar math is Europe/Berlin local days; every rule is
* idempotent (guarded by `*SentAt`/`flaggedAt`/`paidOn`), so running twice in a
* day is a no-op.
*/
type LifecycleServices = {
kysely: Kysely<DB>
email: EmailService
logger: { info: (message: string) => void }
}
/** Today as a Berlin-local `YYYY-MM-DD` (sv-SE locale emits ISO date form). */
export const berlinDate = (d: Date = new Date()): string =>
d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
/** The Berlin-local date portion of a stored ISO timestamp. */
export const berlinDateFromIso = (iso: string): string => berlinDate(new Date(iso))
/** Whole days from `fromYmd` to `toYmd` (both `YYYY-MM-DD`, UTC-anchored so DST never skews the count). */
export const daysBetween = (fromYmd: string, toYmd: string): number =>
Math.round((Date.parse(toYmd) - Date.parse(fromYmd)) / 86_400_000)
/** `YYYY-MM-DD` plus `n` calendar days. */
export const addDays = (ymd: string, n: number): string => {
const d = new Date(`${ymd}T00:00:00Z`)
d.setUTCDate(d.getUTCDate() + n)
return d.toISOString().slice(0, 10)
}
const DEPOSIT_WINDOW_DAYS = 14
const DEPOSIT_REMINDER_DAYS = 7
/** Placeholder deposit amount (cents) until a real per-booking calc lands. */
const DEFAULT_DEPOSIT_CENTS = 30000
/**
* Lifecycle rule R1, for a single booking: ensure a deposit invoice exists,
* send the contract+deposit email, then stamp `contractSentAt`. The
* invoice-existence check (not `contractSentAt`) makes the whole sequence
* safely re-entrant even if a prior run died after the email.
*/
export async function dispatchContract(
{ kysely, email }: Pick<LifecycleServices, 'kysely' | 'email'>,
booking: { bookingId: string; venueId: string },
todayYmd: string,
): Promise<void> {
const existing = await kysely
.selectFrom('invoice')
.where('bookingId', '=', booking.bookingId)
.where('kind', '=', 'deposit')
.select('invoiceId')
.executeTakeFirst()
if (!existing) {
await kysely
.insertInto('invoice')
.values({
invoiceId: `inv_${booking.bookingId}_dep`,
bookingId: booking.bookingId,
kind: 'deposit',
invoiceNumber: `DEP-${todayYmd}-${booking.bookingId}`,
issuedOn: todayYmd,
dueOn: addDays(todayYmd, DEPOSIT_WINDOW_DAYS),
paidOn: null,
amountCents: DEFAULT_DEPOSIT_CENTS,
status: 'outstanding',
})
.execute()
}
await email.sendContractAndDepositEmail(booking.bookingId)
const now = new Date().toISOString()
await kysely
.updateTable('booking')
.set({ contractSentAt: now })
.where('bookingId', '=', booking.bookingId)
.execute()
await kysely
.insertInto('auditLog')
.values({
auditId:
globalThis.crypto?.randomUUID?.() ??
`audit_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
venueId: booking.venueId,
userId: null,
entity: 'booking',
entityId: booking.bookingId,
field: null,
beforeValue: null,
afterValue: 'sendContractAndDepositEmail',
action: 'email_sent',
})
.execute()
}
export type LifecycleSummary = {
date: string
contractsSent: number
reminders: number
flagged: number
ended: number
}
/**
* One daily sweep over active bookings, applying rules R1R4 (see
* docs/booking-lifecycle.md). Returns a counts summary for logging/dev.
*/
export async function runBookingLifecycle(
services: LifecycleServices,
): Promise<LifecycleSummary> {
const { kysely, email, logger } = services
const today = berlinDate()
const bookings = await kysely
.selectFrom('booking')
.where('status', 'in', ['reserved', 'confirmed'])
.select([
'bookingId',
'venueId',
'status',
'startDate',
'endDate',
'contractSentAt',
'depositReminderSentAt',
'flaggedAt',
])
.execute()
const summary: LifecycleSummary = {
date: today,
contractsSent: 0,
reminders: 0,
flagged: 0,
ended: 0,
}
for (const b of bookings) {
// Lifecycle rules are all date-driven; a booking without dates (an
// enquiry that hasn't been scheduled yet) has nothing to act on.
if (b.startDate == null || b.endDate == null) continue
// R4 — auto-end a confirmed booking once its event has ended.
if (b.status === 'confirmed') {
if (b.endDate < today) {
await applyBookingTransition(services, {
bookingId: b.bookingId,
to: 'ended',
userId: null,
})
summary.ended++
}
continue
}
// status === 'reserved' below.
// R1 — dispatch the contract ~1yr before start (daily `<=`, not `==`).
if (!b.contractSentAt) {
if (daysBetween(today, b.startDate) <= 365) {
await dispatchContract(services, b, today)
summary.contractsSent++
}
continue // freshly sent — reminders/flags begin on later ticks
}
// Contract sent → drive deposit reminders/flags off the deposit invoice.
const deposit = await kysely
.selectFrom('invoice')
.where('bookingId', '=', b.bookingId)
.where('kind', '=', 'deposit')
.select(['dueOn', 'paidOn'])
.executeTakeFirst()
if (deposit?.paidOn) continue // deposit settled — nothing to chase
// contractSentAt is a real Date via CoercionPlugin.
const daysSinceContract = daysBetween(berlinDate(b.contractSentAt as Date), today)
// R2 — day-7 reminder while the deposit is still unpaid.
if (!b.depositReminderSentAt && daysSinceContract >= DEPOSIT_REMINDER_DAYS) {
await email.sendDepositReminderEmail(b.bookingId)
const reminderNow = new Date().toISOString()
await kysely
.updateTable('booking')
.set({ depositReminderSentAt: reminderNow })
.where('bookingId', '=', b.bookingId)
.execute()
await kysely
.insertInto('auditLog')
.values({
auditId:
globalThis.crypto?.randomUUID?.() ??
`audit_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
venueId: b.venueId,
userId: null,
entity: 'booking',
entityId: b.bookingId,
field: null,
beforeValue: null,
afterValue: 'sendDepositReminderEmail',
action: 'email_sent',
})
.execute()
summary.reminders++
}
// R3 — past due → FLAG ONLY (deadline = invoice.dueOn, admin-overridable).
// dueOn is a Date via CoercionPlugin; convert to Berlin YYYY-MM-DD for comparison.
if (!b.flaggedAt && deposit?.dueOn && berlinDate(deposit.dueOn as Date) < today) {
await kysely
.updateTable('booking')
.set({ flaggedAt: new Date().toISOString(), flagReason: 'deposit_overdue' })
.where('bookingId', '=', b.bookingId)
.execute()
summary.flagged++
}
}
logger.info(`[booking-lifecycle] ${JSON.stringify(summary)}`)
return summary
}

View File

@@ -0,0 +1,137 @@
import type { Kysely } from 'kysely'
import type { DB } from '../types/db.types.js'
import type { EmailService } from '../services/email.js'
import { InvalidBookingTransitionError } from '../errors.js'
/**
* The lean status "bucket". The contract/deposit progression that runs inside
* `reserved` lives in milestone columns + the deposit invoice, not as statuses.
*/
export const BOOKING_STATUSES = [
'enquiry',
'reserved',
'confirmed',
'ended',
'cancelled',
] as const
export type BookingStatus = (typeof BOOKING_STATUSES)[number]
type EmailMethod =
| 'sendEnquiryDeclinedEmail'
| 'sendReservationConfirmedEmail'
| 'sendBookingConfirmedEmail'
| 'sendCancellationEmail'
type Transition = { to: BookingStatus; email?: EmailMethod }
/**
* Single source of truth for status transitions: `from → [{ to, email }]`.
* Each transition carries the email it owns, so dispatch stays centralised in
* {@link applyBookingTransition} (no double-sends). The client mirrors the
* allowed-target shape in `lib/status.ts` for its CTA buttons; this server copy
* is the one that's enforced.
*/
export const BOOKING_TRANSITIONS: Record<BookingStatus, Transition[]> = {
enquiry: [
{ to: 'reserved', email: 'sendReservationConfirmedEmail' }, // admin approves
{ to: 'cancelled', email: 'sendEnquiryDeclinedEmail' }, // admin declines
],
reserved: [
{ to: 'confirmed', email: 'sendBookingConfirmedEmail' }, // deposit recorded
{ to: 'cancelled', email: 'sendCancellationEmail' },
],
confirmed: [
{ to: 'ended' }, // cron R4 / admin override — no email
{ to: 'cancelled', email: 'sendCancellationEmail' },
],
ended: [],
cancelled: [],
}
export const allowedTransitions = (from: BookingStatus): BookingStatus[] =>
(BOOKING_TRANSITIONS[from] ?? []).map((t) => t.to)
type TransitionServices = { kysely: Kysely<DB>; email: EmailService }
/**
* Apply a guarded status transition: validate against {@link BOOKING_TRANSITIONS},
* update status + updatedAt, write an audit row, and fire the email that the
* transition owns. Returns the resulting status (a no-op self-transition just
* echoes it back). `userId` is null for system (cron-driven) transitions.
*
* Milestone side-effects (deposit, contract response, AGB) belong to the
* dedicated record* RPCs, NOT here — this only moves the status bucket.
*/
export async function applyBookingTransition(
{ kysely, email }: TransitionServices,
args: { bookingId: string; to: BookingStatus; userId: string | null },
): Promise<BookingStatus> {
const { bookingId, to, userId } = args
const booking = await kysely
.selectFrom('booking')
.where('bookingId', '=', bookingId)
.select(['bookingId', 'status', 'venueId'])
.executeTakeFirstOrThrow()
const from = booking.status as BookingStatus
if (from === to) return to
const transition = (BOOKING_TRANSITIONS[from] ?? []).find((t) => t.to === to)
if (!transition) {
throw new InvalidBookingTransitionError(from, to)
}
const now = new Date().toISOString()
const statusAtColumn: Partial<Record<'reservedAt' | 'confirmedAt' | 'endedAt' | 'cancelledAt', string>> = {}
if (to === 'reserved') statusAtColumn.reservedAt = now
if (to === 'confirmed') statusAtColumn.confirmedAt = now
if (to === 'ended') statusAtColumn.endedAt = now
if (to === 'cancelled') statusAtColumn.cancelledAt = now
await kysely
.updateTable('booking')
.set({ status: to, updatedAt: now, ...statusAtColumn })
.where('bookingId', '=', bookingId)
.execute()
await kysely
.insertInto('auditLog')
.values({
auditId:
globalThis.crypto?.randomUUID?.() ??
`audit_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
venueId: booking.venueId,
userId,
entity: 'booking',
entityId: bookingId,
field: 'status',
beforeValue: from,
afterValue: to,
action: 'update',
})
.execute()
if (transition.email) {
await email[transition.email](bookingId)
await kysely
.insertInto('auditLog')
.values({
auditId:
globalThis.crypto?.randomUUID?.() ??
`audit_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
venueId: booking.venueId,
userId,
entity: 'booking',
entityId: bookingId,
field: null,
beforeValue: null,
afterValue: transition.email,
action: 'email_sent',
})
.execute()
}
return to
}

View File

@@ -0,0 +1,5 @@
import { pikkuAuth } from '#pikku'
export const isAdminOrOwner = pikkuAuth(async (_services, session) => {
return session?.role === 'admin' || session?.role === 'owner'
})

View File

@@ -0,0 +1,51 @@
import type { Kysely } from 'kysely'
import type { DB } from '../types/db.types.js'
/**
* Ensures a login-capable client user exists for a client org, idempotently.
*
* Finds or creates a `user` row (role `client`) keyed by lower-cased email,
* then ensures a `client_member` row links that user to the client as `organiser`.
*
* Reuse-by-email is intentional: one person who organises for several clients
* gets one login with multiple `client_member` rows.
* Returns the resolved `userId`.
*/
export async function provisionClientLogin(
kysely: Kysely<DB>,
{ clientId, email, name }: { clientId: string; email: string; name: string | null },
): Promise<string> {
const normalisedEmail = email.toLowerCase()
const existing = await kysely
.selectFrom('user')
.where('email', '=', normalisedEmail)
.select('id')
.executeTakeFirst()
const id = existing?.id ?? crypto.randomUUID()
if (!existing) {
const now = new Date().toISOString()
await kysely
.insertInto('user')
.values({
id,
email: normalisedEmail,
name: name ?? normalisedEmail,
emailVerified: 0,
createdAt: now,
updatedAt: now,
role: 'client',
locale: 'de',
} as any)
.execute()
}
await kysely
.insertInto('clientMember')
.values({ clientId, userId: id, role: 'organiser' })
.onConflict((oc) => oc.columns(['clientId', 'userId']).doNothing())
.execute()
return id
}

View File

@@ -0,0 +1,43 @@
import type { Kysely } from 'kysely'
import type { DB } from '../types/db.types.js'
import { SESSION_COOKIE } from '../middleware/session-cookie.js'
export const SESSION_DAYS = 30
export const uid = () =>
globalThis.crypto?.randomUUID?.() ??
`id_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
const isoOffsetDays = (days: number) => {
const d = new Date()
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString()
}
/**
* Creates a server-side session row for `userId` and writes the session cookie.
* Shared by password login and the passwordless booking-form magic link so both
* issue identical sessions. `http` is optional (absent off the HTTP transport).
*/
export async function createUserSession(
kysely: Kysely<DB>,
http: { response?: { cookie: (name: string, value: string, opts: Record<string, unknown>) => void } } | undefined,
userId: string,
): Promise<string> {
const sessionId = uid()
await kysely
.insertInto('bookingSession')
.values({ sessionId, userId, expiresAt: isoOffsetDays(SESSION_DAYS) })
.execute()
if (http?.response) {
http.response.cookie(SESSION_COOKIE, sessionId, {
httpOnly: true,
secure: false,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * SESSION_DAYS,
})
}
return sessionId
}

View File

@@ -0,0 +1,26 @@
import { pikkuMiddleware } from '@pikku/core'
import type { SingletonServices } from '../application-types.d.js'
export const SESSION_COOKIE = 'sh_session'
export const sessionCookieMiddleware = pikkuMiddleware<SingletonServices>(
async ({ kysely }, { http, setSession, session }, next) => {
if (!http?.request || !setSession || session) {
return next()
}
const token = http.request.cookie(SESSION_COOKIE)
if (token) {
const row = await kysely
.selectFrom('bookingSession')
.innerJoin('user', 'user.id', 'bookingSession.userId')
.where('bookingSession.sessionId', '=', token)
.where('bookingSession.expiresAt', '>', new Date().toISOString())
.select(['user.id as userId', 'user.role'])
.executeTakeFirst()
if (row) {
setSession({ userId: row.userId, role: row.role as string })
}
}
await next()
},
)

View File

@@ -0,0 +1,60 @@
import {
JsonConsoleLogger,
LocalSecretService,
LocalVariablesService,
} from '@pikku/core/services'
import { pikkuServices } from '../.pikku/pikku-types.gen.js'
import { CFWorkerSchemaService } from '@pikku/schema-cfworker'
import { JoseJWTService } from '@pikku/jose'
import { LibsqlWebDialect } from '@pikku/kysely-sqlite'
import { Kysely } from 'kysely'
import type { DB } from './types/db.types.js'
import { EmailService } from './services/email.js'
// Dev fallback only — in prod JWT_SECRET must be set (magic-link tokens are
// signed with it). A rotated-out value can be appended as a second secret.
const DEV_JWT_SECRET = 'dev-insecure-jwt-secret-change-me'
export const createSingletonServices = pikkuServices(async (
config,
existingServices,
) => {
const variables =
existingServices?.variables ?? new LocalVariablesService()
const secrets =
existingServices?.secrets ?? new LocalSecretService(variables)
const logger = existingServices?.logger ?? new JsonConsoleLogger()
const schema =
existingServices?.schema ?? new CFWorkerSchemaService(logger)
let kysely = existingServices?.kysely
if (!kysely) {
const databaseUrl = await variables.get('DATABASE_URL')
if (!databaseUrl) {
throw new Error(
'DATABASE_URL not set — in dev, enable `dev.db` in your pikku config; in prod, Fabric injects DATABASE_URL',
)
}
kysely = new Kysely<DB>({ dialect: new LibsqlWebDialect({ url: databaseUrl }) })
}
const email = existingServices?.email ?? new EmailService(logger)
const content = existingServices?.content
const jwt =
existingServices?.jwt ??
new JoseJWTService(async () => {
const secret = (await variables.get('JWT_SECRET')) ?? DEV_JWT_SECRET
return [{ id: 'k1', value: secret }]
}, logger)
return {
...(existingServices ?? {}),
config,
variables,
secrets,
logger,
schema,
kysely,
email,
content,
jwt,
}
})

View File

@@ -0,0 +1,53 @@
/**
* Transactional email — service SHELL.
*
* Each method names a real lifecycle email but currently only logs and no-ops.
* The lifecycle (status transitions + the daily cron) drives these calls; when
* a provider is wired up later, only the bodies here change — every call site
* stays the same. Methods are async so the future real implementation (an HTTP
* send) is a drop-in without touching callers.
*/
export type EmailLogger = { info: (message: string) => void }
export class EmailService {
constructor(private readonly logger: EmailLogger) {}
private async send(method: string, bookingId: string): Promise<void> {
this.logger.info(`[email:shell] ${method} → booking ${bookingId} (no-op)`)
}
/** Enquiry submitted — acknowledge receipt to the client. */
sendEnquiryReceivedEmail(bookingId: string) {
return this.send('sendEnquiryReceivedEmail', bookingId)
}
/** Admin declined the enquiry. */
sendEnquiryDeclinedEmail(bookingId: string) {
return this.send('sendEnquiryDeclinedEmail', bookingId)
}
/** Admin approved the enquiry — dates are now soft-reserved. */
sendReservationConfirmedEmail(bookingId: string) {
return this.send('sendReservationConfirmedEmail', bookingId)
}
/** Day 0: contract (legally binding) + deposit request. Cron R1. */
sendContractAndDepositEmail(bookingId: string) {
return this.send('sendContractAndDepositEmail', bookingId)
}
/** Day 7: deposit still unpaid — reminder. Cron R2. */
sendDepositReminderEmail(bookingId: string) {
return this.send('sendDepositReminderEmail', bookingId)
}
/** Deposit recorded — reservation is now guaranteed. */
sendBookingConfirmedEmail(bookingId: string) {
return this.send('sendBookingConfirmedEmail', bookingId)
}
/** Booking cancelled at a non-enquiry stage. */
sendCancellationEmail(bookingId: string) {
return this.send('sendCancellationEmail', bookingId)
}
}

View File

@@ -0,0 +1,53 @@
// Password hashing using WebCrypto PBKDF2-SHA256 — works on Node, browsers, and CF Workers.
// Stored format: `pbkdf2$<iterations>$<salt_b64>$<hash_b64>`
const ITERATIONS = 100_000
const KEY_LENGTH = 32 // bytes
const SALT_LENGTH = 16
const b64encode = (bytes: Uint8Array) =>
btoa(String.fromCharCode(...bytes))
const b64decode = (str: string) => {
const bin = atob(str)
const out = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
return out
}
async function pbkdf2(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
const enc = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw',
enc.encode(password),
'PBKDF2',
false,
['deriveBits'],
)
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt: salt as BufferSource, iterations, hash: 'SHA-256' },
key,
KEY_LENGTH * 8,
)
return new Uint8Array(bits)
}
export async function hashPassword(password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH))
const hash = await pbkdf2(password, salt, ITERATIONS)
return `pbkdf2$${ITERATIONS}$${b64encode(salt)}$${b64encode(hash)}`
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const parts = stored.split('$')
if (parts.length !== 4 || parts[0] !== 'pbkdf2') return false
const iterations = Number(parts[1])
if (!Number.isFinite(iterations) || iterations < 1000) return false
const salt = b64decode(parts[2])
const expected = b64decode(parts[3])
const actual = await pbkdf2(password, salt, iterations)
if (actual.length !== expected.length) return false
let diff = 0
for (let i = 0; i < actual.length; i++) diff |= actual[i] ^ expected[i]
return diff === 0
}

View File

@@ -0,0 +1 @@
export type { DB } from '#pikku/db/schema.js'

View File

@@ -0,0 +1,29 @@
import { betterAuth } from 'better-auth'
import { pikkuBetterAuth } from '#pikku/pikku-types.gen.js'
export const auth = pikkuBetterAuth(async ({ kysely, secrets }) => {
const BETTER_AUTH_SECRET = await secrets.getSecret('BETTER_AUTH_SECRET')
return betterAuth({
secret: BETTER_AUTH_SECRET,
database: { db: kysely as any, type: 'sqlite' },
emailAndPassword: { enabled: true },
advanced: { database: { generateId: 'uuid' } },
user: {
additionalFields: {
role: {
type: 'string',
required: false,
defaultValue: 'client',
input: false,
},
locale: {
type: 'string',
required: false,
defaultValue: 'de',
input: true,
},
},
},
})
})

View File

@@ -0,0 +1,22 @@
import { addHTTPMiddleware } from '#pikku'
import { cors } from '@pikku/core/middleware'
import { betterAuthSession } from '@pikku/better-auth'
import { sessionCookieMiddleware } from '../middleware/session-cookie.js'
// sessionCookieMiddleware runs first so that booking-form clients
// (sh_session cookie) are resolved before better-auth is checked.
addHTTPMiddleware('*', [
cors({
origin: true,
credentials: true,
headers: ['Content-Type', 'Authorization', 'x-api-key', 'X-Auth-Return-Redirect'],
}),
sessionCookieMiddleware,
betterAuthSession({
mapSession: (result: any) => ({
userId: result.user.id,
role: result.user.role ?? 'client',
locale: result.user.locale ?? 'de',
}),
}),
])

View File

@@ -0,0 +1,15 @@
import { defineHTTPRoutes, wireHTTPRoutes } from '#pikku'
import { getAvailability } from '../functions/get-availability.function.js'
import { getEventsListing } from '../functions/get-events-listing.function.js'
import { submitEnquiry } from '../functions/submit-enquiry.function.js'
export const publicRoutes = defineHTTPRoutes({
auth: false,
routes: {
getAvailability: { method: 'get', route: '/public/availability', func: getAvailability },
getEventsListing: { method: 'get', route: '/public/events', func: getEventsListing },
submitEnquiry: { method: 'post', route: '/public/enquiry', func: submitEnquiry },
},
})
wireHTTPRoutes({ routes: { public: publicRoutes } })

View File

@@ -0,0 +1,11 @@
import { wireScheduler } from '#pikku'
import { bookingLifecycleDaily } from '../functions/booking-lifecycle.function.js'
// Daily at 06:00. The job's date math is Europe/Berlin regardless of the
// platform's clock, so the exact server-local fire time only sets cadence.
wireScheduler({
name: 'booking-lifecycle-daily',
schedule: '0 6 * * *',
func: bookingLifecycleDaily,
tags: ['daily', 'booking-lifecycle'],
})