_private/qwestly-hire-docs/features/company-search-hire-app.md
Table of Contents
Company search in the Hire app
This document is the single hire-side reference for:
- How
@/packages/uicompany search (ConnectedCompanySearchInputand related helpers) is wired for qwestly-hire (CompanyAssignmentForm, env, CSP, analytics). - The public Hire HTTP API under
/api/companiesthat those flows and other first-party clients call (typeahead, hydration, create/dedupe).
The shared component contract (props, CompanyOption shape, candidate vs hire profiles) lives in packages/ui/docs/features/company-search.md.
Migration note (2026)
Until early 2026 hire exposed /api/external/companies/* and /api/external/search/companies/* (gated by HIRE_APP_API_KEY) plus server actions in src/lib/company-search-server-actions.ts (with a FetchedLinkedInCompany Mongo cache). Those have been removed. User-facing pickers use ConnectedCompanySearchInput and call hire public routes and api-python public routes from the browser.
Product goals and trust boundaries
Goal: Let hire users (signup / onboarding /onboarding/company, /companies/new, and similar) pick a stable hire Company document (_id in MongoDB), optionally backed by vendor LinkedIn data.
Rules
- Database search first. Typeahead hits hire
GET /api/companies/search?query=(viasearchHireCompaniesByNameinpackages/ui) before relying on vendor data. - Vendor lookup is browser โ api-python. No Rapid credential in the browser; api-python holds it. Hire uses
GET /api/public/linkedin-company(see ยงapi-python). - Server-to-server vendor calls on hire are for enrichment (e.g. logo refresh), not the user-facing picker โ
/api/hire/company-rapid/*withQWESTLY_SERVICE_API_KEY(same shared secret as other api-python internal routes; see api-pythonrequire_qwestly_service). - Persist on hire write paths. Vendor-only
CompanyOptionrows (id === "") are persisted withPOST /api/companies(same-origin). The handler OR-dedupes onlinkedin_company_id,domain, andlinkedin_url.
Architecture (hire browser + hire API + api-python)
flowchart TB
subgraph hire-browser["Hire browser"]
HUI["CompanyAssignmentForm โ ConnectedCompanySearchInput\n(profile see ยง Hire UI)"]
end
subgraph hire["Qwestly hire"]
GET_SEARCH["GET /api/companies/search"]
GET_FILTER["GET /api/companies? โฆ equality filters"]
POST_COMPANIES["POST /api/companies"]
LOGO_SVC["company-logo-upload / company-logo-refresh"]
end
subgraph py["api-python"]
PUB["GET /api/public/linkedin-company\n(CORS + Referer gate)"]
HIRE_S2S["GET /api/hire/company-rapid/*\n(QWESTLY_SERVICE_API_KEY)"]
Rapid[RapidAPI]
end
HUI --> GET_SEARCH
HUI --> GET_FILTER
HUI --> POST_COMPANIES
HUI --> PUB
LOGO_SVC --> HIRE_S2S
PUB --> Rapid
HIRE_S2S --> Rapid
The candidate app uses the same ConnectedCompanySearchInput patterns against the same hire public endpoints where applicable; this doc does not duplicate candidate-specific screens.
Hire UI: CompanyAssignmentForm and packages/ui
Location: src/components/company-assignment-form/ โ index.tsx, parts.tsx, use-company-assignment-form.ts, utils.ts.
Used for: /onboarding/company, /companies/new, and any future signup-style company picker that mounts this form.
Views
Three modes โ name (default), external, manual โ one active body with the other two as ghost controls. Switching to external or manual clears any staged selectedCompany (preview) so those flows start without a leftover name-search pick.
Name view โ ConnectedCompanySearchInput
explicitParentClearOnlyโ Wired fromCompanyAssignmentNameViewinparts.tsx. When set,CompanySearchInputonly notifies the parent withonSelect(null)from the inputโs explicit clear (X), not when the user edits the query or blurs with partial text. The preview card stays until dismiss, a new list pick, confirm, or switching to external/manual.companySearchProfile="candidate"โ The candidate profile hides the packageโs grey panel and segmented โCompany database / External lookupโ toggle so hire only shows the bare typeahead + popover. Hire owns external and manual UX in separate views.hireOrigin: ServerappBaseUrl(fromAPP_BASE_URL/VERCEL_BRANCH_URL) passed as prop โ canonical origin forGET /api/companies/searchand hydration fetches.- Two-phase internal suggestions (aligned with candidate onboarding):
- Query length 1โ2:
localSuggestions={filterSp500CompaniesByQuery}fromsrc/lib/hire-company-search-helpers.tsโ prefix match on the@/packages/ui/sp500-tech-companiesdataset (cap 20), no network. Rows are vendor-only (id === "",linkedin_company_idset,linkedin_urlfrom slug). When the SP500 row haslogo_path, hire setslogo_urlviasp500LogoUrlFromPathso the picker can show logos for instant suggestions. - Length โฅ
HIRE_COMPANY_MIN_SEARCH_LENGTH(3): debounced (500 ms)searchHireCompaniesByNameโGET /api/companies/search. Results merged viamergeSp500WithHireResults(dedupe bylinkedin_company_idthen name; hire wins). Sentinel rows from the hire API are ignored during merge. - Sparse merged list (hire queries only, length โฅ 3): if the merged list has at most four real company rows (after dedupeโthe rows the user actually sees), hire appends a sentinel CTA option (
createCompanySearchManualCtaOptionfrom@/packages/ui/lib/company-search, default label โCompany not listed? Try another wayโ). Choosing that row does not select a company:useCompanyAssignmentFormrecordsCOMPANY_SPARSE_SEARCH_CTA_CLICKED, switches the form to the external (LinkedIn/domain) view, and bumpssearchResetKeyto remount the combobox. Shorter queries (SP500-only) do not append this row. hireSearchMinQueryLength={3}โ below threshold the hire fetch does not run (SP500-only).
- Query length 1โ2:
- Hydration on select is implemented inside
packages/ui(handleSelectWithHireHydrationinconnected-company-search-input.tsx): vendor-only rows withoutlogo_urlmay be resolved againstGET /api/companies?linkedin_company_id=and/or search beforeonSelect. Hireโs handler usesisHireMongoObjectIdStringto setselectionSource(hire_dbvsrapid) and to skipPOST /api/companieswhen the row already has a hire_id. - Errors:
onHireSearchErrorโ PostHogCOMPANY_FETCH_FAILED(source: "hire_db").
External view
- Single Input + Search.
detectExternalKind(utils.ts) treats input as LinkedIn URL if it matcheslinkedin.com/company/, else domain. - Validation:
validateExternalDomainInput/validateExternalLinkedInUrlInputfrom@/packages/ui/lib/company-search. - Submit calls
resolveCompanyViaPython(pythonOrigin, โฆ)frompackages/ui(see ยงEnvironment).requirePythonApiBaseUrl()runs in the submit path so name/manual still work if python base URL is unset. - 404 from api-python (not found) is expected; UI shows a friendly message; analytics
COMPANY_FETCH_FAILED { source: "rapid", status: 404, kind }.
Selection preview and confirm
- Preview between body and view switcher: logo / fallback, name, tagline, links, dismiss (disabled while
submitting), โSelect this companyโ. - Confirm: If
company.idis a hire Mongo id โ use it, no POST. ElsePOST /api/companieswithbuildCreatePayloadFromCompanyOption(utils.ts): base fields fromCompanyOption, plusmapVendorSnapshotToCompanyPostFields(company.vendor_snapshot)fromsrc/lib/map-vendor-snapshot-to-company-post.tswhen the Rapid-backed row carries a snapshot (see tests in__tests__/unit/map-vendor-snapshot-to-company-post.test.ts). searchResetKeyremounts the combobox after dismiss so internal input state resets.
Manual view โ SimpleCompanyForm
src/components/simple-company-form.tsx: required name, optional website / description in a collapsible (default collapsed). Submit โ buildPublicCompanyPostBody โ POST /api/companies โ onCompanySelected. No preview step.
Popover / dark theme
Radix PopoverContent portals to document.body. CompanySearchInput in packages/ui uses explicit light surface colors on the dropdown so suggestions stay readable when <html> is dark (hire does not rely on global [data-radix-popper-content-wrapper] hacks).
Analytics (PostHog)
| Event | When |
|---|---|
COMPANY_SEARCH_DOMAIN_SUBMITTED / COMPANY_SEARCH_LINKEDIN_SUBMITTED |
External submit (detectExternalKind) |
COMPANY_FETCH_FAILED |
Typeahead / hydration / external failures (source, status for 404) |
COMPANY_SELECTED |
Confirm or manual add (source, created) |
COMPANY_MANUAL_FORM_OPENED |
First switch to manual in the session |
COMPANY_SPARSE_SEARCH_CTA_CLICKED |
User picked the sparse-results โtry another wayโ row in the name typeahead (from_view: always "name" โ CTA only exists on that surface) |
Environment and build
| Variable | Role |
|---|---|
APP_BASE_URL / VERCEL_BRANCH_URL |
hireOrigin for same-origin hire API calls |
PYTHON_API_BASE_URL |
Inlined at build via next.config.ts env (no NEXT_PUBLIC_*). requirePythonApiBaseUrl() in src/lib/python-api-base-url.ts. CSP connect-src must allow this origin (see next.config.ts comments). |
QWESTLY_SERVICE_API_KEY |
Server-only; Hire โ api-python GET /api/hire/company-rapid/* (e.g. logo services). Must match api-python QWESTLY_SERVICE_API_KEY. Not inlined for the browser. |
Admin: out of scope
(private)/admin/companies/new still uses server actions in actions.ts and company-rapidapi โ not ConnectedCompanySearchInput yet.
packages/ui symbols used by hire
| Symbol | Location (under packages/ui/src/) |
Role |
|---|---|---|
ConnectedCompanySearchInput |
components/connected-company-search-input.tsx |
Combobox + hire hydration; passes props through to CompanySearchInput |
CompanySearchInput |
components/company-search-input.tsx |
Typeahead; optional explicitParentClearOnly (hire name view) defers onSelect(null) until explicit clear (X) |
CompanyOption |
lib/company-search/company-option.ts |
Row shape; id === "" = not yet in hire DB |
searchHireCompaniesByName |
lib/company-search/hire-search.ts |
GET {hireOrigin}/api/companies/search |
resolveCompanyViaPython |
lib/company-search/python-resolve.ts |
GET {pythonOrigin}/api/public/linkedin-company |
isHireMongoObjectIdString |
lib/company-search/mongo-id.ts |
Decide POST vs pass-through |
createCompanySearchManualCtaOption / isCompanySearchManualCtaOption |
lib/company-search/manual-cta-option.ts |
Sparse-results CTA row id + type guard (hire merges appends in mergeSp500WithHireResults; hire hook branches in onSelect) |
The shared component does not POST โ hire owns POST /api/companies in the form hook.
Unit tests: merge / SP500 / sparse CTA behavior for hire helpers lives in __tests__/unit/hire-company-search-helpers.test.ts.
Public Companies API (/api/companies)
Implemented in [src/app/api/companies/[[...slug]]/route.ts](../../src/app/api/companies/<strong class="wikilink-unresolved">...slug</strong>/route.ts). Unauthenticated surface for first-party apps; CORS/middleware per src/lib/proxy-helpers.ts (PUBLIC_ROUTE_PREFIXES). POST may still attach user_id when a session exists and the body omits linkedin_company_id (see below).
Implementation helpers: src/lib/company-public-api-helpers.ts; resolution / projections: src/services/company-public-resolution.service.ts. Equality filters for company_name and linkedin_company_slug use buildPublicCompanyExactNameFilter and buildPublicCompanyLinkedInSlugUrlFilter, composed into findSlimCompanyByFilter or listSlimCompaniesPaginatedPublic.
GET /api/companies/search
MongoDB-only name typeahead (no vendor).
| Query | Required | Notes |
|---|---|---|
query |
No | Trimmed; length behavior below |
limit |
No | Positive; capped server-side (default max 10) |
Query length: Max 100 characters (PUBLIC_COMPANY_NAME_QUERY_MAX_LENGTH); longer โ 400 with error: "query too long". Shorter than 2 characters after trim โ { "companies": [] } (no error).
Matching: Length โค2 โ prefix on name (case-insensitive). Longer โ substring on name.
Response: { "companies": SlimCompanyPublic[] } โ _id, name, optional domain, logo_url, linkedin_url, linkedin_company_id, tagline.
Errors: 400 invalid limit or query too long; 500 server failure.
GET /api/companies
Success envelope (all successful list responses):
{
"success": true,
"companies": [],
"pagination": {
"page": 1,
"pageSize": 50,
"total": 0,
"totalPages": 0
}
}
Unsupported combinations โ 400
- More than one equality filter among
domain,linkedin_url,linkedin_company_id,company_name,linkedin_company_slug(PUBLIC_COMPANY_RESOLUTION_EXCLUSIVE_PARAMS). idswith any equality filter.idswithpageorpageSize.
Bare GET (no valid combination) โ 400 with hint.
Non-2xx: JSON { "error", "hint", ... } โ not the success envelope. Clients must not treat companies.length === 0 on an error response as โnot found.โ
A. Paginated directory (no filter, no ids)
| Query | Required | Notes |
|---|---|---|
page |
No | Default 1; positive when present |
pageSize |
No | Default 50; max 100 |
At least one of page or pageSize required. Empty filter, sort by name ascending.
companies[]: directory row โ _id, name, optional parent_org_id, industries (comma-separated when present).
B. Equality filter (lookup / dedupe)
Exactly one of:
| Query | Notes |
|---|---|
domain |
normalizeDomain; invalid โ 400 Invalid domain |
linkedin_url |
normalizeLinkedInUrl; invalid โ 400 |
linkedin_company_id |
Trimmed match |
company_name |
Case-insensitive exact on Company.name; max 100 chars |
linkedin_company_slug |
Vanity slug rules; must match /company/{slug} in stored linkedin_url |
Pagination: If page / pageSize omitted โ defaults page=1, pageSize=1 (PUBLIC_COMPANY_FILTER_*) for cheap dedupe reads.
companies[]: SlimCompanyPublic (same projection as search).
Dedupe: companies.length === 0 โ not found; >= 1 โ found (use companies[0] for single-hit).
pagination.total (fast path): When both page and pageSize are omitted, the server may use a single-query fast path without countDocuments. Then total is bounded by pageSize (0 or 1) and is not a uniqueness guarantee โ total: 1 means โat least one match.โ To detect duplicates, pass pageSize >= 2. When multiple match, companies[0] is the alphabetically first by name (same as page=1 on the paged path).
C. Batch by ids
| Query | Notes |
|---|---|
ids |
Comma-separated Mongo ObjectIds (24-hex). Deduped. 1โ100 (PUBLIC_COMPANY_IDS_MAX). Invalid token โ 400 whole request |
No page / pageSize. companies[]: SlimCompanyPublic. Order not guaranteed; missing ids omitted (not errors). pagination: page 1; pageSize / total = returned count; totalPages 1 if total > 0 else 0.
GET /api/companies/{id}
{id} must be 24-char hex ObjectId else 400. Response: single SlimCompanyPublic at JSON root. 404 if missing.
POST /api/companies
Signup-style create. JSON. name required else 400.
Dedupe: If body includes any of domain, linkedin_url, linkedin_company_id, server builds $or over normalized fields. Match exists โ 200 { "exists": true, "company": { "_id", "name" } }.
Create: 201 { "exists": false, "company": { "_id", "name" } }; new doc claimed: false.
Optional fields on create: logo_url, linkedin_company_id, tagline, website, domain, linkedin_url, crunchbase_url, industries, employee_count, employee_range, year_founded, funding_info, description (and others supported by the route โ see handler).
logo_url: LinkedIn logo URLs may be uploaded to CDN and replaced with CDN URL.domain/linkedin_url: Invalid values may be skipped (not always hard errors).user_id: Iflinkedin_company_idabsent andgetUserId()returns a user, set on new doc.
Errors: 500 with message / details.
GET migration (breaking, historical)
Older GET /api/companies used { exists, company }. Replaced by success + companies + pagination. Use companies[0]._id when present.
POST /api/companies responses still use exists / company โ unchanged.
Constants (summary)
| Constant | Value | Used for |
|---|---|---|
PUBLIC_COMPANY_NAME_QUERY_MAX_LENGTH |
100 | Search query, company_name |
PUBLIC_COMPANY_NAME_SEARCH_MAX_LIMIT |
10 | Search limit cap |
PUBLIC_COMPANY_NAME_SEARCH_MIN_LENGTH |
2 | Below โ [] |
PUBLIC_COMPANY_IDS_MAX |
100 | Batch ids |
PUBLIC_COMPANY_PAGE_DEFAULT |
1 | Directory |
PUBLIC_COMPANY_PAGE_SIZE_DEFAULT |
50 | Directory |
PUBLIC_COMPANY_PAGE_SIZE_MAX |
100 | Directory + filter pageSize |
PUBLIC_COMPANY_FILTER_PAGE_DEFAULT |
1 | Filter mode default page |
PUBLIC_COMPANY_FILTER_PAGE_SIZE_DEFAULT |
1 | Filter mode default size |
Routing note
Catch-all registers search and root GET '' before {id} so query-only /api/companies is not parsed as an id. The segment search is not a valid ObjectId for {id}.
Removed hire routes (redirect guide)
| Old path | Use instead |
|---|---|
GET /api/external/search/companies/search?q= |
GET /api/companies/search?query= |
GET /api/external/search/companies/search-rapid?q= |
Browser โ GET /api/public/linkedin-company?... |
GET /api/external/search/companies/search-then-rapid?q= |
Compose search + python client-side |
POST /api/external/companies/persist-from-enrichment |
POST /api/companies with picker body |
Other /api/external/... routes (e.g. jobs) are unrelated; they still use HIRE_APP_API_KEY where documented.
api-python
Browser (user-facing)
| Method | Path | Purpose |
|---|---|---|
GET |
/api/public/linkedin-company?domain= | ?linkedin_url= | ?company_id= |
Exactly one param. CORS + Referer host gate in api-python. |
Server-to-server (hire services, not picker)
| Method | Path | Auth |
|---|---|---|
GET |
/api/hire/company-rapid/* |
QWESTLY_SERVICE_API_KEY |
Future internalization (/api/internal/company/*, service API key) is tracked in api-python planning docs, not here.
Known gaps and follow-ups
- Vendor snapshot coverage:
buildCreatePayloadFromCompanyOptionforwardsvendor_snapshotthroughmapVendorSnapshotToCompanyPostFields. Rows without a snapshot still only send the thinCompanyOptionsurface fields unless filled elsewhere. - No hire-side Mongo cache for browser โ python reads (removed
FetchedLinkedInCompany). Revisit cost/latency in api-python or a shared cache if needed (docs/plan/cache-linkedin-fetches.md). QWESTLY_SERVICE_API_KEYon Hire must match api-python for logo upload/refresh and other server โ Python/api/hire/company-rapid/*calls (optional later move to/api/internal/company/*is an api-python product decision).- Stale-logo / CDN promotion on public read paths โ separate track.
Changelog (maintenance)
| Date | Notes |
|---|---|
| 2026-05-12 | Hire name view: explicitParentClearOnly on CompanySearchInput keeps staged preview while editing/blur; switchView to external/manual clears selectedCompany. |
| 2026-05-11 | SP500 instant rows may carry logo_url from packaged logo paths; mergeSp500WithHireResults appends sparse-results CTA when merged real rows โค 4 (query length โฅ 3); analytics COMPANY_SPARSE_SEARCH_CTA_CLICKED; Vitest coverage for hire merge helpers. |
Operational checklist
- New picker needing vendor + hire DB: reuse
ConnectedCompanySearchInput, do not add parallel Rapid proxies from hire for user-visible search. - Changing create contract: update
POST /api/companieshandler andbuildCreatePayloadFromCompanyOption/buildPublicCompanyPostBody/ types incompany-assignment-form/utils.tstogether. requirePythonApiBaseUrl: avoid silent fallbacks โ misconfig should fail clearly.- CSP: extend
connect-srcfor specific api-python origins when adding environments; do not wildcard all ofhttps:.
References
packages/ui/docs/features/company-search.mdโ shared component contractdocs/plan/cache-linkedin-fetches.mdโ caching discussion- api-python:
api/routes/browser_public/linkedin_company.py,lib/public_first_party.py(Referer/CORS) hire-onboarding.mdยง Company step โ wizard UX summary