chore: seminarhof customer project
This commit is contained in:
31
packages/functions/package.json
Normal file
31
packages/functions/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@project/functions",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"imports": {
|
||||
"#pikku": "./.pikku/pikku-types.gen.ts",
|
||||
"#pikku/*.gen.js": "./.pikku/*.gen.ts",
|
||||
"#pikku/*": "./.pikku/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20241218.0",
|
||||
"@date-fns/tz": "^1.5.0",
|
||||
"@pikku/better-auth": "^0.12.16",
|
||||
"@pikku/cloudflare": "^0.12.13",
|
||||
"@pikku/core": "^0.12.57",
|
||||
"@pikku/jose": "^0.12.4",
|
||||
"@pikku/kysely": "^0.13.0",
|
||||
"@pikku/kysely-sqlite": "^0.12.8",
|
||||
"@pikku/schedule": "^0.12.3",
|
||||
"@pikku/schema-cfworker": "^0.12.4",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"better-auth": "^1.6.19",
|
||||
"date-fns": "^4.4.0",
|
||||
"kysely": "^0.29.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pikku/cli": "^0.12.76"
|
||||
}
|
||||
}
|
||||
27
packages/functions/src/application-types.d.ts
vendored
Normal file
27
packages/functions/src/application-types.d.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
import type {
|
||||
CoreServices,
|
||||
CoreSingletonServices,
|
||||
CoreConfig,
|
||||
CoreUserSession,
|
||||
ContentService,
|
||||
AuditService,
|
||||
} 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
|
||||
audit: AuditService
|
||||
}
|
||||
|
||||
export interface Services extends CoreServices<SingletonServices> {}
|
||||
12
packages/functions/src/config.ts
Normal file
12
packages/functions/src/config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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,
|
||||
},
|
||||
// Top-level: `pikku dev` reads userConfig.content to wire LocalContent
|
||||
// (serves /upload + /assets from .pikku-runtime/content in dev).
|
||||
content: true,
|
||||
}))
|
||||
138
packages/functions/src/errors.ts
Normal file
138
packages/functions/src/errors.ts
Normal 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.' }],
|
||||
])
|
||||
@@ -0,0 +1,474 @@
|
||||
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 },
|
||||
audit: true,
|
||||
description: 'Admin: edit booking metadata (does NOT change status — use setBookingStatus).',
|
||||
input: UpdateAdminBookingInput,
|
||||
output: UpdateAdminBookingOutput,
|
||||
func: async ({ kysely, auditLog }, { bookingId, patch }) => {
|
||||
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 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 {
|
||||
entity: 'booking' as const,
|
||||
entityId: bookingId,
|
||||
field,
|
||||
before,
|
||||
after,
|
||||
action: 'update' as const,
|
||||
venueId: current.venueId,
|
||||
}
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null)
|
||||
|
||||
for (const row of auditRows) {
|
||||
await auditLog?.write({
|
||||
type: `${row.entity}.${row.action}`,
|
||||
source: 'explicit',
|
||||
metadata: {
|
||||
entity: row.entity,
|
||||
entityId: row.entityId,
|
||||
action: row.action,
|
||||
field: row.field,
|
||||
before: row.before,
|
||||
after: row.after,
|
||||
venueId: row.venueId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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 }
|
||||
},
|
||||
})
|
||||
162
packages/functions/src/functions/approve-enquiry.function.ts
Normal file
162
packages/functions/src/functions/approve-enquiry.function.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
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 },
|
||||
audit: true,
|
||||
input: ApproveEnquiryInput,
|
||||
output: ApproveEnquiryOutput,
|
||||
func: async ({ kysely, auditLog }, input) => {
|
||||
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()
|
||||
})
|
||||
|
||||
// Audit only committed state — write after the transaction lands.
|
||||
await auditLog?.write({
|
||||
type: 'enquiry.update',
|
||||
source: 'explicit',
|
||||
metadata: {
|
||||
entity: 'enquiry',
|
||||
entityId: enquiry.enquiryId,
|
||||
action: 'update',
|
||||
field: 'status',
|
||||
before: 'pending',
|
||||
after: 'approved',
|
||||
venueId: enquiry.venueId,
|
||||
},
|
||||
})
|
||||
|
||||
return { bookingId, clientId: clientId! }
|
||||
},
|
||||
})
|
||||
36
packages/functions/src/functions/auth.function.ts
Normal file
36
packages/functions/src/functions/auth.function.ts
Normal 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 }
|
||||
},
|
||||
})
|
||||
339
packages/functions/src/functions/booking-form.function.ts
Normal file
339
packages/functions/src/functions/booking-form.function.ts
Normal 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 } 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).',
|
||||
audit: true,
|
||||
input: SubmitBookingFormInput,
|
||||
output: SubmitBookingFormOutput,
|
||||
func: async ({ kysely, auditLog }, 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()
|
||||
})
|
||||
|
||||
// Audit only committed state — write after the transaction lands.
|
||||
await auditLog?.write({
|
||||
type: 'booking.update',
|
||||
source: 'explicit',
|
||||
metadata: {
|
||||
entity: 'booking',
|
||||
entityId: input.bookingId,
|
||||
action: 'update',
|
||||
field: 'bookingForm',
|
||||
after: 'submitted',
|
||||
venueId: booking.venueId,
|
||||
},
|
||||
})
|
||||
|
||||
return { ok: true as const }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
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 R1–R4; idempotent, so re-running is harmless.
|
||||
*/
|
||||
export const bookingLifecycleDaily = pikkuVoidFunc({
|
||||
audit: true,
|
||||
func: async ({ kysely, email, logger, auditLog }) => {
|
||||
await runBookingLifecycle({ kysely, email, logger, auditLog })
|
||||
},
|
||||
})
|
||||
|
||||
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 },
|
||||
audit: true,
|
||||
input: RunBookingLifecycleInput,
|
||||
output: RunBookingLifecycleOutput,
|
||||
func: async ({ kysely, email, logger, auditLog }, _data) => {
|
||||
return runBookingLifecycle({ kysely, email, logger, auditLog })
|
||||
},
|
||||
})
|
||||
59
packages/functions/src/functions/cancel-invoice.function.ts
Normal file
59
packages/functions/src/functions/cancel-invoice.function.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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'),
|
||||
})
|
||||
|
||||
export const cancelInvoice = pikkuFunc({
|
||||
expose: true,
|
||||
description: 'Admin/owner: cancel an outstanding invoice (typo, duplicate, etc.).',
|
||||
permissions: { isAdminOrOwner },
|
||||
audit: true,
|
||||
input: CancelInvoiceInput,
|
||||
output: CancelInvoiceOutput,
|
||||
func: async ({ kysely, auditLog }, { invoiceId }) => {
|
||||
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 auditLog?.write({
|
||||
type: 'invoice.update',
|
||||
source: 'explicit',
|
||||
metadata: {
|
||||
entity: 'invoice',
|
||||
entityId: invoiceId,
|
||||
action: 'update',
|
||||
field: 'status',
|
||||
before: inv.status,
|
||||
after: 'cancelled',
|
||||
venueId: inv.venueId,
|
||||
},
|
||||
})
|
||||
|
||||
return { invoiceId, status: 'cancelled' as const }
|
||||
},
|
||||
})
|
||||
71
packages/functions/src/functions/create-invoice.function.ts
Normal file
71
packages/functions/src/functions/create-invoice.function.ts
Normal 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 },
|
||||
audit: true,
|
||||
input: CreateInvoiceInput,
|
||||
output: CreateInvoiceOutput,
|
||||
func: async ({ kysely, auditLog }, input) => {
|
||||
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 auditLog?.write({
|
||||
type: 'invoice.create',
|
||||
source: 'explicit',
|
||||
metadata: {
|
||||
entity: 'invoice',
|
||||
entityId: invoiceId,
|
||||
action: 'create',
|
||||
after: input.invoiceNumber,
|
||||
venueId: booking.venueId,
|
||||
},
|
||||
})
|
||||
|
||||
return { invoiceId, invoiceNumber: input.invoiceNumber, status: 'outstanding' as const }
|
||||
},
|
||||
})
|
||||
56
packages/functions/src/functions/decline-enquiry.function.ts
Normal file
56
packages/functions/src/functions/decline-enquiry.function.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { z } from 'zod'
|
||||
import { pikkuFunc } from '#pikku'
|
||||
import { isAdminOrOwner } from '../lib/permissions.js'
|
||||
import { EnquiryNotPendingError } from '../errors.js'
|
||||
|
||||
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 },
|
||||
audit: true,
|
||||
input: DeclineEnquiryInput,
|
||||
output: DeclineEnquiryOutput,
|
||||
func: async ({ kysely, auditLog }, input) => {
|
||||
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()
|
||||
})
|
||||
|
||||
// Audit only committed state — write after the transaction lands.
|
||||
await auditLog?.write({
|
||||
type: 'enquiry.update',
|
||||
source: 'explicit',
|
||||
metadata: {
|
||||
entity: 'enquiry',
|
||||
entityId: input.enquiryId,
|
||||
action: 'update',
|
||||
field: 'status',
|
||||
before: 'pending',
|
||||
after: 'declined',
|
||||
venueId: enquiry.venueId,
|
||||
},
|
||||
})
|
||||
|
||||
return { ok: true as const }
|
||||
},
|
||||
})
|
||||
168
packages/functions/src/functions/get-admin-bookings.function.ts
Normal file
168
packages/functions/src/functions/get-admin-bookings.function.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { z } from 'zod'
|
||||
import { pikkuFunc } from '#pikku'
|
||||
import { isAdminOrOwner } from '../lib/permissions.js'
|
||||
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 = pikkuFunc({
|
||||
expose: true,
|
||||
description: 'Admin booking list with filters — replaces Buchungsübersicht spreadsheet.',
|
||||
permissions: { isAdminOrOwner },
|
||||
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,
|
||||
}
|
||||
},
|
||||
})
|
||||
202
packages/functions/src/functions/get-admin-dashboard.function.ts
Normal file
202
packages/functions/src/functions/get-admin-dashboard.function.ts
Normal 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,
|
||||
})),
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
162
packages/functions/src/functions/get-availability.function.ts
Normal file
162
packages/functions/src/functions/get-availability.function.ts
Normal 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') })),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { z } from 'zod'
|
||||
import { sql } from 'kysely'
|
||||
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 }) => {
|
||||
// Domain row-change history is stored as pikku AuditEvents whose payload
|
||||
// lives in the `data` JSON column ({ entity, entityId, action, field,
|
||||
// before, after }); the actor is joined via actor_user_id.
|
||||
const rows = await kysely
|
||||
.selectFrom('audit')
|
||||
.leftJoin('user', 'user.id', 'audit.actorUserId')
|
||||
.where(sql<boolean>`json_extract(audit.data, '$.entity') = 'booking'`)
|
||||
.where(sql<boolean>`json_extract(audit.data, '$.entityId') = ${bookingId}`)
|
||||
.orderBy('audit.occurredAt', 'desc')
|
||||
.select([
|
||||
'audit.auditId',
|
||||
sql<string>`json_extract(audit.data, '$.action')`.as('action'),
|
||||
sql<string | null>`json_extract(audit.data, '$.field')`.as('field'),
|
||||
sql<string | null>`json_extract(audit.data, '$.before')`.as(
|
||||
'beforeValue',
|
||||
),
|
||||
sql<string | null>`json_extract(audit.data, '$.after')`.as('afterValue'),
|
||||
'audit.occurredAt as at',
|
||||
'user.name as userName',
|
||||
'user.email as userEmail',
|
||||
])
|
||||
.execute()
|
||||
|
||||
return {
|
||||
entries: rows.map((r) => ({
|
||||
auditId: r.auditId,
|
||||
action: r.action,
|
||||
field: r.field ?? null,
|
||||
beforeValue: r.beforeValue ?? null,
|
||||
afterValue: r.afterValue ?? null,
|
||||
at: new Date(r.at),
|
||||
userName: r.userName ?? null,
|
||||
userEmail: r.userEmail ?? null,
|
||||
})),
|
||||
}
|
||||
},
|
||||
})
|
||||
216
packages/functions/src/functions/get-booking-detail.function.ts
Normal file
216
packages/functions/src/functions/get-booking-detail.function.ts
Normal 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,
|
||||
}
|
||||
},
|
||||
})
|
||||
136
packages/functions/src/functions/get-client-overview.function.ts
Normal file
136
packages/functions/src/functions/get-client-overview.function.ts
Normal 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,
|
||||
}
|
||||
},
|
||||
})
|
||||
57
packages/functions/src/functions/get-clients.function.ts
Normal file
57
packages/functions/src/functions/get-clients.function.ts
Normal 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 }
|
||||
},
|
||||
})
|
||||
136
packages/functions/src/functions/get-enquiries.function.ts
Normal file
136
packages/functions/src/functions/get-enquiries.function.ts
Normal 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 }
|
||||
},
|
||||
})
|
||||
102
packages/functions/src/functions/get-events-listing.function.ts
Normal file
102
packages/functions/src/functions/get-events-listing.function.ts
Normal 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 }
|
||||
},
|
||||
})
|
||||
73
packages/functions/src/functions/get-upload-url.function.ts
Normal file
73
packages/functions/src/functions/get-upload-url.function.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
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 },
|
||||
audit: true,
|
||||
input: RecordContractResponseInput,
|
||||
output: RecordContractResponseOutput,
|
||||
func: async ({ kysely, auditLog }, { bookingId, response }) => {
|
||||
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 auditLog?.write({
|
||||
type: 'booking.update',
|
||||
source: 'explicit',
|
||||
metadata: {
|
||||
entity: 'booking',
|
||||
entityId: bookingId,
|
||||
action: 'update',
|
||||
field: 'contractResponse',
|
||||
after: response,
|
||||
venueId: booking.venueId,
|
||||
},
|
||||
})
|
||||
|
||||
return { ok: true as const }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
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 },
|
||||
audit: true,
|
||||
input: RecordDepositReceivedInput,
|
||||
output: RecordDepositReceivedOutput,
|
||||
func: async ({ kysely, email, auditLog }, { 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, auditLog },
|
||||
{ bookingId, to: 'confirmed', userId: session.userId },
|
||||
)
|
||||
|
||||
return { ok: true as const }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
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(),
|
||||
})
|
||||
|
||||
export const recordInvoicePayment = pikkuFunc({
|
||||
expose: true,
|
||||
description: 'Admin/owner: mark an outstanding invoice as paid on a given date.',
|
||||
permissions: { isAdminOrOwner },
|
||||
audit: true,
|
||||
input: RecordInvoicePaymentInput,
|
||||
output: RecordInvoicePaymentOutput,
|
||||
func: async ({ kysely, auditLog }, { invoiceId, paidOn }) => {
|
||||
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 auditLog?.write({
|
||||
type: 'invoice.update',
|
||||
source: 'explicit',
|
||||
metadata: {
|
||||
entity: 'invoice',
|
||||
entityId: invoiceId,
|
||||
action: 'update',
|
||||
field: 'status',
|
||||
before: inv.status,
|
||||
after: 'paid',
|
||||
venueId: inv.venueId,
|
||||
},
|
||||
})
|
||||
|
||||
return { invoiceId, status: 'paid' as const, paidOn }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
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 },
|
||||
audit: true,
|
||||
input: SetBookingStatusInput,
|
||||
output: SetBookingStatusOutput,
|
||||
func: async (
|
||||
{ kysely, email, auditLog },
|
||||
{ bookingId, status },
|
||||
{ session },
|
||||
) => {
|
||||
const newStatus = await applyBookingTransition(
|
||||
{ kysely, email, auditLog },
|
||||
{ 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 }
|
||||
},
|
||||
})
|
||||
107
packages/functions/src/functions/submit-enquiry.function.ts
Normal file
107
packages/functions/src/functions/submit-enquiry.function.ts
Normal 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): 0–3 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 }
|
||||
},
|
||||
})
|
||||
141
packages/functions/src/functions/test-reset.function.ts
Normal file
141
packages/functions/src/functions/test-reset.function.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
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)
|
||||
* 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 }) => {
|
||||
// The variables service JSON-parses env values, so "1" arrives as 1.
|
||||
const gate = await variables.get('E2E_TEST_RESET')
|
||||
if (String(gate) !== '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`.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 }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
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 },
|
||||
audit: true,
|
||||
input: TriggerContractEmailInput,
|
||||
output: TriggerContractEmailOutput,
|
||||
func: async ({ kysely, email, auditLog }, { 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, auditLog }, booking, berlinDate())
|
||||
|
||||
return { ok: true as const }
|
||||
},
|
||||
})
|
||||
65
packages/functions/src/functions/update-client.function.ts
Normal file
65
packages/functions/src/functions/update-client.function.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
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) })
|
||||
|
||||
export const updateClient = pikkuFunc({
|
||||
expose: true,
|
||||
description: 'Admin: edit client contact fields. Logs each changed field.',
|
||||
permissions: { isAdminOrOwner },
|
||||
audit: true,
|
||||
input: UpdateClientInput,
|
||||
output: UpdateClientOutput,
|
||||
func: async ({ kysely, auditLog }, { clientId, patch }) => {
|
||||
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()
|
||||
|
||||
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 auditLog?.write({
|
||||
type: 'client.update',
|
||||
source: 'explicit',
|
||||
metadata: {
|
||||
entity: 'client',
|
||||
entityId: clientId,
|
||||
action: 'update',
|
||||
field: k,
|
||||
before: beforeValue == null ? null : String(beforeValue),
|
||||
after: v == null ? null : String(v),
|
||||
venueId: before.venueId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true as const }
|
||||
},
|
||||
})
|
||||
109
packages/functions/src/functions/update-invoice.function.ts
Normal file
109
packages/functions/src/functions/update-invoice.function.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
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(),
|
||||
})
|
||||
|
||||
export const updateInvoice = pikkuFunc({
|
||||
expose: true,
|
||||
description: 'Admin/owner: edit invoice metadata (number, dates, amount) for typo correction.',
|
||||
permissions: { isAdminOrOwner },
|
||||
audit: true,
|
||||
input: UpdateInvoiceInput,
|
||||
output: UpdateInvoiceOutput,
|
||||
func: async ({ kysely, auditLog }, { invoiceId, patch }) => {
|
||||
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 auditLog?.write({
|
||||
type: 'invoice.update',
|
||||
source: 'explicit',
|
||||
metadata: {
|
||||
entity: 'invoice',
|
||||
entityId: invoiceId,
|
||||
action: 'update',
|
||||
field: a.field,
|
||||
before: a.before,
|
||||
after: a.after,
|
||||
venueId: inv.venueId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return { invoiceId }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { z } from 'zod'
|
||||
import { pikkuFunc } from '#pikku'
|
||||
import { isAdminOrOwner } from '../lib/permissions.js'
|
||||
import {
|
||||
EnquiryDatesRequiredError,
|
||||
EnquiryNotPendingError,
|
||||
} from '../errors.js'
|
||||
|
||||
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 },
|
||||
audit: true,
|
||||
input: WaitlistEnquiryInput,
|
||||
output: WaitlistEnquiryOutput,
|
||||
func: async ({ kysely, auditLog }, input) => {
|
||||
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()
|
||||
})
|
||||
|
||||
// Audit only committed state — write after the transaction lands.
|
||||
await auditLog?.write({
|
||||
type: 'enquiry.update',
|
||||
source: 'explicit',
|
||||
metadata: {
|
||||
entity: 'enquiry',
|
||||
entityId: input.enquiryId,
|
||||
action: 'update',
|
||||
field: 'waitlistedAt',
|
||||
after: now,
|
||||
venueId: enquiry.venueId,
|
||||
},
|
||||
})
|
||||
|
||||
return { ok: true as const }
|
||||
},
|
||||
})
|
||||
226
packages/functions/src/lib/booking-lifecycle.ts
Normal file
226
packages/functions/src/lib/booking-lifecycle.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import type { Kysely } from 'kysely'
|
||||
import type { AuditLog } from '@pikku/core'
|
||||
import { addDays as addDaysFns, differenceInCalendarDays, format, parseISO } from 'date-fns'
|
||||
import { TZDate } from '@date-fns/tz'
|
||||
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
|
||||
auditLog?: AuditLog
|
||||
logger: { info: (message: string) => void }
|
||||
}
|
||||
|
||||
/** Today as a Berlin-local `YYYY-MM-DD`. */
|
||||
export const berlinDate = (d: Date = new Date()): string =>
|
||||
format(new TZDate(d.getTime(), 'Europe/Berlin'), 'yyyy-MM-dd')
|
||||
|
||||
/** The Berlin-local date portion of a stored ISO timestamp. */
|
||||
export const berlinDateFromIso = (iso: string): string => berlinDate(parseISO(iso))
|
||||
|
||||
/** Whole calendar days from `fromYmd` to `toYmd` (both `YYYY-MM-DD`). */
|
||||
export const daysBetween = (fromYmd: string, toYmd: string): number =>
|
||||
differenceInCalendarDays(parseISO(toYmd), parseISO(fromYmd))
|
||||
|
||||
/** `YYYY-MM-DD` plus `n` calendar days. */
|
||||
export const addDays = (ymd: string, n: number): string =>
|
||||
format(addDaysFns(parseISO(ymd), n), 'yyyy-MM-dd')
|
||||
|
||||
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,
|
||||
auditLog,
|
||||
}: Pick<LifecycleServices, 'kysely' | 'email' | 'auditLog'>,
|
||||
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 auditLog?.write({
|
||||
type: 'booking.email_sent',
|
||||
source: 'explicit',
|
||||
metadata: {
|
||||
entity: 'booking',
|
||||
entityId: booking.bookingId,
|
||||
action: 'email_sent',
|
||||
after: 'sendContractAndDepositEmail',
|
||||
venueId: booking.venueId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export type LifecycleSummary = {
|
||||
date: string
|
||||
contractsSent: number
|
||||
reminders: number
|
||||
flagged: number
|
||||
ended: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One daily sweep over active bookings, applying rules R1–R4 (see
|
||||
* docs/booking-lifecycle.md). Returns a counts summary for logging/dev.
|
||||
*/
|
||||
export async function runBookingLifecycle(
|
||||
services: LifecycleServices,
|
||||
): Promise<LifecycleSummary> {
|
||||
const { kysely, email, logger, auditLog } = 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 stored as an ISO string (see schema.d.ts).
|
||||
const daysSinceContract = daysBetween(berlinDateFromIso(b.contractSentAt), 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 auditLog?.write({
|
||||
type: 'booking.email_sent',
|
||||
source: 'explicit',
|
||||
metadata: {
|
||||
entity: 'booking',
|
||||
entityId: b.bookingId,
|
||||
action: 'email_sent',
|
||||
after: 'sendDepositReminderEmail',
|
||||
venueId: b.venueId,
|
||||
},
|
||||
})
|
||||
summary.reminders++
|
||||
}
|
||||
|
||||
// R3 — past due → FLAG ONLY (deadline = invoice.dueOn, admin-overridable).
|
||||
// dueOn is stored as an ISO string; compare as Berlin YYYY-MM-DD.
|
||||
if (!b.flaggedAt && deposit?.dueOn && berlinDateFromIso(deposit.dueOn) < 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
|
||||
}
|
||||
136
packages/functions/src/lib/booking-status.ts
Normal file
136
packages/functions/src/lib/booking-status.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { Kysely } from 'kysely'
|
||||
import type { AuditLog } from '@pikku/core'
|
||||
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
|
||||
auditLog?: AuditLog
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, auditLog }: 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 auditLog?.write({
|
||||
type: 'booking.update',
|
||||
source: 'explicit',
|
||||
actor: userId ? { userId } : undefined,
|
||||
metadata: {
|
||||
entity: 'booking',
|
||||
entityId: bookingId,
|
||||
action: 'update',
|
||||
field: 'status',
|
||||
before: from,
|
||||
after: to,
|
||||
venueId: booking.venueId,
|
||||
},
|
||||
})
|
||||
|
||||
if (transition.email) {
|
||||
await email[transition.email](bookingId)
|
||||
|
||||
await auditLog?.write({
|
||||
type: 'booking.email_sent',
|
||||
source: 'explicit',
|
||||
actor: userId ? { userId } : undefined,
|
||||
metadata: {
|
||||
entity: 'booking',
|
||||
entityId: bookingId,
|
||||
action: 'email_sent',
|
||||
after: transition.email,
|
||||
venueId: booking.venueId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return to
|
||||
}
|
||||
5
packages/functions/src/lib/permissions.ts
Normal file
5
packages/functions/src/lib/permissions.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { pikkuAuth } from "#pikku";
|
||||
|
||||
export const isAdminOrOwner = pikkuAuth(async (_services, session) => {
|
||||
return session?.role === "admin" || session?.role === "owner";
|
||||
});
|
||||
51
packages/functions/src/lib/provision-client-login.ts
Normal file
51
packages/functions/src/lib/provision-client-login.ts
Normal 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
|
||||
}
|
||||
43
packages/functions/src/lib/session.ts
Normal file
43
packages/functions/src/lib/session.ts
Normal 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
|
||||
}
|
||||
26
packages/functions/src/middleware/session-cookie.ts
Normal file
26
packages/functions/src/middleware/session-cookie.ts
Normal 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()
|
||||
},
|
||||
)
|
||||
99
packages/functions/src/services.ts
Normal file
99
packages/functions/src/services.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
JsonConsoleLogger,
|
||||
LocalSecretService,
|
||||
LocalVariablesService,
|
||||
resolveAuditActorFromWire,
|
||||
type AuditLog,
|
||||
} from '@pikku/core/services'
|
||||
import { pikkuServices, pikkuWireServices } from '../.pikku/pikku-types.gen.js'
|
||||
import { CFWorkerSchemaService } from '@pikku/schema-cfworker'
|
||||
import { JoseJWTService } from '@pikku/jose'
|
||||
import { LibsqlWebDialect } from '@pikku/kysely-sqlite'
|
||||
import { KyselyAuditService } from '@pikku/kysely'
|
||||
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
|
||||
// In prod the platform injects a queue-backed audit sink as
|
||||
// existingServices.audit; locally we persist audit events to the `audit`
|
||||
// table so they're queryable.
|
||||
const audit = existingServices?.audit ?? new KyselyAuditService(kysely)
|
||||
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,
|
||||
audit,
|
||||
}
|
||||
})
|
||||
|
||||
// Per-invocation audit writer. Functions marked `audit: true` record domain
|
||||
// row-changes explicitly via `auditLog.write(...)`. Writes go straight through
|
||||
// to the `audit` sink: core's createInvocationAudit gates on `wire.audit`,
|
||||
// which the runner only sets for the innermost function — on the exposed-RPC
|
||||
// HTTP path wire services are created on the outer caller wire where it is
|
||||
// still undefined, so its buffered writes were silently dropped.
|
||||
export const createWireServices = pikkuWireServices(async (
|
||||
singletonServices,
|
||||
wire,
|
||||
) => {
|
||||
const { audit } = singletonServices
|
||||
if (!audit) return {}
|
||||
const auditLog: AuditLog = {
|
||||
config: { durability: 'best-effort' },
|
||||
write: async (event) => {
|
||||
await audit.audit({
|
||||
functionId: wire.functionId,
|
||||
wireType: wire.wireType,
|
||||
wireId: wire.wireId,
|
||||
traceId: wire.traceId,
|
||||
actor: event.actor ?? resolveAuditActorFromWire(wire),
|
||||
...event,
|
||||
occurredAt: new Date().toISOString(),
|
||||
})
|
||||
},
|
||||
flush: async () => {},
|
||||
close: async () => {},
|
||||
}
|
||||
return { auditLog }
|
||||
})
|
||||
53
packages/functions/src/services/email.ts
Normal file
53
packages/functions/src/services/email.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
53
packages/functions/src/services/password.ts
Normal file
53
packages/functions/src/services/password.ts
Normal 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
|
||||
}
|
||||
1
packages/functions/src/types/db.types.ts
Normal file
1
packages/functions/src/types/db.types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type { DB } from '#pikku/db/schema.js'
|
||||
67
packages/functions/src/wirings/auth.wiring.ts
Normal file
67
packages/functions/src/wirings/auth.wiring.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { admin } from 'better-auth/plugins'
|
||||
import { actor, fabric } from '@pikku/better-auth'
|
||||
import { pikkuBetterAuth } from '#pikku/pikku-types.gen.js'
|
||||
|
||||
export const auth = pikkuBetterAuth(async ({ kysely, secrets, variables }) => {
|
||||
const BETTER_AUTH_SECRET = await secrets.getSecret('BETTER_AUTH_SECRET')
|
||||
// Genuinely optional: unset simply disables /api/auth/sign-in/actor (scenarios
|
||||
// off for this deployment) — the actor plugin refuses all sign-ins
|
||||
// without it.
|
||||
const SCENARIO_ACTOR_SECRET = await secrets
|
||||
.getSecret('SCENARIO_ACTOR_SECRET')
|
||||
.catch(() => undefined)
|
||||
// Fabric operator admin: the RSA public key the control plane's token is
|
||||
// verified against. The Fabric deployer pushes FABRIC_AUTH_PUBLIC_KEY onto
|
||||
// every stage; locally it's simply absent, which disables /sign-in/fabric.
|
||||
// Asymmetric — the app verifies, it can never forge an operator login.
|
||||
const FABRIC_AUTH_PUBLIC_KEY = await variables.get('FABRIC_AUTH_PUBLIC_KEY')
|
||||
|
||||
return betterAuth({
|
||||
secret: BETTER_AUTH_SECRET,
|
||||
database: { db: kysely as any, type: 'sqlite' },
|
||||
emailAndPassword: { enabled: true },
|
||||
session: {
|
||||
cookieCache: { enabled: true, maxAge: 5 * 60 },
|
||||
},
|
||||
advanced: { database: { generateId: 'uuid' } },
|
||||
user: {
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
defaultValue: 'client',
|
||||
input: false,
|
||||
},
|
||||
locale: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
defaultValue: 'de',
|
||||
input: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Scenario actors: synthetic users (user.actor = true, see
|
||||
// db/sqlite/0012-user-actor.sql) signed in by pikkuScenario via
|
||||
// POST /api/auth/sign-in/actor { email, secret }. Never signs in real users.
|
||||
//
|
||||
// admin(): exposes /api/auth/admin/* (listUsers, setRole, impersonateUser,
|
||||
// …) so an app admin can list and "view as" their end-users — this is what
|
||||
// the Fabric console's Users tab drives. Adds role/banned/impersonatedBy
|
||||
// columns (see db/sqlite/0013-admin.sql). No user is an admin by default:
|
||||
// grant it by setting a user's `role` to 'admin' (or pass
|
||||
// `adminUserIds: [...]` here) — the admin API refuses non-admins.
|
||||
//
|
||||
// fabric(): exposes /api/auth/sign-in/fabric — the Fabric control plane
|
||||
// mints a short-lived RS256 token and signs in as a synthetic `fabric: true`
|
||||
// admin operator (db/sqlite/0014-fabric.sql), so the console Users tab can
|
||||
// list/impersonate real users without the operator being one of them. It
|
||||
// pairs with admin() and verifies against FABRIC_AUTH_PUBLIC_KEY; missing
|
||||
// key disables the endpoint.
|
||||
plugins: [
|
||||
actor({ secret: SCENARIO_ACTOR_SECRET }),
|
||||
admin(),
|
||||
fabric({ publicKey: FABRIC_AUTH_PUBLIC_KEY }),
|
||||
],
|
||||
})
|
||||
})
|
||||
48
packages/functions/src/wirings/middleware.ts
Normal file
48
packages/functions/src/wirings/middleware.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { addHTTPMiddleware } from "#pikku";
|
||||
import { authBearer, cors } from "@pikku/core/middleware";
|
||||
import { betterAuthStatelessSession } 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,
|
||||
// Stateless (cookie-cache) session so the CLI detects our own registration and
|
||||
// does NOT also generate a bare betterAuthStatelessSession() (no mapSession),
|
||||
// which would strip the session to { userId } and 403 every admin RPC.
|
||||
betterAuthStatelessSession({
|
||||
mapSession: (result: any) => {
|
||||
// A logged-in user must always carry a role (Better Auth defaults it to
|
||||
// 'client'). A missing role means the auth wiring/schema is stale — fail
|
||||
// loudly instead of silently downgrading to 'client', which would show the
|
||||
// real role in the header (DB-read) while every admin RPC 403s.
|
||||
const role = result.user.role;
|
||||
if (!role) {
|
||||
throw new Error(
|
||||
`Session for user ${result.user.id} has no role — auth wiring/schema is out of date`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
userId: result.user.id,
|
||||
role,
|
||||
locale: result.user.locale ?? "de",
|
||||
};
|
||||
},
|
||||
}),
|
||||
// External consoles (sandbox/woven-build view) can't carry the session cookie,
|
||||
// so they authenticate with `Authorization: Bearer <PIKKU_CONSOLE_TOKEN>`. The
|
||||
// CLI normally emits this alongside its session middleware, but because we
|
||||
// register our own global session middleware above it steps aside entirely —
|
||||
// so we must add the console-token bridge here or console:* RPCs 403.
|
||||
authBearer({
|
||||
token: {
|
||||
secretId: "PIKKU_CONSOLE_TOKEN",
|
||||
userSession: { userId: "pikku-console-token" },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
15
packages/functions/src/wirings/public.http.ts
Normal file
15
packages/functions/src/wirings/public.http.ts
Normal 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 } })
|
||||
11
packages/functions/src/wirings/scheduled.wiring.ts
Normal file
11
packages/functions/src/wirings/scheduled.wiring.ts
Normal 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'],
|
||||
})
|
||||
4
packages/functions/tests/.env.test
Normal file
4
packages/functions/tests/.env.test
Normal file
@@ -0,0 +1,4 @@
|
||||
# Environment variables required by createConfig() during tests.
|
||||
# Add any env vars your config.ts reads at module load time.
|
||||
# This file is loaded via --env-file and is NOT committed to version control.
|
||||
NODE_ENV=test
|
||||
24
packages/functions/tests/package.json
Normal file
24
packages/functions/tests/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "tests",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Function test harness — Cucumber over in-process Pikku RPC with stubbed services.",
|
||||
"scripts": {
|
||||
"test": "node --env-file=.env.test node_modules/.bin/cucumber-js --config tests/cucumber.mjs --tags 'not @skip'",
|
||||
"test:tag": "node --env-file=.env.test node_modules/.bin/cucumber-js --config tests/cucumber.mjs --tags",
|
||||
"coverage": "pikku tests coverage",
|
||||
"tsc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pikku/cucumber": "0.12.9",
|
||||
"@pikku/kysely-node-sqlite": "^0.12.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cucumber/cucumber": "^11.0.0",
|
||||
"@types/node": "^22",
|
||||
"c8": "^10.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.0"
|
||||
}
|
||||
}
|
||||
7
packages/functions/tests/tests/cucumber.mjs
Normal file
7
packages/functions/tests/tests/cucumber.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
requireModule: ['tsx'],
|
||||
require: ['tests/support/**/*.ts', 'tests/steps/**/*.ts'],
|
||||
paths: ['tests/features/**/*.feature'],
|
||||
format: ['progress', 'html:tests/reports/cucumber-report.html'],
|
||||
forceExit: true,
|
||||
}
|
||||
24
packages/functions/tests/tests/features/example.feature
Normal file
24
packages/functions/tests/tests/features/example.feature
Normal file
@@ -0,0 +1,24 @@
|
||||
Feature: Example function test
|
||||
|
||||
Starter scenario created by `pikku tests init`. It uses only the built-in
|
||||
pikku/cucumber steps (no custom step code) and passes out of the box, so the
|
||||
Run-tests button and coverage report work immediately. Replace it with real
|
||||
scenarios that call your RPCs — see the commented example at the bottom.
|
||||
|
||||
Scenario: the function-test harness is wired up
|
||||
Given the data "example" is:
|
||||
| hello | world |
|
||||
Then the data "example" is not empty
|
||||
|
||||
# Real example — call one of your RPCs and assert the outcome. Uncomment and
|
||||
# adapt (run `pikku meta` to list versioned RPC names and input schemas):
|
||||
#
|
||||
# Scenario: an anonymous user cannot reach a protected RPC
|
||||
# When an anonymous user calls "yourProtectedRpc"
|
||||
# Then the call fails with "Unauthorized"
|
||||
#
|
||||
# Scenario: a public RPC returns data
|
||||
# When an anonymous user calls "yourPublicRpc" with:
|
||||
# | someField | someValue |
|
||||
# Then the call succeeds
|
||||
# And the result has "id"
|
||||
7
packages/functions/tests/tests/support/hooks.ts
Normal file
7
packages/functions/tests/tests/support/hooks.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import '../../../.pikku/pikku-bootstrap.gen.js'
|
||||
import { Before, After, BeforeAll, AfterAll, setDefaultTimeout, Given, When, Then } from '@cucumber/cucumber'
|
||||
import { registerHooks, registerCommonSteps } from '@pikku/cucumber'
|
||||
import { db } from './services.js'
|
||||
|
||||
registerHooks({ Before, After, BeforeAll, AfterAll, setDefaultTimeout }, db)
|
||||
registerCommonSteps({ Given, When, Then })
|
||||
44
packages/functions/tests/tests/support/services.ts
Normal file
44
packages/functions/tests/tests/support/services.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createNodeSqliteKysely, createCoercionPlugin } from '@pikku/kysely-node-sqlite'
|
||||
import { createDbUtils, type StubTracker } from '@pikku/cucumber'
|
||||
import { createConfig } from '../../../src/config.js'
|
||||
import { createSingletonServices } from '../../../src/services.js'
|
||||
import { coercionMap } from '../../../.pikku/db/coercion.gen.js'
|
||||
import type { DB } from '../../../.pikku/db/schema.js'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = (p: string) => resolve(__dirname, '../../../../..', p)
|
||||
|
||||
export const db = createDbUtils({
|
||||
migrationsDir: repoRoot('db/sqlite'),
|
||||
seedFile: repoRoot('db/sqlite-seed.sql'),
|
||||
})
|
||||
|
||||
type StubKysely = ReturnType<typeof createNodeSqliteKysely<DB>>
|
||||
|
||||
const REAL_SERVICES = (kysely: StubKysely) => ({ kysely })
|
||||
|
||||
export async function createStubServices(dbFile: string, tracker: StubTracker) {
|
||||
const kysely = createNodeSqliteKysely<DB>({
|
||||
filename: dbFile,
|
||||
camelCase: true,
|
||||
plugins: [createCoercionPlugin({ map: coercionMap })],
|
||||
})
|
||||
|
||||
const real = REAL_SERVICES(kysely)
|
||||
const injected = new Proxy(real as Record<string, unknown>, {
|
||||
get(target, prop: string) {
|
||||
if (prop in target) return target[prop]
|
||||
return tracker.stub(prop)
|
||||
},
|
||||
})
|
||||
|
||||
const config = await createConfig()
|
||||
const services = await createSingletonServices(
|
||||
{ ...config, dev: { db: { file: dbFile } } } as never,
|
||||
injected as never,
|
||||
)
|
||||
|
||||
return { services, kysely }
|
||||
}
|
||||
5
packages/functions/tests/tests/support/world.ts
Normal file
5
packages/functions/tests/tests/support/world.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { World, setWorldConstructor } from '@cucumber/cucumber'
|
||||
import { createFunctionWorld } from '@pikku/cucumber'
|
||||
import { createStubServices } from './services.js'
|
||||
|
||||
createFunctionWorld(World, setWorldConstructor, createStubServices)
|
||||
18
packages/functions/tests/tsconfig.json
Normal file
18
packages/functions/tests/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": [
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user