worldplay-website/server.js

1086 lines
48 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const express = require('express');
const fs = require('fs').promises;
const path = require('path');
const { google } = require('googleapis');
const nodemailer = require('nodemailer');
const { Pool } = require('pg');
const { createMollieClient } = require('@mollie/api-client');
const { assignBooking, checkAvailability } = require('./api/booking-sheet');
const app = express();
const PORT = process.env.PORT || 3000;
const DATA_DIR = process.env.DATA_DIR || './data';
const REGISTRATIONS_FILE = path.join(DATA_DIR, 'registrations.json');
// Initialize SMTP transport (Mailcow)
const smtp = process.env.SMTP_PASS ? nodemailer.createTransport({
host: process.env.SMTP_HOST || 'mail.rmail.online',
port: parseInt(process.env.SMTP_PORT || '587'),
secure: false,
auth: {
user: process.env.SMTP_USER || 'newsletter@worldplay.art',
pass: process.env.SMTP_PASS,
},
tls: { rejectUnauthorized: false },
}) : null;
// Listmonk PostgreSQL configuration for newsletter management
const LISTMONK_LIST_ID = parseInt(process.env.LISTMONK_LIST_ID) || 20; // WORLDPLAY list
const listmonkPool = process.env.LISTMONK_DB_HOST ? new Pool({
host: process.env.LISTMONK_DB_HOST || 'listmonk-db',
port: parseInt(process.env.LISTMONK_DB_PORT) || 5432,
database: process.env.LISTMONK_DB_NAME || 'listmonk',
user: process.env.LISTMONK_DB_USER || 'listmonk',
password: process.env.LISTMONK_DB_PASS || 'listmonk_secure_2025',
}) : null;
// Initialize Mollie payment client
const mollieClient = process.env.MOLLIE_API_KEY ? createMollieClient({ apiKey: process.env.MOLLIE_API_KEY }) : null;
const BASE_URL = process.env.BASE_URL || 'https://worldplay.art';
const EMAIL_TEAM = process.env.EMAIL_TEAM || 'hello@worldplay.art';
// Pricing configuration
const PARTICIPATION_FEE = 50;
const PROCESSING_FEE_RATE = 0.02; // 2%
const ACCOMMODATION_OPTIONS = {
'ch-shared': { label: 'Commons Hub — Shared Room', price: 275 },
'ch-double': { label: 'Commons Hub — Double Room', price: 350 },
'hh-living': { label: 'Herrnhof Villa — Living Room', price: 315 },
'hh-triple': { label: 'Herrnhof Villa — Triple Room', price: 350 },
'hh-twin': { label: 'Herrnhof Villa — Twin Room', price: 420 },
'hh-single': { label: 'Herrnhof Villa — Single Room', price: 665 },
'hh-couple': { label: 'Herrnhof Villa — Couple Room', price: 700 },
};
// Per-day attendance and per-night accommodation pricing
const EVENT_DAYS = [
{ id: '2026-06-07', label: 'Jun 7' },
{ id: '2026-06-08', label: 'Jun 8' },
{ id: '2026-06-09', label: 'Jun 9' },
{ id: '2026-06-10', label: 'Jun 10' },
{ id: '2026-06-11', label: 'Jun 11' },
{ id: '2026-06-12', label: 'Jun 12' },
{ id: '2026-06-13', label: 'Jun 13' },
];
const VALID_DAY_IDS = new Set(EVENT_DAYS.map(d => d.id));
const PARTICIPATION_FEE_PERDAY = 10;
const ACCOMMODATION_PERNIGHT = {
'ch-shared': { label: 'Commons Hub — Shared Room', nightlyRate: 40 },
'ch-double': { label: 'Commons Hub — Double Room', nightlyRate: 50 },
};
const FULLWEEK_ONLY_ACCOM = ['hh-living', 'hh-triple', 'hh-twin', 'hh-single', 'hh-couple'];
/**
* Central pricing logic.
* @param {string|string[]} selectedDays - 'full-week' or array of date strings
* @param {string|null} accommodationType - e.g. 'ch-shared', 'hh-single', or null
* @returns {{ isFullWeek, numDays, participationFee, accomPrice, accomLabel, subtotal, processingFee, total }}
*/
function calculatePrice(selectedDays, accommodationType) {
const isFullWeek = selectedDays === 'full-week';
const numDays = isFullWeek ? 7 : selectedDays.length;
const participationFee = isFullWeek ? PARTICIPATION_FEE : numDays * PARTICIPATION_FEE_PERDAY;
let accomPrice = 0;
let accomLabel = 'Participation only';
if (accommodationType) {
if (isFullWeek && ACCOMMODATION_OPTIONS[accommodationType]) {
accomPrice = ACCOMMODATION_OPTIONS[accommodationType].price;
accomLabel = ACCOMMODATION_OPTIONS[accommodationType].label;
} else if (!isFullWeek && ACCOMMODATION_PERNIGHT[accommodationType]) {
accomPrice = ACCOMMODATION_PERNIGHT[accommodationType].nightlyRate * numDays;
accomLabel = ACCOMMODATION_PERNIGHT[accommodationType].label;
}
}
const subtotal = participationFee + accomPrice;
const processingFee = Math.round(subtotal * PROCESSING_FEE_RATE * 100) / 100;
const total = subtotal + processingFee;
return { isFullWeek, numDays, participationFee, accomPrice, accomLabel, subtotal, processingFee, total };
}
// Google Sheets configuration
const GOOGLE_SHEET_ID = process.env.GOOGLE_SHEET_ID;
function loadGoogleCredentials() {
// Prefer file-based credentials (cleaner for Docker)
const filePath = process.env.GOOGLE_SERVICE_ACCOUNT_FILE;
if (filePath) {
try {
return JSON.parse(require('fs').readFileSync(filePath, 'utf8'));
} catch (err) {
console.error('Failed to read Google credentials file:', err.message);
return null;
}
}
// Fall back to JSON string in env var
if (process.env.GOOGLE_CREDENTIALS) {
try { return JSON.parse(process.env.GOOGLE_CREDENTIALS); }
catch { console.error('Failed to parse GOOGLE_CREDENTIALS env var'); return null; }
}
return null;
}
const GOOGLE_CREDENTIALS = loadGoogleCredentials();
// Initialize Google Sheets API
let sheets = null;
if (GOOGLE_CREDENTIALS && GOOGLE_SHEET_ID) {
const auth = new google.auth.GoogleAuth({
credentials: GOOGLE_CREDENTIALS,
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
});
sheets = google.sheets({ version: 'v4', auth });
console.log('Google Sheets integration enabled');
}
// Middleware
app.use(express.json());
app.use(express.static('.'));
// Ensure data directory exists
async function ensureDataDir() {
try {
await fs.mkdir(DATA_DIR, { recursive: true });
try {
await fs.access(REGISTRATIONS_FILE);
} catch {
await fs.writeFile(REGISTRATIONS_FILE, '[]');
}
} catch (error) {
console.error('Error creating data directory:', error);
}
}
// Load registrations
async function loadRegistrations() {
try {
const data = await fs.readFile(REGISTRATIONS_FILE, 'utf8');
return JSON.parse(data);
} catch {
return [];
}
}
// Save registrations
async function saveRegistrations(registrations) {
await fs.writeFile(REGISTRATIONS_FILE, JSON.stringify(registrations, null, 2));
}
// Append registration to Google Sheet
async function appendToGoogleSheet(registration) {
if (!sheets || !GOOGLE_SHEET_ID) {
console.log('Google Sheets not configured, skipping...');
return;
}
try {
const values = [[
registration.registeredAt,
registration.firstName,
registration.lastName,
registration.email,
registration.location,
registration.role,
Array.isArray(registration.interests) ? registration.interests.join(', ') : registration.interests,
Array.isArray(registration.contribute) ? registration.contribute.join(', ') : registration.contribute,
registration.message,
registration.newsletter ? 'Yes' : 'No',
registration.id,
registration.paymentStatus || '',
registration.molliePaymentId || '',
registration.accommodationType ? (ACCOMMODATION_OPTIONS[registration.accommodationType]?.label || registration.accommodationType) : ''
]];
await sheets.spreadsheets.values.append({
spreadsheetId: GOOGLE_SHEET_ID,
range: 'Registrations!A:N',
valueInputOption: 'USER_ENTERED',
insertDataOption: 'INSERT_ROWS',
requestBody: { values },
});
console.log(`Added registration to Google Sheet: ${registration.email}`);
} catch (error) {
console.error('Error appending to Google Sheet:', error.message);
}
}
// Send confirmation email
async function sendConfirmationEmail(registration) {
if (!smtp) {
console.log('SMTP not configured, skipping email...');
return;
}
try {
const interestsText = Array.isArray(registration.interests) && registration.interests.length > 0
? registration.interests.map(i => {
const labels = {
'reality': '🎭 Playing with Reality',
'fiction': '✒️ Science Fictions',
'worlding': '🛠 Guerrilla Futuring',
'games': '🎲 Game Commons',
'infrastructure': '🌱 Infrastructures'
};
return labels[i] || i;
}).join(', ')
: 'Not specified';
const roleLabels = {
'writer': 'Sci-Fi Writer / Storyteller',
'gamemaker': 'Game Designer / Maker',
'artist': 'Artist / Performer',
'larper': 'LARPer / Roleplayer',
'economist': 'Weird Economist / Commons Activist',
'futurist': 'Futurist / Speculative Designer',
'academic': 'Academic / Researcher',
'tech': 'Technologist / Developer',
'curious': 'Curious Explorer',
'other': 'Other'
};
const contributeLabels = {
'session': 'Propose a session',
'workshop': 'Host a workshop',
'game': 'Bring a game to play or playtest',
'larp': 'Run a LARP or participatory format',
'project': 'Co-create a project or publication',
'other': 'Contribute in another way',
'attend': 'Attend only',
'unsure': 'Not sure yet'
};
const contributeText = Array.isArray(registration.contribute) && registration.contribute.length > 0
? registration.contribute.map(c => contributeLabels[c] || c).join(', ')
: 'Not specified';
await smtp.sendMail({
from: process.env.EMAIL_FROM || 'WORLDPLAY <newsletter@worldplay.art>',
to: registration.email,
subject: '🎭 Welcome to WORLDPLAY Registration Confirmed',
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #0a0a0f; color: #f0f0f5; padding: 40px 20px; margin: 0;">
<div style="max-width: 600px; margin: 0 auto;">
<div style="text-align: center; margin-bottom: 40px;">
<h1 style="font-family: monospace; font-size: 32px; background: linear-gradient(135deg, #9d4edd 0%, #ff006e 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; margin: 0;">WORLDPLAY</h1>
<p style="color: #a0a0b0; font-style: italic; margin-top: 8px; font-size: 18px; font-weight: 600;">To be Defined</p>
</div>
<div style="background-color: #12121a; border: 1px solid rgba(157, 78, 221, 0.3); border-radius: 12px; padding: 32px; margin-bottom: 24px;">
<h2 style="color: #00d9ff; margin-top: 0; font-size: 20px;">Welcome, ${registration.firstName}! 🌟</h2>
<p style="color: #a0a0b0; line-height: 1.7;">
Thank you for registering your interest in <strong style="color: #f0f0f5;">WORLDPLAY</strong>, a pop-up hub and peer-to-peer network for people interested in fiction, design, performance and play as ways to prefigure and prehearse radical (e.g., postcapitalist, commons-based, degrowth, ecofeminist, decolonial, multispecies) futures.
</p>
<p style="color: #a0a0b0; line-height: 1.7;">
We will be in touch with next steps as we finalize the programme. Over the next few weeks, we will send details to finalize your registration:
</p>
<ul style="color: #a0a0b0; line-height: 1.9; padding-left: 20px;">
<li>programme outline + how to propose sessions</li>
<li>available accommodation and pricing</li>
<li>other logistics (getting there, food, etc.)</li>
<li>payment options and any support possibilities</li>
<li>key dates</li>
</ul>
</div>
<div style="background-color: #12121a; border: 1px solid rgba(157, 78, 221, 0.3); border-radius: 12px; padding: 32px; margin-bottom: 24px;">
<h3 style="color: #9d4edd; margin-top: 0; font-size: 16px; text-transform: uppercase; letter-spacing: 0.1em;">Your Registration Details</h3>
<table style="width: 100%; color: #a0a0b0; font-size: 14px;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Name:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${registration.firstName} ${registration.lastName}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Email:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${registration.email}</td>
</tr>
${registration.location ? `
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Location:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${registration.location}</td>
</tr>
` : ''}
${registration.role ? `
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Role:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${roleLabels[registration.role] || registration.role}</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Interests:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${interestsText}</td>
</tr>
${(Array.isArray(registration.contribute) && registration.contribute.length > 0) || registration.contribute ? `
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Contribution:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${contributeText}</td>
</tr>
` : ''}
</table>
</div>
<div style="background-color: #12121a; border: 1px solid rgba(157, 78, 221, 0.3); border-radius: 12px; padding: 32px; margin-bottom: 24px;">
<h3 style="color: #9d4edd; margin-top: 0; font-size: 16px; text-transform: uppercase; letter-spacing: 0.1em;">Event Details</h3>
<p style="color: #a0a0b0; margin: 0;">
📅 <strong style="color: #f0f0f5;">June 713, 2026</strong><br>
📍 <strong style="color: #f0f0f5;">Commons Hub</strong>, Hirschwang an der Rax, Austria<br>
🏔️ Austrian Alps, ~1.5 hours from Vienna by train
</p>
</div>
<div style="text-align: center; padding: 24px 0; border-top: 1px solid rgba(157, 78, 221, 0.2);">
<p style="color: #a0a0b0; font-size: 14px; margin: 0;">
Questions? Reply to this email or visit <a href="https://worldplay.art" style="color: #00d9ff;">worldplay.art</a>
</p>
</div>
</div>
</body>
</html>
`,
});
console.log(`Confirmation email sent to: ${registration.email}`);
} catch (error) {
console.error('Error sending confirmation email:', error.message);
}
}
// Add subscriber to Listmonk for newsletter management (direct DB access)
async function addToListmonk(registration) {
if (!registration.newsletter) {
console.log('Newsletter not opted in, skipping Listmonk...');
return;
}
if (!listmonkPool) {
console.log('Listmonk database not configured, skipping...');
return;
}
const client = await listmonkPool.connect();
try {
const fullName = `${registration.firstName} ${registration.lastName}`;
const attribs = {
worldplay: {
firstName: registration.firstName,
lastName: registration.lastName,
location: registration.location || '',
role: registration.role || '',
interests: registration.interests || [],
contribute: registration.contribute || [],
registrationId: registration.id,
registeredAt: registration.registeredAt
}
};
// Check if subscriber exists
const existingResult = await client.query(
'SELECT id, attribs FROM subscribers WHERE email = $1',
[registration.email]
);
let subscriberId;
if (existingResult.rows.length > 0) {
// Subscriber exists - update attributes and get ID
subscriberId = existingResult.rows[0].id;
const existingAttribs = existingResult.rows[0].attribs || {};
const mergedAttribs = { ...existingAttribs, ...attribs };
await client.query(
'UPDATE subscribers SET name = $1, attribs = $2, updated_at = NOW() WHERE id = $3',
[fullName, JSON.stringify(mergedAttribs), subscriberId]
);
console.log(`Updated existing Listmonk subscriber: ${registration.email} (ID: ${subscriberId})`);
} else {
// Create new subscriber
const insertResult = await client.query(
`INSERT INTO subscribers (uuid, email, name, status, attribs, created_at, updated_at)
VALUES (gen_random_uuid(), $1, $2, 'enabled', $3, NOW(), NOW())
RETURNING id`,
[registration.email, fullName, JSON.stringify(attribs)]
);
subscriberId = insertResult.rows[0].id;
console.log(`Created new Listmonk subscriber: ${registration.email} (ID: ${subscriberId})`);
}
// Add to WORLDPLAY list if not already a member
await client.query(
`INSERT INTO subscriber_lists (subscriber_id, list_id, status, created_at, updated_at)
VALUES ($1, $2, 'confirmed', NOW(), NOW())
ON CONFLICT (subscriber_id, list_id) DO UPDATE SET status = 'confirmed', updated_at = NOW()`,
[subscriberId, LISTMONK_LIST_ID]
);
console.log(`Added to Listmonk WORLDPLAY list: ${registration.email}`);
} catch (error) {
console.error('Error adding to Listmonk:', error.message);
} finally {
client.release();
}
}
// Update payment status on Google Sheet (find row by registration ID in column K)
async function updateRegistrationPaymentStatus(registrationId, paymentStatus, molliePaymentId, accommodationType) {
if (!sheets || !GOOGLE_SHEET_ID) return;
try {
// Read column K to find the row with matching registration ID
const response = await sheets.spreadsheets.values.get({
spreadsheetId: GOOGLE_SHEET_ID,
range: 'Registrations!K:K',
});
const rows = response.data.values || [];
let rowIndex = -1;
for (let i = 0; i < rows.length; i++) {
if (rows[i][0] === registrationId) {
rowIndex = i + 1; // 1-indexed
break;
}
}
if (rowIndex === -1) {
console.error(`Registration ${registrationId} not found in sheet`);
return;
}
// Update columns L, M, N
await sheets.spreadsheets.values.batchUpdate({
spreadsheetId: GOOGLE_SHEET_ID,
resource: {
valueInputOption: 'USER_ENTERED',
data: [
{ range: `Registrations!L${rowIndex}`, values: [[paymentStatus]] },
{ range: `Registrations!M${rowIndex}`, values: [[molliePaymentId || '']] },
{ range: `Registrations!N${rowIndex}`, values: [[accommodationType ? (ACCOMMODATION_OPTIONS[accommodationType]?.label || accommodationType) : '']] },
],
},
});
console.log(`Updated sheet payment status for ${registrationId}: ${paymentStatus}`);
} catch (error) {
console.error('Error updating sheet payment status:', error.message);
}
}
// Send payment confirmation email with booking details
async function sendPaymentConfirmationEmail(registration) {
if (!smtp) {
console.log('SMTP not configured, skipping payment confirmation email...');
return;
}
try {
const selectedDays = registration.selectedDays || 'full-week';
const pricing = calculatePrice(selectedDays, registration.accommodationType);
const participationLabel = pricing.isFullWeek
? 'Participation fee (full week)'
: `Participation fee (${pricing.numDays} day${pricing.numDays > 1 ? 's' : ''})`;
const accomNightsLabel = pricing.isFullWeek
? `${pricing.accomLabel} (7 nights)`
: `${pricing.accomLabel} (${pricing.numDays} night${pricing.numDays > 1 ? 's' : ''} ×${pricing.isFullWeek ? '' : (ACCOMMODATION_PERNIGHT[registration.accommodationType]?.nightlyRate || '')})`;
const daysAttendingText = pricing.isFullWeek
? 'Full week — June 713'
: EVENT_DAYS.filter(d => selectedDays.includes(d.id)).map(d => d.label).join(', ');
const bookingInfo = registration.bookingResult?.success
? `<tr><td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Room:</strong></td><td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${registration.bookingResult.venue} — Room ${registration.bookingResult.room} (${registration.bookingResult.bedType})</td></tr>`
: '';
await smtp.sendMail({
from: process.env.EMAIL_FROM || 'WORLDPLAY <newsletter@worldplay.art>',
to: registration.email,
cc: EMAIL_TEAM,
subject: 'WORLDPLAY — Payment Confirmed & Booking Details',
html: `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #0a0a0f; color: #f0f0f5; padding: 40px 20px; margin: 0;">
<div style="max-width: 600px; margin: 0 auto;">
<div style="text-align: center; margin-bottom: 40px;">
<h1 style="font-family: monospace; font-size: 32px; background: linear-gradient(135deg, #9d4edd 0%, #ff006e 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; margin: 0;">WORLDPLAY</h1>
<p style="color: #a0a0b0; font-style: italic; margin-top: 8px; font-size: 18px; font-weight: 600;">To be Defined</p>
</div>
<div style="background-color: #12121a; border: 1px solid rgba(0, 255, 136, 0.3); border-radius: 12px; padding: 32px; margin-bottom: 24px;">
<h2 style="color: #00ff88; margin-top: 0; font-size: 20px;">Payment Confirmed!</h2>
<p style="color: #a0a0b0; line-height: 1.7;">
Thank you, <strong style="color: #f0f0f5;">${registration.firstName}</strong>! Your registration and payment for WORLDPLAY have been confirmed. You're officially in.
</p>
<p style="color: #a0a0b0; line-height: 1.7; margin-top: 8px;">
<strong style="color: #f0f0f5;">Attending:</strong> ${daysAttendingText}
</p>
</div>
<div style="background-color: #12121a; border: 1px solid rgba(157, 78, 221, 0.3); border-radius: 12px; padding: 32px; margin-bottom: 24px;">
<h3 style="color: #9d4edd; margin-top: 0; font-size: 16px; text-transform: uppercase; letter-spacing: 0.1em;">Payment Breakdown</h3>
<table style="width: 100%; color: #a0a0b0; font-size: 14px;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${participationLabel}</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1); text-align: right;">&euro;${pricing.participationFee.toFixed(2)}</td>
</tr>
${pricing.accomPrice > 0 ? `
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${accomNightsLabel}</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1); text-align: right;">&euro;${pricing.accomPrice.toFixed(2)}</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">Processing fee (2%)</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1); text-align: right;">&euro;${pricing.processingFee.toFixed(2)}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><strong style="color: #f0f0f5;">Total paid</strong></td>
<td style="padding: 8px 0; text-align: right;"><strong style="color: #00ff88;">&euro;${pricing.total.toFixed(2)}</strong></td>
</tr>
</table>
</div>
${bookingInfo ? `
<div style="background-color: #12121a; border: 1px solid rgba(157, 78, 221, 0.3); border-radius: 12px; padding: 32px; margin-bottom: 24px;">
<h3 style="color: #9d4edd; margin-top: 0; font-size: 16px; text-transform: uppercase; letter-spacing: 0.1em;">Accommodation</h3>
<table style="width: 100%; color: #a0a0b0; font-size: 14px;">
${bookingInfo}
</table>
</div>
` : ''}
<div style="background-color: #1a1520; border: 1px solid rgba(255, 149, 0, 0.3); border-radius: 12px; padding: 24px; margin-bottom: 24px;">
<p style="color: #ff9500; margin: 0 0 8px; font-weight: 600;">About Food</p>
<p style="color: #a0a0b0; margin: 0; font-size: 14px; line-height: 1.6;">
Food is not included in this payment. Expect approximately &euro;1520 per person per day. We'll be in touch about food choices and dietary preferences before the event.
</p>
</div>
<div style="background-color: #12121a; border: 1px solid rgba(157, 78, 221, 0.3); border-radius: 12px; padding: 32px; margin-bottom: 24px;">
<h3 style="color: #9d4edd; margin-top: 0; font-size: 16px; text-transform: uppercase; letter-spacing: 0.1em;">Event Details</h3>
<p style="color: #a0a0b0; margin: 0;">
📅 <strong style="color: #f0f0f5;">June 713, 2026</strong><br>
📍 <strong style="color: #f0f0f5;">Commons Hub</strong>, Hirschwang an der Rax, Austria<br>
🏔️ Austrian Alps, ~1.5 hours from Vienna by train
</p>
</div>
<div style="text-align: center; padding: 24px 0; border-top: 1px solid rgba(157, 78, 221, 0.2);">
<p style="color: #a0a0b0; font-size: 14px; margin: 0;">
Questions? Reply to this email or contact <a href="mailto:${EMAIL_TEAM}" style="color: #00d9ff;">${EMAIL_TEAM}</a>
</p>
</div>
</div>
</body>
</html>
`,
});
console.log(`Payment confirmation email sent to: ${registration.email}`);
} catch (error) {
console.error('Error sending payment confirmation email:', error.message);
}
}
// Registration endpoint (Step 1 — saves registration with awaiting_payment status)
app.post('/api/register', async (req, res) => {
try {
const { firstName, lastName, email, location, role, otherRole, interests, contribute, message, newsletter } = req.body;
// Validation
if (!firstName || !lastName || !email) {
return res.status(400).json({ error: 'First name, last name, and email are required' });
}
if (!email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
return res.status(400).json({ error: 'Please provide a valid email address' });
}
// Load existing registrations
const registrations = await loadRegistrations();
// Check for duplicate email
if (registrations.some(r => r.email.toLowerCase() === email.toLowerCase())) {
return res.status(409).json({ error: 'This email is already registered', code: 'DUPLICATE_EMAIL' });
}
// Create new registration with awaiting_payment status
const registration = {
id: Date.now().toString(36) + Math.random().toString(36).substr(2),
firstName: firstName.trim(),
lastName: lastName.trim(),
email: email.toLowerCase().trim(),
location: location?.trim() || '',
role: role === 'other' && otherRole ? `Other: ${otherRole.trim()}` : (role || ''),
interests: interests || [],
contribute: contribute || [],
message: message?.trim() || '',
newsletter: newsletter === 'yes',
paymentStatus: 'awaiting_payment',
registeredAt: new Date().toISOString(),
ipAddress: req.ip || req.connection.remoteAddress
};
// Save registration locally
registrations.push(registration);
await saveRegistrations(registrations);
console.log(`New registration (awaiting payment): ${registration.firstName} ${registration.lastName} <${registration.email}>`);
// Add to Google Sheet (async, don't block response)
appendToGoogleSheet(registration).catch(err => console.error('Sheet error:', err));
// NOTE: Confirmation email and Listmonk subscription happen AFTER payment
res.json({
success: true,
message: 'Registration saved',
id: registration.id
});
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({ error: 'An error occurred. Please try again.' });
}
});
// Create Mollie checkout session (Step 2)
app.post('/api/create-checkout-session', async (req, res) => {
if (!mollieClient) {
return res.status(503).json({ error: 'Payment system not configured' });
}
try {
const { registrationId, accommodationType, selectedDays } = req.body;
if (!registrationId) {
return res.status(400).json({ error: 'Registration ID is required' });
}
// Validate selectedDays
if (!selectedDays) {
return res.status(400).json({ error: 'selectedDays is required' });
}
if (selectedDays !== 'full-week') {
if (!Array.isArray(selectedDays) || selectedDays.length === 0) {
return res.status(400).json({ error: 'selectedDays must be "full-week" or a non-empty array of dates' });
}
for (const d of selectedDays) {
if (!VALID_DAY_IDS.has(d)) {
return res.status(400).json({ error: `Invalid day: ${d}` });
}
}
}
const isFullWeek = selectedDays === 'full-week';
// Reject villa types for partial-week
if (!isFullWeek && accommodationType && FULLWEEK_ONLY_ACCOM.includes(accommodationType)) {
return res.status(400).json({ error: 'Herrnhof Villa accommodation is only available for full-week attendees' });
}
// Find registration
const registrations = await loadRegistrations();
const registration = registrations.find(r => r.id === registrationId);
if (!registration) {
return res.status(404).json({ error: 'Registration not found' });
}
if (registration.paymentStatus === 'paid') {
return res.status(400).json({ error: 'This registration has already been paid' });
}
// Calculate price
const pricing = calculatePrice(selectedDays, accommodationType);
// Save choices to registration
registration.accommodationType = accommodationType || null;
registration.selectedDays = selectedDays;
registration.paymentAmount = pricing.total;
await saveRegistrations(registrations);
// Create Mollie payment
const payment = await mollieClient.payments.create({
amount: {
currency: 'EUR',
value: pricing.total.toFixed(2),
},
description: `WORLDPLAY 2026 — ${registration.firstName} ${registration.lastName}${accommodationType ? ` + ${pricing.accomLabel}` : ''}`,
redirectUrl: `${BASE_URL}/payment-success.html?id=${registrationId}`,
webhookUrl: `${BASE_URL}/api/mollie/webhook`,
metadata: {
registrationId,
accommodationType: accommodationType || 'none',
selectedDays: JSON.stringify(selectedDays),
},
});
// Save Mollie payment ID
registration.molliePaymentId = payment.id;
await saveRegistrations(registrations);
// Update sheet with pending payment info
updateRegistrationPaymentStatus(registrationId, 'awaiting_payment', payment.id, accommodationType).catch(err => console.error('Sheet update error:', err));
console.log(`Mollie checkout created for ${registration.email}: ${payment.id} (€${pricing.total.toFixed(2)})`);
res.json({
success: true,
checkoutUrl: payment.getCheckoutUrl(),
});
} catch (error) {
console.error('Checkout session error:', error);
res.status(500).json({ error: 'Failed to create payment session' });
}
});
// 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
app.post('/api/mollie/webhook', async (req, res) => {
if (!mollieClient) {
return res.status(503).send('Payment system not configured');
}
try {
const paymentId = req.body.id;
if (!paymentId) {
return res.status(400).send('Missing payment ID');
}
const payment = await mollieClient.payments.get(paymentId);
const registrationId = payment.metadata?.registrationId;
if (!registrationId) {
console.error(`Mollie webhook: no registrationId in metadata for ${paymentId}`);
return res.status(200).send('OK');
}
const registrations = await loadRegistrations();
const registration = registrations.find(r => r.id === registrationId);
if (!registration) {
console.error(`Mollie webhook: registration ${registrationId} not found`);
return res.status(200).send('OK');
}
console.log(`Mollie webhook: payment ${paymentId} status=${payment.status} for ${registration.email}`);
if (payment.status === 'paid' && registration.paymentStatus !== 'paid') {
// Update registration
registration.paymentStatus = 'paid';
registration.molliePaymentId = paymentId;
registration.paidAt = new Date().toISOString();
await saveRegistrations(registrations);
// Update Google Sheet
updateRegistrationPaymentStatus(
registrationId, 'paid', paymentId, registration.accommodationType
).catch(err => console.error('Sheet update error:', err));
// Assign bed on booking sheet if accommodation was selected
if (registration.accommodationType) {
const guestName = `${registration.firstName} ${registration.lastName}`;
const bookingResult = await assignBooking(guestName, registration.accommodationType, registration.selectedDays || 'full-week');
registration.bookingResult = bookingResult;
await saveRegistrations(registrations);
if (bookingResult.success) {
console.log(`Bed assigned for ${registration.email}: ${bookingResult.venue} Room ${bookingResult.room}`);
} else {
console.warn(`Bed assignment failed for ${registration.email}: ${bookingResult.reason}`);
sendOverbookingAlert(registration, bookingResult).catch(err => console.error('Alert email error:', err));
}
}
// Send payment confirmation email
sendPaymentConfirmationEmail(registration).catch(err => console.error('Payment email error:', err));
// Add to Listmonk (now that payment is confirmed)
addToListmonk(registration).catch(err => console.error('Listmonk error:', err));
} else if (payment.status === 'failed' || payment.status === 'canceled' || payment.status === 'expired') {
registration.paymentStatus = payment.status;
await saveRegistrations(registrations);
updateRegistrationPaymentStatus(
registrationId, payment.status, paymentId, registration.accommodationType
).catch(err => console.error('Sheet update error:', err));
}
res.status(200).send('OK');
} catch (error) {
console.error('Mollie webhook error:', error);
res.status(200).send('OK'); // Always return 200 to Mollie
}
});
// Payment status polling endpoint (for frontend)
app.get('/api/payment-status', async (req, res) => {
try {
const { id } = req.query;
if (!id) {
return res.status(400).json({ error: 'Registration ID required' });
}
const registrations = await loadRegistrations();
const registration = registrations.find(r => r.id === id);
if (!registration) {
return res.status(404).json({ error: 'Registration not found' });
}
const accomType = registration.accommodationType;
const selectedDays = registration.selectedDays || 'full-week';
const pricing = calculatePrice(selectedDays, accomType);
res.json({
paymentStatus: registration.paymentStatus,
firstName: registration.firstName,
lastName: registration.lastName,
email: registration.email,
accommodationType: accomType || null,
accommodationLabel: accomType ? pricing.accomLabel : null,
selectedDays,
isFullWeek: pricing.isFullWeek,
numDays: pricing.numDays,
participationFee: pricing.participationFee,
accommodationPrice: pricing.accomPrice,
processingFee: pricing.processingFee,
total: pricing.total,
bookingResult: registration.bookingResult || null,
});
} catch (error) {
console.error('Payment status error:', error);
res.status(500).json({ error: 'Failed to check payment status' });
}
});
// Admin endpoint to view registrations (protected by simple token)
app.get('/api/registrations', async (req, res) => {
const token = req.headers['x-admin-token'] || req.query.token;
const adminToken = process.env.ADMIN_TOKEN || 'worldplay-admin-2026';
if (token !== adminToken) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const registrations = await loadRegistrations();
res.json({
count: registrations.length,
registrations: registrations.map(r => ({
...r,
ipAddress: undefined // Don't expose IP in admin view
}))
});
} catch (error) {
res.status(500).json({ error: 'Failed to load registrations' });
}
});
// Export registrations as CSV
app.get('/api/registrations/export', async (req, res) => {
const token = req.headers['x-admin-token'] || req.query.token;
const adminToken = process.env.ADMIN_TOKEN || 'worldplay-admin-2026';
if (token !== adminToken) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const registrations = await loadRegistrations();
const headers = ['ID', 'First Name', 'Last Name', 'Email', 'Location', 'Role', 'Interests', 'Contribute', 'Message', 'Newsletter', 'Registered At'];
const rows = registrations.map(r => [
r.id,
r.firstName,
r.lastName,
r.email,
r.location,
r.role,
Array.isArray(r.interests) ? r.interests.join('; ') : r.interests,
Array.isArray(r.contribute) ? r.contribute.join('; ') : r.contribute,
r.message.replace(/"/g, '""'),
r.newsletter ? 'Yes' : 'No',
r.registeredAt
]);
const csv = [
headers.join(','),
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
].join('\n');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=worldplay-registrations.csv');
res.send(csv);
} catch (error) {
res.status(500).json({ error: 'Failed to export registrations' });
}
});
// Lookup registration by email (for /pay page)
app.get('/api/lookup-registration', async (req, res) => {
try {
const email = (req.query.email || '').toLowerCase().trim();
if (!email) {
return res.status(400).json({ error: 'Email is required' });
}
const registrations = await loadRegistrations();
const registration = registrations.find(r => r.email.toLowerCase() === email);
if (!registration) {
return res.status(404).json({ error: 'No registration found for this email address' });
}
res.json({
id: registration.id,
firstName: registration.firstName,
lastName: registration.lastName,
paymentStatus: registration.paymentStatus,
});
} catch (error) {
console.error('Lookup registration error:', error);
res.status(500).json({ error: 'Failed to look up registration' });
}
});
// 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
app.get('/pay', (req, res) => {
res.sendFile(path.join(__dirname, 'pay.html'));
});
// Financial transparency page
app.get('/financial-transparency', (req, res) => {
res.sendFile(path.join(__dirname, 'financial-transparency.html'));
});
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Stats endpoint
app.get('/api/stats', async (req, res) => {
try {
const registrations = await loadRegistrations();
const stats = {
totalRegistrations: registrations.length,
byRole: {},
byInterest: {},
byContribute: {}
};
registrations.forEach(r => {
// Count by role
if (r.role) {
stats.byRole[r.role] = (stats.byRole[r.role] || 0) + 1;
}
// Count by interest
if (Array.isArray(r.interests)) {
r.interests.forEach(interest => {
stats.byInterest[interest] = (stats.byInterest[interest] || 0) + 1;
});
}
// Count by contribute
if (Array.isArray(r.contribute)) {
r.contribute.forEach(contrib => {
stats.byContribute[contrib] = (stats.byContribute[contrib] || 0) + 1;
});
} else if (r.contribute) {
stats.byContribute[r.contribute] = (stats.byContribute[r.contribute] || 0) + 1;
}
});
res.json(stats);
} catch (error) {
res.status(500).json({ error: 'Failed to calculate stats' });
}
});
// Start server
ensureDataDir().then(() => {
app.listen(PORT, '0.0.0.0', () => {
console.log(`WORLDPLAY server running on port ${PORT}`);
console.log(`Admin token: ${process.env.ADMIN_TOKEN || 'worldplay-admin-2026'}`);
console.log(`Google Sheets: ${sheets ? 'enabled' : 'disabled'}`);
console.log(`Email notifications: ${smtp ? 'enabled (Mailcow SMTP)' : 'disabled (no SMTP_PASS)'}`);
console.log(`Listmonk newsletter sync: ${listmonkPool ? 'enabled' : 'disabled'} (list ID: ${LISTMONK_LIST_ID})`);
console.log(`Mollie payments: ${mollieClient ? 'enabled' : 'disabled (no MOLLIE_API_KEY)'}`);
});
});