Initial commit: Who Did It Clue character preference collector
Made-with: Cursor
This commit is contained in:
+155
@@ -0,0 +1,155 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
import 'dotenv/config'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const CHARACTERS = [
|
||||
{ id: 'scarlet', name: 'Miss Scarlett', color: '#c0392b', costumeHint: 'Deep red dress or suit, dark lipstick', icon: '🌹' },
|
||||
{ id: 'mustard', name: 'Colonel Mustard', color: '#d4a017', costumeHint: 'Mustard yellow jacket or military', icon: '🎖️' },
|
||||
{ id: 'white', name: 'Mrs. White', color: '#e8e0d0', costumeHint: 'Off-white apron, black dress', icon: '🕯️' },
|
||||
{ id: 'green', name: 'Mr. Green', color: '#1e6e3b', costumeHint: 'Forest green suit or vest', icon: '📖' },
|
||||
{ id: 'peacock', name: 'Mrs. Peacock', color: '#1a3a6b', costumeHint: 'Royal blue dress, pearls, gloves', icon: '💎' },
|
||||
{ id: 'plum', name: 'Professor Plum', color: '#6b2d6b', costumeHint: 'Purple blazer, glasses', icon: '🔭' },
|
||||
]
|
||||
|
||||
const DEFAULT_ALLOWED_NAMES = 'Jim,Colleen,Rory,Gigi,Meghan,Christine,Bob,Rachel,Chuck'
|
||||
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
|
||||
const DATA_DIR = join(__dirname, 'data')
|
||||
const SUBMISSIONS_PATH = join(DATA_DIR, 'submissions.json')
|
||||
|
||||
function ensureDataDir() {
|
||||
if (!existsSync(DATA_DIR)) {
|
||||
mkdirSync(DATA_DIR, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
function readSubmissions() {
|
||||
ensureDataDir()
|
||||
if (!existsSync(SUBMISSIONS_PATH)) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const data = readFileSync(SUBMISSIONS_PATH, 'utf-8')
|
||||
return JSON.parse(data)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeSubmissions(submissions) {
|
||||
ensureDataDir()
|
||||
writeFileSync(SUBMISSIONS_PATH, JSON.stringify(submissions, null, 2))
|
||||
}
|
||||
|
||||
function getAllowedNames() {
|
||||
const raw = process.env.ALLOWED_NAMES || DEFAULT_ALLOWED_NAMES
|
||||
return raw.split(',').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function isNameAllowed(name) {
|
||||
const allowed = getAllowedNames()
|
||||
const lower = (name || '').trim().toLowerCase()
|
||||
return allowed.some(a => a.toLowerCase() === lower)
|
||||
}
|
||||
|
||||
function findSubmittedName(name, submissions) {
|
||||
const lower = (name || '').trim().toLowerCase()
|
||||
return submissions.find(s => s.name.toLowerCase() === lower)
|
||||
}
|
||||
|
||||
const charIds = new Set(CHARACTERS.map(c => c.id))
|
||||
|
||||
// API routes
|
||||
app.get('/api/characters', (req, res) => {
|
||||
res.json(CHARACTERS)
|
||||
})
|
||||
|
||||
app.get('/api/names', (req, res) => {
|
||||
res.json(getAllowedNames())
|
||||
})
|
||||
|
||||
app.get('/api/submissions', (req, res) => {
|
||||
res.json(readSubmissions())
|
||||
})
|
||||
|
||||
app.post('/api/submissions', (req, res) => {
|
||||
const { name, picks } = req.body
|
||||
const trimmedName = (name || '').trim()
|
||||
|
||||
if (!trimmedName) {
|
||||
return res.status(400).json({ error: 'Name is required' })
|
||||
}
|
||||
|
||||
if (!isNameAllowed(trimmedName)) {
|
||||
return res.status(400).json({ error: "We don't have that name on our guest list." })
|
||||
}
|
||||
|
||||
const submissions = readSubmissions()
|
||||
if (findSubmittedName(trimmedName, submissions)) {
|
||||
return res.status(400).json({ error: 'This name has already submitted picks.' })
|
||||
}
|
||||
|
||||
if (!Array.isArray(picks) || picks.length !== 3) {
|
||||
return res.status(400).json({ error: 'Exactly 3 picks (1st, 2nd, 3rd choice) are required' })
|
||||
}
|
||||
|
||||
if (!picks.every(id => charIds.has(id))) {
|
||||
return res.status(400).json({ error: 'Invalid character IDs in picks' })
|
||||
}
|
||||
|
||||
const set = new Set(picks)
|
||||
if (set.size !== 3) {
|
||||
return res.status(400).json({ error: 'Picks must be 3 unique character IDs' })
|
||||
}
|
||||
|
||||
submissions.push({ name: trimmedName, picks })
|
||||
writeSubmissions(submissions)
|
||||
res.json({ success: true })
|
||||
})
|
||||
|
||||
app.post('/api/admin/verify', (req, res) => {
|
||||
const { password } = req.body || {}
|
||||
const expected = process.env.ADMIN_PASSWORD || 'clue2024'
|
||||
if (password === expected) {
|
||||
res.json({ ok: true })
|
||||
} else {
|
||||
res.status(401).json({ error: 'Incorrect password' })
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/api/submissions', (req, res) => {
|
||||
const token = req.headers.authorization?.replace(/^Bearer\s+/i, '') || req.headers['x-admin-password']
|
||||
const expected = process.env.ADMIN_PASSWORD || 'clue2024'
|
||||
|
||||
if (token !== expected) {
|
||||
return res.status(401).json({ error: 'Unauthorized' })
|
||||
}
|
||||
|
||||
writeSubmissions([])
|
||||
res.json({ success: true })
|
||||
})
|
||||
|
||||
// Production: serve built React app
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
const clientDist = join(__dirname, '..', 'client', 'dist')
|
||||
|
||||
if (isProd && existsSync(clientDist)) {
|
||||
app.use(express.static(clientDist))
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(join(clientDist, 'index.html'))
|
||||
})
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 3001
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`)
|
||||
})
|
||||
Reference in New Issue
Block a user