Add guest auth for Cabo voters

This commit is contained in:
TopherMayor
2026-04-30 19:58:22 -07:00
parent e5079cbce4
commit bdd2e5968f
4 changed files with 410 additions and 85 deletions

View File

@@ -20,6 +20,7 @@ node server.js
- **Source-selectable price tracking** — switch each option between Apple, Costco, KAYAK, and other tracked sources - **Source-selectable price tracking** — switch each option between Apple, Costco, KAYAK, and other tracked sources
- **Package vs standalone labels** — bundled flight+hotel quotes stay distinct from room-only, flight-only, tee-time, table, charter, and excursion prices - **Package vs standalone labels** — bundled flight+hotel quotes stay distinct from room-only, flight-only, tee-time, table, charter, and excursion prices
- **Decision detail cards** — automation-enriched pricing, features, amenities, and tradeoffs appear on each option - **Decision detail cards** — automation-enriched pricing, features, amenities, and tradeoffs appear on each option
- **Guest authentication** — bachelor-party voters sign in with their name and the last 4 digits of their phone number
- **Add suggestions** — anyone can propose new venues - **Add suggestions** — anyone can propose new venues
- **Admin approval** — pending options require approval before going live - **Admin approval** — pending options require approval before going live
- **Responsive** — works on desktop and mobile - **Responsive** — works on desktop and mobile
@@ -30,6 +31,7 @@ Votes are stored in `data/votes.json` (created on first run). Edit directly or u
System seed data auto-refreshes researched options while preserving existing votes and user-added options. System seed data auto-refreshes researched options while preserving existing votes and user-added options.
Price-watch automation runs append time-series snapshots in `price-watch/history.jsonl`, which the app turns into per-option trend lines and decision detail cards. Automation output should cover hotels, flights, golf, nightlife, and excursions, with `bookingType` and `priceBasis` separating package quotes from standalone booking prices. Price-watch automation runs append time-series snapshots in `price-watch/history.jsonl`, which the app turns into per-option trend lines and decision detail cards. Automation output should cover hotels, flights, golf, nightlife, and excursions, with `bookingType` and `priceBasis` separating package quotes from standalone booking prices.
When a run includes calculated `budgetScenarios` or `derivedItineraries`, the app uses those fresh automation calculations instead of the static seed budget scenarios. When a run includes calculated `budgetScenarios` or `derivedItineraries`, the app uses those fresh automation calculations instead of the static seed budget scenarios.
Guest access is rostered in `seed-data.js` and `data/votes.json`; Jon is marked as groom and Toph as best man.
For hosted deployments, set `DATA_DIR` or `DATA_FILE` so mutable vote data lives outside the Git checkout. For hosted deployments, set `DATA_DIR` or `DATA_FILE` so mutable vote data lives outside the Git checkout.
When price-watch automation updates tracked data files in the repository, commit/push those changes and refresh the Ubuntu deployment so the hosted app picks up the latest option details, price history, itinerary calculations, and budget scenarios. When price-watch automation updates tracked data files in the repository, commit/push those changes and refresh the Ubuntu deployment so the hosted app picks up the latest option details, price history, itinerary calculations, and budget scenarios.

View File

