feat: dynamic accommodation availability + overbooking alerts
- Add checkAvailability() with 2-min TTL cache to booking-sheet.js - Add GET /api/accommodation-availability endpoint - Fetch availability after day selection, disable sold-out cards - Send overbooking alert email when bed assignment fails post-payment - Sold-out cards show red badge, greyed out, unselectable Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
81ba58ce50
commit
b93d61fafb
|
|
@ -12,6 +12,10 @@ const fs = require('fs');
|
||||||
|
|
||||||
let sheetsClient = null;
|
let sheetsClient = null;
|
||||||
|
|
||||||
|
// In-memory cache for parsed booking sheet (2-min TTL)
|
||||||
|
let sheetCache = { data: null, timestamp: 0 };
|
||||||
|
const CACHE_TTL_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
// Accommodation criteria mapping — maps accommodation_type to sheet matching rules
|
// Accommodation criteria mapping — maps accommodation_type to sheet matching rules
|
||||||
const ACCOMMODATION_CRITERIA = {
|
const ACCOMMODATION_CRITERIA = {
|
||||||
'ch-shared': {
|
'ch-shared': {
|
||||||
|
|
@ -202,6 +206,47 @@ async function parseBookingSheet() {
|
||||||
return beds;
|
return beds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached wrapper around parseBookingSheet().
|
||||||
|
* Returns cached result if less than CACHE_TTL_MS old.
|
||||||
|
*/
|
||||||
|
async function getCachedBeds() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (sheetCache.data && (now - sheetCache.timestamp) < CACHE_TTL_MS) {
|
||||||
|
return sheetCache.data;
|
||||||
|
}
|
||||||
|
const beds = await parseBookingSheet();
|
||||||
|
if (beds) {
|
||||||
|
sheetCache.data = beds;
|
||||||
|
sheetCache.timestamp = now;
|
||||||
|
}
|
||||||
|
return beds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check availability of all accommodation types for the given days.
|
||||||
|
* @param {string|string[]} selectedDays - 'full-week' or array of ISO date strings
|
||||||
|
* @returns {object|null} Map of accommodation type → boolean (available), or null if sheet not configured
|
||||||
|
*/
|
||||||
|
async function checkAvailability(selectedDays) {
|
||||||
|
const beds = await getCachedBeds();
|
||||||
|
if (!beds) return null;
|
||||||
|
|
||||||
|
let dayHeaders;
|
||||||
|
if (selectedDays === 'full-week' || !selectedDays) {
|
||||||
|
dayHeaders = [...DAY_SHEET_HEADERS];
|
||||||
|
} else {
|
||||||
|
dayHeaders = selectedDays.map(id => DAY_ID_TO_SHEET_HEADER[id]).filter(Boolean);
|
||||||
|
if (dayHeaders.length === 0) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availability = {};
|
||||||
|
for (const type of Object.keys(ACCOMMODATION_CRITERIA)) {
|
||||||
|
availability[type] = !!findAvailableBed(beds, type, dayHeaders);
|
||||||
|
}
|
||||||
|
return availability;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find an available bed matching the accommodation criteria.
|
* Find an available bed matching the accommodation criteria.
|
||||||
* A bed is "available" if ALL selected day columns are empty.
|
* A bed is "available" if ALL selected day columns are empty.
|
||||||
|
|
@ -299,6 +344,10 @@ async function assignBooking(guestName, accommodationType, selectedDays) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Invalidate cache after successful assignment
|
||||||
|
sheetCache.data = null;
|
||||||
|
sheetCache.timestamp = 0;
|
||||||
|
|
||||||
console.log(`[Booking Sheet] Assigned ${guestName} to ${bed.venue} Room ${bed.room} (${bed.bedType}) for ${dayHeaders.length} days`);
|
console.log(`[Booking Sheet] Assigned ${guestName} to ${bed.venue} Room ${bed.room} (${bed.bedType}) for ${dayHeaders.length} days`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -313,4 +362,4 @@ async function assignBooking(guestName, accommodationType, selectedDays) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { assignBooking, parseBookingSheet, findAvailableBed, ACCOMMODATION_CRITERIA };
|
module.exports = { assignBooking, parseBookingSheet, findAvailableBed, checkAvailability, ACCOMMODATION_CRITERIA };
|
||||||
|
|
|
||||||
55
index.html
55
index.html
|
|
@ -1091,6 +1091,31 @@
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.accom-card.sold-out {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accom-card.sold-out::after {
|
||||||
|
content: 'Sold Out';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 1.25rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: rgba(255, 0, 110, 0.15);
|
||||||
|
border: 1px solid rgba(255, 0, 110, 0.4);
|
||||||
|
color: var(--accent-magenta);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accom-card.sold-out .accom-price { display: none; }
|
||||||
|
|
||||||
/* Day selection */
|
/* Day selection */
|
||||||
.fullweek-btn {
|
.fullweek-btn {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
@ -2500,6 +2525,9 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
updatePriceSummary();
|
updatePriceSummary();
|
||||||
|
|
||||||
|
// Fetch availability when days change
|
||||||
|
if (selectedDays) fetchAndApplyAvailability();
|
||||||
}
|
}
|
||||||
|
|
||||||
btnFullWeek.addEventListener('click', () => {
|
btnFullWeek.addEventListener('click', () => {
|
||||||
|
|
@ -2518,6 +2546,32 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch and apply accommodation availability
|
||||||
|
async function fetchAndApplyAvailability() {
|
||||||
|
if (!selectedDays) return;
|
||||||
|
const daysParam = isFullWeek ? 'full-week' : selectedDays.join(',');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/accommodation-availability?days=${encodeURIComponent(daysParam)}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const availability = await res.json();
|
||||||
|
document.querySelectorAll('.accom-card[data-type]').forEach(card => {
|
||||||
|
const type = card.dataset.type;
|
||||||
|
if (type in availability && !availability[type]) {
|
||||||
|
card.classList.add('sold-out');
|
||||||
|
card.classList.remove('selected');
|
||||||
|
if (selectedAccom === type) {
|
||||||
|
selectedAccom = null;
|
||||||
|
updatePriceSummary();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
card.classList.remove('sold-out');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Network error — leave all cards enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Accommodation toggle (yes/no) =====
|
// ===== Accommodation toggle (yes/no) =====
|
||||||
const accomCards = document.getElementById('accom-cards');
|
const accomCards = document.getElementById('accom-cards');
|
||||||
document.querySelectorAll('.accom-toggle-btn').forEach(btn => {
|
document.querySelectorAll('.accom-toggle-btn').forEach(btn => {
|
||||||
|
|
@ -2539,6 +2593,7 @@
|
||||||
// Accommodation card selection
|
// Accommodation card selection
|
||||||
document.querySelectorAll('.accom-card').forEach(card => {
|
document.querySelectorAll('.accom-card').forEach(card => {
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
|
if (card.classList.contains('sold-out')) return;
|
||||||
document.querySelectorAll('.accom-card').forEach(c => c.classList.remove('selected'));
|
document.querySelectorAll('.accom-card').forEach(c => c.classList.remove('selected'));
|
||||||
card.classList.add('selected');
|
card.classList.add('selected');
|
||||||
selectedAccom = card.dataset.type;
|
selectedAccom = card.dataset.type;
|
||||||
|
|
|
||||||
56
pay.html
56
pay.html
|
|
@ -379,6 +379,31 @@
|
||||||
|
|
||||||
#hh-section { display: block; }
|
#hh-section { display: block; }
|
||||||
#hh-section.hidden { display: none; }
|
#hh-section.hidden { display: none; }
|
||||||
|
|
||||||
|
.accom-card.sold-out {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accom-card.sold-out::after {
|
||||||
|
content: 'Sold Out';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 1.25rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: rgba(255, 0, 110, 0.15);
|
||||||
|
border: 1px solid rgba(255, 0, 110, 0.4);
|
||||||
|
color: var(--accent-pink);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accom-card.sold-out .accom-price { display: none; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -674,8 +699,33 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch and apply accommodation availability
|
||||||
|
async function fetchAndApplyAvailability() {
|
||||||
|
const daysParam = isFullWeek ? 'full-week' : selectedDays.join(',');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/accommodation-availability?days=${encodeURIComponent(daysParam)}`);
|
||||||
|
if (!res.ok) return; // fail silently — all cards stay enabled
|
||||||
|
const availability = await res.json();
|
||||||
|
document.querySelectorAll('.accom-card[data-type]').forEach(card => {
|
||||||
|
const type = card.dataset.type;
|
||||||
|
if (type in availability && !availability[type]) {
|
||||||
|
card.classList.add('sold-out');
|
||||||
|
card.classList.remove('selected');
|
||||||
|
if (selectedAccom === type) {
|
||||||
|
selectedAccom = null;
|
||||||
|
updatePriceSummary();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
card.classList.remove('sold-out');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Network error — leave all cards enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Continue to accommodation
|
// Continue to accommodation
|
||||||
btnContinueDays.addEventListener('click', () => {
|
btnContinueDays.addEventListener('click', async () => {
|
||||||
stateDays.style.display = 'none';
|
stateDays.style.display = 'none';
|
||||||
statePayment.style.display = 'block';
|
statePayment.style.display = 'block';
|
||||||
|
|
||||||
|
|
@ -707,6 +757,9 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
updatePriceSummary();
|
updatePriceSummary();
|
||||||
|
|
||||||
|
// Fetch availability after UI is set up
|
||||||
|
fetchAndApplyAvailability();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== Accommodation toggle =====
|
// ===== Accommodation toggle =====
|
||||||
|
|
@ -730,6 +783,7 @@
|
||||||
// Accommodation card selection
|
// Accommodation card selection
|
||||||
document.querySelectorAll('.accom-card').forEach(card => {
|
document.querySelectorAll('.accom-card').forEach(card => {
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
|
if (card.classList.contains('sold-out')) return;
|
||||||
document.querySelectorAll('.accom-card').forEach(c => c.classList.remove('selected'));
|
document.querySelectorAll('.accom-card').forEach(c => c.classList.remove('selected'));
|
||||||
card.classList.add('selected');
|
card.classList.add('selected');
|
||||||
selectedAccom = card.dataset.type;
|
selectedAccom = card.dataset.type;
|
||||||
|
|
|
||||||
62
server.js
62
server.js
|
|
@ -5,7 +5,7 @@ const { google } = require('googleapis');
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
const { createMollieClient } = require('@mollie/api-client');
|
const { createMollieClient } = require('@mollie/api-client');
|
||||||
const { assignBooking } = require('./api/booking-sheet');
|
const { assignBooking, checkAvailability } = require('./api/booking-sheet');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
@ -747,6 +747,39 @@ app.post('/api/create-checkout-session', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send overbooking alert email when bed assignment fails after payment
|
||||||
|
const BOOKING_ALERT_EMAIL = process.env.BOOKING_ALERT_EMAIL || 'jeff@jeffemmett.com';
|
||||||
|
async function sendOverbookingAlert(registration, bookingResult) {
|
||||||
|
if (!smtp) return;
|
||||||
|
const accomLabel = ACCOMMODATION_OPTIONS[registration.accommodationType]?.label || registration.accommodationType;
|
||||||
|
try {
|
||||||
|
await smtp.sendMail({
|
||||||
|
from: process.env.EMAIL_FROM || 'WORLDPLAY <newsletter@worldplay.art>',
|
||||||
|
to: BOOKING_ALERT_EMAIL,
|
||||||
|
subject: `⚠️ WORLDPLAY — Bed Assignment Failed: ${registration.firstName} ${registration.lastName}`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
|
||||||
|
<h2 style="color: #dc2626; margin-bottom: 8px;">Accommodation Overbooking Alert</h2>
|
||||||
|
<p>A guest has paid for accommodation but no beds were available for assignment. Manual intervention required.</p>
|
||||||
|
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
|
||||||
|
<tr><td style="padding: 6px 0;"><strong>Guest:</strong></td><td>${registration.firstName} ${registration.lastName}</td></tr>
|
||||||
|
<tr><td style="padding: 6px 0;"><strong>Email:</strong></td><td><a href="mailto:${registration.email}">${registration.email}</a></td></tr>
|
||||||
|
<tr><td style="padding: 6px 0;"><strong>Requested:</strong></td><td>${accomLabel}</td></tr>
|
||||||
|
<tr><td style="padding: 6px 0;"><strong>Days:</strong></td><td>${registration.selectedDays === 'full-week' ? 'Full week' : (registration.selectedDays || []).join(', ')}</td></tr>
|
||||||
|
<tr><td style="padding: 6px 0;"><strong>Reason:</strong></td><td style="color: #dc2626;">${bookingResult.reason || 'No available bed matching criteria'}</td></tr>
|
||||||
|
<tr><td style="padding: 6px 0;"><strong>Paid:</strong></td><td>${registration.paidAt || 'Just now'}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p style="background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 12px; font-size: 0.9rem;">
|
||||||
|
<strong>Action needed:</strong> Contact the guest to discuss alternative accommodation options, or manually assign a bed on the booking sheet.
|
||||||
|
</p>
|
||||||
|
</div>`,
|
||||||
|
});
|
||||||
|
console.log(`Overbooking alert sent to ${BOOKING_ALERT_EMAIL} for ${registration.email}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to send overbooking alert:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mollie webhook — called when payment status changes
|
// Mollie webhook — called when payment status changes
|
||||||
app.post('/api/mollie/webhook', async (req, res) => {
|
app.post('/api/mollie/webhook', async (req, res) => {
|
||||||
if (!mollieClient) {
|
if (!mollieClient) {
|
||||||
|
|
@ -800,6 +833,7 @@ app.post('/api/mollie/webhook', async (req, res) => {
|
||||||
console.log(`Bed assigned for ${registration.email}: ${bookingResult.venue} Room ${bookingResult.room}`);
|
console.log(`Bed assigned for ${registration.email}: ${bookingResult.venue} Room ${bookingResult.room}`);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`Bed assignment failed for ${registration.email}: ${bookingResult.reason}`);
|
console.warn(`Bed assignment failed for ${registration.email}: ${bookingResult.reason}`);
|
||||||
|
sendOverbookingAlert(registration, bookingResult).catch(err => console.error('Alert email error:', err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -956,6 +990,32 @@ app.get('/api/lookup-registration', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Accommodation availability check
|
||||||
|
app.get('/api/accommodation-availability', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const daysParam = (req.query.days || '').trim();
|
||||||
|
let selectedDays;
|
||||||
|
if (!daysParam || daysParam === 'full-week') {
|
||||||
|
selectedDays = 'full-week';
|
||||||
|
} else {
|
||||||
|
selectedDays = daysParam.split(',').filter(d => VALID_DAY_IDS.has(d));
|
||||||
|
if (selectedDays.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No valid days provided' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availability = await checkAvailability(selectedDays);
|
||||||
|
if (!availability) {
|
||||||
|
return res.status(503).json({ error: 'Booking sheet not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(availability);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Availability check error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to check availability' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Complete payment page
|
// Complete payment page
|
||||||
app.get('/pay', (req, res) => {
|
app.get('/pay', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, 'pay.html'));
|
res.sendFile(path.join(__dirname, 'pay.html'));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue