const path = require('path'); const { createServer } = require('http'); const { parse } = require('url'); const { WebSocketServer } = require('ws'); const dev = process.env.NODE_ENV !== 'production'; const hostname = process.env.HOSTNAME || '0.0.0.0'; const port = parseInt(process.env.PORT || '3000', 10); // WebSocket server for chat + real-time updates const wss = new WebSocketServer({ noServer: true }); const clients = new Set(); function setupWebSocket() { wss.on('connection', async (ws) => { clients.add(ws); // Send chat history try { const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); const messages = await prisma.chatMessage.findMany({ where: { deleted: false }, orderBy: { createdAt: 'asc' }, take: 100, include: { user: { select: { name: true } } }, }); ws.send(JSON.stringify({ type: 'chat:history', messages: messages.map((m) => { const fullName = m.user?.name || m.guestName || 'Anonymous'; const firstName = fullName.split(' ')[0]; return { id: m.id, userName: firstName, message: m.message, createdAt: m.createdAt.toISOString(), }; }), })); await prisma.$disconnect(); } catch (err) { console.error('Failed to load chat history:', err); } ws.on('message', async (data) => { try { const parsed = JSON.parse(data.toString()); const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); if (parsed.type === 'chat:message' && parsed.message) { // Filter blacklisted words const blacklist = await prisma.chatBlacklist.findMany(); let filtered = parsed.message; for (const { word } of blacklist) { const regex = new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); filtered = filtered.replace(regex, '***'); } // Determine user from userId let userName = 'Anonymous'; let userId = parsed.userId || null; if (userId) { try { const user = await prisma.user.findUnique({ where: { id: userId }, select: { name: true }, }); if (user) { userName = user.name.split(' ')[0]; } else { userId = null; } } catch { userId = null; } } const msg = await prisma.chatMessage.create({ data: { message: filtered.slice(0, 500), userId, guestName: userId ? null : userName, }, }); const broadcast = JSON.stringify({ type: 'chat:message', message: { id: msg.id, userName, message: msg.message, createdAt: msg.createdAt.toISOString(), }, }); for (const client of clients) { if (client.readyState === 1) { client.send(broadcast); } } } else if (parsed.type === 'squares:changed') { // Broadcast squares refresh notification to all clients const broadcast = JSON.stringify({ type: 'squares:refresh' }); for (const client of clients) { if (client.readyState === 1) { client.send(broadcast); } } await prisma.$disconnect(); return; } else if (parsed.type === 'chat:delete' && parsed.messageId) { if (parsed.userId) { try { const user = await prisma.user.findUnique({ where: { id: parsed.userId }, select: { role: true }, }); if (user && (user.role === 'ADMIN' || user.role === 'VIEWER')) { await prisma.chatMessage.update({ where: { id: parsed.messageId }, data: { deleted: true }, }); const broadcast = JSON.stringify({ type: 'chat:delete', messageId: parsed.messageId, }); for (const client of clients) { if (client.readyState === 1) { client.send(broadcast); } } } } catch { // ignore } } } await prisma.$disconnect(); } catch (err) { console.error('WebSocket message error:', err); } }); ws.on('close', () => { clients.delete(ws); }); }); } // Payment reminder scheduler - check every 15 minutes function startPaymentReminders() { setInterval(async () => { try { const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); const nodemailer = require('nodemailer'); const settings = await prisma.gameSettings.findUnique({ where: { id: 'singleton' }, include: { paymentMethods: true }, }); if (!settings || !settings.graceHours) { await prisma.$disconnect(); return; } const template = await prisma.emailTemplate.findUnique({ where: { name: 'payment_reminder' }, }); if (!template) { await prisma.$disconnect(); return; } const emailSettings = await prisma.emailSettings.findUnique({ where: { id: 'singleton' }, }); if (!emailSettings || !emailSettings.smtpHost) { await prisma.$disconnect(); return; } const transporter = nodemailer.createTransport({ host: emailSettings.smtpHost, port: emailSettings.smtpPort, secure: emailSettings.smtpPort === 465, auth: { user: emailSettings.smtpUser, pass: emailSettings.smtpPass }, }); const gameUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; const enabledPayments = settings.paymentMethods?.filter((pm) => pm.enabled) || []; const paymentMethodsText = enabledPayments.length > 0 ? enabledPayments.map((pm) => `${pm.type}: ${pm.value}`).join('\n') : 'Contact the commissioner for payment details.'; const now = new Date(); const unconfirmed = await prisma.square.findMany({ where: { confirmed: false, reminderSent: false, signupDate: { not: null }, OR: [ { userId: { not: null } }, { guestEmail: { not: null } }, ], }, include: { user: { select: { name: true, email: true } } }, }); for (const sq of unconfirmed) { if (!sq.signupDate) continue; const deadline = new Date(sq.signupDate.getTime() + settings.graceHours * 60 * 60 * 1000); const reminderTime = new Date(deadline.getTime() - 2 * 60 * 60 * 1000); if (now >= reminderTime && now < deadline) { const email = sq.user?.email || sq.guestEmail; const name = sq.user?.name || sq.guestName; if (!email || !name) continue; const variables = { name, email, squares: sq.position, amountDue: settings.betAmount.toFixed(2), commissioner: settings.commissioner || '', eventName: settings.eventName || '', gameUrl, graceHours: String(settings.graceHours), paymentInstructions: settings.paymentInstructions || '', paymentMethods: paymentMethodsText, }; let subject = template.subject; let body = template.body; for (const [key, val] of Object.entries(variables)) { const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g'); subject = subject.replace(regex, val); body = body.replace(regex, val); } try { await transporter.sendMail({ from: `"${emailSettings.fromName || 'Super Bowl Squares'}" <${emailSettings.fromEmail || 'noreply@localhost'}>`, to: email, subject, text: body, }); await prisma.square.update({ where: { id: sq.id }, data: { reminderSent: true }, }); console.log(`[Reminder] Payment reminder sent to ${email} for square ${sq.position}`); } catch (err) { console.error(`[Reminder] Failed to send to ${email}:`, err.message || err); } } } await prisma.$disconnect(); } catch (err) { console.error('[Reminder] Scheduler error:', err.message || err); } }, 15 * 60 * 1000); } // --- Boot --- if (dev) { // Development mode: use standard next() API const next = require('next'); const app = next({ dev, hostname, port }); const handle = app.getRequestHandler(); app.prepare().then(() => { const server = createServer(async (req, res) => { try { const parsedUrl = parse(req.url, true); await handle(req, res, parsedUrl); } catch (err) { console.error('Error occurred handling', req.url, err); res.statusCode = 500; res.end('internal server error'); } }); server.on('upgrade', (req, socket, head) => { const { pathname } = parse(req.url, true); if (pathname === '/ws/chat') { wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req); }); } else { socket.destroy(); } }); setupWebSocket(); startPaymentReminders(); server.listen(port, hostname, () => { console.log(`> Ready on http://${hostname}:${port}`); }); }); } else { // Production standalone mode: use startServer but monkey-patch http.createServer // to capture the server instance for WebSocket upgrade handling const dir = path.join(__dirname); process.chdir(__dirname); // Read the standalone config: either from env (if already set) or from the // saved original standalone server.js which contains the embedded nextConfig if (!process.env.__NEXT_PRIVATE_STANDALONE_CONFIG) { try { const fs = require('fs'); const standaloneCode = fs.readFileSync(path.join(__dirname, 'server.standalone.js'), 'utf8'); const match = standaloneCode.match(/const nextConfig = (\{[\s\S]*?\})\n\nprocess\.env\.__NEXT_PRIVATE_STANDALONE_CONFIG/); if (match) { process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = match[1]; } } catch (err) { console.warn('Could not read standalone config:', err.message); } } const nextConfig = process.env.__NEXT_PRIVATE_STANDALONE_CONFIG ? JSON.parse(process.env.__NEXT_PRIVATE_STANDALONE_CONFIG) : undefined; // Monkey-patch http.createServer to intercept the server Next.js creates const http = require('http'); const originalCreateServer = http.createServer.bind(http); http.createServer = function patchedCreateServer(...args) { const server = originalCreateServer(...args); // Prepend our upgrade listener BEFORE Next.js adds its own const originalOn = server.on.bind(server); server.on = function patchedOn(event, listener) { if (event === 'upgrade') { // Wrap Next.js upgrade handler to intercept /ws/chat originalOn('upgrade', (req, socket, head) => { const { pathname } = parse(req.url, true); if (pathname === '/ws/chat') { wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req); }); } else { listener(req, socket, head); } }); // Restore original .on so future calls work normally server.on = originalOn; return server; } return originalOn(event, listener); }; // Restore http.createServer http.createServer = originalCreateServer; return server; }; setupWebSocket(); startPaymentReminders(); // Now let Next.js startServer do its thing - it will use our patched createServer require('next'); const { startServer } = require('next/dist/server/lib/start-server'); let keepAliveTimeout = parseInt(process.env.KEEP_ALIVE_TIMEOUT, 10); if (Number.isNaN(keepAliveTimeout) || !Number.isFinite(keepAliveTimeout) || keepAliveTimeout < 0) { keepAliveTimeout = undefined; } startServer({ dir, isDev: false, config: nextConfig, hostname, port, allowRetry: false, keepAliveTimeout, }).then(() => { console.log(`> WebSocket server ready on ws://${hostname}:${port}/ws/chat`); }).catch((err) => { console.error(err); process.exit(1); }); }