Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(crm): stop lowercasing picklist values on HubSpot sync

open#2757CedarCopilot

CedarCopilot wants to merge 2 commits into staging from fix/hubspot-picklist-value-normalization-20260911-101702

Not deployedTimeline and evidence
  1. Opened
    Sep 11, 2026, 7:21 AM
  2. Sep 11, 2026, 7:34 AM
  3. Merged, live on staging, live on prod, observed
    Pending
  4. Not deployed

    Not deployed

    Pending

Libra has no production signal for this change yet because it has not deployed. Libra checks hourly for 3 days after each deploy.

Summary

  • Both the inbound pull-sync (crm-field-sync.ts, resolveCrmFieldValue's label-to-value case) and the outbound push (hubspot.ts, updateOpportunityFields) normalize a picklist value (lowercase + underscores) purely to MATCH it against the field's allowedValues , then returned that normalized string as the value to store/send, instead of resolving back to the real, correctly-cased allowedValues entry.
  • This is invisible for hand-typed fields whose values already happen to be snake_case, but silently corrupts any field seeded from a source CRM's own picklist via sourceFromCrm , those keep the source CRM's real casing (e.g. HubSpot's deal_source options are Inbound, Cold Email, not inbound/cold_email), so the write lands as an unrecognized lowercase string matching no defined option.
  • Found while investigating a live customer report , a deal_source field on a HubSpot-synced account had 9 of its 10 populated values silently mismatched this way. Confirmed via the live HubSpot API that HubSpot's own picklist values (and, separately, all 301 of that account's live deal records) are correctly cased , the corruption originates entirely in Cedar's sync code, not the source data.
  • Extracted the outbound logic into a small, pure, exported helper (resolveAllowedValueByNormalizedMatch) since the single-select and multi-select branches in hubspot.ts had duplicated the same normalize/match code with the same bug in both places.

What changed

  • apps/server/src/services/crm/crm-field-sync.ts , resolveCrmFieldValue's label-to-value case (both the single-value and semicolon-joined multi-select branches) now returns the matched entry from allowedValues, not the normalized comparison key. Exported for testing.
  • apps/server/src/services/integrations/crm/hubspot.ts , updateOpportunityFields's picklist-reversal logic extracted into resolveAllowedValueByNormalizedMatch, fixing the same bug on the outbound (Cedar → HubSpot) path and removing ~15 lines of duplicated normalize logic.
  • Two new test files covering both the inbound transform and the new outbound helper directly, plus the existing crm-field-sync-* and hubspot-label-to-value suites re-run to confirm no regression.

Test plan

  • vitest run on both new test files + the 3 existing suites touching this code , 26/26 passing
  • tsc -b on @zero/server , clean
  • After deploy: watch the next periodic HubSpot pull-sync for any account with an allowedValues-constrained mapped field (not just the reported one) and confirm no new lowercase/underscored values land in crm_conversation_field_values

🤖 Generated with Claude Code

https://claude.ai/code/session_01DWbTovQuTH4TDdhFZ9JmcD

Greptile Summary

This PR fixes HubSpot picklist synchronization so normalized strings are used only for comparison while the canonical, correctly cased allowed value is stored or sent.

  • Updates inbound single- and multi-select label-to-value resolution.
  • Extracts canonical outbound matching into a shared HubSpot helper.
  • Adds focused unit tests for inbound and outbound canonical-value resolution.
  • The inbound implementation still mishandles allowed values that collide after normalization.

Confidence Score: 4/5

The PR should not merge until inbound resolution preserves exact allowed-value matches when multiple options share a normalized key.

The inbound Map silently applies last-write-wins semantics to normalized collisions, so a valid exact CRM option can still be replaced with a different allowed value despite this PR’s goal of preserving canonical picklist identity.

Files Needing Attention: apps/server/src/services/crm/crm-field-sync.ts

Important Files Changed

FilenameOverview
apps/server/src/services/crm/crm-field-sync.tsReturns canonical allowed values during inbound synchronization, but normal
Show production surfaces and changed-file mapping

Production surfaces

Libra has not measured any production surfaces for this change yet.

Changed files → surfaces

  • apps/server/src/services/crm/__tests__/crm-field-sync-label-to-value.test.tsno production surface mapped
  • apps/server/src/services/crm/crm-field-sync.tsno production surface mapped
  • apps/server/src/services/integrations/crm/__tests__/hubspot-normalized-match.test.tsno production surface mapped
  • apps/server/src/services/integrations/crm/hubspot.tsno production surface mapped