Skip to main content

Salesforce v2 — two-way Case sync, agent/queue mapping, ticket-updated trigger

Point-in-time planning artifact. What shipped names the trigger Session Updated (session-updated, not ticket-updated); the attribute prefixes stayed sf_case_ / sf_contact_ / sf_<object>_. The shipped contract is documented in docs/integrations/salesforce/email-cases.mdx.
Consolidated design for ONE minimal PR replacing #2229 (case field sync), #2223 (agent parity), and the sync-related half of the intent behind #2227. Scope is strictly TicketingSystemEnum.SALESFORCE_V_2 (salesforce_v2); v1 (salesforce, MoneyGram MIAW + legacy email-to-case) is untouched. Baseline: origin/main @ 14b7ab598f. Every claim below cites the file it was verified against.

1. Goal

  1. SF → OpenCX freshness. A Salesforce agent edits any Case field → the linked session’s sf_case_* attributes refresh within seconds (today they refresh only on new_case / new_reply / human_agent_comment / owner_changedbackend/src/salesforce-case-integration/salesforce-case.service.ts:786,1432,2396,2475).
  2. OpenCX → SF write-back. A session attribute that maps to a Case field changes on our side (workflow “Update Session Attributes”, inbox agent edit) → the Case field updates.
  3. A workflow trigger that fires on those attribute changes so agentic workflows can act on a Salesforce Case change. No such trigger exists today (backend/src/workflow/enums/workflow-trigger.enum.ts has no session/ticket “updated” member).
  4. Agent mapping + assignee sync. Case.OwnerIdchat_sessions.assignee_id, both directions, behind the existing chatbots.sync_assignee_from_3rd_party / sync_assignee_to_3rd_party flags (backend/src/db/opencx.ts:1530-1531) — which gate nothing for Salesforce today (backend/src/chat/chat-integration.service.ts:220).
  5. Queue → team mapping. A Case owned by a Salesforce Queue (00G…) lands the session in the mapped OpenCX team.

