Add corporate record suggestions on Officer step, search all states

- OfficerStep searches entity_cache for matching corporations when loaded
- Shows clickable suggestions with RA name and address to auto-fill Officer 1
- Pre-fills contact_name/email/phone from entity data (helps data-only filers)
- Corp search endpoint: state param now optional (searches all states)
- Corp search returns registered_agent and principal_address fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
justin 2026-04-28 23:02:16 -05:00
parent ab7a2d7dc0
commit 159a576157
2 changed files with 103 additions and 18 deletions

View file

@ -246,24 +246,35 @@ router.get("/api/v1/corp/search", async (req: Request, res: Response) => {
res.status(400).json({ error: "q parameter required (min 2 chars)" });
return;
}
if (!state || state.length !== 2) {
res.status(400).json({ error: "state parameter required (2-letter code)" });
return;
}
const limit = Math.min(Number(req.query.limit) || 10, 20);
try {
const { rows } = await pool.query(
`SELECT entity_name, entity_number, entity_type, status, formation_date,
similarity(entity_name, $2) AS sim
FROM entity_cache
WHERE state = $1 AND entity_name % $2
ORDER BY sim DESC
LIMIT 10`,
[state, q],
);
// State is optional — if omitted, search all states
const { rows } = state
? await pool.query(
`SELECT entity_name, entity_number, entity_type, status, formation_date,
registered_agent, principal_address, state,
similarity(entity_name, $2) AS sim
FROM entity_cache
WHERE state = $1 AND entity_name % $2
ORDER BY sim DESC
LIMIT $3`,
[state, q, limit],
)
: await pool.query(
`SELECT entity_name, entity_number, entity_type, status, formation_date,
registered_agent, principal_address, state,
similarity(entity_name, $2) AS sim
FROM entity_cache
WHERE entity_name % $1
ORDER BY sim DESC
LIMIT $2`,
[q, limit],
);
res.json({
state,
state: state || "all",
query: q,
count: rows.length,
results: rows.map((r: any) => ({
@ -274,11 +285,14 @@ router.get("/api/v1/corp/search", async (req: Request, res: Response) => {
formation_date: r.formation_date
? new Date(r.formation_date).toISOString().slice(0, 10)
: null,
registered_agent: r.registered_agent || null,
principal_address: r.principal_address || null,
state: r.state || null,
})),
});
} catch (err: any) {
if (err?.code === "42P01") {
res.json({ state, query: q, count: 0, results: [], note: "Entity database not yet populated for this state." });
res.json({ state: state || "all", query: q, count: 0, results: [], note: "Entity database not yet populated for this state." });
} else {
console.error("[corp/search] Error:", err);
res.status(500).json({ error: "Search failed" });

View file

@ -15,6 +15,12 @@
a sole proprietor gives 1; a corporation gives 3.
</p>
<!-- Suggested officers from corporate records -->
<div id="pw-officer-suggestions" class="pw-suggestions" hidden>
<p class="pw-suggest-label">We found corporate records that may match. Select to auto-fill:</p>
<div id="pw-officer-suggest-list"></div>
</div>
<label class="pw-field">How many officers will you list?</label>
<select id="pw-officer-count" class="pw-input">
<option value="1">1 — sole proprietor / single officer</option>
@ -83,6 +89,12 @@
.pw-fieldset legend { font-weight: 600; color: #1a2744; padding: 0 0.5rem; }
.pw-row { display: flex; gap: 0.75rem; flex-wrap: wrap; }
.pw-row > * { flex: 1 1 140px; }
.pw-suggestions { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 0.75rem; margin-bottom: 1rem; }
.pw-suggest-label { font-size: 0.82rem; color: #1e40af; font-weight: 600; margin: 0 0 0.5rem; }
.pw-suggest-btn { display: block; width: 100%; text-align: left; padding: 0.5rem 0.75rem; margin: 0.25rem 0; border: 1px solid #dbeafe; background: #fff; border-radius: 6px; cursor: pointer; font-size: 0.85rem; color: #1f2937; transition: all 0.1s; }
.pw-suggest-btn:hover { border-color: #3b82f6; background: #f0f4ff; }
.pw-suggest-btn .sg-name { font-weight: 600; }
.pw-suggest-btn .sg-detail { font-size: 0.78rem; color: #6b7280; }
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
</style>
@ -113,6 +125,60 @@
};
}
// Fetch matching corporate records for officer suggestions
async function loadOfficerSuggestions(entityName: string) {
if (!entityName) return;
const API = (window as any).__PW_API || "";
const suggestDiv = g<HTMLElement>("pw-officer-suggestions");
const listDiv = g<HTMLElement>("pw-officer-suggest-list");
try {
// Search entity_cache for matching corporations across all states
const resp = await fetch(`${API}/api/v1/corp/search?q=${encodeURIComponent(entityName)}&limit=5`);
if (!resp.ok) return;
const data = await resp.json();
const results = data.results || [];
if (results.length === 0) return;
listDiv.innerHTML = "";
for (const r of results) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pw-suggest-btn";
const ra = r.registered_agent || "";
const addr = r.principal_address || "";
btn.innerHTML = `
<span class="sg-name">${r.entity_name}</span>
<span class="sg-detail">${r.state || ""} · ${r.entity_type || ""} · ${r.status || ""}</span>
${ra ? `<span class="sg-detail">RA: ${ra}</span>` : ""}
${addr ? `<span class="sg-detail">${addr}</span>` : ""}
`;
btn.addEventListener("click", () => {
// Auto-fill Officer 1 from this record
const f = officerFields(1);
if (ra && !f.n.value.trim()) {
f.n.value = ra;
}
// Parse address if available
if (addr) {
const parts = addr.split(",").map((s: string) => s.trim());
if (parts.length >= 1 && !f.street.value.trim()) f.street.value = parts[0];
if (parts.length >= 2 && !f.city.value.trim()) f.city.value = parts[parts.length - 2] || parts[1];
if (parts.length >= 3) {
const stateZip = parts[parts.length - 1].trim().split(/\s+/);
if (stateZip[0] && !f.state.value.trim()) f.state.value = stateZip[0];
if (stateZip[1] && !f.zip.value.trim()) f.zip.value = stateZip[1];
}
}
suggestDiv.hidden = true;
});
listDiv.appendChild(btn);
}
suggestDiv.hidden = false;
} catch {}
}
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "officer") return;
const s = (window as any).PWIntake.get();
@ -125,15 +191,20 @@
for (let i = 1; i <= 3; i++) {
const f = officerFields(i);
const oc = o[i - 1] || {};
f.n.value = oc.name || (i === 1 ? (s.entity?.ceo_name || "") : "");
f.n.value = oc.name || (i === 1 ? (s.entity?.ceo_name || s.entity?.contact_name || "") : "");
f.t.value = oc.title || (i === 1 ? (s.entity?.ceo_title || "Chief Executive Officer") : "");
if (f.e) f.e.value = oc.email || "";
if (f.p) f.p.value = oc.phone || "";
if (f.e) f.e.value = oc.email || (i === 1 ? (s.entity?.contact_email || "") : "");
if (f.p) f.p.value = oc.phone || (i === 1 ? (s.entity?.contact_phone || "") : "");
f.street.value = oc.street || (i === 1 ? (s.entity?.address_street || "") : "");
f.city.value = oc.city || (i === 1 ? (s.entity?.address_city || "") : "");
f.state.value = oc.state || (i === 1 ? (s.entity?.address_state || "") : "");
f.zip.value = oc.zip || (i === 1 ? (s.entity?.address_zip || "") : "");
}
// Load corporate record suggestions if officer 1 name is empty
if (!o[0]?.name && s.entity?.legal_name) {
loadOfficerSuggestions(s.entity.legal_name);
}
});
window.addEventListener("pw:step-next", (evt: any) => {