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 || '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; // 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 ]]; 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 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.

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:

  • programme outline + how to propose sessions
  • available accommodation and pricing
  • other logistics (getting there, food, etc.)
  • payment options and any support possibilities
  • key dates

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

`, }); 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})`); }); });