← 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}`);
});