const express = require('express'); const { WebSocketServer } = require('ws'); const cors = require('cors'); const crypto = require('crypto'); 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; const TRIP_CHECK_IN = process.env.TRIP_CHECK_IN || '2027-02-03'; const TRIP_CHECK_OUT = process.env.TRIP_CHECK_OUT || '2027-02-07'; 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 normalizeGuestPin(value) { return String(value || '').replace(/\D/g, '').slice(-4); } function normalizeGuestName(value) { return normalizeKey(value).replace(/-/g, ' '); } function normalizeSourceLabel(value) { return String(value || 'Unknown source').trim() || 'Unknown source'; } function formatCurrencyValue(value, currency = 'USD') { if (typeof value !== 'number' || !Number.isFinite(value)) return ''; return new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: value >= 100 ? 0 : 2, }).format(value); } function normalizeBookingType(value) { const normalized = normalizeKey(value || ''); if (['package', 'standalone', 'calculated'].includes(normalized)) return normalized; if (normalized.includes('package') || normalized.includes('bundle')) return 'package'; if (normalized.includes('calculated') || normalized.includes('derived')) return 'calculated'; return 'standalone'; } function inferBookingType(point, defaults = {}) { if (point.bookingType || point.booking_type || point.productType || defaults.bookingType) { return point.bookingType || point.booking_type || point.productType || defaults.bookingType; } const haystack = [ point.source, point.sourceLabel, point.vendor, point.displayPrice, point.displayLabel, point.priceLabel, point.label, ].filter(Boolean).join(' ').toLowerCase(); if (haystack.includes('costco') || haystack.includes('apple vacation') || haystack.includes('cheapcaribbean')) { return 'package'; } if (haystack.includes('package') || haystack.includes('flight+hotel') || haystack.includes('flight + hotel')) { return 'package'; } if (haystack.includes('automation calculation')) return 'calculated'; return 'standalone'; } const GUEST_AUTH_SECRET = process.env.GUEST_AUTH_SECRET || 'cabo-bachelor-party-guest-auth'; function getGuestRoster() { return Array.isArray(data.guestRoster) ? data.guestRoster : []; } function findGuestByNameAndPin(name, pin) { const normalizedName = normalizeGuestName(name); const normalizedPin = normalizeGuestPin(pin); return getGuestRoster().find((guest) => ( normalizeGuestName(guest.name) === normalizedName && normalizeGuestPin(guest.last4) === normalizedPin )) || null; } function signGuestToken(guest) { const payload = { name: guest.name, last4: normalizeGuestPin(guest.last4), role: guest.role || 'guest', issuedAt: Date.now(), }; const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url'); const signature = crypto .createHmac('sha256', GUEST_AUTH_SECRET) .update(encodedPayload) .digest('base64url'); return `${encodedPayload}.${signature}`; } function verifyGuestToken(token) { if (typeof token !== 'string' || !token.includes('.')) return null; const [encodedPayload, signature] = token.split('.'); if (!encodedPayload || !signature) return null; const expectedSignature = crypto .createHmac('sha256', GUEST_AUTH_SECRET) .update(encodedPayload) .digest('base64url'); const sigA = Buffer.from(signature); const sigB = Buffer.from(expectedSignature); if (sigA.length !== sigB.length || !crypto.timingSafeEqual(sigA, sigB)) return null; try { const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')); const guest = findGuestByNameAndPin(payload.name, payload.last4); if (!guest) return null; return { name: guest.name, last4: normalizeGuestPin(guest.last4), role: guest.role || 'guest', }; } catch { return null; } } function readGuestAuthToken(req, body = {}) { const bodyToken = body.authToken || body.guestToken || body.token; if (typeof bodyToken === 'string' && bodyToken.trim()) return bodyToken.trim(); const headerToken = req.headers['x-guest-auth']; if (typeof headerToken === 'string' && headerToken.trim()) return headerToken.trim(); const authorization = req.headers.authorization || ''; if (authorization.toLowerCase().startsWith('bearer ')) { return authorization.slice(7).trim(); } return null; } function requireGuestAuth(req, res, body = {}) { const guest = verifyGuestToken(readGuestAuthToken(req, body)); if (!guest) { res.status(401).json({ error: 'Guest authentication required' }); return null; } return guest; } const HISTORY_KEY_ALIASES = { 'costco-breathless': 'hotel-breathless', 'costco-grand-fiesta': 'hotel-grand-fiesta', 'costco-secrets': 'hotel-secrets', 'costco-corazon': 'hotel-corazon', 'costco-pacifica': 'hotel-pacifica', 'costco-dreams': 'hotel-dreams-los-cabos', 'costco-zoetry': 'hotel-zoetry-casa-del-mar', 'costco-hard-rock': 'hotel-hard-rock-los-cabos', }; 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 latestNonEmptyArray(runs, fieldNames) { for (let index = runs.length - 1; index >= 0; index -= 1) { for (const fieldName of fieldNames) { if (Array.isArray(runs[index][fieldName]) && runs[index][fieldName].length) { return runs[index][fieldName]; } } } return null; } function getOptionHistoryKeys(option) { const nameKey = normalizeKey(option.name); const categoryNameKey = option.categoryId && nameKey ? `${option.categoryId}-${nameKey}` : ''; const rawKeys = [ option.seedKey, option.id, option.priceKey, option.optionKey, option.slug, categoryNameKey, nameKey, ].filter(Boolean).map(normalizeKey); return [...new Set(rawKeys.flatMap((key) => [key, HISTORY_KEY_ALIASES[key] || null].filter(Boolean)))]; } 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 = parseNumericPriceFromText(candidate); if (Number.isFinite(parsed)) return parsed; } } const textCandidates = [ point.displayPrice, point.displayLabel, point.priceLabel, point.label, point.note, point.description, ].filter(Boolean); for (const candidate of textCandidates) { const parsed = parseNumericPriceFromText(candidate, point.priceBasis || point.price_basis || point.unit); if (Number.isFinite(parsed)) return parsed; } return null; } function parseNumericPriceFromText(value, priceBasis = '') { if (typeof value !== 'string') return null; const normalized = value.replace(/\s+/g, ' ').trim(); if (!normalized || /\b(no|not)\s+(fresh\s+)?(price|rates?|available|counted|visible|captured)\b/i.test(normalized)) { return null; } const matches = [...normalized.matchAll(/\$?\s*([0-9]{1,3}(?:,[0-9]{3})*(?:\.[0-9]{1,2})?|[0-9]+(?:\.[0-9]{1,2})?)/g)] .map((match) => ({ value: Number(match[1].replace(/,/g, '')), index: match.index || 0, })) .filter((match) => Number.isFinite(match.value)); if (!matches.length) return null; const basis = normalizeKey(priceBasis); if (basis === 'totalpackage' || basis === 'pergroup') { return matches.at(-1).value; } const travelerMatch = matches.find((match) => ( /per\s+(traveler|person|guest|adult|round|table|night)|pp|\/night/i.test(normalized.slice(match.index, match.index + 80)) )); if (travelerMatch) return travelerMatch.value; return matches[0].value; } function inferPriceBasis(point, defaults = {}) { if (point.priceBasis || point.price_basis || point.unit || defaults.priceBasis) { return point.priceBasis || point.price_basis || point.unit || defaults.priceBasis; } const haystack = [ point.displayPrice, point.displayLabel, point.priceLabel, point.label, point.note, point.description, ].filter(Boolean).join(' ').toLowerCase(); if (haystack.includes('/night') || haystack.includes('per night')) return 'perNight'; if (haystack.includes('per traveler')) return 'perTraveler'; if (haystack.includes('per person') || /\bpp\b/.test(haystack)) return 'perPerson'; if (haystack.includes('per round')) return 'perRound'; if (haystack.includes('per table')) return 'perTable'; if (haystack.includes('total') || haystack.includes('package')) return 'totalPackage'; const bookingType = normalizeBookingType(inferBookingType(point, defaults)); if (bookingType === 'package') return 'perTraveler'; if (bookingType === 'calculated') return 'perPerson'; return null; } function getTripNightCount() { const checkInMs = Date.parse(`${TRIP_CHECK_IN}T00:00:00Z`); const checkOutMs = Date.parse(`${TRIP_CHECK_OUT}T00:00:00Z`); if (Number.isNaN(checkInMs) || Number.isNaN(checkOutMs) || checkOutMs <= checkInMs) return 1; return Math.max(1, Math.round((checkOutMs - checkInMs) / (24 * 60 * 60 * 1000))); } function isStayUnitPrice(priceBasis) { const normalized = normalizeKey(priceBasis); return ['pernight', 'perday', 'nightly', 'daily'].includes(normalized); } function normalizeTripPrice({ price, priceBasis, currency, displayPrice }) { if (!isStayUnitPrice(priceBasis)) { return { price, unitPrice: price, tripTotalPrice: price, displayPrice, tripNights: null, }; } const tripNights = getTripNightCount(); const tripTotalPrice = Number((price * tripNights).toFixed(2)); const unitLabel = displayPrice || `${formatCurrencyValue(price, currency)}/night`; return { price: tripTotalPrice, unitPrice: price, tripTotalPrice, displayPrice: `${formatCurrencyValue(tripTotalPrice, currency)} stay total (${unitLabel} x ${tripNights} nights)`, tripNights, }; } 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(); const addPointToSeries = (run, point, defaults = {}) => { const key = normalizeKey( point.optionKey || point.optionId || point.seedKey || point.slug || point.key || point.name, ); const price = extractNumericPrice({ ...defaults, ...point, }); if (!key || price === null) return; const currency = point.currency || defaults.currency || 'USD'; const priceBasis = inferPriceBasis(point, defaults); const rawDisplayPrice = point.displayPrice || point.priceLabel || point.displayLabel || point.label || defaults.displayPrice || null; const tripPrice = normalizeTripPrice({ price, priceBasis, currency, displayPrice: rawDisplayPrice, }); const nextPoint = { checkedAt: run.checkedAt, checkedAtMs: run.checkedAtMs, runIndex: run.runIndex, price: tripPrice.price, unitPrice: tripPrice.unitPrice, tripTotalPrice: tripPrice.tripTotalPrice, tripNights: tripPrice.tripNights, tripCheckIn: tripPrice.tripNights ? TRIP_CHECK_IN : null, tripCheckOut: tripPrice.tripNights ? TRIP_CHECK_OUT : null, currency, displayPrice: tripPrice.displayPrice, unitDisplayPrice: rawDisplayPrice, source: normalizeSourceLabel(point.source || point.sourceLabel || point.vendor || defaults.source || null), sourceKey: normalizeKey(point.sourceKey || point.sourceId || point.source || point.sourceLabel || point.vendor || defaults.sourceKey || 'unknown-source'), sourceUrl: point.sourceUrl || point.url || defaults.sourceUrl || null, bookingType: normalizeBookingType(inferBookingType(point, defaults)), priceBasis, includedComponents: toTextList(point.includedComponents || point.includesComponents || point.componentsIncluded || defaults.includedComponents), excludedComponents: toTextList(point.excludedComponents || point.componentsExcluded || defaults.excludedComponents), origin: point.origin || point.originAirport || defaults.origin || null, destination: point.destination || point.destinationAirport || defaults.destination || null, note: point.note || point.description || null, availability: point.availability || point.status || null, decisionNote: point.decisionNote || point.note || point.description || defaults.decisionNote || null, highlights: toTextList(point.highlights || point.summaryBullets || point.bullets || defaults.highlights), features: toTextList(point.features || point.featureHighlights || point.featureLabels || defaults.features), amenities: toTextList(point.amenities || point.amenityHighlights || point.amenityLabels || defaults.amenities), inclusions: toTextList(point.inclusions || point.includes || point.perks || defaults.inclusions), limitations: toTextList(point.limitations || point.tradeoffs || point.caveats || defaults.limitations), }; if (!seriesByKey.has(key)) seriesByKey.set(key, []); seriesByKey.get(key).push(nextPoint); }; 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) => { addPointToSeries(run, point); }); const derivedItineraries = Array.isArray(run.derivedItineraries) ? run.derivedItineraries : Array.isArray(run.itineraryScenarios) ? run.itineraryScenarios : []; derivedItineraries.forEach((itinerary) => { addPointToSeries(run, itinerary, { bookingType: 'calculated', priceBasis: 'perPerson', source: 'Automation calculation', sourceKey: 'automation-calculation', displayPrice: typeof itinerary.perPerson === 'number' ? `$${itinerary.perPerson.toLocaleString()} pp` : null, price: itinerary.perPerson, decisionNote: itinerary.summary, highlights: itinerary.assumptions, inclusions: itinerary.components, }); }); }); seriesByKey.forEach((series) => { series.sort((a, b) => a.runIndex - b.runIndex); }); return { runs, seriesByKey, budgetScenarios: latestNonEmptyArray(runs, ['budgetScenarios', 'derivedBudgetScenarios']), derivedItineraries: latestNonEmptyArray(runs, ['derivedItineraries', 'itineraryScenarios']), latestCheckedAt: runs.at(-1)?.checkedAt || null, totalRuns: runs.length, }; } function buildPriceHistoryBySource(priceHistory) { const grouped = new Map(); priceHistory.forEach((point) => { const sourceKey = normalizeKey(point.sourceKey || point.source || 'unknown-source'); const sourceLabel = normalizeSourceLabel(point.source || point.sourceLabel || sourceKey); if (!grouped.has(sourceKey)) { grouped.set(sourceKey, { sourceKey, sourceLabel, sourceUrl: point.sourceUrl || null, points: [], }); } const bucket = grouped.get(sourceKey); bucket.points.push({ ...point, sourceKey, source: sourceLabel, }); if (!bucket.sourceUrl && point.sourceUrl) bucket.sourceUrl = point.sourceUrl; if (bucket.sourceLabel === 'Unknown source' && sourceLabel) bucket.sourceLabel = sourceLabel; }); const seriesBySource = {}; const sourceSummaries = [...grouped.values()].map((bucket) => { bucket.points.sort((a, b) => a.runIndex - b.runIndex); seriesBySource[bucket.sourceKey] = bucket.points; const latestPoint = bucket.points.at(-1) || null; return { sourceKey: bucket.sourceKey, sourceLabel: bucket.sourceLabel, sourceUrl: bucket.sourceUrl, bookingType: latestPoint?.bookingType || null, priceBasis: latestPoint?.priceBasis || null, pointCount: bucket.points.length, latestCheckedAt: latestPoint?.checkedAt || null, latestCheckedAtMs: latestPoint?.checkedAtMs || 0, latestPrice: latestPoint?.price ?? null, latestDisplayPrice: latestPoint?.displayPrice || null, currency: latestPoint?.currency || 'USD', }; }).sort((a, b) => { const aMs = a.latestCheckedAtMs || 0; const bMs = b.latestCheckedAtMs || 0; if (aMs !== bMs) return bMs - aMs; return a.sourceLabel.localeCompare(b.sourceLabel); }); return { seriesBySource, sourceSummaries, }; } 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 { seriesBySource, sourceSummaries } = buildPriceHistoryBySource(priceHistory); const defaultSourceSummary = sourceSummaries[0] || null; const defaultSourceKey = defaultSourceSummary?.sourceKey || null; const defaultPriceHistory = defaultSourceKey ? seriesBySource[defaultSourceKey] || [] : priceHistory; const latestPricePoint = defaultPriceHistory.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: defaultPriceHistory, priceHistoryBySource: seriesBySource, availableSources: sourceSummaries, defaultSourceKey, currentSourceKey: defaultSourceKey, 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, bookingType: latestPricePoint.bookingType || null, priceBasis: latestPricePoint.priceBasis || null, includedComponents: latestPricePoint.includedComponents || [], excludedComponents: latestPricePoint.excludedComponents || [], 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); const budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || []; return { type: 'init', pollsOpen: data.pollsOpen, categories: data.categories, guestRoster: getGuestRoster().map((guest) => ({ name: guest.name, role: guest.role || 'guest', })), options: decorateOptionsWithPriceHistory(approvedOptions, priceHistoryState), results: approvedOptionsWithVoteSummary(), totalVoters: data.voters.length, 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/auth/guests', (req, res) => { res.json({ guests: getGuestRoster().map((guest) => ({ name: guest.name, role: guest.role || 'guest', })), }); }); app.get('/api/auth/me', (req, res) => { const guest = verifyGuestToken(readGuestAuthToken(req)); if (!guest) { return res.status(401).json({ authenticated: false }); } res.json({ authenticated: true, guest, }); }); app.post('/api/auth/login', (req, res) => { const { name, pin } = req.body || {}; const guest = findGuestByNameAndPin(name, pin); if (!guest) { return res.status(401).json({ error: 'Invalid guest name or code' }); } res.json({ success: true, token: signGuestToken(guest), guest: { name: guest.name, role: guest.role || 'guest', }, }); }); 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 budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || []; 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, 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: priceHistoryState.budgetScenarios || data.budgetScenarios || [], derivedItineraries: priceHistoryState.derivedItineraries || [], }); }); 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, budgetScenarios: priceHistoryState.budgetScenarios || [], derivedItineraries: priceHistoryState.derivedItineraries || [], }); }); app.post('/api/vote', (req, res) => { const { optionId } = req.body; const guest = requireGuestAuth(req, res, req.body); if (!optionId) { return res.status(400).json({ error: 'Missing fields' }); } if (!guest) return; 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 === guest.name) )); if (previousVote) { previousVote.votes = previousVote.votes.filter((vote) => vote.name !== guest.name); } option.votes.push({ name: guest.name, timestamp: Date.now() }); if (!data.voters.find((voter) => voter.name === guest.name)) { data.voters.push({ name: guest.name, 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 guest = requireGuestAuth(req, res, req.body); if (!guest) return; 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 !== guest.name); saveData(data); broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() }); res.json({ success: true }); }); app.post('/api/options', (req, res) => { const { categoryId, name, desc, url, lat, lng } = req.body; const guest = requireGuestAuth(req, res, req.body); if (!categoryId || !name) { return res.status(400).json({ error: 'Missing required fields' }); } if (!guest) return; 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: guest.name, 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, remove } = msg; const guest = verifyGuestToken(msg.authToken || msg.guestToken || null); if (!guest || !optionId) { ws.send(JSON.stringify({ type: 'error', message: 'Guest authentication required' })); 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 !== guest.name); } else { const previousVote = data.options.find((candidate) => ( candidate.categoryId === option.categoryId && candidate.votes.some((vote) => vote.name === guest.name) )); if (previousVote) { previousVote.votes = previousVote.votes.filter((vote) => vote.name !== guest.name); } option.votes.push({ name: guest.name, timestamp: Date.now() }); } if (!data.voters.find((voter) => voter.name === guest.name)) { data.voters.push({ name: guest.name, joinedAt: Date.now() }); } saveData(data); broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() }); } else if (msg.type === 'add_option') { const { categoryId, name, desc, url, lat, lng } = msg; const guest = verifyGuestToken(msg.authToken || msg.guestToken || null); if (!guest || !categoryId || !name) { ws.send(JSON.stringify({ type: 'error', message: 'Guest authentication required' })); return; } const newOption = createUserOption({ categoryId, name, desc, url, voterName: guest.name, 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}`); });