Compare commits

...

1 Commits
main ... dev

Author SHA1 Message Date
Jeff Emmett b93d61fafb 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>
2026-04-15 11:40:32 -04:00
4 changed files with 221 additions and 3 deletions

View File

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

View File

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

View File

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

View File

@ -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'));