← 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
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}`);
});
}