@@ -399,6 +399,16 @@
font-size: 0.75rem; font-size: 0.75rem;
color: var(--accent); color: var(--accent);
} }
.voter-badge .role-tag {
font-size: 0.62rem;
letter-spacing: 0.04em;
text-transform: uppercase;
background: rgba(52,211,153,0.12);
color: var(--green);
border: 1px solid rgba(52,211,153,0.22);
border-radius: 999px;
padding: 2px 8px;
}
.voter-badge button { .voter-badge button {
background: none; background: none;
border: none; border: none;
@@ -437,7 +447,8 @@
} }
.modal h2 { font-size: 1.3rem; margin-bottom: 6px; color: var(--accent); } .modal h2 { font-size: 1.3rem; margin-bottom: 6px; color: var(--accent); }
.modal p { color: var(--text-muted); font-size: 0.85rem; margin-bottom: 20px; } .modal p { color: var(--text-muted); font-size: 0.85rem; margin-bottom: 20px; }
.modal input { .modal input,
.modal select {
width: 100%; width: 100%;
padding: 10px 14px; padding: 10px 14px;
background: var(--surface2); background: var(--surface2);
@@ -449,7 +460,26 @@
margin-bottom: 12px; margin-bottom: 12px;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
.modal input:focus { border-color: var(--accent); } .modal input:focus,
.modal select:focus { border-color: var(--accent); }
.modal select {
appearance: none;
cursor: pointer;
}
.auth-help {
margin-top: -6px;
margin-bottom: 14px;
color: var(--text-muted);
font-size: 0.72rem;
line-height: 1.4;
}
.auth-status {
min-height: 18px;
margin: -6px 0 12px;
font-size: 0.75rem;
color: var(--red);
text-align: left;
}
.modal button { .modal button {
width: 100%; width: 100%;
padding: 10px; padding: 10px;
@@ -1322,14 +1352,17 @@
<body> <body>
<a class="skip-link" href="#optionsList">Skip to voting options</a> <a class="skip-link" href="#optionsList">Skip to voting options</a>
<!-- Name Modal --> <!-- Guest Auth Modal -->
<div class="modal-overlay" id="nameModal"> <div class="modal-overlay" id="authModal">
<div class="modal"> <div class="modal">
<div class="drag-handle"></div> <div class="drag-handle"></div>
<h2>🏄 Who's Voting?</h2> <h2>🔐 Guest Access</h2>
<p>Enter your name so groomsmen know who voted for what. Stored locally — only visible to the group.</p> <p>Select your name and enter the last 4 digits of your phone number to unlock voting.</p>
<input type="text" id="voterNameInput" placeholder="e.g. Mike, Chris, Dave…" maxlength="30" autocomplete="off" /> <select id="guestNameSelect" aria-label="Guest name"></select>
<button onclick="submitName()">Join the Vote →</button> <input type="password" id="guestPinInput" placeholder="Last 4 digits" maxlength="4" inputmode="numeric" autocomplete="one-time-code" />
<div class="auth-help" id="authRoleHelp">Jon is marked as the groom and Toph as the best man.</div>
<div class="auth-status" id="authError"></div>
<button onclick="submitAuth()">Join the Vote →</button>
</div> </div>
</div> </div>
@@ -1351,8 +1384,8 @@
<h1>📍 Cabo Bachelor Party — Vote</h1> <h1>📍 Cabo Bachelor Party — Vote</h1>
<div class="meta"> <div class="meta">
<div class="voter-badge" id="voterBadge" style="display:none;"> <div class="voter-badge" id="voterBadge" style="display:none;">
<span>👤 <span id="voterDisplayName"></span></span> <span>👤 <span id="voterDisplayName"></span> <span class="role-tag" id="voterDisplayRole" style="display:none;"></span></span>
<button onclick="changeName()" title="Change name"></button> <button onclick="changeName()" title="Switch guest"></button>
</div> </div>
</div> </div>
</header> </header>
@@ -1458,7 +1491,10 @@
<script> <script>
// ── State ────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────
let state = { let state = {
voterName: localStorage.getItem('cabo_voter_name') || '', voterName: '',
guestRole: localStorage.getItem('cabo_guest_role') || '',
guestAuthToken: localStorage.getItem('cabo_guest_auth_token') || '',
guestRoster: [],
categories: [], categories: [],
options: [], options: [],
budgetScenarios: [], budgetScenarios: [],
@@ -1475,6 +1511,7 @@
pollsOpen: true, pollsOpen: true,
totalVoters: 0, totalVoters: 0,
wsConnected: false, wsConnected: false,
authReady: false,
}; };
let ws = null; let ws = null;
let activeTab = 'hotel'; let activeTab = 'hotel';
@@ -1485,33 +1522,20 @@
// ── Init ─────────────────────────────────────────────────── // ── Init ───────────────────────────────────────────────────
function init() { function init() {
// Check for ?view=results URL param — skip name modal, go to results // Check for ?view=results URL param and keep the results tab active.
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const viewResults = params.get('view') === 'results'; const viewResults = params.get('view') === 'results';
if (state.voterName) {
applyVoterName(state.voterName);
// Session persists — skip name modal
document.getElementById('nameModal').classList.add('hidden');
}
// If view=results, skip name modal and go to results tab
if (viewResults) { if (viewResults) {
activeTab = 'results'; activeTab = 'results';
document.getElementById('nameModal').classList.add('hidden');
} }
connectWS(); connectWS();
renderTabs(); renderTabs();
render(); render();
schedulePriceRefresh(); schedulePriceRefresh();
renderGuestAuthOptions();
if (!viewResults) { restoreGuestSession();
document.getElementById('voterNameInput').addEventListener('keydown', e => {
if (e.key === 'Enter') submitName();
});
document.getElementById('voterNameInput').focus();
}
// Number keys 1-6 to switch tabs // Number keys 1-6 to switch tabs
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
@@ -1527,6 +1551,118 @@
}); });
} }
function getGuestRoleLabel(role) {
if (!role) return '';
if (role === 'groom') return 'Groom';
if (role === 'best-man') return 'Best Man';
if (role === 'guest') return '';
return role.replace(/-/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
}
function renderGuestAuthOptions() {
const select = document.getElementById('guestNameSelect');
if (!select) return;
const roster = Array.isArray(state.guestRoster) ? state.guestRoster : [];
const currentValue = select.value || state.voterName || '';
select.innerHTML = [
'<option value="" disabled selected>Select your name</option>',
...roster.map((guest) => {
const roleLabel = getGuestRoleLabel(guest.role);
const label = roleLabel ? `${guest.name} (${roleLabel})` : guest.name;
return `<option value="${escapeHtml(guest.name)}">${escapeHtml(label)}</option>`;
}),
].join('');
if (currentValue && roster.some((guest) => guest.name === currentValue)) {
select.value = currentValue;
}
}
function showAuthModal(message = '') {
document.getElementById('authModal')?.classList.remove('hidden');
const err = document.getElementById('authError');
if (err) err.textContent = message;
renderGuestAuthOptions();
const pinInput = document.getElementById('guestPinInput');
if (pinInput) pinInput.value = '';
setTimeout(() => document.getElementById('guestNameSelect')?.focus(), 0);
}
function hideAuthModal() {
document.getElementById('authModal')?.classList.add('hidden');
const err = document.getElementById('authError');
if (err) err.textContent = '';
}
async function restoreGuestSession() {
if (!state.guestAuthToken) {
state.authReady = true;
showAuthModal();
return;
}
try {
const res = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${state.guestAuthToken}` },
});
if (!res.ok) throw new Error('invalid session');
const payload = await res.json();
applyGuestSession(payload.guest, state.guestAuthToken, true);
} catch {
clearGuestSession();
showAuthModal('Your access code is no longer valid. Please sign in again.');
} finally {
state.authReady = true;
}
}
function clearGuestSession() {
state.voterName = '';
state.guestRole = '';
state.guestAuthToken = '';
localStorage.removeItem('cabo_guest_name');
localStorage.removeItem('cabo_guest_role');
localStorage.removeItem('cabo_guest_auth_token');
document.getElementById('voterBadge').style.display = 'none';
}
function applyGuestSession(guest, token, persist = false) {
state.voterName = guest.name;
state.guestRole = guest.role || 'guest';
state.guestAuthToken = token;
if (persist) {
localStorage.setItem('cabo_guest_name', guest.name);
localStorage.setItem('cabo_guest_role', state.guestRole);
localStorage.setItem('cabo_guest_auth_token', token);
}
updateGuestBadge();
hideAuthModal();
render();
if (pendingVoteOptionId) {
openVoteConfirm(pendingVoteOptionId, pendingVoteRemove);
}
}
function updateGuestBadge() {
if (!state.voterName) {
document.getElementById('voterBadge').style.display = 'none';
return;
}
document.getElementById('voterDisplayName').textContent = state.voterName;
const roleEl = document.getElementById('voterDisplayRole');
const roleLabel = getGuestRoleLabel(state.guestRole);
if (roleEl) {
roleEl.textContent = roleLabel;
roleEl.style.display = roleLabel ? 'inline-flex' : 'none';
}
document.getElementById('voterBadge').style.display = 'inline-flex';
}
// ── WebSocket ────────────────────────────────────────────── // ── WebSocket ──────────────────────────────────────────────
const RECONNECT_BASE = 1000; // 1s initial const RECONNECT_BASE = 1000; // 1s initial
const RECONNECT_MAX = 30000; // 30s cap const RECONNECT_MAX = 30000; // 30s cap
@@ -1560,15 +1696,18 @@
ws.onmessage = (event) => { ws.onmessage = (event) => {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
if (msg.type === 'init') { if (msg.type === 'init') {
state.categories = msg.categories; state.categories = msg.categories;
state.options = msg.options; state.guestRoster = msg.guestRoster || [];
state.budgetScenarios = msg.budgetScenarios || []; state.options = msg.options;
state.priceUpdatedAt = msg.priceUpdatedAt || ''; state.budgetScenarios = msg.budgetScenarios || [];
state.priceHistoryRunCount = msg.priceHistoryRunCount || 0; state.priceUpdatedAt = msg.priceUpdatedAt || '';
state.pollsOpen = msg.pollsOpen; state.priceHistoryRunCount = msg.priceHistoryRunCount || 0;
state.totalVoters = msg.totalVoters; state.pollsOpen = msg.pollsOpen;
renderTabs(); state.totalVoters = msg.totalVoters;
render(); renderGuestAuthOptions();
updateGuestBadge();
renderTabs();
render();
} else if (msg.type === 'vote_update') { } else if (msg.type === 'vote_update') {
msg.results.forEach(r => { msg.results.forEach(r => {
const opt = state.options.find(o => o.id === r.id); const opt = state.options.find(o => o.id === r.id);
@@ -1591,6 +1730,8 @@
} else if (msg.type === 'polls_status') { } else if (msg.type === 'polls_status') {
state.pollsOpen = msg.open; state.pollsOpen = msg.open;
updatePollsBadge(); updatePollsBadge();
} else if (msg.type === 'error') {
showToast(msg.message || 'Request failed', 'error');
} }
}; };
@@ -2144,29 +2285,44 @@
`; `;
} }
// ── Name modal ──────────────────────────────────────────── // ── Guest auth modal ───────────────────────────────────────
function submitName() { async function submitAuth() {
const name = document.getElementById('voterNameInput').value.trim(); const name = document.getElementById('guestNameSelect').value.trim();
if (!name) return; const pin = document.getElementById('guestPinInput').value.trim();
state.voterName = name; const err = document.getElementById('authError');
localStorage.setItem('cabo_voter_name', name);
applyVoterName(name); if (!name) {
document.getElementById('nameModal').classList.add('hidden'); if (err) err.textContent = 'Choose your name first.';
render(); return;
if (pendingVoteOptionId) { }
openVoteConfirm(pendingVoteOptionId, pendingVoteRemove); if (!/^\d{4}$/.test(pin)) {
if (err) err.textContent = 'Enter the last 4 digits of your phone number.';
return;
}
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, pin }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
if (err) err.textContent = payload.error || 'That name and code did not match.';
return;
}
applyGuestSession(payload.guest, payload.token, true);
showToast(`Welcome, ${payload.guest.name}!`, 'success');
} catch {
if (err) err.textContent = 'Could not reach the server. Try again.';
} }
} }
function applyVoterName(name) {
document.getElementById('voterDisplayName').textContent = name;
document.getElementById('voterBadge').style.display = 'inline-flex';
}
function changeName() { function changeName() {
document.getElementById('voterNameInput').value = state.voterName; clearGuestSession();
document.getElementById('nameModal').classList.remove('hidden'); showAuthModal();
document.getElementById('voterNameInput').focus();
} }
function openVoteConfirm(optionId, remove = false) { function openVoteConfirm(optionId, remove = false) {
@@ -2174,8 +2330,7 @@
if (!state.voterName) { if (!state.voterName) {
pendingVoteOptionId = optionId; pendingVoteOptionId = optionId;
pendingVoteRemove = remove; pendingVoteRemove = remove;
document.getElementById('nameModal').classList.remove('hidden'); showAuthModal();
document.getElementById('voterNameInput').focus();
return; return;
} }
if (!state.pollsOpen) { if (!state.pollsOpen) {
@@ -2220,7 +2375,7 @@
closeVoteConfirm(); closeVoteConfirm();
return; return;
} }
wsSend({ type: 'vote', optionId, voterName: state.voterName, remove }); wsSend({ type: 'vote', optionId, voterName: state.voterName, authToken: state.guestAuthToken, remove });
showToast(remove ? `Removed vote for ${opt.name}` : `Voted for ${opt.name}!`); showToast(remove ? `Removed vote for ${opt.name}` : `Voted for ${opt.name}!`);
closeVoteConfirm(); closeVoteConfirm();
} }
@@ -2455,12 +2610,11 @@
return; return;
} }
if (!state.voterName) { if (!state.voterName) {
document.getElementById('nameModal').classList.remove('hidden'); showAuthModal();
document.getElementById('voterNameInput').focus();
return; return;
} }
wsSend({ type: 'add_option', categoryId: catId, name, desc, url, voterName: state.voterName }); wsSend({ type: 'add_option', categoryId: catId, name, desc, url, voterName: state.voterName, authToken: state.guestAuthToken });
// Clear form // Clear form
document.getElementById('addName').value = ''; document.getElementById('addName').value = '';

View File

@@ -1,4 +1,4 @@
const SEED_VERSION = 5; const SEED_VERSION = 6;
const PRICE_UPDATED_AT = '2026-04-29'; const PRICE_UPDATED_AT = '2026-04-29';
const CATEGORY_META = { const CATEGORY_META = {
@@ -12,6 +12,23 @@ const CATEGORY_META = {
results: { emoji: '🏆', color: '#facc15' }, results: { emoji: '🏆', color: '#facc15' },
}; };
const GUEST_ROSTER = [
{ name: 'Jon', last4: '7506', role: 'groom' },
{ name: 'Toph', last4: '8116', role: 'best-man' },
{ name: 'Hans', last4: '6681', role: 'guest' },
{ name: 'Janno', last4: '2809', role: 'guest' },
{ name: 'JT', last4: '3286', role: 'guest' },
{ name: 'Cordero', last4: '0379', role: 'guest' },
{ name: 'Lester', last4: '8014', role: 'guest' },
{ name: 'Nick', last4: '6044', role: 'guest' },
{ name: 'David', last4: '5993', role: 'guest' },
{ name: 'Poalo', last4: '9922', role: 'guest' },
{ name: 'Justin', last4: '2329', role: 'guest' },
{ name: 'Ben Stewart', last4: '1957', role: 'guest' },
{ name: 'Joseph', last4: '4976', role: 'guest' },
{ name: 'Francis', last4: '4934', role: 'guest' },
];
const BUDGET_SCENARIOS = [ const BUDGET_SCENARIOS = [
{ {
id: 'budget-8', id: 'budget-8',
@@ -190,6 +207,7 @@ function buildSeedData() {
{ id: 'budget', name: 'Budget', emoji: '💸' }, { id: 'budget', name: 'Budget', emoji: '💸' },
{ id: 'results', name: 'Results', emoji: '🏆' }, { id: 'results', name: 'Results', emoji: '🏆' },
], ],
guestRoster: GUEST_ROSTER,
budgetScenarios: BUDGET_SCENARIOS, budgetScenarios: BUDGET_SCENARIOS,
options: [ options: [
createOption({ createOption({
@@ -738,6 +756,7 @@ function mergeSeedData(existing = {}) {
priceUpdatedAt: seed.priceUpdatedAt, priceUpdatedAt: seed.priceUpdatedAt,
categories: [...seed.categories, ...preservedCustomCategories], categories: [...seed.categories, ...preservedCustomCategories],
budgetScenarios: seed.budgetScenarios, budgetScenarios: seed.budgetScenarios,
guestRoster: seed.guestRoster,
options: [...mergedSeedOptions, ...preservedCustomOptions], options: [...mergedSeedOptions, ...preservedCustomOptions],
voters: Array.isArray(existing.voters) ? existing.voters : [], voters: Array.isArray(existing.voters) ? existing.voters : [],
pollsOpen: typeof existing.pollsOpen === 'boolean' ? existing.pollsOpen : true, pollsOpen: typeof existing.pollsOpen === 'boolean' ? existing.pollsOpen : true,

196
server.js
View File

@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const { WebSocketServer } = require('ws'); const { WebSocketServer } = require('ws');
const cors = require('cors'); const cors = require('cors');
const crypto = require('crypto');
const http = require('http'); const http = require('http');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
@@ -65,6 +66,14 @@ function normalizeKey(value) {
.replace(/^-+|-+$/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) { function normalizeSourceLabel(value) {
return String(value || 'Unknown source').trim() || 'Unknown source'; return String(value || 'Unknown source').trim() || 'Unknown source';
} }
@@ -112,6 +121,91 @@ function inferBookingType(point, defaults = {}) {
return 'standalone'; 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 = { const HISTORY_KEY_ALIASES = {
'costco-breathless': 'hotel-breathless', 'costco-breathless': 'hotel-breathless',
'costco-grand-fiesta': 'hotel-grand-fiesta', 'costco-grand-fiesta': 'hotel-grand-fiesta',
@@ -597,6 +691,10 @@ function buildRealtimeSnapshot() {
type: 'init', type: 'init',
pollsOpen: data.pollsOpen, pollsOpen: data.pollsOpen,
categories: data.categories, categories: data.categories,
guestRoster: getGuestRoster().map((guest) => ({
name: guest.name,
role: guest.role || 'guest',
})),
options: decorateOptionsWithPriceHistory(approvedOptions, priceHistoryState), options: decorateOptionsWithPriceHistory(approvedOptions, priceHistoryState),
results: approvedOptionsWithVoteSummary(), results: approvedOptionsWithVoteSummary(),
totalVoters: data.voters.length, totalVoters: data.voters.length,
@@ -627,6 +725,45 @@ function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, ap
let data = loadData(); 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) => { app.get('/api/categories', (req, res) => {
res.json(data.categories); res.json(data.categories);
}); });
@@ -690,11 +827,13 @@ app.get('/api/price-history', (req, res) => {
}); });
app.post('/api/vote', (req, res) => { app.post('/api/vote', (req, res) => {
const { optionId, voterName } = req.body; const { optionId } = req.body;
const guest = requireGuestAuth(req, res, req.body);
if (!voterName || !optionId) { if (!optionId) {
return res.status(400).json({ error: 'Missing fields' }); return res.status(400).json({ error: 'Missing fields' });
} }
if (!guest) return;
if (!data.pollsOpen) { if (!data.pollsOpen) {
return res.status(403).json({ error: 'Polls are closed' }); return res.status(403).json({ error: 'Polls are closed' });
} }
@@ -706,17 +845,17 @@ app.post('/api/vote', (req, res) => {
const previousVote = data.options.find((candidate) => ( const previousVote = data.options.find((candidate) => (
candidate.categoryId === option.categoryId candidate.categoryId === option.categoryId
&& candidate.votes.some((vote) => vote.name === voterName) && candidate.votes.some((vote) => vote.name === guest.name)
)); ));
if (previousVote) { if (previousVote) {
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName); previousVote.votes = previousVote.votes.filter((vote) => vote.name !== guest.name);
} }
option.votes.push({ name: voterName, timestamp: Date.now() }); option.votes.push({ name: guest.name, timestamp: Date.now() });
if (!data.voters.find((voter) => voter.name === voterName)) { if (!data.voters.find((voter) => voter.name === guest.name)) {
data.voters.push({ name: voterName, joinedAt: Date.now() }); data.voters.push({ name: guest.name, joinedAt: Date.now() });
} }
saveData(data); saveData(data);
@@ -725,23 +864,26 @@ app.post('/api/vote', (req, res) => {
}); });
app.delete('/api/vote/:optionId', (req, res) => { app.delete('/api/vote/:optionId', (req, res) => {
const { voterName } = req.body; const guest = requireGuestAuth(req, res, req.body);
if (!guest) return;
const option = data.options.find((candidate) => candidate.id === req.params.optionId); const option = data.options.find((candidate) => candidate.id === req.params.optionId);
if (!option) return res.status(404).json({ error: 'Not found' }); if (!option) return res.status(404).json({ error: 'Not found' });
option.votes = option.votes.filter((vote) => vote.name !== voterName); option.votes = option.votes.filter((vote) => vote.name !== guest.name);
saveData(data); saveData(data);
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() }); broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
res.json({ success: true }); res.json({ success: true });
}); });
app.post('/api/options', (req, res) => { app.post('/api/options', (req, res) => {
const { categoryId, name, desc, url, voterName, lat, lng } = req.body; const { categoryId, name, desc, url, lat, lng } = req.body;
const guest = requireGuestAuth(req, res, req.body);
if (!categoryId || !name || !voterName) { if (!categoryId || !name) {
return res.status(400).json({ error: 'Missing required fields' }); return res.status(400).json({ error: 'Missing required fields' });
} }
if (!guest) return;
const category = data.categories.find((candidate) => candidate.id === categoryId); const category = data.categories.find((candidate) => candidate.id === categoryId);
if (!category) return res.status(404).json({ error: 'Category not found' }); if (!category) return res.status(404).json({ error: 'Category not found' });
@@ -751,7 +893,7 @@ app.post('/api/options', (req, res) => {
name, name,
desc, desc,
url, url,
voterName, voterName: guest.name,
lat, lat,
lng, lng,
approved: false, approved: false,
@@ -852,8 +994,12 @@ wss.on('connection', (ws) => {
const msg = JSON.parse(raw); const msg = JSON.parse(raw);
if (msg.type === 'vote') { if (msg.type === 'vote') {
const { optionId, voterName, remove } = msg; const { optionId, remove } = msg;
if (!voterName || !optionId) return; 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) { if (!data.pollsOpen) {
ws.send(JSON.stringify({ type: 'error', message: 'Polls closed' })); ws.send(JSON.stringify({ type: 'error', message: 'Polls closed' }));
return; return;
@@ -863,35 +1009,39 @@ wss.on('connection', (ws) => {
if (!option || !option.approved) return; if (!option || !option.approved) return;
if (remove) { if (remove) {
option.votes = option.votes.filter((vote) => vote.name !== voterName); option.votes = option.votes.filter((vote) => vote.name !== guest.name);
} else { } else {
const previousVote = data.options.find((candidate) => ( const previousVote = data.options.find((candidate) => (
candidate.categoryId === option.categoryId candidate.categoryId === option.categoryId
&& candidate.votes.some((vote) => vote.name === voterName) && candidate.votes.some((vote) => vote.name === guest.name)
)); ));
if (previousVote) { if (previousVote) {
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName); previousVote.votes = previousVote.votes.filter((vote) => vote.name !== guest.name);
} }
option.votes.push({ name: voterName, timestamp: Date.now() }); option.votes.push({ name: guest.name, timestamp: Date.now() });
} }
if (!data.voters.find((voter) => voter.name === voterName)) { if (!data.voters.find((voter) => voter.name === guest.name)) {
data.voters.push({ name: voterName, joinedAt: Date.now() }); data.voters.push({ name: guest.name, joinedAt: Date.now() });
} }
saveData(data); saveData(data);
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() }); broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
} else if (msg.type === 'add_option') { } else if (msg.type === 'add_option') {
const { categoryId, name, desc, url, voterName, lat, lng } = msg; const { categoryId, name, desc, url, lat, lng } = msg;
if (!categoryId || !name || !voterName) return; 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({ const newOption = createUserOption({
categoryId, categoryId,
name, name,
desc, desc,
url, url,
voterName, voterName: guest.name,
lat, lat,
lng, lng,
approved: true, approved: true,