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 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 || 'noreply@jeffemmett.com', pass: process.env.SMTP_PASS, }, }) : 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; // Google Sheets configuration const GOOGLE_SHEET_ID = process.env.GOOGLE_SHEET_ID; const GOOGLE_CREDENTIALS = process.env.GOOGLE_CREDENTIALS ? JSON.parse(process.env.GOOGLE_CREDENTIALS) : null; // 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 ]]; await sheets.spreadsheets.values.append({ spreadsheetId: GOOGLE_SHEET_ID, range: 'Registrations!A:K', 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 ', to: registration.email, subject: '🎭 Welcome to WORLDPLAY – Registration Confirmed', html: `

WORLDPLAY

: To be Defined

Welcome, ${registration.firstName}! 🌟

Thank you for registering your interest in WORLDPLAY – a pop-up physical hub for prefiguring and prehearsing postcapitalist futures through fiction, performance and play.

We've received your registration and will be in touch with next steps as we finalize the programme.

Your Registration Details

${registration.location ? ` ` : ''} ${registration.role ? ` ` : ''} ${(Array.isArray(registration.contribute) && registration.contribute.length > 0) || registration.contribute ? ` ` : ''}
Name: ${registration.firstName} ${registration.lastName}
Email: ${registration.email}
Location: ${registration.location}
Role: ${roleLabels[registration.role] || registration.role}
Interests: ${interestsText}
Contribution: ${contributeText}

Event Details

📅 June 7–13, 2026
📍 Commons Hub, Hirschwang an der Rax, Austria
🏔️ Austrian Alps, ~1.5 hours from Vienna by train

Questions? Reply to this email or visit worldplay.art

Reality is a design space ✨

`, }); 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(); } } // Registration endpoint 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(400).json({ error: 'This email is already registered' }); } // Create new registration 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', registeredAt: new Date().toISOString(), ipAddress: req.ip || req.connection.remoteAddress }; // Save registration locally registrations.push(registration); await saveRegistrations(registrations); console.log(`New registration: ${registration.firstName} ${registration.lastName} <${registration.email}>`); // Add to Google Sheet (async, don't block response) appendToGoogleSheet(registration).catch(err => console.error('Sheet error:', err)); // Send confirmation email (async, don't block response) sendConfirmationEmail(registration).catch(err => console.error('Email error:', err)); // Add to Listmonk for newsletter management (async, don't block response) addToListmonk(registration).catch(err => console.error('Listmonk error:', err)); res.json({ success: true, message: 'Registration successful', id: registration.id }); } catch (error) { console.error('Registration error:', error); res.status(500).json({ error: 'An error occurred. Please try again.' }); } }); // 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' }); } }); // 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})`); }); });