← back to Restaurant Directory
src/api/server.ts
357 lines
// MUST be the first import — sets PG env defaults before directory-core/db loads.
import './_pg-defaults.ts';
import 'dotenv/config';
/**
* LA County Eats — single Express server (API + HTML routes).
* Port 9744 by default.
*
* Routes:
* GET / home / list view (paginated, filterable)
* GET /r/:slug restaurant detail
* GET /map map view (all 37K pins)
* GET /api/restaurants JSON list (?city, ?cuisine, ?zip, ?q, ?limit, ?offset)
* GET /api/restaurants/:slug JSON detail
* GET /healthz health check
*/
import express from 'express';
import compression from 'compression';
import helmet from 'helmet';
import path from 'node:path';
// 2026-05-04: migrated from inline `new Pool({...})` to directory-core/db.
// Pool is lazily built on first use; PG config comes from env (defaults set
// in _pg-defaults.ts above for backward compat).
import { pool } from 'directory-core/db';
const PORT = parseInt(process.env.PORT ?? '9744', 10);
const HOST = process.env.HOST ?? '127.0.0.1';
const app = express();
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com', 'https://unpkg.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
imgSrc: ["'self'", 'data:', 'https://*.tile.openstreetmap.org', 'https://unpkg.com'],
scriptSrc: ["'self'", "'unsafe-inline'", 'https://unpkg.com'],
connectSrc: ["'self'"],
},
},
crossOriginEmbedderPolicy: false,
}));
app.use(compression());
app.set('view engine', 'ejs');
app.set('views', path.resolve(__dirname, '..', 'web', 'views'));
app.use('/static', express.static(path.resolve(__dirname, '..', '..', 'public')));
// robots.txt at root (search engines won't read /static/robots.txt)
app.get('/robots.txt', (_req, res) => {
res.type('text/plain');
res.set('Cache-Control', 'public, max-age=3600');
res.sendFile(path.resolve(__dirname, '..', '..', 'public', 'robots.txt'));
});
// Cheap UA-based scraper block at the app layer (defense-in-depth behind Cloudflare WAF).
// Allows GET requests with empty/curl/wget/python/etc. UAs to /healthz only; everything else → 403.
const SCRAPER_UA = /(scrapy|python-requests|python-urllib|Go-http-client|Java\/\d|libwww-perl|^curl\/|^Wget|^aiohttp|^node-fetch|^axios\/|HTTPClient\/|Postman|Insomnia)/i;
const BLOCKED_AI_UA = /(GPTBot|ClaudeBot|anthropic-ai|Claude-Web|CCBot|Google-Extended|PerplexityBot|cohere-ai|Bytespider|Amazonbot|Applebot-Extended|FacebookBot|Meta-ExternalAgent|ImagesiftBot|OAI-SearchBot|Diffbot|Omgili|omgilibot|SemrushBot|AhrefsBot|MJ12bot|DotBot|BLEXBot|DataForSeoBot)/i;
app.use((req, res, next) => {
// Endpoints that must remain reachable by any UA (search-engine crawlers,
// health checks, Cloudflare origin probes). Sitemap and robots.txt are the
// SEO contract — Googlebot fetches them with its own UA but we don't want
// CCBot here either; the BLOCKED_AI_UA filter still catches AI scrapers.
const PUBLIC_PATHS = req.path === '/healthz' || req.path === '/robots.txt' || req.path.startsWith('/sitemap');
const ua = req.get('user-agent') ?? '';
// Always block AI scrapers, even on /sitemap*.xml.
if (BLOCKED_AI_UA.test(ua)) {
res.status(403).type('text/plain').send('AI crawlers not permitted. See /robots.txt.');
return;
}
if (PUBLIC_PATHS) return next();
if (!ua || SCRAPER_UA.test(ua)) {
res.status(403).type('text/plain').send('Scraping not permitted. See /robots.txt.');
return;
}
next();
});
// ────────────────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────────────────
type FacilityRow = {
facility_id: string; slug: string; name: string;
address: string | null; city: string | null; zip: string | null;
lat: number | null; lng: number | null;
facility_type: string | null; pe_description: string | null;
};
const LIST_QUERY = `
SELECT
f.facility_id, f.slug, f.name, f.address, f.city, f.zip,
f.lat::float AS lat, f.lng::float AS lng,
f.facility_type, f.pe_description,
f.source_type, f.opened_on::text AS opened_on, f.source_url,
e.cuisine, e.cuisine_confidence
FROM facility f
LEFT JOIN facility_enrichment e ON e.facility_id = f.facility_id
`;
// ────────────────────────────────────────────────────────────────────────
// HTML routes
// ────────────────────────────────────────────────────────────────────────
app.get('/', async (req, res, next) => {
try {
const city = (req.query.city as string)?.toUpperCase().trim() || null;
const cuisine = (req.query.cuisine as string)?.trim() || null;
const q = (req.query.q as string)?.trim() || null;
const page = Math.max(1, parseInt(req.query.page as string ?? '1', 10) || 1);
const limit = 50;
const offset = (page - 1) * limit;
const where: string[] = ['f.is_active = true', 'f.facility_type = \'restaurant\''];
const params: any[] = [];
if (city) { params.push(city); where.push(`UPPER(f.city) = $${params.length}`); }
if (cuisine) { params.push(cuisine); where.push(`e.cuisine = $${params.length}`); }
if (q) { params.push(`%${q}%`); where.push(`f.name ILIKE $${params.length}`); }
params.push(limit, offset);
const sql = `${LIST_QUERY} WHERE ${where.join(' AND ')} ORDER BY f.name LIMIT $${params.length-1} OFFSET $${params.length}`;
const { rows } = await pool.query<FacilityRow>(sql, params);
const countParams = params.slice(0, -2);
const { rows: cnt } = await pool.query<{ total: string }>(
`SELECT COUNT(*)::text AS total
FROM facility f
LEFT JOIN facility_enrichment e ON e.facility_id = f.facility_id
WHERE ${where.join(' AND ')}`,
countParams
);
const total = parseInt(cnt[0].total, 10);
const { rows: cities } = await pool.query<{ city: string; n: number }>(
`SELECT city, COUNT(*)::int AS n FROM facility WHERE city IS NOT NULL AND facility_type = 'restaurant' GROUP BY city ORDER BY n DESC LIMIT 25`
);
const { rows: cuisines } = await pool.query<{ cuisine: string; n: number }>(
`SELECT cuisine, COUNT(*)::int AS n FROM facility_enrichment
WHERE cuisine IS NOT NULL AND cuisine NOT IN ('Non-restaurant')
GROUP BY cuisine ORDER BY 2 DESC LIMIT 30`
);
// Top cuisines hero block — only shown when no filters applied (homepage)
const showHero = !city && !cuisine && !q && page === 1;
const heroCuisines = showHero ? cuisines.slice(0, 12) : [];
// Newest 6 LA restaurants — only on the unfiltered homepage. Press-sourced
// openings (source_type='press') float to the top by opened_on, then
// permitted facilities by first_seen_at. Filtered to City of LA so the
// section feels local; widening would just surface 6 random recent permits.
const newestRows = showHero ? (await pool.query<FacilityRow & { opened_on: string | null }>(
`${LIST_QUERY}
WHERE f.is_active = true
AND f.facility_type = 'restaurant'
AND UPPER(f.city) = 'LOS ANGELES'
ORDER BY COALESCE(f.opened_on, f.first_seen_at::date) DESC, f.first_seen_at DESC
LIMIT 6`
)).rows : [];
// rel=alternate JSON mirror — same query, paginated. Apps + agents can hit
// /api/restaurants with the same query string for JSON. RFC 8288 HTTP Link
// header in addition to the HTML <link rel> tag in head.ejs.
const baseUrl = (process.env.PUBLIC_URL || `http://localhost:${PORT}`).replace(/\/+$/, '');
const apiQs = new URLSearchParams();
if (q) apiQs.set('q', q);
if (city) apiQs.set('city', city);
if (cuisine) apiQs.set('cuisine', cuisine);
apiQs.set('limit', String(limit));
apiQs.set('offset', String(offset));
const alternateJsonUrl = `${baseUrl}/api/restaurants?${apiQs.toString()}`;
const canonicalUrl = baseUrl + '/' + (req.url.includes('?') ? req.url.split('?')[1] && '?' + req.url.split('?')[1] : '');
res.set('Link', `<${canonicalUrl}>; rel="canonical", <${alternateJsonUrl}>; rel="alternate"; type="application/json"`);
res.render('list', { rows, total, page, limit, city, cuisine, q, cities, cuisines, heroCuisines, showHero, newestRows, alternateJsonUrl });
} catch (e) { next(e); }
});
// ────────────────────────────────────────────────────────────────────────
// City landing page — /c/<slug> e.g. /c/los-angeles
// ────────────────────────────────────────────────────────────────────────
app.get('/c/:slug', async (req, res, next) => {
try {
const slug = req.params.slug.toLowerCase();
const { rows: cityRow } = await pool.query<{ city: string; n: number }>(
`SELECT city, COUNT(*)::int AS n FROM facility
WHERE facility_type = 'restaurant'
AND lower(regexp_replace(city, '[^a-zA-Z0-9]+', '-', 'g')) = $1
GROUP BY city ORDER BY n DESC LIMIT 1`,
[slug]
);
if (cityRow.length === 0) { res.status(404).render('not-found'); return; }
const city = cityRow[0].city;
const total = cityRow[0].n;
// Cuisine breakdown
const { rows: cityCuisines } = await pool.query<{ cuisine: string; n: number }>(
`SELECT e.cuisine, COUNT(*)::int AS n
FROM facility f
JOIN facility_enrichment e ON e.facility_id = f.facility_id
WHERE f.facility_type = 'restaurant' AND UPPER(f.city) = UPPER($1)
AND e.cuisine IS NOT NULL AND e.cuisine NOT IN ('Non-restaurant')
GROUP BY e.cuisine ORDER BY 2 DESC LIMIT 20`,
[city]
);
// Top 50 by name (alphabetical for predictability)
const { rows: topRows } = await pool.query<FacilityRow>(
`${LIST_QUERY} WHERE f.facility_type = 'restaurant' AND UPPER(f.city) = UPPER($1) ORDER BY f.name LIMIT 50`,
[city]
);
const baseUrl = (process.env.PUBLIC_URL || `http://localhost:${PORT}`).replace(/\/+$/, '');
const alternateJsonUrl = `${baseUrl}/api/restaurants?city=${encodeURIComponent(city)}&limit=50`;
res.set('Link', `<${baseUrl}/c/${slug}>; rel="canonical", <${alternateJsonUrl}>; rel="alternate"; type="application/json"`);
res.render('city', { city, slug, total, cityCuisines, topRows, alternateJsonUrl });
} catch (e) { next(e); }
});
app.get('/r/:slug', async (req, res, next) => {
try {
const detailSql = `
SELECT
f.facility_id, f.slug, f.name, f.address, f.city, f.state, f.zip,
f.lat::float AS lat, f.lng::float AS lng,
f.facility_type, f.pe_code, f.pe_description, f.seat_tier,
f.owner_name, f.first_seen_at::text, f.last_updated_at::text,
e.cuisine, e.cuisine_confidence
FROM facility f
LEFT JOIN facility_enrichment e ON e.facility_id = f.facility_id
WHERE f.slug = $1 LIMIT 1`;
const { rows: r2 } = await pool.query(detailSql, [req.params.slug]);
if (r2.length === 0) { res.status(404).render('not-found'); return; }
const fac = r2[0];
res.render('detail', { fac });
} catch (e) { next(e); }
});
app.get('/map', async (_req, res, next) => {
try {
const { rows: stats } = await pool.query<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM facility WHERE facility_type = 'restaurant' AND lat IS NOT NULL`
);
res.render('map', { total: parseInt(stats[0].n, 10) });
} catch (e) { next(e); }
});
// ────────────────────────────────────────────────────────────────────────
// JSON API
// ────────────────────────────────────────────────────────────────────────
app.get('/api/restaurants', async (req, res, next) => {
try {
const limit = Math.min(500, parseInt(req.query.limit as string ?? '50', 10) || 50);
const offset = Math.max(0, Math.min(100000, parseInt(req.query.offset as string ?? '0', 10) || 0));
const where: string[] = ['f.is_active = true', 'f.facility_type = \'restaurant\''];
const params: any[] = [];
if (req.query.city) { params.push((req.query.city as string).toUpperCase()); where.push(`UPPER(f.city) = $${params.length}`); }
if (req.query.zip) { params.push(req.query.zip); where.push(`f.zip = $${params.length}`); }
if (req.query.q) { params.push(`%${req.query.q}%`); where.push(`f.name ILIKE $${params.length}`); }
params.push(limit, offset);
const sql = `${LIST_QUERY} WHERE ${where.join(' AND ')} ORDER BY f.name LIMIT $${params.length-1} OFFSET $${params.length}`;
const { rows } = await pool.query(sql, params);
res.json({ count: rows.length, results: rows });
} catch (e) { next(e); }
});
app.get('/api/restaurants/:slug', async (req, res, next) => {
try {
const { rows } = await pool.query(`${LIST_QUERY} WHERE f.is_active = true AND f.slug = $1 LIMIT 1`, [req.params.slug]);
if (rows.length === 0) { res.status(404).json({ error: 'not_found' }); return; }
res.json(rows[0]);
} catch (e) { next(e); }
});
// Bulk pins endpoint for the map view (cached aggressively)
app.get('/api/pins', async (_req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT facility_id, slug, name, lat::float AS lat, lng::float AS lng
FROM facility
WHERE is_active = true AND facility_type = 'restaurant' AND lat IS NOT NULL AND lng IS NOT NULL`
);
res.set('Cache-Control', 'public, max-age=3600');
res.json({ count: rows.length, pins: rows });
} catch (e) { next(e); }
});
app.get('/healthz', (_req, res) => res.json({ ok: true, ts: new Date().toISOString() }));
// ────────────────────────────────────────────────────────────────────────
// Sitemap — split into index + per-city chunks so we stay under Google's
// 50K-URL / 50MB-uncompressed limit per file. ~37K facilities → 1 file.
// ────────────────────────────────────────────────────────────────────────
app.get('/sitemap.xml', async (_req, res, next) => {
try {
const { rows: cities } = await pool.query<{ city: string }>(
`SELECT DISTINCT UPPER(city) AS city FROM facility WHERE facility_type = 'restaurant' AND city IS NOT NULL ORDER BY 1`
);
const base = `https://lacountyeats.com`;
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>${base}/sitemap-restaurants.xml</loc></sitemap>
${cities.map(c => ` <sitemap><loc>${base}/sitemap-city-${encodeURIComponent(c.city.toLowerCase().replace(/[^a-z0-9]+/g, '-'))}.xml</loc></sitemap>`).join('\n')}
</sitemapindex>`;
res.type('application/xml').set('Cache-Control', 'public, max-age=86400').send(xml);
} catch (e) { next(e); }
});
// Per-city sitemap — slug → city name (case-insensitive)
app.get(/^\/sitemap-city-([a-z0-9-]+)\.xml$/, async (req, res, next) => {
try {
const slug = (req.params as any)[0] as string;
// Resolve slug → city name via reverse-slugify (the slugify pattern in
// ingest is `lower(city).replace(/[^a-z0-9]+/g, '-')`; we just match it)
const { rows } = await pool.query<{ slug: string; last_updated_at: string }>(
`SELECT slug, last_updated_at::text FROM facility
WHERE facility_type = 'restaurant'
AND lower(regexp_replace(city, '[^a-zA-Z0-9]+', '-', 'g')) = $1
ORDER BY name LIMIT 50000`,
[slug]
);
if (rows.length === 0) { res.status(404).type('text/plain').send('No such city'); return; }
const base = `https://lacountyeats.com`;
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>${base}/c/${slug}</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>
${rows.map(r => ` <url><loc>${base}/r/${r.slug}</loc><lastmod>${(r.last_updated_at ?? new Date().toISOString()).slice(0, 10)}</lastmod><changefreq>monthly</changefreq><priority>0.6</priority></url>`).join('\n')}
</urlset>`;
res.type('application/xml').set('Cache-Control', 'public, max-age=86400').send(xml);
} catch (e) { next(e); }
});
app.get('/sitemap-restaurants.xml', async (_req, res, next) => {
try {
const { rows } = await pool.query<{ slug: string; last_updated_at: string }>(
`SELECT slug, last_updated_at::text FROM facility WHERE facility_type = 'restaurant' ORDER BY name LIMIT 50000`
);
const base = `https://lacountyeats.com`;
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>${base}/</loc><changefreq>daily</changefreq><priority>1.0</priority></url>
<url><loc>${base}/map</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>
${rows.map(r => ` <url><loc>${base}/r/${r.slug}</loc><lastmod>${(r.last_updated_at ?? new Date().toISOString()).slice(0, 10)}</lastmod><changefreq>monthly</changefreq><priority>0.6</priority></url>`).join('\n')}
</urlset>`;
res.type('application/xml').set('Cache-Control', 'public, max-age=86400').send(xml);
} catch (e) { next(e); }
});
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error('[server] error:', err);
res.status(500).json({ error: 'internal_server_error', message: err.message });
});
app.listen(PORT, HOST, () => {
console.log(`[lacountyeats] listening on http://${HOST}:${PORT}`);
});