// entities.ts — Internal API for Verilex Data entity sync // // GET /api/v1/entities/bulk?state=CO&limit=10000&cursor=123 — paginated bulk export // GET /api/v1/entities/states — list states with entity counts // GET /api/v1/states/:code/name-search?name=Acme — name availability (cached 24h) // // All endpoints require internal auth (PW_INTERNAL_API_KEY). import { Router, type Request, type Response } from "express"; import { pool } from "../db.js"; import { internalAuth } from "../middleware/internal-auth.js"; const router = Router(); const WORKER_URL = process.env.WORKER_URL || "http://workers:8090"; // ── Bulk entity export ─────────────────────────────────────────────────────── router.get("/api/v1/entities/bulk", internalAuth, async (req: Request, res: Response) => { try { const state = (req.query.state as string || "").toUpperCase(); const limit = Math.min(parseInt(req.query.limit as string || "10000", 10), 50000); const cursor = parseInt(req.query.cursor as string || "0", 10); if (!state || state.length !== 2) { res.status(400).json({ error: "state parameter required (2-letter code)" }); return; } const { rows } = await pool.query( `SELECT id, jurisdiction, entity_name, entity_number, entity_type, status, formation_date, dissolution_date, registered_agent, principal_address, state FROM entity_cache WHERE state = $1 AND id > $2 ORDER BY id LIMIT $3`, [state, cursor, limit], ); const nextCursor = rows.length > 0 ? rows[rows.length - 1].id : cursor; const hasMore = rows.length === limit; res.json({ state, count: rows.length, has_more: hasMore, next_cursor: nextCursor, entities: rows.map((r: any) => ({ entity_name: r.entity_name, entity_number: r.entity_number, entity_type: r.entity_type, status: r.status, formation_date: r.formation_date, dissolution_date: r.dissolution_date, registered_agent: r.registered_agent, principal_address: r.principal_address, jurisdiction: r.jurisdiction, state: r.state, })), }); } catch (err: any) { console.error("Bulk export error:", err.message); res.status(500).json({ error: "Bulk export failed" }); } }); // ── List states with entity counts ─────────────────────────────────────────── router.get("/api/v1/entities/states", internalAuth, async (_req: Request, res: Response) => { try { const { rows } = await pool.query( `SELECT state, COUNT(*) AS count, MAX(last_synced) AS last_synced FROM entity_cache GROUP BY state ORDER BY count DESC`, ); res.json({ total_states: rows.length, total_entities: rows.reduce((sum: number, r: any) => sum + parseInt(r.count, 10), 0), states: rows.map((r: any) => ({ state: r.state, count: parseInt(r.count, 10), last_synced: r.last_synced, })), }); } catch (err: any) { res.status(500).json({ error: "Failed to list states" }); } }); // ── Name availability search (cached 24h) ──────────────────────────────────── router.get("/api/v1/states/:code/name-search", internalAuth, async (req: Request, res: Response) => { try { const stateCode = req.params.code.toUpperCase(); const name = (req.query.name as string || "").trim(); if (!name || name.length < 2) { res.status(400).json({ error: "name parameter required (min 2 chars)" }); return; } // Check cache first const { rows: cached } = await pool.query( `SELECT available, exact_match, similar_names, searched_at FROM name_search_cache WHERE state_code = $1 AND searched_name = $2 AND expires_at > NOW()`, [stateCode, name.toUpperCase()], ); if (cached.length > 0) { res.json({ state_code: stateCode, name, available: cached[0].available, exact_match: cached[0].exact_match, similar_names: cached[0].similar_names || [], cached: true, searched_at: cached[0].searched_at, }); return; } // Call Python worker for live search let searchResult: any; try { const workerResp = await fetch(`${WORKER_URL}/name-search`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ state_code: stateCode, name }), signal: AbortSignal.timeout(30000), }); searchResult = await workerResp.json(); } catch (workerErr: any) { // Worker unavailable — fall back to entity_cache search const { rows: fallback } = await pool.query( `SELECT entity_name FROM entity_cache WHERE state = $1 AND upper(entity_name) = $2 LIMIT 1`, [stateCode, name.toUpperCase()], ); const { rows: similar } = await pool.query( `SELECT entity_name FROM entity_cache WHERE state = $1 AND entity_name ILIKE $2 ORDER BY entity_name LIMIT 10`, [stateCode, `%${name}%`], ); searchResult = { available: fallback.length === 0, exact_match: fallback.length > 0, similar_names: similar.map((r: any) => r.entity_name), source: "cache_fallback", }; } // Cache the result const available = searchResult.available ?? null; const exactMatch = searchResult.exact_match ?? false; const similarNames = searchResult.similar_names || []; await pool.query( `INSERT INTO name_search_cache (state_code, searched_name, available, exact_match, similar_names) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (state_code, searched_name) DO UPDATE SET available = EXCLUDED.available, exact_match = EXCLUDED.exact_match, similar_names = EXCLUDED.similar_names, searched_at = NOW(), expires_at = NOW() + INTERVAL '24 hours'`, [stateCode, name.toUpperCase(), available, exactMatch, similarNames], ).catch(() => {}); // non-critical if cache write fails res.json({ state_code: stateCode, name, available, exact_match: exactMatch, similar_names: similarNames, cached: false, searched_at: new Date().toISOString(), }); } catch (err: any) { console.error("Name search error:", err.message); res.status(500).json({ error: "Name search failed" }); } }); export default router;