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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-09 14:04:23 -07:00
parent 4b78d357c0
commit 45b94e4ed2
8 changed files with 555 additions and 16 deletions

View File

@ -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

View File

@ -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<typeof createMollieClient> | 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

View File

@ -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`)"

View File

@ -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`)"

310
lib/booking-sheet.ts Normal file
View File

@ -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<void> {
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 (0A, 25Z, 26AA)
*/
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<BookingResult> {
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",
}
}
}

View File

@ -23,6 +23,85 @@ const EMAIL_FROM =
process.env.EMAIL_FROM ||
"Crypto Commons Gathering <newsletter@cryptocommonsgather.ing>"
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<boolean> {
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 = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
<h2 style="margin-bottom: 4px;">Accommodation Update: ${data.guestName}</h2>
<p style="margin-top: 0; color: ${statusColor}; font-weight: bold;">${statusLabel}</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr><td style="padding: 4px 0;"><strong>Guest:</strong></td><td>${data.guestName}</td></tr>
<tr><td style="padding: 4px 0;"><strong>Email:</strong></td><td>${data.guestEmail || "N/A"}</td></tr>
<tr><td style="padding: 4px 0;"><strong>Paid:</strong></td><td>${data.amountPaid}</td></tr>
<tr><td style="padding: 4px 0;"><strong>Requested:</strong></td><td>${data.accommodationType}</td></tr>
${data.bookingSuccess ? `
<tr><td style="padding: 4px 0;"><strong>Assigned Venue:</strong></td><td>${data.venue}</td></tr>
<tr><td style="padding: 4px 0;"><strong>Room:</strong></td><td>${data.room}</td></tr>
<tr><td style="padding: 4px 0;"><strong>Bed Type:</strong></td><td>${data.bedType}</td></tr>
` : ""}
</table>
${
flags.length > 0
? `<div style="background: #fef2f2; padding: 12px 16px; border-radius: 6px; border-left: 3px solid #dc2626; margin: 16px 0;">
<strong style="color: #dc2626;">Flags:</strong>
<ul style="margin: 4px 0 0 0; padding-left: 20px;">${flags.map((f) => `<li>${f}</li>`).join("")}</ul>
</div>`
: `<p style="color: #16a34a;">No issues detected. <a href="https://docs.google.com/spreadsheets/d/1QJJNcxsWonmTBshvVVqr3ctNGHsoQbiR7mnjsxBuX9I/edit?gid=768527234#gid=768527234" style="color: #16a34a;">Booking sheet</a> updated automatically.</p>`
}
<p style="font-size: 12px; color: #666; margin-top: 24px;">Automated notification from CCG registration system</p>
</div>
`
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(
<td style="padding: 4px 0;">${data.paymentMethod}</td>
</tr>
${data.dietary ? `<tr><td style="padding: 4px 0;"><strong>Dietary:</strong></td><td style="padding: 4px 0;">${data.dietary}</td></tr>` : ""}
${data.accommodationVenue ? `<tr><td style="padding: 4px 0;"><strong>Accommodation:</strong></td><td style="padding: 4px 0;">${data.accommodationVenue}${data.accommodationRoom ? `, Room ${data.accommodationRoom}` : ""}</td></tr>` : ""}
</table>
</div>
<h3 style="color: #92400e;">Food &amp; Accommodation</h3>
<p>Information about food arrangements coming in a subsequent email. If you haven't yet decided on accommodation, there's still time! The <strong>Commons Hub</strong> (our main venue with 30 on-site beds) and the nearby <strong>Herrnhof Villa</strong> are the most convenient options &mdash; right in the heart of the gathering. Other nearby options are also available; check the <a href="https://cryptocommonsgather.ing/directions" style="color: #f59e0b;">Directions page</a> for details. Email <a href="mailto:office@commons-hub.at" style="color: #f59e0b;">office@commons-hub.at</a> for any questions about accommodation.</p>
${
data.accommodationVenue
? `<p>Your accommodation at the <strong>${data.accommodationVenue}</strong>${data.accommodationRoom ? ` (Room ${data.accommodationRoom})` : ""} has been reserved for the duration of the gathering.</p>
<p>We'll be in touch about food arrangements as we work to keep costs down while creating inclusive, participatory processes for the event.</p>`
: `<p>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 <strong>Commons Hub</strong> (our main venue) and the nearby <strong>Herrnhof Villa</strong> are the most convenient options &mdash; right in the heart of the gathering. Email <a href="mailto:contact@cryptocommonsgather.ing" style="color: #f59e0b;">contact@cryptocommonsgather.ing</a> for any questions.</p>`
}
<h3 style="color: #92400e;">What's Next?</h3>
<ul style="line-height: 1.8;">

View File

@ -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<number> {
"", // 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<bool
// First, get all rows to find the matching registration
const response = await sheets.spreadsheets.values.get({
spreadsheetId: SPREADSHEET_ID,
range: `${SHEET_NAME}!A:N`,
range: `${SHEET_NAME}!A:P`,
})
const rows = response.data.values || []
@ -119,21 +123,24 @@ export async function updatePaymentStatus(data: PaymentUpdateData): Promise<bool
}
// Update the row with payment information
const updateRange = `${SHEET_NAME}!C${targetRowIndex}:N${targetRowIndex}`
const existingRow = rows[targetRowIndex - 1]
const updateRange = `${SHEET_NAME}!C${targetRowIndex}:P${targetRowIndex}`
const updateValues = [
[
data.email || rows[targetRowIndex - 1][2] || "", // C: Email (from Stripe or existing)
rows[targetRowIndex - 1][3], // D: Contact (preserve)
rows[targetRowIndex - 1][4], // E: Contributions (preserve)
rows[targetRowIndex - 1][5], // F: Expectations (preserve)
rows[targetRowIndex - 1][6], // G: How Heard (preserve)
rows[targetRowIndex - 1][7], // H: Dietary (preserve)
rows[targetRowIndex - 1][8], // I: Crew Consent (preserve)
data.email || existingRow[2] || "", // C: Email (from Mollie or existing)
existingRow[3], // D: Contact (preserve)
existingRow[4], // E: Contributions (preserve)
existingRow[5], // F: Expectations (preserve)
existingRow[6], // G: How Heard (preserve)
existingRow[7], // H: Dietary (preserve)
existingRow[8], // I: Crew Consent (preserve)
data.paymentStatus, // J: Payment Status
data.paymentMethod || "", // K: Payment Method
data.paymentSessionId, // L: Stripe Session ID
data.paymentSessionId, // L: Payment Session ID
data.amountPaid || "", // M: Amount Paid
data.paymentDate || new Date().toISOString(), // N: Payment Date
data.accommodationVenue || "", // O: Accommodation Venue
data.accommodationType || "", // P: Accommodation Type
],
]
@ -162,7 +169,7 @@ export async function initializeSheetHeaders(): Promise<void> {
// 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<void> {
"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 },
})

87
scripts/test-booking.ts Normal file
View File

@ -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<ReturnType<typeof assignBooking>> = { 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)