// Booking capture Pages Function (calendar-site skill). // // Served at POST /api/book on the booking domain. Writes the submission // straight to D1 so capture survives a brief Pi outage — the platform reconcile // pass turns accepted rows into Neo4j :Meeting nodes later. Emits the // [calendar-booking] submit + d1-write lifeline (Cloudflare Pages captures // console output). // // The handler is split so its logic is verifiable without the Pages runtime: // processBooking takes an injected DB + logger and returns the HTTP shape. interface D1Result { meta?: { changes?: number } } interface D1PreparedStatement { bind: (...values: unknown[]) => D1PreparedStatement run: () => Promise } interface D1Database { prepare: (query: string) => D1PreparedStatement } interface BookingEnv { DB: D1Database } interface BookingBody { slotStart?: unknown slotEnd?: unknown name?: unknown email?: unknown note?: unknown company?: unknown // honeypot — real users never fill this } type Logger = (line: string) => void function isNonEmptyString(v: unknown): v is string { return typeof v === 'string' && v.trim().length > 0 } // Field caps so a malicious POST cannot store an unbounded blob in D1. const MAX = { name: 200, email: 320, note: 2000, slot: 40 } function isIsoTimestamp(v: unknown): v is string { return typeof v === 'string' && v.length <= MAX.slot && !Number.isNaN(Date.parse(v)) } export async function processBooking( body: BookingBody, env: BookingEnv, log: Logger, newId: () => string, ): Promise<{ status: number; payload: Record }> { // Honeypot: a populated `company` field means a bot. Return ok (so the bot // sees success) but write nothing. if (isNonEmptyString(body.company)) { return { status: 200, payload: { ok: true } } } if ( !isIsoTimestamp(body.slotStart) || !isIsoTimestamp(body.slotEnd) || !isNonEmptyString(body.name) || !isNonEmptyString(body.email) ) { return { status: 400, payload: { ok: false, error: 'name, email, and ISO slotStart/slotEnd are required' } } } if (body.name.length > MAX.name || body.email.length > MAX.email) { return { status: 400, payload: { ok: false, error: 'name or email too long' } } } const bookingId = newId() const note = isNonEmptyString(body.note) ? body.note.slice(0, MAX.note) : '' const createdAt = new Date().toISOString() log(`[calendar-booking] op=submit bookingId=${bookingId} slotStart=${body.slotStart} email=${body.email}`) const result = await env.DB.prepare( `INSERT INTO bookings (bookingId, slotStart, slotEnd, name, email, note, status, createdAt, swept) VALUES (?, ?, ?, ?, ?, ?, 'accepted', ?, 0)`, ) .bind(bookingId, body.slotStart, body.slotEnd, body.name, body.email, note, createdAt) .run() const rowsWritten = result.meta?.changes ?? 0 log(`[calendar-booking] op=d1-write bookingId=${bookingId} rowsWritten=${rowsWritten}`) if (rowsWritten < 1) { return { status: 500, payload: { ok: false, error: 'capture failed' } } } return { status: 200, payload: { ok: true, bookingId } } } interface PagesContext { request: Request env: BookingEnv } export async function onRequestPost(context: PagesContext): Promise { let body: BookingBody try { body = (await context.request.json()) as BookingBody } catch { return Response.json({ ok: false, error: 'invalid JSON' }, { status: 400 }) } const { status, payload } = await processBooking( body, context.env, (line) => console.log(line), () => crypto.randomUUID(), ) return Response.json(payload, { status }) }