← back to Prestige Car Wash
reports/deployed-20260720/server.js
508 lines
'use strict';
/**
* Prestige Car Wash (PCW) — one Express app serving:
* - the public marketing site (/, /services, /contact)
* - the Basic-Auth growth admin (/admin) + its bucket APIs (/api/admin/*)
*
* Data source: data/*.json snapshots (Postgres is an optional future upgrade; the
* schema in scripts/db-init.sql mirrors these files). Everything the front-end needs
* comes through /api/* so the same catalog powers both the public page and the admin.
*/
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const express = require('express');
const helmet = require('helmet');
const app = express();
const PORT = process.env.PORT || 9808;
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
const ADMIN_PASS = process.env.ADMIN_PASS || 'DW2024!';
const DATA = path.join(__dirname, 'data');
// ---- helpers ---------------------------------------------------------------
const readJSON = (f, fallback) => {
try { return JSON.parse(fs.readFileSync(path.join(DATA, f), 'utf8')); }
catch { return fallback; }
};
// Re-read on each request so admin edits / script runs show up without a restart.
const services = () => readJSON('services.json', []);
const competitors = () => readJSON('competitors.json', []);
const suggestions = () => readJSON('suggestions.json', []);
const holidays = () => readJSON('holidays.json', []);
const directories = () => readJSON('directories.json', []);
const ads = () => readJSON('ads.json', []);
const bestTimes = () => readJSON('best-times.json', {});
const places = () => readJSON('places.json', {});
const coupons = () => readJSON('coupons.json', []);
const plans = () => readJSON('plans.json', []);
const area = () => readJSON('area.json', {});
const partners = () => readJSON('partners.json', []);
// Mask a secret to a "present (…last4)" descriptor — never returns the value.
const maskEnv = (key) => {
const v = process.env[key];
if (!v) return { key, present: false, hint: '' };
return { key, present: true, hint: '…' + String(v).slice(-4) };
};
// ---- middleware ------------------------------------------------------------
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
// inline event-handler attributes (onclick=, onerror=) are a separate directive;
// Helmet defaults it to 'none'. The site/admin use inline handlers (house style,
// Basic-Auth admin, no user-generated content) so allow them explicitly.
scriptSrcAttr: ["'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
imgSrc: ["'self'", 'data:', 'https:'],
mediaSrc: ["'self'", 'data:', 'blob:'],
connectSrc: ["'self'"],
// keyless Google Maps embed on /visit
frameSrc: ["'self'", 'https://www.google.com', 'https://maps.google.com']
}
}
}));
app.use(express.json());
// Basic Auth gate for /admin and /api/admin/*
function requireAdmin(req, res, next) {
const hdr = req.headers.authorization || '';
const [scheme, encoded] = hdr.split(' ');
if (scheme === 'Basic' && encoded) {
const [u, p] = Buffer.from(encoded, 'base64').toString().split(':');
if (u === ADMIN_USER && p === ADMIN_PASS) return next();
}
res.set('WWW-Authenticate', 'Basic realm="Prestige Admin"').status(401).send('Auth required');
}
app.use('/admin', requireAdmin);
app.use('/api/admin', requireAdmin);
// ---- sort (shared by public services grid) --------------------------------
function sortServices(list, mode) {
const arr = [...list];
const byTitle = (a, b) => String(a.name).localeCompare(String(b.name));
switch (mode) {
case 'title': return arr.sort(byTitle);
case 'price-asc': return arr.sort((a, b) => (a.price || 0) - (b.price || 0) || byTitle(a, b));
case 'price-desc': return arr.sort((a, b) => (b.price || 0) - (a.price || 0) || byTitle(a, b));
case 'duration': return arr.sort((a, b) => (a.duration_min || 0) - (b.duration_min || 0) || byTitle(a, b));
case 'featured':
default: return arr.sort((a, b) => (b.featured - a.featured) || (a.sort_order || 99) - (b.sort_order || 99));
}
}
// Price bands for the left filter panel.
const priceBand = (p) => p <= 25 ? 'Under $25' : p <= 60 ? '$25–$60' : p <= 150 ? '$60–$150' : '$150+';
// ---- public API ------------------------------------------------------------
app.get('/api/health', (req, res) => {
res.json({
ok: true, service: 'prestige-car-wash', port: PORT,
counts: {
services: services().length, competitors: competitors().length,
suggestions: suggestions().length, holidays: holidays().length,
directories: directories().length, ads: ads().length
},
ts: new Date().toISOString()
});
});
app.get('/api/services', (req, res) => {
let list = services().map(s => ({ ...s, price_band: priceBand(s.price || 0) }));
const { category, band, q } = req.query;
if (category) list = list.filter(s => s.category === category);
if (band) list = list.filter(s => s.price_band === band);
if (q) {
const needle = String(q).toLowerCase();
list = list.filter(s => [s.name, s.blurb, s.category].join(' ').toLowerCase().includes(needle));
}
res.json(sortServices(list, req.query.sort));
});
// Facet counts for the left panel — each dimension counted over the OTHER active filters.
app.get('/api/facets', (req, res) => {
const base = services().map(s => ({ ...s, price_band: priceBand(s.price || 0) }));
const tally = (rows, key) => rows.reduce((m, r) => (m[r[key]] = (m[r[key]] || 0) + 1, m), {});
const applyExcept = (except) => base.filter(s =>
(except === 'category' || !req.query.category || s.category === req.query.category) &&
(except === 'band' || !req.query.band || s.price_band === req.query.band)
);
res.json({
category: tally(applyExcept('category'), 'category'),
band: tally(applyExcept('band'), 'price_band')
});
});
// ---- universal search (nav 🔍 overlay on every public page) ----------------
const SEARCH_PAGES = [
{ title: 'Home', url: '/', kw: 'prestige car wash reseda san fernando valley hand wash home welcome' },
{ title: 'Services & Pricing',url: '/services', kw: 'services pricing wash detail detailing ceramic coating wax interior exterior engine price list' },
{ title: 'Membership', url: '/membership', kw: 'membership unlimited monthly plan subscribe signup member' },
{ title: 'Coupons', url: '/coupons', kw: 'coupons coupon deals discount savings offer promo code' },
{ title: 'Visit Us', url: '/visit', kw: 'visit hours location address directions parking webcam camera weather best times busy open closed map' },
{ title: 'About', url: '/about', kw: 'about story team family owned armenian history' },
{ title: "Contact", url: "/contact", kw: "contact phone call email message quote question appointment walk in first come first served no appointment" },
{ title: 'Tip the Crew', url: '/tip', kw: 'tip tips crew gratuity employee code qr thank' },
];
app.get('/api/search', (req, res) => {
const q = String(req.query.q || '').trim().toLowerCase();
if (q.length < 2) return res.json({ q, results: [] });
const terms = q.split(/\s+/).filter(Boolean);
const results = [];
// AND semantics over title+detail+extra; hits whose TITLE alone matches rank first.
const add = (type, title, detail, url, extra) => {
const t = String(title || ''), d = String(detail || '');
const hay = (t + ' ' + d + ' ' + String(extra || '')).toLowerCase();
if (!terms.every(w => hay.includes(w))) return;
const inTitle = terms.every(w => t.toLowerCase().includes(w));
results.push({ type, title: t, detail: d.slice(0, 140), url,
_s: (inTitle ? 10 : 0) + (t.toLowerCase().startsWith(terms[0]) ? 3 : 0) });
};
services().forEach(s => add('Service', s.name, `${s.price_display || ''} · ${s.blurb || ''}`,
'/services?q=' + encodeURIComponent(s.name),
[s.category, (s.highlights || []).join(' ')].join(' ')));
plans().forEach(p => add('Membership', p.name, `${p.price_display || ''} · ${p.tagline || ''}`,
'/membership', [p.for_who, (p.includes || []).join(' ')].join(' ')));
coupons().forEach(c => add('Coupon', c.title, `${c.value || ''} · ${c.detail || ''}`,
'/coupons', [c.code, c.fine_print].join(' ')));
const sp = readJSON('specials.json', {});
const todays = sp.override || (sp.by_weekday || {})[String(new Date().getDay())];
if (todays) add('Special', "Today's Special", todays, '/contact', 'special deal today daily');
const pl = places();
if (pl && pl.name) add('Visit', 'Hours, Location & Phone',
[pl.address, pl.phone_display || pl.phone].filter(Boolean).join(' · '),
'/visit', 'hours location address directions phone open close ' + JSON.stringify(pl.hours || ''));
SEARCH_PAGES.forEach(p => add('Page', p.title, '', p.url, p.kw));
results.sort((a, b) => b._s - a._s);
res.json({ q, results: results.slice(0, 20).map(({ _s, ...r }) => r) });
});
app.get('/api/best-times', (req, res) => res.json(bestTimes()));
app.get('/api/places', (req, res) => res.json(places()));
app.get('/api/coupons', (req, res) => res.json(coupons()));
app.get('/api/plans', (req, res) => res.json(plans()));
app.get('/api/area', (req, res) => res.json(area()));
// ---- admin bucket APIs -----------------------------------------------------
app.get('/api/admin/competitors', (req, res) => res.json(competitors()));
app.get('/api/admin/suggestions', (req, res) => res.json(suggestions()));
app.get('/api/admin/holidays', (req, res) => res.json(holidays()));
app.get('/api/admin/directories', (req, res) => res.json(directories()));
app.get('/api/admin/ads', (req, res) => res.json(ads()));
app.get('/api/admin/services', (req, res) => res.json(services()));
app.get('/api/admin/best-times', (req, res) => res.json(bestTimes()));
app.get('/api/admin/places', (req, res) => res.json(places()));
app.get('/api/admin/partners', (req, res) => res.json(partners()));
app.get('/api/admin/reviews-raw', (req, res) => res.json(readJSON('reviews-raw.json', {})));
app.get('/api/admin/coupons', (req, res) => res.json(coupons()));
app.get('/api/admin/plans', (req, res) => res.json(plans()));
// Credentials tab — presence + last-4 only. NEVER returns secret values.
app.get('/api/admin/credentials', (req, res) => {
const keys = [
{ key: 'GEMINI_API_KEY', label: 'Nano Banana (Gemini image)', purpose: 'Generate service stills' },
{ key: 'REPLICATE_API_TOKEN', label: 'SeeDance (Replicate)', purpose: 'Generate wash/wax video clips' },
{ key: 'GOOGLE_PLACES_API_KEY', label: 'Google Places (read)', purpose: 'Live hours/reviews/photos — gated setup' },
{ key: 'PCW_PLACE_ID', label: 'Google Place ID', purpose: 'Which listing to read' },
{ key: 'DATABASE_URL', label: 'Postgres', purpose: 'Optional data backend' },
{ key: 'STRIPE_SECRET_KEY', label: 'Stripe (tips)', purpose: 'Employee tip checkout — GATED until owner go' },
{ key: 'WEBCAM_EMBED_URL', label: 'Live cam stream', purpose: 'Outdoor camera embed on /visit — needs camera install' },
{ key: 'NOTIFY_WEBHOOK', label: 'Lead notifications', purpose: 'Slack/Discord/Zapier ping on new leads' }
];
res.json(keys.map(k => ({ ...maskEnv(k.key), label: k.label, purpose: k.purpose })));
});
// "Update Google Place" — draft-write: returns a prefilled Google Business Profile URL.
// No direct API write (owner OAuth + API approval deferred). Front-end opens this in a new tab.
app.post('/api/admin/place/draft-update', (req, res) => {
const { field } = req.body || {};
res.json({
ok: true, mode: 'draft',
message: `Draft update for "${field || 'listing'}" — opens Google Business Profile prefilled.`,
url: 'https://business.google.com/edit/l/' + (process.env.PCW_PLACE_ID || '')
});
});
// Simple per-IP rate limiter for the public form (prevents spam / unbounded lead file).
const _hits = new Map();
function rateLimit(max, windowMs) {
return (req, res, next) => {
const ip = req.ip || req.connection.remoteAddress || 'unknown';
const now = Date.now();
const arr = (_hits.get(ip) || []).filter(t => now - t < windowMs);
if (arr.length >= max) return res.status(429).json({ ok: false, error: 'Too many requests — try again shortly.' });
arr.push(now); _hits.set(ip, arr);
next();
};
}
const cap = (s, n) => String(s || '').slice(0, n);
// Contact / booking lead — saved locally + optional webhook notify (env-gated).
// NOTIFY_WEBHOOK (Slack/Discord/Zapier URL) gives leads a real notification path;
// without it the lead is captured in the admin Leads tab. No mass email (that stays gated).
app.post('/api/contact', rateLimit(5, 10 * 60 * 1000), (req, res) => {
const b = req.body || {};
if (!b.name || !(b.phone || b.email)) return res.status(400).json({ ok: false, error: 'name + phone/email required' });
const lead = {
name: cap(b.name, 120), phone: cap(b.phone, 40), email: cap(b.email, 160), vehicle: cap(b.vehicle, 120),
service: cap(b.service, 120), preferred: cap(b.preferred, 120), message: cap(b.message, 1000),
created_at: new Date().toISOString(), source: 'web-form'
};
try {
const dir = path.join(__dirname, 'reports');
fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(path.join(dir, 'leads.jsonl'), JSON.stringify(lead) + '\n');
} catch (e) { return res.status(500).json({ ok: false, error: 'could not save lead' }); }
// Fire-and-forget notification if a webhook is configured.
if (process.env.NOTIFY_WEBHOOK) {
fetch(process.env.NOTIFY_WEBHOOK, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `🚗 New Prestige lead: ${lead.name} (${lead.phone || lead.email}) — ${lead.service || 'general'}${lead.message ? ' · ' + lead.message : ''}` })
}).catch(() => {});
}
res.json({ ok: true, message: "Thanks! We'll get back to you shortly. And remember — no appointment needed: we're first come, first served, so you can just drive in." });
});
// Admin: view captured leads.
app.get('/api/admin/leads', (req, res) => {
try {
const raw = fs.readFileSync(path.join(__dirname, 'reports', 'leads.jsonl'), 'utf8').trim();
res.json(raw ? raw.split('\n').map(l => JSON.parse(l)).reverse() : []);
} catch { res.json([]); }
});
// ---- daily specials (full-width banner on every public page) ---------------
app.get('/api/specials', (req, res) => {
const sp = readJSON('specials.json', {});
const today = sp.override && sp.override.trim()
? sp.override
: (sp.by_weekday || {})[String(new Date().getDay())] || '';
res.json({ text: today, day: new Date().getDay() });
});
app.get('/api/admin/specials', (req, res) => res.json(readJSON('specials.json', {})));
app.post('/api/admin/specials', (req, res) => {
const cur = readJSON('specials.json', {});
const b = req.body || {};
if (typeof b.override === 'string') cur.override = cap(b.override, 200);
if (b.by_weekday && typeof b.by_weekday === 'object')
for (const k of Object.keys(b.by_weekday))
if (/^[0-6]$/.test(k)) cur.by_weekday[k] = cap(b.by_weekday[k], 200);
cur.updated_at = new Date().toISOString();
fs.writeFileSync(path.join(DATA, 'specials.json'), JSON.stringify(cur, null, 2));
res.json({ ok: true });
});
// ---- weather (Open-Meteo — free, keyless; cached 15 min) --------------------
// 7601 Reseda Blvd sits at ~34.208, -118.536. Server-side proxy keeps CSP tight.
let _wx = { at: 0, data: null };
app.get('/api/weather', async (req, res) => {
if (Date.now() - _wx.at < 15 * 60 * 1000 && _wx.data) return res.json(_wx.data);
try {
const u = 'https://api.open-meteo.com/v1/forecast?latitude=34.208&longitude=-118.536'
+ '¤t=temperature_2m,weather_code,wind_speed_10m'
+ '&daily=precipitation_probability_max,temperature_2m_max&forecast_days=2'
+ '&temperature_unit=fahrenheit&wind_speed_unit=mph&timezone=America%2FLos_Angeles';
const j = await (await fetch(u)).json();
const code = j.current?.weather_code ?? 0;
const rainToday = j.daily?.precipitation_probability_max?.[0] ?? 0;
const rainTomorrow = j.daily?.precipitation_probability_max?.[1] ?? 0;
const desc = code === 0 ? 'Clear' : code <= 3 ? 'Partly cloudy' : code <= 48 ? 'Foggy'
: code <= 67 ? 'Rainy' : code <= 77 ? 'Snow' : code <= 82 ? 'Showers' : 'Stormy';
// The pitch: low rain chance = great day to wash; we already offer a 48h rain check.
const washMsg = rainToday <= 20 && rainTomorrow <= 30
? 'Perfect wash weather — no rain in sight.'
: rainToday <= 20
? 'Great day to wash — and if it rains tomorrow, your 48-hour rain check has you covered.'
: 'Rain possible — every wash comes with a 48-hour rain check, so you’re covered either way.';
_wx = { at: Date.now(), data: { ok: true, temp: Math.round(j.current?.temperature_2m ?? 0), desc, wind: Math.round(j.current?.wind_speed_10m ?? 0), rain_today: rainToday, rain_tomorrow: rainTomorrow, wash_msg: washMsg } };
res.json(_wx.data);
} catch { res.json({ ok: false }); }
});
// ---- live cam (env-configured stream; graceful "coming soon" without it) ----
app.get('/api/webcam', (req, res) => {
res.json({
embed_url: process.env.WEBCAM_EMBED_URL || '',
note: process.env.WEBCAM_EMBED_URL ? '' : 'Camera install pending — check back soon to see the line live.'
});
});
// ---- tips: employees + QR + Stripe-ready checkout ---------------------------
// Roster lives in data/employees.json; 3-digit codes are the public tip IDs.
const employees = () => (readJSON('employees.json', { employees: [] }).employees || []);
const findEmp = (code) => employees().find(e => e.active && String(e.code) === String(code).padStart(3, '0'));
// Public lookup: first name + role only (never expose stripe ids or the roster).
app.get('/api/tip/employee/:code', (req, res) => {
if (!/^\d{3}$/.test(req.params.code)) return res.status(400).json({ ok: false });
const e = findEmp(req.params.code);
if (!e) return res.status(404).json({ ok: false, error: 'No team member with that code.' });
res.json({ ok: true, code: e.code, name: e.name.split(' ')[0], role: e.role || '' });
});
// QR code per employee → points at /tip?e=CODE. SVG, printable at any size.
const QRCode = require('qrcode');
app.get('/api/qr/:code', async (req, res) => {
if (!/^\d{3}$/.test(req.params.code)) return res.status(400).send('bad code');
const base = process.env.PUBLIC_URL || 'https://prestigecarwash.agentabrams.com';
const svg = await QRCode.toString(`${base}/tip?e=${req.params.code}`,
{ type: 'svg', margin: 1, width: 512, color: { dark: '#0b0f16', light: '#ffffff' } });
res.type('image/svg+xml').send(svg);
});
// Tip checkout. With STRIPE_SECRET_KEY set this creates a real Stripe Checkout
// session (metadata carries the codes; Connect transfers happen on webhook once
// employees have Express accounts). Without a key it's inert by design — money
// stays OFF until Steve/owner flips it. Every intent is journaled either way.
const tipLedger = (entry) => {
const dir = path.join(__dirname, 'reports');
fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(path.join(dir, 'tips.jsonl'), JSON.stringify(entry) + '\n');
};
app.post('/api/tip/checkout', rateLimit(10, 10 * 60 * 1000), async (req, res) => {
const b = req.body || {};
const codes = [...new Set((Array.isArray(b.codes) ? b.codes : [b.codes]).filter(c => /^\d{3}$/.test(String(c))))].slice(0, 2);
const emps = codes.map(findEmp).filter(Boolean);
const amount = Math.round(Number(b.amount) * 100);
if (!emps.length) return res.status(400).json({ ok: false, error: 'Enter a valid team code.' });
if (!(amount >= 100 && amount <= 20000)) return res.status(400).json({ ok: false, error: 'Tip must be between $1 and $200.' });
const entry = {
codes: emps.map(e => e.code), names: emps.map(e => e.name), amount_cents: amount,
split: emps.length > 1, note: cap(b.note, 200), created_at: new Date().toISOString()
};
if (!process.env.STRIPE_SECRET_KEY) {
tipLedger({ ...entry, status: 'offline-intent' });
return res.json({ ok: true, mode: 'offline', message: `Card tips are launching soon! For now you can tip ${emps.map(e => e.name.split(' ')[0]).join(' & ')} in cash at the register — thank you!` });
}
try {
const form = new URLSearchParams({
mode: 'payment',
'line_items[0][quantity]': '1',
'line_items[0][price_data][currency]': 'usd',
'line_items[0][price_data][unit_amount]': String(amount),
'line_items[0][price_data][product_data][name]': `Tip for ${emps.map(e => e.name.split(' ')[0]).join(' & ')} — Prestige Car Wash`,
success_url: (process.env.PUBLIC_URL || 'https://prestigecarwash.agentabrams.com') + '/tip?thanks=1',
cancel_url: (process.env.PUBLIC_URL || 'https://prestigecarwash.agentabrams.com') + '/tip',
'metadata[tip_codes]': entry.codes.join('+'),
'metadata[split]': String(entry.split)
});
const s = await (await fetch('https://api.stripe.com/v1/checkout/sessions', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.STRIPE_SECRET_KEY, 'Content-Type': 'application/x-www-form-urlencoded' },
body: form
})).json();
if (!s.url) throw new Error(s.error?.message || 'no checkout url');
tipLedger({ ...entry, status: 'checkout-created', stripe_session: s.id });
res.json({ ok: true, mode: 'stripe', url: s.url });
} catch (e) {
tipLedger({ ...entry, status: 'error', error: String(e.message) });
res.status(502).json({ ok: false, error: 'Payment system hiccup — please tip in cash today.' });
}
});
// Admin: roster read/write + ledger.
app.get('/api/admin/employees', (req, res) => res.json(readJSON('employees.json', { employees: [] })));
app.post('/api/admin/employees', (req, res) => {
const b = req.body || {};
if (!/^\d{3}$/.test(String(b.code)) || !b.name) return res.status(400).json({ ok: false, error: '3-digit code + name required' });
const doc = readJSON('employees.json', { employees: [] });
const i = doc.employees.findIndex(e => String(e.code) === String(b.code));
const row = {
code: String(b.code), name: cap(b.name, 80), role: cap(b.role, 120),
active: b.active !== false, stripe_account_id: cap(b.stripe_account_id, 60),
added_at: i >= 0 ? doc.employees[i].added_at : new Date().toISOString()
};
if (i >= 0) doc.employees[i] = row; else doc.employees.push(row);
fs.writeFileSync(path.join(DATA, 'employees.json'), JSON.stringify(doc, null, 2));
res.json({ ok: true, employee: row });
});
app.get('/api/admin/tips', (req, res) => {
try {
const raw = fs.readFileSync(path.join(__dirname, 'reports', 'tips.jsonl'), 'utf8').trim();
res.json(raw ? raw.split('\n').map(l => JSON.parse(l)).reverse() : []);
} catch { res.json([]); }
});
// ---- marketing assets (left panel of the admin Marketing tab) ---------------
app.get('/api/admin/assets', (req, res) => {
const root = path.join(__dirname, 'media');
const out = [];
(function walk(dir, rel) {
for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
if (f.isDirectory()) { walk(path.join(dir, f.name), rel + f.name + '/'); continue; }
const ext = f.name.split('.').pop().toLowerCase();
if (!['jpg', 'jpeg', 'png', 'webp', 'mp4', 'mov', 'svg'].includes(ext)) continue;
const st = fs.statSync(path.join(dir, f.name));
out.push({
path: 'media/' + rel + f.name, name: f.name,
kind: ['mp4', 'mov'].includes(ext) ? 'video' : 'image',
bucket: rel ? rel.replace(/\/$/, '') : 'generated',
size_kb: Math.round(st.size / 1024), mtime: st.mtime.toISOString()
});
}
})(root, '');
out.sort((a, b) => b.mtime.localeCompare(a.mtime));
res.json(out);
});
// Social-account registry (no secrets — handles/URLs/status only).
app.get('/api/admin/social', (req, res) => res.json(readJSON('credentials.json', {})));
// ---- social scheduler: server-side queue (Phase 1 — DARK/INERT, no auto-post) ----
// The Hootsuite-style composer queue lives in data/schedule.json so it persists
// across devices/browsers (vs. per-browser localStorage). Publishing stays MANUAL
// until a platform adapter is keyed + its go-live is approved — see
// scripts/publish-due.js and reports/SCOPE-social-autopost-*.md.
const SCHEDULE_FILE = 'schedule.json';
app.get('/api/admin/schedule', (req, res) => res.json(readJSON(SCHEDULE_FILE, { posts: [] })));
app.post('/api/admin/schedule', (req, res) => {
const b = req.body || {};
const socials = Array.isArray(b.socials) ? b.socials.map(s => String(s).slice(0, 60)).slice(0, 12) : [];
const caption = typeof b.caption === 'string' ? b.caption.slice(0, 2200) : '';
const when = typeof b.when === 'string' ? b.when.slice(0, 40) : '';
const media = typeof b.media === 'string' ? b.media.slice(0, 300) : '';
if (!socials.length || !caption.trim() || !when)
return res.status(400).json({ ok: false, error: 'socials, caption and when are required' });
const doc = readJSON(SCHEDULE_FILE, { posts: [] });
if (!Array.isArray(doc.posts)) doc.posts = [];
const post = {
id: 'p' + Date.now().toString(36) + Math.floor(Math.random() * 1e4).toString(36),
socials, caption, when, media, status: 'scheduled', created_at: new Date().toISOString()
};
doc.posts.push(post);
fs.writeFileSync(path.join(DATA, SCHEDULE_FILE), JSON.stringify(doc, null, 2));
res.json({ ok: true, post });
});
app.delete('/api/admin/schedule/:id', (req, res) => {
const doc = readJSON(SCHEDULE_FILE, { posts: [] });
if (!Array.isArray(doc.posts)) doc.posts = [];
const before = doc.posts.length;
doc.posts = doc.posts.filter(p => p.id !== req.params.id);
fs.writeFileSync(path.join(DATA, SCHEDULE_FILE), JSON.stringify(doc, null, 2));
res.json({ ok: true, removed: before - doc.posts.length });
});
// ---- static + clean URLs ---------------------------------------------------
app.get('/favicon.svg', (req, res) => res.sendFile(path.join(__dirname, 'public', 'assets', 'logo.svg')));
app.get('/favicon.ico', (req, res) => res.redirect(302, '/favicon.svg'));
app.use('/media', express.static(path.join(__dirname, 'media')));
app.get(/^\/(.+)\.html$/, (req, res) => res.redirect(301, '/' + req.params[0]));
app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));
app.listen(PORT, () => {
console.log(`[prestige-car-wash] http://localhost:${PORT} (admin: /admin)`);
if (process.env.NODE_ENV === 'production' && !process.env.ADMIN_PASS) {
console.warn('⚠ SECURITY: running in production with the DEFAULT admin password. Set ADMIN_PASS in .env before exposing this host.');
}
});