const express = require('express'); const fs = require('fs').promises; const path = require('path'); const { google } = require('googleapis'); const { Resend } = require('resend'); 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 Resend for emails const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null; // Listmonk configuration for newsletter management const LISTMONK_URL = process.env.LISTMONK_URL || 'http://listmonk:9000'; const LISTMONK_USER = process.env.LISTMONK_USER || 'worldplay-api'; const LISTMONK_PASS = process.env.LISTMONK_PASS || 'worldplay-api-2026'; const LISTMONK_LIST_ID = parseInt(process.env.LISTMONK_LIST_ID) || 20; // WORLDPLAY list // 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 (!resend) { console.log('Resend 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 resend.emails.send({ 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 async function addToListmonk(registration) { if (!registration.newsletter) { console.log('Newsletter not opted in, skipping Listmonk...'); return; } try { const auth = Buffer.from(`${LISTMONK_USER}:${LISTMONK_PASS}`).toString('base64'); // First, create or update the subscriber const subscriberResponse = await fetch(`${LISTMONK_URL}/api/subscribers`, { method: 'POST', headers: { 'Authorization': `Basic ${auth}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ email: registration.email, name: `${registration.firstName} ${registration.lastName}`, status: 'enabled', lists: [LISTMONK_LIST_ID], attribs: { firstName: registration.firstName, lastName: registration.lastName, location: registration.location || '', role: registration.role || '', interests: registration.interests || [], contribute: registration.contribute || [], registrationId: registration.id, registeredAt: registration.registeredAt } }), }); if (subscriberResponse.ok) { const data = await subscriberResponse.json(); console.log(`Added to Listmonk WORLDPLAY list: ${registration.email} (ID: ${data.data?.id})`); } else if (subscriberResponse.status === 409) { // Subscriber already exists, add to list console.log(`Subscriber exists in Listmonk, updating list membership: ${registration.email}`); // Get existing subscriber const searchResponse = await fetch(`${LISTMONK_URL}/api/subscribers?query=email='${registration.email}'`, { headers: { 'Authorization': `Basic ${auth}` }, }); if (searchResponse.ok) { const searchData = await searchResponse.json(); if (searchData.data?.results?.length > 0) { const subscriberId = searchData.data.results[0].id; // Add to WORLDPLAY list await fetch(`${LISTMONK_URL}/api/subscribers/lists`, { method: 'PUT', headers: { 'Authorization': `Basic ${auth}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ids: [subscriberId], action: 'add', target_list_ids: [LISTMONK_LIST_ID], status: 'confirmed' }), }); console.log(`Added existing subscriber to WORLDPLAY list: ${registration.email}`); } } } else { const errorText = await subscriberResponse.text(); console.error(`Listmonk API error (${subscriberResponse.status}): ${errorText}`); } } catch (error) { console.error('Error adding to Listmonk:', error.message); } } // 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: ${resend ? 'enabled' : 'disabled'}`); console.log(`Listmonk newsletter sync: enabled (list ID: ${LISTMONK_LIST_ID})`); }); });