Salesforce v2 Two-Way Sync Implementation Plan
Point-in-time planning artifact. What shipped names the trigger Session Updated (session-updated, notticket-updated); the attribute prefixes stayedsf_case_/sf_contact_/sf_<object>_. The shipped contract is documented indocs/integrations/salesforce/email-cases.mdx.
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Keep a Salesforce v2 Case and its OpenCX session consistent in both directions (fields, owner/assignee, queue/team) and let workflows react via a new ticket-updated trigger — in one minimal, v2-only, non-breaking PR.
Architecture: Reuse the existing seams: the session custom-data merge repo (chatSession_repo_mergeCustomData) becomes the single place that diffs, clears stale keys, fires ticket-updated, and reflects to Salesforce; the v2 webhook gains one more type (case_updated) routed to the existing field snapshot; owner↔assignee mirrors the Zendesk v2 assignee-sync shape; queue→team is a nullable FK on the existing queue registry.
Tech Stack: Fastify + Kysely + Zod (backend), vitest with real Postgres/Redis and a per-spec fake Salesforce HTTP server (jsforce hits it), @opencx/workflows trigger definitions, Mintlify docs.
Spec: docs/superpowers/specs/2026-08-16-salesforce-v2-two-way-sync-design.md.
Global Constraints
- Only
TicketingSystemEnum.SALESFORCE_V_2('salesforce_v2') paths change; v1 ('salesforce') must behave identically (never editbackend/src/salesforce-integration/**, MoneyGram code, or the v1 arms listed in the spec §7). - No
any, noascasts (exceptas const), no non-null!; Zod DTOs bound withsatisfies z.ZodType<...>. - Existing tests must pass unmodified. One Salesforce scenario per spec file. No mocks/spies — real DB + real jsforce against a local fake Salesforce HTTP server.
- Run each spec with:
NODE_ENV=test ./node_modules/.bin/vitest run --root /Users/aziz/opencx/.claude/worktrees/salesforce-v2-two-way-sync/backend <ABS_SPEC_PATH>(worktree DBsalesforce_v2_two_way_syncon the throwaway containeropencx-sfwt-postgres). - Type-check:
./node_modules/.bin/tsgo --noEmit -p /Users/aziz/opencx/.claude/worktrees/salesforce-v2-two-way-sync/backend/tsconfig.json; lint touched files:./node_modules/.bin/oxlint <files>; format:./node_modules/.bin/oxfmt <files>. - Commit messages: present-tense imperative, no AI attribution. Stage explicit paths only (never
-A).
Task 1: ticket-updated trigger + session merge diff/clear/fire
Files:
- Create:
backend/src/workflow/definitions/triggers/ticket-updated.trigger.ts - Modify:
backend/src/workflow/enums/workflow-trigger.enum.ts(addTICKET_UPDATED = 'ticket-updated') - Modify:
backend/src/workflow/definitions/triggers/index.ts(import, register, export) - Modify:
backend/src/chat-session/repo/merge-custom-data.ts(prior read,clearNullish, diff, fire) - Test:
backend/src/workflow/definitions/triggers/ticket-updated.trigger.spec.ts - Test:
backend/src/chat-session/repo/merge-custom-data.ticket-updated.spec.ts - Regenerate:
dashboard/packages/sdk/src/schema.ts(enum literal only) — Task 6.
-
Produces:
ticketUpdatedTrigger($trigger({ ticket, changedFields: string[], previousValues: Record<string, unknown> })),WorkflowTriggerEnum.TICKET_UPDATED. -
Produces:
chatSession_repo_mergeCustomData({ sessionId, orgId, customData, reflectOnIntegration?, clearNullish? })→{ data?: { mergedCustomData, changedFields: string[] }, error? }.customDatavalues may benullwhenclearNullishis true (a null clears the key). - Step 1: trigger definition
-
Step 2: enum + registry — add
TICKET_UPDATED = 'ticket-updated',afterTICKET_TEAM_CHANGED; inindex.tsimportticketUpdatedTrigger, add it afterticketTeamChangedTriggerin the registration array and in the export list. -
Step 3: merge repo — replace
backend/src/chat-session/repo/merge-custom-data.tswith:
changedFields.length > 0 — a no-op merge has nothing to push. Existing callers pass whole objects and were already re-pushed unchanged data; the diff-gate is strictly less work.)
- Step 4: tests —
ticket-updated.trigger.spec.ts(pattern:ticket-reopened.trigger.spec.ts): (a)triggerByTypeSYNC round-tripschangedFields/previousValues; (b) e2e:chatSession_repo_mergeCustomData({customData:{foo:'bar'}})on an org with aticket-updatedcapture workflow → 1 run,changedFields=['foo'],previousValues={},ticket.customData.foo==='bar'; (c) same value again → still 1 run; (d)clearNullish{foo:null}→ 2nd run,changedFields=['foo'],previousValues={foo:'bar'}, key gone.merge-custom-data.ticket-updated.spec.ts: return shapechangedFields, non-clearNullish null ignored, cross-org no-op. - Step 5: run, tsgo, lint, format; commit
feat(workflows): add ticket-updated trigger, fired from session attribute merges.
Task 2: case_updated webhook → field refresh (+ stale-key clearing)
Files:
- Modify:
backend/src/salesforce-case-integration/dto/salesforce-case-webhook.dto.ts('case_updated'in enum) - Modify:
backend/src/salesforce-case-integration/salesforce-case.service.ts(dispatchWebhookcase;handleCaseUpdated;syncSalesforceFieldsToCustomDatastale-key clearing;notifyAllowedFieldsChangedunion keys) - Modify:
backend/src/salesforce-case-integration/salesforce-case.controller.tsdocstring list of canonical names - Modify:
docs/integrations/salesforce/email-cases.mdx(Apex method + trigger 7 + test-class method + intro count “seven”) - Test:
backend/src/salesforce-case-integration/__tests__/case-updated-webhook-refreshes-fields-and-fires-ticket-updated.spec.ts - Test:
backend/src/salesforce-case-integration/__tests__/case-updated-webhook-without-diff-fires-nothing.spec.ts - Test:
backend/src/salesforce-case-integration/__tests__/case-updated-clears-field-nulled-in-salesforce.spec.ts - Test:
backend/src/salesforce-case-integration/__tests__/case-updated-unknown-case-and-v1-org-are-ignored.spec.ts
SalesforceCaseService.handleCaseUpdated(orgId, caseId): Promise<void>.
- Step 1: DTO — add
'case_updated'toSalesforceCaseEventType. - Step 2: dispatch — new case in
dispatchWebhook, same fire-and-forget shape asowner_changed, message'Case updated event received'. - Step 3: handler
const CASE_FIELDS_REFRESH_LOCK_TTL_SECONDS = 60; next to the other consts; import RedisLock from #utils/redis-lock.ts — check waitForRelease’s exact parameter names in backend/src/utils/redis-lock.ts before writing.)
- Step 4: stale-key clearing in
syncSalesforceFieldsToCustomData— always read the prior session bag (drop thenotifyOnFieldChange ?guard around it), then:
sf_contact_* on the contact merge (contacts_repo_mergeCustomData({... clearNullish: true}) with the stale contact keys nulled).
- Step 5:
notifyAllowedFieldsChanged— iteratenew Set([...Object.keys(filteredBefore ?? {}), ...Object.keys(filteredAfter ?? {})]); return early only when both are empty. - Step 6: docs — Apex class: add
sendCaseUpdatedEvents(clone ofsendCaseOwnerChangedEvents,&type=case_updated). Trigger list: “seven triggers”; add 7. Case trigger (any field change):
sendCaseUpdatedEvents to the test class alongside the others.
- Step 7: tests (fake SF server per spec: oauth token,
/id/,sobjects/Case/describewith a small field list,GET sobjects/Case/{id}returningthis.records.get(id),PATCH204,queryUser/Case). Drive throughSalesforceCaseService.handleWebhook(token, {caseId, event_type:'case_updated'})wheretoken = SalesforceService.createWebhookToken(orgId). Assertcustom_data.sf_case_*,salesforce_fields_updatedrows (whitelist a key first viaAiContextCustomDataFiltersRepo), workflow runs. - Step 8: run, tsgo, lint, format; commit
feat(salesforce): refresh v2 Case fields on the case_updated webhook.
Task 3: session attribute → Case field write-back
Files:- Modify:
backend/src/salesforce-case-integration/salesforce-case.service.ts(syncCustomDataToCase) - Modify:
backend/src/chat-session/repo/merge-custom-data.ts(thesalesforce_v2reflect arm from Task 1’s comment) - Modify:
backend/src/chat-session/service/update-custom-data.ts(salesforce_v2arm) - Test:
backend/src/salesforce-case-integration/__tests__/custom-data-merge-pushes-sf-case-keys-to-case.spec.ts - Test:
backend/src/salesforce-case-integration/__tests__/editable-custom-data-edit-pushes-to-case.spec.ts
SalesforceCaseService.syncCustomDataToCase({ orgId, sessionId, changed: Record<string, string|number|boolean> }): Promise<void> (never throws).
- Step 1: service
- Step 2: reflect arms — merge repo (Task 1 comment → real code);
update-custom-data.ts: after the hubspot branch addelse if (ticketingSystem === 'salesforce_v2')computingchangedexactly like the hubspot branch and callingSalesforceCaseService.syncCustomDataToCase({ orgId: session.copilot_id, sessionId, changed })(lazy import —chat-session/servicemust not statically import the SF service). - Step 3: tests — (a)
merge-ticket-custom-dataaction or directchatSession_repo_mergeCustomData({customData:{sf_case_Priority:'High', sf_case_Nope:'x', sf_case_OwnerId:'005…'}})→ exactly onePATCH sobjects/Case/{id}whose body is{"Priority":"High"}; v1 org → no PATCH;reflectOnIntegration:false→ no PATCH; boolean/number coercion. (b)chatSession_service_updateCustomData({sessionId, body:{editable_custom_data:{Priority:'Low'}}})→ PATCH{"Priority":"Low"}, unchanged re-save → no second PATCH. - Step 4: run, tsgo, lint, format; commit
feat(salesforce): push session attribute changes to the v2 Case.
Task 4: agent mapping, assignee sync, queue → team
Files:- Create:
backend/src/db/kysely-migrations/20260816090000_add_salesforce_user_mapping.ts - Create:
backend/src/db/kysely-migrations/20260816090001_add_salesforce_user_mapping_index.ts(noTransaction, CONCURRENTLY) - Create:
backend/src/db/kysely-migrations/20260816090002_add_salesforce_escalation_teams_group_id.ts - Modify:
backend/src/db/opencx.ts(codegen:ChatbotUsers.salesforce_user_id: string | null,SalesforceEscalationTeams.group_id: string | null) - Create:
backend/src/salesforce-case-integration/salesforce-agent-mapping.service.ts - Create:
backend/src/salesforce-case-integration/salesforce-assignee-sync.service.ts - Modify:
backend/src/salesforce-case-integration/salesforce-case-sdk.ts(getUserById,findActiveUsersByEmail) - Modify:
backend/src/salesforce-case-integration/salesforce-case.service.ts(handleOwnerChanged) - Modify:
backend/src/chat/chat-integration.service.ts(SALESFORCE_V_2arm) - Modify:
backend/src/salesforce-case-integration/dto/salesforce-escalation-team.dto.ts,salesforce-escalation-teams.service.ts(group_id+resolveGroupIdForQueue) - Modify:
docs/integrations/salesforce/email-cases.mdx(short “Assignee and queue sync” section) - Tests:
salesforce-agent-mapping-links-by-email-and-isolates-orgs.spec.ts,owner-changed-assigns-mapped-teammate-when-sync-from-on.spec.ts,owner-changed-integration-user-assigns-ai.spec.ts,owner-changed-queue-owner-routes-to-mapped-team.spec.ts,owner-changed-legacy-behavior-when-sync-from-off.spec.ts,assignee-change-writes-case-owner-when-sync-to-on.spec.ts,assignee-echo-guard-suppresses-own-owner-write.spec.ts,salesforce-escalation-teams-group-id.spec.ts
-
SalesforceAgentMappingService.resolveOpenCxUserId({orgId, salesforceUser}) → number|null,resolveSalesforceUserId({orgId, openCxUserId}) → string|null. -
SalesforceAssigneeSyncService.syncAssigneeWithSalesforce({orgId, sessionId, openCxAssigneeId}),.syncAssigneeFromSalesforce({orgId, caseId, salesforceOwnerId, integrationUserId}) → {sessionId|null},.isInboundEnabled(orgId),.consumeOwnerEcho(orgId, caseId, ownerId) → boolean. -
SalesforceEscalationTeamsService.resolveGroupIdForQueue(orgId, queueId) → string|null. -
Redis keys:
salesforce_v2:case:assignee-sync:ignore:{caseId}(inbound arms / outbound consumes),salesforce_v2:case:owner-echo:{caseId}= ownerId (outbound arms / inbound consumes). TTL 15s. -
Step 1: migrations (shapes from spec §5D;
group_id character varying(36) REFERENCES public.groups(id) ON DELETE SET NULL, index on(org_id, group_id)), apply withNODE_ENV=test ./node_modules/.bin/tsx src/db/migrator.ts up, then codegen:DATABASE_URL=postgres://postgres:postgres@localhost:5432/salesforce_v2_two_way_sync ./node_modules/.bin/kysely-codegen --runtime-enums --include-pattern 'public.*' --out-file=src/db/opencx.ts && ./node_modules/.bin/oxfmt src/db/opencx.ts(verify the diff is exactly the two columns). -
Step 2: SDK —
getUserById(orgId, userId)(SELECT Id, Name, Email, Username FROM User WHERE Id = '…' LIMIT 1),findActiveUsersByEmail(orgId, email)(… WHERE IsActive = true AND Email = '<escaped>' LIMIT 2, escape viaSalesforceCrmUtils.escapeSoqlValue). -
Step 3: mapping service — the two resolvers + private
findMappedOpenCxUserId, persist NULL-only, tolerate23505(code from #2223 minus identity/avatar). -
Step 4: assignee sync service — Zendesk shape (
getOrgSyncSettings,isInboundEnabled,isOutboundEnabled,consumeOwnerEcho,syncAssigneeWithSalesforce,syncAssigneeFromSalesforce); actor{ type: 'integration' }; lazy-importchange-assignee.ts/assign-session-to-ai.ts/assign-to-team.ts. -
Step 5:
handleOwnerChanged— per spec §5D: echo consume first;HANDED_OFFearly return only when inbound sync is off; handoff write keeps a human assignee for a Queue owner; thensyncAssigneeFromSalesforce; existing field sync at the end. -
Step 6:
syncAssigneeToThirdPartyarm —case TicketingSystemEnum.SALESFORCE_V_2:lazy import + call; remove it from the no-op list. -
Step 7: escalation teams
group_id— DTO fields (group_id: z.string().nullable()on read;.nullable().optional()on create/update), service validates the group belongs to the org and is not deleted (elseBadRequestException),resolveGroupIdForQueue(15/18-char tolerant,enabled,group_id IS NOT NULL, exactly one, groupdeleted_at IS NULL). -
Step 8: docs section; Step 9: tests; Step 10: run, tsgo, lint, format; commit
feat(salesforce): sync Case owner with session assignee and map queues to teams.
Task 5: SDK regen, full touched-spec run, PR
-
cd backend && NODE_ENV=development CI_CHAT_AGENT_VERSION= node --experimental-transform-types scripts/dump-openapi-spec.tsthen regeneratedashboard/packages/sdk/src/schema.tswithopenapi-typescript(from an installed copy) — commit only if the diff is the enum literal(s) +group_id/salesforce_user_idDTO lines; otherwise hand-edit those lines. - Re-run every spec touched or added; run
salesforce-owner-and-assignee-stay-in-sync.spec.ts,sync-diff-notifies-ai-context-allowed-field-changes.spec.ts,salesforce-case-custom-object-sync.spec.ts,webhook-event-type-mapping.spec.ts,salesforce-escalation-teams.spec.ts,contact-updated-adjacent specs unchanged. -
git log --name-only origin/main..HEADsanity (no vendored dirs), push,gh pr createwith a short imperative body; link the spec; note #2229/#2223 supersession.