Campfire HubSpot ECM enrichment

PR #6451 makes Dealops 2 ECM opportunity enrichment create the Account, Engagement, Contract DealGroup, and membership for Campfire HubSpot deals.

Author @pk675 PR #6451 Branch pk/ecm-hubspot-enrichment Area Dealops 2 CRM enrichment Files 11 Diff +1066 / -48 State open

Problem

Campfire HubSpot enrichment stopped at the type gate. record_type was missing, so ECM returned before I/O and produced no useful logs.

Fix

The PR maps HubSpot dealtype, requests mapped CRM fields on fetch, and resolves HubSpot deal-to-company links through associations.

Result

A new Campfire HubSpot deal can now hydrate the ECM chain: OpportunityV2, Account, Engagement, DealGroup, and membership.

Safety

Salesforce remains field-based. Tests assert it never calls the HubSpot-style account association lookup.

Campfire crmSpec
HubSpot connector
crmObjectService
ECM enricher
Feature flags
Tests

1. Why this exists

Before: Salesforce-shaped contract

ECM expected four values to already exist on the opportunity payload or custom bag.

record_type
Salesforce Type / RecordType.
accountId
Salesforce AccountId field.
accountName
Nested lookup such as account.name.
lastOpportunityId
Expansion / renewal pointer.
HubSpot reality

Campfire HubSpot could not satisfy that same contract with scalar deal properties alone.

record_type
Available as dealtype, but unmapped and not fetched by default.
accountId
Not a deal property; only in HubSpot v4 associations.
accountName
Requires fetching the associated company.
lastOpportunityId
No Campfire-side equivalent yet.
Observed failure: Datadog had zero [ECM enrich] lines for Campfire because enrichment returned before any I/O or logging. Salesforce orgs emitted logs normally.

2. What changes

Campfire crmSpec

Adds record_type → dealtype so HubSpot deal type reaches the mapped opportunity data.

crmObjectService

Adds requested-field fetching plus supportsAccountAssociation() and findAccountForOpportunity().

HubSpot connector

Fetches requested properties, normalizes omitted fields to null, and preserves association type metadata.

ECM enricher

Uses a pure input resolver, defaults blank HubSpot type to newbusiness, and resolves associated account links after gates.

Feature flags

Enables Campfire test org for ECM, hierarchy UI, and renewal-chain behavior.

Tests

Adds pure input coverage, HubSpot association behavior, and Salesforce no-association regression coverage.

Field contract after the PR

ECM input Salesforce path HubSpot path New behavior
recordType custom.record_type, top-level type, or custom.type dealtype mapped to record_type Blank HubSpot value defaults to newbusiness; explicit values are not overridden.
crmAccountId AccountId mapped onto custom.accountId Primary associated company id from HubSpot v4 associations Lookup only runs when the field is missing and the CRM supports account associations.
accountName Mapped field or nested lookup Fetched from associated company using account spec mapping, fallback name Recovered name is mirrored to opportunityData.custom.accountName.
lastOpportunityId Expansion / renewal pointer No equivalent yet Still required for amendment-style prior-deal pointer flows.

Expected database effect

Before refresh

Campfire HubSpot produced only the fetched opportunity row.

OpportunityV21 row, empty custom
Account0 rows
Engagement0 rows
DealGroup0 rows
Membership0 rows
After refresh

A HubSpot deal with dealtype = newbusiness and an associated company opens the full ECM hierarchy.

OpportunityV2NEW_BUSINESS, custom has account link
AccountcrmAccountId = HubSpot company id
Engagementstatus=ACTIVE
DealGrouptype=CONTRACT, sequence 1
Membershipcurrent opportunity at sequence=1

3. How it works

1
Fetch CRM object
Request mapped field names from the spec, not just HubSpot defaults.
2
Extract inputs
Pure module produces recordType, account id/name, and prior pointer.
3
Pass gates
Type gate and ECM flag gate run before any association lookup.
4
Resolve account
HubSpot only: pick primary associated company and fetch its name.
5
Hydrate ECM
Create or reuse Account, Engagement, Contract DealGroup, and membership.
Pure extraction module

ecmOpportunityInputs.ts replaces repeated inline reads like custom?.accountId as string.

  • Normalizes blanks to undefined.
  • Keeps record-type precedence centralized.
  • Makes account lookup decision testable without DB or CRM stubs.
HubSpot field fetching

getRequestedCrmFieldNames passes spec field names into connector.fetchObject.

  • Skips nested lookups like account.segment.
  • De-duplicates CRM field names.
  • Prevents mapped properties like dealtype from disappearing.
Association account lookup

findAccountForOpportunity hides the CRM linking strategy difference.

  • HubSpot: v4 associations from deal to companies.
  • Salesforce: returns null; account is already a field.
  • Primary company selected by HubSpot type id 5, not by row order.

Gate placement matters

type gate
Non-hierarchy types still return before I/O. The PR adds an explicit skip log: type="MISSING" does not map.
flag gate
Organizations without ECM enabled still avoid account association reads.
account lookup
Runs only after both gates and only when crmAccountId is absent on an association-based CRM.
merge
Resolved account id/name are mirrored to opportunityData.custom so the persisted OpportunityV2 row carries them.

HubSpot connector details

Change Why it matters
fetchObject(objectType, objectId, properties?) HubSpot returns a small default property set unless callers request properties explicitly.
Requested-but-omitted properties become null Prevents nullable-but-not-optional schema fields from receiving undefined.
fetchAssociations Preserves associationTypes so callers can distinguish primary company from other associated companies.
fetchAssociatedObjectIds Kept as an ids-only wrapper for existing line-item-style callers.

Testing shape

Pure inputs

Tests extraction precedence, blank normalization, missing custom, lookup decisions, and merge precedence.

HubSpot enrichment

Tests association recovery, blank dealtype defaulting, explicit existingbusiness not being upgraded, and no-company degradation.

Salesforce guard

Salesforce-shaped stub rejects if findAccountForOpportunity is called, making accidental extra API reads fail loudly.

4. What it doesn't change

5. Risks / rollback / open questions

Runtime dependency: the checked-in Campfire crmSpec.json is the onboarding source, not necessarily the live database row. Campfire's live CRMSpec row must be updated before this works in preview/runtime.
Risk: defaulting blank HubSpot type

Blank HubSpot dealtype now means newbusiness. Explicit values are preserved, including unmapped values like existingbusiness.

Risk: missing company association

The fetch does not fail. It logs a warning and opens an un-deduped Account named from the opportunity, matching the PR's degraded-read posture.

Follow-up: amendment

AMENDMENT is still blocked because Campfire needs a prior-deal pointer and richer deal-type detection beyond stock HubSpot dealtype.

Follow-up: writeback

DealGroup writeback needs a Campfire HubSpot custom object in crmSpec first; otherwise resolveObjectType would throw.

Rollback shape: disable the three Campfire feature-flag entries to stop ECM hierarchy behavior for the org while leaving the safer connector/service changes in place.