← back to Costa Rica

server.js

778 lines

'use strict';

require('dotenv').config();
const express = require('express');
const basicAuth = require('express-basic-auth');
const path = require('path');
const { harden } = require('./lib/async-harden'); // forward async route throws -> error handler (Express 4)
// Use lib/db's SINGLE shared pg pool (it also registers an idle-client 'error'
// handler that this file's old private pool lacked). server.js previously created
// its OWN `new Pool(...)` with the same connectionString, so the server ran TWO
// pools against one DB — double the connections, and (cycle 16) a test-hang from
// the un-closed second pool. Consolidated onto the shared pool. (Cody gate, cycle 17.)
const { pool } = require('./lib/db');

const PORT = parseInt(process.env.PORT || '9791', 10);
const SITE_NAME = process.env.SITE_NAME || 'Costa Rica Directory';
const SITE_DOMAIN = process.env.SITE_DOMAIN || 'costarica.agentabrams.com';

// M2/R4 — log the full error server-side, return a GENERIC message to the client.
// Never leak DB/driver text (table names, SQL, connection strings) to a caller.
function serverError(res, e, where) {
  console.error(`[500]${where ? ' ' + where : ''}`, e && e.message);
  res.status(500).json({ error: 'internal error' });
}

// Safety net (Cody gate, cycle 10): this app's routes are async and there is no
// per-route asyncHandler / error middleware yet, so an uncaught throw inside a
// route (a DB blip, an unbounded external call) becomes an unhandledRejection —
// which on Node 15+ CRASHES the whole process by default, taking down the entire
// marketplace for one route's error. Log and STAY UP: the offending request still
// fails (its response was never sent), but every other in-flight + future request
// survives. This converts a server-wide crash into a single failed request. The
// proper fix — wrap all routes in an asyncHandler that forwards to error
// middleware — is a separate, review-worthy cycle (tracked in YOLO_NOTES).
process.on('unhandledRejection', (reason) => {
  console.error('[unhandledRejection]', reason && (reason.stack || reason.message || reason));
});

const app = express();
app.set('trust proxy', true);

// CORS for the /api/app mobile surface (native app has no origin; the web build
// + browser previews need this). Permissive on the app API only.
app.use('/api/app', (req, res, next) => {
  res.set('Access-Control-Allow-Origin', '*');
  res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');
  res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
  if (req.method === 'OPTIONS') return res.sendStatus(204);
  next();
});

// Payment/WhatsApp webhooks need the RAW body for HMAC verification, so mount
// them BEFORE the global JSON parser (and before the site basic-auth gate).
app.use('/webhooks', harden(require('./routes/webhooks')));

app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));

// Cloudflare HTML caching guard (per MEMORY.md)
app.use((req, res, next) => {
  if (req.path === '/' || req.path.endsWith('.html')) {
    res.set('Cache-Control', 'no-store, must-revalidate');
  }
  next();
});

// Mobile-app API (JWT-authed) — mounted BEFORE the site basic-auth gate so
// public app users can reach it.
app.use('/api/app', harden(require('./routes/app').router));
app.get('/api/app/health', (_req, res) => {
  const { getProvider } = require('./lib/payments');
  res.json({ ok: true, layer: 'marketplace',
    payment_provider: getProvider().name, payment_live: getProvider().liveMode,
    whatsapp_live: require('./lib/whatsapp').liveMode, plaid_live: require('./lib/plaid').liveMode });
});

// Public legal/support pages — mounted BEFORE the site basic-auth gate so the
// App Store listing's Privacy Policy URL and Support URL are reachable by
// Apple's reviewers and the public (a 401 there rejects the app submission).
app.get(['/privacy', '/privacy.html'], (_req, res) =>
  res.sendFile(path.join(__dirname, 'public', 'legal', 'privacy.html')));
app.get(['/support', '/support.html'], (_req, res) =>
  res.sendFile(path.join(__dirname, 'public', 'legal', 'support.html')));

// Whole-site Basic Auth gate (in-development; remove when ready for public)
const BA_USER = process.env.BASIC_AUTH_USER;
const BA_PASS = process.env.BASIC_AUTH_PASS;
if (BA_USER && BA_PASS) {
  app.use(basicAuth({
    users: { [BA_USER]: BA_PASS },
    challenge: true,
    realm: 'CR-Directory',
  }));
} else {
  console.warn('[boot] BASIC_AUTH_USER/PASS not set — site is OPEN');
}

