Salesforce v2 — two-way Case sync, agent/queue mapping, ticket-updated trigger
Point-in-time planning artifact. What shipped names the trigger Session Updated (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 strictlysession-updated, notticket-updated); the attribute prefixes stayedsf_case_/sf_contact_/sf_<object>_. The shipped contract is documented indocs/integrations/salesforce/email-cases.mdx.
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
- SF → OpenCX freshness. A Salesforce agent edits any Case field → the linked session’s
sf_case_*attributes refresh within seconds (today they refresh only onnew_case/new_reply/human_agent_comment/owner_changed—backend/src/salesforce-case-integration/salesforce-case.service.ts:786,1432,2396,2475). - 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.
- 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.tshas no session/ticket “updated” member). - Agent mapping + assignee sync.
Case.OwnerId↔chat_sessions.assignee_id, both directions, behind the existingchatbots.sync_assignee_from_3rd_party/sync_assignee_to_3rd_partyflags (backend/src/db/opencx.ts:1530-1531) — which gate nothing for Salesforce today (backend/src/chat/chat-integration.service.ts:220). - 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-updatedforeditable_custom_dataedits (the ticket payload exposes onlycustom_data—backend/src/workflow/definitions/payloads/workflow-trigger-payload.utils.ts:111). case_closedrefreshing fields / firingticket-resolved(raw update atsalesforce-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 aSALESFORCE_V_2-gated arm or inside backend/src/salesforce-case-integration/.
5A. ticket-updated workflow trigger (generic, fired from the session custom-data seam)
Definition — backend/src/workflow/definitions/triggers/ticket-updated.trigger.ts, WorkflowTriggerEnum.TICKET_UPDATED = 'ticket-updated', registered in triggers/index.ts. Payload:
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:
- read prior
custom_data(oneSELECT— the contact repo already pays this), - merge (
COALESCE(custom_data,'{}') || $set, then- $keysToClear::text[]whenkeysToClearis non-empty — new optional param, same idiom as the contact repo), changedFields = keys where JSON(prior[k]) !== JSON(merged[k])∪ cleared keys; if empty → no fire,workflowTrigger_service_triggerByType({ orgId, triggerPayload: ticketUpdatedTrigger.$trigger({ ticket: await getTicketPayload({sessionId}), changedFields, previousValues }) }), wrapped in try/catch +console.error— never fails the write (mirrorsemit-contact-updated.ts).
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_updatedtoSalesforceCaseEventType(dto/salesforce-case-webhook.dto.ts:3-10);mapApexTypeToEventTypepasses it through (salesforce-case.controller.ts:36-58). Same query-param contract as every existing event. - Dispatch:
case 'case_updated'indispatchWebhook(salesforce-case.service.ts:244-330) →handleCaseUpdated(orgId, caseId)fire-and-forget in prod, awaited otherwise (identical toowner_changed). - Handler
handleCaseUpdated: status-blind session lookup by(copilot_id, salesforce_case_id); unknown case → log + return; acquireRedisLocksalesforce_v2:case:fields-refresh:{orgId}:{caseId}(waitForReleaseif held — a later webhook must not be dropped, it may carry the newest state); call the existingsyncSalesforceFieldsToCustomData({ orgId, sessionId, caseId, notifyOnFieldChange: true }); release. - Stale-key clearing (the one behavioural change to the existing refresh): in
syncSalesforceFieldsToCustomData, computekeysToClear = priorKeys.filter(k => k.startsWith('sf_case_') && !(k in caseCustomData))and pass it tochatSession_repo_mergeCustomData. Applies to every caller (new_case/new_reply/…): a Case field cleared in Salesforce now clears locally instead of lingering. Same forsf_contact_*on the contact merge (already supported by the contact repo’skeysToClear). 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-updatedfire happens inside the merge (5A); thesalesforce_fields_updatedchat_history row keeps working unchanged. - Docs (
docs/integrations/salesforce/email-cases.mdx): one new Apex methodsendCaseUpdatedEvents(List<Id>)(clone ofsendCaseOwnerChangedEventswith&type=case_updated) and trigger 7. Case trigger (any field change) —after update, fires for every record inTrigger.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@futurecallout, including echoes of OpenCX’s own writes (harmless: no diff → no trigger).
5C. Outbound write-back — attribute → Case field
NewSalesforceCaseService.syncCustomDataToCase({ orgId, sessionId, changed }) (in salesforce-case.service.ts, next to syncSalesforceFieldsToCustomData):
- session must have
salesforce_case_id; org must besalesforce_v2(callers already gate on ticketing system, re-check anyway). describeUpdateableCaseFields(orgId)→ set of updateable API names (existing SDK method,salesforce-case-sdk.ts:1089-1110).- For each
[key, value]inchanged:field = key.startsWith('sf_case_') ? key.slice(8) : key; keep only iffieldis updateable andfield !== 'OwnerId'(owner is owned by 5D) — one rule for both bags. Coerce boolean/number/currency exactly asupdate-salesforce-case.action.ts:184-219does (reuse that mapping inline). - Nothing left → return. Else ONE
SalesforceCaseSDK.updateCase(orgId, caseId, fields); on errorconsole.error(never throws into the caller — mirrors HubSpotsync-custom-data-with-ticketing-system.ts:190-203). - No echo guard needed for fields: the SF
case_updatedecho re-fetches the Case; localcustom_dataalready 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.)
chatSessionTags_service_getTicketingSystem(orgId) === 'salesforce_v2', so v1 never enters):
chatSession_repo_mergeCustomDatareflect 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 withreflectOnIntegrationdefault-true on an SF org: the workflow “Update Session Attributes” action (merge-ticket-custom-data.action.ts:108). Every SF-internal merge passesreflectOnIntegration:false(salesforce-case.service.ts:2664,:2911) → no self-push.chatSession_service_updateCustomData(update-custom-data.ts:59-80): add thesalesforce_v2arm using the samechangeddiff the HubSpot arm computes → inbox agent edits push (barePriorityorsf_case_Priorityboth resolve).
5D. Agent mapping, assignee sync, queue → team
Migrationschatbot_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.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”). Exposegroup_idinsalesforce-escalation-team.dto.ts(satisfiesbinding keeps it honest) and pass through increate/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 onchatbot_users ⋈ users, persist NULL-only (WHERE salesforce_user_id IS NULL, tolerate23505), 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 viaSalesforceCrmUtils.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 newcase TicketingSystemEnum.SALESFORCE_V_2:insyncAssigneeToThirdParty(chat-integration.service.ts:217-232, lazy import like HubSpot’s). Requires linked case. Consume-once inbound-armed keysalesforce_v2:case:assignee-sync:ignore:{caseId}→ skip if present. Resolve target:AI_COPILOT_USER_ID→getCurrentUser().Id(the AI’s seat, same idea as HubSpot’sdefault_user_id);null→meta.salesforce_previous_owner_idif present else no-op (OwnerId is required in SF); user → mapped SF user, unmapped → skip with log. If target equals current owner → skip. Armsalesforce_v2:case:owner-echo:{caseId} = ownerId EX 15thenupdateCaseOwner; on failure delete the key. - Inbound — inside
handleOwnerChanged(salesforce-case.service.ts:2413-2479):- Read
OwnerId. Ifowner-echokey equals it →DEL+ return (our own write bouncing back). - Existing behaviour untouched when
sync_assignee_from_3rd_partyis off (early return onHANDED_OFF, handoff + “stepping back” comment on a non-integration owner,assignee_id = null). - When the flag is on: skip the
HANDED_OFFearly-return (an A→B reassignment must sync); on the first non-integration owner still do the handoff write + comment, but keep a humanassignee_idwhen the new owner is a Queue (nullif(assignee_id, 555), from #2223); thensyncAssigneeFromSalesforce: 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'})ifinbox_iddiffers; owner is a User → map →changeAssignee(actor:{type:'integration'})if it differs; unmapped → leave as is. Before anychangeAssignee/assignSessionToAiarmsalesforce_v2:case:assignee-sync:ignore:{caseId}so the mirror-back is consumed and skipped (changeAssignee’s ownassignee_id === agentIdno-op guard is the primary safety,change-assignee.ts:44). - Existing
syncSalesforceFieldsToCustomDatacall at the end stays.
- Read
changeAssignee / assignSessionToAi signatures.
6. Echo / loop matrix
7. Non-breaking / v1 safety checklist
- New code lives in
backend/src/salesforce-case-integration/**, theSALESFORCE_V_2arm ofsyncAssigneeToThirdParty, andsalesforce_v2-gatedelse ifarms in the two custom-data write functions. V1 (salesforce) hits none of them:chat-integration.service.ts:219keeps v1 in the no-op list;merge-custom-data.ts/update-custom-data.tsbranch on'salesforce_v2'only. - V1 webhook is a different controller (
/backend/salesforce-case-management/webhook,salesforce-integration/salesforce.controller.ts:271-324) — the newcase_updatedtype is invisible to it. MoneyGram’s caseId-only contract routes throughhandleCaseIdWebhook(salesforce-case.service.ts:356-367), untouched. - No change to
SalesforceService.getConnection/ token /verifyWebhookToken(salesforce-integration/salesforce.service.ts:915, 1344, 1361), tosalesforce_integration_settingscolumns, tochat_sessions.salesforce_case_idor its unique index. - The shared
append-note-to-ticket.ts:128-129fall-through (v1 → V2addCaseCommentWithEchoGuard) andresolve.service.ts:364-365are not touched. - New behaviour is opt-in:
case_updatedrequires the customer to add trigger 7; assignee sync requires the org flags (already false by default); queue routing requiresgroup_idto be set; write-back only fires for keys that resolve to an updateable Case field on asalesforce_v2org. - 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__/
case-updated-webhook-refreshes-fields-and-fires-ticket-updated.spec.ts—?type=case_updated→sf_case_*refreshed,salesforce_fields_updatedrow for whitelisted keys, ONEticket-updatedrun withchangedFields/previousValues.case-updated-webhook-without-diff-fires-nothing.spec.ts— echo of an unchanged Case → no run, no chat_history row.case-updated-clears-field-nulled-in-salesforce.spec.ts— key removed locally;changedFieldsincludes it; whitelisted diff reportsnew: null.case-updated-unknown-case-and-v1-org-are-ignored.spec.ts— no session /ticketing_system = salesforce→ no SF read, no writes.case-updated-concurrent-webhooks-serialize-per-case.spec.ts— two overlapping webhooks → newest snapshot wins.custom-data-merge-pushes-sf-case-keys-to-case.spec.ts— merge action withsf_case_Priority→ one PATCH with{Priority}; non-updateable /OwnerId/ unknown keys skipped; v1 org → no PATCH.editable-custom-data-edit-pushes-to-case.spec.ts— inbox editPriority→ PATCH; unchanged key → no PATCH.owner-changed-assigns-mapped-teammate-when-sync-from-on.spec.ts— email auto-link persisted,changeAssigneewith integration actor, no mirror-back PATCH.owner-changed-integration-user-assigns-ai.spec.ts— owner back to integration user →assignee_id = 555,ai_closure_typecleared.owner-changed-queue-owner-routes-to-mapped-team.spec.ts—00G…withgroup_id→inbox_idset, human assignee preserved; unmapped/disabled/ambiguous → untouched.owner-changed-legacy-behavior-when-sync-from-off.spec.ts— flag off → exactly today’s behaviour (handoff, null assignee, comment).assignee-change-writes-case-owner-when-sync-to-on.spec.ts— human/AI/null cases; unmapped skipped; flag off → no PATCH.assignee-echo-guard-suppresses-own-owner-write.spec.ts— outbound PATCH thenowner_changedecho → no handoff, no comment.agent-mapping-email-match-persists-and-isolates-orgs.spec.ts— NULL-only, cross-org isolation,23505tolerated.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)
feat(workflows): add ticket-updated trigger, fired from session custom-data merges— trigger + enum + registry +keysToClear/diff/fire inmerge-custom-data.ts+pnpm gensdk+ tests.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.feat(salesforce): push session attribute changes to the v2 Case—syncCustomDataToCase+ the two reflect arms + tests.feat(salesforce): sync Case owner with session assignee and map queues to teams— migrations + codegen, mapping + assignee-sync services,owner_changedchanges,syncAssigneeToThirdPartyarm, escalation-teamgroup_id, docs section, tests.
10. Follow-ups (not in this PR)
- Poll safety-net for orgs that cannot deploy Apex:
SELECT … FROM Case WHERE SystemModstamp > :cursorover linked open sessions, mirroringsalesforce-crm-poll-sync.worker.tscadence/cursor idioms. - Manual agent-mapping endpoint + roster cache + dashboard picker (Zendesk
PUT /agents/mappingshape); dashboard team picker forsalesforce_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 (firesticket-resolved) + refresh fields.- Expose
editable_custom_datain the ticket payload and fireticket-updatedon inbox edits. - Optional Apex
Queueableretry wrapper for callouts, in the production checklist.
11. Decisions to confirm
- Trigger name:
ticket-updated(“Ticket Updated”) vsticket-attributes-updated. Recommendation:ticket-updated, description makes the attribute scope explicit. previousValueson the payload — keep (cheap, enables “from X to Y”) or drop for strict YAGNI. Recommendation: keep.- Queue → team storage:
salesforce_escalation_teams.group_id(recommended, reuses the queue registry + CRUD) vs newgroups.salesforce_queue_id. case_updatedrefreshes Case + Contact + related objects (existing method, most current data) vs Case-only (#2229). Recommendation: reuse the existing method; add acaseOnlyflag later only if API budget bites.