From 45b94e4ed2b6431b10dd2707db7c0e316d6d53b0 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 9 Mar 2026 14:04:23 -0700 Subject: [PATCH] feat: auto-assign room bookings on payment and notify contact@ - Add booking-sheet.ts: parses booking spreadsheet, finds first available bed matching accommodation type, writes guest name across date columns - Expand registration sheet to columns O-P (Accommodation Venue/Type) - Webhook now assigns room booking (best-effort) on successful payment - Send internal notification to contact@ with assignment details and flags - Confirmation email shows assigned room; updated food/accommodation copy - Add test script for end-to-end verification - Add BOOKING_SHEET_ID/NAME to env and docker-compose configs Co-Authored-By: Claude Opus 4.6 --- .env.example | 6 + app/api/webhook/route.ts | 38 ++++- docker-compose.staging.yml | 2 + docker-compose.yml | 2 + lib/booking-sheet.ts | 310 +++++++++++++++++++++++++++++++++++++ lib/email.ts | 89 ++++++++++- lib/google-sheets.ts | 37 +++-- scripts/test-booking.ts | 87 +++++++++++ 8 files changed, 555 insertions(+), 16 deletions(-) create mode 100644 lib/booking-sheet.ts create mode 100644 scripts/test-booking.ts diff --git a/.env.example b/.env.example index 6d48d56..87fea2a 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,9 @@ GOOGLE_SHEET_ID=your-google-sheet-id-here # Optional: Sheet tab name (defaults to "Registrations") GOOGLE_SHEET_NAME=Registrations + +# Booking/Room Assignment Spreadsheet (separate from registration sheet) +# Sheet ID from URL: https://docs.google.com/spreadsheets/d/SHEET_ID/edit +BOOKING_SHEET_ID=your-booking-sheet-id-here +# Optional: Tab name in the booking spreadsheet (defaults to "Sheet1") +BOOKING_SHEET_NAME=Sheet1 diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts index e8b4b82..f237a25 100644 --- a/app/api/webhook/route.ts +++ b/app/api/webhook/route.ts @@ -1,8 +1,9 @@ import { type NextRequest, NextResponse } from "next/server" import createMollieClient from "@mollie/api-client" import { updatePaymentStatus } from "@/lib/google-sheets" -import { sendPaymentConfirmation } from "@/lib/email" +import { sendPaymentConfirmation, sendBookingNotification } from "@/lib/email" import { addToListmonk } from "@/lib/listmonk" +import { assignBooking } from "@/lib/booking-sheet" // Lazy initialization let mollieClient: ReturnType | null = null @@ -34,6 +35,37 @@ export async function POST(request: NextRequest) { if (payment.status === "paid") { const customerEmail = payment.billingAddress?.email || "" const amountPaid = `€${payment.amount.value}` + const accommodationType = metadata.accommodation || "none" + + // Attempt room booking assignment (best-effort, don't fail webhook) + let bookingResult: { success: boolean; venue?: string; room?: string; bedType?: string } = { success: false } + if (accommodationType !== "none") { + try { + bookingResult = await assignBooking(metadata.name || "Unknown", accommodationType) + if (bookingResult.success) { + console.log(`[Webhook] Booking assigned: ${bookingResult.venue} Room ${bookingResult.room}`) + } else { + console.warn(`[Webhook] Booking assignment failed (non-fatal): ${(bookingResult as { error?: string }).error}`) + } + } catch (err) { + console.error("[Webhook] Booking assignment error (non-fatal):", err) + } + } + + // Send internal notification to contact@ about accommodation assignment + if (accommodationType !== "none") { + sendBookingNotification({ + guestName: metadata.name || "Unknown", + guestEmail: customerEmail, + accommodationType, + amountPaid, + bookingSuccess: bookingResult.success, + venue: bookingResult.venue, + room: bookingResult.room, + bedType: bookingResult.bedType, + error: (bookingResult as { error?: string }).error, + }).catch((err) => console.error("[Webhook] Booking notification failed:", err)) + } // Update Google Sheet const updated = await updatePaymentStatus({ @@ -44,6 +76,8 @@ export async function POST(request: NextRequest) { paymentMethod: payment.method || "unknown", amountPaid, paymentDate: new Date().toISOString(), + accommodationVenue: bookingResult.venue || "", + accommodationType: accommodationType !== "none" ? accommodationType : "", }) if (updated) { @@ -61,6 +95,8 @@ export async function POST(request: NextRequest) { paymentMethod: payment.method || "card", contributions: metadata.contributions || "", dietary: metadata.dietary || "", + accommodationVenue: bookingResult.success ? bookingResult.venue : undefined, + accommodationRoom: bookingResult.success ? bookingResult.room : undefined, }) // Add to Listmonk newsletter diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index 63a972b..82d8719 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -21,6 +21,8 @@ services: - LISTMONK_DB_USER=${LISTMONK_DB_USER:-listmonk} - LISTMONK_DB_PASS=${LISTMONK_DB_PASS} - LISTMONK_LIST_ID=${LISTMONK_LIST_ID:-22} + - BOOKING_SHEET_ID=${BOOKING_SHEET_ID} + - BOOKING_SHEET_NAME=${BOOKING_SHEET_NAME:-Sheet1} labels: - "traefik.enable=true" - "traefik.http.routers.ccg-staging.rule=Host(`staging-ccg.jeffemmett.com`)" diff --git a/docker-compose.yml b/docker-compose.yml index 7747441..e972321 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,8 @@ services: - LISTMONK_DB_USER=${LISTMONK_DB_USER:-listmonk} - LISTMONK_DB_PASS=${LISTMONK_DB_PASS} - LISTMONK_LIST_ID=${LISTMONK_LIST_ID:-22} + - BOOKING_SHEET_ID=${BOOKING_SHEET_ID} + - BOOKING_SHEET_NAME=${BOOKING_SHEET_NAME:-Sheet1} labels: - "traefik.enable=true" - "traefik.http.routers.ccg.rule=Host(`cryptocommonsgather.ing`) || Host(`www.cryptocommonsgather.ing`)" diff --git a/lib/booking-sheet.ts b/lib/booking-sheet.ts new file mode 100644 index 0000000..bd49e3b --- /dev/null +++ b/lib/booking-sheet.ts @@ -0,0 +1,310 @@ +import { getGoogleSheetsClient } from "./google-sheets" + +const BOOKING_SHEET_ID = process.env.BOOKING_SHEET_ID +const BOOKING_SHEET_NAME = process.env.BOOKING_SHEET_NAME || "Sheet1" + +// Mapping from checkout form accommodation codes to bed search criteria +const ACCOMMODATION_CRITERIA: Record< + string, + { venue: string; bedTypes: string[]; roomFilter?: (room: string) => boolean } +> = { + "ch-multi": { + venue: "Commons Hub", + bedTypes: ["bunk up", "bunk down", "single"], + roomFilter: (room) => { + const num = parseInt(room, 10) + return num >= 5 && num <= 9 + }, + }, + "ch-double": { + venue: "Commons Hub", + bedTypes: ["double", "double (shared)"], + roomFilter: (room) => room === "2", + }, + "hh-single": { + venue: "Herrnhof Villa", + bedTypes: ["double"], + }, + "hh-double-separate": { + venue: "Herrnhof Villa", + bedTypes: ["single"], + }, + "hh-double-shared": { + venue: "Herrnhof Villa", + bedTypes: ["double (shared)"], + }, + "hh-triple": { + venue: "Herrnhof Villa", + bedTypes: ["double (shared)"], + roomFilter: (room) => room.toLowerCase().includes("triple"), + }, + "hh-daybed": { + venue: "Herrnhof Villa", + bedTypes: ["couch"], + }, +} + +interface BedRow { + rowIndex: number // 0-based index in the sheet data + venue: string + room: string + bedType: string + dateColumns: number[] // column indices for date cells + occupied: boolean // true if any date column has a value +} + +interface BookingResult { + success: boolean + venue?: string + room?: string + bedType?: string + error?: string +} + +/** + * Parse the booking spreadsheet to extract bed information. + * + * Expected sheet structure: + * - Two venue sections: "Commons Hub" and "Herrnhof Villa" appear as section headers + * - Below each header: column headers with Room, Bed Type, then date columns + * - Bed rows follow with room number, bed type, and occupant names in date columns + * - Room numbers may be merged (only first row of a room group has the room number) + */ +function parseBookingSheet(data: string[][]): BedRow[] { + const beds: BedRow[] = [] + let currentVenue = "" + let dateColumnIndices: number[] = [] + let roomCol = -1 + let bedTypeCol = -1 + let lastRoom = "" + let inDataSection = false + + for (let i = 0; i < data.length; i++) { + const row = data[i] + if (!row || row.length === 0) { + // Empty row — could be section separator + inDataSection = false + continue + } + + const firstCell = (row[0] || "").trim() + + // Detect venue section headers + if (firstCell.toLowerCase().includes("commons hub")) { + currentVenue = "Commons Hub" + inDataSection = false + lastRoom = "" + continue + } + if ( + firstCell.toLowerCase().includes("herrnhof") || + firstCell.toLowerCase().includes("villa") + ) { + currentVenue = "Herrnhof Villa" + inDataSection = false + lastRoom = "" + continue + } + + if (!currentVenue) continue + + // Detect column headers row (contains "room" and date-like patterns) + const lowerRow = row.map((c) => (c || "").trim().toLowerCase()) + const roomIdx = lowerRow.findIndex( + (c) => c === "room" || c === "room #" || c === "room number" + ) + const bedIdx = lowerRow.findIndex( + (c) => + c === "bed type" || + c === "bed" || + c === "type" || + c === "bed/type" + ) + + if (roomIdx !== -1 && bedIdx !== -1) { + roomCol = roomIdx + bedTypeCol = bedIdx + // Date columns are everything after bedTypeCol that looks like a date or has content + dateColumnIndices = [] + for (let j = bedTypeCol + 1; j < row.length; j++) { + const cell = (row[j] || "").trim() + if (cell) { + dateColumnIndices.push(j) + } + } + inDataSection = true + lastRoom = "" + continue + } + + // Parse data rows + if (inDataSection && roomCol !== -1 && bedTypeCol !== -1) { + let bedType = (row[bedTypeCol] || "").trim().toLowerCase() + if (!bedType) continue // Skip rows without bed type + // Normalize: "double (shared" → "double (shared)" + if (bedType.includes("(") && !bedType.includes(")")) { + bedType += ")" + } + + // Carry forward room number from merged cells + const roomValue = (row[roomCol] || "").trim() + if (roomValue) { + lastRoom = roomValue + } + if (!lastRoom) continue + + // Check if any date column has an occupant + const occupied = dateColumnIndices.some((colIdx) => { + const cell = (row[colIdx] || "").trim() + return cell.length > 0 + }) + + beds.push({ + rowIndex: i, + venue: currentVenue, + room: lastRoom, + bedType, + dateColumns: dateColumnIndices, + occupied, + }) + } + } + + return beds +} + +/** + * Find the first available bed matching the given criteria + */ +function findFirstAvailableBed( + beds: BedRow[], + venue: string, + bedTypes: string[], + roomFilter?: (room: string) => boolean +): BedRow | null { + return ( + beds.find((bed) => { + if (bed.venue !== venue) return false + if (!bedTypes.includes(bed.bedType)) return false + if (bed.occupied) return false + if (roomFilter && !roomFilter(bed.room)) return false + return true + }) || null + ) +} + +/** + * Write a guest name into all date columns for a given bed row + */ +async function assignGuestToBed( + guestName: string, + bed: BedRow, + sheetData: string[][] +): Promise { + const sheets = getGoogleSheetsClient() + + // Build batch update data — one value range per date column + const data = bed.dateColumns.map((colIdx) => { + const colLetter = columnToLetter(colIdx) + const rowNum = bed.rowIndex + 1 // Convert to 1-indexed + return { + range: `${BOOKING_SHEET_NAME}!${colLetter}${rowNum}`, + values: [[guestName]], + } + }) + + await sheets.spreadsheets.values.batchUpdate({ + spreadsheetId: BOOKING_SHEET_ID!, + requestBody: { + valueInputOption: "USER_ENTERED", + data, + }, + }) +} + +/** + * Convert 0-based column index to spreadsheet column letter (0→A, 25→Z, 26→AA) + */ +function columnToLetter(col: number): string { + let letter = "" + let c = col + while (c >= 0) { + letter = String.fromCharCode((c % 26) + 65) + letter + c = Math.floor(c / 26) - 1 + } + return letter +} + +/** + * Main entry point: assign a guest to the first available matching bed. + * Best-effort — failures are logged but don't throw. + */ +export async function assignBooking( + guestName: string, + accommodationType: string +): Promise { + if (!BOOKING_SHEET_ID) { + return { success: false, error: "BOOKING_SHEET_ID not configured" } + } + + const criteria = ACCOMMODATION_CRITERIA[accommodationType] + if (!criteria) { + return { + success: false, + error: `Unknown accommodation type: ${accommodationType}`, + } + } + + try { + const sheets = getGoogleSheetsClient() + + // Read the entire booking sheet + const response = await sheets.spreadsheets.values.get({ + spreadsheetId: BOOKING_SHEET_ID, + range: BOOKING_SHEET_NAME, + }) + + const sheetData = response.data.values || [] + if (sheetData.length === 0) { + return { success: false, error: "Booking sheet is empty" } + } + + // Parse the sheet into bed rows + const beds = parseBookingSheet(sheetData) + + // Find first available bed matching criteria + const bed = findFirstAvailableBed( + beds, + criteria.venue, + criteria.bedTypes, + criteria.roomFilter + ) + + if (!bed) { + return { + success: false, + error: `No available ${criteria.bedTypes.join("/")} beds in ${criteria.venue}`, + } + } + + // Assign the guest + await assignGuestToBed(guestName, bed, sheetData) + + console.log( + `[Booking] Assigned ${guestName} to ${criteria.venue} Room ${bed.room} (${bed.bedType})` + ) + + return { + success: true, + venue: criteria.venue, + room: bed.room, + bedType: bed.bedType, + } + } catch (error) { + console.error("[Booking] Error assigning booking:", error) + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + } + } +} diff --git a/lib/email.ts b/lib/email.ts index b2a5a77..25d3cd4 100644 --- a/lib/email.ts +++ b/lib/email.ts @@ -23,6 +23,85 @@ const EMAIL_FROM = process.env.EMAIL_FROM || "Crypto Commons Gathering " +const INTERNAL_NOTIFY_EMAIL = "contact@cryptocommonsgather.ing" + +interface BookingNotificationData { + guestName: string + guestEmail: string + accommodationType: string + amountPaid: string + bookingSuccess: boolean + venue?: string + room?: string + bedType?: string + error?: string +} + +export async function sendBookingNotification( + data: BookingNotificationData +): Promise { + const transport = getTransporter() + if (!transport) { + console.log("[Email] SMTP not configured, skipping booking notification") + return false + } + + const statusColor = data.bookingSuccess ? "#16a34a" : "#dc2626" + const statusLabel = data.bookingSuccess ? "ASSIGNED" : "FAILED" + + const flags: string[] = [] + if (!data.bookingSuccess) { + flags.push(`Booking assignment failed: ${data.error || "unknown reason"}`) + } + if (!data.guestEmail) { + flags.push("No email address on file for this guest") + } + + const html = ` +
+