// Fail-CLOSED guard for the admin-only curation surfaces below (/api/admin, /admin,
// /api/build, /build, /api/logo-agent, /logo-agent). None of them carry their own
// auth — they rely entirely on the site-wide gate above. When BA_USER/BA_PASS are
// unset, that gate is a no-op ("site is OPEN"), which would silently expose
// host-claim approvals + bookings PII (admin), the ops dashboard (build), and the
// logo tool the moment BASIC_AUTH_* is removed per docs/GO-LIVE.md §8 — the exact
// collision Cody's audit flagged (TK-10346-costa-admin-auth-coupling, option B,
// Steve-approved 2026-09-24). This does NOT touch the public directory (/api/map,
// /api/places, etc.) — only the admin-only routes mounted below opt in. When
// BA_USER/BA_PASS ARE set, both guards are a no-op (unauthenticated requests are
// already rejected 401 by the basicAuth gate above before reaching these routes at
// all), so behavior is byte-for-byte unchanged in the configured case.
const ADMIN_GATE_CONFIGURED = !!(BA_USER && BA_PASS);
function requireAdminGateJson(_req, res, next) {
  if (!ADMIN_GATE_CONFIGURED) return res.status(503).json({ error: 'admin_gate_unconfigured' });
  next();
}
function requireAdminGateHtml(_req, res, next) {
  if (!ADMIN_GATE_CONFIGURED) return res.status(503).type('text/plain').send('admin gate not configured');
  next();
}

app.get('/health', (_req, res) => res.json({ ok: true, site: SITE_NAME, ts: new Date().toISOString() }));

// Admin (behind the basic-auth gate) — host-claim approvals + bookings oversight.
app.use('/api/admin', requireAdminGateJson, harden(require('./routes/admin')));
app.get('/admin', requireAdminGateHtml, (_req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));
app.use('/api/build', requireAdminGateJson, harden(require('./routes/build')));
app.get('/build', requireAdminGateHtml, (_req, res) => res.sendFile(path.join(__dirname, 'public', 'build.html')));
// Logo Agent — hot-or-not tournament brand/logo builder (admin-gated curation tool)
app.use('/api/logo-agent', requireAdminGateJson, harden(require('./routes/logo-agent')));
app.get('/logo-agent', requireAdminGateHtml, (_req, res) => res.sendFile(path.join(__dirname, 'public', 'logo-agent.html')));
// Map of all geocoded places (desktop viewer)
app.get('/api/map', async (req, res) => {
  try {
    const { rows } = await pool.query(
      `SELECT p.slug, p.name, p.category, p.vertical, p.lat, p.lng, p.rating, r.name AS region
         FROM places p LEFT JOIN regions r ON r.id=p.region_id
        WHERE p.lat IS NOT NULL AND p.lng IS NOT NULL
          AND ($1::text IS NULL OR p.category=$1)
        LIMIT 20000`, [req.query.category || null]);
    res.json({ ok: true, count: rows.length, places: rows });
  } catch (e) { console.error('[500]', e && e.message); res.status(500).json({ ok: false, error: 'internal error' }); }
});
app.get('/map', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'map.html')));

const VERTICALS = {
  tourism: ['tourism_hotel','tourism_tour','tourism_beach','tourism_restaurant','tourism_surf'],
  rentals: ['rentals_short','rentals_long','rentals_realestate'],
  service: ['service_food','service_beauty','service_retail','service_fitness','service_pet','service_auto','service_cleaning','service_creative'],
};

function sortClause(sort) {
  switch ((sort || '').toLowerCase()) {
    case 'name':       return 'ORDER BY LOWER(p.name) ASC';
    case 'name-desc':  return 'ORDER BY LOWER(p.name) DESC';
    case 'rating':     return 'ORDER BY p.rating DESC NULLS LAST, p.id DESC';
    case 'verified':   return 'ORDER BY p.verified DESC, p.id DESC';
    case 'newest':     return 'ORDER BY p.id DESC';
    case 'oldest':     return 'ORDER BY p.id ASC';
    default:           return 'ORDER BY p.id DESC';
  }
}

app.get('/api/provinces', async (_req, res) => {
  try {
    const { rows } = await pool.query(`
      WITH province_totals AS (
        SELECT r.province AS name, COUNT(p.id)::int AS total_places
          FROM regions r LEFT JOIN places p ON p.region_id = r.id AND p.status='active'
         GROUP BY r.province
      ),
      top_regions AS (
        SELECT r.province, r.slug, r.name AS region_name, r.image_url,
               COUNT(p.id)::int AS n,
               ROW_NUMBER() OVER (PARTITION BY r.province ORDER BY COUNT(p.id) DESC, r.name ASC) AS rk
          FROM regions r LEFT JOIN places p ON p.region_id = r.id AND p.status='active'
         GROUP BY r.id
      )
      SELECT pt.name AS province, pt.total_places,
             json_agg(json_build_object('slug', tr.slug, 'name', tr.region_name, 'image_url', tr.image_url, 'n', tr.n)
                      ORDER BY tr.rk) FILTER (WHERE tr.rk <= 6) AS top_regions
        FROM province_totals pt
        LEFT JOIN top_regions tr ON tr.province = pt.name
       WHERE pt.name IS NOT NULL
       GROUP BY pt.name, pt.total_places
       ORDER BY pt.total_places DESC, pt.name ASC`);
    res.json({ provinces: rows });
  } catch (e) { serverError(res, e); }
});

