new-site/api/src/routes/entities.ts
justin f8cd37ac8c Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 06:54:22 -05:00

191 lines
6.6 KiB
TypeScript

// 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;