← back to Stayclaim
src/lib/la-permits.ts
189 lines
/**
* LA City permits adapter — Socrata `pi9x-tg5x` (Building and Safety).
*
* Limitation: This is the City of LA dataset only. Beverly Hills, Santa
* Monica, Culver City, West Hollywood, Pasadena etc. are separate
* jurisdictions with their own portals; each needs its own adapter.
*
* APN format here is digits-only (no hyphens) — `4343024017` not `4343-024-017`.
* Cache in `la_permit` table.
*/
import { pool } from './db';
const SOCRATA_BASE = 'https://data.lacity.org/resource/pi9x-tg5x.json';
const UA = 'pastdoor/1.0 (https://wholivedthere.com)';
const CACHE_TTL_DAYS = 7; // permits update more often than parcel data
export type LAPermit = {
permit_nbr: string;
apn: string;
primary_address: string | null;
zip_code: string | null;
permit_type: string | null;
permit_subtype: string | null;
permit_group: string | null;
status: string | null;
issue_date: string | null; // ISO date
status_date: string | null;
valuation: number | null;
use_code: string | null;
use_description: string | null;
jurisdiction: string;
};
function apnDigits(apn: string): string {
// Strict digits-only — Socrata APN field is a 10-digit string. Anything else
// (whitespace, hyphens, '&', quotes, SoQL metachars) is dropped before the
// value is interpolated into the request URL.
return String(apn).replace(/\D/g, '');
}
async function fetchJson(url: string): Promise<unknown> {
// 5s hard timeout — without AbortSignal a hung Socrata endpoint blocks the
// server-side caller indefinitely. Mirrors la-arcgis pattern.
const r = await fetch(url, {
headers: { 'User-Agent': UA, Accept: 'application/json' },
signal: AbortSignal.timeout(5_000),
});
if (!r.ok) throw new Error(`socrata ${r.status}`);
return r.json();
}
/** Cache-first lookup. Returns up to `limit` permits for an APN. */
export async function getPermitsForApn(apnInput: string, opts: { limit?: number } = {}): Promise<LAPermit[]> {
const apn = apnDigits(apnInput);
if (!apn) return [];
const limit = Math.min(opts.limit ?? 50, 200);
// Check cache freshness — if any permit for this APN was fetched within TTL, trust the cache.
try {
const fresh = await pool.query<{ exists: boolean }>(
`SELECT EXISTS (SELECT 1 FROM la_permit WHERE apn = $1 AND fetched_at > now() - ($2 || ' days')::interval) AS exists`,
[apn, CACHE_TTL_DAYS]
);
if (fresh.rows[0]?.exists) {
const { rows } = await pool.query<LAPermit>(
`SELECT permit_nbr, apn, primary_address, zip_code, permit_type, permit_subtype, permit_group,
status, issue_date::text, status_date::text, valuation, use_code, use_description, jurisdiction
FROM la_permit
WHERE apn = $1
ORDER BY issue_date DESC NULLS LAST
LIMIT $2`,
[apn, limit]
);
return rows;
}
} catch (e) {
// Table may not exist yet — fall through to fetch.
// Use pg error code 42P01 (undefined_table) primarily; fall back to message
// match for non-pg errors.
const code = (e as { code?: string } | null)?.code;
if (code !== '42P01' && !(e instanceof Error && /relation .* does not exist/.test(e.message))) throw e;
}
// Fetch from Socrata. APN is digit-sanitized above; encodeURIComponent is
// belt-and-braces.
const url = `${SOCRATA_BASE}?apn=${encodeURIComponent(apn)}&$order=issue_date DESC&$limit=${encodeURIComponent(String(limit))}`;
let data: unknown;
try {
data = await fetchJson(url);
} catch (e) {
// Transient network/timeout failure — fall back to the cache regardless of
// TTL so callers don't see "no permits" when the truth is "lookup failed."
console.warn(`la_permits fetch: ${e instanceof Error ? e.message : e}`);
try {
const { rows } = await pool.query<LAPermit>(
`SELECT permit_nbr, apn, primary_address, zip_code, permit_type, permit_subtype, permit_group,
status, issue_date::text, status_date::text, valuation, use_code, use_description, jurisdiction
FROM la_permit
WHERE apn = $1
ORDER BY issue_date DESC NULLS LAST
LIMIT $2`,
[apn, limit]
);
return rows;
} catch {
return [];
}
}
const rows = Array.isArray(data) ? data : [];
if (!rows.length) return [];
// Build permit_nbr → raw row map once so the cache-write loop doesn't
// re-scan `rows` on every iteration (was O(n^2)).
const rawByPermit = new Map<string, Record<string, unknown>>();
for (const r of rows) {
const x = r as Record<string, unknown>;
const k = String(x.permit_nbr ?? '');
if (k) rawByPermit.set(k, x);
}
const permits: LAPermit[] = rows.map(r => {
const x = r as Record<string, unknown>;
// Keep 0 as a real valuation (legitimate "no-cost" permits exist). Only
// null/undefined/'' are missing. NaN is also missing.
const valRaw = x.valuation;
let valuation: number | null = null;
if (valRaw !== null && valRaw !== undefined && valRaw !== '') {
const n = Number(valRaw);
valuation = Number.isFinite(n) ? n : null;
}
return {
permit_nbr: String(x.permit_nbr ?? ''),
apn,
primary_address: (x.primary_address as string) ?? null,
zip_code: (x.zip_code as string) ?? null,
permit_type: (x.permit_type as string) ?? null,
permit_subtype: (x.permit_sub_type as string) ?? null,
permit_group: (x.permit_group as string) ?? null,
status: (x.status as string) ?? null,
issue_date: typeof x.issue_date === 'string' ? x.issue_date.slice(0, 10) : null,
status_date: typeof x.status_date === 'string' ? x.status_date.slice(0, 10) : null,
valuation,
use_code: (x.use_code as string) ?? null,
use_description: (x.use_desc as string) ?? null,
jurisdiction: 'LA City',
};
}).filter(p => p.permit_nbr);
// Best-effort cache write. Refresh all mutable columns in DO UPDATE so
// upstream changes to address/type/use_description don't get pinned to
// the first-seen value forever.
try {
for (const p of permits) {
await pool.query(
`INSERT INTO la_permit
(permit_nbr, apn, primary_address, zip_code, permit_type, permit_subtype, permit_group,
status, issue_date, status_date, valuation, use_code, use_description, jurisdiction, raw, fetched_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::date,$10::date,$11,$12,$13,$14,$15, now())
ON CONFLICT (permit_nbr) DO UPDATE SET
apn = EXCLUDED.apn,
primary_address = EXCLUDED.primary_address,
zip_code = EXCLUDED.zip_code,
permit_type = EXCLUDED.permit_type,
permit_subtype = EXCLUDED.permit_subtype,
permit_group = EXCLUDED.permit_group,
status = EXCLUDED.status,
issue_date = EXCLUDED.issue_date,
status_date = EXCLUDED.status_date,
valuation = EXCLUDED.valuation,
use_code = EXCLUDED.use_code,
use_description = EXCLUDED.use_description,
jurisdiction = EXCLUDED.jurisdiction,
raw = EXCLUDED.raw,
fetched_at = now()`,
[p.permit_nbr, p.apn, p.primary_address, p.zip_code, p.permit_type, p.permit_subtype, p.permit_group,
p.status, p.issue_date, p.status_date, p.valuation, p.use_code, p.use_description, p.jurisdiction,
JSON.stringify(rawByPermit.get(p.permit_nbr) ?? {})]
);
}
} catch (e) {
const code = (e as { code?: string } | null)?.code;
if (code !== '42P01' && !(e instanceof Error && /relation .* does not exist/.test(e.message))) {
console.warn('la_permit upsert:', e instanceof Error ? e.message : e);
}
}
return permits;
}