// Province detail: all cantones + by-vertical totals + 8 sample places
const PROVINCE_SLUGS = {
  'san-jose':    'San José',
  'alajuela':    'Alajuela',
  'heredia':     'Heredia',
  'cartago':     'Cartago',
  'guanacaste':  'Guanacaste',
  'puntarenas':  'Puntarenas',
  'limon':       'Limón',
};

app.get('/api/provinces/:slug', async (req, res) => {
  try {
    const provName = PROVINCE_SLUGS[req.params.slug];
    if (!provName) return res.status(404).json({ error: 'unknown province' });

    const [{ rows: cantones }, { rows: byVert }, { rows: samples }, { rows: totalRow }] = await Promise.all([
      pool.query(
        `SELECT r.slug, r.name, r.image_url, r.image_credit, r.image_source_url,
                COUNT(p.id)::int AS n
           FROM regions r LEFT JOIN places p ON p.region_id = r.id AND p.status='active'
          WHERE r.province = $1
          GROUP BY r.id
          ORDER BY n DESC, r.name ASC`, [provName]
      ),
      pool.query(
        `SELECT p.vertical, COUNT(*)::int AS n
           FROM places p JOIN regions r ON r.id = p.region_id
          WHERE r.province = $1 AND p.status='active'
          GROUP BY p.vertical
          ORDER BY n DESC LIMIT 12`, [provName]
      ),
      pool.query(
        `SELECT p.slug, p.name, p.vertical, p.image_url, p.address,
                r.slug AS region_slug, r.name AS region_name,
                r.image_url AS region_image_url
           FROM places p JOIN regions r ON r.id = p.region_id
          WHERE r.province = $1 AND p.status='active'
          ORDER BY (p.image_url IS NOT NULL) DESC, p.id DESC
          LIMIT 8`, [provName]
      ),
      pool.query(
        `SELECT COUNT(p.id)::int AS total
           FROM places p JOIN regions r ON r.id = p.region_id
          WHERE r.province = $1 AND p.status='active'`, [provName]
      ),
    ]);

    res.json({
      slug: req.params.slug,
      name: provName,
      total: totalRow[0]?.total || 0,
      cantones,
      by_vertical: byVert,
      samples,
    });
  } catch (e) { serverError(res, e); }
});

// Vertical = e.g. service_retail, tourism_hotel, rentals_realestate.
// Slug uses hyphens: service_retail <-> service-retail.
const vSlug = v => String(v || '').replaceAll('_', '-').toLowerCase();
const vUnSlug = s => String(s || '').replaceAll('-', '_').toLowerCase();

app.get('/api/verticals', async (_req, res) => {
  try {
    const { rows } = await pool.query(`
      WITH counts AS (
        SELECT vertical, category, COUNT(*)::int AS n
          FROM places WHERE status='active' GROUP BY vertical, category
      ),
      samples AS (
        SELECT DISTINCT ON (p.vertical) p.vertical, p.image_url, r.image_url AS region_image_url
          FROM places p LEFT JOIN regions r ON r.id = p.region_id
         WHERE p.status='active'
         ORDER BY p.vertical, (p.image_url IS NOT NULL) DESC, p.id DESC
      )
      SELECT c.vertical, c.category, c.n,
             COALESCE(s.image_url, s.region_image_url) AS image_url
        FROM counts c LEFT JOIN samples s ON s.vertical = c.vertical
       ORDER BY c.n DESC, c.vertical ASC`);
    res.json({ verticals: rows.map(r => ({ ...r, slug: vSlug(r.vertical) })) });
  } catch (e) { serverError(res, e); }
});

app.get('/api/verticals/:slug', async (req, res) => {
  try {
    const vertical = vUnSlug(req.params.slug);
    const [{ rows: meta }, { rows: byRegion }, { rows: byProv }, { rows: samples }] = await Promise.all([
      pool.query(
        `SELECT vertical, category, COUNT(*)::int AS total
           FROM places WHERE vertical=$1 AND status='active'
          GROUP BY vertical, category`, [vertical]
      ),
      pool.query(
        `SELECT r.slug, r.name, r.province, r.image_url, COUNT(p.id)::int AS n
           FROM places p JOIN regions r ON r.id = p.region_id
          WHERE p.vertical=$1 AND p.status='active'
          GROUP BY r.id
          ORDER BY n DESC, r.name ASC LIMIT 24`, [vertical]
      ),
      pool.query(
        `SELECT r.province AS name, COUNT(p.id)::int AS n
           FROM places p JOIN regions r ON r.id = p.region_id
          WHERE p.vertical=$1 AND p.status='active'
          GROUP BY r.province ORDER BY n DESC`, [vertical]
      ),
      pool.query(
        `SELECT p.slug, p.name, p.image_url, p.address, p.website,
                r.slug AS region_slug, r.name AS region_name, r.province,
                r.image_url AS region_image_url
           FROM places p JOIN regions r ON r.id = p.region_id
          WHERE p.vertical=$1 AND p.status='active'
          ORDER BY (p.image_url IS NOT NULL) DESC, p.id DESC LIMIT 24`, [vertical]
      ),
    ]);
    if (!meta.length) return res.status(404).json({ error: 'unknown vertical' });
    res.json({
      slug: req.params.slug,
      vertical,
      category: meta[0].category,
      total: meta[0].total,
      by_region: byRegion,
      by_province: byProv,
      samples,
    });
  } catch (e) { serverError(res, e); }
});

// Cross-entity search: places + regions + provinces, grouped & ranked
app.get('/api/search', async (req, res) => {
  try {
    const q = String(req.query.q || '').trim();
    if (!q || q.length < 2) return res.json({ q, places: [], regions: [], provinces: [], counts: { places: 0, regions: 0, provinces: 0 } });
    const placeLimit  = Math.min(parseInt(req.query.place_limit  || '24', 10) || 24, 60);
    const regionLimit = Math.min(parseInt(req.query.region_limit || '12', 10) || 12, 30);

    const pat = `%${q.toLowerCase()}%`;
    const exactPat = q.toLowerCase();

    const [{ rows: places }, { rows: placesCnt }, { rows: regions }, { rows: regionsCnt }] = await Promise.all([
      pool.query(
        `SELECT p.slug, p.name, p.vertical, p.category, p.address, p.image_url, p.cedula_juridica,
                r.slug AS region_slug, r.name AS region_name, r.province, r.image_url AS region_image_url,
                CASE WHEN LOWER(p.name) = $2 THEN 100
                     WHEN LOWER(p.name) LIKE $2 || '%' THEN 80
                     WHEN LOWER(p.name) LIKE '%' || $2 || '%' THEN 50
                     ELSE 10 END AS rank
           FROM places p LEFT JOIN regions r ON r.id = p.region_id
          WHERE p.status='active'
            AND (LOWER(p.name) LIKE $1 OR LOWER(p.address) LIKE $1 OR LOWER(p.description) LIKE $1)
          ORDER BY rank DESC, (p.image_url IS NOT NULL) DESC, p.name ASC
          LIMIT $3`, [pat, exactPat, placeLimit]
      ),
      pool.query(
        `SELECT COUNT(*)::int AS total FROM places p
          WHERE p.status='active'
            AND (LOWER(p.name) LIKE $1 OR LOWER(p.address) LIKE $1 OR LOWER(p.description) LIKE $1)`, [pat]
      ),
      pool.query(
        `SELECT r.slug, r.name, r.province, r.image_url, r.image_credit, r.image_source_url,
                COUNT(p.id)::int AS n,
                CASE WHEN LOWER(r.name) = $2 THEN 100
                     WHEN LOWER(r.name) LIKE $2 || '%' THEN 80
                     WHEN LOWER(r.name) LIKE '%' || $2 || '%' THEN 50
                     ELSE 10 END AS rank
           FROM regions r LEFT JOIN places p ON p.region_id = r.id AND p.status='active'
          WHERE LOWER(r.name) LIKE $1
          GROUP BY r.id
          ORDER BY rank DESC, n DESC, r.name ASC
          LIMIT $3`, [pat, exactPat, regionLimit]
      ),
      pool.query(
        `SELECT COUNT(*)::int AS total FROM regions r WHERE LOWER(r.name) LIKE $1`, [pat]
      ),
    ]);

    // Provinces: small fixed list, ILIKE on canonical names
    const allProv = ['San José','Alajuela','Heredia','Cartago','Guanacaste','Puntarenas','Limón'];
    const provMatches = allProv
      .filter(p => p.toLowerCase().includes(exactPat))
      .map(p => ({ name: p, slug: { 'San José':'san-jose','Alajuela':'alajuela','Heredia':'heredia','Cartago':'cartago','Guanacaste':'guanacaste','Puntarenas':'puntarenas','Limón':'limon' }[p] }));

    res.json({
      q,
      places, regions, provinces: provMatches,
      counts: { places: placesCnt[0]?.total || 0, regions: regionsCnt[0]?.total || 0, provinces: provMatches.length },
    });
  } catch (e) { serverError(res, e); }
});

app.get('/api/regions', async (_req, res) => {
  try {
    const { rows } = await pool.query(`
      SELECT r.id, r.slug, r.name, r.province, r.region_type, r.lat, r.lng,
             COUNT(p.id)::int AS place_count
        FROM regions r
   LEFT JOIN places p ON p.region_id = r.id AND p.status = 'active'
    GROUP BY r.id
    ORDER BY r.name ASC
    `);
    res.json({ regions: rows });
  } catch (e) {
    serverError(res, e);
  }
});

app.get('/api/places', async (req, res) => {
  try {
    const limit  = Math.min(parseInt(req.query.limit  || '60', 10) || 60, 250);
    const offset = Math.max(parseInt(req.query.offset || '0',  10) || 0, 0);
    const sort   = sortClause(req.query.sort);
    const where  = ["p.status = 'active'"];
    const args   = [];

    if (req.query.category) {
      args.push(req.query.category);
      where.push(`p.category = $${args.length}`);
    }
    if (req.query.vertical) {
      args.push(req.query.vertical);
      where.push(`p.vertical = $${args.length}`);
    }
    if (req.query.region) {
      args.push(req.query.region);
      where.push(`r.slug = $${args.length}`);
    }
    // Only apply the text filter for q >= 2 chars. A 1-char q can't use the trigram
    // index (pg_trgm needs >= 3 chars) and its leading-wildcard LIKE would full-scan
    // the whole places table and return ~the entire directory as a "search result"
    // (matches /api/search's own length>=2 floor). A too-short q is ignored -> the
    // normal paginated listing, no scan. (Cody gate, cycle 15.)
    if (req.query.q && String(req.query.q).trim().length >= 2) {
      args.push(`%${req.query.q.toLowerCase()}%`);
      where.push(`(LOWER(p.name) LIKE $${args.length} OR LOWER(p.description) LIKE $${args.length} OR LOWER(p.address) LIKE $${args.length})`);
    }

    const sql = `
      SELECT p.id, p.slug, p.name, p.category, p.vertical, p.description, p.address,
             p.phone, p.email, p.website, p.price_range, p.rating, p.image_url, p.tags,
             p.lat, p.lng, p.verified, p.source, p.source_url, p.created_at, p.cedula_juridica,
             p.image_credit, p.image_license, p.image_source_url,
             r.slug AS region_slug, r.name AS region_name, r.province,
             r.image_url AS region_image_url, r.image_credit AS region_image_credit,
             r.image_source_url AS region_image_source_url, r.lat AS region_lat, r.lng AS region_lng,
             COALESCE(p.image_url, r.image_url)            AS effective_image_url,
             COALESCE(p.image_credit, r.image_credit)      AS effective_image_credit,
             COALESCE(p.image_source_url, r.image_source_url) AS effective_image_source_url
        FROM places p
   LEFT JOIN regions r ON p.region_id = r.id
       WHERE ${where.join(' AND ')}
       ${sort}
       ${'LIMIT ' + Number(limit) + ' OFFSET ' + Number(offset)}
    `;
    const { rows } = await pool.query(sql, args);

    const countArgs = args.slice();
    const countSql = `
      SELECT COUNT(*)::int AS total
        FROM places p
   LEFT JOIN regions r ON p.region_id = r.id
       WHERE ${where.join(' AND ')}
    `;
    const { rows: [{ total }] } = await pool.query(countSql, countArgs);

    // RFC 8288 Link header — canonical + prev/next/alternate (VCL pattern)
    const baseUrl = (process.env.PUBLIC_URL || `https://${SITE_DOMAIN}`).replace(/\/+$/, '');
    const qs = new URLSearchParams();
    for (const k of ['q','category','vertical','region','sort']) if (req.query[k]) qs.set(k, req.query[k]);
    const buildUrl = (path, n) => {
      const params = new URLSearchParams(qs);
      if (n != null) params.set('offset', String(n));
      params.set('limit', String(limit));
      return `${baseUrl}${path}${params.toString() ? '?' + params.toString() : ''}`;
    };
    const linkParts = [`<${buildUrl('/api/places', offset)}>; rel="canonical"`];
    if (offset > 0) linkParts.push(`<${buildUrl('/api/places', Math.max(0, offset - limit))}>; rel="prev"`);
    if (offset + limit < total) linkParts.push(`<${buildUrl('/api/places', offset + limit)}>; rel="next"`);
    res.set('Link', linkParts.join(', '));

    res.json({ total, limit, offset, places: rows });
  } catch (e) {
    serverError(res, e);
  }
});

// JSON mirror of the homepage / region listing — surfaced via rel="alternate"
// HTML link tag + HTTP Link header so apps + agents can fetch the same paginated
// results without HTML scraping (VCL pattern, RFC 8288).
app.get('/api/find', async (req, res) => {
  // /api/find is just /api/places under a more discoverable name.
  req.url = '/api/places' + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '');
  return app._router.handle(req, res, () => {});
});

app.get('/api/places/:slug', async (req, res) => {
  try {
    const { rows } = await pool.query(`
      SELECT p.*,
             r.slug AS region_slug, r.name AS region_name, r.province,
             r.image_url AS region_image_url, r.image_credit AS region_image_credit,
             r.image_source_url AS region_image_source_url,
             r.lat AS region_lat, r.lng AS region_lng,
             COALESCE(p.image_url, r.image_url)            AS effective_image_url,
             COALESCE(p.image_credit, r.image_credit)      AS effective_image_credit,
             COALESCE(p.image_source_url, r.image_source_url) AS effective_image_source_url
        FROM places p
   LEFT JOIN regions r ON p.region_id = r.id
       WHERE p.slug = $1
    `, [req.params.slug]);
    if (!rows.length) return res.status(404).json({ error: 'not_found' });

    const place = rows[0];
    // Sibling listings — "More in {region}" cross-linking (VCL pattern)
    if (place.region_id) {
      const { rows: siblings } = await pool.query(`
        SELECT slug, name, vertical, image_url
          FROM places
         WHERE region_id = $1 AND id != $2 AND status = 'active'
         ORDER BY id DESC LIMIT 8
      `, [place.region_id, place.id]);
      place.siblings_in_region = siblings;
    } else {
      place.siblings_in_region = [];
    }

    res.json(place);
  } catch (e) {
    serverError(res, e);
  }
});

app.get('/api/stats', async (_req, res) => {
  try {
    const { rows: byCat } = await pool.query(
      `SELECT category, COUNT(*)::int AS n FROM places WHERE status='active' GROUP BY category ORDER BY n DESC`
    );
    const { rows: byVert } = await pool.query(
      `SELECT vertical, COUNT(*)::int AS n FROM places WHERE status='active' GROUP BY vertical ORDER BY n DESC`
    );
    const { rows: byRegion } = await pool.query(`
      SELECT r.slug, r.name, COUNT(p.id)::int AS n
        FROM regions r
   LEFT JOIN places p ON p.region_id = r.id AND p.status='active'
    GROUP BY r.id ORDER BY n DESC, r.name ASC
    `);
    const { rows: [{ total }] } = await pool.query(
      `SELECT COUNT(*)::int AS total FROM places WHERE status='active'`
    );
    res.json({ total, by_category: byCat, by_vertical: byVert, by_region: byRegion, verticals: VERTICALS });
  } catch (e) {
    serverError(res, e);
  }
});

app.post('/api/leads', async (req, res) => {
  try {
    const { place_id, name, email, phone, message, meta } = req.body || {};
    const { rows } = await pool.query(`
      INSERT INTO leads (place_id, name, email, phone, message, meta, ip, user_agent)
      VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id
    `, [place_id || null, name, email, phone, message, meta || {}, req.ip, req.get('user-agent') || '']);
    res.json({ ok: true, id: rows[0].id });
  } catch (e) {
    serverError(res, e);
  }
});

app.get('/api/ingest/runs', async (_req, res) => {
  try {
    const { rows } = await pool.query(
      `SELECT id, source, started_at, finished_at, rows_in, rows_added, rows_updated, status, notes
         FROM ingest_runs ORDER BY started_at DESC LIMIT 50`
    );
    res.json({ runs: rows });
  } catch (e) {
    serverError(res, e);
  }
});

// SEO: robots.txt — keep /api/* + /unsubscribe + /admin out of search
app.get('/robots.txt', (_req, res) => {
  const url = (process.env.PUBLIC_URL || `https://${SITE_DOMAIN}`).replace(/\/+$/, '');
  res.type('text/plain').send(
    `User-agent: *\nAllow: /\nDisallow: /api/\n\nSitemap: ${url}/sitemap.xml\n`
  );
});

// SEO: sitemap.xml — VCL pattern.
// Static + per-region + per-vertical + per-place. Capped at 50k URLs for now;
// when we exceed that we paginate via sitemap-index.
app.get('/sitemap.xml', async (_req, res, next) => {
  try {
    const baseUrl = (process.env.PUBLIC_URL || `https://${SITE_DOMAIN}`).replace(/\/+$/, '');
    const escape = (s) => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&apos;');
    const today = new Date().toISOString().slice(0, 10);

    const { rows: regions } = await pool.query(
      `SELECT slug, name FROM regions WHERE region_type IN ('city','town','canton') ORDER BY name`
    );
    const { rows: verticals } = await pool.query(
      `SELECT DISTINCT vertical FROM places WHERE status='active' ORDER BY vertical`
    );
    const { rows: places } = await pool.query(
      `SELECT slug, updated_at, image_url FROM places WHERE status='active' ORDER BY id DESC LIMIT 48000`
    );

    const staticPages = [
      { loc: '/',         changefreq: 'daily',   priority: '1.0' },
      { loc: '/provinces', changefreq: 'weekly', priority: '0.8' },
      { loc: '/verticals', changefreq: 'weekly', priority: '0.8' },
      { loc: '/about',    changefreq: 'monthly', priority: '0.7' },
      { loc: '/search',   changefreq: 'weekly',  priority: '0.6' },
      { loc: '/stats',    changefreq: 'weekly',  priority: '0.5' },
      { loc: '/?category=tourism',  changefreq: 'daily', priority: '0.8' },
      { loc: '/?category=rentals',  changefreq: 'daily', priority: '0.8' },
      { loc: '/?category=service',  changefreq: 'daily', priority: '0.8' },
      ...Object.keys(PROVINCE_SLUGS).map(s => ({ loc: `/pr/${s}`, changefreq: 'weekly', priority: '0.85' })),
      ...verticals.map(v => ({ loc: `/v/${vSlug(v.vertical)}`, changefreq: 'weekly', priority: '0.7' })),
    ];
    const regionPages = regions.map(r => ({ loc: `/r/${r.slug}`, changefreq: 'weekly', priority: '0.7' }));

    let xml = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">\n';
    for (const p of [...staticPages, ...regionPages]) {
      xml += `  <url><loc>${baseUrl}${escape(p.loc)}</loc><lastmod>${today}</lastmod><changefreq>${p.changefreq}</changefreq><priority>${p.priority}</priority></url>\n`;
    }
    for (const pl of places) {
      const lastmod = (pl.updated_at instanceof Date ? pl.updated_at : new Date(pl.updated_at || Date.now())).toISOString().slice(0, 10);
      const loc = `${baseUrl}/p/${escape(pl.slug)}`;
      let imgBlock = '';
      if (pl.image_url) imgBlock = `<image:image><image:loc>${escape(pl.image_url)}</image:loc></image:image>`;
      xml += `  <url><loc>${loc}</loc><lastmod>${lastmod}</lastmod><changefreq>weekly</changefreq><priority>0.5</priority>${imgBlock}</url>\n`;
    }
    xml += '</urlset>\n';

    res.set('Content-Type', 'application/xml; charset=utf-8');
    res.set('Cache-Control', 'public, max-age=3600');
    res.send(xml);
  } catch (err) { next(err); }
});

// JSON-LD LocalBusiness payload for a single place — embedded by /p/<slug> client.
app.get('/api/places/:slug/jsonld', async (req, res) => {
  try {
    const { rows } = await pool.query(`
      SELECT p.*, r.name AS region_name, r.province
        FROM places p LEFT JOIN regions r ON p.region_id = r.id
       WHERE p.slug = $1`, [req.params.slug]);
    if (!rows.length) return res.status(404).json({});
    const p = rows[0];
    const baseUrl = (process.env.PUBLIC_URL || `https://${SITE_DOMAIN}`).replace(/\/+$/, '');
    const ld = {
      '@context': 'https://schema.org',
      '@type': 'LocalBusiness',
      '@id': `${baseUrl}/p/${p.slug}`,
      name: p.name,
      description: p.description || undefined,
      url: p.website || `${baseUrl}/p/${p.slug}`,
      image: p.image_url || undefined,
      telephone: p.phone || undefined,
      email: p.email || undefined,
      identifier: p.cedula_juridica ? { '@type': 'PropertyValue', name: 'Cédula jurídica', value: p.cedula_juridica } : undefined,
      address: (p.address || p.region_name) ? {
        '@type': 'PostalAddress',
        streetAddress: p.address || undefined,
        addressLocality: p.region_name || undefined,
        addressRegion: p.province || undefined,
        addressCountry: 'CR',
      } : undefined,
      geo: (p.lat && p.lng) ? { '@type': 'GeoCoordinates', latitude: p.lat, longitude: p.lng } : undefined,
      aggregateRating: p.rating ? { '@type': 'AggregateRating', ratingValue: p.rating, bestRating: 5 } : undefined,
    };
    Object.keys(ld).forEach(k => ld[k] === undefined && delete ld[k]);
    res.json(ld);
  } catch (e) { serverError(res, e); }
});

// Region landing page — dedicated layout with hero image + region map.
app.get('/r/:slug', async (req, res) => {
  try {
    const { rows } = await pool.query('SELECT slug FROM regions WHERE slug = $1', [req.params.slug]);
    if (!rows.length) return res.status(404).sendFile(path.join(__dirname, 'public', '404.html'));
    res.sendFile(path.join(__dirname, 'public', 'region.html'));
  } catch (e) { serverError(res, e); }
});

