_private/qwestly-hire-docs/features/company-search-hire-app.md

Company search in the Hire app

This document is the single hire-side reference for:

  • How @/packages/ui company search (ConnectedCompanySearchInput and related helpers) is wired for qwestly-hire (CompanyAssignmentForm, env, CSP, analytics).
  • The public Hire HTTP API under /api/companies that 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

  1. Database search first. Typeahead hits hire GET /api/companies/search?query= (via searchHireCompaniesByName in packages/ui) before relying on vendor data.
  2. 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).
  3. Server-to-server vendor calls on hire are for enrichment (e.g. logo refresh), not the user-facing picker โ€” /api/hire/company-rapid/* with QWESTLY_SERVICE_API_KEY (same shared secret as other api-python internal routes; see api-python require_qwestly_service).
  4. Persist on hire write paths. Vendor-only CompanyOption rows (id === "") are persisted with POST /api/companies (same-origin). The handler OR-dedupes on linkedin_company_id, domain, and linkedin_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 from CompanyAssignmentNameView in parts.tsx. When set, CompanySearchInput only notifies the parent with onSelect(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: Server appBaseUrl (from APP_BASE_URL / VERCEL_BRANCH_URL) passed as prop โ€” canonical origin for GET /api/companies/search and hydration fetches.
  • Two-phase internal suggestions (aligned with candidate onboarding):
    • Query length 1โ€“2: localSuggestions={filterSp500CompaniesByQuery} from src/lib/hire-company-search-helpers.ts โ€” prefix match on the @/packages/ui/sp500-tech-companies dataset (cap 20), no network. Rows are vendor-only (id === "", linkedin_company_id set, linkedin_url from slug). When the SP500 row has logo_path, hire sets logo_url via sp500LogoUrlFromPath so 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 via mergeSp500WithHireResults (dedupe by linkedin_company_id then 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 (createCompanySearchManualCtaOption from @/packages/ui/lib/company-search, default label โ€œCompany not listed? Try another wayโ€). Choosing that row does not select a company: useCompanyAssignmentForm records COMPANY_SPARSE_SEARCH_CTA_CLICKED, switches the form to the external (LinkedIn/domain) view, and bumps searchResetKey to remount the combobox. Shorter queries (SP500-only) do not append this row.
    • hireSearchMinQueryLength={3} โ€” below threshold the hire fetch does not run (SP500-only).
  • Hydration on select is implemented inside packages/ui (handleSelectWithHireHydration in connected-company-search-input.tsx): vendor-only rows without logo_url may be resolved against GET /api/companies?linkedin_company_id= and/or search before onSelect. Hireโ€™s handler uses isHireMongoObjectIdString to set selectionSource (hire_db vs rapid) and to skip POST /api/companies when the row already has a hire _id.
  • Errors: onHireSearchError โ†’ PostHog COMPANY_FETCH_FAILED (source: "hire_db").

External view

  • Single Input + Search. detectExternalKind (utils.ts) treats input as LinkedIn URL if it matches linkedin.com/company/, else domain.
  • Validation: validateExternalDomainInput / validateExternalLinkedInUrlInput from @/packages/ui/lib/company-search.
  • Submit calls resolveCompanyViaPython(pythonOrigin, โ€ฆ) from packages/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.id is a hire Mongo id โ†’ use it, no POST. Else POST /api/companies with buildCreatePayloadFromCompanyOption (utils.ts): base fields from CompanyOption, plus mapVendorSnapshotToCompanyPostFields(company.vendor_snapshot) from src/lib/map-vendor-snapshot-to-company-post.ts when the Rapid-backed row carries a snapshot (see tests in __tests__/unit/map-vendor-snapshot-to-company-post.test.ts).
  • searchResetKey remounts 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).
  • ids with any equality filter.
  • ids with page or pageSize.

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: If linkedin_company_id absent and getUserId() 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

  1. Vendor snapshot coverage: buildCreatePayloadFromCompanyOption forwards vendor_snapshot through mapVendorSnapshotToCompanyPostFields. Rows without a snapshot still only send the thin CompanyOption surface fields unless filled elsewhere.
  2. 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).
  3. QWESTLY_SERVICE_API_KEY on 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).
  4. 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/companies handler and buildCreatePayloadFromCompanyOption / buildPublicCompanyPostBody / types in company-assignment-form/utils.ts together.
  • requirePythonApiBaseUrl: avoid silent fallbacks โ€” misconfig should fail clearly.
  • CSP: extend connect-src for specific api-python origins when adding environments; do not wildcard all of https:.

References