Skip to main content

Add one contact-match object for lookup and typed search

Add one contact-match object for lookup and typed search

Summary

Make the profile lookup return the Link finder's pre-filled name and result rows, and make typed search return the same object. The contact finder (SUR-539) and the row details (SUR-544) then render with no second call, and the lookup does not get slower.

Problem Statement

When a LinkedIn profile is not in the CRM, the rep needs to see whether a contact with that name already exists before pressing + Add to HubSpot. The data behind that check doesn't support it:

  • The lookup (POST /contacts/search) returns a 404 when the URL lookup misses and nobody matches the name, so the client can't tell "searched, nothing found" apart from "never searched"
  • The lookup and typed search (GET /contacts/search-many) use different matching (first AND last name CONTAINS_TOKEN vs full-text) and different row objects (NormalizedContactRecord vs ContactSearchPreview). The same name can show different people depending on the entry point
  • Neither row object carries email, owner and create date, the fields reps asked for to tell similar names apart ("Suggesting wrong guy")
  • A lookup with 5 name hits costs up to 12 HubSpot calls and about 20 DB calls, because each row loads the full contact and its company one by one. Typed search has the same per-row fan-out, run one call after another
  • Neither path folds whitespace, so "Van Roy" never finds "Vanroy". SUR-542 requires this for all finders

🚦 Definition of Ready (Checklist)

Before entering a cycle, confirm:

  • [X] Problem statement is clear
  • [X] Acceptance criteria are defined
  • [X] Scope & non-goals are explicit
  • [X] Linked to relevant context
  • [X] Can be completed by one person in one sprint

If any box is unchecked → do not pull into sprint

Acceptance Criteria

  • [ ] libs/contracts exports ContactMatch (id, firstName, lastName, title, company {id, name}, avatarUrl, linkedLinkedInProfile, crmUrl, email, owner {id, name}, createdAt) and ContactNameSearch (query, results with max 5)
  • [ ] Given the LinkedIn URL lookup misses and a name was sent, when POST /contacts/search runs, then it returns a 200 with contact: null and nameSearch, including with 0 results
  • [ ] GET /contacts/search-many?name= returns ContactNameSearch, built by the same findContactMatches function as the lookup, so the same name gives the same rows on both paths
  • [ ] Given a name with accents or spaces, then the search also runs the accent-stripped and/or spaces-removed variant and merges the results without duplicates ("Kuhnert" finds "Kühnert", "Van Roy" finds "Vanroy")
  • [ ] Given 5 name hits, then the request makes no per-row HubSpot or DB calls:
  • one HubSpot search per variant
  • one companies batch read
  • one avatar batch read
  • owner names from the cached roster
  • [ ] Contacts dismissed with "Not this person" stay filtered out of nameSearch and candidates
  • [ ] Given an extension below the new version, then its lookup still gets legacy candidates and its typed search still gets contacts (built from the same rows), and its candidate picker renders as before

If this checklist is complete, the issue is done.

Scope

In Scope

  • ContactMatch / ContactNameSearch contract in libs/contracts
  • findContactMatches(accountId, name) and the shared query-variant helper (raw, accent-stripped, spaces removed), which SUR-548 reuses for companies
  • Lookup response: nameSearch added, and the 404 replaced with a 200 on a zero-result name search
  • search-many response: ContactNameSearch next to the legacy contacts
  • Lookup name matching moves from first AND last name CONTAINS_TOKEN to full-text query
  • Legacy candidates built from ContactMatch rows, filling only the fields old InjectionPanelCandidatePicker reads
  • Extension plumbing:
  • normalizeContactSearchResponse passes nameSearch through
  • LookupContactResult carries it
  • the lookup cache stores it with the not-in-CRM entry

Out of Scope

  • Any finder UI (auto-open, pre-fill, row format, injected panel): SUR-539
  • The row expander UI: SUR-544
  • The company match object: SUR-548
  • Removing legacy candidates and contacts: a separate PR after EXTENSION_MIN_VERSION is raised
  • Building nameSearch from the local contact index (index freshness follow-up)
  • Phone, lifecycle stage, enrichment data or custom fields on rows: the full record still loads by id after Link

| Type | Link / Reference | | -- | -- | | Featurebase (user feedback) | "Would like some more info to really know if it's them" + "Suggesting wrong guy" (Feedbacksurroundr.pdf) | | Granola (call notes) | N/A | | PRD / Project | Extension 3.0; blocks SUR-539, SUR-544; helper reused by SUR-548 | | Incident | N/A | | Design | https://github.com/SurroundR/surroundr-wireframes/blob/main/side-panel-30.html | | Technical Decision Record | docs/EXTENSION_VERSION_POLICY.md (legacy field cutover) |

Implementation Notes

  • HubSpot search properties: firstname, lastname, jobtitle, email, hubspot_owner_id, createdate, associatedcompanyid, hs_linkedin_url, srndr_linkedin_profile_url, plus the configured LinkedIn property
  • Company on a row: the primary associated company through associatedcompanyid plus one companies/batch/read. Not the free-text company property
  • Owner names: HubspotOwnerResolverService.resolveOwnerName. Check that the roster is cached, so a request makes no owners call
  • Already-linked: from the URL properties via toLinkedInProfileLinkFromUrl, with no extra call
  • Index-only accounts (CRM_INDEX_AUTHORITATIVE_LOOKUPS): the name search still goes to HubSpot, +2-3 calls per unlinked lookup. That's the accepted price for finding contacts that haven't synced yet
  • Verify before building:
  • associatedcompanyid is filled on target portals (otherwise use one associations batch read)
  • no extension or web caller treats the 404 differently from a null contact
  • every search-many consumer, including apps/web/src/lib/contacts/searchManyContactsByName.ts
  • HubSpot full-text behavior with accents and collapsed whitespace on a real portal
  • Behavior change: the lookup's broader matching also reaches old extensions, so their candidate lists can show different people than today
  • WIP: no external blockers. SUR-539 and SUR-544 can be built against fixtures of this contract in parallel

✅ Definition of Done (Reference)

This issue is done when:

  • [ ] Acceptance criteria are met
  • [ ] Code is reviewed & merged
  • [ ] Tests pass (functional + design where applicable)
  • [ ] Feature is deployed
  • [ ] Docs / release notes updated if needed
Status: In Progress

Log in to comment and vote

No comments yet

Be the first to share your thoughts.