← 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'
      + '&current=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.');
  }
});