Campfire HubSpot ECM enrichment
PR #6451 makes Dealops 2 ECM opportunity enrichment create the Account, Engagement, Contract DealGroup, and membership for Campfire HubSpot deals.
Campfire HubSpot enrichment stopped at the type gate. record_type was missing, so ECM returned before I/O and produced no useful logs.
The PR maps HubSpot dealtype, requests mapped CRM fields on fetch, and resolves HubSpot deal-to-company links through associations.
A new Campfire HubSpot deal can now hydrate the ECM chain: OpportunityV2, Account, Engagement, DealGroup, and membership.
Salesforce remains field-based. Tests assert it never calls the HubSpot-style account association lookup.
1. Why this exists
ECM expected four values to already exist on the opportunity payload or custom bag.
Type / RecordType.AccountId field.account.name.Campfire HubSpot could not satisfy that same contract with scalar deal properties alone.
dealtype, but unmapped and not fetched by default.[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
Campfire HubSpot produced only the fetched opportunity row.
customA HubSpot deal with dealtype = newbusiness and an associated company opens the full ECM hierarchy.
NEW_BUSINESS, custom has account linkcrmAccountId = HubSpot company idstatus=ACTIVEtype=CONTRACT, sequence 1sequence=13. How it works
recordType, account id/name, and prior pointer.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.
getRequestedCrmFieldNames passes spec field names into connector.fetchObject.
- Skips nested lookups like
account.segment. - De-duplicates CRM field names.
- Prevents mapped properties like
dealtypefrom disappearing.
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="MISSING" does not map.crmAccountId is absent on an association-based CRM.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
Tests extraction precedence, blank normalization, missing custom, lookup decisions, and merge precedence.
Tests association recovery, blank dealtype defaulting, explicit existingbusiness not being upgraded, and no-company degradation.
Salesforce-shaped stub rejects if findAccountForOpportunity is called, making accidental extra API reads fail loudly.
4. What it doesn't change
- No REST API surface is added; this is server-side Dealops 2 CRM/enrichment behavior.
- No Prisma schema or migration changes are included.
- No ECM DealGroup writeback is enabled for Campfire; the crmSpec still has no
dealGroupobject. - No writeback / pricing flags are enabled:
isConfigurableWritebackEnabledanduseRefactoredGetRevenuestay out of scope. - Salesforce account handling remains field-based and should not call HubSpot association APIs.
- Existing ECM idempotency and single-active-Contract invariant remain in force.
5. Risks / rollback / open questions
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.
Blank HubSpot dealtype now means newbusiness. Explicit values are preserved, including unmapped values like existingbusiness.
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.
AMENDMENT is still blocked because Campfire needs a prior-deal pointer and richer deal-type detection beyond stock HubSpot dealtype.
DealGroup writeback needs a Campfire HubSpot custom object in crmSpec first; otherwise resolveObjectType would throw.