Skip to main content

Salesforce v2 Two-Way Sync Implementation Plan

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.
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 edit backend/src/salesforce-integration/**, MoneyGram code, or the v1 arms listed in the spec §7).
  • No any, no as casts (except as const), no non-null !; Zod DTOs bound with satisfies 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 DB salesforce_v2_two_way_sync on the throwaway container opencx-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 (add TICKET_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.
Interfaces:
  • 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? }. customData values may be null when clearNullish is true (a null clears the key).
  • Step 1: trigger definition
  • Step 2: enum + registry — add TICKET_UPDATED = 'ticket-updated', after TICKET_TEAM_CHANGED; in index.ts import ticketUpdatedTrigger, add it after ticketTeamChangedTrigger in the registration array and in the export list.
  • Step 3: merge repo — replace backend/src/chat-session/repo/merge-custom-data.ts with:
(Keep the exact existing intercom/hubspot reflection code; only wrap the block in 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: teststicket-updated.trigger.spec.ts (pattern: ticket-reopened.trigger.spec.ts): (a) triggerByType SYNC round-trips changedFields/previousValues; (b) e2e: chatSession_repo_mergeCustomData({customData:{foo:'bar'}}) on an org with a ticket-updated capture 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 shape changedFields, 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 (dispatchWebhook case; handleCaseUpdated; syncSalesforceFieldsToCustomData stale-key clearing; notifyAllowedFieldsChanged union keys)
  • Modify: backend/src/salesforce-case-integration/salesforce-case.controller.ts docstring 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
Interfaces: Produces SalesforceCaseService.handleCaseUpdated(orgId, caseId): Promise<void>.
  • Step 1: DTO — add 'case_updated' to SalesforceCaseEventType.
  • Step 2: dispatch — new case in dispatchWebhook, same fire-and-forget shape as owner_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 the notifyOnFieldChange ? guard around it), then:
Same for sf_contact_* on the contact merge (contacts_repo_mergeCustomData({... clearNullish: true}) with the stale contact keys nulled).
  • Step 5: notifyAllowedFieldsChanged — iterate new Set([...Object.keys(filteredBefore ?? {}), ...Object.keys(filteredAfter ?? {})]); return early only when both are empty.
  • Step 6: docs — Apex class: add sendCaseUpdatedEvents (clone of sendCaseOwnerChangedEvents, &type=case_updated). Trigger list: “seven triggers”; add 7. Case trigger (any field change):
Prose: refreshes the session’s Salesforce attributes and fires the Ticket Updated workflow trigger; keep triggers 4/5 for owner/close semantics. Add sendCaseUpdatedEvents to the test class alongside the others.
  • Step 7: tests (fake SF server per spec: oauth token, /id/, sobjects/Case/describe with a small field list, GET sobjects/Case/{id} returning this.records.get(id), PATCH 204, query User/Case). Drive through SalesforceCaseService.handleWebhook(token, {caseId, event_type:'case_updated'}) where token = SalesforceService.createWebhookToken(orgId). Assert custom_data.sf_case_*, salesforce_fields_updated rows (whitelist a key first via AiContextCustomDataFiltersRepo), 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 (the salesforce_v2 reflect arm from Task 1’s comment)
  • Modify: backend/src/chat-session/service/update-custom-data.ts (salesforce_v2 arm)
  • 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
Interfaces: Produces 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 add else if (ticketingSystem === 'salesforce_v2') computing changed exactly like the hubspot branch and calling SalesforceCaseService.syncCustomDataToCase({ orgId: session.copilot_id, sessionId, changed }) (lazy import — chat-session/service must not statically import the SF service).
  • Step 3: tests — (a) merge-ticket-custom-data action or direct chatSession_repo_mergeCustomData({customData:{sf_case_Priority:'High', sf_case_Nope:'x', sf_case_OwnerId:'005…'}}) → exactly one PATCH 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_2 arm)
  • 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
Interfaces:
  • 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 with NODE_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: SDKgetUserById(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 via SalesforceCrmUtils.escapeSoqlValue).
  • Step 3: mapping service — the two resolvers + private findMappedOpenCxUserId, persist NULL-only, tolerate 23505 (code from #2223 minus identity/avatar).
  • Step 4: assignee sync service — Zendesk shape (getOrgSyncSettings, isInboundEnabled, isOutboundEnabled, consumeOwnerEcho, syncAssigneeWithSalesforce, syncAssigneeFromSalesforce); actor { type: 'integration' }; lazy-import change-assignee.ts / assign-session-to-ai.ts / assign-to-team.ts.
  • Step 5: handleOwnerChanged — per spec §5D: echo consume first; HANDED_OFF early return only when inbound sync is off; handoff write keeps a human assignee for a Queue owner; then syncAssigneeFromSalesforce; existing field sync at the end.
  • Step 6: syncAssigneeToThirdParty armcase 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 (else BadRequestException), resolveGroupIdForQueue (15/18-char tolerant, enabled, group_id IS NOT NULL, exactly one, group deleted_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.ts then regenerate dashboard/packages/sdk/src/schema.ts with openapi-typescript (from an installed copy) — commit only if the diff is the enum literal(s) + group_id/salesforce_user_id DTO 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..HEAD sanity (no vendored dirs), push, gh pr create with a short imperative body; link the spec; note #2229/#2223 supersession.