Accommodation Update: ${data.guestName}

+

${statusLabel}

+ + + + + + + ${data.bookingSuccess ? ` + + + + ` : ""} +
Guest:${data.guestName}
Email:${data.guestEmail || "N/A"}
Paid:${data.amountPaid}
Requested:${data.accommodationType}
Assigned Venue:${data.venue}
Room:${data.room}
Bed Type:${data.bedType}
+ + ${ + flags.length > 0 + ? `
+ Flags: +
    ${flags.map((f) => `
  • ${f}
  • `).join("")}
+
` + : `

No issues detected. Booking sheet updated automatically.

` + } + +

Automated notification from CCG registration system

+
+ ` + + try { + const info = await transport.sendMail({ + from: EMAIL_FROM, + to: INTERNAL_NOTIFY_EMAIL, + subject: `[CCG Booking] ${statusLabel}: ${data.guestName} — ${data.accommodationType}`, + html, + }) + console.log(`[Email] Booking notification sent to ${INTERNAL_NOTIFY_EMAIL} (${info.messageId})`) + return true + } catch (error) { + console.error("[Email] Failed to send booking notification:", error) + return false + } +} + interface PaymentConfirmationData { name: string email: string @@ -30,6 +109,8 @@ interface PaymentConfirmationData { paymentMethod: string contributions: string dietary: string + accommodationVenue?: string + accommodationRoom?: string } export async function sendPaymentConfirmation( @@ -62,11 +143,17 @@ export async function sendPaymentConfirmation( ${data.paymentMethod} ${data.dietary ? `Dietary:${data.dietary}` : ""} + ${data.accommodationVenue ? `Accommodation:${data.accommodationVenue}${data.accommodationRoom ? `, Room ${data.accommodationRoom}` : ""}` : ""}

Food & Accommodation

-

Information about food arrangements coming in a subsequent email. If you haven't yet decided on accommodation, there's still time! The Commons Hub (our main venue with 30 on-site beds) and the nearby Herrnhof Villa are the most convenient options — right in the heart of the gathering. Other nearby options are also available; check the Directions page for details. Email office@commons-hub.at for any questions about accommodation.

+ ${ + data.accommodationVenue + ? `

