549 lines
16 KiB
JavaScript
549 lines
16 KiB
JavaScript
const express = require('express');
|
|
const { WebSocketServer } = require('ws');
|
|
const cors = require('cors');
|
|
const http = require('http');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { CATEGORY_META, buildSeedData, mergeSeedData } = require('./seed-data');
|
|
|
|
const app = express();
|
|
const server = http.createServer(app);
|
|
const wss = new WebSocketServer({ server });
|
|
|
|
const DEFAULT_DATA_DIR = path.join(__dirname, 'data');
|
|
const DATA_DIR = process.env.DATA_DIR
|
|
? path.resolve(process.env.DATA_DIR)
|
|
: DEFAULT_DATA_DIR;
|
|
const DATA_FILE = process.env.DATA_FILE
|
|
? path.resolve(process.env.DATA_FILE)
|
|
: path.join(DATA_DIR, 'votes.json');
|
|
const DEFAULT_PRICE_HISTORY_FILE = path.join(__dirname, 'price-watch', 'history.jsonl');
|
|
const PRICE_HISTORY_FILE = process.env.PRICE_HISTORY_FILE
|
|
? path.resolve(process.env.PRICE_HISTORY_FILE)
|
|
: DEFAULT_PRICE_HISTORY_FILE;
|
|
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
|
|
app.get('/admin', (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
|
|
});
|
|
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
function loadData() {
|
|
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
|
|
if (!fs.existsSync(DATA_FILE)) {
|
|
const seed = buildSeedData();
|
|
fs.writeFileSync(DATA_FILE, JSON.stringify(seed, null, 2));
|
|
return seed;
|
|
}
|
|
|
|
const existing = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
|
|
const merged = mergeSeedData(existing);
|
|
|
|
if (JSON.stringify(existing) !== JSON.stringify(merged)) {
|
|
fs.writeFileSync(DATA_FILE, JSON.stringify(merged, null, 2));
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
function saveData(nextData) {
|
|
fs.writeFileSync(DATA_FILE, JSON.stringify(nextData, null, 2));
|
|
}
|
|
|
|
function normalizeKey(value) {
|
|
return String(value || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '');
|
|
}
|
|
|
|
function readJsonLines(filePath) {
|
|
if (!fs.existsSync(filePath)) return [];
|
|
|
|
return fs.readFileSync(filePath, 'utf8')
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
try {
|
|
return JSON.parse(line);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function getOptionHistoryKeys(option) {
|
|
const nameKey = normalizeKey(option.name);
|
|
const categoryNameKey = option.categoryId && nameKey ? `${option.categoryId}-${nameKey}` : '';
|
|
|
|
return [...new Set([
|
|
option.seedKey,
|
|
option.id,
|
|
option.priceKey,
|
|
option.optionKey,
|
|
option.slug,
|
|
categoryNameKey,
|
|
nameKey,
|
|
].filter(Boolean).map(normalizeKey))];
|
|
}
|
|
|
|
function extractNumericPrice(point) {
|
|
const candidates = [
|
|
point.price,
|
|
point.value,
|
|
point.amount,
|
|
point.perPerson,
|
|
point.groupTotal,
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
if (typeof candidate === 'number' && Number.isFinite(candidate)) return candidate;
|
|
if (typeof candidate === 'string') {
|
|
const parsed = Number(candidate.replace(/[^0-9.-]/g, ''));
|
|
if (Number.isFinite(parsed)) return parsed;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function loadPriceHistoryState() {
|
|
const runs = readJsonLines(PRICE_HISTORY_FILE)
|
|
.map((entry, index) => {
|
|
const checkedAtRaw = entry.checkedAt || entry.checked_at || entry.runAt || entry.timestamp || entry.date || null;
|
|
const checkedAtMs = checkedAtRaw ? Date.parse(checkedAtRaw) : Date.now() + index;
|
|
|
|
return {
|
|
...entry,
|
|
checkedAt: Number.isNaN(checkedAtMs) ? null : new Date(checkedAtMs).toISOString(),
|
|
checkedAtMs: Number.isNaN(checkedAtMs) ? Date.now() + index : checkedAtMs,
|
|
};
|
|
})
|
|
.sort((a, b) => a.checkedAtMs - b.checkedAtMs);
|
|
|
|
runs.forEach((run, index) => {
|
|
run.runIndex = index;
|
|
});
|
|
|
|
const seriesByKey = new Map();
|
|
|
|
runs.forEach((run) => {
|
|
const pricePoints = Array.isArray(run.optionPrices)
|
|
? run.optionPrices
|
|
: Array.isArray(run.prices)
|
|
? run.prices
|
|
: Array.isArray(run.trackedPrices)
|
|
? run.trackedPrices
|
|
: [];
|
|
|
|
pricePoints.forEach((point) => {
|
|
const key = normalizeKey(
|
|
point.optionKey || point.optionId || point.seedKey || point.slug || point.key || point.name,
|
|
);
|
|
const price = extractNumericPrice(point);
|
|
|
|
if (!key || price === null) return;
|
|
|
|
const nextPoint = {
|
|
checkedAt: run.checkedAt,
|
|
checkedAtMs: run.checkedAtMs,
|
|
runIndex: run.runIndex,
|
|
price,
|
|
currency: point.currency || 'USD',
|
|
displayPrice: point.displayPrice || point.priceLabel || point.label || null,
|
|
source: point.source || null,
|
|
sourceUrl: point.sourceUrl || point.url || null,
|
|
note: point.note || point.description || null,
|
|
};
|
|
|
|
if (!seriesByKey.has(key)) seriesByKey.set(key, []);
|
|
seriesByKey.get(key).push(nextPoint);
|
|
});
|
|
});
|
|
|
|
seriesByKey.forEach((series) => {
|
|
series.sort((a, b) => a.runIndex - b.runIndex);
|
|
});
|
|
|
|
return {
|
|
runs,
|
|
seriesByKey,
|
|
latestCheckedAt: runs.at(-1)?.checkedAt || null,
|
|
totalRuns: runs.length,
|
|
};
|
|
}
|
|
|
|
function getPriceHistoryForOption(option, priceHistoryState) {
|
|
const optionKeys = getOptionHistoryKeys(option);
|
|
for (const key of optionKeys) {
|
|
const series = priceHistoryState.seriesByKey.get(key);
|
|
if (series && series.length) {
|
|
return series;
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function decorateOptionWithPriceHistory(option, priceHistoryState) {
|
|
const priceHistory = getPriceHistoryForOption(option, priceHistoryState);
|
|
const latestPricePoint = priceHistory.at(-1) || null;
|
|
|
|
return {
|
|
...option,
|
|
priceHistory,
|
|
latestPricePoint,
|
|
currentPrice: latestPricePoint?.price ?? null,
|
|
};
|
|
}
|
|
|
|
function decorateOptionsWithPriceHistory(options, priceHistoryState) {
|
|
return options.map((option) => decorateOptionWithPriceHistory(option, priceHistoryState));
|
|
}
|
|
|
|
function approvedOptionsWithVoteSummary() {
|
|
return data.options
|
|
.filter((option) => option.approved)
|
|
.map((option) => ({
|
|
id: option.id,
|
|
votes: option.votes.length,
|
|
voters: option.votes.map((vote) => vote.name),
|
|
}));
|
|
}
|
|
|
|
function broadcast(payload) {
|
|
const msg = JSON.stringify(payload);
|
|
wss.clients.forEach((client) => {
|
|
if (client.readyState === 1) client.send(msg);
|
|
});
|
|
}
|
|
|
|
function buildRealtimeSnapshot() {
|
|
const priceHistoryState = loadPriceHistoryState();
|
|
const approvedOptions = data.options.filter((option) => option.approved);
|
|
|
|
return {
|
|
type: 'init',
|
|
pollsOpen: data.pollsOpen,
|
|
categories: data.categories,
|
|
options: decorateOptionsWithPriceHistory(approvedOptions, priceHistoryState),
|
|
results: approvedOptionsWithVoteSummary(),
|
|
totalVoters: data.voters.length,
|
|
budgetScenarios: data.budgetScenarios || [],
|
|
priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null,
|
|
priceHistoryRunCount: priceHistoryState.totalRuns,
|
|
};
|
|
}
|
|
|
|
function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, approved }) {
|
|
return {
|
|
id: uuidv4(),
|
|
seedKey: null,
|
|
categoryId,
|
|
name: name.trim(),
|
|
desc: (desc || '').trim(),
|
|
url: url ? url.trim() : null,
|
|
links: url ? [{ label: 'Website', url: url.trim() }] : [],
|
|
lat: lat || null,
|
|
lng: lng || null,
|
|
addedBy: voterName,
|
|
approved,
|
|
votes: [],
|
|
details: [],
|
|
categoryColor: CATEGORY_META[categoryId]?.color || '#888',
|
|
};
|
|
}
|
|
|
|
let data = loadData();
|
|
|
|
app.get('/api/categories', (req, res) => {
|
|
res.json(data.categories);
|
|
});
|
|
|
|
app.get('/api/options', (req, res) => {
|
|
const { category, includeUnapproved } = req.query;
|
|
let options = data.options;
|
|
const priceHistoryState = loadPriceHistoryState();
|
|
|
|
if (category) options = options.filter((option) => option.categoryId === category);
|
|
if (!includeUnapproved) options = options.filter((option) => option.approved);
|
|
|
|
res.json(decorateOptionsWithPriceHistory(options, priceHistoryState));
|
|
});
|
|
|
|
app.get('/api/results', (req, res) => {
|
|
const priceHistoryState = loadPriceHistoryState();
|
|
const results = data.categories.map((category) => ({
|
|
...category,
|
|
options: data.options
|
|
.filter((option) => option.approved && option.categoryId === category.id)
|
|
.map((option) => ({
|
|
...decorateOptionWithPriceHistory(option, priceHistoryState),
|
|
voteCount: option.votes.length,
|
|
})),
|
|
}));
|
|
|
|
res.json({
|
|
pollsOpen: data.pollsOpen,
|
|
results,
|
|
totalVoters: data.voters.length,
|
|
budgetScenarios: data.budgetScenarios || [],
|
|
priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null,
|
|
priceHistoryRunCount: priceHistoryState.totalRuns,
|
|
});
|
|
});
|
|
|
|
app.get('/api/budgets', (req, res) => {
|
|
const priceHistoryState = loadPriceHistoryState();
|
|
res.json({
|
|
updatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null,
|
|
scenarios: data.budgetScenarios || [],
|
|
});
|
|
});
|
|
|
|
app.get('/api/price-history', (req, res) => {
|
|
const priceHistoryState = loadPriceHistoryState();
|
|
const seriesByOption = Object.fromEntries(
|
|
[...priceHistoryState.seriesByKey.entries()],
|
|
);
|
|
|
|
res.json({
|
|
latestCheckedAt: priceHistoryState.latestCheckedAt,
|
|
totalRuns: priceHistoryState.totalRuns,
|
|
seriesByOption,
|
|
});
|
|
});
|
|
|
|
app.post('/api/vote', (req, res) => {
|
|
const { optionId, voterName } = req.body;
|
|
|
|
if (!voterName || !optionId) {
|
|
return res.status(400).json({ error: 'Missing fields' });
|
|
}
|
|
if (!data.pollsOpen) {
|
|
return res.status(403).json({ error: 'Polls are closed' });
|
|
}
|
|
|
|
const option = data.options.find((candidate) => candidate.id === optionId);
|
|
if (!option || !option.approved) {
|
|
return res.status(404).json({ error: 'Option not found' });
|
|
}
|
|
|
|
const previousVote = data.options.find((candidate) => (
|
|
candidate.categoryId === option.categoryId
|
|
&& candidate.votes.some((vote) => vote.name === voterName)
|
|
));
|
|
|
|
if (previousVote) {
|
|
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName);
|
|
}
|
|
|
|
option.votes.push({ name: voterName, timestamp: Date.now() });
|
|
|
|
if (!data.voters.find((voter) => voter.name === voterName)) {
|
|
data.voters.push({ name: voterName, joinedAt: Date.now() });
|
|
}
|
|
|
|
saveData(data);
|
|
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
|
|
res.json({ success: true, voteCount: option.votes.length });
|
|
});
|
|
|
|
app.delete('/api/vote/:optionId', (req, res) => {
|
|
const { voterName } = req.body;
|
|
const option = data.options.find((candidate) => candidate.id === req.params.optionId);
|
|
|
|
if (!option) return res.status(404).json({ error: 'Not found' });
|
|
|
|
option.votes = option.votes.filter((vote) => vote.name !== voterName);
|
|
saveData(data);
|
|
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.post('/api/options', (req, res) => {
|
|
const { categoryId, name, desc, url, voterName, lat, lng } = req.body;
|
|
|
|
if (!categoryId || !name || !voterName) {
|
|
return res.status(400).json({ error: 'Missing required fields' });
|
|
}
|
|
|
|
const category = data.categories.find((candidate) => candidate.id === categoryId);
|
|
if (!category) return res.status(404).json({ error: 'Category not found' });
|
|
|
|
const newOption = createUserOption({
|
|
categoryId,
|
|
name,
|
|
desc,
|
|
url,
|
|
voterName,
|
|
lat,
|
|
lng,
|
|
approved: false,
|
|
});
|
|
|
|
data.options.push(newOption);
|
|
saveData(data);
|
|
broadcast({ type: 'option_added', option: newOption });
|
|
res.json({ success: true, option: newOption });
|
|
});
|
|
|
|
app.post('/api/options/:id/approve', (req, res) => {
|
|
const option = data.options.find((candidate) => candidate.id === req.params.id);
|
|
if (!option) return res.status(404).json({ error: 'Not found' });
|
|
|
|
option.approved = true;
|
|
saveData(data);
|
|
broadcast({ type: 'option_approved', option });
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.delete('/api/options/:id', (req, res) => {
|
|
const optionIndex = data.options.findIndex((candidate) => candidate.id === req.params.id);
|
|
if (optionIndex === -1) return res.status(404).json({ error: 'Not found' });
|
|
|
|
data.options.splice(optionIndex, 1);
|
|
saveData(data);
|
|
broadcast({ type: 'option_deleted', id: req.params.id });
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.post('/api/polls', (req, res) => {
|
|
data.pollsOpen = req.body.open !== undefined ? req.body.open : !data.pollsOpen;
|
|
saveData(data);
|
|
broadcast({ type: 'polls_status', open: data.pollsOpen });
|
|
res.json({ success: true, pollsOpen: data.pollsOpen });
|
|
});
|
|
|
|
const YELP_API_KEY = process.env.YELP_API_KEY || '';
|
|
|
|
app.get('/api/yelp', async (req, res) => {
|
|
const { term, location } = req.query;
|
|
if (!term) return res.status(400).json({ error: 'term is required' });
|
|
|
|
if (!YELP_API_KEY) {
|
|
return res.status(503).json({
|
|
error: 'YELP_API_KEY not configured on server. Add it as an environment variable.',
|
|
});
|
|
}
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
term: `${term} in ${location}`,
|
|
location: location || 'Los Cabos Mexico',
|
|
limit: '15',
|
|
sort_by: 'rating',
|
|
categories: 'restaurants,nightlife,active,arts,health',
|
|
});
|
|
|
|
const response = await fetch(`https://api.yelp.com/v3/businesses/search?${params}`, {
|
|
headers: {
|
|
Authorization: `Bearer ${YELP_API_KEY}`,
|
|
Accept: 'application/json',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
return res.status(response.status).json({ error: `Yelp API error: ${errorText}` });
|
|
}
|
|
|
|
const payload = await response.json();
|
|
const businesses = (payload.businesses || []).map((business) => ({
|
|
name: business.name,
|
|
image_url: business.image_url,
|
|
url: business.url,
|
|
rating: business.rating,
|
|
price: business.price,
|
|
coordinates: business.coordinates,
|
|
location: business.location,
|
|
categories: business.categories,
|
|
display_phone: business.display_phone,
|
|
distance: business.distance,
|
|
}));
|
|
|
|
res.json({ businesses, total: payload.total });
|
|
} catch (error) {
|
|
console.error('Yelp proxy error:', error);
|
|
res.status(500).json({ error: 'Failed to fetch from Yelp' });
|
|
}
|
|
});
|
|
|
|
wss.on('connection', (ws) => {
|
|
ws.send(JSON.stringify(buildRealtimeSnapshot()));
|
|
|
|
ws.on('message', (raw) => {
|
|
try {
|
|
const msg = JSON.parse(raw);
|
|
|
|
if (msg.type === 'vote') {
|
|
const { optionId, voterName, remove } = msg;
|
|
if (!voterName || !optionId) return;
|
|
if (!data.pollsOpen) {
|
|
ws.send(JSON.stringify({ type: 'error', message: 'Polls closed' }));
|
|
return;
|
|
}
|
|
|
|
const option = data.options.find((candidate) => candidate.id === optionId);
|
|
if (!option || !option.approved) return;
|
|
|
|
if (remove) {
|
|
option.votes = option.votes.filter((vote) => vote.name !== voterName);
|
|
} else {
|
|
const previousVote = data.options.find((candidate) => (
|
|
candidate.categoryId === option.categoryId
|
|
&& candidate.votes.some((vote) => vote.name === voterName)
|
|
));
|
|
if (previousVote) {
|
|
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName);
|
|
}
|
|
|
|
option.votes.push({ name: voterName, timestamp: Date.now() });
|
|
}
|
|
|
|
if (!data.voters.find((voter) => voter.name === voterName)) {
|
|
data.voters.push({ name: voterName, joinedAt: Date.now() });
|
|
}
|
|
|
|
saveData(data);
|
|
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
|
|
} else if (msg.type === 'add_option') {
|
|
const { categoryId, name, desc, url, voterName, lat, lng } = msg;
|
|
if (!categoryId || !name || !voterName) return;
|
|
|
|
const newOption = createUserOption({
|
|
categoryId,
|
|
name,
|
|
desc,
|
|
url,
|
|
voterName,
|
|
lat,
|
|
lng,
|
|
approved: true,
|
|
});
|
|
|
|
data.options.push(newOption);
|
|
saveData(data);
|
|
broadcast({ type: 'option_added', option: newOption });
|
|
}
|
|
} catch {
|
|
// Ignore malformed websocket payloads.
|
|
}
|
|
});
|
|
});
|
|
|
|
const PORT = process.env.PORT || 3001;
|
|
const HOST = process.env.HOST || '0.0.0.0';
|
|
|
|
server.listen(PORT, HOST, () => {
|
|
console.log(`🏄 Cabo Voting App → http://${HOST}:${PORT}`);
|
|
});
|