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:
parent
4b78d357c0
commit
45b94e4ed2
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`)"
|
||||
|
|
|
|||
|
|
@ -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`)"
|
||||
|
|
|
|||
|
|
@ -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 (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<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",
|
||||
}
|
||||
}
|
||||
}
|
||||
89
lib/email.ts
89
lib/email.ts
|
|
@ -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 & 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 — 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 — 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;">
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
Loading…
Reference in New Issue