Your accommodation at the ${data.accommodationVenue}${data.accommodationRoom ? ` (Room ${data.accommodationRoom})` : ""} has been reserved for the duration of the gathering.

+

We'll be in touch about food arrangements as we work to keep costs down while creating inclusive, participatory processes for the event.

` + : `

We'll be in touch about food arrangements as we work to keep costs down while creating inclusive, participatory processes for the event. If you haven't yet decided on accommodation, the Commons Hub (our main venue) and the nearby Herrnhof Villa are the most convenient options — right in the heart of the gathering. Email contact@cryptocommonsgather.ing for any questions.

` + }

What's Next?

    diff --git a/lib/google-sheets.ts b/lib/google-sheets.ts index 85da78d..58c0112 100644 --- a/lib/google-sheets.ts +++ b/lib/google-sheets.ts @@ -1,7 +1,7 @@ import { google } from "googleapis" // Initialize Google Sheets API -function getGoogleSheetsClient() { +export function getGoogleSheetsClient() { const credentials = JSON.parse(process.env.GOOGLE_SERVICE_ACCOUNT_KEY || "{}") const auth = new google.auth.GoogleAuth({ @@ -34,6 +34,8 @@ export interface PaymentUpdateData { paymentMethod?: string amountPaid?: string paymentDate?: string + accommodationVenue?: string + accommodationType?: string } /** @@ -61,12 +63,14 @@ export async function addRegistration(data: RegistrationData): Promise { "", // L: Stripe Session ID "", // M: Amount Paid "", // N: Payment Date + "", // O: Accommodation Venue + "", // P: Accommodation Type ], ] const response = await sheets.spreadsheets.values.append({ spreadsheetId: SPREADSHEET_ID, - range: `${SHEET_NAME}!A:N`, + range: `${SHEET_NAME}!A:P`, valueInputOption: "USER_ENTERED", insertDataOption: "INSERT_ROWS", requestBody: { values }, @@ -93,7 +97,7 @@ export async function updatePaymentStatus(data: PaymentUpdateData): Promise { // Check if first row has data const response = await sheets.spreadsheets.values.get({ spreadsheetId: SPREADSHEET_ID, - range: `${SHEET_NAME}!A1:N1`, + range: `${SHEET_NAME}!A1:P1`, }) if (!response.data.values || response.data.values.length === 0) { @@ -183,12 +190,14 @@ export async function initializeSheetHeaders(): Promise { "Payment Session ID", "Amount Paid", "Payment Date", + "Accommodation Venue", + "Accommodation Type", ], ] await sheets.spreadsheets.values.update({ spreadsheetId: SPREADSHEET_ID, - range: `${SHEET_NAME}!A1:N1`, + range: `${SHEET_NAME}!A1:P1`, valueInputOption: "USER_ENTERED", requestBody: { values: headers }, }) diff --git a/scripts/test-booking.ts b/scripts/test-booking.ts new file mode 100644 index 0000000..7e9f56c --- /dev/null +++ b/scripts/test-booking.ts @@ -0,0 +1,87 @@ +/** + * Test script: sends a test confirmation email, a test booking notification, + * and writes a test entry into the room booking sheet. + * + * Usage: npx tsx --env-file=.env.local scripts/test-booking.ts + */ + +import { sendPaymentConfirmation, sendBookingNotification } from "../lib/email" +import { assignBooking } from "../lib/booking-sheet" + +const TEST_GUEST = "Test Guest (DELETE ME)" +const TEST_EMAILS = ["jeff@jeffemmett.com", "contact@cryptocommonsgather.ing"] +const TEST_ACCOMMODATION = "ch-multi" // Bed in shared room, Commons Hub + +async function main() { + console.log("=== Test 1: Registration Confirmation Email (no accommodation) ===") + for (const email of TEST_EMAILS) { + try { + const sent = await sendPaymentConfirmation({ + name: TEST_GUEST, + email, + amountPaid: "€100.00", + paymentMethod: "ideal", + contributions: "Testing the booking system", + dietary: "None", + }) + console.log(sent ? `OK — confirmation sent to ${email}` : "SKIPPED — SMTP not configured") + } catch (err) { + console.error(`FAILED (${email}):`, err) + } + } + + console.log("\n=== Test 2: Room Booking Sheet Assignment ===") + let bookingResult: Awaited> = { success: false } + try { + bookingResult = await assignBooking(TEST_GUEST, TEST_ACCOMMODATION) + if (bookingResult.success) { + console.log(`OK — assigned to ${bookingResult.venue} Room ${bookingResult.room} (${bookingResult.bedType})`) + console.log("*** IMPORTANT: Remove this test entry from the booking sheet! ***") + } else { + console.log(`NOT ASSIGNED: ${bookingResult.error}`) + } + } catch (err) { + console.error("FAILED:", err) + } + + console.log("\n=== Test 3: Internal Booking Notification Email ===") + try { + const sent = await sendBookingNotification({ + guestName: TEST_GUEST, + guestEmail: TEST_EMAILS[0], + accommodationType: TEST_ACCOMMODATION, + amountPaid: "€100.00", + bookingSuccess: bookingResult.success, + venue: bookingResult.venue, + room: bookingResult.room, + bedType: bookingResult.bedType, + error: bookingResult.success ? undefined : bookingResult.error, + }) + console.log(sent ? "OK — notification email sent to contact@" : "SKIPPED — SMTP not configured") + } catch (err) { + console.error("FAILED:", err) + } + + console.log("\n=== Test 4: Confirmation Email WITH Accommodation ===") + for (const email of TEST_EMAILS) { + try { + const sent = await sendPaymentConfirmation({ + name: TEST_GUEST, + email, + amountPaid: "€379.30", + paymentMethod: "ideal", + contributions: "Testing the booking system", + dietary: "Vegetarian", + accommodationVenue: bookingResult.venue || "Commons Hub", + accommodationRoom: bookingResult.room || "7", + }) + console.log(sent ? `OK — confirmation with accommodation sent to ${email}` : "SKIPPED — SMTP not configured") + } catch (err) { + console.error(`FAILED (${email}):`, err) + } + } + + console.log("\nDone. Check inboxes and booking sheet.") +} + +main().catch(console.error)