2. Non-goals (explicitly deferred)

  • Polling / reconciliation worker for Case fields (webhook-first; see §10 for the poll follow-up).
  • Attributing SF agent comments/emails to OpenCX teammates with avatars (the identity half of #2223).
  • Outbound team → Queue push (Zendesk v2 has no outbound group push either — backend/src/zendesk-integration-v2/zendesk-group-mapping.service.ts:17-24).
  • Roster cache table + dashboard mapping UI for agents/queues (Zendesk has zendesk_agents + PUT /agents/mapping; phase 1 here is auto-map-by-email with a persisted link, no UI).
  • Firing ticket-updated for editable_custom_data edits (the ticket payload exposes only custom_databackend/src/workflow/definitions/payloads/workflow-trigger-payload.utils.ts:111).
  • case_closed refreshing fields / firing ticket-resolved (raw update at salesforce-case.service.ts:2481-2531; separate fix).
  • “For Each Matching Session” cron action bundled into #2229 — its own PR.
  • “Scan Salesforce Case Queue” action (#2227) — read-only bulk read, independent; keep as its own PR.

3. What exists on main (facts the design leans on)

4. Assessment of the open PRs

5. Design

Four pieces, one PR, four commits (§9). Every hook point is inside a SALESFORCE_V_2-gated arm or inside backend/src/salesforce-case-integration/.

5A. ticket-updated workflow trigger (generic, fired from the session custom-data seam)

Definitionbackend/src/workflow/definitions/triggers/ticket-updated.trigger.ts, WorkflowTriggerEnum.TICKET_UPDATED = 'ticket-updated', registered in triggers/index.ts. Payload:
No trigger_configuration (like contact-updated); authors gate with an if-block on changedFields contains "sf_case_Status" (array contains exists in the condition evaluator) and/or previousValues.sf_case_Status. Description states: fires when a ticket’s attributes change (integration sync, workflow “Update Session Attributes”, API); status/assignee/team/tag changes have their own triggers; a workflow that writes attributes to the same ticket re-triggers itself → gate on Changed Fields (same wording as contact-updated.trigger.ts:36-38). Fire site — exactly one: inside chatSession_repo_mergeCustomData (merge-custom-data.ts), mirroring consumer/repo/merge-custom-data.ts:55-92:
  1. read prior custom_data (one SELECT — the contact repo already pays this),
  2. merge (COALESCE(custom_data,'{}') || $set, then - $keysToClear::text[] when keysToClear is non-empty — new optional param, same idiom as the contact repo),
  3. changedFields = keys where JSON(prior[k]) !== JSON(merged[k]) ∪ cleared keys; if empty → no fire,
  4. workflowTrigger_service_triggerByType({ orgId, triggerPayload: ticketUpdatedTrigger.$trigger({ ticket: await getTicketPayload({sessionId}), changedFields, previousValues }) }), wrapped in try/catch + console.error — never fails the write (mirrors emit-contact-updated.ts).
Because the Salesforce refresh (5B) writes through this same function, it needs no fire site of its own. Loop termination = the diff: a workflow that writes the same value again produces changedFields = [] → no fire. Dashboard: trigger types are SDK-generated only (dashboard/packages/sdk/src/schema.ts), so pnpm gensdk is the only dashboard change.

5B. Inbound freshness — type=case_updated webhook

  • DTO/enum: add case_updated to SalesforceCaseEventType (dto/salesforce-case-webhook.dto.ts:3-10); mapApexTypeToEventType passes it through (salesforce-case.controller.ts:36-58). Same query-param contract as every existing event.
  • Dispatch: case 'case_updated' in dispatchWebhook (salesforce-case.service.ts:244-330) → handleCaseUpdated(orgId, caseId) fire-and-forget in prod, awaited otherwise (identical to owner_changed).
  • Handler handleCaseUpdated: status-blind session lookup by (copilot_id, salesforce_case_id); unknown case → log + return; acquire RedisLock salesforce_v2:case:fields-refresh:{orgId}:{caseId} (waitForRelease if held — a later webhook must not be dropped, it may carry the newest state); call the existing syncSalesforceFieldsToCustomData({ orgId, sessionId, caseId, notifyOnFieldChange: true }); release.
  • Stale-key clearing (the one behavioural change to the existing refresh): in syncSalesforceFieldsToCustomData, compute keysToClear = priorKeys.filter(k => k.startsWith('sf_case_') && !(k in caseCustomData)) and pass it to chatSession_repo_mergeCustomData. Applies to every caller (new_case/new_reply/…): a Case field cleared in Salesforce now clears locally instead of lingering. Same for sf_contact_* on the contact merge (already supported by the contact repo’s keysToClear).
  • notifyAllowedFieldsChanged: iterate the union of before/after keys so a removed whitelisted key is reported as {old, new: null} (cherry-pick of #2229’s small change at its :3036-3050).
  • Emits nothing else — the ticket-updated fire happens inside the merge (5A); the salesforce_fields_updated chat_history row keeps working unchanged.
  • Docs (docs/integrations/salesforce/email-cases.mdx): one new Apex method sendCaseUpdatedEvents(List<Id>) (clone of sendCaseOwnerChangedEvents with &type=case_updated) and trigger 7. Case trigger (any field change)after update, fires for every record in Trigger.new (no field filter; the backend diff dedupes). Documented as optional-but-recommended; existing 6 triggers unchanged; existing customers keep working with zero changes. Add the trigger to the Apex test class. Note the ceiling: each Case update = one @future callout, including echoes of OpenCX’s own writes (harmless: no diff → no trigger).

5C. Outbound write-back — attribute → Case field

New SalesforceCaseService.syncCustomDataToCase({ orgId, sessionId, changed }) (in salesforce-case.service.ts, next to syncSalesforceFieldsToCustomData):
  1. session must have salesforce_case_id; org must be salesforce_v2 (callers already gate on ticketing system, re-check anyway).
  2. describeUpdateableCaseFields(orgId) → set of updateable API names (existing SDK method, salesforce-case-sdk.ts:1089-1110).
  3. For each [key, value] in changed: field = key.startsWith('sf_case_') ? key.slice(8) : key; keep only if field is updateable and field !== 'OwnerId' (owner is owned by 5D) — one rule for both bags. Coerce boolean/number/currency exactly as update-salesforce-case.action.ts:184-219 does (reuse that mapping inline).
  4. Nothing left → return. Else ONE SalesforceCaseSDK.updateCase(orgId, caseId, fields); on error console.error (never throws into the caller — mirrors HubSpot sync-custom-data-with-ticketing-system.ts:190-203).
  5. No echo guard needed for fields: the SF case_updated echo re-fetches the Case; local custom_data already holds the value (merged before the push) → changedFields = [] → nothing fires. (// ponytail: a concurrent local edit between push and echo could be overwritten by the echo snapshot; self-heals on the next change.)
Wire-up (both mirror the existing HubSpot arms, keyed on chatSessionTags_service_getTicketingSystem(orgId) === 'salesforce_v2', so v1 never enters):
  • chatSession_repo_mergeCustomData reflect branch (merge-custom-data.ts:93-109): else if (ticketingSystem === 'salesforce_v2') await SalesforceCaseService.syncCustomDataToCase({…, changed: <only the changedFields subset>}) — lazy import like the HubSpot one. Callers reaching this with reflectOnIntegration default-true on an SF org: the workflow “Update Session Attributes” action (merge-ticket-custom-data.action.ts:108). Every SF-internal merge passes reflectOnIntegration:false (salesforce-case.service.ts:2664, :2911) → no self-push.
  • chatSession_service_updateCustomData (update-custom-data.ts:59-80): add the salesforce_v2 arm using the same changed diff the HubSpot arm computes → inbox agent edits push (bare Priority or sf_case_Priority both resolve).

5D. Agent mapping, assignee sync, queue → team

Migrations
  1. chatbot_users.salesforce_user_id text NULL + CREATE UNIQUE INDEX CONCURRENTLY … ON chatbot_users (chatbot_id, salesforce_user_id) WHERE salesforce_user_id IS NOT NULL (noTransaction = true) — the shape #2223 already wrote (20260815111500, 20260815111501); cherry-pick.
  2. salesforce_escalation_teams.group_id uuid NULL REFERENCES groups(id) ON DELETE SET NULL + COMMENT ON COLUMN (“OpenCX team that owns Cases assigned to this Queue; NULL = escalation-only, no inbound routing”). Expose group_id in salesforce-escalation-team.dto.ts (satisfies binding keeps it honest) and pass through in create/update.
SalesforceAgentMappingService (new file salesforce-agent-mapping.service.ts, trimmed from #2223 — keep resolveOpenCxUserId(sfUser), resolveSalesforceUserId(openCxUserId); drop resolveIdentity/avatar):
  • SF User → member: stored salesforce_user_id, else case-insensitive email match on chatbot_users ⋈ users, persist NULL-only (WHERE salesforce_user_id IS NULL, tolerate 23505), never re-link a member already linked to a different SF id.
  • Member → SF User: stored, else SELECT Id,Name,Email FROM User WHERE IsActive=true AND Email='…' LIMIT 2 (SOQL value escaped via SalesforceCrmUtils.escapeSoqlValue), exactly one hit → persist.
  • Two SDK additions: getUserById, findActiveUsersByEmail.
SalesforceAssigneeSyncService (new file, Zendesk shape):
  • Gate: ticketing_system === salesforce_v2 + the flag for the direction.
  • Outbound syncAssigneeWithSalesforce({orgId, sessionId, openCxAssigneeId}) — called from a new case TicketingSystemEnum.SALESFORCE_V_2: in syncAssigneeToThirdParty (chat-integration.service.ts:217-232, lazy import like HubSpot’s). Requires linked case. Consume-once inbound-armed key salesforce_v2:case:assignee-sync:ignore:{caseId} → skip if present. Resolve target: AI_COPILOT_USER_IDgetCurrentUser().Id (the AI’s seat, same idea as HubSpot’s default_user_id); nullmeta.salesforce_previous_owner_id if present else no-op (OwnerId is required in SF); user → mapped SF user, unmapped → skip with log. If target equals current owner → skip. Arm salesforce_v2:case:owner-echo:{caseId} = ownerId EX 15 then updateCaseOwner; on failure delete the key.
  • Inbound — inside handleOwnerChanged (salesforce-case.service.ts:2413-2479):
    1. Read OwnerId. If owner-echo key equals it → DEL + return (our own write bouncing back).
    2. Existing behaviour untouched when sync_assignee_from_3rd_party is off (early return on HANDED_OFF, handoff + “stepping back” comment on a non-integration owner, assignee_id = null).
    3. When the flag is on: skip the HANDED_OFF early-return (an A→B reassignment must sync); on the first non-integration owner still do the handoff write + comment, but keep a human assignee_id when the new owner is a Queue (nullif(assignee_id, 555), from #2223); then syncAssigneeFromSalesforce: owner == integration user → assignSessionToAi (actor:{type:'integration'}); owner matches /^00G/SalesforceEscalationTeamsService.resolveGroupId(orgId, ownerId) (15/18-char tolerant, enabled, group_id IS NOT NULL, exactly one) → assignToTeam({shallowAssignment:true, actor:{type:'integration'}, trigger:'integration'}) if inbox_id differs; owner is a User → map → changeAssignee(actor:{type:'integration'}) if it differs; unmapped → leave as is. Before any changeAssignee/assignSessionToAi arm salesforce_v2:case:assignee-sync:ignore:{caseId} so the mirror-back is consumed and skipped (changeAssignee’s own assignee_id === agentId no-op guard is the primary safety, change-assignee.ts:44).
    4. Existing syncSalesforceFieldsToCustomData call at the end stays.
No change to changeAssignee / assignSessionToAi signatures.

6. Echo / loop matrix

7. Non-breaking / v1 safety checklist

  • New code lives in backend/src/salesforce-case-integration/**, the SALESFORCE_V_2 arm of syncAssigneeToThirdParty, and salesforce_v2-gated else if arms in the two custom-data write functions. V1 (salesforce) hits none of them: chat-integration.service.ts:219 keeps v1 in the no-op list; merge-custom-data.ts / update-custom-data.ts branch on 'salesforce_v2' only.
  • V1 webhook is a different controller (/backend/salesforce-case-management/webhook, salesforce-integration/salesforce.controller.ts:271-324) — the new case_updated type is invisible to it. MoneyGram’s caseId-only contract routes through handleCaseIdWebhook (salesforce-case.service.ts:356-367), untouched.
  • No change to SalesforceService.getConnection / token / verifyWebhookToken (salesforce-integration/salesforce.service.ts:915, 1344, 1361), to salesforce_integration_settings columns, to chat_sessions.salesforce_case_id or its unique index.
  • The shared append-note-to-ticket.ts:128-129 fall-through (v1 → V2 addCaseCommentWithEchoGuard) and resolve.service.ts:364-365 are not touched.
  • New behaviour is opt-in: case_updated requires the customer to add trigger 7; assignee sync requires the org flags (already false by default); queue routing requires group_id to be set; write-back only fires for keys that resolve to an updateable Case field on a salesforce_v2 org.
  • The only change to an existing path is stale sf_case_*/sf_contact_* keys now being cleared on refresh — a correctness fix, covered by tests.

8. Test plan (one scenario per file, local fake-Salesforce HTTP server + real Postgres/Redis, as in salesforce-owner-and-assignee-stay-in-sync.spec.ts; no mocks/spies)

backend/src/salesforce-case-integration/__tests__/
  1. case-updated-webhook-refreshes-fields-and-fires-ticket-updated.spec.ts?type=case_updatedsf_case_* refreshed, salesforce_fields_updated row for whitelisted keys, ONE ticket-updated run with changedFields/previousValues.
  2. case-updated-webhook-without-diff-fires-nothing.spec.ts — echo of an unchanged Case → no run, no chat_history row.
  3. case-updated-clears-field-nulled-in-salesforce.spec.ts — key removed locally; changedFields includes it; whitelisted diff reports new: null.
  4. case-updated-unknown-case-and-v1-org-are-ignored.spec.ts — no session / ticketing_system = salesforce → no SF read, no writes.
  5. case-updated-concurrent-webhooks-serialize-per-case.spec.ts — two overlapping webhooks → newest snapshot wins.
  6. custom-data-merge-pushes-sf-case-keys-to-case.spec.ts — merge action with sf_case_Priority → one PATCH with {Priority}; non-updateable / OwnerId / unknown keys skipped; v1 org → no PATCH.
  7. editable-custom-data-edit-pushes-to-case.spec.ts — inbox edit Priority → PATCH; unchanged key → no PATCH.
  8. owner-changed-assigns-mapped-teammate-when-sync-from-on.spec.ts — email auto-link persisted, changeAssignee with integration actor, no mirror-back PATCH.
  9. owner-changed-integration-user-assigns-ai.spec.ts — owner back to integration user → assignee_id = 555, ai_closure_type cleared.
  10. owner-changed-queue-owner-routes-to-mapped-team.spec.ts00G… with group_idinbox_id set, human assignee preserved; unmapped/disabled/ambiguous → untouched.
  11. owner-changed-legacy-behavior-when-sync-from-off.spec.ts — flag off → exactly today’s behaviour (handoff, null assignee, comment).
  12. assignee-change-writes-case-owner-when-sync-to-on.spec.ts — human/AI/null cases; unmapped skipped; flag off → no PATCH.
  13. assignee-echo-guard-suppresses-own-owner-write.spec.ts — outbound PATCH then owner_changed echo → no handoff, no comment.
  14. agent-mapping-email-match-persists-and-isolates-orgs.spec.ts — NULL-only, cross-org isolation, 23505 tolerated.
  15. escalation-teams-group-id-crud.spec.ts — DTO/CRUD round-trip, ON DELETE SET NULL.
backend/src/workflow/definitions/triggers/ticket-updated.trigger.spec.ts — payload shape, registry, changedFields/previousValues, no fire on empty diff (mirrors ticket-reopened.trigger.spec.ts). backend/src/chat-session/repo/merge-custom-data.spec.ts (new scenarios) — keysToClear removes keys; diff → fire; no-diff → no fire; reflect false → no push.

9. Delivery — one PR, four commits (each green on its own)

  1. feat(workflows): add ticket-updated trigger, fired from session custom-data merges — trigger + enum + registry + keysToClear/diff/fire in merge-custom-data.ts + pnpm gensdk + tests.
  2. feat(salesforce): refresh v2 Case fields on case_updated webhook — DTO/dispatch/handler/lock, stale-key clearing, diff of removed keys, docs (Apex method + trigger 7 + test class), tests.
  3. feat(salesforce): push session attribute changes to the v2 CasesyncCustomDataToCase + the two reflect arms + tests.
  4. feat(salesforce): sync Case owner with session assignee and map queues to teams — migrations + codegen, mapping + assignee-sync services, owner_changed changes, syncAssigneeToThirdParty arm, escalation-team group_id, docs section, tests.
Then: close #2229 and #2223 with a pointer to the new PR; open a separate PR for “For Each Matching Session” (already-decided semantics); #2227 stays independent. Estimated size ≈ 700–800 src lines + tests (vs 12k across the three open PRs).

10. Follow-ups (not in this PR)

  • Poll safety-net for orgs that cannot deploy Apex: SELECT … FROM Case WHERE SystemModstamp > :cursor over linked open sessions, mirroring salesforce-crm-poll-sync.worker.ts cadence/cursor idioms.
  • Manual agent-mapping endpoint + roster cache + dashboard picker (Zendesk PUT /agents/mapping shape); dashboard team picker for salesforce_escalation_teams.group_id.
  • Teammate identity/avatar on inbound SF comments/emails (the dropped half of #2223).
  • case_closed → run through the shared close path (fires ticket-resolved) + refresh fields.
  • Expose editable_custom_data in the ticket payload and fire ticket-updated on inbox edits.
  • Optional Apex Queueable retry wrapper for callouts, in the production checklist.

11. Decisions to confirm

  1. Trigger name: ticket-updated (“Ticket Updated”) vs ticket-attributes-updated. Recommendation: ticket-updated, description makes the attribute scope explicit.
  2. previousValues on the payload — keep (cheap, enables “from X to Y”) or drop for strict YAGNI. Recommendation: keep.
  3. Queue → team storage: salesforce_escalation_teams.group_id (recommended, reuses the queue registry + CRUD) vs new groups.salesforce_queue_id.
  4. case_updated refreshes Case + Contact + related objects (existing method, most current data) vs Case-only (#2229). Recommendation: reuse the existing method; add a caseOnly flag later only if API budget bites.