Initial commit: Next.js rewrite of Super Bowl Squares app
Full rewrite of the legacy PHP/MySQL app using Next.js 14, PostgreSQL, Prisma, NextAuth, Tailwind CSS, and WebSocket-based live chat/grid updates. Deployed via Docker Compose with a custom Node.js server for WebSocket support. Fix chat display names by passing userId from the NextAuth session over WebSocket instead of attempting to read the HttpOnly session cookie (which is inaccessible to JavaScript). Server now looks up the user's first name from the database using the userId. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user