const express = require('express'); const { WebSocketServer } = require('ws'); const cors = require('cors'); const http = require('http'); const path = require('path'); const fs = require('fs'); const { v4: uuidv4 } = require('uuid'); const { CATEGORY_META, buildSeedData, mergeSeedData } = require('./seed-data'); const app = express(); const server = http.createServer(app); const wss = new WebSocketServer({ server }); const DEFAULT_DATA_DIR = path.join(__dirname, 'data'); const DATA_DIR = process.env.DATA_DIR ? path.resolve(process.env.DATA_DIR) : DEFAULT_DATA_DIR; const DATA_FILE = process.env.DATA_FILE ? path.resolve(process.env.DATA_FILE) : path.join(DATA_DIR, 'votes.json'); app.use(cors()); app.use(express.json()); app.get('/admin', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'admin.html')); }); app.use(express.static(path.join(__dirname, 'public'))); function loadData() { if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); if (!fs.existsSync(DATA_FILE)) { const seed = buildSeedData(); fs.writeFileSync(DATA_FILE, JSON.stringify(seed, null, 2)); return seed; } const existing = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); const merged = mergeSeedData(existing); if (JSON.stringify(existing) !== JSON.stringify(merged)) { fs.writeFileSync(DATA_FILE, JSON.stringify(merged, null, 2)); } return merged; } function saveData(nextData) { fs.writeFileSync(DATA_FILE, JSON.stringify(nextData, null, 2)); } function approvedOptionsWithVoteSummary() { return data.options .filter((option) => option.approved) .map((option) => ({ id: option.id, votes: option.votes.length, voters: option.votes.map((vote) => vote.name), })); } function broadcast(payload) { const msg = JSON.stringify(payload); wss.clients.forEach((client) => { if (client.readyState === 1) client.send(msg); }); } function buildRealtimeSnapshot() { return { type: 'init', pollsOpen: data.pollsOpen, categories: data.categories, options: data.options.filter((option) => option.approved), results: approvedOptionsWithVoteSummary(), totalVoters: data.voters.length, budgetScenarios: data.budgetScenarios || [], priceUpdatedAt: data.priceUpdatedAt || null, }; } function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, approved }) { return { id: uuidv4(), seedKey: null, categoryId, name: name.trim(), desc: (desc || '').trim(), url: url ? url.trim() : null, links: url ? [{ label: 'Website', url: url.trim() }] : [], lat: lat || null, lng: lng || null, addedBy: voterName, approved, votes: [], details: [], categoryColor: CATEGORY_META[categoryId]?.color || '#888', }; } let data = loadData(); app.get('/api/categories', (req, res) => { res.json(data.categories); }); app.get('/api/options', (req, res) => { const { category, includeUnapproved } = req.query; let options = data.options; if (category) options = options.filter((option) => option.categoryId === category); if (!includeUnapproved) options = options.filter((option) => option.approved); res.json(options); }); app.get('/api/results', (req, res) => { const results = data.categories.map((category) => ({ ...category, options: data.options .filter((option) => option.approved && option.categoryId === category.id) .map((option) => ({ ...option, voteCount: option.votes.length })), })); res.json({ pollsOpen: data.pollsOpen, results, totalVoters: data.voters.length, budgetScenarios: data.budgetScenarios || [], priceUpdatedAt: data.priceUpdatedAt || null, }); }); app.get('/api/budgets', (req, res) => { res.json({ updatedAt: data.priceUpdatedAt || null, scenarios: data.budgetScenarios || [], }); }); app.post('/api/vote', (req, res) => { const { optionId, voterName } = req.body; if (!voterName || !optionId) { return res.status(400).json({ error: 'Missing fields' }); } if (!data.pollsOpen) { return res.status(403).json({ error: 'Polls are closed' }); } const option = data.options.find((candidate) => candidate.id === optionId); if (!option || !option.approved) { return res.status(404).json({ error: 'Option not found' }); } const previousVote = data.options.find((candidate) => ( candidate.categoryId === option.categoryId && candidate.votes.some((vote) => vote.name === voterName) )); if (previousVote) { previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName); } option.votes.push({ name: voterName, timestamp: Date.now() }); if (!data.voters.find((voter) => voter.name === voterName)) { data.voters.push({ name: voterName, joinedAt: Date.now() }); } saveData(data); broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() }); res.json({ success: true, voteCount: option.votes.length }); }); app.delete('/api/vote/:optionId', (req, res) => { const { voterName } = req.body; const option = data.options.find((candidate) => candidate.id === req.params.optionId); if (!option) return res.status(404).json({ error: 'Not found' }); option.votes = option.votes.filter((vote) => vote.name !== voterName); saveData(data); broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() }); res.json({ success: true }); }); app.post('/api/options', (req, res) => { const { categoryId, name, desc, url, voterName, lat, lng } = req.body; if (!categoryId || !name || !voterName) { return res.status(400).json({ error: 'Missing required fields' }); } const category = data.categories.find((candidate) => candidate.id === categoryId); if (!category) return res.status(404).json({ error: 'Category not found' }); const newOption = createUserOption({ categoryId, name, desc, url, voterName, lat, lng, approved: false, }); data.options.push(newOption); saveData(data); broadcast({ type: 'option_added', option: newOption }); res.json({ success: true, option: newOption }); }); app.post('/api/options/:id/approve', (req, res) => { const option = data.options.find((candidate) => candidate.id === req.params.id); if (!option) return res.status(404).json({ error: 'Not found' }); option.approved = true; saveData(data); broadcast({ type: 'option_approved', option }); res.json({ success: true }); }); app.delete('/api/options/:id', (req, res) => { const optionIndex = data.options.findIndex((candidate) => candidate.id === req.params.id); if (optionIndex === -1) return res.status(404).json({ error: 'Not found' }); data.options.splice(optionIndex, 1); saveData(data); broadcast({ type: 'option_deleted', id: req.params.id }); res.json({ success: true }); }); app.post('/api/polls', (req, res) => { data.pollsOpen = req.body.open !== undefined ? req.body.open : !data.pollsOpen; saveData(data); broadcast({ type: 'polls_status', open: data.pollsOpen }); res.json({ success: true, pollsOpen: data.pollsOpen }); }); const YELP_API_KEY = process.env.YELP_API_KEY || ''; app.get('/api/yelp', async (req, res) => { const { term, location } = req.query; if (!term) return res.status(400).json({ error: 'term is required' }); if (!YELP_API_KEY) { return res.status(503).json({ error: 'YELP_API_KEY not configured on server. Add it as an environment variable.', }); } try { const params = new URLSearchParams({ term: `${term} in ${location}`, location: location || 'Los Cabos Mexico', limit: '15', sort_by: 'rating', categories: 'restaurants,nightlife,active,arts,health', }); const response = await fetch(`https://api.yelp.com/v3/businesses/search?${params}`, { headers: { Authorization: `Bearer ${YELP_API_KEY}`, Accept: 'application/json', }, }); if (!response.ok) { const errorText = await response.text(); return res.status(response.status).json({ error: `Yelp API error: ${errorText}` }); } const payload = await response.json(); const businesses = (payload.businesses || []).map((business) => ({ name: business.name, image_url: business.image_url, url: business.url, rating: business.rating, price: business.price, coordinates: business.coordinates, location: business.location, categories: business.categories, display_phone: business.display_phone, distance: business.distance, })); res.json({ businesses, total: payload.total }); } catch (error) { console.error('Yelp proxy error:', error); res.status(500).json({ error: 'Failed to fetch from Yelp' }); } }); wss.on('connection', (ws) => { ws.send(JSON.stringify(buildRealtimeSnapshot())); ws.on('message', (raw) => { try { const msg = JSON.parse(raw); if (msg.type === 'vote') { const { optionId, voterName, remove } = msg; if (!voterName || !optionId) return; if (!data.pollsOpen) { ws.send(JSON.stringify({ type: 'error', message: 'Polls closed' })); return; } const option = data.options.find((candidate) => candidate.id === optionId); if (!option || !option.approved) return; if (remove) { option.votes = option.votes.filter((vote) => vote.name !== voterName); } else { const previousVote = data.options.find((candidate) => ( candidate.categoryId === option.categoryId && candidate.votes.some((vote) => vote.name === voterName) )); if (previousVote) { previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName); } option.votes.push({ name: voterName, timestamp: Date.now() }); } if (!data.voters.find((voter) => voter.name === voterName)) { data.voters.push({ name: voterName, joinedAt: Date.now() }); } saveData(data); broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() }); } else if (msg.type === 'add_option') { const { categoryId, name, desc, url, voterName, lat, lng } = msg; if (!categoryId || !name || !voterName) return; const newOption = createUserOption({ categoryId, name, desc, url, voterName, lat, lng, approved: true, }); data.options.push(newOption); saveData(data); broadcast({ type: 'option_added', option: newOption }); } } catch { // Ignore malformed websocket payloads. } }); }); const PORT = process.env.PORT || 3001; const HOST = process.env.HOST || '0.0.0.0'; server.listen(PORT, HOST, () => { console.log(`🏄 Cabo Voting App → http://${HOST}:${PORT}`); });