Files
cabo-voting-app/server.js
2026-06-12 13:18:43 -07:00

1196 lines
39 KiB
JavaScript

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 PUBLIC_DIR = path.join(__dirname, 'public');
const CLIENT_DIST_DIR = path.join(__dirname, 'client', 'dist');
const CLIENT_INDEX_FILE = path.join(CLIENT_DIST_DIR, 'index.html');
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-02';
const TRIP_CHECK_OUT = process.env.TRIP_CHECK_OUT || '2027-02-06';
const PREVIEW_IMAGE_CACHE_MS = 1000 * 60 * 60 * 24;
const previewImageCache = new Map();
app.use(cors());
app.use(express.json());
app.get('/admin', (req, res) => {
res.sendFile(path.join(PUBLIC_DIR, 'admin.html'));
});
if (fs.existsSync(CLIENT_INDEX_FILE)) {
app.use(express.static(CLIENT_DIST_DIR));
}
app.use(express.static(PUBLIC_DIR));
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 parseHttpUrl(value) {
try {
const url = new URL(String(value || '').trim());
if (!['http:', 'https:'].includes(url.protocol)) return null;
return url;
} catch {
return null;
}
}
function resolveHtmlUrl(value, baseUrl) {
if (!value) return null;
try {
const resolved = new URL(value, baseUrl);
if (!['http:', 'https:'].includes(resolved.protocol)) return null;
return resolved.toString();
} catch {
return null;
}
}
function extractPreviewImage(html, pageUrl) {
const patterns = [
/<meta[^>]+property=["']og:image:secure_url["'][^>]+content=["']([^"']+)["'][^>]*>/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image:secure_url["'][^>]*>/i,
/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["'][^>]*>/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["'][^>]*>/i,
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["'][^>]*>/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:image["'][^>]*>/i,
/<link[^>]+rel=["'][^"']*image_src[^"']*["'][^>]+href=["']([^"']+)["'][^>]*>/i,
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["'][^"']*image_src[^"']*["'][^>]*>/i,
];
for (const pattern of patterns) {
const match = html.match(pattern);
const imageUrl = resolveHtmlUrl(match?.[1], pageUrl);
if (imageUrl) return imageUrl;
}
return null;
}
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: point.tripCheckIn || defaults.tripCheckIn || (tripPrice.tripNights ? TRIP_CHECK_IN : null),
tripCheckOut: point.tripCheckOut || defaults.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: latestPoint?.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,
tripCheckIn: TRIP_CHECK_IN,
tripCheckOut: TRIP_CHECK_OUT,
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 normalizeDetails(details) {
if (Array.isArray(details)) {
return details.map((item) => String(item || '').trim()).filter(Boolean);
}
if (typeof details === 'string') {
return details
.split(/\n+/)
.map((item) => item.trim())
.filter(Boolean);
}
return [];
}
function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, approved, details }) {
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: normalizeDetails(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/preview-image', async (req, res) => {
const pageUrl = parseHttpUrl(req.query.url);
if (!pageUrl) return res.status(400).send('A valid http(s) url is required');
const cacheKey = pageUrl.toString();
const cached = previewImageCache.get(cacheKey);
if (cached && Date.now() - cached.checkedAt < PREVIEW_IMAGE_CACHE_MS) {
return res.redirect(302, cached.imageUrl);
}
const fallbackUrl = new URL('/favicon.ico', pageUrl.origin).toString();
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(pageUrl, {
signal: controller.signal,
headers: {
Accept: 'text/html,application/xhtml+xml',
'User-Agent': 'Mozilla/5.0 (compatible; CaboVotePreview/1.0)',
},
});
clearTimeout(timeout);
if (!response.ok) throw new Error(`Preview fetch failed: ${response.status}`);
const html = await response.text();
const imageUrl = extractPreviewImage(html.slice(0, 250000), pageUrl.toString()) || fallbackUrl;
previewImageCache.set(cacheKey, { imageUrl, checkedAt: Date.now() });
return res.redirect(302, imageUrl);
} catch (error) {
previewImageCache.set(cacheKey, { imageUrl: fallbackUrl, checkedAt: Date.now() });
return res.redirect(302, fallbackUrl);
}
});
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, details } = 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,
details,
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.patch('/api/options/:id', (req, res) => {
const option = data.options.find((candidate) => candidate.id === req.params.id);
if (!option) return res.status(404).json({ error: 'Not found' });
const { categoryId, name, desc, url, lat, lng, details, approved } = req.body;
if (categoryId !== undefined) option.categoryId = categoryId;
if (name !== undefined) option.name = String(name || '').trim();
if (desc !== undefined) option.desc = String(desc || '').trim();
if (url !== undefined) option.url = String(url || '').trim() || null;
if (lat !== undefined) option.lat = Number.isFinite(Number(lat)) ? Number(lat) : null;
if (lng !== undefined) option.lng = Number.isFinite(Number(lng)) ? Number(lng) : null;
if (details !== undefined) option.details = normalizeDetails(details);
if (approved !== undefined) option.approved = Boolean(approved);
saveData(data);
broadcast({ type: 'option_updated', option });
res.json({ success: true, option });
});
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' });
}
});
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api/') || req.path === '/admin') {
return next();
}
if (!fs.existsSync(CLIENT_INDEX_FILE)) {
return next();
}
return res.sendFile(CLIENT_INDEX_FILE);
});
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}`);
});