1196 lines
39 KiB
JavaScript
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}`);
|
|
});
|