// Region details endpoint (one region + count + sibling regions in same province)
app.get('/api/regions/:slug', async (req, res) => {
  try {
    const { rows } = await pool.query(`
      SELECT r.id, r.slug, r.name, r.province, r.region_type, r.lat, r.lng,
             r.image_url, r.image_credit, r.image_license, r.image_source_url,
             r.description,
             COUNT(p.id)::int AS place_count
        FROM regions r
   LEFT JOIN places p ON p.region_id = r.id AND p.status = 'active'
       WHERE r.slug = $1
    GROUP BY r.id`, [req.params.slug]);
    if (!rows.length) return res.status(404).json({ error: 'not_found' });
    const region = rows[0];

    const { rows: byVert } = await pool.query(
      `SELECT vertical, COUNT(*)::int AS n FROM places WHERE region_id=$1 AND status='active' GROUP BY vertical ORDER BY n DESC`,
      [region.id]
    );
    const { rows: provincial } = await pool.query(
      `SELECT r.slug, r.name, r.image_url, COUNT(p.id)::int AS n
         FROM regions r LEFT JOIN places p ON p.region_id=r.id AND p.status='active'
        WHERE r.province = $1 AND r.id != $2 AND r.region_type IN ('city','town','canton')
     GROUP BY r.id ORDER BY n DESC NULLS LAST, r.name LIMIT 12`,
      [region.province, region.id]
    );

    region.by_vertical = byVert;
    region.nearby_in_province = provincial;
    res.json(region);
  } catch (e) {
    serverError(res, e);
  }
});

// Place detail page
app.get('/p/:slug', async (req, res) => {
  try {
    const { rows } = await pool.query(`
      SELECT p.*, r.slug AS region_slug, r.name AS region_name, r.province
        FROM places p
   LEFT JOIN regions r ON p.region_id = r.id
       WHERE p.slug = $1
    `, [req.params.slug]);
    if (!rows.length) return res.status(404).sendFile(path.join(__dirname, 'public', '404.html'));
    res.sendFile(path.join(__dirname, 'public', 'place.html'));
  } catch (e) { serverError(res, e); }
});

app.get('/stats', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'stats.html')));
app.get('/provinces', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'provinces.html')));
app.get('/search', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'search.html')));
app.get('/verticals', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'verticals.html')));
app.get('/about', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'about.html')));
app.get('/v/:slug', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'vertical.html')));
app.get('/pr/:slug', async (req, res) => {
  if (!PROVINCE_SLUGS[req.params.slug]) return res.status(404).sendFile(path.join(__dirname, 'public', '404.html'));
  res.sendFile(path.join(__dirname, 'public', 'province.html'));
});

// Snapshot-file 404 guard — refuse to ever serve .bak / .bak.* / .pre-* /
// .orig editor leftovers, even if one slips into public/ by accident.
app.use((req, res, next) => {
  if (/\.(bak|orig)(\.|$)|\.pre-/i.test(req.path)) {
    return res.status(404).type('text/plain').send('not found');
  }
  next();
});

app.use(express.static(path.join(__dirname, 'public')));

// Global error handler — MUST be last (4-arg). Catches next(err) forwarded by the
// harden()'d sub-routers (an uncaught async throw in any route) and returns a clean,
// generic 500 instead of a hung request. res.headersSent guard avoids a
// double-response if a handler already started replying before throwing.
// (Cody gate, cycle 11, TK-10346.)
app.use((err, req, res, next) => {
  if (res.headersSent) return next(err);
  serverError(res, err, `${req.method} ${req.path}`);
});

// Export the fully-wired app so route tests can import it (supertest-style) WITHOUT
// binding a port or running the boot guard. Only the real entrypoint (`node server.js`,
// via pm2 / `npm start`) has require.main === module, so preflight + listen run in
// prod exactly as before — importing this module (in a test) does neither.
// Expose the DB pool on app.locals so a test can end() it (this module-scoped pool
// is otherwise unreachable, and an open pool would keep the test process alive).
app.locals.pool = pool;
module.exports = app;

if (require.main === module) {
  // TK-10346 — fail-closed boot guard: refuse to serve in production if a payment/WhatsApp
  // integration is LIVE without its webhook secret (would silently reject every real webhook
  // -> payments succeed but bookings never confirm). Inert while everything is sandbox.
  require('./lib/preflight').runPreflight({
    getProvider: require('./lib/payments').getProvider,
    whatsapp: require('./lib/whatsapp'),
  });

  app.listen(PORT, '0.0.0.0', () => {
    console.log(`[${SITE_NAME}] listening on :${PORT} — gated as ${BA_USER || 'OPEN'} — domain ${SITE_DOMAIN}`);
  });
}