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:
Philip
2026-02-17 17:34:50 -08:00
commit b4e89ea9ee
178 changed files with 12268 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
ADMIN
VIEWER
PLAYER
}
enum PaymentType {
VENMO
PAYPAL
CASHAPP
ZELLE
CASH
}
model User {
id String @id @default(cuid())
email String @unique
name String
passwordHash String
role Role @default(PLAYER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
squares Square[]
chatMessages ChatMessage[]
}
model GameSettings {
id String @id @default("singleton")
title String @default("Super Bowl Squares")
commissioner String @default("")
eventName String @default("Super Bowl")
eventDate String @default("")
eventTime String @default("")
sbLogo String @default("/images/superbowlnumber.png")
nfcTeam String @default("NFC Team")
nfcLogo String @default("/images/nfc-generic.png")
afcTeam String @default("AFC Team")
afcLogo String @default("/images/afc-generic.png")
betAmount Float @default(10)
winFirstPct Float @default(20)
winSecondPct Float @default(20)
winThirdPct Float @default(20)
winFinalPct Float @default(30)
donationPct Float @default(10)
graceHours Int @default(48)
rulesText String @default("")
paymentInstructions String @default("")
paymentMethods PaymentMethod[]
}
model PaymentMethod {
id String @id @default(cuid())
gameSettingsId String @default("singleton")
gameSettings GameSettings @relation(fields: [gameSettingsId], references: [id], onDelete: Cascade)
type PaymentType
value String
enabled Boolean @default(true)
}
model Square {
id String @id @default(cuid())
position String @unique // "00" to "99"
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
guestName String?
guestEmail String?
notes String?
confirmed Boolean @default(false)
signupDate DateTime?
firstWin Boolean @default(false)
halfWin Boolean @default(false)
thirdWin Boolean @default(false)
finalWin Boolean @default(false)
reminderSent Boolean @default(false)
}
model GridNumber {
id String @id @default(cuid())
position Int @unique // 0-9 (column/row index)
nfcNumber Int // 0-9
afcNumber Int // 0-9
}
model Score {
id String @id @default("singleton")
nfcFirst Int?
afcFirst Int?
nfcHalf Int?
afcHalf Int?
nfcThird Int?
afcThird Int?
nfcFinal Int?
afcFinal Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model EmailSettings {
id String @id @default("singleton")
smtpHost String @default("")
smtpPort Int @default(587)
smtpUser String @default("")
smtpPass String @default("")
useSsl Boolean @default(false)
fromEmail String @default("")
fromName String @default("")
}
model EmailTemplate {
id String @id @default(cuid())
name String @unique
subject String
body String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ChatMessage {
id String @id @default(cuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
guestName String?
message String
deleted Boolean @default(false)
createdAt DateTime @default(now())
}
model ChatBlacklist {
id String @id @default(cuid())
word String @unique
createdAt DateTime @default(now())
}
+118
View File
@@ -0,0 +1,118 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// Seed 100 squares (positions "00" through "99")
const squares = [];
for (let row = 0; row < 10; row++) {
for (let col = 0; col < 10; col++) {
const position = `${row}${col}`;
squares.push({ position });
}
}
for (const square of squares) {
await prisma.square.upsert({
where: { position: square.position },
update: {},
create: square,
});
}
// Seed default game settings
await prisma.gameSettings.upsert({
where: { id: 'singleton' },
update: {},
create: {
id: 'singleton',
title: 'Super Bowl Squares',
commissioner: 'Commissioner',
eventName: 'Super Bowl',
betAmount: 10,
winFirstPct: 20,
winSecondPct: 20,
winThirdPct: 20,
winFinalPct: 30,
donationPct: 10,
graceHours: 48,
},
});
// Seed default score row
await prisma.score.upsert({
where: { id: 'singleton' },
update: {},
create: { id: 'singleton' },
});
// Seed default email settings
await prisma.emailSettings.upsert({
where: { id: 'singleton' },
update: {},
create: { id: 'singleton' },
});
// Seed default email templates
const templates = [
{
name: 'welcome',
subject: 'Welcome to {{eventName}}!',
body: 'Hi {{name}},\n\nWelcome to {{eventName}} Squares!\n\nYour account has been created successfully.\n\nUsername: {{email}}\n\nYou can log in and view the game board at:\n{{gameUrl}}/login\n\nThanks for joining!\n{{commissioner}}',
},
{
name: 'square_confirmation',
subject: 'Square Purchase Confirmation - {{eventName}}',
body: 'Hi {{name}},\n\nThank you for purchasing squares for {{eventName}}!\n\nSquare(s): {{squares}}\nAmount Due: ${{amountDue}}\n\nPlease submit payment within {{graceHours}} hours to keep your squares.\n\nPayment Instructions:\n{{paymentInstructions}}\n\nPayment Methods:\n{{paymentMethods}}\n\nView your squares at: {{gameUrl}}\n\nThanks,\n{{commissioner}}',
},
{
name: 'square_confirmed',
subject: 'Payment Confirmed - {{eventName}}',
body: 'Hi {{name}},\n\nGreat news! Your payment has been confirmed for {{eventName}}.\n\nSquare(s): {{squares}}\n\nYour squares are now locked in. View the game board at:\n{{gameUrl}}\n\nGood luck!\n{{commissioner}}',
},
{
name: 'square_released',
subject: 'Square Released - {{eventName}}',
body: 'Hi {{name}},\n\nYour square(s) for {{eventName}} have been released.\n\nSquare(s): {{squares}}\n\nIf you believe this is an error, please contact the commissioner.\n\nView the game board at: {{gameUrl}}\n\n{{commissioner}}',
},
{
name: 'payment_reminder',
subject: 'Payment Reminder - {{eventName}}',
body: 'Hi {{name}},\n\nThis is a friendly reminder that your payment for {{eventName}} is due soon!\n\nSquare(s): {{squares}}\nAmount Due: ${{amountDue}}\n\nYour grace period expires in approximately 2 hours. After that, your squares may be released.\n\nPayment Instructions:\n{{paymentInstructions}}\n\nPayment Methods:\n{{paymentMethods}}\n\nView your squares at: {{gameUrl}}\n\nThanks,\n{{commissioner}}',
},
{
name: 'winner_notification',
subject: 'Congratulations! You won {{quarter}} - {{eventName}}',
body: 'Hi {{name}},\n\nCongratulations! You are the winner of the {{quarter}} quarter!\n\nSquare: {{square}}\nScore: {{nfcTeam}} {{nfcScore}} - {{afcTeam}} {{afcScore}}\nPrize: ${{prize}}\n\nThanks,\n{{commissioner}}',
},
{
name: 'numbers_assigned',
subject: 'Numbers Have Been Assigned - {{eventName}}',
body: 'Hi {{name}},\n\nThe random numbers have been assigned for {{eventName}}!\n\nVisit the game board to see your numbers and check your squares:\n{{gameUrl}}\n\nGood luck!\n{{commissioner}}',
},
{
name: 'game_results',
subject: 'Final Results - {{eventName}}',
body: 'Hi {{name}},\n\nThe game is over! Here are the final results for {{eventName}}:\n\nWinners:\n{{winners}}\n\nCongratulations to all the winners!\n\nThank you for participating in this year\'s Super Bowl Squares. We hope you had a great time!\n\nView the final board at: {{gameUrl}}\n\n{{commissioner}}',
},
];
for (const template of templates) {
await prisma.emailTemplate.upsert({
where: { name: template.name },
update: {},
create: template,
});
}
console.log('Seed completed successfully');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});