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'); const DEFAULT_PRICE_HISTORY_FILE = path.join(__dirname, 'price-watch', 'history.jsonl'); const PRICE_HISTORY_FILE = process.env.PRICE_HISTORY_FILE ? path.resolve(process.env.PRICE_HISTORY_FILE) : DEFAULT_PRICE_HISTORY_FILE; 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 normalizeKey(value) { return String(value || '') .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } function toTextList(value) { const items = Array.isArray(value) ? value : value == null ? [] : [value]; return [...new Set(items.flatMap((item) => { if (Array.isArray(item)) { return item; } if (item && typeof item === 'object') { return [ item.label, item.name, item.text, item.title, item.value, item.summary, item.description, ].filter(Boolean); } return [item]; }) .map((item) => String(item).trim()) .filter(Boolean))]; } function readJsonLines(filePath) { if (!fs.existsSync(filePath)) return []; return fs.readFileSync(filePath, 'utf8') .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .map((line) => { try { return JSON.parse(line); } catch { return null; } }) .filter(Boolean); } function getOptionHistoryKeys(option) { const nameKey = normalizeKey(option.name); const categoryNameKey = option.categoryId && nameKey ? `${option.categoryId}-${nameKey}` : ''; return [...new Set([ option.seedKey, option.id, option.priceKey, option.optionKey, option.slug, categoryNameKey, nameKey, ].filter(Boolean).map(normalizeKey))]; } function extractNumericPrice(point) { const candidates = [ point.price, point.value, point.amount, point.perPerson, point.groupTotal, ]; for (const candidate of candidates) { if (typeof candidate === 'number' && Number.isFinite(candidate)) return candidate; if (typeof candidate === 'string') { const parsed = Number(candidate.replace(/[^0-9.-]/g, '')); if (Number.isFinite(parsed)) return parsed; } } return null; } function loadPriceHistoryState() { const runs = readJsonLines(PRICE_HISTORY_FILE) .map((entry, index) => { const checkedAtRaw = entry.checkedAt || entry.checked_at || entry.runAt || entry.timestamp || entry.date || null; const checkedAtMs = checkedAtRaw ? Date.parse(checkedAtRaw) : Date.now() + index; return { ...entry, checkedAt: Number.isNaN(checkedAtMs) ? null : new Date(checkedAtMs).toISOString(), checkedAtMs: Number.isNaN(checkedAtMs) ? Date.now() + index : checkedAtMs, }; }) .sort((a, b) => a.checkedAtMs - b.checkedAtMs); runs.forEach((run, index) => { run.runIndex = index; }); const seriesByKey = new Map(); runs.forEach((run) => { const pricePoints = Array.isArray(run.optionPrices) ? run.optionPrices : Array.isArray(run.prices) ? run.prices : Array.isArray(run.trackedPrices) ? run.trackedPrices : []; pricePoints.forEach((point) => { const key = normalizeKey( point.optionKey || point.optionId || point.seedKey || point.slug || point.key || point.name, ); const price = extractNumericPrice(point); if (!key || price === null) return; const nextPoint = { checkedAt: run.checkedAt, checkedAtMs: run.checkedAtMs, runIndex: run.runIndex, price, currency: point.currency || 'USD', displayPrice: point.displayPrice || point.priceLabel || point.label || null, source: point.source || null, sourceUrl: point.sourceUrl || point.url || null, note: point.note || point.description || null, availability: point.availability || point.status || null, decisionNote: point.decisionNote || point.note || point.description || null, highlights: toTextList(point.highlights || point.summaryBullets || point.bullets), features: toTextList(point.features || point.featureHighlights || point.featureLabels), amenities: toTextList(point.amenities || point.amenityHighlights || point.amenityLabels), inclusions: toTextList(point.inclusions || point.includes || point.perks), limitations: toTextList(point.limitations || point.tradeoffs || point.caveats), }; if (!seriesByKey.has(key)) seriesByKey.set(key, []); seriesByKey.get(key).push(nextPoint); }); }); seriesByKey.forEach((series) => { series.sort((a, b) => a.runIndex - b.runIndex); }); return { runs, seriesByKey, latestCheckedAt: runs.at(-1)?.checkedAt || null, totalRuns: runs.length, }; } function getPriceHistoryForOption(option, priceHistoryState) { const optionKeys = getOptionHistoryKeys(option); for (const key of optionKeys) { const series = priceHistoryState.seriesByKey.get(key); if (series && series.length) { return series; } } return []; } function decorateOptionWithPriceHistory(option, priceHistoryState) { const priceHistory = getPriceHistoryForOption(option, priceHistoryState); const latestPricePoint = priceHistory.at(-1) || null; const optionDetails = toTextList(option.details); const automationHighlights = toTextList(latestPricePoint?.highlights); const automationFeatures = toTextList(latestPricePoint?.features); const automationAmenities = toTextList(latestPricePoint?.amenities); const automationInclusions = toTextList(latestPricePoint?.inclusions); const automationLimitations = toTextList(latestPricePoint?.limitations); const decisionDetails = [ ...optionDetails, ...automationHighlights, ...automationFeatures, ...automationAmenities, ...automationInclusions, ...automationLimitations, ]; return { ...option, priceHistory, latestPricePoint, currentPrice: latestPricePoint?.price ?? null, decisionDetails: [...new Set(decisionDetails)], automationInsights: latestPricePoint ? { currentPrice: latestPricePoint.price, currency: latestPricePoint.currency || 'USD', displayPrice: latestPricePoint.displayPrice || null, source: latestPricePoint.source || null, sourceUrl: latestPricePoint.sourceUrl || null, availability: latestPricePoint.availability || null, decisionNote: latestPricePoint.decisionNote || null, highlights: automationHighlights, features: automationFeatures, amenities: automationAmenities, inclusions: automationInclusions, limitations: automationLimitations, } : null, }; } function decorateOptionsWithPriceHistory(options, priceHistoryState) { return options.map((option) => decorateOptionWithPriceHistory(option, priceHistoryState)); } 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() { const priceHistoryState = loadPriceHistoryState(); const approvedOptions = data.options.filter((option) => option.approved); return { type: 'init', pollsOpen: data.pollsOpen, categories: data.categories, options: decorateOptionsWithPriceHistory(approvedOptions, priceHistoryState), results: approvedOptionsWithVoteSummary(), totalVoters: data.voters.length, budgetScenarios: data.budgetScenarios || [], priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null, priceHistoryRunCount: priceHistoryState.totalRuns, }; } 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; const priceHistoryState = loadPriceHistoryState(); if (category) options = options.filter((option) => option.categoryId === category); if (!includeUnapproved) options = options.filter((option) => option.approved); res.json(decorateOptionsWithPriceHistory(options, priceHistoryState)); }); app.get('/api/results', (req, res) => { const priceHistoryState = loadPriceHistoryState(); const results = data.categories.map((category) => ({ ...category, options: data.options .filter((option) => option.approved && option.categoryId === category.id) .map((option) => ({ ...decorateOptionWithPriceHistory(option, priceHistoryState), voteCount: option.votes.length, })), })); res.json({ pollsOpen: data.pollsOpen, results, totalVoters: data.voters.length, budgetScenarios: data.budgetScenarios || [], priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null, priceHistoryRunCount: priceHistoryState.totalRuns, }); }); app.get('/api/budgets', (req, res) => { const priceHistoryState = loadPriceHistoryState(); res.json({ updatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null, scenarios: data.budgetScenarios || [], }); }); app.get('/api/price-history', (req, res) => { const priceHistoryState = loadPriceHistoryState(); const seriesByOption = Object.fromEntries( [...priceHistoryState.seriesByKey.entries()], ); res.json({ latestCheckedAt: priceHistoryState.latestCheckedAt, totalRuns: priceHistoryState.totalRuns, seriesByOption, }); }); 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}`); });