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>
191 lines
6.6 KiB
TypeScript
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;
|