From 6b7e254e56828f3704623f47b199d23cb06e519b Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sun, 4 Oct 2026 08:49:30 +0200 Subject: [PATCH 1/2] feat: add native Salesforce sync with Lead and Contact matching and links, a leased activity outbox that logs sends, replies, bounces, opt-outs and meetings as Tasks, Lead Status and Email Opt Out writeback, a pull loop with CRM pause rules, list view and Campaign imports, sandbox and My Domain OAuth that refreshes expired sessions, a Salesforce settings page with contact and inbox cards in web, and docs --- cmd/backend/main.go | 24 + cmd/consumer/main.go | 11 + docs/content/docs/api/endpoints.mdx | 9 + docs/content/docs/api/error-codes.mdx | 15 + .../docs/api/reference/integrations.mdx | 32 +- .../docs/development/configuration.mdx | 2 + docs/content/docs/guides/automations.mdx | 2 +- docs/content/docs/guides/integrations.mdx | 5 +- docs/content/docs/guides/meta.json | 1 + docs/content/docs/guides/salesforce.mdx | 155 +++ .../docs/guides/workspace-export-import.mdx | 3 +- internal/api/handler/handler.go | 5 + internal/api/handler/integration.go | 12 +- internal/api/handler/salesforce.go | 509 ++++++++++ internal/api/routes.go | 25 + internal/app/contact/campaign_state.go | 6 + internal/app/integration/catalog.go | 20 +- internal/app/integration/dispatch.go | 17 + internal/app/integration/oauth.go | 109 ++- internal/app/integration/push.go | 42 + internal/app/integration/service.go | 224 ++++- internal/app/orgtransfer/spec.go | 14 + internal/app/salesforce/client.go | 578 +++++++++++ internal/app/salesforce/client_test.go | 50 + internal/app/salesforce/drain.go | 755 ++++++++++++++ internal/app/salesforce/importer.go | 520 ++++++++++ internal/app/salesforce/link.go | 860 ++++++++++++++++ internal/app/salesforce/panel.go | 377 +++++++ internal/app/salesforce/pull.go | 384 ++++++++ internal/app/salesforce/recorder.go | 295 ++++++ internal/app/salesforce/service.go | 737 ++++++++++++++ internal/app/salesforce/settings.go | 440 +++++++++ internal/app/salesforce/sync_test.go | 249 +++++ internal/app/webhook/record_sinks_test.go | 26 + internal/app/webhook/service.go | 15 +- .../000255_salesforce_native_sync.down.sql | 13 + .../000255_salesforce_native_sync.up.sql | 132 +++ internal/models/contact.go | 8 +- internal/models/integration.go | 11 +- internal/models/integration_capability.go | 4 +- internal/models/salesforce.go | 140 +++ internal/repository/pg_campaign_progress.go | 11 + internal/repository/pg_integration.go | 37 +- internal/repository/pg_salesforce.go | 924 ++++++++++++++++++ .../_components/ConnectDrawer.tsx | 63 +- .../_components/ConnectionDetail.tsx | 19 +- web/src/app/app/integrations/page.tsx | 7 +- .../app/integrations/salesforce/[id]/page.tsx | 415 ++++++++ .../salesforce/_components/ActivityLogTab.tsx | 376 +++++++ .../_components/FieldMappingTab.tsx | 369 +++++++ .../salesforce/_components/ImportDialog.tsx | 685 +++++++++++++ .../salesforce/_components/ImportTab.tsx | 289 ++++++ .../salesforce/_components/OverviewTab.tsx | 265 +++++ .../salesforce/_components/SyncRulesTab.tsx | 503 ++++++++++ .../salesforce/_components/shared.tsx | 399 ++++++++ .../salesforce/_components/util.ts | 32 + .../app/contacts/contact-edit/ActivityTab.tsx | 4 + .../app/contacts/contact-edit/OverviewTab.tsx | 3 + .../integrations/SalesforceContactCard.tsx | 450 +++++++++ .../app/segments/SegmentPickers.tsx | 1 + .../app/unibox/ContactContextPanel.tsx | 3 + web/src/hooks/useDocumentTitle.ts | 1 + web/src/hooks/useRealtimeEvents.ts | 6 +- .../api/client/app/integrations/salesforce.ts | 230 +++++ .../app/integrations/startIntegrationOAuth.ts | 3 + .../hooks/app/integrations/useSalesforce.ts | 253 +++++ .../lib/api/models/app/automations/meta.ts | 2 +- .../lib/api/models/app/contacts/Contact.ts | 11 +- .../api/models/app/contacts/ContactDetail.ts | 1 + .../api/models/app/integrations/Salesforce.ts | 358 +++++++ web/src/main.tsx | 5 + 71 files changed, 12494 insertions(+), 67 deletions(-) create mode 100644 docs/content/docs/guides/salesforce.mdx create mode 100644 internal/api/handler/salesforce.go create mode 100644 internal/app/salesforce/client.go create mode 100644 internal/app/salesforce/client_test.go create mode 100644 internal/app/salesforce/drain.go create mode 100644 internal/app/salesforce/importer.go create mode 100644 internal/app/salesforce/link.go create mode 100644 internal/app/salesforce/panel.go create mode 100644 internal/app/salesforce/pull.go create mode 100644 internal/app/salesforce/recorder.go create mode 100644 internal/app/salesforce/service.go create mode 100644 internal/app/salesforce/settings.go create mode 100644 internal/app/salesforce/sync_test.go create mode 100644 internal/app/webhook/record_sinks_test.go create mode 100644 internal/infrastructure/db/migrations/000255_salesforce_native_sync.down.sql create mode 100644 internal/infrastructure/db/migrations/000255_salesforce_native_sync.up.sql create mode 100644 internal/models/salesforce.go create mode 100644 internal/repository/pg_salesforce.go create mode 100644 web/src/app/app/integrations/salesforce/[id]/page.tsx create mode 100644 web/src/app/app/integrations/salesforce/_components/ActivityLogTab.tsx create mode 100644 web/src/app/app/integrations/salesforce/_components/FieldMappingTab.tsx create mode 100644 web/src/app/app/integrations/salesforce/_components/ImportDialog.tsx create mode 100644 web/src/app/app/integrations/salesforce/_components/ImportTab.tsx create mode 100644 web/src/app/app/integrations/salesforce/_components/OverviewTab.tsx create mode 100644 web/src/app/app/integrations/salesforce/_components/SyncRulesTab.tsx create mode 100644 web/src/app/app/integrations/salesforce/_components/shared.tsx create mode 100644 web/src/app/app/integrations/salesforce/_components/util.ts create mode 100644 web/src/components/app/integrations/SalesforceContactCard.tsx create mode 100644 web/src/lib/api/client/app/integrations/salesforce.ts create mode 100644 web/src/lib/api/hooks/app/integrations/useSalesforce.ts create mode 100644 web/src/lib/api/models/app/integrations/Salesforce.ts diff --git a/cmd/backend/main.go b/cmd/backend/main.go index cff86b320..d779d0e69 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -92,6 +92,7 @@ import ( "github.com/warmbly/warmbly/internal/app/releases" "github.com/warmbly/warmbly/internal/app/replyclassify" "github.com/warmbly/warmbly/internal/app/research" + "github.com/warmbly/warmbly/internal/app/salesforce" "github.com/warmbly/warmbly/internal/app/segment" "github.com/warmbly/warmbly/internal/app/sendingdomain" "github.com/warmbly/warmbly/internal/app/sequence" @@ -348,6 +349,7 @@ func main() { var attachmentRepoForHandler repository.AttachmentRepository var emailImageRepoForHandler repository.EmailImageRepository var leadSyncServiceForHandler leadsync.Service + var salesforceServiceForHandler *salesforce.Service ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -1584,6 +1586,25 @@ func main() { // The AI switch's optional web search shares the same pluggable backend as // the campaign switch and dashboard agent. integrationServiceForHandler.SetAISearch(aiSearch) + + // Native Salesforce sync: events are recorded ahead of the webhook + // throttle, and the loops log them as Tasks, pull changes back and run + // recurring list-view imports. + salesforceServiceForHandler = salesforce.NewService(salesforce.Deps{ + Repo: repository.NewSalesforceRepository(primaryDB.Pool), + Integrations: integrationServiceForHandler, + Cipher: cipherService, + Contacts: contactService, + Holds: campaignProgressRepository, + Suppression: advancedRepository, + Subscription: contactRepostory, + }) + integrationServiceForHandler.SetSalesforce(salesforceServiceForHandler) + webhookService.WireRecordSink(salesforceServiceForHandler.Recorder().Record) + go jobrun.Loop(ctx, "salesforce_activity_drain", 30*time.Second, true, salesforceServiceForHandler.Drain) + go jobrun.Loop(ctx, "salesforce_pull", 5*time.Minute, false, salesforceServiceForHandler.Pull) + go jobrun.Loop(ctx, "salesforce_recurring_imports", 5*time.Minute, false, salesforceServiceForHandler.RunRecurring) + go jobrun.Loop(ctx, "salesforce_activity_prune", 24*time.Hour, false, salesforceServiceForHandler.Prune) // Port reply-classifier Layer 3 onto the platform provider (OpenAI-first, // self-hostable). Platform-paid, never charged to org credits. Nil provider // leaves Layer 3 disabled (the ambiguous middle resolves to "unknown"). @@ -2344,6 +2365,9 @@ func main() { // On-demand Google Sheets -> leads sync LeadSyncService: leadSyncServiceForHandler, + // Native Salesforce sync + SalesforceService: salesforceServiceForHandler, + WebsocketURI: websocketURI, // Object storage + direct repository handles for handlers diff --git a/cmd/consumer/main.go b/cmd/consumer/main.go index b30fb2066..17bfbaefe 100644 --- a/cmd/consumer/main.go +++ b/cmd/consumer/main.go @@ -35,6 +35,7 @@ import ( "github.com/warmbly/warmbly/internal/app/notification" "github.com/warmbly/warmbly/internal/app/opsnotify" "github.com/warmbly/warmbly/internal/app/replyclassify" + "github.com/warmbly/warmbly/internal/app/salesforce" warmupapp "github.com/warmbly/warmbly/internal/app/warmup" "github.com/warmbly/warmbly/internal/app/webhook" workerapp "github.com/warmbly/warmbly/internal/app/worker" @@ -240,6 +241,16 @@ func main() { integrationRepoC := repository.NewIntegrationRepository(primaryDB.Pool) integrationServiceC := integration.NewService(integrationRepoC, cipherService, integration.NewOAuthManager()) webhookService.WireDispatchSink(integrationServiceC.DispatchAny) + // Replies, opens and clicks are raised here, so the Salesforce outbox + // records them here too; the backend drains it. Automation upserts take + // the native path as well. + salesforceC := salesforce.NewService(salesforce.Deps{ + Repo: repository.NewSalesforceRepository(primaryDB.Pool), + Integrations: integrationServiceC, + Cipher: cipherService, + }) + integrationServiceC.SetSalesforce(salesforceC) + webhookService.WireRecordSink(salesforceC.Recorder().Record) // AI automation nodes + reply-classifier Layer 3 run in THIS process (reply / // warmup / bounce events dispatch here). Build the credit ledger + provider so // the ai_step / ai_switch nodes can charge + call, and so the classifier's diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 99681bb5a..15e5e9752 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -417,6 +417,15 @@ Registering and managing the OAuth apps your workspace owns. The flow itself (au | POST | `/webhooks/deliveries/:deliveryId/redeliver` | `WEBHOOKS` | | GET | `/webhooks/throttle-drops` | `WEBHOOKS` | | GET/POST/DELETE | `/integrations/*` (except `/integrations/slack/*`, which is [JWT only](#slack)) | `INTEGRATIONS` | +| GET/PUT | `/integrations/salesforce/:id/settings` | `INTEGRATIONS` | +| GET | `/integrations/salesforce/:id/overview`, `/metadata`, `/users`, `/list-views`, `/campaigns`, `/activity` | `INTEGRATIONS` | +| POST | `/integrations/salesforce/:id/import/preview` | `INTEGRATIONS` | +| GET/POST/PATCH/DELETE | `/integrations/salesforce/:id/import-sources[/:sourceId]` | `INTEGRATIONS` | +| POST | `/integrations/salesforce/:id/import-sources/:sourceId/run` | `INTEGRATIONS` | +| POST | `/integrations/salesforce/:id/activity/retry`, `/sync-now` | `INTEGRATIONS` | +| GET | `/contacts/:id/salesforce` | `READ_CRM` | +| POST | `/contacts/:id/salesforce/sync` | `INTEGRATIONS` | +| DELETE | `/contacts/:id/salesforce/links/:linkId` | `INTEGRATIONS` | | GET/POST/PATCH/DELETE | `/automations[/:id]` | `INTEGRATIONS` | | PATCH | `/automations/:id/layout` | `INTEGRATIONS` | | GET/POST/PATCH/DELETE | `/warmup/routing[/:id]` | `WARMUP_ROUTING` | diff --git a/docs/content/docs/api/error-codes.mdx b/docs/content/docs/api/error-codes.mdx index 90f1a2361..7bcc57d98 100644 --- a/docs/content/docs/api/error-codes.mdx +++ b/docs/content/docs/api/error-codes.mdx @@ -301,6 +301,21 @@ Returned by the CRM endpoints while a workspace runs its CRM on [HubSpot](/guide } ``` +#### Salesforce sync refusals + +The [Salesforce](/guides/salesforce/) endpoints answer with their own codes. When Salesforce itself refused, `message` carries its `errorCode` and text, such as `Salesforce: INVALID_FIELD: No such column 'Tier__c' on entity 'Lead'`. + +| `code` | Status | Meaning | +|--------|--------|---------| +| `invalid_salesforce_domain` | 400 | The custom domain given to `POST /integrations/oauth/start` is not a Salesforce My Domain (`*.my.salesforce.com`) | +| `invalid_salesforce_settings` | 400 | `PUT /integrations/salesforce/:id/settings` named something the sync cannot run: an unknown field, a related field to write, an engagement field to read, a field mapped twice, or a fixed owner with no user | +| `salesforce_error` | 400 | Salesforce refused the request. The message is Salesforce's own | +| `salesforce_unreachable` | 400 | Salesforce did not answer. Retry later | +| `salesforce_sync_off` | 400 | `POST /integrations/salesforce/:id/sync-now` on a connection with sync turned off | +| `salesforce_reconnect_required` | 409 | The Salesforce session ended (a revoked app, a password reset, a deactivated user). Reconnect the integration | +| `import_running` | 409 | `POST /integrations/salesforce/:id/import-sources/:sourceId/run` while that import is already running | +| `salesforce_rate_limited` | 429 | The Salesforce org has used its API requests for today | + ### 401 Unauthorized Returned when authentication fails. diff --git a/docs/content/docs/api/reference/integrations.mdx b/docs/content/docs/api/reference/integrations.mdx index 8da268263..bb4585067 100644 --- a/docs/content/docs/api/reference/integrations.mdx +++ b/docs/content/docs/api/reference/integrations.mdx @@ -493,7 +493,7 @@ Auth: **Scope** `INTEGRATIONS` · **Org permission** `manage_settings`. `POST /integrations/connections/:id/push` -Synchronously upserts the given org contacts into a connected CRM (HubSpot, Pipedrive, Salesforce, Close). Retries are naturally safe: every provider upsert is keyed by email, so a repeated push converges rather than duplicating records. No `Idempotency-Key` is required. Per-record results are returned. +Synchronously upserts the given org contacts into a connected CRM (HubSpot, Pipedrive, Salesforce, Close). Retries are naturally safe: every provider upsert is keyed by email, so a repeated push converges rather than duplicating records. No `Idempotency-Key` is required. Per-record results are returned. For Salesforce, each contact is matched to its Lead or Contact, created as the connection's **Create as** when missing, and updated through its [field rules](/guides/salesforce/#field-mapping). Auth: **Scope** `INTEGRATIONS` · **Org permission** `use_integrations`. @@ -533,6 +533,36 @@ Auth: **Scope** `INTEGRATIONS` · **Org permission** `use_integrations`. A connection whose token can no longer be refreshed returns `409 Conflict` with a "needs to be reconnected" message. +## Salesforce sync + +Every route below takes the Salesforce connection id as `:id` and is scoped to the caller's organization. See the [Salesforce guide](/guides/salesforce/) for what each setting does. + +| Method | Path | Org permission | Purpose | +|--------|------|----------------|---------| +| GET | `/integrations/salesforce/:id/overview` | read | Health, API usage and budget, outbox counts, last pull. `?checks=1` also probes what the connected user can create and edit | +| GET | `/integrations/salesforce/:id/settings` | read | `{ settings, warmbly_fields, defaults }` | +| PUT | `/integrations/salesforce/:id/settings` | `manage_settings` | Replace the settings. A whole-document write, so a retry converges | +| GET | `/integrations/salesforce/:id/metadata` | read | Lead and Contact fields, Lead Status and Lead Source picklists | +| GET | `/integrations/salesforce/:id/users?q=` | read | Active Salesforce users, for owner pickers | +| GET | `/integrations/salesforce/:id/list-views?object=Lead\|Contact` | read | List views the connected user can see | +| GET | `/integrations/salesforce/:id/campaigns?q=` | read | Salesforce Campaigns | +| POST | `/integrations/salesforce/:id/import/preview` | `use_integrations` | `{ source_kind, object, source_id }` to `{ total, sample }`. Writes nothing | +| GET | `/integrations/salesforce/:id/import-sources` | read | Saved imports with their last result | +| POST | `/integrations/salesforce/:id/import-sources` | `manage_settings` | Save an import and start its first run | +| PATCH | `/integrations/salesforce/:id/import-sources/:sourceId` | `manage_settings` | Edit name, `campaign_id` (`null` clears it), `category_ids`, `recurring`, `enabled` | +| POST | `/integrations/salesforce/:id/import-sources/:sourceId/run` | `use_integrations` | Run now. `409 import_running` while one is running | +| DELETE | `/integrations/salesforce/:id/import-sources/:sourceId` | `manage_settings` | Remove the import. Contacts it brought in stay | +| GET | `/integrations/salesforce/:id/activity?status=&contact_id=&cursor=&limit=` | read | The activity log, newest first, `{ data, pagination }` | +| POST | `/integrations/salesforce/:id/activity/retry` | `manage_settings` | `{ ids }` re-queues those failed or skipped rows; no ids re-queues every failed row | +| POST | `/integrations/salesforce/:id/sync-now` | `use_integrations` | Log pending activity and pull changes now | +| GET | `/contacts/:id/salesforce` | `view_contacts` | The contact's Salesforce card: linked records, owner, status, opportunities, recent Tasks, sync state | +| POST | `/contacts/:id/salesforce/sync` | `use_integrations` | `{ connection_id?, create_as? }`: match (or create as `lead` or `contact`) and push mapped fields; returns the card | +| DELETE | `/contacts/:id/salesforce/links/:linkId` | `use_integrations` | Unlink the contact from a record. Nothing changes in Salesforce | + +"read" is any of `manage_settings` or `use_integrations`. API keys need the `INTEGRATIONS` permission, except `GET /contacts/:id/salesforce`, which shows opportunities and needs `READ_CRM`. + +Creating an import is not deduplicated by request: a retried create saves a second import, whose run brings in nobody new because people are matched by address. + ## List meeting bookings (integrations view) `GET /integrations/bookings` diff --git a/docs/content/docs/development/configuration.mdx b/docs/content/docs/development/configuration.mdx index 16f3cbfe3..0eded7db5 100644 --- a/docs/content/docs/development/configuration.mdx +++ b/docs/content/docs/development/configuration.mdx @@ -536,6 +536,8 @@ The repository's `hubspot-app/` directory is the same app as a HubSpot developer | `POST /api/v1/integrations/hubspot/actions/enroll` | The "Add to Warmbly campaign" workflow action | | `POST /api/v1/integrations/hubspot/actions/campaigns` | The workflow action's campaign list | +Salesforce reads `SALESFORCE_OAUTH_CLIENT_ID` and `SALESFORCE_OAUTH_CLIENT_SECRET`, from an External Client App or Connected App in your own org with PKCE required and the `api`, `refresh_token` and `id` scopes. One app serves production, sandbox and My Domain logins; each member picks theirs when connecting. See [Salesforce self-hosting](/guides/salesforce/#self-hosting). + ## AI and search | Variable | What it does | Default | diff --git a/docs/content/docs/guides/automations.mdx b/docs/content/docs/guides/automations.mdx index 7f2dc5964..74eaca8d9 100644 --- a/docs/content/docs/guides/automations.mdx +++ b/docs/content/docs/guides/automations.mdx @@ -91,7 +91,7 @@ Each action node picks a **Run** target (an integration, or Warmbly built-in) an | --- | --- | | Send a Slack / Discord message | Posts to a channel (Slack needs `#channel`) | | Send a webhook | HTTP request to a URL you provide | -| Create / update HubSpot contact, Pipedrive person, Salesforce contact, Close lead | Upserts the record. In [HubSpot mode](/guides/hubspot/) contacts already sync on their own, so the HubSpot action is only needed for fields the sync does not map | +| Create / update HubSpot contact, Pipedrive person, Salesforce record, Close lead | Upserts the record. In [HubSpot mode](/guides/hubspot/) contacts already sync on their own, so the HubSpot action is only needed for fields the sync does not map. Salesforce finds the Lead or Contact, or creates one, with the [connection's rules](/guides/salesforce/#how-people-are-matched) | Slack, Discord, and webhook actions take an optional message template. Slack and Discord arrive as a branded card in Warmbly's accent color with contact and subject fields, not a plain line. diff --git a/docs/content/docs/guides/integrations.mdx b/docs/content/docs/guides/integrations.mdx index 324e24ff6..07f5aa179 100644 --- a/docs/content/docs/guides/integrations.mdx +++ b/docs/content/docs/guides/integrations.mdx @@ -12,7 +12,7 @@ The Integrations page is a searchable directory with your existing connections a | Provider | Category | Connect | What it does | | --- | --- | --- | --- | | HubSpot | CRM | one-click OAuth | Create or update a contact, log the reply as a note, or run the workspace's whole CRM on HubSpot ([HubSpot mode](/guides/hubspot/)) | -| Salesforce | CRM | one-click OAuth | Upsert a contact on reply or on demand | +| Salesforce | CRM | one-click OAuth (production, sandbox or My Domain) | Two-way sync: activity on the timeline, Lead Status writeback, opt-outs both ways, list view and Campaign imports. See [Salesforce](/guides/salesforce/) | | Pipedrive | CRM | one-click OAuth | Upsert a person on reply or on demand | | Close | CRM | API key | Upsert a lead on reply or on demand | | Zapier, Make, n8n | Automation | Warmbly API key | Fan events to that tool's webhook URL | @@ -67,10 +67,11 @@ A mapping decides how a Warmbly contact projects onto provider fields. Every con | Provider | Default mapping | |----------|-----------------| | HubSpot | `email`, `firstname`, `lastname`, `company`, `phone` | -| Salesforce | `Email`, `FirstName`, `LastName`, `Phone` | | Pipedrive | `name`, `email`, `phone` (a person) | | Close | `name`, `email`, `phone`, `company` (a lead) | +Salesforce has its own field rules, per object and with a direction and a conflict policy; see [Salesforce field mapping](/guides/salesforce/#field-mapping). + Override per connection: each row pairs a Warmbly source field with a destination name and can apply a `uppercase`, `lowercase`, or `trim` transform, or write a fixed `static` value. Custom contact fields are addressed as `custom:your_key`, and a static value can include event variables like `{{.company}}`. Mappings resolve by specificity: provider defaults, then your connection map, then per-automation overrides, with the most specific winning. diff --git a/docs/content/docs/guides/meta.json b/docs/content/docs/guides/meta.json index 1241479b1..e469c317a 100644 --- a/docs/content/docs/guides/meta.json +++ b/docs/content/docs/guides/meta.json @@ -41,6 +41,7 @@ "integrations", "slack", "hubspot", + "salesforce", "zapier", "make", "n8n", diff --git a/docs/content/docs/guides/salesforce.mdx b/docs/content/docs/guides/salesforce.mdx new file mode 100644 index 000000000..706b2baea --- /dev/null +++ b/docs/content/docs/guides/salesforce.mdx @@ -0,0 +1,155 @@ +--- +title: Salesforce +description: "Two-way Salesforce sync: activity on the timeline, Lead Status writeback, do-not-email in step, imports from list views and Campaigns, and Salesforce context on every contact." +--- + +The Salesforce integration keeps your org and Warmbly in step without anyone copying data between them. Campaign emails, replies and meetings land on the Lead or Contact timeline as completed Tasks, a reply can move the Lead Status, an opt-out on either side stops email on both, and every contact drawer and inbox thread shows who owns the person in Salesforce and which deals are open. + +## What you need + +- A Salesforce edition with API access: Enterprise, Unlimited, Performance or Developer, or Professional with the API add-on. Essentials has no API. +- A connecting user with **API Enabled**, read and edit on Leads, Contacts and Tasks, and read on Accounts, Opportunities and Campaigns. Field-level security applies: a field the user cannot edit cannot be written back. +- If your org restricts connected apps, an admin approves the Warmbly app once (**Setup > Connected Apps OAuth Usage**). Since September 2025 Salesforce refuses an app nobody approved unless the user can approve uninstalled apps. + +Warmbly acts as the connecting user. Records it creates and Tasks it logs are owned according to the rules below, but every call is made with that user's access, so a dedicated integration user with the permissions above is the cleanest setup. + +## Connect + +1. Open **Integrations > Salesforce > Connect**. +2. Choose where your org lives: **Production**, **Sandbox**, or **Custom domain** for a My Domain such as `acme.my.salesforce.com`. +3. Sign in to Salesforce and approve access. Warmbly asks for the `api`, `refresh_token` and `id` scopes and nothing else: API access, staying connected, and knowing which user connected. + +You land on the Salesforce page for the connection, where the rest of the setup lives. A new connection starts syncing with safe defaults: it logs sends, replies, bounces, unsubscribes and meetings, never overwrites a filled field, and only creates a Lead when someone replies. A connection made before native sync existed starts with sync off; turn it on from **Overview**. + +Connecting a sandbox and production at the same time is supported; each is its own connection with its own settings. Disconnecting revokes Warmbly's token in Salesforce at once. + + +**Overview > Run permission check** describes Leads, Contacts and Tasks as the connected user and says exactly what is missing, for example "Email Opt Out is not editable for this user, so unsubscribes cannot be written back". + + +## How people are matched + +Every Warmbly contact is linked to at most one record per connected org, by email address: + +- When an address is both a Contact and a Lead, the Contact wins by default. Switch **Matching > Prefer** to Lead for orgs that work Leads first. +- A converted Lead is never a match. When a linked Lead converts, the link follows it to the Contact it became. +- When nobody matches, **Create when** decides: never, when they reply or book a meeting (the default), or on the first logged email. A bounce, an unsubscribe, an open or a click never creates anyone. **Create as** picks Lead or Contact. A connection made before native sync existed keeps creating Contacts, as its upsert action did, until you change it. + +A created record gets the contact's name, company (the email domain when there is none, because Salesforce requires one on a Lead), phone and title, the **Lead Source** you set (Warmbly by default) and the initial **Lead Status** you pick. Its owner is the connected user, the Salesforce user whose email matches the sending mailbox, or a fixed user. Turn on **Run assignment rules** to let your org's lead assignment rules decide instead. Duplicate rules that only alert do not block the save. + +Links are made as activity flows, when a contact's drawer opens, on import, and from **Push to Salesforce** in Contacts. + +## Activity on the timeline + +Each event is logged as a completed Task on the Lead or Contact, with the email icon in the timeline: + +| Event | Subject | Description | Default | +|---|---|---|---| +| Campaign email sent | `Email sent: ` | Campaign, step, from, to, and the email text | on | +| Reply received | `Reply received: ` | From, classified intent, and the reply text | on | +| Bounce | `Email bounced` | Reason | on | +| Unsubscribe or spam complaint | `Unsubscribed from Warmbly outreach` | | on | +| Meeting booked | `Meeting booked: ` | When and the join link | on | +| Open | `Email opened` | | off | +| Click | `Link clicked: ` | The URL | off | + +Opens and clicks are off by default: each one is an API call and a line on the timeline, and a mail client's prefetch is never counted as an open anyway. + +A Contact's Tasks are related to the account's most recently active open opportunity when there is one, so deal reviews see the outreach too. A Task is owned by the record's owner, the sender's Salesforce user, or the connected user. A Lead sitting in a queue falls back to the connected user, since a queue cannot own a Task. + +Every event is recorded the moment it happens and logged in batches of up to 200, normally within a minute. A send is logged once it has actually gone out, so a send that failed and was retried appears once. Each event is logged once even if it is reported twice. Turn **Include the email text** off to log subjects and metadata only. + +## Status writeback + +On a Lead (never a converted one), Warmbly can move Lead Status: + +- when they are first emailed, for example to **Working - Contacted**; +- when they reply, per classified intent: interested, not interested, question, neutral, out of office, or any reply. An out-of-office only moves a Lead when you map it explicitly; +- when they book a meeting. + +With **Never move backwards** on, a Lead already further along the status picklist stays where it is. + +## Do not email, both ways + +With **Opt-out sync** set to both (the default): + +- an unsubscribe or spam complaint in Warmbly sets **Email Opt Out** (`HasOptedOutOfEmail`) on the record; +- **Email Opt Out** set in Salesforce suppresses the address in Warmbly and unsubscribes the contact, so no campaign mails them again. This applies when it changes, when a contact is first linked to an opted-out record, and on import. + +You can restrict it to one direction or turn it off. + +## When Salesforce changes + +Warmbly reads what changed in your org every five minutes and refreshes linked contacts: owner, Lead Status, account, conversion and opt-out. It can also pause outreach, the same hold a member sets on a lead, in every campaign the contact is in: + +- when a Lead is converted; +- when a Lead reaches one of the statuses you pick, such as **Qualified** or **Unqualified**; +- when a new open opportunity appears on a Contact's account, because the deal is already being worked. + +A paused lead shows "Paused by Salesforce" with the reason in its campaign drawer and stays paused until someone resumes it. A rule never replaces a pause a member set, and each change triggers it once, so resuming a lead is not undone by the next pull. + +## Field mapping + +**Field mapping** lists one rule per field, per object: + +| Setting | Options | +|---|---| +| Direction | Warmbly to Salesforce, Salesforce to Warmbly, or two-way | +| Conflict | Always overwrite, or only fill blanks | + +Warmbly fields are first name, last name, company, phone, any contact custom field, and five read-only engagement values you can push into custom fields of your own: last campaign, last emailed, last replied, last reply intent, and outreach status. Salesforce fields come from your org's own field list; formula, roll-up and auto-number fields can only be read. Related fields such as **Account Name** on a Contact are read-only. + +"Only fill blanks" never writes a field Warmbly has not read, so it cannot overwrite a value it never saw. The default rules fill names, phone and company in both directions without overwriting either side. + +## Import from Salesforce + +**Import > New import** brings people in from: + +- a **Lead list view** or **Contact list view**, with its own filters and scope. Views run as the connected user, so "My Leads" means that user's Leads, or +- a **Salesforce Campaign**, taking each member's Lead, or the Contact a converted Lead became. + +Preview shows the count and a sample before anything is written. Choose a Warmbly campaign to enroll them in and tags to apply. People without an email address are counted and skipped, and come in on a later run once they have one. People with **Email Opt Out** set are suppressed instead of imported while opt-out sync reads from Salesforce. Imported contacts are linked to their records and carry a **CRM sync** source. + +Turn on **Keep in sync** and the import re-reads the list every 30 minutes, bringing in only people who are new to it, so a teammate's edits in Warmbly are never overwritten by the next run. One run imports up to 10,000 people; the rest come in on the following runs. + +## Contacts and the inbox + +The contact drawer and every inbox thread show a Salesforce card: the Lead or Contact, owner, Lead Status, account, open opportunities, the latest Salesforce Tasks (Warmbly's own marked), opt-out and conversion badges, and when it last synced. **Open in Salesforce** goes straight to the record. When the person is not in Salesforce yet, **Add to Salesforce** creates them as a Lead or Contact. **Sync now** refreshes the card and pushes mapped fields. + +## Sync health + +- **Overview** shows the connection's health, Warmbly's API calls today against its budget, the org's own daily usage, pending and failed activity, and the last pull. +- **Activity log** lists every event with Salesforce's own error text, such as `REQUIRED_FIELD_MISSING`. Select rows and **Retry**, or **Retry all failed**. +- Transient errors retry with backoff for up to six attempts. A record Salesforce refuses for good is marked failed and not retried until you ask. + +### API budget + +Warmbly caps its own daily calls, a fifth of your org's daily allocation by default, and pauses background work when your org as a whole is above 95% of its allocation. Past either, pending activity waits until the next UTC day rather than competing with your other tools. Set **Daily API budget** to a fixed number to change the cap. Opening a contact's card is never blocked by the budget. + +### Reconnecting + +When Salesforce ends the session (a password reset, a revoked app, a deactivated user), the connection shows **Reconnect** and activity waits. Nothing is lost; it is logged after you reconnect. + +## Automations + +The **Create or update Salesforce record** action in [automations](/guides/automations/) uses the same matching, creation and field rules as the sync, so an automation and the sync never disagree about who someone is. + +## What moves with a workspace export + +Saved imports travel with the Automations group of a [workspace export](/guides/workspace-export-import/). Links between contacts and records, the activity log and pull progress do not: the destination links contacts again by address the first time it syncs, and Tasks already logged are in Salesforce. Reconnect Salesforce on the destination before anything syncs. + +## Self-hosting + +A self-hosted instance needs its own Salesforce app. Which kind depends on how many Salesforce orgs will connect: + +- **Only your own org.** Create an **External Client App** in that org. Salesforce's default for these is **Local**, which works only in the org it was created in, so a sandbox needs one of its own (or gets a copy when it is created or refreshed from that org). +- **Several orgs, or orgs you do not administer.** Use a **Connected App**, which any org can authorize. Since Spring '26, creating one needs Salesforce Support to enable it for your org first; then turn on **Allow creation of connected apps** under **Setup > External Client Apps > Settings**. A packaged External Client App works too, but every org has to install your package before it can connect. + +Whichever you create, configure it with: + +- callback URL `/integrations/oauth/callback`, or your `INTEGRATIONS_OAUTH_REDIRECT_URL`. It must match exactly, including the scheme and no trailing slash; +- OAuth scopes **Manage user data via APIs (api)**, **Perform requests at any time (refresh_token, offline_access)** and **Access the identity URL service (id, profile, email, address, phone)**; +- **Require PKCE** on, and the **Web Server Flow** enabled with its client secret required; +- refresh tokens **valid until revoked**. + +Copy the consumer key and secret into `SALESFORCE_OAUTH_CLIENT_ID` and `SALESFORCE_OAUTH_CLIENT_SECRET` (see [configuration](/development/configuration/#integrations)) and restart the backend. Salesforce can take a few minutes to activate a new app; until then a connect attempt fails with `invalid_client_id`. diff --git a/docs/content/docs/guides/workspace-export-import.mdx b/docs/content/docs/guides/workspace-export-import.mdx index b83396cbc..940cccf8e 100644 --- a/docs/content/docs/guides/workspace-export-import.mdx +++ b/docs/content/docs/guides/workspace-export-import.mdx @@ -103,6 +103,7 @@ Some things belong to an instance rather than to a workspace, so they are not ap | Sends still in flight | A campaign step handed to a worker on the source has no worker on the destination to report back, so it arrives queued and is sent there instead of waiting forever. Steps already sent keep their history | | An invalid workspace name | An archive's workspace name is applied only when it passes the same [naming rules](/api/error-codes/#name-refusals) as a rename. Otherwise the destination keeps its own | | Personal list layouts | Which columns each member shows on the contacts list and how they sort it, and how they arrange the unibox scope rail (Favorites, row order, hidden rows), belongs to the person, not the workspace. Everyone starts from the default view on the new instance and sets it up again | +| Salesforce links and activity | Which Salesforce record each contact is, the activity waiting to be logged and its recent results, and how far the pull loop read. The destination links contacts again by address on its first sync, and activity already logged is in Salesforce. Saved list view and Campaign imports do travel, with the records each one already brought in | | Send plan snapshots | A campaign's precomputed "today's sending plan" is worked out from the campaign, its leads, its mailboxes and the instance's own limits, which all travel. The destination works it out again in the background, so a plan for today is current where the campaign now lives rather than a copy of what the source instance expected | | HubSpot sync queue and pull checkpoints | Writes queued for [HubSpot](/guides/hubspot/) and the record of how far each pull had read belong to the instance doing the work. The destination logs its own activity from the move onward and starts its own pull, which reads HubSpot afresh | @@ -119,7 +120,7 @@ An import runs as one transaction. If anything fails, nothing lands and the work - **Repoint your forms domain too.** A custom forms domain travels with the archive, but its verification does not: the record still points at the old instance. Update the `CNAME`, and the hourly re-check picks it up. Until then form links fall back to the shared host rather than breaking. - **Check campaign schedules.** Per-contact progress travels, so a running campaign resumes at the step it reached rather than restarting. - **Expect the daily send counters to be honoured.** Today's counts come across, so a mailbox cannot double its volume by being migrated mid-day. -- **Re-authorize integrations** if you exported without credentials. +- **Re-authorize integrations** if you exported without credentials. A Salesforce connection syncs nothing until it is reauthorized on the destination; see [Salesforce](/guides/salesforce/#what-moves-with-a-workspace-export). - **Reconnect Slack, then link again.** A Slack install belongs to the Slack app of the instance that made it, and Slack sends that app's messages to that instance only. Reconnect under **Integrations > Slack** on the destination, and each member links their Slack account again (the bot offers a link button the first time they message it). Assistant conversations held in Slack arrive in the dashboard history, where you can continue them. - **Check HubSpot mode.** The mode, its choices and the links between Warmbly and HubSpot records travel, so the destination keeps updating the same HubSpot records instead of creating duplicates. Its token only keeps working when the archive carried credentials and the destination uses the same HubSpot app; otherwise reconnect HubSpot there. Writes still queued on the source do not travel, so let [sync health](/guides/hubspot/#sync-health-and-fixing-errors) show nothing pending before exporting. The destination starts its own pull. - **Rotate each webhook's signing secret.** The secret is encrypted at rest with the source instance's key, so it travels only in an export that carries credentials. Exported without them, the endpoint arrives with no secret and its deliveries will not verify at your receiver. Open each endpoint and use **Rotate secret**, then put the new value in your receiver. diff --git a/internal/api/handler/handler.go b/internal/api/handler/handler.go index 453e5e82e..bdbbc935c 100644 --- a/internal/api/handler/handler.go +++ b/internal/api/handler/handler.go @@ -51,6 +51,7 @@ import ( "github.com/warmbly/warmbly/internal/app/ratelimit" "github.com/warmbly/warmbly/internal/app/referral" "github.com/warmbly/warmbly/internal/app/research" + "github.com/warmbly/warmbly/internal/app/salesforce" "github.com/warmbly/warmbly/internal/app/segment" "github.com/warmbly/warmbly/internal/app/sendingdomain" "github.com/warmbly/warmbly/internal/app/sequence" @@ -300,6 +301,10 @@ type Handler struct { // connection's token to read sheets and the contact import path to upsert. LeadSyncService leadsync.Service + // SalesforceService is the native Salesforce sync: settings, imports, the + // activity log and the contact panel. + SalesforceService *salesforce.Service + // Public websocket URL used by frontend clients WebsocketURI string diff --git a/internal/api/handler/integration.go b/internal/api/handler/integration.go index fa6e79f17..8b743a852 100644 --- a/internal/api/handler/integration.go +++ b/internal/api/handler/integration.go @@ -162,6 +162,10 @@ func (h *Handler) DisconnectIntegration(c *gin.Context) { type oauthStartPayload struct { Provider string `json:"provider"` Label string `json:"label"` + // Environment ("production" or "sandbox") and Domain (a My Domain host) + // choose the Salesforce login server; ignored for other providers. + Environment string `json:"environment"` + Domain string `json:"domain"` } // StartIntegrationOAuth returns the provider authorization URL for the SPA to @@ -181,8 +185,14 @@ func (h *Handler) StartIntegrationOAuth(c *gin.Context) { errx.JSON(c, errx.New(errx.BadRequest, "unknown provider")) return } - resp, err := h.IntegrationService.OAuthStart(c.Request.Context(), orgID, userID, provider, p.Label) + params := map[string]string{"environment": p.Environment, "domain": p.Domain} + resp, err := h.IntegrationService.OAuthStart(c.Request.Context(), orgID, userID, provider, p.Label, params) if err != nil { + var xe *errx.Error + if errors.As(err, &xe) { + errx.JSON(c, xe) + return + } if errors.Is(err, integration.ErrOAuthNotConfigured) { errx.JSON(c, errx.New(errx.NotImplemented, "This provider isn't available yet — OAuth credentials are not configured on the server.")) return diff --git a/internal/api/handler/salesforce.go b/internal/api/handler/salesforce.go new file mode 100644 index 000000000..0469bc086 --- /dev/null +++ b/internal/api/handler/salesforce.go @@ -0,0 +1,509 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/rs/zerolog/log" + + "github.com/warmbly/warmbly/internal/api/middleware" + "github.com/warmbly/warmbly/internal/app/salesforce" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" + "github.com/warmbly/warmbly/internal/utils/paging" +) + +// salesforceReady resolves the org, the actor and the connection id, or +// answers the request itself. +func (h *Handler) salesforceReady(c *gin.Context) (orgID, userID, connID uuid.UUID, ok bool) { + if h.SalesforceService == nil { + errx.JSON(c, errx.New(errx.ServiceUnavailable, "Salesforce sync is not available on this instance")) + return uuid.Nil, uuid.Nil, uuid.Nil, false + } + orgID, userID, ok = h.requireIntegrationActor(c, false) + if !ok { + return uuid.Nil, uuid.Nil, uuid.Nil, false + } + connID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid connection id")) + return uuid.Nil, uuid.Nil, uuid.Nil, false + } + return orgID, userID, connID, true +} + +// salesforceFail answers a sync error: the service's own refusals as they +// are, anything else as an internal error with the cause logged. +func salesforceFail(c *gin.Context, err error, what string) { + var xe *errx.Error + if errors.As(err, &xe) { + errx.JSON(c, xe) + return + } + log.Error().Err(err).Str("op", what).Msg("salesforce request failed") + errx.JSON(c, errx.InternalError()) +} + +// SalesforceOverview is the connection's health page. +func (h *Handler) SalesforceOverview(c *gin.Context) { + orgID, _, connID, ok := h.salesforceReady(c) + if !ok { + return + } + out, err := h.SalesforceService.Overview(c.Request.Context(), orgID, connID, c.Query("checks") == "1") + if err != nil { + salesforceFail(c, err, "overview") + return + } + c.JSON(http.StatusOK, out) +} + +// GetSalesforceSettings returns the sync settings and the Warmbly field list. +func (h *Handler) GetSalesforceSettings(c *gin.Context) { + orgID, _, connID, ok := h.salesforceReady(c) + if !ok { + return + } + st, err := h.SalesforceService.GetSettings(c.Request.Context(), orgID, connID) + if err != nil { + salesforceFail(c, err, "get settings") + return + } + c.JSON(http.StatusOK, gin.H{"settings": st, "warmbly_fields": salesforce.WarmblyFields, "defaults": salesforce.DefaultSettings()}) +} + +// UpdateSalesforceSettings validates and saves the sync settings. A PUT of the +// whole document, so a retry converges. +func (h *Handler) UpdateSalesforceSettings(c *gin.Context) { + orgID, userID, connID, ok := h.salesforceReady(c) + if !ok { + return + } + var st salesforce.Settings + if err := c.ShouldBindJSON(&st); err != nil { + errx.JSON(c, errx.InvalidBody(err)) + return + } + saved, err := h.SalesforceService.SaveSettings(c.Request.Context(), orgID, connID, st) + if err != nil { + salesforceFail(c, err, "save settings") + return + } + h.auditIntegration(c, userID, models.AuditActionUpdate, connID, "salesforce:settings") + c.JSON(http.StatusOK, gin.H{"settings": saved}) +} + +// SalesforceMetadata returns Lead and Contact fields and picklists. +func (h *Handler) SalesforceMetadata(c *gin.Context) { + orgID, _, connID, ok := h.salesforceReady(c) + if !ok { + return + } + out, err := h.SalesforceService.Metadata(c.Request.Context(), orgID, connID) + if err != nil { + salesforceFail(c, err, "metadata") + return + } + c.JSON(http.StatusOK, out) +} + +// SalesforceUsers searches active Salesforce users. +func (h *Handler) SalesforceUsers(c *gin.Context) { + orgID, _, connID, ok := h.salesforceReady(c) + if !ok { + return + } + out, err := h.SalesforceService.Users(c.Request.Context(), orgID, connID, c.Query("q")) + if err != nil { + salesforceFail(c, err, "users") + return + } + c.JSON(http.StatusOK, gin.H{"data": out}) +} + +// SalesforceListViews lists Lead or Contact list views. +func (h *Handler) SalesforceListViews(c *gin.Context) { + orgID, _, connID, ok := h.salesforceReady(c) + if !ok { + return + } + out, err := h.SalesforceService.ListViews(c.Request.Context(), orgID, connID, c.Query("object")) + if err != nil { + salesforceFail(c, err, "list views") + return + } + c.JSON(http.StatusOK, gin.H{"data": out}) +} + +// SalesforceCampaigns searches Salesforce Campaigns. +func (h *Handler) SalesforceCampaigns(c *gin.Context) { + orgID, _, connID, ok := h.salesforceReady(c) + if !ok { + return + } + out, err := h.SalesforceService.Campaigns(c.Request.Context(), orgID, connID, c.Query("q")) + if err != nil { + salesforceFail(c, err, "campaigns") + return + } + c.JSON(http.StatusOK, gin.H{"data": out}) +} + +// PreviewSalesforceImport reads the first rows of a list view or Campaign. +// Read-only, so retries are naturally safe. +func (h *Handler) PreviewSalesforceImport(c *gin.Context) { + orgID, _, connID, ok := h.salesforceReady(c) + if !ok { + return + } + var in salesforce.ImportSource + if err := c.ShouldBindJSON(&in); err != nil { + errx.JSON(c, errx.InvalidBody(err)) + return + } + out, err := h.SalesforceService.Preview(c.Request.Context(), orgID, connID, in) + if err != nil { + salesforceFail(c, err, "import preview") + return + } + c.JSON(http.StatusOK, out) +} + +// ListSalesforceImportSources lists a connection's saved imports. +func (h *Handler) ListSalesforceImportSources(c *gin.Context) { + orgID, _, connID, ok := h.salesforceReady(c) + if !ok { + return + } + out, err := h.SalesforceService.Repo.ListSources(c.Request.Context(), orgID, connID) + if err != nil { + salesforceFail(c, err, "list sources") + return + } + c.JSON(http.StatusOK, gin.H{"data": out}) +} + +// CreateSalesforceImportSource saves an import and starts its first run. A +// retried create makes a second source whose run imports nothing new: people +// are deduplicated by address, so the retry is harmless. +func (h *Handler) CreateSalesforceImportSource(c *gin.Context) { + orgID, userID, connID, ok := h.salesforceReady(c) + if !ok { + return + } + var in salesforce.SourceInput + if err := c.ShouldBindJSON(&in); err != nil { + errx.JSON(c, errx.InvalidBody(err)) + return + } + out, err := h.SalesforceService.CreateSource(c.Request.Context(), orgID, connID, userID, in) + if err != nil { + salesforceFail(c, err, "create source") + return + } + h.auditIntegration(c, userID, models.AuditActionCreate, connID, "salesforce:import:"+out.ID.String()) + c.JSON(http.StatusCreated, out) +} + +// UpdateSalesforceImportSource edits an import's targets and schedule. +func (h *Handler) UpdateSalesforceImportSource(c *gin.Context) { + orgID, userID, connID, ok := h.salesforceReady(c) + if !ok { + return + } + id, err := uuid.Parse(c.Param("sourceId")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid import source id")) + return + } + raw, err := c.GetRawData() + if err != nil { + errx.JSON(c, errx.InvalidBody(err)) + return + } + var in salesforce.SourceInput + if err := json.Unmarshal(raw, &in); err != nil { + errx.JSON(c, errx.InvalidBody(err)) + return + } + // campaign_id: null clears the target; absent leaves it. + var probe map[string]json.RawMessage + if json.Unmarshal(raw, &probe) == nil { + if v, ok := probe["campaign_id"]; ok && strings.TrimSpace(string(v)) == "null" { + in.ClearCampaign = true + } + } + src, err := h.SalesforceService.Repo.GetSource(c.Request.Context(), orgID, id) + if err != nil || src == nil || src.ConnectionID != connID { + errx.JSON(c, errx.New(errx.NotFound, "import source not found")) + return + } + out, err := h.SalesforceService.UpdateSource(c.Request.Context(), orgID, id, in) + if err != nil { + salesforceFail(c, err, "update source") + return + } + h.auditIntegration(c, userID, models.AuditActionUpdate, connID, "salesforce:import:"+id.String()) + c.JSON(http.StatusOK, out) +} + +// RunSalesforceImportSource runs an import now. A second request while one +// runs is refused with 409, so retries cannot double-run it. +func (h *Handler) RunSalesforceImportSource(c *gin.Context) { + orgID, userID, connID, ok := h.salesforceReady(c) + if !ok { + return + } + id, err := uuid.Parse(c.Param("sourceId")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid import source id")) + return + } + src, err := h.SalesforceService.Repo.GetSource(c.Request.Context(), orgID, id) + if err != nil || src == nil || src.ConnectionID != connID { + errx.JSON(c, errx.New(errx.NotFound, "import source not found")) + return + } + out, err := h.SalesforceService.StartRun(c.Request.Context(), orgID, id) + if err != nil { + salesforceFail(c, err, "run source") + return + } + h.auditIntegration(c, userID, models.AuditActionUpdate, connID, "salesforce:import-run:"+id.String()) + c.JSON(http.StatusOK, out) +} + +// DeleteSalesforceImportSource removes an import; contacts it brought in stay. +func (h *Handler) DeleteSalesforceImportSource(c *gin.Context) { + orgID, userID, connID, ok := h.salesforceReady(c) + if !ok { + return + } + id, err := uuid.Parse(c.Param("sourceId")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid import source id")) + return + } + src, err := h.SalesforceService.Repo.GetSource(c.Request.Context(), orgID, id) + if err != nil || src == nil || src.ConnectionID != connID { + errx.JSON(c, errx.New(errx.NotFound, "import source not found")) + return + } + if _, err := h.SalesforceService.Repo.DeleteSource(c.Request.Context(), orgID, id); err != nil { + salesforceFail(c, err, "delete source") + return + } + h.auditIntegration(c, userID, models.AuditActionDelete, connID, "salesforce:import:"+id.String()) + c.Status(http.StatusNoContent) +} + +// ListSalesforceActivity pages the activity log, newest first. +func (h *Handler) ListSalesforceActivity(c *gin.Context) { + orgID, _, connID, ok := h.salesforceReady(c) + if !ok { + return + } + f := repository.SalesforceActivityFilter{Limit: 50} + if v := c.Query("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 || n > 200 { + errx.JSON(c, errx.New(errx.BadRequest, "limit must be between 1 and 200")) + return + } + f.Limit = n + } + switch st := c.Query("status"); st { + case "", models.SalesforceActivityPending, models.SalesforceActivitySynced, models.SalesforceActivitySkipped, models.SalesforceActivityFailed: + f.Status = st + default: + errx.JSON(c, errx.New(errx.BadRequest, "status must be pending, synced, skipped or failed")) + return + } + if v := c.Query("contact_id"); v != "" { + id, err := uuid.Parse(v) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid contact_id")) + return + } + f.ContactID = &id + } + at, id, xerr := paging.DecodeTimeCursor(c.Query("cursor")) + if xerr != nil { + errx.JSON(c, xerr) + return + } + if id != uuid.Nil { + f.Before, f.BeforeID = &at, &id + } + rows, err := h.SalesforceService.Repo.ListActivities(c.Request.Context(), orgID, connID, f) + if err != nil { + salesforceFail(c, err, "list activity") + return + } + var next *string + if len(rows) > f.Limit { + last := rows[f.Limit-1] + next = paging.EncodeTime(last.CreatedAt, last.ID) + rows = rows[:f.Limit] + } + if rows == nil { + rows = []models.SalesforceActivity{} + } + c.JSON(http.StatusOK, gin.H{ + "data": rows, + "pagination": gin.H{"next_cursor": next, "has_more": next != nil}, + }) +} + +type salesforceRetryPayload struct { + IDs []string `json:"ids"` +} + +// RetrySalesforceActivity re-queues failed or skipped rows. Re-queuing a row +// that is already pending changes nothing, so retries are safe. +func (h *Handler) RetrySalesforceActivity(c *gin.Context) { + orgID, userID, connID, ok := h.salesforceReady(c) + if !ok { + return + } + var p salesforceRetryPayload + if err := c.ShouldBindJSON(&p); err != nil { + errx.JSON(c, errx.InvalidBody(err)) + return + } + if len(p.IDs) > 500 { + errx.JSON(c, errx.New(errx.BadRequest, "at most 500 ids per retry")) + return + } + ids := make([]uuid.UUID, 0, len(p.IDs)) + for _, raw := range p.IDs { + id, err := uuid.Parse(raw) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid activity id: "+raw)) + return + } + ids = append(ids, id) + } + n, err := h.SalesforceService.Repo.RetryActivities(c.Request.Context(), orgID, connID, ids) + if err != nil { + salesforceFail(c, err, "retry activity") + return + } + h.auditIntegration(c, userID, models.AuditActionUpdate, connID, "salesforce:retry:"+strconv.Itoa(n)) + c.JSON(http.StatusOK, gin.H{"requeued": n}) +} + +// SalesforceSyncNow drains pending activity and pulls changes at once. Both +// are idempotent passes, so a retry only repeats a no-op. +func (h *Handler) SalesforceSyncNow(c *gin.Context) { + orgID, _, connID, ok := h.salesforceReady(c) + if !ok { + return + } + if err := h.SalesforceService.SyncNow(c.Request.Context(), orgID, connID); err != nil { + salesforceFail(c, err, "sync now") + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// --- contact panel ------------------------------------------------------------ + +func (h *Handler) salesforceContact(c *gin.Context) (orgID, contactID uuid.UUID, ok bool) { + if h.SalesforceService == nil { + errx.JSON(c, errx.New(errx.ServiceUnavailable, "Salesforce sync is not available on this instance")) + return uuid.Nil, uuid.Nil, false + } + orgID, ok = requireOrgID(c) + if !ok { + return uuid.Nil, uuid.Nil, false + } + contactID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid contact id")) + return uuid.Nil, uuid.Nil, false + } + return orgID, contactID, true +} + +// GetContactSalesforce returns the contact's Salesforce panel. +func (h *Handler) GetContactSalesforce(c *gin.Context) { + orgID, contactID, ok := h.salesforceContact(c) + if !ok { + return + } + out, err := h.SalesforceService.ContactPanel(c.Request.Context(), orgID, contactID) + if err != nil { + salesforceFail(c, err, "contact panel") + return + } + if out == nil { + errx.JSON(c, errx.New(errx.NotFound, "contact not found")) + return + } + c.JSON(http.StatusOK, out) +} + +type contactSalesforceSyncPayload struct { + ConnectionID *uuid.UUID `json:"connection_id"` + CreateAs string `json:"create_as"` +} + +// SyncContactSalesforce matches (or creates) the contact in Salesforce and +// pushes its mapped fields. Matching runs first, so a retry finds the record +// the first attempt created instead of creating another. +func (h *Handler) SyncContactSalesforce(c *gin.Context) { + orgID, contactID, ok := h.salesforceContact(c) + if !ok { + return + } + var p contactSalesforceSyncPayload + if c.Request.ContentLength > 0 { + if err := c.ShouldBindJSON(&p); err != nil { + errx.JSON(c, errx.InvalidBody(err)) + return + } + } + ctx := c.Request.Context() + if err := h.SalesforceService.SyncContact(ctx, orgID, contactID, p.ConnectionID, p.CreateAs); err != nil { + salesforceFail(c, err, "contact sync") + return + } + h.SalesforceService.ForgetLive(ctx, orgID, contactID) + out, err := h.SalesforceService.ContactPanel(ctx, orgID, contactID) + if err != nil { + salesforceFail(c, err, "contact panel") + return + } + if uid, perr := uuid.Parse(middleware.GetUserID(c)); perr == nil { + h.auditIntegrationEntity(c, uid, models.AuditActionUpdate, models.AuditEntityContact, contactID, "salesforce:sync") + } + c.JSON(http.StatusOK, out) +} + +// UnlinkContactSalesforce drops a contact's link to a Salesforce record. +func (h *Handler) UnlinkContactSalesforce(c *gin.Context) { + orgID, contactID, ok := h.salesforceContact(c) + if !ok { + return + } + linkID, err := uuid.Parse(c.Param("linkId")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid link id")) + return + } + if err := h.SalesforceService.Unlink(c.Request.Context(), orgID, contactID, linkID); err != nil { + salesforceFail(c, err, "unlink") + return + } + if uid, perr := uuid.Parse(middleware.GetUserID(c)); perr == nil { + h.auditIntegrationEntity(c, uid, models.AuditActionUpdate, models.AuditEntityContact, contactID, "salesforce:unlink") + } + c.Status(http.StatusNoContent) +} diff --git a/internal/api/routes.go b/internal/api/routes.go index ffb80662a..4c3dce791 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -884,6 +884,11 @@ func Run( contacts.DELETE("/:id/notes/:noteId", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.DeleteContactNote) contacts.GET("/:id/activities", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListContactActivities) contacts.GET("/:id/deals", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.GetDealsByContact) + // The contact's linked Salesforce record, deals included, so API keys + // need the CRM read bit; writing to Salesforce is an integration action. + contacts.GET("/:id/salesforce", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.GetContactSalesforce) + contacts.POST("/:id/salesforce/sync", m.RequireAccess(models.PermUseIntegrations, models.APIPermIntegrations), h.SyncContactSalesforce) + contacts.DELETE("/:id/salesforce/links/:linkId", m.RequireAccess(models.PermUseIntegrations, models.APIPermIntegrations), h.UnlinkContactSalesforce) } // Group endpoints map to the resources they organize: campaign @@ -1167,6 +1172,26 @@ func Run( integrations.POST("/connections/:id/test", write, h.TestConnection) integrations.POST("/connections/:id/push", operate, h.PushContactsToIntegration) integrations.GET("/bookings", read, h.ListMeetingBookings) + + // Native Salesforce sync (:id is the connection). Reading health and + // the activity log is operational; changing what syncs is settings. + sf := integrations.Group("/salesforce/:id") + sf.GET("/overview", read, h.SalesforceOverview) + sf.GET("/settings", read, h.GetSalesforceSettings) + sf.PUT("/settings", write, h.UpdateSalesforceSettings) + sf.GET("/metadata", read, h.SalesforceMetadata) + sf.GET("/users", read, h.SalesforceUsers) + sf.GET("/list-views", read, h.SalesforceListViews) + sf.GET("/campaigns", read, h.SalesforceCampaigns) + sf.POST("/import/preview", operate, h.PreviewSalesforceImport) + sf.GET("/import-sources", read, h.ListSalesforceImportSources) + sf.POST("/import-sources", write, h.CreateSalesforceImportSource) + sf.PATCH("/import-sources/:sourceId", write, h.UpdateSalesforceImportSource) + sf.POST("/import-sources/:sourceId/run", operate, h.RunSalesforceImportSource) + sf.DELETE("/import-sources/:sourceId", write, h.DeleteSalesforceImportSource) + sf.GET("/activity", read, h.ListSalesforceActivity) + sf.POST("/activity/retry", write, h.RetrySalesforceActivity) + sf.POST("/sync-now", operate, h.SalesforceSyncNow) } // Meetings (org-scoped). Booked calls from connected scheduling diff --git a/internal/app/contact/campaign_state.go b/internal/app/contact/campaign_state.go index 54b9b1600..c38b9cd61 100644 --- a/internal/app/contact/campaign_state.go +++ b/internal/app/contact/campaign_state.go @@ -123,6 +123,12 @@ func holdCopy(hold *models.LeadHold) string { return "Copied on the emails to " + r + ", so none of their own are sent" } return "Copied on another lead's emails, so none of their own are sent" + case models.LeadHoldSourceCRM: + // The reason already reads "Salesforce: ...", naming the rule that held it. + if r := strings.TrimSpace(hold.Reason); r != "" { + return "Paused by " + r + ", until someone resumes it" + } + return "Paused by a CRM rule, until someone resumes it" } if r := strings.TrimSpace(hold.Reason); r != "" { what += ": " + r diff --git a/internal/app/integration/catalog.go b/internal/app/integration/catalog.go index d9d524cbb..8e8751a88 100644 --- a/internal/app/integration/catalog.go +++ b/internal/app/integration/catalog.go @@ -55,13 +55,19 @@ func Catalog() []models.IntegrationCatalogEntry { SupportsPush: true, }, { - Provider: models.IntegrationSalesforce, - Name: "Salesforce", - Tagline: "Sync leads, contacts, and email activity to Salesforce.", - Category: models.IntegrationCategoryCRM, - AuthMethod: string(models.IntegrationAuthOAuth), - DocsURL: "https://developer.salesforce.com/docs", - Highlights: []string{"OAuth connect", "Upsert a Salesforce contact on reply or on demand"}, + Provider: models.IntegrationSalesforce, + Name: "Salesforce", + Tagline: "Two-way sync with Leads, Contacts and the activity timeline.", + Category: models.IntegrationCategoryCRM, + AuthMethod: string(models.IntegrationAuthOAuth), + DocsURL: "https://docs.warmbly.com/guides/salesforce/", + Highlights: []string{ + "Production, sandbox or My Domain, connected in one click", + "Sends, replies and meetings logged as Tasks on the Lead or Contact", + "Lead Status writeback and do-not-email kept in step both ways", + "Import from list views and Salesforce Campaigns, kept in sync", + "Owner, status and open deals on every contact and inbox thread", + }, Events: crmEvents, ActionTypes: []string{string(models.IntegrationActionSalesforceUpsert)}, SupportsPush: true, diff --git a/internal/app/integration/dispatch.go b/internal/app/integration/dispatch.go index 3eca9d53b..368350cd7 100644 --- a/internal/app/integration/dispatch.go +++ b/internal/app/integration/dispatch.go @@ -182,6 +182,23 @@ func (s *service) execAction(ctx context.Context, target repository.DispatchTarg return pipedriveUpsertPerson(ctx, token, contactEmail(data), props) case models.IntegrationActionSalesforceUpsert: + if s.salesforce != nil { + ev := map[string]any{} + for k, v := range data { + ev[k] = v + } + // An automation's own field map overrides the connection's rules. + if len(autoCfg.FieldMap) > 0 { + ev["_salesforce_fields"] = projectFields(autoCfg.FieldMap, eventSource(data)) + } + if err := s.salesforce.UpsertFromEvent(ctx, sub.OrganizationID, sub.ConnectionID, ev); err != nil { + if errors.Is(err, ErrPushReauth) { + return errReauthRequired + } + return err + } + return nil + } token, terr := s.accessTokenFor(ctx, &target.Secrets) if terr != nil { return errReauthRequired diff --git a/internal/app/integration/oauth.go b/internal/app/integration/oauth.go index e62b256e1..a3a73ffab 100644 --- a/internal/app/integration/oauth.go +++ b/internal/app/integration/oauth.go @@ -6,9 +6,11 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" + "net/url" "os" "strings" "time" @@ -114,7 +116,7 @@ func NewOAuthManager() *OAuthManager { register(models.IntegrationSalesforce, "SALESFORCE", oauth2.Endpoint{ AuthURL: "https://login.salesforce.com/services/oauth2/authorize", TokenURL: "https://login.salesforce.com/services/oauth2/token", - }, []string{"api", "refresh_token"}, true, identifySalesforce) + }, []string{"api", "refresh_token", "id"}, true, identifySalesforce) return m } @@ -157,14 +159,60 @@ func (m *OAuthManager) Scopes(p models.IntegrationProvider) []string { return nil } +// configFor returns the provider's OAuth config, pointed at loginHost for a +// provider whose authorization server varies per org (Salesforce sandboxes and +// My Domains). An empty host keeps the registered endpoint. +func (op *oauthProvider) configFor(loginHost string) *oauth2.Config { + if op.config == nil || loginHost == "" || op.provider != models.IntegrationSalesforce { + return op.config + } + cfg := *op.config + cfg.Endpoint = oauth2.Endpoint{ + AuthURL: "https://" + loginHost + "/services/oauth2/authorize", + TokenURL: "https://" + loginHost + "/services/oauth2/token", + } + return &cfg +} + +// SalesforceLoginHost resolves "production", "sandbox" or a My Domain to the +// host a Salesforce handshake runs against. Only Salesforce's own domains are +// accepted: the token endpoint receives the client secret. +func SalesforceLoginHost(in string) (string, error) { + v := strings.ToLower(strings.TrimSpace(in)) + switch v { + case "", "production", "login.salesforce.com": + return "login.salesforce.com", nil + case "sandbox", "test.salesforce.com": + return "test.salesforce.com", nil + } + v = strings.TrimPrefix(strings.TrimPrefix(v, "https://"), "http://") + if i := strings.IndexAny(v, "/?#"); i >= 0 { + v = v[:i] + } + if !strings.HasSuffix(v, ".my.salesforce.com") || len(v) > 200 { + return "", errors.New("enter your My Domain, for example acme.my.salesforce.com") + } + for _, r := range v { + if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '.') { + return "", errors.New("enter your My Domain, for example acme.my.salesforce.com") + } + } + return v, nil +} + // AuthCodeURL builds the provider authorization URL. It returns the URL plus // the PKCE verifier to persist (empty when the provider doesn't use PKCE). -func (m *OAuthManager) AuthCodeURL(p models.IntegrationProvider, state string) (authURL, verifier string, err error) { +func (m *OAuthManager) AuthCodeURL(p models.IntegrationProvider, state, loginHost string) (authURL, verifier string, err error) { op, ok := m.providers[p] if !ok || op.config == nil { return "", "", fmt.Errorf("oauth not configured for provider %s", p) } opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline, oauth2.ApprovalForce} + if p == models.IntegrationSalesforce { + // Salesforce reuses a live browser session; asking for a login lets the + // member choose which org they authorize. + opts = []oauth2.AuthCodeOption{oauth2.SetAuthURLParam("prompt", "login consent")} + } if op.scopeSep != "" && len(op.scopes) > 0 { opts = append(opts, oauth2.SetAuthURLParam("scope", strings.Join(op.scopes, op.scopeSep))) } @@ -180,12 +228,12 @@ func (m *OAuthManager) AuthCodeURL(p models.IntegrationProvider, state string) ( oauth2.SetAuthURLParam("code_challenge_method", "S256"), ) } - return op.config.AuthCodeURL(state, opts...), verifier, nil + return op.configFor(loginHost).AuthCodeURL(state, opts...), verifier, nil } // Exchange swaps an authorization code for tokens and resolves the connected // account identity. -func (m *OAuthManager) Exchange(ctx context.Context, p models.IntegrationProvider, code, verifier string) (*models.IntegrationTokens, extAccount, error) { +func (m *OAuthManager) Exchange(ctx context.Context, p models.IntegrationProvider, code, verifier, loginHost string) (*models.IntegrationTokens, extAccount, error) { op, ok := m.providers[p] if !ok || op.config == nil { return nil, extAccount{}, fmt.Errorf("oauth not configured for provider %s", p) @@ -194,7 +242,7 @@ func (m *OAuthManager) Exchange(ctx context.Context, p models.IntegrationProvide if op.usePKCE && verifier != "" { opts = append(opts, oauth2.SetAuthURLParam("code_verifier", verifier)) } - tok, err := op.config.Exchange(ctx, code, opts...) + tok, err := op.configFor(loginHost).Exchange(ctx, code, opts...) if err != nil { return nil, extAccount{}, fmt.Errorf("token exchange failed: %w", err) } @@ -227,29 +275,33 @@ func (m *OAuthManager) Exchange(ctx context.Context, p models.IntegrationProvide // "instance_url" extra on the token. Capture it so action handlers know // which host to call — the value is persisted in the connection's // non-secret display fields by OAuthFinish. - if iu, ok := tok.Extra("instance_url").(string); ok { + if iu, ok := tok.Extra("instance_url").(string); ok && strings.HasPrefix(strings.TrimSpace(iu), "https://") { acct.InstanceURL = strings.TrimRight(strings.TrimSpace(iu), "/") } if p == models.IntegrationHubSpot { acct.UIDomain = hubspotUIDomain(ctx, m, tok.AccessToken) } + if id, ok := tok.Extra("id").(string); ok { + acct.IdentityURL = strings.TrimSpace(id) + } return tokens, acct, nil } // RefreshIfNeeded returns a valid access token for the connection, refreshing -// via the stored refresh token when the access token is within 60s of expiry. -// It reports whether the token was refreshed (so the caller can persist it). -func (m *OAuthManager) RefreshIfNeeded(ctx context.Context, p models.IntegrationProvider, current models.IntegrationTokens) (models.IntegrationTokens, bool, error) { +// via the stored refresh token when the access token is within 60s of expiry, +// or whenever force is set (Salesforce issues no expiry, so a refused session +// is the only signal). It reports whether the token was refreshed. +func (m *OAuthManager) RefreshIfNeeded(ctx context.Context, p models.IntegrationProvider, current models.IntegrationTokens, force bool, loginHost string) (models.IntegrationTokens, bool, error) { op, ok := m.providers[p] if !ok || op.config == nil { return current, false, fmt.Errorf("oauth not configured for provider %s", p) } stillValid := current.ExpiresAt == nil || time.Until(*current.ExpiresAt) > 60*time.Second - if stillValid || current.RefreshToken == "" { + if (stillValid && !force) || current.RefreshToken == "" { return current, false, nil } - src := op.config.TokenSource(ctx, &oauth2.Token{ + src := op.configFor(loginHost).TokenSource(ctx, &oauth2.Token{ AccessToken: current.AccessToken, RefreshToken: current.RefreshToken, Expiry: time.Now().Add(-time.Minute), @@ -270,6 +322,9 @@ func (m *OAuthManager) RefreshIfNeeded(ctx context.Context, p models.Integration exp := tok.Expiry.UTC() refreshed.ExpiresAt = &exp } + if iu, ok := tok.Extra("instance_url").(string); ok && strings.HasPrefix(strings.TrimSpace(iu), "https://") { + refreshed.InstanceURL = strings.TrimRight(strings.TrimSpace(iu), "/") + } return refreshed, true, nil } @@ -282,6 +337,8 @@ type extAccount struct { InstanceURL string // UIDomain is the provider web app host for record links (HubSpot). UIDomain string + // IdentityURL is Salesforce's /id// URL for the connected user. + IdentityURL string } // --- identity resolvers ----------------------------------------------------- @@ -391,6 +448,36 @@ func identifySalesforce(ctx context.Context, m *OAuthManager, tok *oauth2.Token) // --- helpers ---------------------------------------------------------------- +// Revoke asks a provider to invalidate a token at its revocation endpoint. +func (m *OAuthManager) Revoke(ctx context.Context, endpoint, token string) error { + form := url.Values{"token": {token}} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := m.http.Do(req) + if err != nil { + return err + } + resp.Body.Close() + return nil +} + +// salesforceIdentityIDs pulls the org and user ids out of the identity URL +// (https://login.salesforce.com/id//). +func salesforceIdentityIDs(identityURL string) (orgID, userID string) { + u, err := url.Parse(identityURL) + if err != nil { + return "", "" + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) >= 3 && parts[0] == "id" { + return parts[1], parts[2] + } + return "", "" +} + func (m *OAuthManager) getJSON(ctx context.Context, url, bearer string, dst any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { diff --git a/internal/app/integration/push.go b/internal/app/integration/push.go index 94c598824..ef0405948 100644 --- a/internal/app/integration/push.go +++ b/internal/app/integration/push.go @@ -70,6 +70,11 @@ func (s *service) PushContacts(ctx context.Context, orgID, connID uuid.UUID, con if conn.Status != models.IntegrationStatusConnected && conn.Status != models.IntegrationStatusDegraded { return nil, fmt.Errorf("connection is not usable (status: %s)", conn.Status) } + // Salesforce pushes go through the native sync: Lead or Contact matching, + // the connection's field rules, and a link the contact keeps. + if conn.Provider == models.IntegrationSalesforce && s.salesforce != nil { + return s.salesforce.PushContacts(ctx, orgID, connID, contacts) + } sec, err := s.repo.GetConnectionSecrets(ctx, connID) if err != nil { @@ -224,6 +229,17 @@ func (s *service) UpdateConnectionConfig(ctx context.Context, orgID, connID uuid if stored := configString(conn.ConfigCapabilities, models.ConfigCapabilitiesSigningSecret); stored != "" { next[models.ConfigCapabilitiesSigningSecret] = stored } + // Salesforce sync settings are written only through their own validated + // endpoint, so a generic config save carries the stored ones over. + if conn.Provider == models.IntegrationSalesforce { + delete(next, "salesforce") + var stored map[string]json.RawMessage + if json.Unmarshal(conn.ConfigCapabilities, &stored) == nil { + if v, ok := stored["salesforce"]; ok { + next["salesforce"] = v + } + } + } raw, err := json.Marshal(next) if err != nil { return nil, err @@ -257,3 +273,29 @@ func contactSource(ct PushContact) map[string]any { "name": strings.TrimSpace(ct.FirstName + " " + ct.LastName), } } + +// SetConfigKey replaces one key of a connection's config_capabilities, leaving +// the rest as stored. Used by provider settings that validate themselves. +func (s *service) SetConfigKey(ctx context.Context, orgID, connID uuid.UUID, key string, value any) error { + conn, err := s.repo.GetConnectionByID(ctx, orgID, connID) + if err != nil { + return err + } + if conn == nil { + return errors.New("connection not found") + } + cc := map[string]any{} + if len(conn.ConfigCapabilities) > 0 { + _ = json.Unmarshal(conn.ConfigCapabilities, &cc) + } + cc[key] = value + raw, err := json.Marshal(cc) + if err != nil { + return err + } + dir := conn.SyncDirection + if dir == "" { + dir = string(models.SyncDirectionPush) + } + return s.repo.UpdateConnectionConfig(ctx, orgID, connID, raw, dir) +} diff --git a/internal/app/integration/service.go b/internal/app/integration/service.go index 6d731ea9a..3f812adde 100644 --- a/internal/app/integration/service.go +++ b/internal/app/integration/service.go @@ -9,11 +9,13 @@ import ( "fmt" emailverifyapp "github.com/warmbly/warmbly/internal/app/emailverify" "github.com/warmbly/warmbly/internal/pkg/emailverify" + "net/http" "slices" "strings" "time" "github.com/google/uuid" + "golang.org/x/oauth2" "golang.org/x/sync/singleflight" "github.com/warmbly/warmbly/internal/app/cipher" @@ -53,7 +55,8 @@ type Service interface { Disconnect(ctx context.Context, orgID, id uuid.UUID) error // OAuthStart returns the provider authorization URL for a one-click connect. - OAuthStart(ctx context.Context, orgID, userID uuid.UUID, provider models.IntegrationProvider, label string) (*models.IntegrationOAuthStartResponse, error) + // params carries provider options (Salesforce: "environment", "domain"). + OAuthStart(ctx context.Context, orgID, userID uuid.UUID, provider models.IntegrationProvider, label string, params map[string]string) (*models.IntegrationOAuthStartResponse, error) // OAuthFinish completes the handshake: validates state, exchanges the code, // resolves the account identity, and persists encrypted tokens. authorize // runs against the state's organization before the code is exchanged. @@ -128,6 +131,8 @@ type Service interface { // UpdateConnectionConfig persists the onboarding/capability snapshot + sync // direction for a connection. UpdateConnectionConfig(ctx context.Context, orgID, connID uuid.UUID, configCapabilities map[string]any, syncDirection string) (*models.IntegrationConnection, error) + // SetConfigKey replaces one key of config_capabilities, keeping the rest. + SetConfigKey(ctx context.Context, orgID, connID uuid.UUID, key string, value any) error // WebhookSigningSecret returns the HMAC signing secret for an automation // connection's outbound webhook deliveries, generating + persisting one on @@ -195,6 +200,29 @@ type Service interface { // SetCRMModeCheck reports whether a workspace runs its CRM on HubSpot, so // the legacy HubSpot action steps aside. SetCRMModeCheck(check func(ctx context.Context, orgID uuid.UUID) bool) + // ProviderAccess returns a usable OAuth access token for an org-owned + // connection, refreshing first when force is set or the token is expiring. + ProviderAccess(ctx context.Context, orgID, connID uuid.UUID, force bool) (*ProviderAccess, error) + // SetSalesforce routes Salesforce pushes, upsert actions and new + // connections through the native sync. + SetSalesforce(b SalesforceBridge) +} + +// ProviderAccess is a live credential for one connection. +type ProviderAccess struct { + Token string + InstanceURL string + Conn *models.IntegrationConnection +} + +// SalesforceBridge is the native Salesforce sync as the integration surface +// sees it. Implemented by the salesforce package, wired after construction. +type SalesforceBridge interface { + PushContacts(ctx context.Context, orgID, connID uuid.UUID, contacts []PushContact) (*PushResult, error) + // UpsertFromEvent finds or creates the event's person; data may carry + // "_salesforce_fields", an automation's own projected field values. + UpsertFromEvent(ctx context.Context, orgID, connID uuid.UUID, data map[string]any) error + Connected(ctx context.Context, conn *models.IntegrationConnection) } type service struct { @@ -208,6 +236,7 @@ type service struct { aiProvider generation.Provider credits credits.CreditService aiSearch generation.SearchClient + salesforce SalesforceBridge } // NewService builds the integration service. cipherSvc seals provider secrets @@ -228,6 +257,7 @@ func (s *service) SetAI(p generation.Provider, c credits.CreditService) { s.credits = c } func (s *service) SetAISearch(sc generation.SearchClient) { s.aiSearch = sc } +func (s *service) SetSalesforce(b SalesforceBridge) { s.salesforce = b } func (s *service) Repo() repository.IntegrationRepository { return s.repo } @@ -388,15 +418,61 @@ func (s *service) Connect(ctx context.Context, orgID, userID uuid.UUID, provider } func (s *service) Disconnect(ctx context.Context, orgID, id uuid.UUID) error { + if conn, err := s.repo.GetConnectionByID(ctx, orgID, id); err == nil && conn != nil && conn.Provider == models.IntegrationSalesforce { + s.revokeSalesforce(ctx, conn) + } return s.repo.DeleteConnection(ctx, orgID, id) } -func (s *service) OAuthStart(ctx context.Context, orgID, userID uuid.UUID, provider models.IntegrationProvider, label string) (*models.IntegrationOAuthStartResponse, error) { +// revokeSalesforce invalidates the refresh token (and every access token it +// minted) so a disconnected org stops trusting Warmbly at once. Best-effort. +func (s *service) revokeSalesforce(ctx context.Context, conn *models.IntegrationConnection) { + sec, err := s.repo.GetConnectionSecrets(ctx, conn.ID) + if err != nil || sec == nil { + return + } + refresh, err := s.open(ctx, conn.OrganizationID, sec.RefreshTokenEnc) + if err != nil || refresh == "" { + return + } + host := configString(conn.DisplayFields, "login_host") + if host == "" { + host = "login.salesforce.com" + } + if _, herr := SalesforceLoginHost(host); herr != nil { + return + } + rctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + _ = s.oauth.Revoke(rctx, "https://"+host+"/services/oauth2/revoke", refresh) +} + +func (s *service) OAuthStart(ctx context.Context, orgID, userID uuid.UUID, provider models.IntegrationProvider, label string, params map[string]string) (*models.IntegrationOAuthStartResponse, error) { if !s.oauth.Configured(provider) { return nil, ErrOAuthNotConfigured } + stParams := map[string]string{} + loginHost := "" + if provider == models.IntegrationSalesforce { + in := params["domain"] + if strings.TrimSpace(in) == "" { + in = params["environment"] + } + host, herr := SalesforceLoginHost(in) + if herr != nil { + return nil, errx.NewWithIdentifier(errx.BadRequest, "invalid_salesforce_domain", herr.Error()) + } + loginHost = host + stParams["login_host"] = host + if strings.TrimSpace(label) == "" { + label = "Salesforce" + if host == "test.salesforce.com" || strings.Contains(host, ".sandbox.") { + label = "Salesforce sandbox" + } + } + } state := randomURLToken(24) - authURL, verifier, err := s.oauth.AuthCodeURL(provider, state) + authURL, verifier, err := s.oauth.AuthCodeURL(provider, state, loginHost) if err != nil { return nil, err } @@ -409,6 +485,7 @@ func (s *service) OAuthStart(ctx context.Context, orgID, userID uuid.UUID, provi CodeVerifier: verifier, Label: strings.TrimSpace(label), RequestedScopes: s.oauth.Scopes(provider), + Params: stParams, ExpiresAt: time.Now().UTC().Add(oauthStateTTL), } if err := s.repo.CreateOAuthState(ctx, st); err != nil { @@ -441,7 +518,13 @@ func (s *service) OAuthFinish(ctx context.Context, userID uuid.UUID, code, state return nil, err } - tokens, account, err := s.oauth.Exchange(ctx, st.Provider, code, st.CodeVerifier) + loginHost := st.Params["login_host"] + if loginHost != "" { + if _, herr := SalesforceLoginHost(loginHost); herr != nil { + return nil, herr + } + } + tokens, account, err := s.oauth.Exchange(ctx, st.Provider, code, st.CodeVerifier, loginHost) if err != nil { return nil, err } @@ -472,6 +555,31 @@ func (s *service) OAuthFinish(ctx context.Context, userID uuid.UUID, code, state if account.UIDomain != "" { display["ui_domain"] = account.UIDomain } + if st.Provider == models.IntegrationSalesforce { + if loginHost == "" { + loginHost = "login.salesforce.com" + } + display["login_host"] = loginHost + display["environment"] = "production" + if loginHost == "test.salesforce.com" || strings.Contains(loginHost, ".sandbox.") { + display["environment"] = "sandbox" + } + if orgSF, userSF := salesforceIdentityIDs(account.IdentityURL); orgSF != "" { + display["sf_org_id"] = orgSF + display["sf_user_id"] = userSF + } + } + if st.Provider == models.IntegrationSalesforce { + // Connections are keyed by label; a login into a different org must + // become its own connection, never swap the org under this one's + // links and history. + if prev, perr := s.repo.GetConnection(ctx, st.OrganizationID, st.Provider, label); perr == nil && prev != nil { + prevOrg := configString(prev.DisplayFields, "sf_org_id") + if prevOrg != "" && display["sf_org_id"] != nil && prevOrg != display["sf_org_id"] { + label = fmt.Sprintf("%s (%s)", label, orFallback(account.Name, fmt.Sprint(display["sf_org_id"]))) + } + } + } df, _ := json.Marshal(display) now := time.Now().UTC() @@ -508,6 +616,9 @@ func (s *service) OAuthFinish(ctx context.Context, userID uuid.UUID, code, state Status: "success", Detail: "authorized " + string(st.Provider), }) + if st.Provider == models.IntegrationSalesforce && s.salesforce != nil { + s.salesforce.Connected(ctx, stored) + } return stored, nil } return conn, nil @@ -525,7 +636,11 @@ func (s *service) Reauth(ctx context.Context, orgID, userID, id uuid.UUID) (*mod return nil, ErrUseOAuth } _ = s.repo.SetConnectionStatus(ctx, id, models.IntegrationStatusAuthorizing, models.IntegrationHealthDegraded, "reauthorizing") - return s.OAuthStart(ctx, orgID, userID, conn.Provider, conn.Label) + var params map[string]string + if host := configString(conn.DisplayFields, "login_host"); host != "" { + params = map[string]string{"domain": host} + } + return s.OAuthStart(ctx, orgID, userID, conn.Provider, conn.Label, params) } func (s *service) RotateInboundSecret(ctx context.Context, orgID, id uuid.UUID) (string, error) { @@ -1052,15 +1167,23 @@ func (s *service) openConfig(ctx context.Context, sec *repository.ConnectionSecr // On an unrecoverable refresh failure it flips the connection to // reauth_required and returns an error. func (s *service) accessTokenFor(ctx context.Context, sec *repository.ConnectionSecrets) (string, error) { + tok, _, err := s.accessToken(ctx, sec, false) + return tok, err +} + +// accessToken is accessTokenFor that can force a refresh, and also reports the +// API host when the provider has one per org. +func (s *service) accessToken(ctx context.Context, sec *repository.ConnectionSecrets, force bool) (token, instanceURL string, err error) { orgID := sec.Conn.OrganizationID + instanceURL = configString(sec.Conn.DisplayFields, "instance_url") access, err := s.open(ctx, orgID, sec.AccessTokenEnc) if err != nil { - return "", err + return "", "", err } refresh, err := s.open(ctx, orgID, sec.RefreshTokenEnc) if err != nil { - return "", err + return "", "", err } current := models.IntegrationTokens{ @@ -1069,18 +1192,95 @@ func (s *service) accessTokenFor(ctx context.Context, sec *repository.Connection ExpiresAt: sec.Conn.TokenExpiresAt, Scopes: sec.Conn.GrantedScopes, } - refreshed, didRefresh, rerr := s.oauth.RefreshIfNeeded(ctx, sec.Conn.Provider, current) + loginHost := configString(sec.Conn.DisplayFields, "login_host") + if loginHost != "" { + if _, herr := SalesforceLoginHost(loginHost); herr != nil { + loginHost = "" + } + } + refreshed, didRefresh, rerr := s.oauth.RefreshIfNeeded(ctx, sec.Conn.Provider, current, force, loginHost) if rerr != nil { - _ = s.repo.SetConnectionStatus(ctx, sec.Conn.ID, models.IntegrationStatusReauthRequired, models.IntegrationHealthDown, "token refresh failed: reconnect required") - return "", rerr + // Only the provider refusing the grant needs a person; a timeout or a + // 5xx at the token endpoint is retried on the next call. + if refreshRefused(rerr) { + _ = s.repo.SetConnectionStatus(ctx, sec.Conn.ID, models.IntegrationStatusReauthRequired, models.IntegrationHealthDown, "token refresh failed: reconnect required") + } + return "", "", rerr } if didRefresh { accessEnc, _ := s.seal(ctx, orgID, refreshed.AccessToken) refreshEnc, _ := s.seal(ctx, orgID, refreshed.RefreshToken) _ = s.repo.UpdateConnectionTokens(ctx, sec.Conn.ID, accessEnc, refreshEnc, refreshed.ExpiresAt, refreshed.Scopes) - return refreshed.AccessToken, nil + // An org migration moves the instance host; the refresh is where it shows. + if refreshed.InstanceURL != "" && refreshed.InstanceURL != instanceURL { + instanceURL = refreshed.InstanceURL + _ = s.repo.MergeDisplayFields(ctx, sec.Conn.ID, map[string]any{"instance_url": instanceURL}) + } } - return refreshed.AccessToken, nil + return refreshed.AccessToken, instanceURL, nil +} + +func orFallback(v, d string) string { + if strings.TrimSpace(v) == "" { + return d + } + return v +} + +// refreshRefused reports a refresh the provider rejected outright: a revoked +// or expired grant, or a client it no longer recognises. +func refreshRefused(err error) bool { + var re *oauth2.RetrieveError + if !errors.As(err, &re) { + return false + } + switch re.ErrorCode { + case "invalid_grant", "invalid_client", "unauthorized_client", "invalid_scope": + return true + } + return re.Response != nil && (re.Response.StatusCode == http.StatusBadRequest || re.Response.StatusCode == http.StatusUnauthorized) +} + +// ProviderAccess returns a usable token for an org-owned OAuth connection. +func (s *service) ProviderAccess(ctx context.Context, orgID, connID uuid.UUID, force bool) (*ProviderAccess, error) { + conn, err := s.repo.GetConnectionByID(ctx, orgID, connID) + if err != nil { + return nil, err + } + if conn == nil { + return nil, errors.New("connection not found") + } + if conn.Status == models.IntegrationStatusReauthRequired || conn.Status == models.IntegrationStatusDisconnected { + return nil, ErrPushReauth + } + sec, err := s.repo.GetConnectionSecrets(ctx, connID) + if err != nil { + return nil, err + } + if sec == nil || sec.Conn.OrganizationID != orgID { + return nil, errors.New("connection not found") + } + // Concurrent callers share one refresh: with refresh-token rotation, two + // parallel refreshes would leave one holding a dead token. + key := connID.String() + if force { + key += ":force" + } + v, err, _ := s.refresh.Do(key, func() (any, error) { + tok, instance, err := s.accessToken(ctx, sec, force) + if err != nil { + return nil, err + } + return [2]string{tok, instance}, nil + }) + if err != nil { + if refreshRefused(err) { + return nil, fmt.Errorf("%w: %v", ErrPushReauth, err) + } + return nil, fmt.Errorf("could not refresh the access token: %w", err) + } + pair := v.([2]string) + return &ProviderAccess{Token: pair[0], InstanceURL: pair[1], Conn: conn}, nil } // AccessToken loads, refreshes and returns a connection's OAuth token after diff --git a/internal/app/orgtransfer/spec.go b/internal/app/orgtransfer/spec.go index 2bf81fed5..1d381be7b 100644 --- a/internal/app/orgtransfer/spec.go +++ b/internal/app/orgtransfer/spec.go @@ -582,6 +582,17 @@ var Tables = []Table{ Scope: scopeOrg, ResetOnImport: []string{"last_synced_at", "last_result", "last_error"}, }, + { + Name: "salesforce_import_sources", Group: models.OrgDataGroupAutomations, + Scope: scopeOrg, + ResetOnImport: []string{"status", "last_run_at", "last_result", "last_error"}, + Note: "Saved Salesforce list view and Campaign imports. They point at the same org once the connection is reauthorized on the destination.", + }, + { + Name: "salesforce_import_members", Group: models.OrgDataGroupAutomations, + Scope: `source_id IN (SELECT id FROM salesforce_import_sources WHERE organization_id = $1)`, + Note: "Which records each import already brought in, so a recurring import on the destination does not import them again.", + }, // ---------- assistant ---------- { @@ -926,6 +937,9 @@ var ExcludedTables = map[string]string{ "integration_oauth_states": "In-flight OAuth handshakes, valid for minutes and bound to the source instance's redirect URL.", "crm_sync_jobs": "The outbox of pending CRM writes on this instance; the destination's own events feed its outbox.", "crm_sync_cursors": "Pull checkpoints for this instance; the destination starts its own pull.", + "salesforce_record_links": "Which Salesforce record each contact is, with a cached copy of it. The destination links contacts again by address the first time it syncs or shows them, and reads the record fresh.", + "salesforce_activity_queue": "Activity waiting to be logged in Salesforce, and the recent outcome of what was. What was logged is already in Salesforce; what was waiting belongs to this instance's drain.", + "salesforce_sync_state": "Where this instance's pull loop got to in each Salesforce org, and the API calls it counted today. The destination starts its own cursor when the connection first syncs.", "oauth_authorization_codes": "Single-use authorization codes, valid for seconds.", "scheduled_deletions": "Instance lifecycle state. Importing a pending deletion would schedule the destination workspace for destruction.", "dedicated_worker_assignments": "Worker topology, which is a property of the instance rather than the workspace.", diff --git a/internal/app/salesforce/client.go b/internal/app/salesforce/client.go new file mode 100644 index 000000000..720494196 --- /dev/null +++ b/internal/app/salesforce/client.go @@ -0,0 +1,578 @@ +// Package salesforce is Warmbly's native Salesforce sync: the REST client, the +// link between a Warmbly contact and its Lead or Contact, the activity outbox +// that logs campaign events as Tasks, the pull loop that keeps linked records +// current, and imports from list views and Salesforce Campaigns. +package salesforce + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +// APIVersion is the REST version every request targets. +const APIVersion = "v62.0" + +// maxBatch is the record cap of one composite sObject collection call. +const maxBatch = 200 + +// TokenSource hands the client a usable access token and the org's API host. +// force asks for a fresh token after Salesforce refused the current one. +type TokenSource interface { + Token(ctx context.Context, force bool) (token, instanceURL string, err error) +} + +// Usage is the org-wide API consumption Salesforce reports on every response. +type Usage struct { + Used int + Max int +} + +// Client calls one connected Salesforce org. +type Client struct { + http *http.Client + tokens TokenSource + onUsage func(Usage) + + mu sync.Mutex + calls int +} + +// NewClient builds a client over a token source. onUsage, when set, receives +// the Sforce-Limit-Info reading of every response. +func NewClient(ts TokenSource, onUsage func(Usage)) *Client { + return &Client{ + http: &http.Client{Timeout: 30 * time.Second}, + tokens: ts, + onUsage: onUsage, + } +} + +// Calls is how many API requests this client has made. +func (c *Client) Calls() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.calls +} + +// APIError is Salesforce's own refusal, kept whole so the activity log can show +// the errorCode an admin can act on. +type APIError struct { + Status int + Code string + Message string + Fields []string +} + +func (e *APIError) Error() string { + msg := e.Message + if msg == "" { + msg = http.StatusText(e.Status) + } + if e.Code != "" { + msg = e.Code + ": " + msg + } + if len(e.Fields) > 0 { + msg += " (" + strings.Join(e.Fields, ", ") + ")" + } + return msg +} + +var ( + // ErrSessionExpired means a fresh token was refused too. + ErrSessionExpired = errors.New("salesforce refused the session; reconnect required") + // ErrRateLimited means the org has spent its daily API allocation. + ErrRateLimited = errors.New("salesforce API request limit reached") +) + +// IsCode reports whether err is a Salesforce refusal with this errorCode. +func IsCode(err error, code string) bool { + var ae *APIError + return errors.As(err, &ae) && ae.Code == code +} + +// Retryable reports whether a failed call may succeed later unchanged. +func Retryable(err error) bool { + if err == nil { + return false + } + if errors.Is(err, ErrRateLimited) { + return true + } + var ae *APIError + if errors.As(err, &ae) { + switch ae.Code { + case "UNABLE_TO_LOCK_ROW", "SERVER_UNAVAILABLE", "REQUEST_RUNNING_TOO_LONG", "QUERY_TIMEOUT": + return true + } + return ae.Status >= 500 || ae.Status == http.StatusTooManyRequests + } + // Transport failures (timeouts, resets) carry no API error at all. + return !errors.Is(err, ErrSessionExpired) +} + +// Header is an extra request header for one call. +type Header struct{ Key, Value string } + +// do sends one request. path is either a /services/... path or an absolute +// nextRecordsUrl-style path Salesforce handed back. A 401 is answered once with +// a forced token refresh. +func (c *Client) do(ctx context.Context, method, path string, body any, out any, headers ...Header) error { + var payload []byte + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + payload = b + } + for attempt := 0; attempt < 2; attempt++ { + token, instance, err := c.tokens.Token(ctx, attempt > 0) + if err != nil { + return err + } + if instance == "" { + return errors.New("salesforce instance URL unknown; reconnect the integration") + } + var reader io.Reader + if payload != nil { + reader = bytes.NewReader(payload) + } + req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(instance, "/")+path, reader) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + for _, h := range headers { + req.Header.Set(h.Key, h.Value) + } + resp, err := c.http.Do(req) + c.mu.Lock() + c.calls++ + c.mu.Unlock() + if err != nil { + return err + } + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + resp.Body.Close() + if u, ok := parseLimitInfo(resp.Header.Get("Sforce-Limit-Info")); ok && c.onUsage != nil { + c.onUsage(u) + } + if resp.StatusCode == http.StatusUnauthorized { + if attempt == 0 { + continue + } + return ErrSessionExpired + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + if out != nil && len(raw) > 0 { + return json.Unmarshal(raw, out) + } + return nil + } + apiErr := parseAPIError(resp.StatusCode, raw) + if apiErr.Code == "REQUEST_LIMIT_EXCEEDED" { + return fmt.Errorf("%w: %s", ErrRateLimited, apiErr.Message) + } + return apiErr + } + return ErrSessionExpired +} + +func parseAPIError(status int, raw []byte) *APIError { + out := &APIError{Status: status} + var list []struct { + Message string `json:"message"` + ErrorCode string `json:"errorCode"` + Fields []string `json:"fields"` + } + if json.Unmarshal(raw, &list) == nil && len(list) > 0 { + out.Code, out.Message, out.Fields = list[0].ErrorCode, list[0].Message, list[0].Fields + return out + } + // The OAuth endpoints answer with a single object instead. + var single struct { + Error string `json:"error"` + Description string `json:"error_description"` + } + if json.Unmarshal(raw, &single) == nil && single.Error != "" { + out.Code, out.Message = single.Error, single.Description + return out + } + out.Message = strings.TrimSpace(string(raw)) + if r := []rune(out.Message); len(r) > 300 { + out.Message = string(r[:300]) + } + return out +} + +// parseLimitInfo reads "api-usage=18/15000". +func parseLimitInfo(h string) (Usage, bool) { + for _, part := range strings.Split(h, ";") { + k, v, ok := strings.Cut(strings.TrimSpace(part), "=") + if !ok || k != "api-usage" { + continue + } + used, max, ok := strings.Cut(v, "/") + if !ok { + return Usage{}, false + } + u, err1 := strconv.Atoi(used) + m, err2 := strconv.Atoi(max) + if err1 != nil || err2 != nil { + return Usage{}, false + } + return Usage{Used: u, Max: m}, true + } + return Usage{}, false +} + +func dataPath(rest string) string { + return "/services/data/" + APIVersion + rest +} + +// Record is one row as Salesforce returns it, relationship fields nested. +type Record map[string]any + +// String reads a field, following a dotted relationship path ("Owner.Name"). +func (r Record) String(path string) string { + var cur any = map[string]any(r) + for _, part := range strings.Split(path, ".") { + m, ok := cur.(map[string]any) + if !ok { + return "" + } + cur = m[part] + } + switch v := cur.(type) { + case string: + return v + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case bool: + return strconv.FormatBool(v) + default: + return "" + } +} + +// Bool reads a boolean field. +func (r Record) Bool(field string) bool { + b, _ := r[field].(bool) + return b +} + +// Time reads a datetime field. +func (r Record) Time(field string) *time.Time { + s := r.String(field) + if s == "" { + return nil + } + for _, layout := range []string{"2006-01-02T15:04:05.000-0700", time.RFC3339Nano, "2006-01-02"} { + if t, err := time.Parse(layout, s); err == nil { + t = t.UTC() + return &t + } + } + return nil +} + +type queryPage struct { + Done bool `json:"done"` + TotalSize int `json:"totalSize"` + NextRecordsURL string `json:"nextRecordsUrl"` + Records []Record `json:"records"` +} + +// Query runs SOQL and hands every page to fn until the result is exhausted, +// fn returns false, or max records (0 = no cap) have been read. +func (c *Client) Query(ctx context.Context, soql string, max int, fn func([]Record) bool) (total int, err error) { + path := dataPath("/query?q=" + url.QueryEscape(soql)) + read := 0 + for path != "" { + var page queryPage + if err := c.do(ctx, http.MethodGet, path, nil, &page); err != nil { + return total, err + } + total = page.TotalSize + recs := page.Records + if max > 0 && read+len(recs) > max { + recs = recs[:max-read] + } + for _, r := range recs { + delete(r, "attributes") + } + read += len(recs) + if !fn(recs) || page.Done || (max > 0 && read >= max) { + return total, nil + } + path = page.NextRecordsURL + } + return total, nil +} + +// QueryAll is Query collected into one slice. +func (c *Client) QueryAll(ctx context.Context, soql string, max int) ([]Record, error) { + var out []Record + _, err := c.Query(ctx, soql, max, func(rs []Record) bool { + out = append(out, rs...) + return true + }) + return out, err +} + +// PicklistValue is one option of a picklist field. +type PicklistValue struct { + Value string `json:"value"` + Label string `json:"label"` + Active bool `json:"active"` + DefaultValue bool `json:"defaultValue"` +} + +// Field is one field of an object's describe. +type Field struct { + Name string `json:"name"` + Label string `json:"label"` + Type string `json:"type"` + Length int `json:"length"` + Createable bool `json:"createable"` + Updateable bool `json:"updateable"` + Calculated bool `json:"calculated"` + AutoNumber bool `json:"autoNumber"` + Custom bool `json:"custom"` + Nillable bool `json:"nillable"` + DefaultedOn bool `json:"defaultedOnCreate"` + Restricted bool `json:"restrictedPicklist"` + PicklistValues []PicklistValue `json:"picklistValues"` + ReferenceTo []string `json:"referenceTo"` +} + +// Describe is the subset of an object's describe the sync reads. +type Describe struct { + Name string `json:"name"` + Label string `json:"label"` + Createable bool `json:"createable"` + Updateable bool `json:"updateable"` + Queryable bool `json:"queryable"` + Fields []Field `json:"fields"` +} + +// Field returns the named field, or nil. +func (d *Describe) Field(name string) *Field { + for i := range d.Fields { + if strings.EqualFold(d.Fields[i].Name, name) { + return &d.Fields[i] + } + } + return nil +} + +// Describe reads an object's metadata. +func (c *Client) Describe(ctx context.Context, object string) (*Describe, error) { + var d Describe + if err := c.do(ctx, http.MethodGet, dataPath("/sobjects/"+url.PathEscape(object)+"/describe"), nil, &d); err != nil { + return nil, err + } + return &d, nil +} + +// ListView is a saved list view of an object. +type ListView struct { + ID string `json:"id"` + Label string `json:"label"` + Name string `json:"developerName"` +} + +// ListViews returns the list views the connected user can see on an object. +func (c *Client) ListViews(ctx context.Context, object string) ([]ListView, error) { + var out struct { + ListViews []ListView `json:"listviews"` + NextURL string `json:"nextRecordsUrl"` + } + if err := c.do(ctx, http.MethodGet, dataPath("/sobjects/"+url.PathEscape(object)+"/listviews?limit=200"), nil, &out); err != nil { + return nil, err + } + for i := range out.ListViews { + out.ListViews[i].ID = NormalizeID(out.ListViews[i].ID) + } + return out.ListViews, nil +} + +// ListViewQuery returns the SOQL a list view runs, so its whole result can be +// paged through /query instead of the 2,000-row results resource. +func (c *Client) ListViewQuery(ctx context.Context, object, id string) (string, error) { + var out struct { + Query string `json:"query"` + } + if err := c.do(ctx, http.MethodGet, dataPath("/sobjects/"+url.PathEscape(object)+"/listviews/"+url.PathEscape(id)+"/describe"), nil, &out); err != nil { + return "", err + } + if strings.TrimSpace(out.Query) == "" { + return "", errors.New("salesforce returned no query for this list view") + } + return out.Query, nil +} + +// SaveResult is the outcome of one record in a collection write. +type SaveResult struct { + ID string `json:"id"` + Success bool `json:"success"` + Errors []struct { + StatusCode string `json:"statusCode"` + Message string `json:"message"` + Fields []string `json:"fields"` + } `json:"errors"` +} + +// Err turns a failed result into an APIError. +func (r SaveResult) Err() error { + if r.Success { + return nil + } + if len(r.Errors) == 0 { + return &APIError{Status: http.StatusBadRequest, Message: "salesforce rejected the record"} + } + e := r.Errors[0] + return &APIError{Status: http.StatusBadRequest, Code: e.StatusCode, Message: e.Message, Fields: e.Fields} +} + +// Create inserts records of one object, up to 200 per call, without letting one +// bad record fail the rest. Results are in input order. +func (c *Client) Create(ctx context.Context, object string, records []map[string]any, headers ...Header) ([]SaveResult, error) { + return c.collection(ctx, http.MethodPost, object, records, headers...) +} + +// Update patches records (each carrying Id) of one object. +func (c *Client) Update(ctx context.Context, object string, records []map[string]any, headers ...Header) ([]SaveResult, error) { + return c.collection(ctx, http.MethodPatch, object, records, headers...) +} + +func (c *Client) collection(ctx context.Context, method, object string, records []map[string]any, headers ...Header) ([]SaveResult, error) { + out := make([]SaveResult, 0, len(records)) + for start := 0; start < len(records); start += maxBatch { + end := min(start+maxBatch, len(records)) + chunk := make([]map[string]any, 0, end-start) + for _, r := range records[start:end] { + rec := make(map[string]any, len(r)+1) + for k, v := range r { + rec[k] = v + } + rec["attributes"] = map[string]string{"type": object} + chunk = append(chunk, rec) + } + var res []SaveResult + body := map[string]any{"allOrNone": false, "records": chunk} + if err := c.do(ctx, method, dataPath("/composite/sobjects"), body, &res, headers...); err != nil { + return out, err + } + for i := range res { + res[i].ID = NormalizeID(res[i].ID) + } + out = append(out, res...) + } + return out, nil +} + +// Get reads one record with the named fields. +func (c *Client) Get(ctx context.Context, object, id string, fields []string) (Record, error) { + var out Record + path := dataPath("/sobjects/" + url.PathEscape(object) + "/" + url.PathEscape(id)) + if len(fields) > 0 { + path += "?fields=" + url.QueryEscape(strings.Join(fields, ",")) + } + if err := c.do(ctx, http.MethodGet, path, nil, &out); err != nil { + return nil, err + } + delete(out, "attributes") + return out, nil +} + +// IdentityIDs pulls the org and user ids out of the identity URL the token +// response carries (https://login.salesforce.com/id//). +func IdentityIDs(identityURL string) (orgID, userID string) { + u, err := url.Parse(identityURL) + if err != nil { + return "", "" + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) >= 3 && parts[0] == "id" { + return NormalizeID(parts[1]), NormalizeID(parts[2]) + } + return "", "" +} + +// Limits reads the org's daily API allocation. +func (c *Client) Limits(ctx context.Context) (Usage, error) { + var out map[string]struct { + Max int `json:"Max"` + Remaining int `json:"Remaining"` + } + if err := c.do(ctx, http.MethodGet, dataPath("/limits"), nil, &out); err != nil { + return Usage{}, err + } + d := out["DailyApiRequests"] + return Usage{Used: d.Max - d.Remaining, Max: d.Max}, nil +} + +// Quote renders s as a SOQL string literal. +func Quote(s string) string { + r := strings.NewReplacer(`\`, `\\`, `'`, `\'`, "\n", `\n`, "\r", `\r`, "\t", `\t`, "\"", `\"`) + return "'" + r.Replace(s) + "'" +} + +// QuoteList renders values as the body of a SOQL IN clause. +func QuoteList(values []string) string { + parts := make([]string, len(values)) + for i, v := range values { + parts[i] = Quote(v) + } + return "(" + strings.Join(parts, ",") + ")" +} + +// NormalizeID returns the 18-character form of a Salesforce id. The API speaks +// 18 characters and the UI 15; storing one form keeps comparisons honest. +func NormalizeID(id string) string { + id = strings.TrimSpace(id) + if len(id) != 15 { + return id + } + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345" + suffix := make([]byte, 3) + for block := 0; block < 3; block++ { + n := 0 + for i := 0; i < 5; i++ { + ch := id[block*5+i] + if ch >= 'A' && ch <= 'Z' { + n |= 1 << i + } + } + suffix[block] = alphabet[n] + } + return id + string(suffix) +} + +// ValidID reports whether s looks like a Salesforce record id. +func ValidID(s string) bool { + if len(s) != 15 && len(s) != 18 { + return false + } + for _, r := range s { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') { + return false + } + } + return true +} diff --git a/internal/app/salesforce/client_test.go b/internal/app/salesforce/client_test.go new file mode 100644 index 000000000..6c8109039 --- /dev/null +++ b/internal/app/salesforce/client_test.go @@ -0,0 +1,50 @@ +package salesforce + +import "testing" + +func TestNormalizeID(t *testing.T) { + cases := map[string]string{ + "001A0000006Vm9r": "001A0000006Vm9rIAC", + "003000000000001": "003000000000001AAA", + "001A0000006Vm9rIAC": "001A0000006Vm9rIAC", + "": "", + } + for in, want := range cases { + if got := NormalizeID(in); got != want { + t.Errorf("NormalizeID(%q) = %q, want %q", in, got, want) + } + } +} + +func TestQuoteEscapesBackslashBeforeQuote(t *testing.T) { + if got := Quote(`o'neil\`); got != `'o\'neil\\'` { + t.Fatalf("Quote = %s", got) + } +} + +func TestParseLimitInfo(t *testing.T) { + u, ok := parseLimitInfo("api-usage=18/15000") + if !ok || u.Used != 18 || u.Max != 15000 { + t.Fatalf("got %+v %v", u, ok) + } + if _, ok := parseLimitInfo("per-app-api-usage=1/2(appName=x)"); ok { + t.Fatal("a per-app reading is not the org budget") + } +} + +func TestIdentityIDs(t *testing.T) { + org, user := IdentityIDs("https://login.salesforce.com/id/00Dxx0000001gPLEAY/005xx000001SwiUAAS") + if org != "00Dxx0000001gPLEAY" || user != "005xx000001SwiUAAS" { + t.Fatalf("got %s %s", org, user) + } +} + +func TestRecordStringFollowsRelationships(t *testing.T) { + r := Record{"Owner": map[string]any{"Name": "Ada"}, "Amount": 1200.5, "IsWon": true} + if r.String("Owner.Name") != "Ada" || r.String("Amount") != "1200.5" || r.String("IsWon") != "true" { + t.Fatalf("unexpected %v", r) + } + if r.String("Missing.Name") != "" { + t.Fatal("a missing relationship reads empty") + } +} diff --git a/internal/app/salesforce/drain.go b/internal/app/salesforce/drain.go new file mode 100644 index 000000000..fe64d4b19 --- /dev/null +++ b/internal/app/salesforce/drain.go @@ -0,0 +1,755 @@ +package salesforce + +import ( + "context" + "encoding/json" + "errors" + "html" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + + "github.com/warmbly/warmbly/internal/app/integration" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +const ( + drainBatch = 500 + drainLease = 10 * time.Minute + maxAttempts = 6 + descriptionCap = 32000 +) + +// Drain logs due outbox activity in Salesforce. Run on a short interval; each +// pass leases what it takes, so replicas never log the same event twice. +func (s *Service) Drain(ctx context.Context) error { + items, err := s.Repo.ClaimDueActivities(ctx, drainBatch, drainLease) + if err != nil || len(items) == 0 { + return err + } + byConn := map[uuid.UUID][]models.SalesforceActivity{} + orgOf := map[uuid.UUID]uuid.UUID{} + for _, a := range items { + byConn[a.ConnectionID] = append(byConn[a.ConnectionID], a) + orgOf[a.ConnectionID] = a.OrganizationID + } + for connID, list := range byConn { + s.drainConnection(ctx, orgOf[connID], connID, list) + } + return nil +} + +// DrainConnection logs one connection's due activity now ("Sync now"). +func (s *Service) drainConnection(ctx context.Context, orgID, connID uuid.UUID, items []models.SalesforceActivity) { + c, err := s.open(ctx, orgID, connID) + if err != nil { + s.deferAll(ctx, items, 15*time.Minute, "Salesforce connection not available right now") + return + } + defer s.settle(ctx, c) + if !c.settings.Enabled { + s.finishAll(ctx, items, models.SalesforceActivitySkipped, "Salesforce sync is turned off") + return + } + if c.Status == models.IntegrationStatusReauthRequired { + s.deferAll(ctx, items, time.Hour, "Waiting for Salesforce to be reconnected") + return + } + if s.overBudget(ctx, c) { + s.deferAll(ctx, items, untilTomorrow(), "Daily Salesforce API budget reached; resumes tomorrow") + return + } + if err := s.logActivities(ctx, c, items); err != nil { + log.Warn().Err(err).Str("connection", connID.String()).Msg("salesforce: activity batch failed") + s.retryAll(ctx, items, err) + } +} + +// pending is one activity on its way to Salesforce. +type pending struct { + a models.SalesforceActivity + sent *repository.SalesforceSentContent + contact repository.SalesforceContact + link *models.SalesforceRecordLink + task map[string]any + done bool +} + +func (s *Service) logActivities(ctx context.Context, c *conn, items []models.SalesforceActivity) error { + // Resolve every activity to a Warmbly contact in the organization. + var ids []uuid.UUID + var emails []string + for _, a := range items { + if a.ContactID != nil { + ids = append(ids, *a.ContactID) + } else { + emails = append(emails, a.ContactEmail) + } + } + byID, err := s.Repo.ContactsByIDs(ctx, c.OrganizationID, ids) + if err != nil { + return err + } + byEmail, err := s.Repo.ContactsByEmails(ctx, c.OrganizationID, emails) + if err != nil { + return err + } + work := make([]*pending, 0, len(items)) + for _, a := range items { + p := &pending{a: a} + if a.ContactID != nil { + p.contact = byID[*a.ContactID] + } + if p.contact.ID == uuid.Nil { + p.contact = byEmail[strings.ToLower(a.ContactEmail)] + } + if p.contact.ID == uuid.Nil { + s.finish(ctx, p, models.SalesforceActivitySkipped, "", "Not a contact in this workspace") + continue + } + if a.Kind == KindSent && !s.sendWentOut(ctx, p) { + continue + } + work = append(work, p) + } + if len(work) == 0 { + return nil + } + + // Link: existing links, then a match, then creation when the rule allows. + var contacts []repository.SalesforceContact + seen := map[uuid.UUID]bool{} + createFor := map[uuid.UUID]bool{} + sender := map[uuid.UUID]string{} + for _, p := range work { + if !seen[p.contact.ID] { + seen[p.contact.ID] = true + contacts = append(contacts, p.contact) + } + if mayCreate(c.settings, p.a.Kind) { + createFor[p.contact.ID] = true + } + if e := s.senderEmail(ctx, c.OrganizationID, p.a); e != "" { + sender[p.contact.ID] = e + } + } + links, failed := s.ensureLinks(ctx, c, contacts, "", "", nil) + // People still missing are created in groups that share an owner rule input. + bySender := map[string][]repository.SalesforceContact{} + for _, ct := range contacts { + if _, ok := links[ct.ID]; !ok && failed[ct.ID] == nil && createFor[ct.ID] { + bySender[sender[ct.ID]] = append(bySender[sender[ct.ID]], ct) + } + } + for from, group := range bySender { + created, cerr := s.ensureLinks(ctx, c, group, c.settings.Matching.CreateAs, from, nil) + for id, e := range cerr { + failed[id] = e + } + for id, l := range created { + links[id] = l + } + } + for _, p := range work { + if e := failed[p.contact.ID]; e != nil { + s.fail(ctx, p, e) + p.done = true + continue + } + l, ok := links[p.contact.ID] + if !ok { + why := "No Lead or Contact in Salesforce for this address" + if c.settings.Matching.CreateWhen == "reply" && !mayCreate(c.settings, p.a.Kind) { + why += "; one is created when they reply" + } + s.finish(ctx, p, models.SalesforceActivitySkipped, "", why) + p.done = true + continue + } + lc := l + p.link = &lc + } + + // Tasks. Rows are already settled above, so a failure here settles the rest + // rather than handing the whole batch back. + if err := s.buildTasks(ctx, c, work); err != nil { + for _, p := range work { + if !p.done { + s.fail(ctx, p, err) + p.done = true + } + } + return nil + } + var withTask []*pending + for _, p := range work { + if !p.done && p.task != nil { + withTask = append(withTask, p) + } + } + if len(withTask) > 0 { + s.createTasks(ctx, c, withTask, true) + } + + // Writeback: status, opt-out and pushed fields, one update per record. + s.writeback(ctx, c, work, links) + + for _, p := range work { + if p.done { + continue + } + detail := "" + if p.task == nil { + detail = "Fields updated; no Task for this kind of activity" + } + s.finish(ctx, p, models.SalesforceActivitySynced, "", detail) + } + return nil +} + +// sendWentOut settles a send event whose email never left: the dispatch is +// recorded before the worker answers, and a failed send retries under a new +// task. A send still in flight waits a little. +func (s *Service) sendWentOut(ctx context.Context, p *pending) bool { + id, err := uuid.Parse(str(p.a.Payload, "task_id")) + if err != nil { + return true + } + sent, err := s.Repo.SentContent(ctx, p.a.OrganizationID, id) + if err != nil || sent == nil { + return true + } + p.sent = sent + switch sent.Status { + case "completed": + return true + case "pending", "active": + if time.Since(p.a.OccurredAt) > 24*time.Hour { + s.finish(ctx, p, models.SalesforceActivitySkipped, "", "The send never completed") + return false + } + p.a.Status = models.SalesforceActivityPending + p.a.NextAttemptAt = time.Now().UTC().Add(3 * time.Minute) + p.a.Detail = "Waiting for the send to complete" + _ = s.Repo.FinishActivity(ctx, &p.a) + return false + default: + s.finish(ctx, p, models.SalesforceActivitySkipped, "", "The email was not sent") + return false + } +} + +// mayCreate reports whether an activity of this kind may create its person. +// Bounces, opt-outs and opens never create anyone. +func mayCreate(st Settings, kind string) bool { + switch st.Matching.CreateWhen { + case "send": + return kind == KindSent || kind == KindReplied || kind == KindMeetingBooked + case "reply": + return kind == KindReplied || kind == KindMeetingBooked + } + return false +} + +func (s *Service) senderEmail(ctx context.Context, orgID uuid.UUID, a models.SalesforceActivity) string { + if e := str(a.Payload, "from_email"); e != "" { + return e + } + for _, k := range []string{"email_account_id", "sender_email_account_id"} { + if id, err := uuid.Parse(str(a.Payload, k)); err == nil { + if e, err := s.Repo.MailboxEmail(ctx, orgID, id); err == nil && e != "" { + return e + } + } + } + return "" +} + +// buildTasks writes the Task for every activity whose kind is logged. +func (s *Service) buildTasks(ctx context.Context, c *conn, work []*pending) error { + meta, err := s.describe(ctx, c) + if err != nil { + return err + } + opps := s.openOpportunities(ctx, c, work) + for _, p := range work { + if p.done || p.link == nil || !c.settings.Activity.Logs(p.a.Kind) { + continue + } + subject, body := s.taskText(ctx, c, p.a, p.sent) + t := map[string]any{ + "Subject": truncate(subject, 255), + "Status": meta.closedTask, + "ActivityDate": p.a.OccurredAt.UTC().Format("2006-01-02"), + "WhoId": p.link.RecordID, + } + if isEmailKind(p.a.Kind) { + t["TaskSubtype"] = "Email" + } + if body != "" { + t["Description"] = truncate(body, descriptionCap) + } + if p.link.SObject == ObjectContact && c.settings.Activity.RelateToOpportunity { + if opp := opps[p.link.AccountID]; opp != "" { + t["WhatId"] = opp + } + } + if owner := s.taskOwner(ctx, c, p); owner != "" { + t["OwnerId"] = owner + } + p.task = t + } + return nil +} + +func isEmailKind(kind string) bool { + switch kind { + case KindSent, KindReplied, KindOpened, KindClicked, KindBounced: + return true + } + return false +} + +// taskOwner picks who owns the logged Task. A queue cannot own a Task, so a +// Lead sitting in a queue falls back to the connected user. +func (s *Service) taskOwner(ctx context.Context, c *conn, p *pending) string { + switch c.settings.Activity.AssignTo { + case "record_owner": + if strings.HasPrefix(p.link.OwnerID, "005") { + return p.link.OwnerID + } + case "sender": + if id := s.userByEmail(ctx, c, s.senderEmail(ctx, c.OrganizationID, p.a)); id != "" { + return id + } + } + return c.sfUserID() +} + +// openOpportunities maps each Contact's account to its most recently touched +// open opportunity. +func (s *Service) openOpportunities(ctx context.Context, c *conn, work []*pending) map[string]string { + out := map[string]string{} + if !c.settings.Activity.RelateToOpportunity { + return out + } + var accounts []string + seen := map[string]bool{} + for _, p := range work { + if p.link != nil && p.link.SObject == ObjectContact && p.link.AccountID != "" && !seen[p.link.AccountID] { + seen[p.link.AccountID] = true + accounts = append(accounts, p.link.AccountID) + } + } + for start := 0; start < len(accounts); start += 200 { + chunk := accounts[start:min(start+200, len(accounts))] + rows, err := c.client.QueryAll(ctx, "SELECT Id, AccountId FROM Opportunity WHERE IsClosed = false AND AccountId IN "+QuoteList(chunk)+" ORDER BY LastModifiedDate DESC", 0) + if err != nil { + return out + } + for _, r := range rows { + acc := NormalizeID(r.String("AccountId")) + if _, ok := out[acc]; !ok { + out[acc] = NormalizeID(r.String("Id")) + } + } + } + return out +} + +// taskText renders a Task's subject and description the way a rep would have +// written them. +func (s *Service) taskText(ctx context.Context, c *conn, a models.SalesforceActivity, sent *repository.SalesforceSentContent) (string, string) { + content := s.openContent(ctx, a) + var lines []string + meta := func(label, v string) { + if v != "" { + lines = append(lines, label+": "+v) + } + } + subject := content.Subject + body := "" + switch a.Kind { + case KindSent: + body = htmlToText(content.Body) + if sent != nil { + if subject == "" { + subject = sent.Subject + } + meta("Campaign", sent.Campaign) + meta("Step", sent.Step) + meta("From", sent.FromEmail) + } + meta("To", a.ContactEmail) + subject = "Email sent: " + orDefault(subject, "(no subject)") + case KindReplied: + meta("From", a.ContactEmail) + meta("Intent", humanIntent(str(a.Payload, "intent"))) + body = content.Body + subject = "Reply received: " + orDefault(subject, "(no subject)") + case KindOpened: + subject = "Email opened" + meta("By", a.ContactEmail) + case KindClicked: + link := orDefault(str(a.Payload, "link_label"), str(a.Payload, "url")) + subject = "Link clicked: " + orDefault(link, "a tracked link") + meta("URL", str(a.Payload, "url")) + case KindBounced: + subject = "Email bounced" + meta("Reason", str(a.Payload, "reason")) + case KindUnsubscribed: + subject = "Unsubscribed from Warmbly outreach" + if str(a.Payload, "source") == "complaint" { + subject = "Marked Warmbly outreach as spam" + } + case KindMeetingBooked: + subject = "Meeting booked: " + orDefault(str(a.Payload, "event_name"), "meeting") + meta("When", str(a.Payload, "scheduled_for")) + meta("Join", str(a.Payload, "join_url")) + } + desc := strings.Join(lines, "\n") + if c.settings.Activity.IncludeBody && strings.TrimSpace(body) != "" { + if desc != "" { + desc += "\n\n" + } + desc += strings.TrimSpace(body) + } + if desc != "" { + desc += "\n\n" + } + desc += "Logged by Warmbly" + return subject, desc +} + +func (s *Service) openContent(ctx context.Context, a models.SalesforceActivity) activityContent { + var out activityContent + if a.ContentEncrypted == "" || s.Cipher == nil { + return out + } + ci, err := s.Cipher.Cipher(ctx, a.OrganizationID) + if err != nil { + return out + } + plain, err := ci.Decrypt(ctx, a.ContentEncrypted) + if err != nil { + return out + } + _ = json.Unmarshal([]byte(plain), &out) + return out +} + +var ( + reBlockTags = regexp.MustCompile(`(?i)<\s*(br|/p|/div|/li|/tr|/h[1-6])\s*/?>`) + reTags = regexp.MustCompile(`(?s)<[^>]*>`) + reBlank = regexp.MustCompile(`\n{3,}`) + reStyle = regexp.MustCompile(`(?is)<(style|script)[^>]*>.*?`) +) + +// htmlToText is a plain rendering of an email body for a Task description. +func htmlToText(s string) string { + if !strings.Contains(s, "<") { + return strings.TrimSpace(s) + } + s = reStyle.ReplaceAllString(s, "") + s = reBlockTags.ReplaceAllString(s, "\n") + s = reTags.ReplaceAllString(s, "") + s = html.UnescapeString(s) + s = strings.ReplaceAll(s, "\r", "") + s = reBlank.ReplaceAllString(s, "\n\n") + return strings.TrimSpace(s) +} + +func humanIntent(v string) string { + switch v { + case "positive": + return "Interested" + case "negative": + return "Not interested" + case "out_of_office": + return "Out of office" + case "question": + return "Question" + case "neutral": + return "Neutral" + case "automated": + return "Automated reply" + } + return v +} + +func orDefault(v, d string) string { + if strings.TrimSpace(v) == "" { + return d + } + return v +} + +// createTasks inserts the Tasks. A Task refused over its owner (an inactive +// user, a queue) is retried once owned by the connected user. +func (s *Service) createTasks(ctx context.Context, c *conn, work []*pending, retryOwner bool) { + records := make([]map[string]any, len(work)) + for i, p := range work { + records[i] = p.task + } + res, err := c.client.Create(ctx, "Task", records) + if err != nil { + for _, p := range work { + s.fail(ctx, p, err) + p.done = true + } + return + } + var again []*pending + for i, p := range work { + if i >= len(res) { + s.fail(ctx, p, errors.New("salesforce returned no result for this Task")) + p.done = true + continue + } + if e := res[i].Err(); e != nil { + var ae *APIError + if retryOwner && errors.As(e, &ae) && ownerProblem(ae) { + delete(p.task, "OwnerId") + again = append(again, p) + continue + } + if errors.As(e, &ae) && taskSubtypeProblem(ae) { + delete(p.task, "TaskSubtype") + again = append(again, p) + continue + } + s.fail(ctx, p, e) + p.done = true + continue + } + s.finish(ctx, p, models.SalesforceActivitySynced, res[i].ID, "") + p.done = true + } + if len(again) > 0 { + s.createTasks(ctx, c, again, false) + } +} + +func ownerProblem(e *APIError) bool { + for _, f := range e.Fields { + if strings.EqualFold(f, "OwnerId") { + return true + } + } + return e.Code == "INACTIVE_OWNER_OR_USER" || e.Code == "INVALID_CROSS_REFERENCE_KEY" && strings.Contains(e.Message, "owner") +} + +func taskSubtypeProblem(e *APIError) bool { + for _, f := range e.Fields { + if strings.EqualFold(f, "TaskSubtype") { + return true + } + } + return false +} + +// writeback applies status, opt-out and pushed-field changes for the batch. +func (s *Service) writeback(ctx context.Context, c *conn, work []*pending, links map[uuid.UUID]models.SalesforceRecordLink) { + meta, _ := s.describe(ctx, c) + var leadDescribe *Describe + if meta != nil { + leadDescribe = meta.lead + } + changes := map[uuid.UUID]map[string]any{} + var ids []uuid.UUID + contactByID := map[uuid.UUID]repository.SalesforceContact{} + for _, p := range work { + if p.link == nil { + continue + } + cid := p.contact.ID + if _, ok := changes[cid]; !ok { + changes[cid] = map[string]any{} + ids = append(ids, cid) + contactByID[cid] = p.contact + } + ch := changes[cid] + if p.link.SObject == ObjectLead && !p.link.IsConverted { + target := "" + w := c.settings.Writeback + switch p.a.Kind { + case KindSent: + target = w.LeadStatusOnSent + case KindReplied: + target = w.statusForReply(str(p.a.Payload, "intent")) + case KindMeetingBooked: + target = w.LeadStatusOnMeeting + } + if target != "" && !strings.EqualFold(target, p.link.LeadStatus) { + cur := p.link.LeadStatus + if v, ok := ch["Status"].(string); ok { + cur = v + } + if !w.NeverMoveBackwards || statusRank(leadDescribe, target) >= statusRank(leadDescribe, cur) { + ch["Status"] = target + } + } + } + if p.a.Kind == KindUnsubscribed && !p.link.OptedOut && + (c.settings.Inbound.OptOut == "both" || c.settings.Inbound.OptOut == "to_salesforce") { + ch["HasOptedOutOfEmail"] = true + } + } + eng, _ := s.Repo.Engagement(ctx, c.OrganizationID, ids) + for _, cid := range ids { + var e *repository.SalesforceEngagement + if v, ok := eng[cid]; ok { + e = &v + } + for k, v := range pushChanges(c.settings, links[cid], contactByID[cid], e) { + if _, set := changes[cid][k]; !set { + changes[cid][k] = v + } + } + } + errs := s.applyUpdates(ctx, c, changes, links) + var pushed []uuid.UUID + for _, cid := range ids { + if errs[cid] == nil { + pushed = append(pushed, links[cid].ID) + } + } + _ = s.Repo.MarkLinksPushed(ctx, pushed) + for _, p := range work { + if p.link == nil { + continue + } + if e := errs[p.contact.ID]; e != nil { + if p.done { + // The Task landed; the record update did not. Say so on the row. + s.annotate(ctx, p, "Task logged, but updating the record failed: "+e.Error()) + continue + } + s.fail(ctx, p, e) + p.done = true + } + } +} + +// --- outcomes --------------------------------------------------------------- + +func (s *Service) finish(ctx context.Context, p *pending, status, taskID, detail string) { + p.a.Status = status + p.a.Detail = detail + if taskID != "" { + p.a.TaskID = taskID + } + if p.link != nil { + p.a.RecordID = p.link.RecordID + } + p.a.Attempts++ + _ = s.Repo.FinishActivity(ctx, &p.a) +} + +func (s *Service) annotate(ctx context.Context, p *pending, detail string) { + p.a.Detail = truncate(detail, 1000) + _ = s.Repo.FinishActivity(ctx, &p.a) +} + +// fail retries a transient failure with backoff and gives up on anything +// Salesforce will refuse again unchanged. +func (s *Service) fail(ctx context.Context, p *pending, err error) { + if errors.Is(err, integration.ErrPushReauth) || errors.Is(err, ErrSessionExpired) { + // Not the activity's fault: hold it until the connection is fixed. + p.a.Status = models.SalesforceActivityPending + p.a.NextAttemptAt = time.Now().UTC().Add(time.Hour) + p.a.Detail = "Waiting for Salesforce to be reconnected" + _ = s.Repo.FinishActivity(ctx, &p.a) + return + } + if errors.Is(err, ErrRateLimited) { + p.a.Status = models.SalesforceActivityPending + p.a.NextAttemptAt = time.Now().UTC().Add(untilTomorrow()) + p.a.Detail = "Salesforce API limit reached for today; resumes tomorrow" + _ = s.Repo.FinishActivity(ctx, &p.a) + return + } + p.a.Attempts++ + p.a.Detail = truncate(describeErr(err), 1000) + if p.link != nil { + p.a.RecordID = p.link.RecordID + _ = s.Repo.SetLinkError(ctx, p.link.ID, describeErr(err)) + } + if Retryable(err) && p.a.Attempts < maxAttempts { + p.a.Status = models.SalesforceActivityPending + p.a.NextAttemptAt = time.Now().UTC().Add(backoff(p.a.Attempts)) + } else { + p.a.Status = models.SalesforceActivityFailed + } + _ = s.Repo.FinishActivity(ctx, &p.a) +} + +func backoff(attempt int) time.Duration { + d := time.Minute << min(attempt, 8) + return min(d, 6*time.Hour) +} + +func (s *Service) finishAll(ctx context.Context, items []models.SalesforceActivity, status, detail string) { + for i := range items { + p := &pending{a: items[i]} + s.finish(ctx, p, status, "", detail) + } +} + +func (s *Service) deferAll(ctx context.Context, items []models.SalesforceActivity, after time.Duration, detail string) { + for i := range items { + a := items[i] + a.Status = models.SalesforceActivityPending + a.NextAttemptAt = time.Now().UTC().Add(after) + a.Detail = detail + _ = s.Repo.FinishActivity(ctx, &a) + } +} + +func (s *Service) retryAll(ctx context.Context, items []models.SalesforceActivity, err error) { + for i := range items { + p := &pending{a: items[i]} + s.fail(ctx, p, err) + } +} + +func untilTomorrow() time.Duration { + now := time.Now().UTC() + next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 5, 0, 0, time.UTC) + return next.Sub(now) +} + +// SyncNow logs a connection's pending activity and pulls its changes at once. +// One run per connection at a time; a second click while it runs is a no-op. +func (s *Service) SyncNow(ctx context.Context, orgID, connID uuid.UUID) error { + c, err := s.open(ctx, orgID, connID) + if err != nil { + return err + } + if !c.settings.Enabled { + return errx.NewWithIdentifier(errx.BadRequest, "salesforce_sync_off", "Turn Salesforce sync on first.") + } + if _, running := s.syncing.LoadOrStore(connID, true); running { + return nil + } + _ = s.Repo.EnsureSyncState(ctx, connID, orgID, time.Now().UTC()) + _ = s.Repo.MakeDue(ctx, orgID, connID) + go func() { + defer s.syncing.Delete(connID) + bg, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + if items, err := s.Repo.ClaimDueForConnection(bg, connID, drainBatch, drainLease); err == nil && len(items) > 0 { + s.drainConnection(bg, orgID, connID, items) + } + if c2, err := s.open(bg, orgID, connID); err == nil && !s.overBudget(bg, c2) { + s.pullConnection(bg, c2) + s.settle(bg, c2) + } + }() + return nil +} diff --git a/internal/app/salesforce/importer.go b/internal/app/salesforce/importer.go new file mode 100644 index 000000000..b7a3965b1 --- /dev/null +++ b/internal/app/salesforce/importer.go @@ -0,0 +1,520 @@ +package salesforce + +import ( + "bytes" + "context" + "encoding/csv" + "errors" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +const ( + // importCap bounds how many people one run imports; the rest come in on + // the next run, which skips everyone already brought in. + importCap = 10000 + // readCap bounds how many records one run reads to find them. + readCap = 50000 + // RecurringEvery is how often a "keep in sync" source re-reads its list. + RecurringEvery = 30 * time.Minute +) + +// person is one importable record, whatever it was read from. +type person struct { + recordID string + object string + email string + first string + last string + company string + title string + phone string + owner string + status string + optedOut bool + rec Record +} + +// ImportSource names what to read. +type ImportSource struct { + SourceKind string `json:"source_kind"` + Object string `json:"object"` + SourceID string `json:"source_id"` +} + +func (in *ImportSource) validate() error { + in.SourceID = NormalizeID(in.SourceID) + if !ValidID(in.SourceID) { + return errx.New(errx.BadRequest, "source_id is not a Salesforce id") + } + switch in.SourceKind { + case "list_view": + if in.Object != ObjectLead && in.Object != ObjectContact { + return errx.New(errx.BadRequest, "a list view import reads Leads or Contacts") + } + case "campaign": + in.Object = "CampaignMember" + default: + return errx.New(errx.BadRequest, "source_kind must be list_view or campaign") + } + return nil +} + +var reSelect = regexp.MustCompile(`(?is)^\s*SELECT\s+(.*?)\s+FROM\s+`) + +// listViewSOQL rewrites a list view's query to select the fields an import +// needs, keeping its filters, scope and order. +func listViewSOQL(st Settings, object, viewSOQL string) (string, bool) { + m := reSelect.FindStringSubmatchIndex(viewSOQL) + if m == nil { + return "", false + } + if strings.Contains(strings.ToUpper(viewSOQL[m[2]:m[3]]), "(SELECT") { + return "", false + } + return "SELECT " + strings.Join(selectFields(st, object), ", ") + " FROM " + viewSOQL[m[1]:], true +} + +// read collects up to max people from a source. +func (s *Service) read(ctx context.Context, c *conn, in ImportSource, max int) ([]person, int, error) { + var out []person + if in.SourceKind == "campaign" { + soql := "SELECT LeadId, ContactId, Lead.Email, Lead.FirstName, Lead.LastName, Lead.Company, Lead.Title, Lead.Phone, " + + "Lead.Status, Lead.Owner.Name, Lead.IsConverted, Lead.HasOptedOutOfEmail, Contact.Email, Contact.FirstName, " + + "Contact.LastName, Contact.Title, Contact.Phone, Contact.Account.Name, Contact.Owner.Name, Contact.HasOptedOutOfEmail " + + "FROM CampaignMember WHERE CampaignId = " + Quote(in.SourceID) + total, err := c.client.Query(ctx, soql, max, func(rows []Record) bool { + for _, r := range rows { + // A converted Lead's membership carries the Contact it became. + if cid := r.String("ContactId"); cid != "" { + out = append(out, person{ + recordID: NormalizeID(cid), object: ObjectContact, + email: r.String("Contact.Email"), first: r.String("Contact.FirstName"), last: r.String("Contact.LastName"), + company: r.String("Contact.Account.Name"), title: r.String("Contact.Title"), phone: r.String("Contact.Phone"), + owner: r.String("Contact.Owner.Name"), optedOut: r.String("Contact.HasOptedOutOfEmail") == "true", + }) + continue + } + out = append(out, person{ + recordID: NormalizeID(r.String("LeadId")), object: ObjectLead, + email: r.String("Lead.Email"), first: r.String("Lead.FirstName"), last: r.String("Lead.LastName"), + company: r.String("Lead.Company"), title: r.String("Lead.Title"), phone: r.String("Lead.Phone"), + owner: r.String("Lead.Owner.Name"), status: r.String("Lead.Status"), + optedOut: r.String("Lead.HasOptedOutOfEmail") == "true", + }) + } + return true + }) + return out, total, err + } + + view, err := c.client.ListViewQuery(ctx, in.Object, in.SourceID) + if err != nil { + return nil, 0, err + } + collect := func(rows []Record) { + for _, r := range rows { + out = append(out, personFrom(in.Object, r)) + } + } + if soql, ok := listViewSOQL(c.settings, in.Object, view); ok { + total, err := c.client.Query(ctx, soql, max, func(rows []Record) bool { collect(rows); return true }) + if err == nil || !IsCode(err, "INVALID_FIELD") { + return out, total, err + } + out = out[:0] + } + // The view's own query, then the fields for the ids it returned. + var ids []string + total, err := c.client.Query(ctx, view, max, func(rows []Record) bool { + for _, r := range rows { + ids = append(ids, NormalizeID(r.String("Id"))) + } + return true + }) + if err != nil { + return nil, 0, err + } + for start := 0; start < len(ids); start += 200 { + rows, err := sfQuery(ctx, c, in.Object, "Id IN "+QuoteList(ids[start:min(start+200, len(ids))]), 0) + if err != nil { + return nil, 0, err + } + collect(rows) + } + return out, total, nil +} + +func personFrom(object string, r Record) person { + p := person{ + recordID: NormalizeID(r.String("Id")), object: object, rec: r, + email: r.String("Email"), first: r.String("FirstName"), last: r.String("LastName"), + title: r.String("Title"), phone: r.String("Phone"), owner: r.String("Owner.Name"), + optedOut: r.Bool("HasOptedOutOfEmail"), + } + if object == ObjectLead { + p.company, p.status = r.String("Company"), r.String("Status") + } else { + p.company = r.String("Account.Name") + } + return p +} + +// PreviewRow is one sample row of a preview. +type PreviewRow struct { + RecordID string `json:"record_id"` + Object string `json:"object"` + Name string `json:"name"` + Email string `json:"email"` + Company string `json:"company"` + Title string `json:"title"` + OwnerName string `json:"owner_name"` + Status string `json:"status"` + AlreadyLinked bool `json:"already_linked"` +} + +// Preview is the first rows of a source and how many it holds. +type Preview struct { + Total int `json:"total"` + Sample []PreviewRow `json:"sample"` +} + +// Preview reads the first rows of a source without importing. +func (s *Service) Preview(ctx context.Context, orgID, connID uuid.UUID, in ImportSource) (*Preview, error) { + if err := in.validate(); err != nil { + return nil, err + } + c, err := s.open(ctx, orgID, connID) + if err != nil { + return nil, err + } + defer s.settle(ctx, c) + people, total, err := s.read(ctx, c, in, 25) + if err != nil { + return nil, sfError(err) + } + ids := make([]string, 0, len(people)) + for _, p := range people { + ids = append(ids, p.recordID) + } + linked, _ := s.Repo.LinkedRecordIDs(ctx, c.ID, ids) + out := &Preview{Total: total, Sample: make([]PreviewRow, 0, len(people))} + for _, p := range people { + out.Sample = append(out.Sample, PreviewRow{ + RecordID: p.recordID, Object: p.object, Name: strings.TrimSpace(p.first + " " + p.last), + Email: p.email, Company: p.company, Title: p.title, OwnerName: p.owner, Status: p.status, + AlreadyLinked: linked[p.recordID], + }) + } + return out, nil +} + +// SourceInput creates or edits an import source. +type SourceInput struct { + Name *string `json:"name"` + SourceKind string `json:"source_kind"` + Object string `json:"object"` + SourceID string `json:"source_id"` + SourceLabel string `json:"source_label"` + CampaignID *uuid.UUID `json:"campaign_id"` + ClearCampaign bool `json:"-"` + CategoryIDs []uuid.UUID `json:"category_ids"` + Recurring *bool `json:"recurring"` + Enabled *bool `json:"enabled"` +} + +// CreateSource saves a source and starts its first run. +func (s *Service) CreateSource(ctx context.Context, orgID, connID, userID uuid.UUID, in SourceInput) (*models.SalesforceImportSource, error) { + src := ImportSource{SourceKind: in.SourceKind, Object: in.Object, SourceID: in.SourceID} + if err := src.validate(); err != nil { + return nil, err + } + if _, err := s.open(ctx, orgID, connID); err != nil { + return nil, err + } + if len(in.CategoryIDs) > 50 { + return nil, errx.New(errx.BadRequest, "at most 50 tags") + } + label := truncate(strings.TrimSpace(in.SourceLabel), 200) + name := label + if in.Name != nil && strings.TrimSpace(*in.Name) != "" { + name = truncate(strings.TrimSpace(*in.Name), 200) + } + uid := userID + row := &models.SalesforceImportSource{ + OrganizationID: orgID, + ConnectionID: connID, + CreatedByUserID: &uid, + Name: name, + SourceKind: src.SourceKind, + SObject: src.Object, + SourceID: src.SourceID, + SourceLabel: label, + CampaignID: in.CampaignID, + CategoryIDs: in.CategoryIDs, + Recurring: in.Recurring != nil && *in.Recurring, + Enabled: true, + } + if err := s.Repo.CreateSource(ctx, row); err != nil { + if errors.Is(err, repository.ErrSalesforceSourceRefs) { + return nil, errx.New(errx.BadRequest, "The campaign to enroll into was not found") + } + return nil, err + } + return s.StartRun(ctx, orgID, row.ID) +} + +// UpdateSource edits a source's targets and schedule. +func (s *Service) UpdateSource(ctx context.Context, orgID, id uuid.UUID, in SourceInput) (*models.SalesforceImportSource, error) { + src, err := s.Repo.GetSource(ctx, orgID, id) + if err != nil { + return nil, err + } + if src == nil { + return nil, errx.New(errx.NotFound, "import source not found") + } + if in.Name != nil && strings.TrimSpace(*in.Name) != "" { + src.Name = truncate(strings.TrimSpace(*in.Name), 200) + } + if in.ClearCampaign { + src.CampaignID = nil + } else if in.CampaignID != nil { + src.CampaignID = in.CampaignID + } + if in.CategoryIDs != nil { + if len(in.CategoryIDs) > 50 { + return nil, errx.New(errx.BadRequest, "at most 50 tags") + } + src.CategoryIDs = in.CategoryIDs + } + if in.Recurring != nil { + src.Recurring = *in.Recurring + } + if in.Enabled != nil { + src.Enabled = *in.Enabled + } + if err := s.Repo.UpdateSource(ctx, src); err != nil { + if errors.Is(err, repository.ErrSalesforceSourceRefs) { + return nil, errx.New(errx.BadRequest, "The campaign to enroll into was not found") + } + return nil, err + } + return s.Repo.GetSource(ctx, orgID, id) +} + +// StartRun claims a source and runs it in the background. +func (s *Service) StartRun(ctx context.Context, orgID, id uuid.UUID) (*models.SalesforceImportSource, error) { + src, err := s.Repo.GetSource(ctx, orgID, id) + if err != nil { + return nil, err + } + if src == nil { + return nil, errx.New(errx.NotFound, "import source not found") + } + claimed, err := s.Repo.ClaimSource(ctx, id) + if err != nil { + return nil, err + } + if !claimed { + return nil, errx.NewWithIdentifier(errx.Conflict, "import_running", "This import is already running.") + } + go func(src models.SalesforceImportSource) { + bg, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + s.runSource(bg, src) + }(*src) + src.Status = "running" + return src, nil +} + +// RunRecurring runs every "keep in sync" source that is due. +func (s *Service) RunRecurring(ctx context.Context) error { + due, err := s.Repo.DueRecurringSources(ctx, RecurringEvery) + if err != nil { + return err + } + for _, src := range due { + claimed, err := s.Repo.ClaimSource(ctx, src.ID) + if err != nil || !claimed { + continue + } + s.runSource(ctx, src) + } + return nil +} + +func (s *Service) runSource(ctx context.Context, src models.SalesforceImportSource) { + res, err := s.importOnce(ctx, src) + msg := "" + if err != nil { + msg = describeErr(err) + var xe *errx.Error + if errors.As(err, &xe) { + msg = xe.Message + } + log.Warn().Err(err).Str("source", src.ID.String()).Msg("salesforce: import run failed") + } + _ = s.Repo.FinishSource(ctx, src.ID, res, msg) +} + +// importOnce reads the source, imports people it has not brought in before, +// and links every one of them to its record. +func (s *Service) importOnce(ctx context.Context, src models.SalesforceImportSource) (*models.SalesforceRunResult, error) { + c, err := s.open(ctx, src.OrganizationID, src.ConnectionID) + if err != nil { + return nil, err + } + defer s.settle(ctx, c) + people, total, err := s.read(ctx, c, ImportSource{SourceKind: src.SourceKind, Object: src.SObject, SourceID: src.SourceID}, readCap) + if err != nil { + return nil, err + } + res := &models.SalesforceRunResult{Read: len(people), Truncated: total > len(people)} + ids := make([]string, 0, len(people)) + for _, p := range people { + ids = append(ids, p.recordID) + } + seen, err := s.Repo.SourceMembers(ctx, src.ID, ids) + if err != nil { + return nil, err + } + honourOptOut := c.settings.Inbound.OptOut == "both" || c.settings.Inbound.OptOut == "from_salesforce" + var fresh []person + for _, p := range people { + switch { + case seen[p.recordID]: + res.Skipped++ + case !strings.Contains(p.email, "@"): + // Not remembered: once the record gets an address, it comes in. + res.NoEmail++ + case p.optedOut && honourOptOut: + res.OptedOut++ + s.optOutFromSalesforce(ctx, c, repository.SalesforceContact{Email: strings.ToLower(strings.TrimSpace(p.email))}) + case len(fresh) >= importCap: + res.Truncated = true + default: + fresh = append(fresh, p) + } + } + if len(fresh) == 0 { + return res, nil + } + + csvBytes, mapping := s.importCSV(c, fresh) + var campaigns []string + if src.CampaignID != nil { + campaigns = []string{src.CampaignID.String()} + } + cats := make([]string, 0, len(src.CategoryIDs)) + for _, id := range src.CategoryIDs { + cats = append(cats, id.String()) + } + subscribed := true + opts := &models.ContactImportCommit{ + Mapping: mapping, + Dedup: models.ContactImportDedupUpdate, + HasHeader: true, + CategoryIDs: cats, + CampaignIDs: campaigns, + SkipMissingSegments: true, + SubscribedDefault: &subscribed, + Source: models.ContactSourceCRMSync, + SourceDetail: truncate("Salesforce: "+orDefault(src.SourceLabel, src.Name), 200), + } + actor := uuid.Nil + if src.CreatedByUserID != nil { + actor = *src.CreatedByUserID + } else if c.ConnectedByUserID != nil { + actor = *c.ConnectedByUserID + } + result, xerr := s.Contacts.ImportCommit(ctx, actor.String(), src.OrganizationID, bytes.NewReader(csvBytes), "salesforce-import.csv", opts) + if xerr != nil { + return res, xerr + } + res.Imported, res.Updated, res.Failed = result.Imported, result.Updated, result.Failed + res.Skipped += result.Skipped + + // Link every row to its record, and remember it for the next run. + emails := make([]string, 0, len(fresh)) + for _, p := range fresh { + emails = append(emails, p.email) + } + contacts, err := s.Repo.ContactsByEmails(ctx, src.OrganizationID, emails) + if err != nil { + return res, nil + } + members := map[string]uuid.UUID{} + for _, p := range fresh { + ct, ok := contacts[strings.ToLower(strings.TrimSpace(p.email))] + if !ok { + continue + } + members[p.recordID] = ct.ID + rec := p.rec + if rec == nil { + rec = Record{"Id": p.recordID, "Email": p.email, "FirstName": p.first, "LastName": p.last, "Title": p.title, + "Phone": p.phone, "Status": p.status, "Owner": map[string]any{"Name": p.owner}} + if p.object == ObjectLead { + rec["Company"] = p.company + } else { + rec["Account"] = map[string]any{"Name": p.company} + } + } + if err := s.Repo.UpsertLink(ctx, linkFrom(c, ct.ID, p.object, rec, "import")); err == nil { + res.Linked++ + } + } + _ = s.Repo.AddSourceMembers(ctx, src.ID, members) + return res, nil +} + +// importCSV lays the people out as the contact importer's input: the identity +// columns, title, then every custom field a pull rule fills. +func (s *Service) importCSV(c *conn, people []person) ([]byte, []models.ContactImportColumnMapping) { + header := []string{"email", "first_name", "last_name", "company", "phone", "title"} + mapping := []models.ContactImportColumnMapping{ + {Index: 0, Target: models.ContactImportTargetEmail}, + {Index: 1, Target: models.ContactImportTargetFirstName}, + {Index: 2, Target: models.ContactImportTargetLastName}, + {Index: 3, Target: models.ContactImportTargetCompany}, + {Index: 4, Target: models.ContactImportTargetPhone}, + {Index: 5, Target: models.ContactImportTargetCustom, CustomKey: "title"}, + } + type extra struct{ key, field, object string } + var extras []extra + seen := map[string]bool{"title": true} + for _, r := range c.settings.FieldMap { + key, ok := strings.CutPrefix(r.Warmbly, "custom:") + if !ok || seen[key] || (r.Direction != DirectionPull && r.Direction != DirectionBoth) { + continue + } + seen[key] = true + extras = append(extras, extra{key: key, field: r.Salesforce, object: r.Object}) + mapping = append(mapping, models.ContactImportColumnMapping{Index: len(header), Target: models.ContactImportTargetCustom, CustomKey: key}) + header = append(header, key) + } + var buf bytes.Buffer + w := csv.NewWriter(&buf) + _ = w.Write(header) + for _, p := range people { + row := []string{strings.TrimSpace(p.email), p.first, p.last, p.company, p.phone, p.title} + for _, e := range extras { + v := "" + if p.rec != nil && e.object == p.object { + v = p.rec.String(e.field) + } + row = append(row, v) + } + _ = w.Write(row) + } + w.Flush() + return buf.Bytes(), mapping +} diff --git a/internal/app/salesforce/link.go b/internal/app/salesforce/link.go new file mode 100644 index 000000000..f1f39bc99 --- /dev/null +++ b/internal/app/salesforce/link.go @@ -0,0 +1,860 @@ +package salesforce + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/app/integration" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +// baseFields are read for every record, whatever the field rules name. +var baseFields = map[string][]string{ + ObjectLead: {"Id", "Email", "Name", "FirstName", "LastName", "Title", "Phone", "Company", "Status", + "OwnerId", "Owner.Name", "IsConverted", "ConvertedContactId", "ConvertedOpportunityId", "HasOptedOutOfEmail", + "LeadSource", "SystemModstamp"}, + ObjectContact: {"Id", "Email", "Name", "FirstName", "LastName", "Title", "Phone", "AccountId", "Account.Name", + "OwnerId", "Owner.Name", "HasOptedOutOfEmail", "LeadSource", "SystemModstamp"}, +} + +// selectFields is the SELECT list for an object: the base set plus every field +// a rule reads or compares against. +func selectFields(st Settings, object string) []string { + seen := map[string]bool{} + var out []string + add := func(f string) { + k := strings.ToLower(f) + if !seen[k] { + seen[k] = true + out = append(out, f) + } + } + for _, f := range baseFields[object] { + add(f) + } + for _, r := range st.FieldMap { + if r.Object == object { + add(r.Salesforce) + } + } + return out +} + +// sfQuery runs a SELECT and, if a field rule names a field the org does not +// have, retries with the base fields so one bad rule never stops the sync. +func sfQuery(ctx context.Context, c *conn, object, where string, limit int) ([]Record, error) { + fields := selectFields(c.settings, object) + q := func(fs []string) ([]Record, error) { + soql := "SELECT " + strings.Join(fs, ", ") + " FROM " + object + " WHERE " + where + if limit > 0 { + soql += fmt.Sprintf(" LIMIT %d", limit) + } + return c.client.QueryAll(ctx, soql, 0) + } + rows, err := q(fields) + if err != nil && IsCode(err, "INVALID_FIELD") && len(fields) > len(baseFields[object]) { + return q(baseFields[object]) + } + return rows, err +} + +// matched is a Salesforce record an address resolved to. +type matched struct { + object string + rec Record +} + +// matchEmails finds the Lead or Contact behind each address. A converted Lead +// is never a match (its Contact is), and when an address is both, the +// connection's preference decides. +func (s *Service) matchEmails(ctx context.Context, c *conn, emails []string) (map[string]matched, error) { + out := map[string]matched{} + uniq := dedupeLower(emails) + for start := 0; start < len(uniq); start += 100 { + chunk := uniq[start:min(start+100, len(uniq))] + in := QuoteList(chunk) + contacts, err := sfQuery(ctx, c, ObjectContact, "Email IN "+in+" ORDER BY LastModifiedDate DESC", 0) + if err != nil { + return nil, err + } + leads, err := sfQuery(ctx, c, ObjectLead, "IsConverted = false AND Email IN "+in+" ORDER BY LastModifiedDate DESC", 0) + if err != nil { + return nil, err + } + first, second := contacts, leads + firstObj, secondObj := ObjectContact, ObjectLead + if c.settings.Matching.Prefer == "lead" { + first, second, firstObj, secondObj = leads, contacts, ObjectLead, ObjectContact + } + for _, r := range first { + e := strings.ToLower(r.String("Email")) + if _, ok := out[e]; !ok && e != "" { + out[e] = matched{object: firstObj, rec: r} + } + } + for _, r := range second { + e := strings.ToLower(r.String("Email")) + if _, ok := out[e]; !ok && e != "" { + out[e] = matched{object: secondObj, rec: r} + } + } + } + return out, nil +} + +// linkFrom builds a link from a fetched record. +func linkFrom(c *conn, contactID uuid.UUID, object string, r Record, by string) *models.SalesforceRecordLink { + snap := map[string]any{} + for k, v := range r { + if k == "attributes" { + continue + } + snap[k] = v + } + raw, _ := json.Marshal(snap) + l := &models.SalesforceRecordLink{ + OrganizationID: c.OrganizationID, + ConnectionID: c.ID, + ContactID: contactID, + SObject: object, + RecordID: NormalizeID(r.String("Id")), + OwnerID: NormalizeID(r.String("OwnerId")), + OwnerName: r.String("Owner.Name"), + OptedOut: r.Bool("HasOptedOutOfEmail"), + Snapshot: raw, + LinkedBy: by, + RecordModifiedAt: r.Time("SystemModstamp"), + } + if object == ObjectLead { + l.LeadStatus = r.String("Status") + l.IsConverted = r.Bool("IsConverted") + l.AccountName = r.String("Company") + } else { + l.AccountID = NormalizeID(r.String("AccountId")) + l.AccountName = r.String("Account.Name") + } + return l +} + +func snapshotOf(l models.SalesforceRecordLink) Record { + out := Record{} + _ = json.Unmarshal(l.Snapshot, &out) + return out +} + +// --- creation --------------------------------------------------------------- + +// createRequest is one person to create in Salesforce. +type createRequest struct { + contact repository.SalesforceContact + object string + ownerID string + overrides map[string]any +} + +// creationHeaders apply the connection's assignment-rule choice and let a +// duplicate rule that only alerts still save. +func creationHeaders(st Settings) []Header { + assign := "FALSE" + if st.Matching.RunAssignmentRules { + assign = "TRUE" + } + return []Header{{Key: "Sforce-Auto-Assign", Value: assign}, {Key: "Sforce-Duplicate-Rule-Header", Value: "allowSave=true"}} +} + +// createRecords inserts people as Leads or Contacts. Required fields fall back +// to what the address says, because Salesforce refuses a Lead without a last +// name or a company. +func (s *Service) createRecords(ctx context.Context, c *conn, reqs []createRequest) (map[uuid.UUID]matched, map[uuid.UUID]error) { + ok := map[uuid.UUID]matched{} + failed := map[uuid.UUID]error{} + byObject := map[string][]createRequest{} + for _, r := range reqs { + byObject[r.object] = append(byObject[r.object], r) + } + meta, _ := s.describe(ctx, c) + for object, list := range byObject { + records := make([]map[string]any, 0, len(list)) + for _, r := range list { + records = append(records, allowedPicklists(meta, object, s.newRecordFields(c, object, r))) + } + res, err := c.client.Create(ctx, object, records, creationHeaders(c.settings)...) + if err != nil { + for _, r := range list { + failed[r.contact.ID] = err + } + continue + } + var ids []string + idx := map[string]uuid.UUID{} + for i, sr := range res { + if i >= len(list) { + break + } + if e := sr.Err(); e != nil { + failed[list[i].contact.ID] = e + continue + } + ids = append(ids, sr.ID) + idx[sr.ID] = list[i].contact.ID + } + if len(ids) == 0 { + continue + } + rows, err := sfQuery(ctx, c, object, "Id IN "+QuoteList(ids), 0) + if err != nil { + // Created but unreadable: link with the id alone; the pull fills it in. + for id, cid := range idx { + ok[cid] = matched{object: object, rec: Record{"Id": id}} + } + continue + } + for _, r := range rows { + if cid, found := idx[NormalizeID(r.String("Id"))]; found { + ok[cid] = matched{object: object, rec: r} + } + } + } + return ok, failed +} + +func (s *Service) newRecordFields(c *conn, object string, r createRequest) map[string]any { + ct := r.contact + f := map[string]any{"Email": ct.Email} + local, domain, _ := strings.Cut(ct.Email, "@") + if ct.FirstName != "" { + f["FirstName"] = ct.FirstName + } + last := ct.LastName + if last == "" { + last = local + } + f["LastName"] = truncate(last, 80) + if ct.Phone != "" { + f["Phone"] = ct.Phone + } + if object == ObjectLead { + company := ct.Company + if company == "" { + company = domain + } + if company == "" { + company = "[not provided]" + } + f["Company"] = truncate(company, 255) + if st := c.settings.Matching.LeadStatus; st != "" { + f["Status"] = st + } + } + if src := c.settings.Matching.LeadSource; src != "" { + f["LeadSource"] = src + } + if t := ct.CustomFields["title"]; t != "" { + f["Title"] = truncate(t, 128) + } + for _, rule := range c.settings.rulesFor(object, DirectionPush) { + if strings.Contains(rule.Salesforce, ".") || isEngagementField(rule.Warmbly) { + continue + } + if v := warmblyValue(ct, nil, rule.Warmbly); v != "" { + f[rule.Salesforce] = v + } + } + for k, v := range safeOverrides(r.overrides) { + f[k] = v + } + if r.ownerID != "" && !c.settings.Matching.RunAssignmentRules { + f["OwnerId"] = r.ownerID + } + return f +} + +// allowedPicklists drops a Lead Source or Status the org's restricted +// picklist would refuse, so one unknown value never blocks every creation. +func allowedPicklists(meta *cachedMeta, object string, f map[string]any) map[string]any { + if meta == nil { + return f + } + d := meta.contact + if object == ObjectLead { + d = meta.lead + } + for _, name := range []string{"LeadSource", "Status"} { + v, ok := f[name].(string) + if !ok { + continue + } + fd := d.Field(name) + if fd == nil { + delete(f, name) + continue + } + if !fd.Restricted { + continue + } + allowed := false + for _, p := range fd.PicklistValues { + if p.Active && strings.EqualFold(p.Value, v) { + f[name] = p.Value + allowed = true + break + } + } + if !allowed { + delete(f, name) + } + } + return f +} + +// createOwner picks the owner of a created record. +func (s *Service) createOwner(ctx context.Context, c *conn, senderEmail string) string { + switch c.settings.Matching.Owner { + case "fixed": + return c.settings.Matching.OwnerID + case "sender": + if id := s.userByEmail(ctx, c, senderEmail); id != "" { + return id + } + } + return c.sfUserID() +} + +// userByEmail finds the active Salesforce user with an address, cached with +// the connection's metadata. +func (s *Service) userByEmail(ctx context.Context, c *conn, email string) string { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return "" + } + m, err := s.describe(ctx, c) + if err != nil { + return "" + } + s.metaMu.Lock() + id, ok := m.userByEmail[email] + s.metaMu.Unlock() + if ok { + return id + } + rows, err := c.client.QueryAll(ctx, "SELECT Id FROM User WHERE IsActive = true AND Email = "+Quote(email)+" LIMIT 1", 1) + if err != nil { + return "" + } + if len(rows) > 0 { + id = NormalizeID(rows[0].String("Id")) + } + s.metaMu.Lock() + m.userByEmail[email] = id + s.metaMu.Unlock() + return id +} + +// --- field values ----------------------------------------------------------- + +// warmblyValue reads a Warmbly-side field for a contact. +func warmblyValue(ct repository.SalesforceContact, eng *repository.SalesforceEngagement, field string) string { + switch field { + case "first_name": + return ct.FirstName + case "last_name": + return ct.LastName + case "company": + return ct.Company + case "phone": + return ct.Phone + case "email": + return ct.Email + } + if key, ok := strings.CutPrefix(field, "custom:"); ok { + return strings.TrimSpace(ct.CustomFields[key]) + } + if eng == nil { + return "" + } + switch field { + case "engagement.last_campaign": + return eng.LastCampaign + case "engagement.last_sent_at": + return sfDateTime(eng.LastSentAt) + case "engagement.last_reply_at": + return sfDateTime(eng.LastReplyAt) + case "engagement.reply_intent": + return eng.ReplyIntent + case "engagement.status": + return eng.Status + } + return "" +} + +func sfDateTime(t *time.Time) string { + if t == nil { + return "" + } + return t.UTC().Format("2006-01-02T15:04:05Z") +} + +// pushChanges computes the field updates a contact's rules call for, against +// what Salesforce last showed. +func pushChanges(st Settings, l models.SalesforceRecordLink, ct repository.SalesforceContact, eng *repository.SalesforceEngagement) map[string]any { + snap := snapshotOf(l) + out := map[string]any{} + for _, rule := range st.rulesFor(l.SObject, DirectionPush) { + if strings.Contains(rule.Salesforce, ".") { + continue + } + v := warmblyValue(ct, eng, rule.Warmbly) + if v == "" { + continue + } + _, known := snap[rule.Salesforce] + cur := snap.String(rule.Salesforce) + // A field never read is unknown, and "only fill blanks" never writes blind. + if rule.Policy == PolicyIfEmpty && (!known || cur != "") { + continue + } + if cur == v { + continue + } + out[rule.Salesforce] = v + } + return out +} + +// --- on-demand sync --------------------------------------------------------- + +// ensureLinks returns each contact's link, matching unlinked ones and, when +// createAs is set, creating the people still missing. senderEmail feeds the +// owner rule. +func (s *Service) ensureLinks(ctx context.Context, c *conn, contacts []repository.SalesforceContact, createAs, senderEmail string, overrides map[string]any) (map[uuid.UUID]models.SalesforceRecordLink, map[uuid.UUID]error) { + ids := make([]uuid.UUID, 0, len(contacts)) + for _, ct := range contacts { + ids = append(ids, ct.ID) + } + failed := map[uuid.UUID]error{} + links, err := s.Repo.LinksForContacts(ctx, c.ID, ids) + if err != nil { + for _, id := range ids { + failed[id] = err + } + return nil, failed + } + var missing []repository.SalesforceContact + for _, ct := range contacts { + l, ok := links[ct.ID] + if !ok { + missing = append(missing, ct) + continue + } + // A converted Lead's link moves to the Contact it became. + if l.SObject == ObjectLead && l.IsConverted { + missing = append(missing, ct) + delete(links, ct.ID) + } + } + if len(missing) == 0 { + return links, failed + } + emails := make([]string, 0, len(missing)) + for _, ct := range missing { + emails = append(emails, ct.Email) + } + found, err := s.matchEmails(ctx, c, emails) + if err != nil { + for _, ct := range missing { + failed[ct.ID] = err + } + return links, failed + } + var toCreate []createRequest + for _, ct := range missing { + if m, ok := found[strings.ToLower(ct.Email)]; ok { + l := linkFrom(c, ct.ID, m.object, m.rec, "match") + if err := s.Repo.UpsertLink(ctx, l); err != nil { + failed[ct.ID] = err + continue + } + links[ct.ID] = *l + if l.OptedOut { + s.optOutFromSalesforce(ctx, c, ct) + } + continue + } + if createAs == "" { + continue + } + obj := ObjectLead + if createAs == "contact" { + obj = ObjectContact + } + toCreate = append(toCreate, createRequest{contact: ct, object: obj, ownerID: s.createOwner(ctx, c, senderEmail), overrides: overrides}) + } + if len(toCreate) > 0 { + created, cerr := s.createRecords(ctx, c, toCreate) + for id, e := range cerr { + failed[id] = e + } + for id, m := range created { + l := linkFrom(c, id, m.object, m.rec, "created") + if err := s.Repo.UpsertLink(ctx, l); err != nil { + failed[id] = err + continue + } + links[id] = *l + } + } + return links, failed +} + +// applyUpdates writes field changes to linked records, one batch per object, +// and refreshes each link's snapshot with what was written. +func (s *Service) applyUpdates(ctx context.Context, c *conn, changes map[uuid.UUID]map[string]any, links map[uuid.UUID]models.SalesforceRecordLink) map[uuid.UUID]error { + failed := map[uuid.UUID]error{} + byObject := map[string][]uuid.UUID{} + for cid, ch := range changes { + if len(ch) == 0 { + continue + } + l := links[cid] + if l.SObject == ObjectLead && l.IsConverted { + continue + } + byObject[l.SObject] = append(byObject[l.SObject], cid) + } + for object, cids := range byObject { + records := make([]map[string]any, 0, len(cids)) + for _, cid := range cids { + rec := map[string]any{"Id": links[cid].RecordID} + for k, v := range changes[cid] { + rec[k] = v + } + records = append(records, rec) + } + res, err := c.client.Update(ctx, object, records) + if err != nil { + for _, cid := range cids { + failed[cid] = err + } + continue + } + for i, sr := range res { + if i >= len(cids) { + break + } + cid := cids[i] + if e := sr.Err(); e != nil { + failed[cid] = e + _ = s.Repo.SetLinkError(ctx, links[cid].ID, e.Error()) + continue + } + l := links[cid] + snap := snapshotOf(l) + for k, v := range changes[cid] { + snap[k] = v + } + if v, ok := changes[cid]["Status"].(string); ok { + l.LeadStatus = v + } + if v, ok := changes[cid]["HasOptedOutOfEmail"].(bool); ok { + l.OptedOut = v + } + l.Snapshot, _ = json.Marshal(snap) + _ = s.Repo.UpsertLink(ctx, &l) + links[cid] = l + } + } + return failed +} + +// SyncContact matches (or, with createAs, creates) one contact's record in +// every active Salesforce connection of the organization, or only connID, and +// pushes the mapped fields. +func (s *Service) SyncContact(ctx context.Context, orgID, contactID uuid.UUID, connID *uuid.UUID, createAs string) error { + if createAs != "" && createAs != "lead" && createAs != "contact" { + return errx.New(errx.BadRequest, "create_as must be lead or contact") + } + cts, err := s.Repo.ContactsByIDs(ctx, orgID, []uuid.UUID{contactID}) + if err != nil { + return err + } + ct, ok := cts[contactID] + if !ok { + return errx.New(errx.NotFound, "contact not found") + } + refs, err := s.Repo.ActiveConnectionsForOrg(ctx, orgID) + if err != nil { + return err + } + ran := false + for _, ref := range refs { + if connID != nil && ref.ID != *connID { + continue + } + ran = true + c, err := s.open(ctx, orgID, ref.ID) + if err != nil { + return err + } + err = s.syncOne(ctx, c, ct, createAs, nil) + s.settle(ctx, c) + if err != nil { + return sfError(err) + } + } + if !ran { + return errx.New(errx.NotFound, "no connected Salesforce org") + } + return nil +} + +func (s *Service) syncOne(ctx context.Context, c *conn, ct repository.SalesforceContact, createAs string, overrides map[string]any) error { + links, failed := s.ensureLinks(ctx, c, []repository.SalesforceContact{ct}, createAs, "", overrides) + if err := failed[ct.ID]; err != nil { + return err + } + l, ok := links[ct.ID] + if !ok { + return nil + } + eng, _ := s.Repo.Engagement(ctx, c.OrganizationID, []uuid.UUID{ct.ID}) + var e *repository.SalesforceEngagement + if v, ok := eng[ct.ID]; ok { + e = &v + } + ch := pushChanges(c.settings, l, ct, e) + snap := snapshotOf(l) + for k, v := range safeOverrides(overrides) { + if snap.String(k) != fmt.Sprint(v) { + ch[k] = v + } + } + if len(ch) == 0 { + return s.Repo.MarkLinksPushed(ctx, []uuid.UUID{l.ID}) + } + if errs := s.applyUpdates(ctx, c, map[uuid.UUID]map[string]any{ct.ID: ch}, links); errs[ct.ID] != nil { + return errs[ct.ID] + } + return s.Repo.MarkLinksPushed(ctx, []uuid.UUID{l.ID}) +} + +// PushContacts is the contextual "push to Salesforce" action: every contact is +// matched or created (as the connection's create_as) and its fields pushed. +func (s *Service) PushContacts(ctx context.Context, orgID, connID uuid.UUID, contacts []integration.PushContact) (*integration.PushResult, error) { + c, err := s.open(ctx, orgID, connID) + if err != nil { + return nil, err + } + defer s.settle(ctx, c) + ids := make([]uuid.UUID, 0, len(contacts)) + for _, p := range contacts { + ids = append(ids, p.ID) + } + byID, err := s.Repo.ContactsByIDs(ctx, orgID, ids) + if err != nil { + return nil, err + } + var list []repository.SalesforceContact + for _, id := range ids { + if ct, ok := byID[id]; ok && strings.TrimSpace(ct.Email) != "" { + list = append(list, ct) + } + } + links, failed := s.ensureLinks(ctx, c, list, c.settings.Matching.CreateAs, "", nil) + eng, _ := s.Repo.Engagement(ctx, orgID, ids) + changes := map[uuid.UUID]map[string]any{} + for _, ct := range list { + l, ok := links[ct.ID] + if !ok { + continue + } + var e *repository.SalesforceEngagement + if v, ok := eng[ct.ID]; ok { + e = &v + } + changes[ct.ID] = pushChanges(c.settings, l, ct, e) + } + for id, e := range s.applyUpdates(ctx, c, changes, links) { + failed[id] = e + } + var pushed []uuid.UUID + res := &integration.PushResult{Provider: string(models.IntegrationSalesforce)} + for _, p := range contacts { + rr := integration.PushRecordResult{ContactID: p.ID, Email: p.Email, OK: true} + _, known := byID[p.ID] + switch { + case !known || strings.TrimSpace(p.Email) == "": + rr.OK, rr.Error = false, "contact has no email" + case failed[p.ID] != nil: + rr.OK, rr.Error = false, truncate(failed[p.ID].Error(), 240) + default: + l, ok := links[p.ID] + if !ok { + rr.OK, rr.Error = false, "not created: creating records is off for this connection" + } else { + pushed = append(pushed, l.ID) + } + } + if rr.OK { + res.Pushed++ + } else { + res.Failed++ + } + res.Results = append(res.Results, rr) + } + _ = s.Repo.MarkLinksPushed(ctx, pushed) + return res, nil +} + +// UpsertFromEvent is the automation action "create or update Salesforce +// record": the event's contact is matched, or created as the connection's +// create_as, then updated. +func (s *Service) UpsertFromEvent(ctx context.Context, orgID, connID uuid.UUID, data map[string]any) error { + err := s.upsertFromEvent(ctx, orgID, connID, data) + if errors.Is(err, ErrSessionExpired) { + return fmt.Errorf("%w: %v", integration.ErrPushReauth, err) + } + return err +} + +func (s *Service) upsertFromEvent(ctx context.Context, orgID, connID uuid.UUID, data map[string]any) error { + c, err := s.open(ctx, orgID, connID) + if err != nil { + return err + } + defer s.settle(ctx, c) + var ct repository.SalesforceContact + if id, perr := uuid.Parse(str(data, "contact_id")); perr == nil { + if m, err := s.Repo.ContactsByIDs(ctx, orgID, []uuid.UUID{id}); err == nil { + ct = m[id] + } + } + if ct.ID == uuid.Nil { + email := strings.ToLower(str(data, "contact_email", "invitee_email", "email", "recipient")) + if email == "" { + return nil + } + m, err := s.Repo.ContactsByEmails(ctx, orgID, []string{email}) + if err != nil { + return err + } + ct = m[email] + } + overrides, _ := data["_salesforce_fields"].(map[string]any) + if ct.ID == uuid.Nil { + return s.upsertBare(ctx, c, data, overrides) + } + return s.syncOne(ctx, c, ct, c.settings.Matching.CreateAs, overrides) +} + +// upsertBare finds or creates someone who is not a Warmbly contact (a meeting +// invitee, a form or webhook lead) from the event's own fields. Nothing is +// linked, because there is no contact to link. +func (s *Service) upsertBare(ctx context.Context, c *conn, data map[string]any, overrides map[string]any) error { + email := strings.ToLower(str(data, "contact_email", "invitee_email", "email", "recipient")) + if !strings.Contains(email, "@") { + return nil + } + ct := repository.SalesforceContact{ + ID: uuid.New(), + Email: email, + FirstName: str(data, "first_name", "contact_first_name"), + LastName: str(data, "last_name", "contact_last_name"), + Company: str(data, "company", "contact_company"), + Phone: str(data, "phone", "contact_phone"), + CustomFields: map[string]string{}, + } + if ct.FirstName == "" && ct.LastName == "" { + if full := str(data, "contact_name", "invitee_name", "name"); full != "" { + first, last, _ := strings.Cut(full, " ") + ct.FirstName, ct.LastName = first, strings.TrimSpace(last) + } + } + found, err := s.matchEmails(ctx, c, []string{email}) + if err != nil { + return err + } + if m, ok := found[email]; ok { + ch := map[string]any{} + for k, v := range safeOverrides(overrides) { + if m.rec.String(k) != fmt.Sprint(v) { + ch[k] = v + } + } + if len(ch) == 0 || (m.object == ObjectLead && m.rec.Bool("IsConverted")) { + return nil + } + ch["Id"] = NormalizeID(m.rec.String("Id")) + res, err := c.client.Update(ctx, m.object, []map[string]any{ch}) + if err != nil { + return err + } + if len(res) > 0 { + return res[0].Err() + } + return nil + } + obj := ObjectLead + if c.settings.Matching.CreateAs == "contact" { + obj = ObjectContact + } + _, failed := s.createRecords(ctx, c, []createRequest{{contact: ct, object: obj, ownerID: s.createOwner(ctx, c, ""), overrides: overrides}}) + return failed[ct.ID] +} + +// Unlink drops a contact's link to a record; nothing changes in Salesforce. +func (s *Service) Unlink(ctx context.Context, orgID, contactID, linkID uuid.UUID) error { + links, err := s.Repo.LinksForContact(ctx, orgID, contactID) + if err != nil { + return err + } + for _, l := range links { + if l.ID == linkID { + _, err := s.Repo.DeleteLink(ctx, orgID, linkID) + return err + } + } + return errx.New(errx.NotFound, "link not found") +} + +// safeOverrides keeps an automation's own field values that name a plain field +// of the record: never its Id, which would retarget the write, or a related one. +func safeOverrides(in map[string]any) map[string]any { + out := map[string]any{} + for k, v := range in { + if strings.EqualFold(k, "Id") || strings.Contains(k, ".") || !validSalesforceField(k) { + continue + } + out[k] = v + } + return out +} + +func dedupeLower(in []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(in)) + for _, v := range in { + v = strings.ToLower(strings.TrimSpace(v)) + if v == "" || seen[v] { + continue + } + seen[v] = true + out = append(out, v) + } + return out +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) +} diff --git a/internal/app/salesforce/panel.go b/internal/app/salesforce/panel.go new file mode 100644 index 000000000..5cccdd296 --- /dev/null +++ b/internal/app/salesforce/panel.go @@ -0,0 +1,377 @@ +package salesforce + +import ( + "context" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +// Panel is a contact's Salesforce context as the drawer and inbox show it. +type Panel struct { + Connections []PanelConnection `json:"connections"` + Records []PanelRecord `json:"records"` + CanSync bool `json:"can_sync"` +} + +// PanelConnection is one connected org. +type PanelConnection struct { + ID uuid.UUID `json:"id"` + Label string `json:"label"` + Environment string `json:"environment"` + InstanceURL string `json:"instance_url"` +} + +// PanelRecord is the linked Lead or Contact in one org. +type PanelRecord struct { + LinkID uuid.UUID `json:"link_id"` + ConnectionID uuid.UUID `json:"connection_id"` + ConnectionLabel string `json:"connection_label"` + Object string `json:"object"` + ID string `json:"id"` + URL string `json:"url"` + Name string `json:"name"` + Title string `json:"title,omitempty"` + Company string `json:"company,omitempty"` + Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` + Status string `json:"status,omitempty"` + Owner *PanelRef `json:"owner,omitempty"` + Account *PanelAccount `json:"account,omitempty"` + IsConverted bool `json:"is_converted"` + OptedOut bool `json:"opted_out"` + LeadSource string `json:"lead_source,omitempty"` + Opportunities []PanelOpportunity `json:"opportunities"` + Tasks []PanelTask `json:"tasks"` + LinkedBy string `json:"linked_by"` + LastSyncedAt *time.Time `json:"last_synced_at,omitempty"` + LastPushedAt *time.Time `json:"last_pushed_at,omitempty"` + Sync PanelSync `json:"sync"` + Stale bool `json:"stale"` + Error string `json:"error,omitempty"` +} + +// PanelRef names a related user. +type PanelRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// PanelAccount is the Contact's account. +type PanelAccount struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` +} + +// PanelOpportunity is an opportunity on the account (or the converted Lead's). +type PanelOpportunity struct { + ID string `json:"id"` + Name string `json:"name"` + Stage string `json:"stage"` + Amount *float64 `json:"amount,omitempty"` + CloseDate string `json:"close_date,omitempty"` + IsClosed bool `json:"is_closed"` + IsWon bool `json:"is_won"` + URL string `json:"url"` +} + +// PanelTask is a recent activity on the record. +type PanelTask struct { + ID string `json:"id"` + Subject string `json:"subject"` + Date string `json:"date,omitempty"` + Status string `json:"status"` + OwnerName string `json:"owner_name,omitempty"` + URL string `json:"url"` + FromWarmbly bool `json:"from_warmbly"` +} + +// PanelSync is the outbox picture for the contact. +type PanelSync struct { + Pending int `json:"pending"` + Failed int `json:"failed"` + Synced int `json:"synced"` + LastError string `json:"last_error,omitempty"` +} + +// liveTTL is how long a live read of a record is reused before the next panel +// open asks Salesforce again. +const liveTTL = 90 * time.Second + +// missTTL is how long "this address is not in Salesforce" is remembered. +const missTTL = 10 * time.Minute + +type liveEntry struct { + at time.Time + opps []PanelOpportunity + tasks []PanelTask +} + +var ( + liveMu sync.Mutex + liveCache = map[uuid.UUID]liveEntry{} +) + +// ContactPanel builds a contact's Salesforce panel, linking it on the way when +// its address matches a record nobody has linked yet. +func (s *Service) ContactPanel(ctx context.Context, orgID, contactID uuid.UUID) (*Panel, error) { + out := &Panel{Connections: []PanelConnection{}, Records: []PanelRecord{}} + refs, err := s.Repo.ActiveConnectionsForOrg(ctx, orgID) + if err != nil { + return nil, err + } + if len(refs) == 0 { + return out, nil + } + cts, err := s.Repo.ContactsByIDs(ctx, orgID, []uuid.UUID{contactID}) + if err != nil { + return nil, err + } + ct, ok := cts[contactID] + if !ok { + return nil, nil + } + links, err := s.Repo.LinksForContact(ctx, orgID, contactID) + if err != nil { + return nil, err + } + byConn := map[uuid.UUID]models.SalesforceRecordLink{} + for _, l := range links { + byConn[l.ConnectionID] = l + } + pending, failed, synced, lastErr, _ := s.Repo.ContactActivityCounts(ctx, orgID, contactID) + out.CanSync = true + for _, ref := range refs { + c, err := s.open(ctx, orgID, ref.ID) + if err != nil { + continue + } + env := configString(c.DisplayFields, "environment") + if env == "" { + env = "production" + } + out.Connections = append(out.Connections, PanelConnection{ID: c.ID, Label: c.Label, Environment: env, InstanceURL: c.instanceURL()}) + if c.Status == models.IntegrationStatusReauthRequired { + if l, ok := byConn[c.ID]; ok { + rec := s.recordFromLink(c, l) + rec.Stale = true + rec.Error = "Reconnect Salesforce to see live data" + out.Records = append(out.Records, rec) + } + continue + } + l, linked := byConn[c.ID] + if !linked { + l, linked = s.linkOnView(ctx, c, ct.Email, contactID) + } + if !linked { + s.settle(ctx, c) + continue + } + rec := s.livePanelRecord(ctx, c, l) + rec.Sync = PanelSync{Pending: pending, Failed: failed, Synced: synced, LastError: lastErr} + out.Records = append(out.Records, rec) + s.settle(ctx, c) + } + return out, nil +} + +// linkOnView matches an unlinked contact by address the first time its panel +// opens, remembering a miss for a while so browsing costs no API calls. +func (s *Service) linkOnView(ctx context.Context, c *conn, email string, contactID uuid.UUID) (models.SalesforceRecordLink, bool) { + key := c.ID.String() + "|" + strings.ToLower(email) + s.missMu.Lock() + at, missed := s.misses[key] + s.missMu.Unlock() + if missed && time.Since(at) < missTTL { + return models.SalesforceRecordLink{}, false + } + found, err := s.matchEmails(ctx, c, []string{email}) + if err != nil { + return models.SalesforceRecordLink{}, false + } + m, ok := found[strings.ToLower(email)] + if !ok { + s.missMu.Lock() + s.misses[key] = time.Now() + if len(s.misses) > 50000 { + s.misses = map[string]time.Time{} + } + s.missMu.Unlock() + return models.SalesforceRecordLink{}, false + } + l := linkFrom(c, contactID, m.object, m.rec, "match") + if err := s.Repo.UpsertLink(ctx, l); err != nil { + return models.SalesforceRecordLink{}, false + } + if l.OptedOut { + s.optOutFromSalesforce(ctx, c, repository.SalesforceContact{ID: contactID, Email: email}) + } + return *l, true +} + +// recordFromLink renders what the link last saw. +func (s *Service) recordFromLink(c *conn, l models.SalesforceRecordLink) PanelRecord { + snap := snapshotOf(l) + rec := PanelRecord{ + LinkID: l.ID, ConnectionID: c.ID, ConnectionLabel: c.Label, + Object: l.SObject, ID: l.RecordID, URL: c.recordURL(l.RecordID), + Name: snap.String("Name"), Title: snap.String("Title"), Email: snap.String("Email"), Phone: snap.String("Phone"), + Status: l.LeadStatus, IsConverted: l.IsConverted, OptedOut: l.OptedOut, LeadSource: snap.String("LeadSource"), + LinkedBy: l.LinkedBy, LastSyncedAt: l.LastPulledAt, LastPushedAt: l.LastPushedAt, + Opportunities: []PanelOpportunity{}, Tasks: []PanelTask{}, + } + if rec.Name == "" { + rec.Name = strings.TrimSpace(snap.String("FirstName") + " " + snap.String("LastName")) + } + if l.OwnerID != "" || l.OwnerName != "" { + rec.Owner = &PanelRef{ID: l.OwnerID, Name: l.OwnerName} + } + if l.SObject == ObjectLead { + rec.Company = l.AccountName + } else { + rec.Company = l.AccountName + if l.AccountID != "" { + rec.Account = &PanelAccount{ID: l.AccountID, Name: l.AccountName, URL: c.recordURL(l.AccountID)} + } + } + if l.LastError != nil { + rec.Error = *l.LastError + } + return rec +} + +// livePanelRecord refreshes the record from Salesforce when the link is stale, +// and adds opportunities and recent activity. +func (s *Service) livePanelRecord(ctx context.Context, c *conn, l models.SalesforceRecordLink) PanelRecord { + if l.LastPulledAt == nil || time.Since(*l.LastPulledAt) > liveTTL { + rows, err := sfQuery(ctx, c, l.SObject, "Id = "+Quote(l.RecordID), 1) + switch { + case err != nil: + rec := s.recordFromLink(c, l) + rec.Stale = true + rec.Error = describeErr(err) + return rec + case len(rows) == 0: + rec := s.recordFromLink(c, l) + rec.Stale = true + rec.Error = "This record was deleted or is no longer visible to the connected Salesforce user" + return rec + default: + next := linkFrom(c, l.ContactID, l.SObject, rows[0], l.LinkedBy) + if next.SObject == ObjectLead && next.IsConverted && s.followConversion(ctx, c, l, rows[0]) { + // Shown as the Contact it became; that link was just read, so + // this goes one level deep at most. + if links, err := s.Repo.LinksForContact(ctx, c.OrganizationID, l.ContactID); err == nil { + for _, nl := range links { + if nl.ConnectionID == c.ID && nl.SObject == ObjectContact { + return s.livePanelRecord(ctx, c, nl) + } + } + } + } + next.LastPushedAt = l.LastPushedAt + if err := s.Repo.UpsertLink(ctx, next); err == nil { + now := time.Now().UTC() + next.LastPulledAt = &now + l = *next + } + } + } + rec := s.recordFromLink(c, l) + opps, tasks := s.related(ctx, c, l) + rec.Opportunities, rec.Tasks = opps, tasks + return rec +} + +// related reads opportunities and recent Tasks, reused for liveTTL. +func (s *Service) related(ctx context.Context, c *conn, l models.SalesforceRecordLink) ([]PanelOpportunity, []PanelTask) { + liveMu.Lock() + e, ok := liveCache[l.ID] + liveMu.Unlock() + if ok && time.Since(e.at) < liveTTL { + return e.opps, e.tasks + } + opps := []PanelOpportunity{} + where := "" + switch { + case l.SObject == ObjectContact && l.AccountID != "": + where = "AccountId = " + Quote(l.AccountID) + case l.SObject == ObjectLead: + if id := NormalizeID(snapshotOf(l).String("ConvertedOpportunityId")); id != "" { + where = "Id = " + Quote(id) + } + } + if where != "" { + rows, err := c.client.QueryAll(ctx, "SELECT Id, Name, StageName, Amount, CloseDate, IsClosed, IsWon FROM Opportunity WHERE "+ + where+" ORDER BY IsClosed ASC, CloseDate DESC LIMIT 5", 5) + if err == nil { + for _, r := range rows { + o := PanelOpportunity{ + ID: NormalizeID(r.String("Id")), Name: r.String("Name"), Stage: r.String("StageName"), + CloseDate: r.String("CloseDate"), IsClosed: r.Bool("IsClosed"), IsWon: r.Bool("IsWon"), + } + o.URL = c.recordURL(o.ID) + if v := r.String("Amount"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + o.Amount = &f + } + } + opps = append(opps, o) + } + } + } + tasks := []PanelTask{} + rows, err := c.client.QueryAll(ctx, "SELECT Id, Subject, ActivityDate, Status, Owner.Name, Description FROM Task WHERE WhoId = "+ + Quote(l.RecordID)+" ORDER BY CreatedDate DESC LIMIT 8", 8) + if err == nil { + for _, r := range rows { + id := NormalizeID(r.String("Id")) + tasks = append(tasks, PanelTask{ + ID: id, Subject: r.String("Subject"), Date: r.String("ActivityDate"), Status: r.String("Status"), + OwnerName: r.String("Owner.Name"), URL: c.recordURL(id), + FromWarmbly: strings.HasSuffix(strings.TrimSpace(r.String("Description")), "Logged by Warmbly"), + }) + } + } + liveMu.Lock() + liveCache[l.ID] = liveEntry{at: time.Now(), opps: opps, tasks: tasks} + if len(liveCache) > 20000 { + liveCache = map[uuid.UUID]liveEntry{} + } + liveMu.Unlock() + return opps, tasks +} + +// ForgetLive drops cached live reads and remembered misses for a contact, so +// the panel after an explicit sync shows what Salesforce holds now. +func (s *Service) ForgetLive(ctx context.Context, orgID, contactID uuid.UUID) { + if links, err := s.Repo.LinksForContact(ctx, orgID, contactID); err == nil { + liveMu.Lock() + for _, l := range links { + delete(liveCache, l.ID) + } + liveMu.Unlock() + } + cts, err := s.Repo.ContactsByIDs(ctx, orgID, []uuid.UUID{contactID}) + if err != nil { + return + } + if ct, ok := cts[contactID]; ok { + suffix := "|" + strings.ToLower(ct.Email) + s.missMu.Lock() + for k := range s.misses { + if strings.HasSuffix(k, suffix) { + delete(s.misses, k) + } + } + s.missMu.Unlock() + } +} diff --git a/internal/app/salesforce/pull.go b/internal/app/salesforce/pull.go new file mode 100644 index 000000000..9e21067d1 --- /dev/null +++ b/internal/app/salesforce/pull.go @@ -0,0 +1,384 @@ +package salesforce + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +// pullPageCap bounds how many changed records one connection reads per pass; +// the cursor carries the rest to the next pass. +const pullPageCap = 6000 + +// Pull reads what changed in every connected org since its cursor and applies +// it to linked contacts. +func (s *Service) Pull(ctx context.Context) error { + refs, err := s.Repo.ActiveConnections(ctx) + if err != nil { + return err + } + for _, ref := range refs { + if !ParseSettings(ref.ConfigCapabilities).Enabled { + continue + } + c, err := s.open(ctx, ref.OrganizationID, ref.ID) + if err != nil { + continue + } + if c.Status == models.IntegrationStatusReauthRequired || s.overBudget(ctx, c) { + continue + } + s.pullConnection(ctx, c) + s.settle(ctx, c) + } + return nil +} + +func (s *Service) pullConnection(ctx context.Context, c *conn) { + if err := s.Repo.EnsureSyncState(ctx, c.ID, c.OrganizationID, time.Now().UTC()); err != nil { + return + } + state, err := s.Repo.GetSyncState(ctx, c.ID) + if err != nil || state == nil { + return + } + var errs []string + leadCursor, err := s.pullObject(ctx, c, ObjectLead, state.LeadCursor) + if err != nil { + errs = append(errs, "Leads: "+describeErr(err)) + } + contactCursor, err := s.pullObject(ctx, c, ObjectContact, state.ContactCursor) + if err != nil { + errs = append(errs, "Contacts: "+describeErr(err)) + } + oppCursor := state.OppCursor + if c.settings.Inbound.PauseOnOpenOpportunity { + next, err := s.pullOpportunities(ctx, c, state.OppCursor) + if err != nil { + errs = append(errs, "Opportunities: "+describeErr(err)) + } else { + oppCursor = next + } + } else { + // Off means only opportunities opened after it is turned on count. + now := time.Now().UTC() + oppCursor = &now + } + _ = s.Repo.SetCursors(ctx, c.ID, leadCursor, contactCursor, oppCursor, strings.Join(errs, "; ")) +} + +// soqlTime renders a SOQL datetime literal, which carries whole seconds. +func soqlTime(t time.Time) string { + return t.UTC().Format("2006-01-02T15:04:05Z") +} + +// advance moves a cursor to the latest timestamp read. SOQL compares whole +// seconds, so a cursor that would land in the second it started from steps a +// full second past it; otherwise a second holding more changes than one pass +// reads would be re-read forever. +func advance(cursor time.Time, latest *time.Time) *time.Time { + start := cursor.UTC().Truncate(time.Second) + if latest == nil { + return &cursor + } + if !latest.UTC().Truncate(time.Second).After(start) { + next := start.Add(time.Second) + return &next + } + l := latest.UTC() + return &l +} + +// pullObject reads records modified at or after the cursor. Reading from the +// cursor inclusive re-reads the boundary record, which is harmless, rather +// than skipping one that shares its timestamp. +func (s *Service) pullObject(ctx context.Context, c *conn, object string, cursor *time.Time) (*time.Time, error) { + if cursor == nil { + now := time.Now().UTC() + return &now, nil + } + where := "SystemModstamp >= " + soqlTime(*cursor) + " ORDER BY SystemModstamp ASC" + fields := selectFields(c.settings, object) + soql := "SELECT " + strings.Join(fields, ", ") + " FROM " + object + " WHERE " + where + var latest *time.Time + var applyErr error + _, err := c.client.Query(ctx, soql, pullPageCap, func(rows []Record) bool { + if err := s.applyPulled(ctx, c, object, rows); err != nil { + applyErr = err + return false + } + for _, r := range rows { + if t := r.Time("SystemModstamp"); t != nil && (latest == nil || t.After(*latest)) { + latest = t + } + } + return true + }) + if err != nil && IsCode(err, "INVALID_FIELD") { + // A rule names a field the org dropped; keep pulling on the base set. + soql = "SELECT " + strings.Join(baseFields[object], ", ") + " FROM " + object + " WHERE " + where + _, err = c.client.Query(ctx, soql, pullPageCap, func(rows []Record) bool { + _ = s.applyPulled(ctx, c, object, rows) + for _, r := range rows { + if t := r.Time("SystemModstamp"); t != nil && (latest == nil || t.After(*latest)) { + latest = t + } + } + return true + }) + } + if err == nil { + err = applyErr + } + if err != nil || latest == nil { + return cursor, err + } + return advance(*cursor, latest), nil +} + +// applyPulled updates the links behind changed records and acts on what +// changed. +func (s *Service) applyPulled(ctx context.Context, c *conn, object string, rows []Record) error { + if len(rows) == 0 { + return nil + } + ids := make([]string, 0, len(rows)) + byID := map[string]Record{} + for _, r := range rows { + id := NormalizeID(r.String("Id")) + ids = append(ids, id) + byID[id] = r + } + links, err := s.Repo.LinksForRecords(ctx, c.ID, ids) + if err != nil || len(links) == 0 { + return err + } + var contactIDs []uuid.UUID + for _, l := range links { + contactIDs = append(contactIDs, l.ContactID) + } + contacts, err := s.Repo.ContactsByIDs(ctx, c.OrganizationID, contactIDs) + if err != nil { + return err + } + for _, old := range links { + if old.SObject != object { + continue + } + rec := byID[old.RecordID] + ct, ok := contacts[old.ContactID] + if rec == nil || !ok { + continue + } + next := linkFrom(c, old.ContactID, object, rec, old.LinkedBy) + + if object == ObjectLead && next.IsConverted && !old.IsConverted { + if !s.followConversion(ctx, c, old, rec) { + // The Contact it became is not readable; keep the converted Lead. + _ = s.Repo.UpsertLink(ctx, next) + } + if c.settings.Inbound.PauseOnConverted { + s.hold(ctx, c, ct, "Salesforce: the Lead was converted") + } + continue + } + if object == ObjectLead && next.LeadStatus != old.LeadStatus && containsFold(c.settings.Inbound.PauseOnStatuses, next.LeadStatus) { + s.hold(ctx, c, ct, "Salesforce: Lead Status is "+next.LeadStatus) + } + if next.OptedOut && !old.OptedOut { + s.optOutFromSalesforce(ctx, c, ct) + } + s.pullFields(ctx, c, object, rec, ct) + if err := s.Repo.UpsertLink(ctx, next); err != nil { + log.Warn().Err(err).Msg("salesforce: could not refresh link") + } + } + return nil +} + +// followConversion moves a converted Lead's link to the Contact it became, +// and reports whether it could. +func (s *Service) followConversion(ctx context.Context, c *conn, old models.SalesforceRecordLink, lead Record) bool { + contactID := NormalizeID(lead.String("ConvertedContactId")) + if contactID == "" { + return false + } + rows, err := sfQuery(ctx, c, ObjectContact, "Id = "+Quote(contactID), 1) + if err != nil || len(rows) == 0 { + return false + } + l := linkFrom(c, old.ContactID, ObjectContact, rows[0], old.LinkedBy) + return s.Repo.UpsertLink(ctx, l) == nil +} + +// hold parks the contact's outreach everywhere, as a Salesforce rule. +func (s *Service) hold(ctx context.Context, c *conn, ct repository.SalesforceContact, reason string) { + if s.Holds == nil { + return + } + if _, err := s.Holds.HoldLeadEverywhere(ctx, ct.ID, nil, reason, models.LeadHoldSourceCRM); err != nil { + log.Warn().Err(err).Str("contact", ct.ID.String()).Msg("salesforce: could not hold lead") + } +} + +// optOutFromSalesforce honours Email Opt Out set in Salesforce. +func (s *Service) optOutFromSalesforce(ctx context.Context, c *conn, ct repository.SalesforceContact) { + mode := c.settings.Inbound.OptOut + if mode != "both" && mode != "from_salesforce" { + return + } + if s.Suppression != nil { + _ = s.Suppression.UpsertSuppressedRecipient(ctx, &models.SuppressedRecipient{ + OrganizationID: c.OrganizationID, + Email: strings.ToLower(ct.Email), + Kind: models.SuppressionKindEmail, + Reason: "Email Opt Out is set in Salesforce", + Source: models.DeliverabilityEventUnsubscribe, + Metadata: map[string]any{"via": "salesforce", "connection_id": c.ID.String()}, + }) + } + if s.Subscription != nil { + _ = s.Subscription.SetSubscribedByEmail(ctx, c.OrganizationID, ct.Email, false) + } +} + +// pullFields writes the pull rules' values onto the Warmbly contact. +func (s *Service) pullFields(ctx context.Context, c *conn, object string, rec Record, ct repository.SalesforceContact) { + rules := c.settings.rulesFor(object, DirectionPull) + if len(rules) == 0 || s.Contacts == nil { + return + } + upd := &models.UpdateContact{} + custom := map[string]string{} + for k, v := range ct.CustomFields { + custom[k] = v + } + changed, customChanged := false, false + set := func(dst **string, cur, v string, policy string) { + if v == "" || cur == v || (policy == PolicyIfEmpty && cur != "") { + return + } + val := v + *dst = &val + changed = true + } + for _, r := range rules { + if isEngagementField(r.Warmbly) { + continue + } + v := strings.TrimSpace(rec.String(r.Salesforce)) + switch r.Warmbly { + case "first_name": + set(&upd.FirstName, ct.FirstName, v, r.Policy) + case "last_name": + set(&upd.LastName, ct.LastName, v, r.Policy) + case "company": + set(&upd.Company, ct.Company, v, r.Policy) + case "phone": + set(&upd.Phone, ct.Phone, v, r.Policy) + default: + key, ok := strings.CutPrefix(r.Warmbly, "custom:") + if !ok || v == "" { + continue + } + cur := custom[key] + if cur == v || (r.Policy == PolicyIfEmpty && cur != "") { + continue + } + custom[key] = v + customChanged = true + } + } + if customChanged { + upd.CustomFields = &custom + changed = true + } + if !changed { + return + } + actor := uuid.Nil + if c.ConnectedByUserID != nil { + actor = *c.ConnectedByUserID + } + if _, xerr := s.Contacts.Update(ctx, actor.String(), ct.ID.String(), c.OrganizationID, upd); xerr != nil { + log.Warn().Str("contact", ct.ID.String()).Str("error", xerr.Message).Msg("salesforce: could not apply pulled fields") + } +} + +// pullOpportunities holds Contacts whose account gained an open opportunity +// since the cursor, and returns where it read up to. Each opportunity is read +// once, so a member who resumes a lead is not overruled by the next pass. +func (s *Service) pullOpportunities(ctx context.Context, c *conn, cursor *time.Time) (*time.Time, error) { + if cursor == nil { + now := time.Now().UTC() + return &now, nil + } + rows, err := c.client.QueryAll(ctx, "SELECT AccountId, CreatedDate FROM Opportunity WHERE IsClosed = false AND AccountId != null AND CreatedDate >= "+ + soqlTime(*cursor)+" ORDER BY CreatedDate ASC LIMIT 2000", 2000) + if err != nil { + return cursor, err + } + var accounts []string + var latest *time.Time + seen := map[string]bool{} + for _, r := range rows { + if t := r.Time("CreatedDate"); t != nil && (latest == nil || t.After(*latest)) { + latest = t + } + id := NormalizeID(r.String("AccountId")) + if id != "" && !seen[id] { + seen[id] = true + accounts = append(accounts, id) + } + } + next := advance(*cursor, latest) + links, err := s.Repo.LinksForAccounts(ctx, c.ID, accounts) + if err != nil { + return cursor, err + } + if len(links) == 0 { + return next, nil + } + ids := make([]uuid.UUID, 0, len(links)) + for _, l := range links { + ids = append(ids, l.ContactID) + } + contacts, err := s.Repo.ContactsByIDs(ctx, c.OrganizationID, ids) + if err != nil { + return cursor, err + } + for _, l := range links { + if ct, ok := contacts[l.ContactID]; ok { + s.hold(ctx, c, ct, fmt.Sprintf("Salesforce: an open opportunity exists on %s", orDefault(l.AccountName, "the account"))) + } + } + return next, nil +} + +func containsFold(list []string, v string) bool { + if v == "" { + return false + } + for _, x := range list { + if strings.EqualFold(strings.TrimSpace(x), v) { + return true + } + } + return false +} + +// Prune drops processed outbox rows past the retention window. +func (s *Service) Prune(ctx context.Context) error { + _, err := s.Repo.PruneActivities(ctx, time.Now().UTC().AddDate(0, 0, -activityRetentionDays)) + return err +} + +// activityRetentionDays is how long the activity log keeps processed rows. +const activityRetentionDays = 30 diff --git a/internal/app/salesforce/recorder.go b/internal/app/salesforce/recorder.go new file mode 100644 index 000000000..bf9e236d7 --- /dev/null +++ b/internal/app/salesforce/recorder.go @@ -0,0 +1,295 @@ +package salesforce + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + + "github.com/warmbly/warmbly/internal/app/cipher" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +// Recorder turns platform events into outbox rows. It runs wherever events are +// raised (backend and consumer) and only writes; the drain runs elsewhere. +// It is wired ahead of the webhook throttle, because a dropped event is a gap +// in a customer's CRM history. +type Recorder struct { + repo repository.SalesforceRepository + cipher cipher.CipherService + + mu sync.Mutex + cache map[uuid.UUID]recorderEntry +} + +type recorderEntry struct { + at time.Time + conns []recorderConn +} + +type recorderConn struct { + id uuid.UUID + settings Settings +} + +// recorderTTL bounds how stale the per-org connection list can be. Settings +// saved on this process are seen at once; another process sees them within it. +const recorderTTL = time.Minute + +// NewRecorder builds the event sink. +func NewRecorder(repo repository.SalesforceRepository, c cipher.CipherService) *Recorder { + return &Recorder{repo: repo, cipher: c, cache: map[uuid.UUID]recorderEntry{}} +} + +func (r *Recorder) forget(orgID uuid.UUID) { + r.mu.Lock() + delete(r.cache, orgID) + r.mu.Unlock() +} + +func (r *Recorder) connections(ctx context.Context, orgID uuid.UUID) []recorderConn { + r.mu.Lock() + e, ok := r.cache[orgID] + r.mu.Unlock() + if ok && time.Since(e.at) < recorderTTL { + return e.conns + } + refs, err := r.repo.ActiveConnectionsForOrg(ctx, orgID) + if err != nil { + return nil + } + conns := make([]recorderConn, 0, len(refs)) + for _, ref := range refs { + st := ParseSettings(ref.ConfigCapabilities) + if st.Enabled { + conns = append(conns, recorderConn{id: ref.ID, settings: st}) + } + } + r.mu.Lock() + r.cache[orgID] = recorderEntry{at: time.Now(), conns: conns} + r.mu.Unlock() + return conns +} + +// kindFor maps a platform event to an activity kind. +func kindFor(t models.WebhookEventType) string { + switch t { + case models.WebhookEventCampaignEmailSent: + return KindSent + case models.WebhookEventCampaignEmailOpened: + return KindOpened + case models.WebhookEventCampaignEmailClicked: + return KindClicked + case models.WebhookEventCampaignReplyReceived: + return KindReplied + case models.WebhookEventCampaignEmailBounced: + return KindBounced + case models.WebhookEventCampaignUnsubscribed, models.WebhookEventDeliverabilityComplaint: + return KindUnsubscribed + case models.WebhookEventMeetingBooked: + return KindMeetingBooked + } + return "" +} + +// wants reports whether a connection has anything to do with an event: a Task +// to log or a field to write back. +func wants(st Settings, kind string) bool { + if st.Activity.Logs(kind) { + return true + } + w := st.Writeback + switch kind { + case KindSent: + return w.LeadStatusOnSent != "" + case KindReplied: + for _, v := range w.LeadStatusOnReply { + if strings.TrimSpace(v) != "" { + return true + } + } + case KindMeetingBooked: + return w.LeadStatusOnMeeting != "" + case KindUnsubscribed: + return st.Inbound.OptOut == "both" || st.Inbound.OptOut == "to_salesforce" + } + return false +} + +// Record is the webhook dispatch sink. Best-effort and quick: an event with no +// Salesforce interest costs one cached lookup. +func (r *Recorder) Record(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) { + kind := kindFor(eventType) + if kind == "" || orgID == uuid.Nil { + return + } + m, ok := data.(map[string]any) + if !ok { + return + } + if b, _ := m["test"].(bool); b { + return + } + // The caller's context may end with its request; the insert must not. + wctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + conns := r.connections(wctx, orgID) + if len(conns) == 0 { + return + } + email := strings.ToLower(str(m, "contact_email", "invitee_email", "recipient", "email")) + if email == "" { + return + } + var contactID *uuid.UUID + if id, err := uuid.Parse(str(m, "contact_id")); err == nil { + contactID = &id + } + payload, content, dedupe := r.shape(kind, eventType, m, email) + sealed := "" + if content != nil { + raw, _ := json.Marshal(content) + if s, err := r.seal(wctx, orgID, string(raw)); err == nil { + sealed = s + } + } + for _, c := range conns { + if !wants(c.settings, kind) { + continue + } + a := &models.SalesforceActivity{ + OrganizationID: orgID, + ConnectionID: c.id, + ContactID: contactID, + ContactEmail: email, + Kind: kind, + DedupeKey: dedupe, + Payload: payload, + ContentEncrypted: sealed, + OccurredAt: time.Now().UTC(), + } + if err := r.repo.EnqueueActivity(wctx, a); err != nil { + log.Warn().Err(err).Str("kind", kind).Msg("salesforce: could not queue activity") + } + } +} + +// activityContent is the text half of an activity, sealed at rest. +type activityContent struct { + Subject string `json:"subject,omitempty"` + Body string `json:"body,omitempty"` +} + +// shape picks the ids worth keeping, the text to seal, and the key that makes +// a repeat of the same event a no-op. +func (r *Recorder) shape(kind string, t models.WebhookEventType, m map[string]any, email string) (map[string]any, *activityContent, string) { + p := map[string]any{} + keep := func(keys ...string) { + for _, k := range keys { + if v := str(m, k); v != "" { + p[strings.TrimPrefix(k, "_")] = v + } + } + } + keep("campaign_id", "sequence_id", "from_email") + step := str(m, "campaign_id") + ":" + str(m, "sequence_id") + day := time.Now().UTC().Format("2006-01-02") + switch kind { + case KindSent: + keep("_task_id") + c := &activityContent{Subject: str(m, "_subject"), Body: truncate(str(m, "_body_text"), descriptionCap)} + if id := str(m, "_task_id"); id != "" { + return p, c, "sent:" + id + } + return p, c, "sent:" + email + ":" + step + case KindOpened: + return p, nil, "opened:" + email + ":" + step + case KindClicked: + keep("url", "link_label") + return p, nil, "clicked:" + email + ":" + step + ":" + str(m, "url") + case KindReplied: + keep("intent", "thread_id", "email_account_id", "sender_email_account_id") + body := truncate(str(m, "_body_text"), descriptionCap) + if body == "" { + body = str(m, "snippet") + } + c := &activityContent{Subject: str(m, "subject"), Body: body} + if id := str(m, "_message_id"); id != "" { + return p, c, "replied:" + id + } + return p, c, "replied:" + email + ":" + str(m, "thread_id") + ":" + str(m, "subject") + ":" + day + case KindBounced: + keep("reason", "provider") + return p, nil, "bounced:" + email + ":" + str(m, "campaign_id") + case KindUnsubscribed: + keep("source") + if t == models.WebhookEventDeliverabilityComplaint { + p["source"] = "complaint" + } + return p, nil, "unsubscribed:" + email + case KindMeetingBooked: + keep("event_name", "scheduled_for", "join_url", "booking_id", "source") + return p, nil, "meeting:" + str(m, "booking_id", "scheduled_for") + ":" + email + } + return p, nil, kind + ":" + email + ":" + day +} + +func (r *Recorder) seal(ctx context.Context, orgID uuid.UUID, plain string) (string, error) { + if r.cipher == nil { + return "", fmt.Errorf("cipher unavailable") + } + c, err := r.cipher.Cipher(ctx, orgID) + if err != nil { + return "", err + } + return c.Encrypt(ctx, plain) +} + +// str reads the first non-empty value among keys as a string, whatever type +// the event carried it as. +func str(m map[string]any, keys ...string) string { + for _, k := range keys { + switch v := m[k].(type) { + case nil: + case string: + if s := strings.TrimSpace(v); s != "" { + return s + } + case *string: + if v != nil && strings.TrimSpace(*v) != "" { + return strings.TrimSpace(*v) + } + case uuid.UUID: + if v != uuid.Nil { + return v.String() + } + case *uuid.UUID: + if v != nil && *v != uuid.Nil { + return v.String() + } + case time.Time: + if !v.IsZero() { + return v.UTC().Format(time.RFC3339) + } + case *time.Time: + if v != nil && !v.IsZero() { + return v.UTC().Format(time.RFC3339) + } + case fmt.Stringer: + if s := strings.TrimSpace(v.String()); s != "" { + return s + } + default: + if s := strings.TrimSpace(fmt.Sprint(v)); s != "" && s != "" { + return s + } + } + } + return "" +} diff --git a/internal/app/salesforce/service.go b/internal/app/salesforce/service.go new file mode 100644 index 000000000..bc7bef9d4 --- /dev/null +++ b/internal/app/salesforce/service.go @@ -0,0 +1,737 @@ +package salesforce + +import ( + "context" + "encoding/json" + "errors" + "net/url" + "sort" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + + "github.com/warmbly/warmbly/internal/app/cipher" + "github.com/warmbly/warmbly/internal/app/contact" + "github.com/warmbly/warmbly/internal/app/integration" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +// LeadHolder parks a contact's outreach in every campaign they are a lead of. +type LeadHolder interface { + HoldLeadEverywhere(ctx context.Context, contactID uuid.UUID, until *time.Time, reason, source string) ([]uuid.UUID, error) +} + +// Suppressor adds an address to the organization's suppression list. +type Suppressor interface { + UpsertSuppressedRecipient(ctx context.Context, entry *models.SuppressedRecipient) error +} + +// SubscriptionWriter flips a contact's subscribed flag by address. +type SubscriptionWriter interface { + SetSubscribedByEmail(ctx context.Context, orgID uuid.UUID, email string, subscribed bool) error +} + +// Deps is everything the sync talks to. +type Deps struct { + Repo repository.SalesforceRepository + Integrations integration.Service + Cipher cipher.CipherService + Contacts contact.ContactService + Holds LeadHolder + Suppression Suppressor + Subscription SubscriptionWriter +} + +// Service is the native Salesforce sync. +type Service struct { + Deps + recorder *Recorder + + metaMu sync.Mutex + meta map[uuid.UUID]*cachedMeta + + missMu sync.Mutex + misses map[string]time.Time + + // syncing holds the connections a "Sync now" is running for. + syncing sync.Map +} + +// NewService builds the sync. +func NewService(d Deps) *Service { + return &Service{ + Deps: d, + recorder: NewRecorder(d.Repo, d.Cipher), + meta: map[uuid.UUID]*cachedMeta{}, + misses: map[string]time.Time{}, + } +} + +// Recorder is the event sink half, for wiring into the webhook fan-out. +func (s *Service) Recorder() *Recorder { return s.recorder } + +// --- connection access ------------------------------------------------------ + +// conn is one Salesforce connection resolved for work. +type conn struct { + *models.IntegrationConnection + settings Settings + client *Client + usage Usage + usageMu sync.Mutex +} + +func (c *conn) instanceURL() string { return configString(c.DisplayFields, "instance_url") } +func (c *conn) sfUserID() string { return NormalizeID(configString(c.DisplayFields, "sf_user_id")) } + +// recordURL is a record's address in the org; Salesforce redirects / to +// the right Lightning page for any object. +func (c *conn) recordURL(id string) string { + if id == "" || c.instanceURL() == "" { + return "" + } + return strings.TrimRight(c.instanceURL(), "/") + "/" + id +} + +type tokenSource struct { + svc integration.Service + orgID, conn uuid.UUID + mu sync.Mutex + token, inst string +} + +func (t *tokenSource) Token(ctx context.Context, force bool) (string, string, error) { + t.mu.Lock() + defer t.mu.Unlock() + if t.token != "" && !force { + return t.token, t.inst, nil + } + acc, err := t.svc.ProviderAccess(ctx, t.orgID, t.conn, force) + if err != nil { + return "", "", err + } + t.token, t.inst = acc.Token, acc.InstanceURL + return t.token, t.inst, nil +} + +// open resolves an org-owned Salesforce connection and a client for it. +func (s *Service) open(ctx context.Context, orgID, connID uuid.UUID) (*conn, error) { + ic, err := s.Integrations.GetConnection(ctx, orgID, connID) + if err != nil { + return nil, err + } + if ic == nil || ic.Provider != models.IntegrationSalesforce { + return nil, errx.New(errx.NotFound, "Salesforce connection not found") + } + c := &conn{IntegrationConnection: ic, settings: ParseSettings(ic.ConfigCapabilities)} + if !hasSettings(ic.ConfigCapabilities) { + if rows, err := s.Integrations.ListFieldMappings(ctx, orgID, connID); err == nil { + c.settings.withLegacyMappings(rows) + } + } + c.client = NewClient(&tokenSource{svc: s.Integrations, orgID: orgID, conn: connID}, func(u Usage) { + c.usageMu.Lock() + c.usage = u + c.usageMu.Unlock() + }) + return c, nil +} + +// settle records the calls a unit of work made and the org's API reading. +func (s *Service) settle(ctx context.Context, c *conn) { + calls := c.client.Calls() + c.usageMu.Lock() + u := c.usage + c.usageMu.Unlock() + if calls == 0 && u.Max == 0 { + return + } + _ = s.Repo.EnsureSyncState(ctx, c.ID, c.OrganizationID, time.Now().UTC()) + _ = s.Repo.RecordUsage(ctx, c.ID, u.Used, u.Max, calls) +} + +// budget is the connection's daily call allowance. +func budget(st Settings, state *models.SalesforceSyncState) int { + if st.DailyAPIBudget > 0 { + return st.DailyAPIBudget + } + if state != nil && state.APIMax > 0 { + return max(state.APIMax/5, 1000) + } + return 5000 +} + +// overBudget reports whether background work should wait for tomorrow: either +// Warmbly spent its share, or the org as a whole is nearly out of calls. +func (s *Service) overBudget(ctx context.Context, c *conn) bool { + state, err := s.Repo.GetSyncState(ctx, c.ID) + if err != nil || state == nil { + return false + } + if state.CallsToday >= budget(c.settings, state) { + return true + } + return state.APIMax > 0 && state.APISeenAt != nil && time.Since(*state.APISeenAt) < time.Hour && + float64(state.APIUsed) >= 0.95*float64(state.APIMax) +} + +// Connected starts a new connection's pull cursor at the moment it connected, +// so the first pull reads changes from then on. +// A brand-new connection is saved with the defaults, sync on; reconnecting an +// existing one keeps whatever it had. +func (s *Service) Connected(ctx context.Context, ic *models.IntegrationConnection) { + _ = s.Repo.EnsureSyncState(ctx, ic.ID, ic.OrganizationID, time.Now().UTC()) + if !hasSettings(ic.ConfigCapabilities) && time.Since(ic.CreatedAt) < 5*time.Minute { + if _, err := s.SaveSettings(ctx, ic.OrganizationID, ic.ID, DefaultSettings()); err != nil { + log.Warn().Err(err).Str("connection", ic.ID.String()).Msg("salesforce: could not save default settings") + } + } + s.recorder.forget(ic.OrganizationID) +} + +func hasSettings(raw json.RawMessage) bool { + var wrap map[string]json.RawMessage + if json.Unmarshal(raw, &wrap) != nil { + return false + } + _, ok := wrap[settingsKey] + return ok +} + +// --- settings --------------------------------------------------------------- + +// GetSettings returns a connection's settings. +func (s *Service) GetSettings(ctx context.Context, orgID, connID uuid.UUID) (Settings, error) { + c, err := s.open(ctx, orgID, connID) + if err != nil { + return Settings{}, err + } + return c.settings, nil +} + +// SaveSettings validates and stores a connection's settings. +func (s *Service) SaveSettings(ctx context.Context, orgID, connID uuid.UUID, next Settings) (Settings, error) { + if next.Writeback.LeadStatusOnReply == nil { + next.Writeback.LeadStatusOnReply = map[string]string{} + } + if next.Inbound.PauseOnStatuses == nil { + next.Inbound.PauseOnStatuses = []string{} + } + if next.FieldMap == nil { + next.FieldMap = []FieldRule{} + } + for i := range next.FieldMap { + next.FieldMap[i].Salesforce = strings.TrimSpace(next.FieldMap[i].Salesforce) + next.FieldMap[i].Warmbly = strings.TrimSpace(next.FieldMap[i].Warmbly) + } + next.Matching.OwnerID = NormalizeID(next.Matching.OwnerID) + if err := next.Validate(); err != nil { + return Settings{}, errx.NewWithIdentifier(errx.BadRequest, "invalid_salesforce_settings", err.Error()) + } + c, err := s.open(ctx, orgID, connID) + if err != nil { + return Settings{}, err + } + if err := s.Integrations.SetConfigKey(ctx, orgID, c.ID, settingsKey, next); err != nil { + return Settings{}, err + } + s.recorder.forget(orgID) + if next.Enabled { + _ = s.Repo.EnsureSyncState(ctx, connID, orgID, time.Now().UTC()) + } + return next, nil +} + +// --- overview --------------------------------------------------------------- + +// Check is one permission or setup probe. +type Check struct { + Key string `json:"key"` + Label string `json:"label"` + OK bool `json:"ok"` + Detail string `json:"detail,omitempty"` +} + +// Overview is the connection's health page. +type Overview struct { + ConnectionID uuid.UUID `json:"connection_id"` + Label string `json:"label"` + Status string `json:"status"` + Health string `json:"health"` + HealthDetail string `json:"health_detail,omitempty"` + Org OverviewOrg `json:"org"` + API OverviewAPI `json:"api"` + Counts models.SalesforceActivityCounts `json:"counts"` + LastPullAt *time.Time `json:"last_pull_at,omitempty"` + LastPullError string `json:"last_pull_error,omitempty"` + SettingsEnabled bool `json:"settings_enabled"` + Checks []Check `json:"checks,omitempty"` +} + +// OverviewOrg names the connected org. +type OverviewOrg struct { + ID string `json:"id,omitempty"` + InstanceURL string `json:"instance_url"` + Environment string `json:"environment"` + LoginHost string `json:"login_host"` + UserID string `json:"user_id,omitempty"` + Account string `json:"account,omitempty"` +} + +// OverviewAPI is the API budget picture. +type OverviewAPI struct { + Used int `json:"used"` + Max int `json:"max"` + CallsToday int `json:"calls_today"` + Budget int `json:"budget"` +} + +// Overview builds the health page; withChecks also probes permissions. +func (s *Service) Overview(ctx context.Context, orgID, connID uuid.UUID, withChecks bool) (*Overview, error) { + c, err := s.open(ctx, orgID, connID) + if err != nil { + return nil, err + } + env := configString(c.DisplayFields, "environment") + if env == "" { + env = "production" + } + host := configString(c.DisplayFields, "login_host") + if host == "" { + host = "login.salesforce.com" + } + out := &Overview{ + ConnectionID: c.ID, + Label: c.Label, + Status: string(c.Status), + Health: c.Health, + Org: OverviewOrg{ + ID: configString(c.DisplayFields, "sf_org_id"), + InstanceURL: c.instanceURL(), + Environment: env, + LoginHost: host, + UserID: c.sfUserID(), + Account: c.ExternalAccountName, + }, + SettingsEnabled: c.settings.Enabled, + } + if c.HealthDetail != nil { + out.HealthDetail = *c.HealthDetail + } + if counts, err := s.Repo.ActivityCounts(ctx, c.ID); err == nil { + out.Counts = counts + } + state, _ := s.Repo.GetSyncState(ctx, c.ID) + if withChecks { + if u, err := c.client.Limits(ctx); err == nil { + c.usageMu.Lock() + c.usage = u + c.usageMu.Unlock() + } + out.Checks = s.checks(ctx, c) + s.settle(ctx, c) + state, _ = s.Repo.GetSyncState(ctx, c.ID) + } + if state != nil { + out.API = OverviewAPI{Used: state.APIUsed, Max: state.APIMax, CallsToday: state.CallsToday} + out.LastPullAt = state.LastPullAt + out.LastPullError = state.LastPullError + } + out.API.Budget = budget(c.settings, state) + return out, nil +} + +// checks probes what the connected user can do, as plain answers an admin can +// act on. +func (s *Service) checks(ctx context.Context, c *conn) []Check { + var out []Check + add := func(key, label string, ok bool, detail string) { + out = append(out, Check{Key: key, Label: label, OK: ok, Detail: detail}) + } + for _, obj := range []string{ObjectLead, ObjectContact, "Task"} { + d, err := c.client.Describe(ctx, obj) + if err != nil { + add(strings.ToLower(obj)+"_access", obj+" access", false, err.Error()) + continue + } + switch obj { + case "Task": + ok := d.Createable + detail := "" + if !ok { + detail = "The connected user cannot create Tasks, so activity cannot be logged." + } else if f := d.Field("TaskSubtype"); f == nil || !f.Createable { + detail = "Tasks are logged without the email icon: TaskSubtype is not writable for this user." + } + add("task_create", "Log activity as Tasks", ok, detail) + default: + detail := "" + if !d.Createable { + detail = "The connected user cannot create " + obj + "s." + } + add(strings.ToLower(obj)+"_create", "Create "+obj+"s", d.Createable, detail) + upd := d.Updateable + detail = "" + if !upd { + detail = "The connected user cannot edit " + obj + "s, so status and opt-out writeback is off." + } else if f := d.Field("HasOptedOutOfEmail"); f == nil || !f.Updateable { + detail = "Email Opt Out is not editable for this user, so unsubscribes cannot be written back." + } + add(strings.ToLower(obj)+"_update", "Update "+obj+"s", upd, detail) + } + } + if _, err := c.client.ListViews(ctx, ObjectLead); err != nil { + add("list_views", "Read list views", false, err.Error()) + } else { + add("list_views", "Read list views", true, "") + } + return out +} + +// --- metadata --------------------------------------------------------------- + +type cachedMeta struct { + at time.Time + lead *Describe + contact *Describe + closedTask string + userByEmail map[string]string +} + +const metaTTL = 15 * time.Minute + +// describe returns the Lead and Contact describes, cached per connection. +func (s *Service) describe(ctx context.Context, c *conn) (*cachedMeta, error) { + s.metaMu.Lock() + m := s.meta[c.ID] + s.metaMu.Unlock() + if m != nil && time.Since(m.at) < metaTTL { + return m, nil + } + lead, err := c.client.Describe(ctx, ObjectLead) + if err != nil { + return nil, err + } + ct, err := c.client.Describe(ctx, ObjectContact) + if err != nil { + return nil, err + } + m = &cachedMeta{at: time.Now(), lead: lead, contact: ct, closedTask: "Completed", userByEmail: map[string]string{}} + rows, err := c.client.QueryAll(ctx, "SELECT ApiName, MasterLabel FROM TaskStatus WHERE IsClosed = true ORDER BY SortOrder LIMIT 1", 1) + if err != nil { + rows, err = c.client.QueryAll(ctx, "SELECT MasterLabel FROM TaskStatus WHERE IsClosed = true ORDER BY SortOrder LIMIT 1", 1) + } + if err == nil && len(rows) > 0 { + if v := rows[0].String("ApiName"); v != "" { + m.closedTask = v + } else if v := rows[0].String("MasterLabel"); v != "" { + m.closedTask = v + } + } + s.metaMu.Lock() + s.meta[c.ID] = m + s.metaMu.Unlock() + return m, nil +} + +// FieldInfo is one field as the mapping editor shows it. +type FieldInfo struct { + Name string `json:"name"` + Label string `json:"label"` + Type string `json:"type"` + Createable bool `json:"createable"` + Updateable bool `json:"updateable"` + Calculated bool `json:"calculated"` + Custom bool `json:"custom"` + Picklist []PickOption `json:"picklist,omitempty"` +} + +// PickOption is a picklist value. +type PickOption struct { + Value string `json:"value"` + Label string `json:"label"` +} + +// Metadata is what the settings screens pick from. +type Metadata struct { + LeadFields []FieldInfo `json:"lead_fields"` + ContactFields []FieldInfo `json:"contact_fields"` + LeadStatuses []PickOption `json:"lead_statuses"` + LeadSources []PickOption `json:"lead_sources"` +} + +// Metadata returns the field and picklist catalogue of a connection. +func (s *Service) Metadata(ctx context.Context, orgID, connID uuid.UUID) (*Metadata, error) { + c, err := s.open(ctx, orgID, connID) + if err != nil { + return nil, err + } + defer s.settle(ctx, c) + m, err := s.describe(ctx, c) + if err != nil { + return nil, sfError(err) + } + out := &Metadata{ + LeadFields: fieldInfos(m.lead, ObjectLead), + ContactFields: fieldInfos(m.contact, ObjectContact), + LeadStatuses: picklist(m.lead, "Status"), + LeadSources: picklist(m.lead, "LeadSource"), + } + return out, nil +} + +// relatedReadFields are lookups worth reading through, offered as pull-only. +var relatedReadFields = map[string][]FieldInfo{ + ObjectContact: { + {Name: "Account.Name", Label: "Account Name", Type: "string"}, + {Name: "Account.Website", Label: "Account Website", Type: "url"}, + {Name: "Account.Industry", Label: "Account Industry", Type: "picklist"}, + }, + ObjectLead: {}, +} + +func fieldInfos(d *Describe, object string) []FieldInfo { + out := make([]FieldInfo, 0, len(d.Fields)) + for _, f := range d.Fields { + switch f.Type { + case "address", "location", "base64", "anyType", "complexvalue": + continue + } + fi := FieldInfo{ + Name: f.Name, Label: f.Label, Type: f.Type, + Createable: f.Createable, Updateable: f.Updateable, + Calculated: f.Calculated || f.AutoNumber, Custom: f.Custom, + } + if f.Type == "picklist" || f.Type == "multipicklist" { + for _, p := range f.PicklistValues { + if p.Active { + fi.Picklist = append(fi.Picklist, PickOption{Value: p.Value, Label: p.Label}) + } + } + } + out = append(out, fi) + } + out = append(out, relatedReadFields[object]...) + sort.SliceStable(out, func(i, j int) bool { return strings.ToLower(out[i].Label) < strings.ToLower(out[j].Label) }) + return out +} + +func picklist(d *Describe, field string) []PickOption { + f := d.Field(field) + if f == nil { + return []PickOption{} + } + out := make([]PickOption, 0, len(f.PicklistValues)) + for _, p := range f.PicklistValues { + if p.Active { + out = append(out, PickOption{Value: p.Value, Label: p.Label}) + } + } + return out +} + +// statusRank is a Lead status's position in the picklist, -1 when unknown. +func statusRank(d *Describe, status string) int { + if d == nil || status == "" { + return -1 + } + f := d.Field("Status") + if f == nil { + return -1 + } + n := 0 + for _, p := range f.PicklistValues { + if !p.Active { + continue + } + if strings.EqualFold(p.Value, status) { + return n + } + n++ + } + return -1 +} + +// User is a Salesforce user for owner pickers. +type User struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +// Users searches the org's active users. +func (s *Service) Users(ctx context.Context, orgID, connID uuid.UUID, q string) ([]User, error) { + c, err := s.open(ctx, orgID, connID) + if err != nil { + return nil, err + } + defer s.settle(ctx, c) + soql := "SELECT Id, Name, Email FROM User WHERE IsActive = true AND UserType = 'Standard'" + if q = strings.TrimSpace(q); q != "" { + like := likeQuote(q) + soql += " AND (Name LIKE " + like + " OR Email LIKE " + like + ")" + } + soql += " ORDER BY Name LIMIT 50" + rows, err := c.client.QueryAll(ctx, soql, 50) + if err != nil { + return nil, sfError(err) + } + out := make([]User, 0, len(rows)) + for _, r := range rows { + out = append(out, User{ID: NormalizeID(r.String("Id")), Name: r.String("Name"), Email: r.String("Email")}) + } + return out, nil +} + +// ListViewInfo is a list view for the import picker. +type ListViewInfo struct { + ID string `json:"id"` + Label string `json:"label"` + Object string `json:"object"` +} + +// ListViews lists an object's list views. +func (s *Service) ListViews(ctx context.Context, orgID, connID uuid.UUID, object string) ([]ListViewInfo, error) { + if object != ObjectLead && object != ObjectContact { + return nil, errx.New(errx.BadRequest, "object must be Lead or Contact") + } + c, err := s.open(ctx, orgID, connID) + if err != nil { + return nil, err + } + defer s.settle(ctx, c) + views, err := c.client.ListViews(ctx, object) + if err != nil { + return nil, sfError(err) + } + out := make([]ListViewInfo, 0, len(views)) + for _, v := range views { + out = append(out, ListViewInfo{ID: v.ID, Label: v.Label, Object: object}) + } + return out, nil +} + +// CampaignInfo is a Salesforce Campaign for the import picker. +type CampaignInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Type string `json:"type"` + MemberCount int `json:"member_count"` +} + +// Campaigns searches the org's Salesforce Campaigns. +func (s *Service) Campaigns(ctx context.Context, orgID, connID uuid.UUID, q string) ([]CampaignInfo, error) { + c, err := s.open(ctx, orgID, connID) + if err != nil { + return nil, err + } + defer s.settle(ctx, c) + soql := "SELECT Id, Name, Status, Type, NumberOfLeads, NumberOfContacts FROM Campaign WHERE IsDeleted = false" + if q = strings.TrimSpace(q); q != "" { + soql += " AND Name LIKE " + likeQuote(q) + } + soql += " ORDER BY IsActive DESC, LastModifiedDate DESC LIMIT 50" + rows, err := c.client.QueryAll(ctx, soql, 50) + if err != nil { + return nil, sfError(err) + } + out := make([]CampaignInfo, 0, len(rows)) + for _, r := range rows { + out = append(out, CampaignInfo{ + ID: NormalizeID(r.String("Id")), Name: r.String("Name"), Status: r.String("Status"), Type: r.String("Type"), + MemberCount: atoi(r.String("NumberOfLeads")) + atoi(r.String("NumberOfContacts")), + }) + } + return out, nil +} + +// --- helpers ---------------------------------------------------------------- + +// sfError turns a Salesforce failure into an answer the dashboard can show. +// Anything that is not Salesforce's own refusal answers with a fixed sentence; +// the cause is logged. +func sfError(err error) error { + if err == nil { + return nil + } + var xe *errx.Error + if errors.As(err, &xe) { + return xe + } + switch { + case errors.Is(err, integration.ErrPushReauth), errors.Is(err, ErrSessionExpired): + return errx.NewWithIdentifier(errx.Conflict, "salesforce_reconnect_required", "Salesforce needs to be reconnected before this can run.") + case errors.Is(err, ErrRateLimited): + return errx.NewWithIdentifier(errx.TooManyRequests, "salesforce_rate_limited", "Your Salesforce org has used its API requests for today.") + } + var ae *APIError + if errors.As(err, &ae) { + return errx.NewWithIdentifier(errx.BadRequest, "salesforce_error", "Salesforce: "+ae.Error()) + } + var ue *url.Error + if errors.As(err, &ue) { + return errx.NewWithIdentifier(errx.BadRequest, "salesforce_unreachable", "Could not reach Salesforce. Try again shortly.") + } + log.Error().Err(err).Msg("salesforce request failed") + return errx.InternalError() +} + +// describeErr is the text an activity row or link keeps about a failure: what +// Salesforce said, or a fixed sentence, never an internal error or a URL that +// carries a contact's address. +func describeErr(err error) string { + var ae *APIError + switch { + case errors.As(err, &ae): + return ae.Error() + case errors.Is(err, integration.ErrPushReauth), errors.Is(err, ErrSessionExpired): + return "Waiting for Salesforce to be reconnected" + case errors.Is(err, ErrRateLimited): + return "Salesforce API limit reached for today" + } + var ue *url.Error + if errors.As(err, &ue) { + return "Salesforce did not respond; this will be retried" + } + log.Warn().Err(err).Msg("salesforce: sync step failed") + return "Something went wrong syncing with Salesforce; this will be retried" +} + +func configString(raw json.RawMessage, key string) string { + if len(raw) == 0 { + return "" + } + var m map[string]any + if json.Unmarshal(raw, &m) != nil { + return "" + } + if v, ok := m[key].(string); ok { + return strings.TrimSpace(v) + } + return "" +} + +// likeQuote renders a "contains" LIKE pattern with the user's own % and _ +// matched literally. +func likeQuote(s string) string { + q := Quote(s) + inner := strings.NewReplacer("%", `\%`, "_", `\_`).Replace(q[1 : len(q)-1]) + return "'%" + inner + "%'" +} + +func atoi(s string) int { + n := 0 + for _, r := range s { + if r < '0' || r > '9' { + break + } + n = n*10 + int(r-'0') + } + return n +} diff --git a/internal/app/salesforce/settings.go b/internal/app/salesforce/settings.go new file mode 100644 index 000000000..916124606 --- /dev/null +++ b/internal/app/salesforce/settings.go @@ -0,0 +1,440 @@ +package salesforce + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/warmbly/warmbly/internal/models" +) + +// settingsKey is where the settings live inside a connection's +// config_capabilities. Free-form, evolving, read-then-execute config, so jsonb +// with this struct as the type boundary and Validate on every write. +const settingsKey = "salesforce" + +// Object names the sync writes. +const ( + ObjectLead = "Lead" + ObjectContact = "Contact" +) + +// Settings is one connection's sync configuration. +type Settings struct { + // Enabled is the master switch for logging, writeback and the pull loop. + // Imports and the contact panel work regardless. + Enabled bool `json:"enabled"` + + Matching MatchingSettings `json:"matching"` + Activity ActivitySettings `json:"activity"` + Writeback WritebackSettings `json:"writeback"` + Inbound InboundSettings `json:"inbound"` + + // FieldMap is per-object field sync, in both directions. + FieldMap []FieldRule `json:"field_map"` + + // DailyAPIBudget caps the calls Warmbly makes per day; 0 means a fifth of + // the org's daily allocation. + DailyAPIBudget int `json:"daily_api_budget"` +} + +// MatchingSettings decides which record an email is, and what happens when it +// is none. +type MatchingSettings struct { + // Prefer is which object wins when an email is both: "contact" or "lead". + Prefer string `json:"prefer"` + // CreateWhen is when a missing person is created: "never", "reply" (on a + // reply or a meeting) or "send" (on the first logged activity). + CreateWhen string `json:"create_when"` + // CreateAs is "lead" or "contact". A created Contact needs no Account. + CreateAs string `json:"create_as"` + // LeadSource stamps created records; empty leaves Salesforce's default. + LeadSource string `json:"lead_source"` + // LeadStatus is the status a created Lead starts in; empty is the default. + LeadStatus string `json:"lead_status"` + // Owner of created records: "connected_user", "sender" (the Salesforce + // user with the sending mailbox's address) or "fixed". + Owner string `json:"owner"` + OwnerID string `json:"owner_id,omitempty"` + // RunAssignmentRules lets the org's lead assignment rules pick the owner. + RunAssignmentRules bool `json:"run_assignment_rules"` +} + +// ActivitySettings decides which campaign events become Tasks. +type ActivitySettings struct { + Sent bool `json:"sent"` + Replied bool `json:"replied"` + Opened bool `json:"opened"` + Clicked bool `json:"clicked"` + Bounced bool `json:"bounced"` + Unsubscribed bool `json:"unsubscribed"` + MeetingBooked bool `json:"meeting_booked"` + // IncludeBody writes the email or reply text into the Task description. + IncludeBody bool `json:"include_body"` + // AssignTo owns the Task: "record_owner", "sender" or "connected_user". + AssignTo string `json:"assign_to"` + // RelateToOpportunity puts a Contact's Tasks on their account's open + // opportunity, else the account. + RelateToOpportunity bool `json:"relate_to_opportunity"` +} + +// Logs reports whether an activity kind is switched on. +func (a ActivitySettings) Logs(kind string) bool { + switch kind { + case KindSent: + return a.Sent + case KindReplied: + return a.Replied + case KindOpened: + return a.Opened + case KindClicked: + return a.Clicked + case KindBounced: + return a.Bounced + case KindUnsubscribed: + return a.Unsubscribed + case KindMeetingBooked: + return a.MeetingBooked + } + return false +} + +// WritebackSettings decides which Lead and Contact fields Warmbly changes. +type WritebackSettings struct { + // LeadStatusOnSent is the status a Lead moves to when first emailed. + LeadStatusOnSent string `json:"lead_status_on_sent"` + // LeadStatusOnReply maps a reply intent (positive, negative, neutral, + // question, out_of_office, any) to a Lead status. + LeadStatusOnReply map[string]string `json:"lead_status_on_reply"` + // LeadStatusOnMeeting is the status a booked meeting moves a Lead to. + LeadStatusOnMeeting string `json:"lead_status_on_meeting"` + // NeverMoveBackwards keeps a Lead that is further along the status + // picklist where it is. + NeverMoveBackwards bool `json:"never_move_backwards"` +} + +// InboundSettings decides what a change in Salesforce does in Warmbly. +type InboundSettings struct { + // OptOut keeps do-not-email in step: "both", "to_salesforce", + // "from_salesforce" or "off". + OptOut string `json:"opt_out"` + // PauseOnConverted holds a Lead's outreach once it is converted. + PauseOnConverted bool `json:"pause_on_converted"` + // PauseOnStatuses holds a Lead's outreach once it reaches one of these. + PauseOnStatuses []string `json:"pause_on_statuses"` + // PauseOnOpenOpportunity holds a Contact whose account has an open + // opportunity: the deal is already being worked. + PauseOnOpenOpportunity bool `json:"pause_on_open_opportunity"` +} + +// Field directions and conflict policies. +const ( + DirectionPush = "push" + DirectionPull = "pull" + DirectionBoth = "both" + + // PolicyOverwrite writes the value whenever it changes. + PolicyOverwrite = "overwrite" + // PolicyIfEmpty only fills a field that is blank on the receiving side. + PolicyIfEmpty = "if_empty" +) + +// FieldRule syncs one Warmbly field with one Salesforce field. +type FieldRule struct { + Object string `json:"object"` + Warmbly string `json:"warmbly"` + Salesforce string `json:"salesforce"` + Direction string `json:"direction"` + Policy string `json:"policy"` +} + +// Activity kinds the outbox carries; mirror the queue's CHECK. +const ( + KindSent = "sent" + KindOpened = "opened" + KindClicked = "clicked" + KindReplied = "replied" + KindBounced = "bounced" + KindUnsubscribed = "unsubscribed" + KindMeetingBooked = "meeting_booked" +) + +// DefaultSettings is what a new connection starts with: sends and replies on +// the timeline, opt-outs in step both ways, nothing created or overwritten. +func DefaultSettings() Settings { + return Settings{ + Enabled: true, + Matching: MatchingSettings{ + Prefer: "contact", + CreateWhen: "reply", + CreateAs: "lead", + LeadSource: "Warmbly", + Owner: "connected_user", + }, + Activity: ActivitySettings{ + Sent: true, + Replied: true, + Bounced: true, + Unsubscribed: true, + MeetingBooked: true, + IncludeBody: true, + AssignTo: "record_owner", + RelateToOpportunity: true, + }, + Writeback: WritebackSettings{ + LeadStatusOnReply: map[string]string{}, + NeverMoveBackwards: true, + }, + Inbound: InboundSettings{ + OptOut: "both", + PauseOnConverted: false, + PauseOnStatuses: []string{}, + }, + FieldMap: DefaultFieldMap(), + } +} + +// DefaultFieldMap pushes identity fields to new records and fills blanks in +// Warmbly from Salesforce, never overwriting either side. +func DefaultFieldMap() []FieldRule { + var out []FieldRule + for _, obj := range []string{ObjectLead, ObjectContact} { + out = append(out, + FieldRule{Object: obj, Warmbly: "first_name", Salesforce: "FirstName", Direction: DirectionBoth, Policy: PolicyIfEmpty}, + FieldRule{Object: obj, Warmbly: "last_name", Salesforce: "LastName", Direction: DirectionBoth, Policy: PolicyIfEmpty}, + FieldRule{Object: obj, Warmbly: "phone", Salesforce: "Phone", Direction: DirectionBoth, Policy: PolicyIfEmpty}, + ) + } + out = append(out, FieldRule{Object: ObjectLead, Warmbly: "company", Salesforce: "Company", Direction: DirectionBoth, Policy: PolicyIfEmpty}) + out = append(out, FieldRule{Object: ObjectContact, Warmbly: "company", Salesforce: "Account.Name", Direction: DirectionPull, Policy: PolicyIfEmpty}) + return out +} + +// WarmblyFields is the vocabulary a field rule may name on the Warmbly side. +// custom: reads and writes a contact custom field; engagement fields are +// push-only and derived from the contact's campaign activity. +var WarmblyFields = []models.FieldDef{ + {Key: "first_name", Label: "First name"}, + {Key: "last_name", Label: "Last name"}, + {Key: "company", Label: "Company"}, + {Key: "phone", Label: "Phone"}, + {Key: "engagement.last_campaign", Label: "Last Warmbly campaign"}, + {Key: "engagement.last_sent_at", Label: "Last emailed at"}, + {Key: "engagement.last_reply_at", Label: "Last replied at"}, + {Key: "engagement.reply_intent", Label: "Last reply intent"}, + {Key: "engagement.status", Label: "Outreach status"}, +} + +func isEngagementField(k string) bool { return strings.HasPrefix(k, "engagement.") } + +func validWarmblyField(k string) bool { + if strings.HasPrefix(k, "custom:") { + return len(strings.TrimSpace(strings.TrimPrefix(k, "custom:"))) > 0 + } + for _, f := range WarmblyFields { + if f.Key == k { + return true + } + } + return false +} + +func validSalesforceField(name string) bool { + if name == "" || len(name) > 120 { + return false + } + for _, r := range name { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '.') { + return false + } + } + return true +} + +func oneOf(v string, allowed ...string) bool { + for _, a := range allowed { + if v == a { + return true + } + } + return false +} + +// replyIntents are the keys LeadStatusOnReply may use. +var replyIntents = []string{"any", "positive", "negative", "neutral", "question", "out_of_office"} + +// Validate refuses a configuration the sync cannot execute. +func (s *Settings) Validate() error { + m := s.Matching + if !oneOf(m.Prefer, "contact", "lead") { + return fmt.Errorf("matching.prefer must be contact or lead") + } + if !oneOf(m.CreateWhen, "never", "reply", "send") { + return fmt.Errorf("matching.create_when must be never, reply or send") + } + if !oneOf(m.CreateAs, "lead", "contact") { + return fmt.Errorf("matching.create_as must be lead or contact") + } + if !oneOf(m.Owner, "connected_user", "sender", "fixed") { + return fmt.Errorf("matching.owner must be connected_user, sender or fixed") + } + if m.Owner == "fixed" && !ValidID(m.OwnerID) { + return fmt.Errorf("choose the Salesforce user who owns created records") + } + if len(m.LeadSource) > 120 || len(m.LeadStatus) > 120 { + return fmt.Errorf("lead source and status are limited to 120 characters") + } + if !oneOf(s.Activity.AssignTo, "record_owner", "sender", "connected_user") { + return fmt.Errorf("activity.assign_to must be record_owner, sender or connected_user") + } + for k, v := range s.Writeback.LeadStatusOnReply { + if !oneOf(k, replyIntents...) { + return fmt.Errorf("unknown reply intent %q", k) + } + if len(v) > 120 { + return fmt.Errorf("lead status values are limited to 120 characters") + } + } + if !oneOf(s.Inbound.OptOut, "both", "to_salesforce", "from_salesforce", "off") { + return fmt.Errorf("inbound.opt_out must be both, to_salesforce, from_salesforce or off") + } + if len(s.Inbound.PauseOnStatuses) > 50 { + return fmt.Errorf("at most 50 pause statuses") + } + if s.DailyAPIBudget < 0 { + return fmt.Errorf("daily_api_budget cannot be negative") + } + if len(s.FieldMap) > 100 { + return fmt.Errorf("at most 100 field rules") + } + seen := map[string]bool{} + for i, r := range s.FieldMap { + if !oneOf(r.Object, ObjectLead, ObjectContact) { + return fmt.Errorf("field rule %d: object must be Lead or Contact", i+1) + } + if !validWarmblyField(r.Warmbly) { + return fmt.Errorf("field rule %d: unknown Warmbly field %q", i+1, r.Warmbly) + } + if !validSalesforceField(r.Salesforce) { + return fmt.Errorf("field rule %d: invalid Salesforce field %q", i+1, r.Salesforce) + } + if !oneOf(r.Direction, DirectionPush, DirectionPull, DirectionBoth) { + return fmt.Errorf("field rule %d: direction must be push, pull or both", i+1) + } + if !oneOf(r.Policy, PolicyOverwrite, PolicyIfEmpty) { + return fmt.Errorf("field rule %d: policy must be overwrite or if_empty", i+1) + } + if isEngagementField(r.Warmbly) && r.Direction != DirectionPush { + return fmt.Errorf("field rule %d: engagement fields only push to Salesforce", i+1) + } + if strings.Contains(r.Salesforce, ".") && r.Direction != DirectionPull { + return fmt.Errorf("field rule %d: a related field like %s can only be read", i+1, r.Salesforce) + } + key := r.Object + "|" + strings.ToLower(r.Salesforce) + "|" + r.Direction + if seen[key] { + return fmt.Errorf("field rule %d: %s.%s is mapped twice", i+1, r.Object, r.Salesforce) + } + seen[key] = true + } + return nil +} + +// ParseSettings reads the settings out of a connection's config_capabilities, +// filling anything unset from the defaults. A connection that never saved any +// predates native sync and stays off until someone turns it on. +func ParseSettings(raw json.RawMessage) Settings { + legacy := DefaultSettings() + legacy.Enabled = false + // The upsert action this replaces created Contacts. + legacy.Matching.CreateAs = "contact" + if len(raw) == 0 { + return legacy + } + var wrap map[string]json.RawMessage + if json.Unmarshal(raw, &wrap) != nil { + return legacy + } + blob, ok := wrap[settingsKey] + if !ok { + return legacy + } + s := DefaultSettings() + _ = json.Unmarshal(blob, &s) + if s.Writeback.LeadStatusOnReply == nil { + s.Writeback.LeadStatusOnReply = map[string]string{} + } + if s.Inbound.PauseOnStatuses == nil { + s.Inbound.PauseOnStatuses = []string{} + } + // Field names reach SOQL, so a rule that would not pass Validate is dropped + // however it got stored. + rules := make([]FieldRule, 0, len(s.FieldMap)) + for _, r := range s.FieldMap { + if oneOf(r.Object, ObjectLead, ObjectContact) && validWarmblyField(r.Warmbly) && validSalesforceField(r.Salesforce) && + oneOf(r.Direction, DirectionPush, DirectionPull, DirectionBoth) && oneOf(r.Policy, PolicyOverwrite, PolicyIfEmpty) { + rules = append(rules, r) + } + } + s.FieldMap = rules + return s +} + +// withLegacyMappings carries a pre-native connection's own Contact field map +// into its rules, replacing the default rule for any field it names. +func (s *Settings) withLegacyMappings(rows []models.IntegrationFieldMapping) { + var extra []FieldRule + named := map[string]bool{} + for _, r := range rows { + if r.SubscriptionID != nil || !strings.EqualFold(r.ObjectName, "contact") { + continue + } + if r.Transform != "" && r.Transform != string(models.FieldTransformNone) { + continue + } + if !validWarmblyField(r.WarmblyField) || !validSalesforceField(r.ExternalField) || strings.Contains(r.ExternalField, ".") { + continue + } + if strings.EqualFold(r.ExternalField, "Email") || named[strings.ToLower(r.ExternalField)] { + continue + } + named[strings.ToLower(r.ExternalField)] = true + extra = append(extra, FieldRule{Object: ObjectContact, Warmbly: r.WarmblyField, Salesforce: r.ExternalField, Direction: DirectionPush, Policy: PolicyOverwrite}) + } + if len(extra) == 0 { + return + } + kept := s.FieldMap[:0] + for _, r := range s.FieldMap { + if r.Object == ObjectContact && named[strings.ToLower(r.Salesforce)] { + continue + } + kept = append(kept, r) + } + s.FieldMap = append(kept, extra...) +} + +// rulesFor returns the object's rules that move data in a direction. +func (s *Settings) rulesFor(object, direction string) []FieldRule { + var out []FieldRule + for _, r := range s.FieldMap { + if r.Object != object { + continue + } + if r.Direction == direction || r.Direction == DirectionBoth { + out = append(out, r) + } + } + return out +} + +// statusForReply picks the Lead status a reply of this intent moves to. +func (w WritebackSettings) statusForReply(intent string) string { + if v := strings.TrimSpace(w.LeadStatusOnReply[intent]); v != "" { + return v + } + if intent == string(models.ReplyIntentAutomated) || intent == string(models.ReplyIntentOutOfOffice) { + // An auto-reply is not a conversation; only an explicit mapping moves it. + return "" + } + return strings.TrimSpace(w.LeadStatusOnReply["any"]) +} diff --git a/internal/app/salesforce/sync_test.go b/internal/app/salesforce/sync_test.go new file mode 100644 index 000000000..061acc9c1 --- /dev/null +++ b/internal/app/salesforce/sync_test.go @@ -0,0 +1,249 @@ +package salesforce + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +func TestDefaultSettingsAreValid(t *testing.T) { + s := DefaultSettings() + if err := s.Validate(); err != nil { + t.Fatalf("defaults must validate: %v", err) + } + if s.Activity.Opened || s.Activity.Clicked { + t.Fatal("opens and clicks cost an API call each and stay off by default") + } +} + +func TestValidateRefusesWhatTheSyncCannotRun(t *testing.T) { + cases := map[string]func(*Settings){ + "engagement pulled": func(s *Settings) { + s.FieldMap = []FieldRule{{Object: ObjectLead, Warmbly: "engagement.status", Salesforce: "X__c", Direction: DirectionPull, Policy: PolicyOverwrite}} + }, + "related field write": func(s *Settings) { + s.FieldMap = []FieldRule{{Object: ObjectContact, Warmbly: "company", Salesforce: "Account.Name", Direction: DirectionBoth, Policy: PolicyIfEmpty}} + }, + "injected field": func(s *Settings) { + s.FieldMap = []FieldRule{{Object: ObjectLead, Warmbly: "company", Salesforce: "Company FROM User", Direction: DirectionPush, Policy: PolicyIfEmpty}} + }, + "fixed owner, no id": func(s *Settings) { s.Matching.Owner = "fixed" }, + "unknown intent": func(s *Settings) { s.Writeback.LeadStatusOnReply = map[string]string{"spam": "Dead"} }, + "duplicate rule": func(s *Settings) { + r := FieldRule{Object: ObjectLead, Warmbly: "phone", Salesforce: "MobilePhone", Direction: DirectionPush, Policy: PolicyIfEmpty} + s.FieldMap = []FieldRule{r, r} + }, + } + for name, mutate := range cases { + s := DefaultSettings() + mutate(&s) + if s.Validate() == nil { + t.Errorf("%s: expected a validation error", name) + } + } +} + +func TestParseSettingsKeepsOtherConfigAndFillsDefaults(t *testing.T) { + raw := json.RawMessage(`{"signing_secret":"x","salesforce":{"enabled":false,"matching":{"prefer":"lead"}}}`) + s := ParseSettings(raw) + if s.Enabled || s.Matching.Prefer != "lead" { + t.Fatalf("stored values must win: %+v", s.Matching) + } + if s.Matching.CreateAs != "lead" || s.Activity.AssignTo != "record_owner" { + t.Fatalf("unset values come from the defaults: %+v", s) + } +} + +func TestAConnectionThatNeverSavedSettingsStaysOff(t *testing.T) { + if ParseSettings(json.RawMessage(`{"signing_secret":"x"}`)).Enabled { + t.Fatal("a connection from before native sync must not start logging on its own") + } + if !ParseSettings(json.RawMessage(`{"salesforce":{"matching":{"prefer":"lead"}}}`)).Enabled { + t.Fatal("saved settings without the flag keep the default, on") + } +} + +func TestLegacyConnectionKeepsItsBehaviour(t *testing.T) { + s := ParseSettings(nil) + if s.Matching.CreateAs != "contact" { + t.Fatal("the upsert action this replaces created Contacts") + } + s.withLegacyMappings([]models.IntegrationFieldMapping{ + {ObjectName: "contact", WarmblyField: "phone", ExternalField: "Phone"}, + {ObjectName: "contact", WarmblyField: "custom:tier", ExternalField: "Tier__c"}, + {ObjectName: "contact", WarmblyField: "company", ExternalField: "Department", Transform: "uppercase"}, + }) + if err := s.Validate(); err != nil { + t.Fatalf("carried rules must validate: %v", err) + } + var phone, tier int + for _, r := range s.FieldMap { + if r.Object == ObjectContact && r.Salesforce == "Phone" { + phone++ + if r.Policy != PolicyOverwrite { + t.Fatal("the saved mapping replaces the default rule") + } + } + if r.Salesforce == "Tier__c" { + tier++ + } + if r.Salesforce == "Department" { + t.Fatal("a transformed mapping has no rule equivalent and is not carried") + } + } + if phone != 1 || tier != 1 { + t.Fatalf("phone=%d tier=%d", phone, tier) + } +} + +func TestWantsRecordsWritebackEvenWhenTheTaskIsOff(t *testing.T) { + s := DefaultSettings() + s.Activity.Sent = false + if wants(s, KindSent) { + t.Fatal("nothing to do for a send") + } + s.Writeback.LeadStatusOnSent = "Working - Contacted" + if !wants(s, KindSent) { + t.Fatal("a status writeback needs the event") + } + s.Activity.Unsubscribed = false + s.Inbound.OptOut = "from_salesforce" + if wants(s, KindUnsubscribed) { + t.Fatal("opt-out flows only from Salesforce here") + } +} + +func TestStatusForReplySkipsAutoRepliesUnlessMapped(t *testing.T) { + w := WritebackSettings{LeadStatusOnReply: map[string]string{"any": "Working", "positive": "Qualified"}} + if got := w.statusForReply("positive"); got != "Qualified" { + t.Fatalf("got %q", got) + } + if got := w.statusForReply("neutral"); got != "Working" { + t.Fatalf("got %q", got) + } + if got := w.statusForReply("out_of_office"); got != "" { + t.Fatalf("an out-of-office is not a conversation, got %q", got) + } + w.LeadStatusOnReply["out_of_office"] = "Nurture" + if got := w.statusForReply("out_of_office"); got != "Nurture" { + t.Fatalf("an explicit mapping wins, got %q", got) + } +} + +func TestListViewSOQLKeepsFiltersAndScope(t *testing.T) { + s := DefaultSettings() + view := "SELECT Name, Company, toLabel(Status) FROM Lead USING SCOPE mine WHERE IsConverted = false ORDER BY Name ASC NULLS FIRST, Id ASC NULLS FIRST" + got, ok := listViewSOQL(s, ObjectLead, view) + if !ok { + t.Fatal("expected a rewrite") + } + if !strings.HasPrefix(got, "SELECT Id, Email,") || !strings.HasSuffix(got, "FROM Lead USING SCOPE mine WHERE IsConverted = false ORDER BY Name ASC NULLS FIRST, Id ASC NULLS FIRST") { + t.Fatalf("unexpected rewrite: %s", got) + } + if _, ok := listViewSOQL(s, ObjectLead, "SELECT Id, (SELECT Id FROM Tasks) FROM Lead"); ok { + t.Fatal("a subquery in the select list falls back to the id path") + } +} + +func TestPushChangesHonoursPolicy(t *testing.T) { + st := DefaultSettings() + st.FieldMap = []FieldRule{ + {Object: ObjectLead, Warmbly: "phone", Salesforce: "Phone", Direction: DirectionBoth, Policy: PolicyIfEmpty}, + {Object: ObjectLead, Warmbly: "company", Salesforce: "Company", Direction: DirectionPush, Policy: PolicyOverwrite}, + {Object: ObjectLead, Warmbly: "custom:tier", Salesforce: "Tier__c", Direction: DirectionPush, Policy: PolicyIfEmpty}, + {Object: ObjectLead, Warmbly: "engagement.last_campaign", Salesforce: "Last_Campaign__c", Direction: DirectionPush, Policy: PolicyOverwrite}, + } + snap, _ := json.Marshal(map[string]any{"Phone": "+1 555", "Company": "Old", "Tier__c": nil}) + l := models.SalesforceRecordLink{SObject: ObjectLead, Snapshot: snap} + ct := repository.SalesforceContact{ID: uuid.New(), Phone: "+1 777", Company: "New", CustomFields: map[string]string{"tier": "A"}} + eng := &repository.SalesforceEngagement{LastCampaign: "Q3 outbound"} + got := pushChanges(st, l, ct, eng) + if _, ok := got["Phone"]; ok { + t.Fatal("a filled field is never overwritten under if_empty") + } + if got["Company"] != "New" || got["Tier__c"] != "A" || got["Last_Campaign__c"] != "Q3 outbound" { + t.Fatalf("unexpected changes: %v", got) + } + st.FieldMap = append(st.FieldMap, FieldRule{Object: ObjectLead, Warmbly: "first_name", Salesforce: "Nickname__c", Direction: DirectionPush, Policy: PolicyIfEmpty}) + ct.FirstName = "Ada" + if _, ok := pushChanges(st, l, ct, eng)["Nickname__c"]; ok { + t.Fatal("a field never read is unknown and never written blind") + } +} + +func TestMayCreate(t *testing.T) { + s := DefaultSettings() + if mayCreate(s, KindSent) || !mayCreate(s, KindReplied) || !mayCreate(s, KindMeetingBooked) { + t.Fatal("reply mode creates on replies and meetings only") + } + s.Matching.CreateWhen = "never" + if mayCreate(s, KindReplied) { + t.Fatal("never means never") + } +} + +func TestHTMLToText(t *testing.T) { + got := htmlToText(`

Hi Ada,

Worth a chat?
Thanks & regards

`) + if got != "Hi Ada,\nWorth a chat?\nThanks & regards" { + t.Fatalf("got %q", got) + } +} + +func TestRecorderDedupeKeys(t *testing.T) { + r := &Recorder{} + _, _, k := r.shape(KindSent, models.WebhookEventCampaignEmailSent, map[string]any{"_task_id": "t1"}, "a@b.co") + if k != "sent:t1" { + t.Fatalf("got %q", k) + } + p, c, k := r.shape(KindReplied, models.WebhookEventCampaignReplyReceived, map[string]any{ + "_message_id": "", "_body_text": "Sounds good", "subject": "Re: hi", "intent": "positive", + }, "a@b.co") + if k != "replied:" || c == nil || c.Body != "Sounds good" || p["intent"] != "positive" { + t.Fatalf("unexpected reply shape: %v %+v %q", p, c, k) + } + if _, ok := p["body_text"]; ok { + t.Fatal("message text is sealed, never kept in the payload") + } +} + +func TestLikeQuoteEscapesWildcards(t *testing.T) { + if got := likeQuote(`50%_o'k`); got != `'%50\%\_o\'k%'` { + t.Fatalf("got %s", got) + } +} + +func TestAdvanceNeverStallsInsideOneSecond(t *testing.T) { + cursor := time.Date(2026, 10, 3, 10, 0, 5, 0, time.UTC) + same := cursor.Add(400 * time.Millisecond) + if got := advance(cursor, &same); !got.Equal(cursor.Add(time.Second)) { + t.Fatalf("a full page inside the cursor's second must step a whole second, got %v", got) + } + later := cursor.Add(3 * time.Second) + if got := advance(cursor, &later); !got.Equal(later) { + t.Fatalf("got %v", got) + } + if got := advance(cursor, nil); !got.Equal(cursor) { + t.Fatalf("nothing read keeps the cursor, got %v", got) + } +} + +func TestSafeOverridesNeverRetarget(t *testing.T) { + got := safeOverrides(map[string]any{"Id": "001", "Account.Name": "x", "Tier__c": "A", "bad field": 1}) + if len(got) != 1 || got["Tier__c"] != "A" { + t.Fatalf("got %v", got) + } +} + +func TestParseSettingsDropsRulesThatCouldNotBeSaved(t *testing.T) { + raw := json.RawMessage(`{"salesforce":{"field_map":[{"object":"Lead","warmbly":"phone","salesforce":"Phone FROM User","direction":"push","policy":"overwrite"},{"object":"Lead","warmbly":"phone","salesforce":"MobilePhone","direction":"push","policy":"overwrite"}]}}`) + s := ParseSettings(raw) + if len(s.FieldMap) != 1 || s.FieldMap[0].Salesforce != "MobilePhone" { + t.Fatalf("got %+v", s.FieldMap) + } +} diff --git a/internal/app/webhook/record_sinks_test.go b/internal/app/webhook/record_sinks_test.go new file mode 100644 index 000000000..67d3e6507 --- /dev/null +++ b/internal/app/webhook/record_sinks_test.go @@ -0,0 +1,26 @@ +package webhook + +import ( + "context" + "testing" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/models" +) + +// Two connected CRMs each keep their own record of every event, so wiring a +// second record sink must add it, not replace the first. +func TestWireRecordSinkKeepsEverySink(t *testing.T) { + s := &service{} + var got []string + s.WireRecordSink(func(context.Context, uuid.UUID, models.WebhookEventType, any) { got = append(got, "hubspot") }) + s.WireRecordSink(func(context.Context, uuid.UUID, models.WebhookEventType, any) { got = append(got, "salesforce") }) + s.WireRecordSink(nil) + for _, sink := range s.recordSinks { + sink(context.Background(), uuid.New(), models.WebhookEventCampaignEmailSent, nil) + } + if len(got) != 2 || got[0] != "hubspot" || got[1] != "salesforce" { + t.Fatalf("every sink sees the event once: %v", got) + } +} diff --git a/internal/app/webhook/service.go b/internal/app/webhook/service.go index 835f597ff..7b7d11039 100644 --- a/internal/app/webhook/service.go +++ b/internal/app/webhook/service.go @@ -89,8 +89,9 @@ type Service interface { // replacement; pass nil to detach. WireDispatchSink(sink DispatchSink) - // WireRecordSink attaches a sink that sees every event before the throttle: - // a connected CRM is a system of record and must not lose a send to a burst. + // WireRecordSink adds a sink that sees every event before the throttle: a + // connected CRM is a system of record and must not lose a send to a burst. + // Each call adds one; every sink sees every event. WireRecordSink(sink DispatchSink) // WireThrottle attaches a Redis-backed per-org, per-event-type dispatch @@ -134,7 +135,7 @@ type service struct { repo repository.WebhookRepository now func() time.Time sink DispatchSink - recordSink DispatchSink + recordSinks []DispatchSink cache *cache.Cache resolveLimit func(ctx context.Context, orgID uuid.UUID) int appDomains AppDomainResolver @@ -149,7 +150,9 @@ func (s *service) WireDispatchSink(sink DispatchSink) { } func (s *service) WireRecordSink(sink DispatchSink) { - s.recordSink = sink + if sink != nil { + s.recordSinks = append(s.recordSinks, sink) + } } func (s *service) WireThrottle(c *cache.Cache, resolveLimit func(ctx context.Context, orgID uuid.UUID) int) { @@ -227,8 +230,8 @@ func (s *service) throttled(ctx context.Context, orgID uuid.UUID, eventType mode func (s *service) Dispatch(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) (uuid.UUID, error) { eventID := uuid.New() - if s.recordSink != nil { - s.recordSink(ctx, orgID, eventType, data) + for _, sink := range s.recordSinks { + sink(ctx, orgID, eventType, data) } // Global per-org, per-event-type fan-out throttle. Stops a per-contact diff --git a/internal/infrastructure/db/migrations/000255_salesforce_native_sync.down.sql b/internal/infrastructure/db/migrations/000255_salesforce_native_sync.down.sql new file mode 100644 index 000000000..a2499f885 --- /dev/null +++ b/internal/infrastructure/db/migrations/000255_salesforce_native_sync.down.sql @@ -0,0 +1,13 @@ +DROP TABLE IF EXISTS salesforce_sync_state; +DROP TABLE IF EXISTS salesforce_import_members; +DROP TABLE IF EXISTS salesforce_import_sources; +DROP TABLE IF EXISTS salesforce_activity_queue; +DROP TABLE IF EXISTS salesforce_record_links; + +UPDATE public.contacts SET source = 'import' WHERE source = 'crm_sync'; +ALTER TABLE public.contacts DROP CONSTRAINT contacts_source_check; +ALTER TABLE public.contacts + ADD CONSTRAINT contacts_source_check + CHECK (source IN ('unknown', 'manual', 'campaign', 'import', 'sheet_sync', 'api', 'ai_assistant', 'form', 'automation')) NOT VALID; + +ALTER TABLE integration_oauth_states DROP COLUMN IF EXISTS params; diff --git a/internal/infrastructure/db/migrations/000255_salesforce_native_sync.up.sql b/internal/infrastructure/db/migrations/000255_salesforce_native_sync.up.sql new file mode 100644 index 000000000..4702fb2ef --- /dev/null +++ b/internal/infrastructure/db/migrations/000255_salesforce_native_sync.up.sql @@ -0,0 +1,132 @@ +-- Native Salesforce sync: a Warmbly contact is linked to the Lead or Contact it +-- is in Salesforce, campaign activity is logged there as Tasks through a durable +-- outbox, and list views or Salesforce Campaigns feed contacts in. + +-- Which Salesforce login host a handshake started against (production, sandbox +-- or a My Domain), so the code is exchanged where it was issued. +ALTER TABLE integration_oauth_states + ADD COLUMN params jsonb NOT NULL DEFAULT '{}'::jsonb; + +-- Contacts imported from a CRM list or campaign are a first-touch origin. +ALTER TABLE public.contacts DROP CONSTRAINT contacts_source_check; +ALTER TABLE public.contacts + ADD CONSTRAINT contacts_source_check + CHECK (source IN ('unknown', 'manual', 'campaign', 'import', 'sheet_sync', 'api', 'ai_assistant', 'form', 'automation', 'crm_sync')) NOT VALID; + +-- One row per Warmbly contact per Salesforce connection: the record it is, and a +-- snapshot of what the dashboard shows about it. record_id is always 18 chars. +CREATE TABLE salesforce_record_links ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + connection_id uuid NOT NULL REFERENCES integration_connections (id) ON DELETE CASCADE, + contact_id uuid NOT NULL REFERENCES contacts (id) ON DELETE CASCADE, + sobject text NOT NULL CHECK (sobject IN ('Lead', 'Contact')), + record_id varchar(18) NOT NULL, + account_id varchar(18), + account_name text NOT NULL DEFAULT '', + owner_id varchar(18), + owner_name text NOT NULL DEFAULT '', + lead_status text NOT NULL DEFAULT '', + is_converted boolean NOT NULL DEFAULT false, + opted_out boolean NOT NULL DEFAULT false, + snapshot jsonb NOT NULL DEFAULT '{}'::jsonb, + linked_by text NOT NULL DEFAULT 'match' CHECK (linked_by IN ('match', 'created', 'import', 'manual')), + record_modified_at timestamptz, + last_pushed_at timestamptz, + last_pulled_at timestamptz, + last_error text, + last_error_at timestamptz, + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW(), + UNIQUE (connection_id, contact_id) +); + +CREATE INDEX idx_salesforce_record_links_record ON salesforce_record_links (connection_id, record_id); +CREATE INDEX idx_salesforce_record_links_contact ON salesforce_record_links (organization_id, contact_id); + +-- The activity outbox: every campaign event to log in Salesforce, written when +-- the event happens and drained in batches. Doubles as the per-record sync log. +-- content_encrypted holds subject and body text sealed with the org DEK. +CREATE TABLE salesforce_activity_queue ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + connection_id uuid NOT NULL REFERENCES integration_connections (id) ON DELETE CASCADE, + contact_id uuid REFERENCES contacts (id) ON DELETE SET NULL, + contact_email text NOT NULL, + kind text NOT NULL CHECK (kind IN ('sent', 'opened', 'clicked', 'replied', 'bounced', 'unsubscribed', 'meeting_booked')), + dedupe_key text NOT NULL, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + content_encrypted text NOT NULL DEFAULT '', + status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'synced', 'skipped', 'failed')), + attempts integer NOT NULL DEFAULT 0, + -- The drain pass holding the row; its outcome only lands while it still does. + lease_id uuid, + next_attempt_at timestamptz NOT NULL DEFAULT NOW(), + sf_record_id varchar(18), + sf_task_id varchar(18), + detail text NOT NULL DEFAULT '', + occurred_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + processed_at timestamptz, + UNIQUE (connection_id, dedupe_key) +); + +CREATE INDEX idx_salesforce_activity_queue_due ON salesforce_activity_queue (next_attempt_at) WHERE status = 'pending'; +CREATE INDEX idx_salesforce_activity_queue_conn ON salesforce_activity_queue (connection_id, created_at DESC); +CREATE INDEX idx_salesforce_activity_queue_contact ON salesforce_activity_queue (contact_id) WHERE contact_id IS NOT NULL; + +-- A saved Salesforce list view or Campaign that feeds contacts in, once or on +-- a schedule, optionally straight into a Warmbly campaign. +CREATE TABLE salesforce_import_sources ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + connection_id uuid NOT NULL REFERENCES integration_connections (id) ON DELETE CASCADE, + created_by_user_id uuid REFERENCES users (id) ON DELETE SET NULL, + name text NOT NULL DEFAULT '', + source_kind text NOT NULL CHECK (source_kind IN ('list_view', 'campaign')), + sobject text NOT NULL CHECK (sobject IN ('Lead', 'Contact', 'CampaignMember')), + source_id varchar(18) NOT NULL, + source_label text NOT NULL DEFAULT '', + campaign_id uuid REFERENCES campaigns (id) ON DELETE SET NULL, + category_ids uuid[] NOT NULL DEFAULT '{}', + recurring boolean NOT NULL DEFAULT false, + enabled boolean NOT NULL DEFAULT true, + status text NOT NULL DEFAULT 'idle' CHECK (status IN ('idle', 'running', 'error')), + last_run_at timestamptz, + last_result jsonb, + last_error text NOT NULL DEFAULT '', + total_imported integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_salesforce_import_sources_conn ON salesforce_import_sources (connection_id); + +-- Which records a source has already brought in, so a recurring run only +-- imports people new to the view instead of rewriting every contact. +CREATE TABLE salesforce_import_members ( + source_id uuid NOT NULL REFERENCES salesforce_import_sources (id) ON DELETE CASCADE, + record_id varchar(18) NOT NULL, + contact_id uuid REFERENCES contacts (id) ON DELETE SET NULL, + first_seen_at timestamptz NOT NULL DEFAULT NOW(), + PRIMARY KEY (source_id, record_id) +); + +-- Where the pull loop got to in each connection, and the API budget it saw. +-- Instance-local: an imported connection starts from its own first pull. +CREATE TABLE salesforce_sync_state ( + connection_id uuid PRIMARY KEY REFERENCES integration_connections (id) ON DELETE CASCADE, + organization_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + lead_cursor timestamptz, + contact_cursor timestamptz, + opportunity_cursor timestamptz, + last_pull_at timestamptz, + last_pull_error text NOT NULL DEFAULT '', + api_used integer NOT NULL DEFAULT 0, + api_max integer NOT NULL DEFAULT 0, + api_seen_at timestamptz, + -- Warmbly's own calls on calls_day, held against the connection's budget. + calls_day date, + calls_today integer NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL DEFAULT NOW() +); diff --git a/internal/models/contact.go b/internal/models/contact.go index a96530f48..5fb02abd3 100644 --- a/internal/models/contact.go +++ b/internal/models/contact.go @@ -140,6 +140,9 @@ const ( // LeadHoldSourceInboxTagging is a hold a classified reply wrote: "not now" // for a while, a decline with no end. LeadHoldSourceInboxTagging = "inbox_tagging" + // LeadHoldSourceCRM is a hold a CRM rule wrote: a lead status, a + // conversion or an open opportunity in Salesforce. + LeadHoldSourceCRM = "crm" ) // Lead status constants for ContactCampaignProgress.Status. @@ -820,6 +823,9 @@ const ( // ContactSourceAutomation is a contact an automation's "create or update // contact" action wrote; the detail is the automation's name. ContactSourceAutomation ContactSource = "automation" + // ContactSourceCRMSync is a contact imported from a CRM list view or + // campaign; the detail names the CRM and the list. + ContactSourceCRMSync ContactSource = "crm_sync" ) // Valid reports whether the value is one the database accepts. @@ -827,7 +833,7 @@ func (s ContactSource) Valid() bool { switch s { case ContactSourceUnknown, ContactSourceManual, ContactSourceCampaign, ContactSourceImport, ContactSourceSheetSync, ContactSourceAPI, ContactSourceAIAssistant, ContactSourceForm, - ContactSourceAutomation: + ContactSourceAutomation, ContactSourceCRMSync: return true } return false diff --git a/internal/models/integration.go b/internal/models/integration.go index 81ea60d23..26d151a30 100644 --- a/internal/models/integration.go +++ b/internal/models/integration.go @@ -256,6 +256,8 @@ type IntegrationTokens struct { RefreshToken string ExpiresAt *time.Time Scopes []string + // InstanceURL is a per-org API host a refresh reported (Salesforce). + InstanceURL string } // IntegrationOAuthState is the short-lived CSRF/PKCE record minted at the @@ -269,9 +271,12 @@ type IntegrationOAuthState struct { CodeVerifier string Label string RequestedScopes []string - UsedAt *time.Time - ExpiresAt time.Time - CreatedAt time.Time + // Params carries provider options chosen at start, such as the Salesforce + // login host the code must be exchanged at. + Params map[string]string + UsedAt *time.Time + ExpiresAt time.Time + CreatedAt time.Time } // IntegrationOAuthStartResponse is returned to the SPA so it can open the diff --git a/internal/models/integration_capability.go b/internal/models/integration_capability.go index 999ff9fbe..3dc960c10 100644 --- a/internal/models/integration_capability.go +++ b/internal/models/integration_capability.go @@ -172,8 +172,8 @@ func Capabilities() map[IntegrationProvider]ProviderCapability { Provider: IntegrationSalesforce, Directions: pushOnly, Objects: []CapabilityObject{salesforceContact}, Actions: []CapabilityAction{{ - ID: IntegrationActionSalesforceUpsert, Label: "Create or update contact", - Description: "Upsert a Salesforce Contact (matched by email).", + ID: IntegrationActionSalesforceUpsert, Label: "Create or update Salesforce record", + Description: "Find the person's Lead or Contact by email, or create one, and apply the connection's field rules.", Object: "contact", }}, }, diff --git a/internal/models/salesforce.go b/internal/models/salesforce.go new file mode 100644 index 000000000..1da51bd8b --- /dev/null +++ b/internal/models/salesforce.go @@ -0,0 +1,140 @@ +package models + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +// SalesforceRecordLink ties a Warmbly contact to the Lead or Contact it is in +// one connected Salesforce org. +type SalesforceRecordLink struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + ConnectionID uuid.UUID `json:"connection_id"` + ContactID uuid.UUID `json:"contact_id"` + SObject string `json:"object"` + RecordID string `json:"record_id"` + AccountID string `json:"account_id,omitempty"` + AccountName string `json:"account_name,omitempty"` + OwnerID string `json:"owner_id,omitempty"` + OwnerName string `json:"owner_name,omitempty"` + LeadStatus string `json:"lead_status,omitempty"` + IsConverted bool `json:"is_converted"` + OptedOut bool `json:"opted_out"` + Snapshot json.RawMessage `json:"snapshot,omitempty"` + LinkedBy string `json:"linked_by"` + RecordModifiedAt *time.Time `json:"record_modified_at,omitempty"` + LastPushedAt *time.Time `json:"last_pushed_at,omitempty"` + LastPulledAt *time.Time `json:"last_pulled_at,omitempty"` + LastError *string `json:"last_error,omitempty"` + LastErrorAt *time.Time `json:"last_error_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SalesforceActivity is one queued or processed activity: a campaign event to +// log as a Task, and what became of it. +type SalesforceActivity struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + ConnectionID uuid.UUID `json:"connection_id"` + ContactID *uuid.UUID `json:"contact_id,omitempty"` + ContactEmail string `json:"contact_email"` + Kind string `json:"kind"` + DedupeKey string `json:"-"` + Payload map[string]any `json:"payload,omitempty"` + // ContentEncrypted is subject and body text sealed with the org DEK. + ContentEncrypted string `json:"-"` + Status string `json:"status"` + Attempts int `json:"attempts"` + LeaseID *uuid.UUID `json:"-"` + NextAttemptAt time.Time `json:"next_attempt_at"` + RecordID string `json:"record_id,omitempty"` + TaskID string `json:"task_id,omitempty"` + Detail string `json:"detail,omitempty"` + OccurredAt time.Time `json:"occurred_at"` + CreatedAt time.Time `json:"created_at"` + ProcessedAt *time.Time `json:"processed_at,omitempty"` +} + +// Activity statuses; mirror the queue's CHECK. +const ( + SalesforceActivityPending = "pending" + SalesforceActivitySynced = "synced" + SalesforceActivitySkipped = "skipped" + SalesforceActivityFailed = "failed" +) + +// SalesforceActivityPage is a page of the activity log. +type SalesforceActivityPage struct { + Data []SalesforceActivity `json:"data"` + Pagination Pagination `json:"pagination"` +} + +// SalesforceImportSource is a saved list view or Salesforce Campaign that feeds +// contacts into Warmbly. +type SalesforceImportSource struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + ConnectionID uuid.UUID `json:"connection_id"` + CreatedByUserID *uuid.UUID `json:"created_by_user_id,omitempty"` + Name string `json:"name"` + SourceKind string `json:"source_kind"` + SObject string `json:"object"` + SourceID string `json:"source_id"` + SourceLabel string `json:"source_label"` + CampaignID *uuid.UUID `json:"campaign_id,omitempty"` + CategoryIDs []uuid.UUID `json:"category_ids"` + Recurring bool `json:"recurring"` + Enabled bool `json:"enabled"` + Status string `json:"status"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + LastResult *SalesforceRunResult `json:"last_result,omitempty"` + LastError string `json:"last_error,omitempty"` + TotalImported int `json:"total_imported"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SalesforceRunResult is what one import run did. +type SalesforceRunResult struct { + Read int `json:"read"` + Imported int `json:"imported"` + Updated int `json:"updated"` + Linked int `json:"linked"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + // NoEmail counts records with no address, which cannot be contacts. + NoEmail int `json:"no_email"` + // OptedOut counts people with Email Opt Out set, suppressed rather than + // imported. + OptedOut int `json:"opted_out"` + // Truncated is set when the source held more than one run reads. + Truncated bool `json:"truncated,omitempty"` +} + +// SalesforceSyncState is where the pull loop got to and the budget it saw. +type SalesforceSyncState struct { + ConnectionID uuid.UUID `json:"connection_id"` + OrganizationID uuid.UUID `json:"organization_id"` + LeadCursor *time.Time `json:"lead_cursor,omitempty"` + ContactCursor *time.Time `json:"contact_cursor,omitempty"` + OppCursor *time.Time `json:"opportunity_cursor,omitempty"` + LastPullAt *time.Time `json:"last_pull_at,omitempty"` + LastPullError string `json:"last_pull_error,omitempty"` + APIUsed int `json:"api_used"` + APIMax int `json:"api_max"` + APISeenAt *time.Time `json:"api_seen_at,omitempty"` + CallsToday int `json:"calls_today"` +} + +// SalesforceActivityCounts summarises the outbox for the overview. +type SalesforceActivityCounts struct { + Pending int `json:"pending"` + Synced24h int `json:"synced_24h"` + Failed int `json:"failed"` + Skipped24h int `json:"skipped_24h"` + LinkedTotal int `json:"linked_records"` +} diff --git a/internal/repository/pg_campaign_progress.go b/internal/repository/pg_campaign_progress.go index 83e9004bf..880860a83 100644 --- a/internal/repository/pg_campaign_progress.go +++ b/internal/repository/pg_campaign_progress.go @@ -2289,6 +2289,13 @@ func automaticHoldGuard(alias, untilParam string) string { )` } +// crmHoldGuard lets a CRM rule hold a lead that is not held, or replace an +// automatic hold; it never replaces a member's own pause or a CC hold. +func crmHoldGuard(alias string) string { + return ` + AND (NOT (` + liveHold(alias) + `) OR ` + alias + `.pause_source IN ('out_of_office', 'inbox_tagging'))` +} + // automaticHoldSource reports a hold the system wrote, which a member's own // pause always outranks and a longer automatic hold is never cut short by. func automaticHoldSource(source string) bool { @@ -2308,6 +2315,8 @@ func (r *campaignProgressRepository) HoldLead(ctx context.Context, campaignID, c guard := "" if automaticHoldSource(source) { guard = automaticHoldGuard("campaign_leads", "$3") + } else if source == models.LeadHoldSourceCRM { + guard = crmHoldGuard("campaign_leads") } now := time.Now() hold, err := scanHold(r.db.QueryRow(ctx, ` @@ -2347,6 +2356,8 @@ func (r *campaignProgressRepository) HoldLeadEverywhere(ctx context.Context, con guard := "" if automaticHoldSource(source) { guard = automaticHoldGuard("cl", "$2") + } else if source == models.LeadHoldSourceCRM { + guard = crmHoldGuard("cl") } rows, err := r.db.Query(ctx, ` UPDATE campaign_leads cl diff --git a/internal/repository/pg_integration.go b/internal/repository/pg_integration.go index 7bab27dee..0ed7f7d53 100644 --- a/internal/repository/pg_integration.go +++ b/internal/repository/pg_integration.go @@ -63,6 +63,7 @@ type IntegrationRepository interface { DeleteConnection(ctx context.Context, orgID, id uuid.UUID) error MarkConnectionSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields json.RawMessage, errMsg string) error UpdateConnectionTokens(ctx context.Context, id uuid.UUID, accessEnc, refreshEnc string, expiresAt *time.Time, scopes []string) error + MergeDisplayFields(ctx context.Context, id uuid.UUID, patch map[string]any) error SetConnectionStatus(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, health models.IntegrationHealth, detail string) error ClearConnectionHealth(ctx context.Context, id uuid.UUID) error @@ -367,6 +368,28 @@ func (r *integrationRepository) MarkConnectionSynced(ctx context.Context, id uui return err } +// MergeDisplayFields overlays keys onto a connection's non-secret display +// fields, such as a Salesforce instance host that moved. +func (r *integrationRepository) MergeDisplayFields(ctx context.Context, id uuid.UUID, patch map[string]any) error { + raw, err := json.Marshal(patch) + if err != nil { + return err + } + _, err = r.db.Exec(ctx, `UPDATE integration_connections SET display_fields = COALESCE(display_fields, '{}'::jsonb) || $2::jsonb, updated_at = NOW() WHERE id = $1`, id, raw) + return err +} + +func oauthParams(p map[string]string) []byte { + if len(p) == 0 { + return []byte("{}") + } + raw, err := json.Marshal(p) + if err != nil { + return []byte("{}") + } + return raw +} + func (r *integrationRepository) UpdateConnectionTokens(ctx context.Context, id uuid.UUID, accessEnc, refreshEnc string, expiresAt *time.Time, scopes []string) error { now := time.Now().UTC() _, err := r.db.Exec(ctx, ` @@ -418,10 +441,10 @@ func (r *integrationRepository) CreateOAuthState(ctx context.Context, st *models _, err := r.db.Exec(ctx, ` INSERT INTO integration_oauth_states ( id, organization_id, user_id, provider, state, code_verifier, - label, requested_scopes, expires_at, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + label, requested_scopes, expires_at, created_at, params + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, st.ID, st.OrganizationID, st.UserID, string(st.Provider), st.State, st.CodeVerifier, - st.Label, normalizeScopes(st.RequestedScopes), st.ExpiresAt, st.CreatedAt) + st.Label, normalizeScopes(st.RequestedScopes), st.ExpiresAt, st.CreatedAt, oauthParams(st.Params)) return err } @@ -434,11 +457,15 @@ func (r *integrationRepository) TakeOAuthState(ctx context.Context, state string SET used_at = NOW() WHERE state = $1 AND used_at IS NULL AND expires_at > NOW() RETURNING id, organization_id, user_id, provider, state, code_verifier, - label, requested_scopes, used_at, expires_at, created_at`, state) + label, requested_scopes, used_at, expires_at, created_at, params`, state) var st models.IntegrationOAuthState var provider string + var params []byte err := row.Scan(&st.ID, &st.OrganizationID, &st.UserID, &provider, &st.State, &st.CodeVerifier, - &st.Label, &st.RequestedScopes, &st.UsedAt, &st.ExpiresAt, &st.CreatedAt) + &st.Label, &st.RequestedScopes, &st.UsedAt, &st.ExpiresAt, &st.CreatedAt, ¶ms) + if err == nil && len(params) > 0 { + _ = json.Unmarshal(params, &st.Params) + } if isNoRows(err) { return nil, nil } diff --git a/internal/repository/pg_salesforce.go b/internal/repository/pg_salesforce.go new file mode 100644 index 000000000..0ae29c295 --- /dev/null +++ b/internal/repository/pg_salesforce.go @@ -0,0 +1,924 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/warmbly/warmbly/internal/models" +) + +// ErrSalesforceSourceRefs means an import source, its connection or its target +// campaign is not in the organization. +var ErrSalesforceSourceRefs = errors.New("import source, connection or campaign not found") + +// SalesforceConnectionRef is a live Salesforce connection the loops work on. +type SalesforceConnectionRef struct { + ID uuid.UUID + OrganizationID uuid.UUID + ConfigCapabilities json.RawMessage + ConnectedByUserID *uuid.UUID +} + +// SalesforceContact is the slice of a contact the sync reads and writes. +type SalesforceContact struct { + ID uuid.UUID + Email string + FirstName string + LastName string + Company string + Phone string + CustomFields map[string]string + Subscribed bool +} + +// SalesforceEngagement is a contact's campaign activity, for engagement fields. +type SalesforceEngagement struct { + LastCampaign string + LastSentAt *time.Time + LastReplyAt *time.Time + ReplyIntent string + Status string +} + +// SalesforceSentContent is the stored copy of one sent campaign email. +type SalesforceSentContent struct { + Subject string + FromEmail string + Status string + Campaign string + Step string + SentAt time.Time +} + +// SalesforceActivityFilter scopes an activity-log page. +type SalesforceActivityFilter struct { + Status string + ContactID *uuid.UUID + Before *time.Time + BeforeID *uuid.UUID + Limit int +} + +// SalesforceRepository is the native Salesforce sync's storage. +type SalesforceRepository interface { + ActiveConnections(ctx context.Context) ([]SalesforceConnectionRef, error) + ActiveConnectionsForOrg(ctx context.Context, orgID uuid.UUID) ([]SalesforceConnectionRef, error) + + LinksForContact(ctx context.Context, orgID, contactID uuid.UUID) ([]models.SalesforceRecordLink, error) + LinksForContacts(ctx context.Context, connID uuid.UUID, contactIDs []uuid.UUID) (map[uuid.UUID]models.SalesforceRecordLink, error) + LinksForRecords(ctx context.Context, connID uuid.UUID, recordIDs []string) ([]models.SalesforceRecordLink, error) + UpsertLink(ctx context.Context, l *models.SalesforceRecordLink) error + SetLinkError(ctx context.Context, id uuid.UUID, msg string) error + MarkLinksPushed(ctx context.Context, ids []uuid.UUID) error + DeleteLink(ctx context.Context, orgID, id uuid.UUID) (bool, error) + LinkedRecordIDs(ctx context.Context, connID uuid.UUID, recordIDs []string) (map[string]bool, error) + + EnqueueActivity(ctx context.Context, a *models.SalesforceActivity) error + ClaimDueActivities(ctx context.Context, limit int, lease time.Duration) ([]models.SalesforceActivity, error) + ClaimDueForConnection(ctx context.Context, connID uuid.UUID, limit int, lease time.Duration) ([]models.SalesforceActivity, error) + MakeDue(ctx context.Context, orgID, connID uuid.UUID) error + FinishActivity(ctx context.Context, a *models.SalesforceActivity) error + ListActivities(ctx context.Context, orgID, connID uuid.UUID, f SalesforceActivityFilter) ([]models.SalesforceActivity, error) + RetryActivities(ctx context.Context, orgID, connID uuid.UUID, ids []uuid.UUID) (int, error) + ActivityCounts(ctx context.Context, connID uuid.UUID) (models.SalesforceActivityCounts, error) + ContactActivityCounts(ctx context.Context, orgID, contactID uuid.UUID) (pending, failed, synced int, lastError string, err error) + PruneActivities(ctx context.Context, before time.Time) (int64, error) + + CreateSource(ctx context.Context, s *models.SalesforceImportSource) error + GetSource(ctx context.Context, orgID, id uuid.UUID) (*models.SalesforceImportSource, error) + ListSources(ctx context.Context, orgID, connID uuid.UUID) ([]models.SalesforceImportSource, error) + UpdateSource(ctx context.Context, s *models.SalesforceImportSource) error + DeleteSource(ctx context.Context, orgID, id uuid.UUID) (bool, error) + ClaimSource(ctx context.Context, id uuid.UUID) (bool, error) + FinishSource(ctx context.Context, id uuid.UUID, result *models.SalesforceRunResult, errMsg string) error + DueRecurringSources(ctx context.Context, every time.Duration) ([]models.SalesforceImportSource, error) + SourceMembers(ctx context.Context, sourceID uuid.UUID, recordIDs []string) (map[string]bool, error) + AddSourceMembers(ctx context.Context, sourceID uuid.UUID, members map[string]uuid.UUID) error + LinksForAccounts(ctx context.Context, connID uuid.UUID, accountIDs []string) ([]models.SalesforceRecordLink, error) + + GetSyncState(ctx context.Context, connID uuid.UUID) (*models.SalesforceSyncState, error) + EnsureSyncState(ctx context.Context, connID, orgID uuid.UUID, start time.Time) error + SetCursors(ctx context.Context, connID uuid.UUID, lead, contact, opp *time.Time, pullErr string) error + RecordUsage(ctx context.Context, connID uuid.UUID, used, max, calls int) error + + ContactsByEmails(ctx context.Context, orgID uuid.UUID, emails []string) (map[string]SalesforceContact, error) + ContactsByIDs(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) (map[uuid.UUID]SalesforceContact, error) + Engagement(ctx context.Context, orgID uuid.UUID, contactIDs []uuid.UUID) (map[uuid.UUID]SalesforceEngagement, error) + SentContent(ctx context.Context, orgID, taskID uuid.UUID) (*SalesforceSentContent, error) + MailboxEmail(ctx context.Context, orgID, accountID uuid.UUID) (string, error) +} + +type salesforceRepository struct { + db *pgxpool.Pool +} + +// NewSalesforceRepository builds the Postgres-backed repository. +func NewSalesforceRepository(db *pgxpool.Pool) SalesforceRepository { + return &salesforceRepository{db: db} +} + +func (r *salesforceRepository) activeConnections(ctx context.Context, where string, args ...any) ([]SalesforceConnectionRef, error) { + rows, err := r.db.Query(ctx, ` + SELECT id, organization_id, COALESCE(config_capabilities, '{}'::jsonb), connected_by_user_id + FROM integration_connections + WHERE provider = 'salesforce' AND status IN ('connected', 'degraded')`+where+` + ORDER BY created_at`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []SalesforceConnectionRef + for rows.Next() { + var c SalesforceConnectionRef + if err := rows.Scan(&c.ID, &c.OrganizationID, &c.ConfigCapabilities, &c.ConnectedByUserID); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +func (r *salesforceRepository) ActiveConnections(ctx context.Context) ([]SalesforceConnectionRef, error) { + return r.activeConnections(ctx, "") +} + +func (r *salesforceRepository) ActiveConnectionsForOrg(ctx context.Context, orgID uuid.UUID) ([]SalesforceConnectionRef, error) { + return r.activeConnections(ctx, " AND organization_id = $1", orgID) +} + +// --- links ------------------------------------------------------------------ + +const linkColumns = `id, organization_id, connection_id, contact_id, sobject, record_id, + COALESCE(account_id, ''), account_name, COALESCE(owner_id, ''), owner_name, lead_status, + is_converted, opted_out, snapshot, linked_by, record_modified_at, last_pushed_at, + last_pulled_at, last_error, last_error_at, created_at, updated_at` + +func scanSFLink(row pgx.Row) (models.SalesforceRecordLink, error) { + var l models.SalesforceRecordLink + err := row.Scan(&l.ID, &l.OrganizationID, &l.ConnectionID, &l.ContactID, &l.SObject, &l.RecordID, + &l.AccountID, &l.AccountName, &l.OwnerID, &l.OwnerName, &l.LeadStatus, + &l.IsConverted, &l.OptedOut, &l.Snapshot, &l.LinkedBy, &l.RecordModifiedAt, &l.LastPushedAt, + &l.LastPulledAt, &l.LastError, &l.LastErrorAt, &l.CreatedAt, &l.UpdatedAt) + return l, err +} + +func (r *salesforceRepository) queryLinks(ctx context.Context, q string, args ...any) ([]models.SalesforceRecordLink, error) { + rows, err := r.db.Query(ctx, `SELECT `+linkColumns+` FROM salesforce_record_links `+q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.SalesforceRecordLink + for rows.Next() { + l, err := scanSFLink(rows) + if err != nil { + return nil, err + } + out = append(out, l) + } + return out, rows.Err() +} + +func (r *salesforceRepository) LinksForContact(ctx context.Context, orgID, contactID uuid.UUID) ([]models.SalesforceRecordLink, error) { + return r.queryLinks(ctx, `WHERE organization_id = $1 AND contact_id = $2 ORDER BY created_at`, orgID, contactID) +} + +func (r *salesforceRepository) LinksForContacts(ctx context.Context, connID uuid.UUID, contactIDs []uuid.UUID) (map[uuid.UUID]models.SalesforceRecordLink, error) { + out := map[uuid.UUID]models.SalesforceRecordLink{} + if len(contactIDs) == 0 { + return out, nil + } + links, err := r.queryLinks(ctx, `WHERE connection_id = $1 AND contact_id = ANY($2)`, connID, contactIDs) + if err != nil { + return nil, err + } + for _, l := range links { + out[l.ContactID] = l + } + return out, nil +} + +func (r *salesforceRepository) LinksForRecords(ctx context.Context, connID uuid.UUID, recordIDs []string) ([]models.SalesforceRecordLink, error) { + if len(recordIDs) == 0 { + return nil, nil + } + return r.queryLinks(ctx, `WHERE connection_id = $1 AND record_id = ANY($2)`, connID, recordIDs) +} + +func (r *salesforceRepository) LinkedRecordIDs(ctx context.Context, connID uuid.UUID, recordIDs []string) (map[string]bool, error) { + out := map[string]bool{} + if len(recordIDs) == 0 { + return out, nil + } + rows, err := r.db.Query(ctx, `SELECT DISTINCT record_id FROM salesforce_record_links WHERE connection_id = $1 AND record_id = ANY($2)`, connID, recordIDs) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out[id] = true + } + return out, rows.Err() +} + +// UpsertLink writes a link keyed by (connection, contact). The contact must be +// in the link's organization; a mismatched pair writes nothing. +func (r *salesforceRepository) UpsertLink(ctx context.Context, l *models.SalesforceRecordLink) error { + snap := l.Snapshot + if len(snap) == 0 { + snap = json.RawMessage(`{}`) + } + if l.LinkedBy == "" { + l.LinkedBy = "match" + } + row := r.db.QueryRow(ctx, ` + INSERT INTO salesforce_record_links ( + organization_id, connection_id, contact_id, sobject, record_id, account_id, account_name, + owner_id, owner_name, lead_status, is_converted, opted_out, snapshot, linked_by, + record_modified_at, last_pulled_at + ) + SELECT $1, $2, c.id, $4, $5, NULLIF($6, ''), $7, NULLIF($8, ''), $9, $10, $11, $12, $13, $14, $15, NOW() + FROM contacts c + WHERE c.id = $3 AND c.organization_id = $1 + ON CONFLICT (connection_id, contact_id) DO UPDATE SET + sobject = EXCLUDED.sobject, + record_id = EXCLUDED.record_id, + account_id = EXCLUDED.account_id, + account_name = EXCLUDED.account_name, + owner_id = EXCLUDED.owner_id, + owner_name = EXCLUDED.owner_name, + lead_status = EXCLUDED.lead_status, + is_converted = EXCLUDED.is_converted, + opted_out = EXCLUDED.opted_out, + snapshot = EXCLUDED.snapshot, + record_modified_at = EXCLUDED.record_modified_at, + last_pulled_at = NOW(), + last_error = NULL, + last_error_at = NULL, + updated_at = NOW() + RETURNING id, linked_by, created_at, updated_at`, + l.OrganizationID, l.ConnectionID, l.ContactID, l.SObject, l.RecordID, l.AccountID, l.AccountName, + l.OwnerID, l.OwnerName, l.LeadStatus, l.IsConverted, l.OptedOut, snap, l.LinkedBy, l.RecordModifiedAt) + err := row.Scan(&l.ID, &l.LinkedBy, &l.CreatedAt, &l.UpdatedAt) + if isNoRows(err) { + return fmt.Errorf("contact %s is not in this organization", l.ContactID) + } + return err +} + +func (r *salesforceRepository) SetLinkError(ctx context.Context, id uuid.UUID, msg string) error { + _, err := r.db.Exec(ctx, `UPDATE salesforce_record_links SET last_error = NULLIF($2, ''), last_error_at = CASE WHEN $2 = '' THEN NULL ELSE NOW() END, updated_at = NOW() WHERE id = $1`, id, sfTruncate(msg, 500)) + return err +} + +func (r *salesforceRepository) MarkLinksPushed(ctx context.Context, ids []uuid.UUID) error { + if len(ids) == 0 { + return nil + } + _, err := r.db.Exec(ctx, `UPDATE salesforce_record_links SET last_pushed_at = NOW(), last_error = NULL, last_error_at = NULL WHERE id = ANY($1)`, ids) + return err +} + +func (r *salesforceRepository) DeleteLink(ctx context.Context, orgID, id uuid.UUID) (bool, error) { + tag, err := r.db.Exec(ctx, `DELETE FROM salesforce_record_links WHERE organization_id = $1 AND id = $2`, orgID, id) + if err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil +} + +// --- activity outbox -------------------------------------------------------- + +const activityColumns = `id, organization_id, connection_id, contact_id, contact_email, kind, dedupe_key, + payload, content_encrypted, status, attempts, lease_id, next_attempt_at, COALESCE(sf_record_id, ''), + COALESCE(sf_task_id, ''), detail, occurred_at, created_at, processed_at` + +func scanActivity(row pgx.Row) (models.SalesforceActivity, error) { + var a models.SalesforceActivity + var payload []byte + err := row.Scan(&a.ID, &a.OrganizationID, &a.ConnectionID, &a.ContactID, &a.ContactEmail, &a.Kind, &a.DedupeKey, + &payload, &a.ContentEncrypted, &a.Status, &a.Attempts, &a.LeaseID, &a.NextAttemptAt, &a.RecordID, + &a.TaskID, &a.Detail, &a.OccurredAt, &a.CreatedAt, &a.ProcessedAt) + if err == nil && len(payload) > 0 { + _ = json.Unmarshal(payload, &a.Payload) + } + return a, err +} + +// EnqueueActivity records an event once per (connection, dedupe key); a repeat +// of the same event is a no-op. The contact must be in the organization. +func (r *salesforceRepository) EnqueueActivity(ctx context.Context, a *models.SalesforceActivity) error { + payload, err := json.Marshal(a.Payload) + if err != nil { + return err + } + _, err = r.db.Exec(ctx, ` + INSERT INTO salesforce_activity_queue ( + organization_id, connection_id, contact_id, contact_email, kind, dedupe_key, + payload, content_encrypted, occurred_at + ) + SELECT $1, $2, + (SELECT id FROM contacts WHERE id = $3 AND organization_id = $1), + $4, $5, $6, $7, $8, $9 + WHERE EXISTS (SELECT 1 FROM integration_connections WHERE id = $2 AND organization_id = $1) + ON CONFLICT (connection_id, dedupe_key) DO NOTHING`, + a.OrganizationID, a.ConnectionID, a.ContactID, strings.ToLower(strings.TrimSpace(a.ContactEmail)), + a.Kind, a.DedupeKey, payload, a.ContentEncrypted, a.OccurredAt) + return err +} + +// ClaimDueActivities leases due rows to one drain pass. A second replica skips +// them, and a pass that outlives its lease cannot write over the one that +// re-claimed them: FinishActivity only lands while the lease is still its own. +func (r *salesforceRepository) ClaimDueActivities(ctx context.Context, limit int, lease time.Duration) ([]models.SalesforceActivity, error) { + return r.claim(ctx, "", nil, limit, lease) +} + +// ClaimDueForConnection leases one connection's due rows. +func (r *salesforceRepository) ClaimDueForConnection(ctx context.Context, connID uuid.UUID, limit int, lease time.Duration) ([]models.SalesforceActivity, error) { + return r.claim(ctx, " AND connection_id = $4", &connID, limit, lease) +} + +func (r *salesforceRepository) claim(ctx context.Context, extra string, connID *uuid.UUID, limit int, lease time.Duration) ([]models.SalesforceActivity, error) { + args := []any{limit, lease.Seconds(), uuid.New()} + if connID != nil { + args = append(args, *connID) + } + rows, err := r.db.Query(ctx, ` + WITH due AS ( + SELECT id FROM salesforce_activity_queue + WHERE status = 'pending' AND next_attempt_at <= NOW()`+extra+` + ORDER BY next_attempt_at + LIMIT $1 + FOR UPDATE SKIP LOCKED + ) + UPDATE salesforce_activity_queue q + SET next_attempt_at = NOW() + make_interval(secs => $2), lease_id = $3 + FROM due WHERE q.id = due.id + RETURNING q.id, q.organization_id, q.connection_id, q.contact_id, q.contact_email, q.kind, q.dedupe_key, + q.payload, q.content_encrypted, q.status, q.attempts, q.lease_id, q.next_attempt_at, COALESCE(q.sf_record_id, ''), + COALESCE(q.sf_task_id, ''), q.detail, q.occurred_at, q.created_at, q.processed_at`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.SalesforceActivity + for rows.Next() { + a, err := scanActivity(rows) + if err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// MakeDue brings a connection's waiting rows forward, for "Sync now". +func (r *salesforceRepository) MakeDue(ctx context.Context, orgID, connID uuid.UUID) error { + _, err := r.db.Exec(ctx, `UPDATE salesforce_activity_queue SET next_attempt_at = NOW() + WHERE organization_id = $1 AND connection_id = $2 AND status = 'pending' AND next_attempt_at > NOW()`, orgID, connID) + return err +} + +func (r *salesforceRepository) FinishActivity(ctx context.Context, a *models.SalesforceActivity) error { + _, err := r.db.Exec(ctx, ` + UPDATE salesforce_activity_queue SET + status = $2, attempts = $3, next_attempt_at = $4, sf_record_id = NULLIF($5, ''), + sf_task_id = NULLIF($6, ''), detail = $7, lease_id = NULL, + processed_at = CASE WHEN $2 = 'pending' THEN processed_at ELSE NOW() END + WHERE id = $1 AND lease_id IS NOT DISTINCT FROM $8`, + a.ID, a.Status, a.Attempts, a.NextAttemptAt, a.RecordID, a.TaskID, sfTruncate(a.Detail, 1000), a.LeaseID) + return err +} + +func (r *salesforceRepository) ListActivities(ctx context.Context, orgID, connID uuid.UUID, f SalesforceActivityFilter) ([]models.SalesforceActivity, error) { + if f.Limit <= 0 || f.Limit > 200 { + f.Limit = 50 + } + args := []any{orgID, connID} + where := []string{"organization_id = $1", "connection_id = $2"} + if f.Status != "" { + args = append(args, f.Status) + where = append(where, fmt.Sprintf("status = $%d", len(args))) + } + if f.ContactID != nil { + args = append(args, *f.ContactID) + where = append(where, fmt.Sprintf("contact_id = $%d", len(args))) + } + if f.Before != nil && f.BeforeID != nil { + args = append(args, *f.Before, *f.BeforeID) + where = append(where, fmt.Sprintf("(created_at, id) < ($%d, $%d)", len(args)-1, len(args))) + } + args = append(args, f.Limit+1) + rows, err := r.db.Query(ctx, `SELECT `+activityColumns+` FROM salesforce_activity_queue WHERE `+ + strings.Join(where, " AND ")+fmt.Sprintf(` ORDER BY created_at DESC, id DESC LIMIT $%d`, len(args)), args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.SalesforceActivity + for rows.Next() { + a, err := scanActivity(rows) + if err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// RetryActivities re-queues failed rows: the given ones, or every failed row +// on the connection when ids is empty. +func (r *salesforceRepository) RetryActivities(ctx context.Context, orgID, connID uuid.UUID, ids []uuid.UUID) (int, error) { + q := `UPDATE salesforce_activity_queue SET status = 'pending', attempts = 0, next_attempt_at = NOW(), detail = '', lease_id = NULL + WHERE organization_id = $1 AND connection_id = $2 AND status IN ('failed', 'skipped')` + args := []any{orgID, connID} + if len(ids) > 0 { + q += ` AND id = ANY($3)` + args = append(args, ids) + } else { + q += ` AND status = 'failed'` + } + tag, err := r.db.Exec(ctx, q, args...) + if err != nil { + return 0, err + } + return int(tag.RowsAffected()), nil +} + +func (r *salesforceRepository) ActivityCounts(ctx context.Context, connID uuid.UUID) (models.SalesforceActivityCounts, error) { + var c models.SalesforceActivityCounts + err := r.db.QueryRow(ctx, ` + SELECT + COUNT(*) FILTER (WHERE status = 'pending'), + COUNT(*) FILTER (WHERE status = 'synced' AND processed_at > NOW() - INTERVAL '24 hours'), + COUNT(*) FILTER (WHERE status = 'failed'), + COUNT(*) FILTER (WHERE status = 'skipped' AND processed_at > NOW() - INTERVAL '24 hours'), + (SELECT COUNT(*) FROM salesforce_record_links WHERE connection_id = $1) + FROM salesforce_activity_queue WHERE connection_id = $1`, connID). + Scan(&c.Pending, &c.Synced24h, &c.Failed, &c.Skipped24h, &c.LinkedTotal) + return c, err +} + +func (r *salesforceRepository) ContactActivityCounts(ctx context.Context, orgID, contactID uuid.UUID) (pending, failed, synced int, lastError string, err error) { + err = r.db.QueryRow(ctx, ` + SELECT + COUNT(*) FILTER (WHERE status = 'pending'), + COUNT(*) FILTER (WHERE status = 'failed'), + COUNT(*) FILTER (WHERE status = 'synced'), + COALESCE((SELECT detail FROM salesforce_activity_queue + WHERE organization_id = $1 AND contact_id = $2 AND status = 'failed' + ORDER BY processed_at DESC NULLS LAST LIMIT 1), '') + FROM salesforce_activity_queue WHERE organization_id = $1 AND contact_id = $2`, orgID, contactID). + Scan(&pending, &failed, &synced, &lastError) + return +} + +func (r *salesforceRepository) PruneActivities(ctx context.Context, before time.Time) (int64, error) { + tag, err := r.db.Exec(ctx, `DELETE FROM salesforce_activity_queue WHERE status <> 'pending' AND created_at < $1`, before) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +// --- import sources --------------------------------------------------------- + +const sourceColumns = `id, organization_id, connection_id, created_by_user_id, name, source_kind, sobject, + source_id, source_label, campaign_id, category_ids, recurring, enabled, status, last_run_at, + last_result, last_error, total_imported, created_at, updated_at` + +func scanSource(row pgx.Row) (models.SalesforceImportSource, error) { + var s models.SalesforceImportSource + var result []byte + err := row.Scan(&s.ID, &s.OrganizationID, &s.ConnectionID, &s.CreatedByUserID, &s.Name, &s.SourceKind, &s.SObject, + &s.SourceID, &s.SourceLabel, &s.CampaignID, &s.CategoryIDs, &s.Recurring, &s.Enabled, &s.Status, &s.LastRunAt, + &result, &s.LastError, &s.TotalImported, &s.CreatedAt, &s.UpdatedAt) + if err == nil && len(result) > 0 { + var rr models.SalesforceRunResult + if json.Unmarshal(result, &rr) == nil { + s.LastResult = &rr + } + } + if s.CategoryIDs == nil { + s.CategoryIDs = []uuid.UUID{} + } + return s, err +} + +func (r *salesforceRepository) CreateSource(ctx context.Context, s *models.SalesforceImportSource) error { + if s.CategoryIDs == nil { + s.CategoryIDs = []uuid.UUID{} + } + row := r.db.QueryRow(ctx, ` + INSERT INTO salesforce_import_sources ( + organization_id, connection_id, created_by_user_id, name, source_kind, sobject, source_id, + source_label, campaign_id, category_ids, recurring, enabled + ) + SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 + WHERE EXISTS (SELECT 1 FROM integration_connections WHERE id = $2 AND organization_id = $1) + AND ($9::uuid IS NULL OR EXISTS (SELECT 1 FROM campaigns WHERE id = $9 AND organization_id = $1)) + RETURNING `+sourceColumns, + s.OrganizationID, s.ConnectionID, s.CreatedByUserID, s.Name, s.SourceKind, s.SObject, s.SourceID, + s.SourceLabel, s.CampaignID, s.CategoryIDs, s.Recurring, s.Enabled) + out, err := scanSource(row) + if isNoRows(err) { + return ErrSalesforceSourceRefs + } + if err != nil { + return err + } + *s = out + return nil +} + +func (r *salesforceRepository) GetSource(ctx context.Context, orgID, id uuid.UUID) (*models.SalesforceImportSource, error) { + s, err := scanSource(r.db.QueryRow(ctx, `SELECT `+sourceColumns+` FROM salesforce_import_sources WHERE organization_id = $1 AND id = $2`, orgID, id)) + if isNoRows(err) { + return nil, nil + } + if err != nil { + return nil, err + } + return &s, nil +} + +func (r *salesforceRepository) ListSources(ctx context.Context, orgID, connID uuid.UUID) ([]models.SalesforceImportSource, error) { + rows, err := r.db.Query(ctx, `SELECT `+sourceColumns+` FROM salesforce_import_sources WHERE organization_id = $1 AND connection_id = $2 ORDER BY created_at DESC`, orgID, connID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []models.SalesforceImportSource{} + for rows.Next() { + s, err := scanSource(rows) + if err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +func (r *salesforceRepository) UpdateSource(ctx context.Context, s *models.SalesforceImportSource) error { + tag, err := r.db.Exec(ctx, ` + UPDATE salesforce_import_sources SET + name = $3, campaign_id = $4, category_ids = $5, recurring = $6, enabled = $7, updated_at = NOW() + WHERE organization_id = $1 AND id = $2 + AND ($4::uuid IS NULL OR EXISTS (SELECT 1 FROM campaigns WHERE id = $4 AND organization_id = $1))`, + s.OrganizationID, s.ID, s.Name, s.CampaignID, s.CategoryIDs, s.Recurring, s.Enabled) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrSalesforceSourceRefs + } + return nil +} + +func (r *salesforceRepository) DeleteSource(ctx context.Context, orgID, id uuid.UUID) (bool, error) { + tag, err := r.db.Exec(ctx, `DELETE FROM salesforce_import_sources WHERE organization_id = $1 AND id = $2`, orgID, id) + if err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil +} + +// ClaimSource marks a source running unless it already is; a run that died +// mid-way is reclaimable after 30 minutes. +func (r *salesforceRepository) ClaimSource(ctx context.Context, id uuid.UUID) (bool, error) { + tag, err := r.db.Exec(ctx, ` + UPDATE salesforce_import_sources SET status = 'running', updated_at = NOW() + WHERE id = $1 AND (status <> 'running' OR updated_at < NOW() - INTERVAL '30 minutes')`, id) + if err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil +} + +func (r *salesforceRepository) FinishSource(ctx context.Context, id uuid.UUID, result *models.SalesforceRunResult, errMsg string) error { + var raw []byte + added := 0 + if result != nil { + raw, _ = json.Marshal(result) + added = result.Imported + } + status := "idle" + if errMsg != "" { + status = "error" + } + _, err := r.db.Exec(ctx, ` + UPDATE salesforce_import_sources SET + status = $2, last_run_at = NOW(), last_result = COALESCE($3, last_result), last_error = $4, + total_imported = total_imported + $5, updated_at = NOW() + WHERE id = $1`, id, status, raw, sfTruncate(errMsg, 500), added) + return err +} + +func (r *salesforceRepository) DueRecurringSources(ctx context.Context, every time.Duration) ([]models.SalesforceImportSource, error) { + rows, err := r.db.Query(ctx, ` + SELECT s.id, s.organization_id, s.connection_id, s.created_by_user_id, s.name, s.source_kind, s.sobject, + s.source_id, s.source_label, s.campaign_id, s.category_ids, s.recurring, s.enabled, s.status, s.last_run_at, + s.last_result, s.last_error, s.total_imported, s.created_at, s.updated_at + FROM salesforce_import_sources s + JOIN integration_connections c ON c.id = s.connection_id + WHERE s.recurring AND s.enabled AND s.status <> 'running' + AND c.status IN ('connected', 'degraded') + AND (s.last_run_at IS NULL OR s.last_run_at < NOW() - make_interval(secs => $1)) + ORDER BY s.last_run_at NULLS FIRST + LIMIT 50`, every.Seconds()) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.SalesforceImportSource + for rows.Next() { + s, err := scanSource(rows) + if err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +func (r *salesforceRepository) SourceMembers(ctx context.Context, sourceID uuid.UUID, recordIDs []string) (map[string]bool, error) { + out := map[string]bool{} + if len(recordIDs) == 0 { + return out, nil + } + rows, err := r.db.Query(ctx, `SELECT record_id FROM salesforce_import_members WHERE source_id = $1 AND record_id = ANY($2)`, sourceID, recordIDs) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out[id] = true + } + return out, rows.Err() +} + +// AddSourceMembers records records a source imported; contact may be uuid.Nil +// for a record that had no usable address. +func (r *salesforceRepository) AddSourceMembers(ctx context.Context, sourceID uuid.UUID, members map[string]uuid.UUID) error { + if len(members) == 0 { + return nil + } + ids := make([]string, 0, len(members)) + contacts := make([]string, 0, len(members)) + for rec, c := range members { + ids = append(ids, rec) + if c == uuid.Nil { + contacts = append(contacts, "") + } else { + contacts = append(contacts, c.String()) + } + } + _, err := r.db.Exec(ctx, ` + INSERT INTO salesforce_import_members (source_id, record_id, contact_id) + SELECT $1, rec, NULLIF(cid, '')::uuid FROM UNNEST($2::text[], $3::text[]) AS m(rec, cid) + ON CONFLICT (source_id, record_id) DO UPDATE SET contact_id = COALESCE(EXCLUDED.contact_id, salesforce_import_members.contact_id)`, + sourceID, ids, contacts) + return err +} + +func (r *salesforceRepository) LinksForAccounts(ctx context.Context, connID uuid.UUID, accountIDs []string) ([]models.SalesforceRecordLink, error) { + if len(accountIDs) == 0 { + return nil, nil + } + return r.queryLinks(ctx, `WHERE connection_id = $1 AND sobject = 'Contact' AND account_id = ANY($2)`, connID, accountIDs) +} + +// --- sync state ------------------------------------------------------------- + +func (r *salesforceRepository) GetSyncState(ctx context.Context, connID uuid.UUID) (*models.SalesforceSyncState, error) { + var s models.SalesforceSyncState + err := r.db.QueryRow(ctx, ` + SELECT connection_id, organization_id, lead_cursor, contact_cursor, opportunity_cursor, last_pull_at, last_pull_error, + api_used, api_max, api_seen_at, + CASE WHEN calls_day = (NOW() AT TIME ZONE 'UTC')::date THEN calls_today ELSE 0 END + FROM salesforce_sync_state WHERE connection_id = $1`, connID). + Scan(&s.ConnectionID, &s.OrganizationID, &s.LeadCursor, &s.ContactCursor, &s.OppCursor, &s.LastPullAt, &s.LastPullError, + &s.APIUsed, &s.APIMax, &s.APISeenAt, &s.CallsToday) + if isNoRows(err) { + return nil, nil + } + if err != nil { + return nil, err + } + return &s, nil +} + +// EnsureSyncState starts a connection's cursors at start, so the first pull +// reads changes from then on rather than the org's whole history. +func (r *salesforceRepository) EnsureSyncState(ctx context.Context, connID, orgID uuid.UUID, start time.Time) error { + _, err := r.db.Exec(ctx, ` + INSERT INTO salesforce_sync_state (connection_id, organization_id, lead_cursor, contact_cursor, opportunity_cursor) + SELECT $1, $2, $3, $3, $3 + WHERE EXISTS (SELECT 1 FROM integration_connections WHERE id = $1 AND organization_id = $2) + ON CONFLICT (connection_id) DO NOTHING`, connID, orgID, start) + return err +} + +func (r *salesforceRepository) SetCursors(ctx context.Context, connID uuid.UUID, lead, contact, opp *time.Time, pullErr string) error { + _, err := r.db.Exec(ctx, ` + UPDATE salesforce_sync_state SET + lead_cursor = COALESCE($2, lead_cursor), contact_cursor = COALESCE($3, contact_cursor), + opportunity_cursor = COALESCE($5, opportunity_cursor, NOW()), + last_pull_at = NOW(), last_pull_error = $4, updated_at = NOW() + WHERE connection_id = $1`, connID, lead, contact, sfTruncate(pullErr, 500), opp) + return err +} + +// RecordUsage stores the org's API reading and adds calls to today's count. +func (r *salesforceRepository) RecordUsage(ctx context.Context, connID uuid.UUID, used, max, calls int) error { + _, err := r.db.Exec(ctx, ` + UPDATE salesforce_sync_state SET + api_used = CASE WHEN $3 > 0 THEN $2 ELSE api_used END, + api_max = CASE WHEN $3 > 0 THEN $3 ELSE api_max END, + api_seen_at = CASE WHEN $3 > 0 THEN NOW() ELSE api_seen_at END, + calls_today = CASE WHEN calls_day = (NOW() AT TIME ZONE 'UTC')::date THEN calls_today + $4 ELSE $4 END, + calls_day = (NOW() AT TIME ZONE 'UTC')::date, + updated_at = NOW() + WHERE connection_id = $1`, connID, used, max, calls) + return err +} + +// --- contacts and campaign context ------------------------------------------ + +func (r *salesforceRepository) scanContacts(ctx context.Context, q string, args ...any) ([]SalesforceContact, error) { + rows, err := r.db.Query(ctx, ` + SELECT id, email, COALESCE(first_name, ''), COALESCE(last_name, ''), COALESCE(company, ''), + COALESCE(phone, ''), COALESCE(custom_fields, '{}'::jsonb), subscribed + FROM contacts `+q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []SalesforceContact + for rows.Next() { + var c SalesforceContact + var custom []byte + if err := rows.Scan(&c.ID, &c.Email, &c.FirstName, &c.LastName, &c.Company, &c.Phone, &custom, &c.Subscribed); err != nil { + return nil, err + } + c.CustomFields = map[string]string{} + _ = json.Unmarshal(custom, &c.CustomFields) + out = append(out, c) + } + return out, rows.Err() +} + +func (r *salesforceRepository) ContactsByEmails(ctx context.Context, orgID uuid.UUID, emails []string) (map[string]SalesforceContact, error) { + out := map[string]SalesforceContact{} + if len(emails) == 0 { + return out, nil + } + lower := make([]string, len(emails)) + for i, e := range emails { + lower[i] = strings.ToLower(strings.TrimSpace(e)) + } + cs, err := r.scanContacts(ctx, `WHERE organization_id = $1 AND LOWER(email) = ANY($2)`, orgID, lower) + if err != nil { + return nil, err + } + for _, c := range cs { + out[strings.ToLower(c.Email)] = c + } + return out, nil +} + +func (r *salesforceRepository) ContactsByIDs(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) (map[uuid.UUID]SalesforceContact, error) { + out := map[uuid.UUID]SalesforceContact{} + if len(ids) == 0 { + return out, nil + } + cs, err := r.scanContacts(ctx, `WHERE organization_id = $1 AND id = ANY($2)`, orgID, ids) + if err != nil { + return nil, err + } + for _, c := range cs { + out[c.ID] = c + } + return out, nil +} + +// Engagement summarises each contact's most recent campaign activity in the +// organization. +func (r *salesforceRepository) Engagement(ctx context.Context, orgID uuid.UUID, contactIDs []uuid.UUID) (map[uuid.UUID]SalesforceEngagement, error) { + out := map[uuid.UUID]SalesforceEngagement{} + if len(contactIDs) == 0 { + return out, nil + } + rows, err := r.db.Query(ctx, ` + SELECT DISTINCT ON (ccp.contact_id) + ccp.contact_id, cam.name, + MAX(ccp.sent_at) OVER (PARTITION BY ccp.contact_id), + MAX(ccp.replied_at) OVER (PARTITION BY ccp.contact_id), + CASE + WHEN BOOL_OR(ccp.replied_at IS NOT NULL) OVER (PARTITION BY ccp.contact_id) THEN 'replied' + WHEN BOOL_OR(ccp.bounced_at IS NOT NULL) OVER (PARTITION BY ccp.contact_id) THEN 'bounced' + WHEN cam.status = 'active' THEN 'in_sequence' + ELSE 'finished' + END + FROM campaign_contact_progress ccp + JOIN campaigns cam ON cam.id = ccp.campaign_id AND cam.organization_id = $1 + WHERE ccp.contact_id = ANY($2) + ORDER BY ccp.contact_id, ccp.sent_at DESC NULLS LAST`, orgID, contactIDs) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var id uuid.UUID + var e SalesforceEngagement + if err := rows.Scan(&id, &e.LastCampaign, &e.LastSentAt, &e.LastReplyAt, &e.Status); err != nil { + return nil, err + } + out[id] = e + } + if err := rows.Err(); err != nil { + return nil, err + } + intents, err := r.db.Query(ctx, ` + SELECT DISTINCT ON (c.id) c.id, ri.intent + FROM contacts c + JOIN reply_intents ri ON ri.organization_id = c.organization_id AND LOWER(ri.contact_email) = LOWER(c.email) + WHERE c.organization_id = $1 AND c.id = ANY($2) + ORDER BY c.id, ri.created_at DESC`, orgID, contactIDs) + if err != nil { + return out, nil + } + defer intents.Close() + for intents.Next() { + var id uuid.UUID + var intent string + if intents.Scan(&id, &intent) == nil { + e := out[id] + e.ReplyIntent = intent + out[id] = e + } + } + return out, nil +} + +// SentContent reads what is stored about a campaign send in the organization: +// its rendered subject, where it came from, and whether it went out. +func (r *salesforceRepository) SentContent(ctx context.Context, orgID, taskID uuid.UUID) (*SalesforceSentContent, error) { + var c SalesforceSentContent + err := r.db.QueryRow(ctx, ` + SELECT COALESCE(ct.subject, ''), COALESCE(ea.email, ''), COALESCE(cam.name, ''), + COALESCE(seq.name, ''), t.status::text, t.created_at + FROM tasks t + JOIN campaign_tasks ct ON ct.task_id = t.id + JOIN campaigns cam ON cam.id = ct.campaign_id AND cam.organization_id = $1 + LEFT JOIN sequences seq ON seq.id = ct.sequence_id + LEFT JOIN email_accounts ea ON ea.id = t.email_account_id + WHERE t.id = $2`, orgID, taskID). + Scan(&c.Subject, &c.FromEmail, &c.Campaign, &c.Step, &c.Status, &c.SentAt) + if isNoRows(err) { + return nil, nil + } + if err != nil { + return nil, err + } + return &c, nil +} + +func (r *salesforceRepository) MailboxEmail(ctx context.Context, orgID, accountID uuid.UUID) (string, error) { + var email string + err := r.db.QueryRow(ctx, `SELECT email FROM email_accounts WHERE id = $1 AND organization_id = $2`, accountID, orgID).Scan(&email) + if isNoRows(err) { + return "", nil + } + return email, err +} + +// sfTruncate cuts to at most n bytes without splitting a character, so a +// long localized message never becomes invalid UTF-8. +func sfTruncate(s string, n int) string { + if len(s) <= n { + return s + } + for n > 0 && !utf8.RuneStart(s[n]) { + n-- + } + return s[:n] +} diff --git a/web/src/app/app/integrations/_components/ConnectDrawer.tsx b/web/src/app/app/integrations/_components/ConnectDrawer.tsx index f1ba540da..a29440208 100644 --- a/web/src/app/app/integrations/_components/ConnectDrawer.tsx +++ b/web/src/app/app/integrations/_components/ConnectDrawer.tsx @@ -31,7 +31,8 @@ import { } from "lucide-react"; import toast from "react-hot-toast"; -import { Label, TextInput } from "@/components/ui/field"; +import { FieldError, Label, TextInput } from "@/components/ui/field"; +import { OptionSelect } from "@/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox"; import useConnectIntegration from "@/lib/api/hooks/app/integrations/useConnectIntegration"; import { useFinishIntegrationOAuth, @@ -101,6 +102,14 @@ const FIELDS_BY_PROVIDER: Record = { ], }; +type SalesforceEnv = "production" | "sandbox" | "custom"; + +// Accepts "acme.my.salesforce.com" or a pasted URL; returns the bare host or "". +function salesforceHost(raw: string): string { + const host = raw.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, ""); + return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(host) ? host : ""; +} + export default function ConnectDrawer({ entry, onClose, @@ -114,6 +123,8 @@ export default function ConnectDrawer({ const [config, setConfig] = React.useState>({}); const [step, setStep] = React.useState<"overview" | "credentials">("overview"); const [busy, setBusy] = React.useState(false); + const [sfEnv, setSfEnv] = React.useState("production"); + const [sfDomain, setSfDomain] = React.useState(""); const navigate = useNavigate(); const connect = useConnectIntegration(); @@ -127,15 +138,30 @@ export default function ConnectDrawer({ const fields = FIELDS_BY_PROVIDER[entry.provider] ?? []; // Only providers with real credential fields take the extra credentials step. const needsCredentials = !isOAuth && !isInbound && fields.length > 0; + const isSalesforce = entry.provider === "salesforce"; + const sfHost = salesforceHost(sfDomain); + const sfDomainError = + isSalesforce && sfEnv === "custom" && sfDomain.trim() !== "" && !sfHost + ? "Enter a host like acme.my.salesforce.com" + : null; function update(key: string, value: string) { setConfig((c) => ({ ...c, [key]: value })); } async function runOAuth() { + if (isSalesforce && sfEnv === "custom" && !sfHost) { + toast.error("Enter your Salesforce My Domain, like acme.my.salesforce.com"); + return; + } setBusy(true); try { - const { url } = await startOAuth.mutateAsync({ provider: entry.provider, label: label.trim() }); + const sf = isSalesforce + ? sfEnv === "custom" + ? { environment: sfHost.includes(".sandbox.") ? ("sandbox" as const) : ("production" as const), domain: sfHost } + : { environment: sfEnv } + : {}; + const { url } = await startOAuth.mutateAsync({ provider: entry.provider, label: label.trim(), ...sf }); const { code, state } = await openOAuthPopup(url); const conn = await finishOAuth.mutateAsync({ code, state }); toast.success(`Connected to ${entry.name}`); @@ -229,6 +255,39 @@ export default function ConnectDrawer({ )} + {isSalesforce && ( +
+ Salesforce environment + + {sfEnv === "custom" && ( +
+ + +
+ )} +

+ The user you sign in as needs API access: Enterprise, Unlimited, Performance or + Developer edition, or Professional with the API add-on. Your Salesforce admin may + need to approve the Warmbly connected app first. +

+
+ )} +
diff --git a/web/src/app/app/integrations/_components/ConnectionDetail.tsx b/web/src/app/app/integrations/_components/ConnectionDetail.tsx index 89711c6fd..6fde85f3b 100644 --- a/web/src/app/app/integrations/_components/ConnectionDetail.tsx +++ b/web/src/app/app/integrations/_components/ConnectionDetail.tsx @@ -17,6 +17,7 @@ import { Loader2Icon, RefreshCwIcon, SendIcon, + Settings2Icon, UnplugIcon, } from "lucide-react"; import toast from "react-hot-toast"; @@ -193,6 +194,22 @@ export default function ConnectionDetail({ )}
+ {/* Salesforce has its own page: sync rules, field map, imports, activity log. */} + {conn.provider === "salesforce" && ( +
+ + + Open Salesforce settings + +

+ Sync rules, field mapping, imports from list views and campaigns, and the activity log. +

+
+ )} + {/* Granted access */} {conn.granted_scopes && conn.granted_scopes.length > 0 && (
@@ -231,7 +248,7 @@ export default function ConnectionDetail({ )} {/* Field mapping — control exactly what each CRM record gets */} - {crmObject && !isHubSpot && ( + {crmObject && !isHubSpot && conn.provider !== "salesforce" && (
Field mapping diff --git a/web/src/app/app/integrations/page.tsx b/web/src/app/app/integrations/page.tsx index 1866d6df7..3d3b17805 100644 --- a/web/src/app/app/integrations/page.tsx +++ b/web/src/app/app/integrations/page.tsx @@ -46,12 +46,12 @@ export default function IntegrationsPage() { const catalogQuery = useIntegrationCatalog(); const connectionsQuery = useIntegrationConnections(); const bookingsQuery = useMeetingBookings(); + const navigate = useNavigate(); const [connectTarget, setConnectTarget] = React.useState(null); const [manageTarget, setManageTarget] = React.useState(null); const [inboundUrl, setInboundUrl] = React.useState<{ provider: IntegrationProvider; url: string } | null>(null); const [query, setQuery] = React.useState(""); - const navigate = useNavigate(); const catalog = React.useMemo(() => catalogQuery.data?.catalog ?? [], [catalogQuery.data?.catalog]); const connections = React.useMemo( @@ -225,7 +225,10 @@ export default function IntegrationsPage() { onClose={() => setConnectTarget(null)} onConnected={(conn) => { connectionsQuery.refetch(); - if (conn.inbound_webhook_url) { + if (conn.provider === "salesforce") { + // Setup continues on the Salesforce page: rules, mapping, imports. + navigate(`/app/integrations/salesforce/${conn.id}`); + } else if (conn.inbound_webhook_url) { setInboundUrl({ provider: conn.provider, url: conn.inbound_webhook_url }); } else { // Drop straight into management so the user can wire automations. diff --git a/web/src/app/app/integrations/salesforce/[id]/page.tsx b/web/src/app/app/integrations/salesforce/[id]/page.tsx new file mode 100644 index 000000000..842402a6f --- /dev/null +++ b/web/src/app/app/integrations/salesforce/[id]/page.tsx @@ -0,0 +1,415 @@ +// Salesforce settings page: everything about one Salesforce connection in one +// place. Overview (health, API usage, sync counts, permission check), the sync +// rules and field map (one settings document, one save bar), imports from list +// views and Salesforce campaigns, and the activity log with retry. + +"use client"; + +import React from "react"; +import { Link, useBlocker, useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { motion } from "framer-motion"; +import { + ActivityIcon, + AlertTriangleIcon, + ArrowLeftIcon, + DownloadCloudIcon, + ExternalLinkIcon, + GaugeIcon, + Loader2Icon, + RefreshCwIcon, + SaveIcon, + SlidersHorizontalIcon, + TableIcon, + type LucideIcon, +} from "lucide-react"; +import toast from "react-hot-toast"; + +import ScrollStrip from "@/components/ui/scroll-strip"; +import ResourceViewers from "@/components/app/presence/ResourceViewers"; +import { useConfirm } from "@/hooks/context/confirm"; +import { usePresenceResource } from "@/hooks/PresenceProvider"; +import { + useFinishIntegrationOAuth, + useReauthIntegration, +} from "@/lib/api/hooks/app/integrations/useIntegrationOAuth"; +import { + useSalesforceImportSources, + useSalesforceOverview, + useSalesforceSettings, + useSalesforceSyncNow, + useUpdateSalesforceSettings, +} from "@/lib/api/hooks/app/integrations/useSalesforce"; +import type { SalesforceSettings } from "@/lib/api/models/app/integrations/Salesforce"; +import { openOAuthPopup } from "@/lib/integrations/oauthPopup"; +import { cn } from "@/lib/utils"; + +import ProviderGlyph from "../../_components/ProviderGlyph"; +import StatusPill, { HealthDot } from "../../_components/StatusPill"; +import ActivityLogTab from "../_components/ActivityLogTab"; +import FieldMappingTab from "../_components/FieldMappingTab"; +import ImportTab from "../_components/ImportTab"; +import OverviewTab from "../_components/OverviewTab"; +import { Pill, primaryBtn, secondaryBtn } from "../_components/shared"; +import SyncRulesTab from "../_components/SyncRulesTab"; +import { errMsg } from "../_components/util"; + +type TabId = "overview" | "rules" | "fields" | "import" | "activity"; + +const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [ + { id: "overview", label: "Overview", icon: GaugeIcon }, + { id: "rules", label: "Sync rules", icon: SlidersHorizontalIcon }, + { id: "fields", label: "Field mapping", icon: TableIcon }, + { id: "import", label: "Import", icon: DownloadCloudIcon }, + { id: "activity", label: "Activity log", icon: ActivityIcon }, +]; + +const isTab = (v: string | null): v is TabId => !!v && TABS.some((t) => t.id === v); + +export default function SalesforcePage() { + const { id = "" } = useParams<{ id: string }>(); + const [params, setParams] = useSearchParams(); + const tab: TabId = isTab(params.get("tab")) ? (params.get("tab") as TabId) : "overview"; + const navigate = useNavigate(); + const confirm = useConfirm(); + + const overview = useSalesforceOverview(id); + const settingsQ = useSalesforceSettings(id); + const sources = useSalesforceImportSources(id); + const update = useUpdateSalesforceSettings(id); + const syncNow = useSalesforceSyncNow(id); + const reauth = useReauthIntegration(); + const finishOAuth = useFinishIntegrationOAuth(); + const [reconnecting, setReconnecting] = React.useState(false); + + usePresenceResource(id ? `integration_connection:${id}` : null, "editing"); + + // One draft for Sync rules and Field mapping: they are one settings document. + // `base` is the server copy the draft started from, so a teammate's save is + // adopted while nobody is editing and never clobbers an edit in progress. + const saved = settingsQ.data?.settings; + const [base, setBase] = React.useState(null); + const [draft, setDraft] = React.useState(null); + const [saveError, setSaveError] = React.useState(null); + const dirty = !!draft && !!base && JSON.stringify(draft) !== JSON.stringify(base); + + const dirtyRef = React.useRef(dirty); + dirtyRef.current = dirty; + React.useEffect(() => { + if (!saved || dirtyRef.current) return; + setBase(saved); + setDraft(structuredClone(saved)); + }, [saved]); + + const patch = React.useCallback((fn: (s: SalesforceSettings) => SalesforceSettings) => { + setDraft((d) => (d ? fn(d) : d)); + setSaveError(null); + }, []); + + async function save(next?: SalesforceSettings) { + const body = next ?? draft; + if (!body) return; + setSaveError(null); + try { + const res = await update.mutateAsync({ connectionId: id, settings: body }); + setBase(res.settings); + setDraft(structuredClone(res.settings)); + toast.success("Salesforce settings saved"); + } catch (err) { + const m = errMsg(err, "Could not save the settings"); + setSaveError(m); + toast.error(m); + throw err; + } + } + + function discard() { + if (saved) { + setBase(saved); + setDraft(structuredClone(saved)); + } + setSaveError(null); + } + + function enableSync() { + const from = dirty && draft ? draft : saved; + if (!from) return; + void save({ ...from, enabled: true }).catch(() => undefined); + } + + // Leaving with unsaved edits asks first. Tab switches stay on this path. + const skipGuard = React.useRef(false); + const blocker = useBlocker( + React.useCallback( + ({ currentLocation, nextLocation }: { currentLocation: { pathname: string }; nextLocation: { pathname: string } }) => + !skipGuard.current && dirtyRef.current && currentLocation.pathname !== nextLocation.pathname, + [], + ), + ); + React.useEffect(() => { + if (blocker.state !== "blocked") return; + const to = blocker.location; + blocker.reset(); + confirm.show("You have unsaved Salesforce settings. Leave and discard them?", async () => { + skipGuard.current = true; + discard(); + navigate(to.pathname + to.search + to.hash); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [blocker.state]); + React.useEffect(() => { + const handler = (e: BeforeUnloadEvent) => { + if (dirtyRef.current) { + e.preventDefault(); + e.returnValue = ""; + } + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, []); + + function setTab(t: TabId, extra?: Record) { + const next = new URLSearchParams(); + if (t !== "overview") next.set("tab", t); + for (const [k, v] of Object.entries(extra ?? {})) next.set(k, v); + setParams(next, { replace: true }); + } + + async function handleReconnect() { + setReconnecting(true); + try { + const { url } = await reauth.mutateAsync(id); + const { code, state } = await openOAuthPopup(url); + await finishOAuth.mutateAsync({ code, state }); + toast.success("Reconnected to Salesforce"); + void overview.refetch(); + } catch (err) { + toast.error(errMsg(err, "Reconnect failed")); + } finally { + setReconnecting(false); + } + } + + function runSyncNow() { + syncNow.mutate(undefined, { + onSuccess: () => toast.success("Sync started"), + onError: (err) => toast.error(errMsg(err, "Could not start a sync")), + }); + } + + const ov = overview.data; + const instanceUrl = ov?.org.instance_url ?? ""; + const needsReauth = ov?.status === "reauth_required"; + const running = (sources.data ?? []).some((s) => s.status === "running"); + const failed = ov?.counts.failed ?? 0; + + if (overview.isError && !ov) { + return ( +
+
+ +
+

This Salesforce connection could not be loaded

+

+ {errMsg(overview.error, "It may have been disconnected.")} +

+ +
+
+
+ ); + } + + return ( +
+
+
+ +
+ +
+
+

+ {ov?.label || "Salesforce"} +

+ {ov && } + {ov?.org.environment === "sandbox" && Sandbox} + +
+
+ {ov?.org.account && {ov.org.account}} + {ov && ( + + + {ov.health} + + )} + {instanceUrl && ( + + {instanceUrl.replace(/^https?:\/\//, "")} + + )} +
+
+
+
+
+ {needsReauth && ( + + )} + {instanceUrl && ( + + + Open Salesforce + + )} + +
+
+ +
+ + {TABS.map((t) => { + const active = tab === t.id; + return ( + + ); + })} + +
+ +
+ {tab === "overview" && ( + setTab("activity", status ? { status } : undefined)} + /> + )} + {(tab === "rules" || tab === "fields") && !draft && ( +
+ {settingsQ.isError ? ( + {errMsg(settingsQ.error, "Could not load the settings")} + ) : ( + <> + + Loading settings… + + )} +
+ )} + {tab === "rules" && draft && } + {tab === "fields" && draft && ( + + )} + {tab === "import" && } + {tab === "activity" && ( + setTab("activity", s ? { status: s } : undefined)} + /> + )} +
+ + {dirty && ( + + {saveError ? ( + + + {saveError} + + ) : ( + Unsaved Salesforce settings + )} + + + + )} +
+ ); +} + +function BackLink() { + return ( + + + Integrations + + ); +} diff --git a/web/src/app/app/integrations/salesforce/_components/ActivityLogTab.tsx b/web/src/app/app/integrations/salesforce/_components/ActivityLogTab.tsx new file mode 100644 index 000000000..016d1d1df --- /dev/null +++ b/web/src/app/app/integrations/salesforce/_components/ActivityLogTab.tsx @@ -0,0 +1,376 @@ +// Activity log: every event Warmbly queued for Salesforce, its outcome, the +// Salesforce error when it failed, and retry for failed or skipped ones. + +import React from "react"; +import { + CalendarCheckIcon, + CheckIcon, + ExternalLinkIcon, + Loader2Icon, + MailIcon, + MailOpenIcon, + MailWarningIcon, + MousePointerClickIcon, + ReplyIcon, + RotateCwIcon, + UserXIcon, + XIcon, + type LucideIcon, +} from "lucide-react"; +import toast from "react-hot-toast"; + +import { Checkbox } from "@/components/ui/checkbox"; +import ScrollStrip from "@/components/ui/scroll-strip"; +import ContactEdit from "@/components/app/contacts/ContactEdit"; +import useContact from "@/lib/api/hooks/app/contacts/useContact"; +import { + useRetrySalesforceActivity, + useSalesforceActivity, +} from "@/lib/api/hooks/app/integrations/useSalesforce"; +import { + SALESFORCE_ACTIVITY_LABELS, + salesforceRecordURL, + type SalesforceActivity, + type SalesforceActivityKind, + type SalesforceActivityStatus, +} from "@/lib/api/models/app/integrations/Salesforce"; +import { cn } from "@/lib/utils"; + +import { Pill, secondaryBtn } from "./shared"; +import { absolute, ago, errMsg } from "./util"; + +const FILTERS: { value: SalesforceActivityStatus | ""; label: string }[] = [ + { value: "", label: "All" }, + { value: "failed", label: "Failed" }, + { value: "pending", label: "Pending" }, + { value: "synced", label: "Synced" }, + { value: "skipped", label: "Skipped" }, +]; + +const KIND_ICON: Record = { + sent: MailIcon, + replied: ReplyIcon, + opened: MailOpenIcon, + clicked: MousePointerClickIcon, + bounced: MailWarningIcon, + unsubscribed: UserXIcon, + meeting_booked: CalendarCheckIcon, +}; + +const STATUS_TONE: Record = { + synced: "emerald", + pending: "sky", + skipped: "slate", + failed: "rose", +}; + +const retryable = (a: SalesforceActivity) => a.status === "failed" || a.status === "skipped"; + +export default function ActivityLogTab({ + connectionId, + instanceUrl, + status: rawStatus, + onStatus, +}: { + connectionId: string; + instanceUrl: string; + status: string; + onStatus: (s: string) => void; +}) { + const status = (FILTERS.some((f) => f.value === rawStatus) ? rawStatus : "") as SalesforceActivityStatus | ""; + const activity = useSalesforceActivity(connectionId, status); + const retry = useRetrySalesforceActivity(connectionId); + const [selected, setSelected] = React.useState>(new Set()); + const [openContact, setOpenContact] = React.useState(""); + + // A selection never outlives the filter it was made under. + React.useEffect(() => setSelected(new Set()), [status]); + + const rows = activity.rows; + const selectable = rows.filter(retryable); + const allSelected = selectable.length > 0 && selectable.every((r) => selected.has(r.id)); + + function toggle(id: string) { + setSelected((s) => { + const n = new Set(s); + if (n.has(id)) n.delete(id); + else n.add(id); + return n; + }); + } + function toggleAll() { + setSelected(allSelected ? new Set() : new Set(selectable.map((r) => r.id))); + } + + function runRetry(ids?: string[]) { + retry.mutate( + { connectionId, ids }, + { + onSuccess: (res) => { + toast.success( + res.requeued === 0 + ? "Nothing to retry" + : `Queued ${res.requeued.toLocaleString()} ${res.requeued === 1 ? "event" : "events"} again`, + ); + setSelected(new Set()); + }, + onError: (err) => toast.error(errMsg(err, "Could not retry")), + }, + ); + } + + return ( +
+
+ + {FILTERS.map((f) => { + const active = f.value === status; + return ( + + ); + })} + + +
+ +
+ + + + + {["Event", "Contact", "Status", "Detail", "Tries", "When", ""].map((h, i) => ( + + ))} + + + + {activity.isPending ? ( + + + + ) : activity.isError ? ( + + + + ) : rows.length === 0 ? ( + + + + ) : ( + rows.map((a) => ( + toggle(a.id)} + onOpenContact={() => a.contact_id && setOpenContact(a.contact_id)} + /> + )) + )} + +
+ + + {h} +
+ + Loading activity… +
+ {errMsg(activity.error, "Could not load the activity log")} +
+ {status ? `No ${status} events.` : "Nothing has been sent to Salesforce yet."} +
+
+ + {activity.hasNextPage && ( +
+ +
+ )} + + {selected.size > 0 && ( +
+
+ + {selected.size.toLocaleString()} selected +
+ + +
+ )} + + {openContact && setOpenContact("")} />} +
+ ); +} + +function ActivityRow({ + a, + instanceUrl, + selected, + onToggle, + onOpenContact, +}: { + a: SalesforceActivity; + instanceUrl: string; + selected: boolean; + onToggle: () => void; + onOpenContact: () => void; +}) { + const Icon = KIND_ICON[a.kind] ?? MailIcon; + const taskUrl = salesforceRecordURL(instanceUrl, a.task_id); + const recordUrl = salesforceRecordURL(instanceUrl, a.record_id); + return ( + + + {retryable(a) && ( + + )} + + + + + {SALESFORCE_ACTIVITY_LABELS[a.kind] ?? a.kind} + + + + {a.contact_id ? ( + + ) : ( + {a.contact_email} + )} + + + {a.status} + + + {a.detail ? ( + + {a.detail} + + ) : ( + None + )} + {a.status === "pending" && a.attempts > 0 && ( +
Next try {absolute(a.next_attempt_at)}
+ )} + + {a.attempts} + + {ago(a.occurred_at)} + + + + {taskUrl && ( + + Task + + + )} + {recordUrl && ( + + Record + + + )} + + + + ); +} + +// Opens the contact drawer for one id without leaving the page. +function ContactQuickView({ contactId, onClose }: { contactId: string; onClose: () => void }) { + const detail = useContact(contactId); + const contacts = React.useMemo(() => (detail.data ? [detail.data] : []), [detail.data]); + + React.useEffect(() => { + if (detail.isError) { + toast.error(errMsg(detail.error, "Could not open the contact")); + onClose(); + } + }, [detail.isError, detail.error, onClose]); + + return ( + { + const next = typeof v === "function" ? v(contactId) : v; + if (!next) onClose(); + }} + /> + ); +} diff --git a/web/src/app/app/integrations/salesforce/_components/FieldMappingTab.tsx b/web/src/app/app/integrations/salesforce/_components/FieldMappingTab.tsx new file mode 100644 index 000000000..e3fcbaf40 --- /dev/null +++ b/web/src/app/app/integrations/salesforce/_components/FieldMappingTab.tsx @@ -0,0 +1,369 @@ +// Field mapping: which Warmbly field moves to or from which Salesforce field, +// per object, in which direction, and whether it may overwrite a value. + +import React from "react"; +import { AlertTriangleIcon, PlusIcon, RotateCcwIcon, Trash2Icon, XIcon } from "lucide-react"; + +import { TextInput } from "@/components/ui/field"; +import { SelectMenu } from "@/components/ui/select-menu"; +import { Segmented } from "@/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox"; +import { useConfirm } from "@/hooks/context/confirm"; +import useCustomFieldKeys from "@/lib/api/hooks/app/contacts/useCustomFieldKeys"; +import { useSalesforceMetadata } from "@/lib/api/hooks/app/integrations/useSalesforce"; +import { + SALESFORCE_DIRECTION_LABELS, + SALESFORCE_POLICY_LABELS, + type SalesforceFieldDirection, + type SalesforceFieldInfo, + type SalesforceFieldMapRow, + type SalesforceFieldPolicy, + type SalesforceObject, + type SalesforceSettings, + type SalesforceWarmblyField, +} from "@/lib/api/models/app/integrations/Salesforce"; +import { cn } from "@/lib/utils"; + +import { SearchSelect, secondaryBtn, type SearchOption } from "./shared"; +import { errMsg } from "./util"; + +type Patch = (fn: (s: SalesforceSettings) => SalesforceSettings) => void; + +const CUSTOM_NEW = "__custom_new__"; +const GRID = "sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_170px_140px_28px] sm:items-center gap-2"; + +const isEngagement = (k: string) => k.startsWith("engagement."); +const isRelated = (f: string) => f.includes("."); + +export default function FieldMappingTab({ + connectionId, + draft, + patch, + warmblyFields, + defaultFieldMap, +}: { + connectionId: string; + draft: SalesforceSettings; + patch: Patch; + warmblyFields: SalesforceWarmblyField[]; + defaultFieldMap: SalesforceFieldMapRow[]; +}) { + const confirm = useConfirm(); + const meta = useSalesforceMetadata(connectionId); + const customKeys = useCustomFieldKeys(); + const [object, setObject] = React.useState("Lead"); + + const sfFields = React.useMemo( + () => (object === "Lead" ? (meta.data?.lead_fields ?? []) : (meta.data?.contact_fields ?? [])), + [meta.data, object], + ); + + const rows = draft.field_map + .map((r, index) => ({ r, index })) + .filter(({ r }) => r.object === object); + const counts = { + Lead: draft.field_map.filter((r) => r.object === "Lead").length, + Contact: draft.field_map.filter((r) => r.object === "Contact").length, + }; + + // Two rules writing the same Salesforce field the same way are refused on save. + const dupes = React.useMemo(() => { + const seen = new Map(); + for (const r of draft.field_map) { + const k = `${r.object}|${r.salesforce.toLowerCase()}|${r.direction}`; + seen.set(k, (seen.get(k) ?? 0) + 1); + } + return seen; + }, [draft.field_map]); + + const warmblyOptions: SearchOption[] = React.useMemo(() => { + const opts: SearchOption[] = warmblyFields.map((f) => ({ + value: f.key, + label: f.label, + hint: isEngagement(f.key) ? "Push only" : undefined, + })); + for (const k of customKeys.data ?? []) { + opts.push({ value: `custom:${k}`, label: `Custom: ${k}`, hint: "Contact custom field" }); + } + opts.push({ value: CUSTOM_NEW, label: "Custom field…", hint: "Type a custom field key" }); + return opts; + }, [warmblyFields, customKeys.data]); + + function setRow(index: number, p: Partial) { + patch((s) => ({ + ...s, + field_map: s.field_map.map((r, i) => (i === index ? normalize({ ...r, ...p }) : r)), + })); + } + function removeRow(index: number) { + patch((s) => ({ ...s, field_map: s.field_map.filter((_, i) => i !== index) })); + } + function addRow() { + patch((s) => ({ + ...s, + field_map: [...s.field_map, { object, warmbly: "", salesforce: "", direction: "push", policy: "if_empty" }], + })); + } + function resetDefaults() { + confirm.show( + "Replace every field rule, for Leads and Contacts, with the defaults? Nothing changes until you save.", + async () => patch((s) => ({ ...s, field_map: defaultFieldMap.map((r) => ({ ...r })) })), + ); + } + + return ( +
+
+ +

+ Email always matches records. These rules move everything else. +

+ +
+ + {meta.isError && ( +
+ + + Could not read your Salesforce fields ({errMsg(meta.error, "unknown error")}). Type API names + instead. + +
+ )} + +
+
+ Warmbly field + Salesforce field + Direction + When a value exists + +
+ {rows.length === 0 ? ( +
+ No {object} fields are mapped. Only email is matched. +
+ ) : ( +
+ {rows.map(({ r, index }) => ( + 1} + onChange={(p) => setRow(index, p)} + onRemove={() => removeRow(index)} + /> + ))} +
+ )} +
+ +
+
+

+ Engagement fields only push. Related fields such as Account.Name can only be read. Two-way fields + resolve conflicts with the policy: “Only fill blanks” never overwrites a value on either side. +

+
+ ); +} + +// Directions a rule may take for its two fields. +function allowedDirections(warmbly: string, salesforce: string, field?: SalesforceFieldInfo): SalesforceFieldDirection[] { + if (isEngagement(warmbly)) return ["push"]; + if (isRelated(salesforce)) return ["pull"]; + if (field && (!field.updateable || field.calculated)) return ["pull"]; + return ["push", "pull", "both"]; +} + +function normalize(r: SalesforceFieldMapRow): SalesforceFieldMapRow { + if (isEngagement(r.warmbly) && r.direction !== "push") return { ...r, direction: "push" }; + if (isRelated(r.salesforce) && r.direction !== "pull") return { ...r, direction: "pull" }; + return r; +} + +function FieldRow({ + row, + sfFields, + metaFailed, + metaLoading, + warmblyOptions, + warmblyFields, + duplicate, + onChange, + onRemove, +}: { + row: SalesforceFieldMapRow; + sfFields: SalesforceFieldInfo[]; + metaFailed: boolean; + metaLoading: boolean; + warmblyOptions: SearchOption[]; + warmblyFields: SalesforceWarmblyField[]; + duplicate: boolean; + onChange: (p: Partial) => void; + onRemove: () => void; +}) { + const field = sfFields.find((f) => f.name === row.salesforce); + const directions = allowedDirections(row.warmbly, row.salesforce, field); + const isCustom = row.warmbly.startsWith("custom:"); + const customKey = isCustom ? row.warmbly.slice("custom:".length) : ""; + // Writing needs an updateable field, so push and two-way rules only offer those. + const writes = row.direction !== "pull"; + + const sfOptions: SearchOption[] = sfFields.map((f) => { + const readOnly = !f.updateable || f.calculated || isRelated(f.name); + return { + value: f.name, + label: f.label, + hint: f.name, + disabled: writes && readOnly, + disabledReason: `${f.name} · read-only, switch the rule to Salesforce → Warmbly`, + }; + }); + + const problem = !row.warmbly || (isCustom && !customKey.trim()) + ? "Choose a Warmbly field" + : !row.salesforce + ? "Choose a Salesforce field" + : duplicate + ? `${row.salesforce} is mapped twice in this direction` + : null; + + return ( +
+
+ Warmbly field + {isCustom ? ( +
+ + custom + + onChange({ warmbly: `custom:${v.replace(/\s+/g, "_")}` })} + placeholder="field_key" + className="flex-1 font-mono text-[12px]" + invalid={!customKey.trim()} + /> + +
+ ) : ( + f.key === row.warmbly)?.label} + onChange={(v) => onChange({ warmbly: v === CUSTOM_NEW ? "custom:" : v })} + options={warmblyOptions} + placeholder="Choose a field" + searchPlaceholder="Search Warmbly fields…" + className="w-full" + aria-label="Warmbly field" + /> + )} +
+
+ Salesforce field + {metaFailed ? ( + onChange({ salesforce: v.replace(/[^A-Za-z0-9_.]/g, "") })} + placeholder="API name, e.g. Title" + className="w-full font-mono text-[12px]" + /> + ) : ( + onChange({ salesforce: v })} + options={sfOptions} + loading={metaLoading} + placeholder="Choose a field" + searchPlaceholder="Search Salesforce fields…" + className="w-full" + minWidth={320} + aria-label="Salesforce field" + /> + )} +
+
+ Direction + onChange({ direction: v as SalesforceFieldDirection })} + fullWidth + aria-label="Direction" + options={(["push", "pull", "both"] as SalesforceFieldDirection[]).map((d) => ({ + value: d, + label: SALESFORCE_DIRECTION_LABELS[d], + disabled: !directions.includes(d), + }))} + /> +
+
+ When a value exists + onChange({ policy: v as SalesforceFieldPolicy })} + fullWidth + aria-label="Overwrite policy" + options={(["if_empty", "overwrite"] as SalesforceFieldPolicy[]).map((p) => ({ + value: p, + label: SALESFORCE_POLICY_LABELS[p], + }))} + /> +
+
+ +
+ {problem && ( +

+ + {problem} +

+ )} +
+ ); +} + +function HeadCell({ children }: { children: React.ReactNode }) { + return {children}; +} + +function MobileLabel({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/web/src/app/app/integrations/salesforce/_components/ImportDialog.tsx b/web/src/app/app/integrations/salesforce/_components/ImportDialog.tsx new file mode 100644 index 000000000..2402e2813 --- /dev/null +++ b/web/src/app/app/integrations/salesforce/_components/ImportDialog.tsx @@ -0,0 +1,685 @@ +// New Salesforce import: pick a list view or a Salesforce Campaign, preview what +// comes across, then choose where it lands. Also the edit dialog for a saved +// import source. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { + AlertCircleIcon, + CheckIcon, + ChevronLeftIcon, + ChevronRightIcon, + ContactIcon, + DownloadCloudIcon, + Loader2Icon, + MegaphoneIcon, + RefreshCwIcon, + UserIcon, + XIcon, + type LucideIcon, +} from "lucide-react"; +import toast from "react-hot-toast"; + +import { Label, TextInput } from "@/components/ui/field"; +import CampaignPicker from "@/components/app/campaigns/CampaignPicker"; +import { Toggle } from "@/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox"; +import CategoryPicker from "@/components/app/contacts/CategoryPicker"; +import { useConfirm } from "@/hooks/context/confirm"; +import { + useCreateSalesforceImportSource, + useSalesforceCampaigns, + useSalesforceImportPreview, + useSalesforceListViews, + useUpdateSalesforceImportSource, +} from "@/lib/api/hooks/app/integrations/useSalesforce"; +import type { + SalesforceImportObject, + SalesforceImportPreview, + SalesforceImportSource, + SalesforceSourceKind, +} from "@/lib/api/models/app/integrations/Salesforce"; +import { cn } from "@/lib/utils"; + +import ProviderGlyph from "../../_components/ProviderGlyph"; +import { Pill, SearchSelect, primaryBtn } from "./shared"; +import { errMsg } from "./util"; + +type Kind = "lead_view" | "contact_view" | "campaign"; + +const KINDS: { id: Kind; label: string; hint: string; icon: LucideIcon }[] = [ + { id: "lead_view", label: "Lead list view", hint: "Any Lead list view your user can see", icon: UserIcon }, + { id: "contact_view", label: "Contact list view", hint: "Any Contact list view your user can see", icon: ContactIcon }, + { id: "campaign", label: "Salesforce Campaign", hint: "Its Leads and Contacts, as campaign members", icon: MegaphoneIcon }, +]; + +function kindParts(k: Kind): { source_kind: SalesforceSourceKind; object: SalesforceImportObject } { + if (k === "campaign") return { source_kind: "campaign", object: "CampaignMember" }; + return { source_kind: "list_view", object: k === "lead_view" ? "Lead" : "Contact" }; +} + +const STEPS = [ + { key: "source", label: "Source" }, + { key: "preview", label: "Preview" }, + { key: "options", label: "Options" }, +] as const; + +const paneVariants = { + enter: (dir: 1 | -1) => ({ x: dir * 28, opacity: 0 }), + center: { x: 0, opacity: 1 }, + exit: (dir: 1 | -1) => ({ x: dir * -28, opacity: 0 }), +}; + +export default function ImportDialog({ connectionId, onClose }: { connectionId: string; onClose: () => void }) { + const confirm = useConfirm(); + const [step, setStep] = React.useState(0); + const [direction, setDirection] = React.useState<1 | -1>(1); + const [nudge, setNudge] = React.useState(null); + + const [kind, setKind] = React.useState("lead_view"); + const [sourceId, setSourceId] = React.useState(""); + const [sourceLabel, setSourceLabel] = React.useState(""); + const [preview, setPreview] = React.useState(null); + const [previewFor, setPreviewFor] = React.useState(""); + const [name, setName] = React.useState(""); + const [campaignId, setCampaignId] = React.useState(null); + const [campaignName, setCampaignName] = React.useState(""); + const [categoryIds, setCategoryIds] = React.useState([]); + const [recurring, setRecurring] = React.useState(true); + + const previewM = useSalesforceImportPreview(); + const create = useCreateSalesforceImportSource(connectionId); + + const selectionKey = `${kind}|${sourceId}`; + const dirty = !!sourceId; + + const requestClose = React.useCallback(() => { + if (create.isPending) return; + if (dirty) confirm.show("Discard this import? Nothing has been imported yet.", async () => onClose()); + else onClose(); + }, [dirty, confirm, onClose, create.isPending]); + + React.useEffect(() => { + const onKey = (ev: KeyboardEvent) => { + if (ev.key !== "Escape") return; + if (document.querySelector("[data-floating], [role='alertdialog']")) return; + ev.preventDefault(); + requestClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [requestClose]); + + async function loadPreview() { + const parts = kindParts(kind); + setPreviewFor(selectionKey); + setPreview(null); + try { + const p = await previewM.mutateAsync({ connectionId, ...parts, source_id: sourceId }); + setPreview(p); + } catch { + // Rendered in the step from previewM.error. + } + } + + function issueFor(s: number): string | null { + if (s === 0 && !sourceId) return kind === "campaign" ? "Pick a Salesforce Campaign first" : "Pick a list view first"; + if (s === 1) { + if (previewM.isPending) return "Wait for the preview to load"; + if (!preview || previewFor !== selectionKey) return "The preview did not load. Retry it before continuing"; + } + return null; + } + + function goTo(target: number) { + if (target > step) { + for (let s = step; s < target; s++) { + const issue = issueFor(s); + if (issue) { + setNudge(issue); + return; + } + } + } + setNudge(null); + setDirection(target > step ? 1 : -1); + setStep(target); + if (target === 1 && previewFor !== selectionKey) void loadPreview(); + if (target === 2 && !name.trim()) setName(sourceLabel); + } + + async function submit() { + const parts = kindParts(kind); + try { + await create.mutateAsync({ + connectionId, + body: { + name: name.trim() || sourceLabel, + ...parts, + source_id: sourceId, + source_label: sourceLabel, + campaign_id: campaignId ?? undefined, + category_ids: categoryIds, + recurring, + }, + }); + toast.success("Import started"); + onClose(); + } catch (err) { + toast.error(errMsg(err, "Could not create the import")); + } + } + + const last = STEPS.length - 1; + + return ( + +
+ {STEPS.map((s, i) => ( + + {i > 0 && } + + + ))} +
+ +
+ + + {step === 0 && ( + { + setKind(k); + setSourceId(""); + setSourceLabel(""); + setNudge(null); + }} + sourceId={sourceId} + sourceLabel={sourceLabel} + onPick={(id, label) => { + setSourceId(id); + setSourceLabel(label); + setNudge(null); + }} + /> + )} + {step === 1 && ( + void loadPreview()} + /> + )} + {step === 2 && ( +
+
+ + +
+
+ + { + setCampaignId(id); + setCampaignName(n); + }} + noneLabel="Only add to contacts" + /> +
+
+ + +
+
+
+
Keep in sync
+

+ Checks the {kind === "campaign" ? "campaign" : "list view"} every 30 minutes and + brings in new people. Off imports once. +

+
+ +
+
+ )} +
+
+
+ +
+ {step > 0 && ( + + )} +
+ + {nudge && ( + + + {nudge} + + )} + + {step < last ? ( + + ) : ( + + )} +
+
+
+ ); +} + +function SourceStep({ + connectionId, + kind, + setKind, + sourceId, + sourceLabel, + onPick, +}: { + connectionId: string; + kind: Kind; + setKind: (k: Kind) => void; + sourceId: string; + sourceLabel: string; + onPick: (id: string, label: string) => void; +}) { + const isCampaign = kind === "campaign"; + const views = useSalesforceListViews(connectionId, kind === "contact_view" ? "Contact" : "Lead", !isCampaign); + const [q, setQ] = React.useState(""); + const [debounced, setDebounced] = React.useState(""); + React.useEffect(() => { + const t = window.setTimeout(() => setDebounced(q.trim()), 250); + return () => window.clearTimeout(t); + }, [q]); + const campaigns = useSalesforceCampaigns(connectionId, debounced, isCampaign); + + return ( +
+
+ {KINDS.map((k) => { + const active = k.id === kind; + return ( + + ); + })} +
+ +
+ + {isCampaign ? ( + onPick(id, o.label)} + options={(campaigns.data ?? []).map((c) => ({ + value: c.id, + label: c.name, + hint: [c.status, c.type, `${c.member_count.toLocaleString()} members`].filter(Boolean).join(" · "), + }))} + onQueryChange={setQ} + loading={campaigns.isFetching} + placeholder="Choose a campaign" + searchPlaceholder="Search Salesforce campaigns…" + emptyText={campaigns.isError ? errMsg(campaigns.error, "Could not load campaigns") : "No campaigns match."} + className="w-full" + minWidth={360} + aria-label="Salesforce Campaign" + /> + ) : ( + onPick(id, o.label)} + options={(views.data ?? []).map((v) => ({ value: v.id, label: v.label }))} + loading={views.isFetching} + placeholder="Choose a list view" + searchPlaceholder="Search list views…" + emptyText={views.isError ? errMsg(views.error, "Could not load list views") : "No list views."} + className="w-full" + minWidth={320} + aria-label="List view" + /> + )} +

+ People without an email address are skipped. Anyone already in Warmbly is updated and linked, never + duplicated. +

+
+
+ ); +} + +function PreviewStep({ + loading, + error, + preview, + sourceLabel, + onRetry, +}: { + loading: boolean; + error: string | null; + preview: SalesforceImportPreview | null; + sourceLabel: string; + onRetry: () => void; +}) { + if (loading) { + return ( +
+ + Reading {sourceLabel} from Salesforce… +
+ ); + } + if (error || !preview) { + return ( +
+

Could not preview {sourceLabel}

+ {error &&

{error}

} + +
+ ); + } + const noEmail = preview.sample.filter((r) => !r.email).length; + const linked = preview.sample.filter((r) => r.already_linked).length; + return ( +
+
+
+ {preview.total.toLocaleString()}{" "} + {preview.total === 1 ? "record" : "records"} in {sourceLabel} +
+ {preview.sample.length > 0 && ( + + Showing {preview.sample.length}. {noEmail > 0 ? `${noEmail} of them have no email and will be skipped. ` : ""} + {linked > 0 ? `${linked} are already linked to Warmbly contacts.` : ""} + + )} +
+ {preview.sample.length === 0 ? ( +

Nothing to import right now.

+ ) : ( +
+ + + + {["Name", "Email", "Company", "Title", "Owner", "Status"].map((h) => ( + + ))} + + + + {preview.sample.map((r) => ( + + + + + + + + + ))} + +
+ {h} +
+ + {r.name || "Unnamed"} + {r.already_linked && Already linked} + + + {r.email ? ( + {r.email} + ) : ( + No email + )} + {r.company}{r.title}{r.owner_name}{r.status}
+
+ )} +
+ ); +} + +// Edit a saved import source: where it lands and whether it keeps syncing. +export function EditImportDialog({ + connectionId, + source, + onClose, +}: { + connectionId: string; + source: SalesforceImportSource; + onClose: () => void; +}) { + const confirm = useConfirm(); + const update = useUpdateSalesforceImportSource(connectionId); + const [name, setName] = React.useState(source.name); + const [campaignId, setCampaignId] = React.useState(source.campaign_id ?? null); + const [campaignName, setCampaignName] = React.useState(""); + const [categoryIds, setCategoryIds] = React.useState(source.category_ids ?? []); + const [recurring, setRecurring] = React.useState(source.recurring); + + const dirty = + name !== source.name || + (campaignId ?? null) !== (source.campaign_id ?? null) || + recurring !== source.recurring || + JSON.stringify([...categoryIds].sort()) !== JSON.stringify([...(source.category_ids ?? [])].sort()); + + const requestClose = React.useCallback(() => { + if (update.isPending) return; + if (dirty) confirm.show("Discard your changes to this import?", async () => onClose()); + else onClose(); + }, [dirty, confirm, onClose, update.isPending]); + + React.useEffect(() => { + const onKey = (ev: KeyboardEvent) => { + if (ev.key !== "Escape") return; + if (document.querySelector("[data-floating], [role='alertdialog']")) return; + ev.preventDefault(); + requestClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [requestClose]); + + async function save() { + try { + await update.mutateAsync({ + connectionId, + sourceId: source.id, + body: { + name: name.trim() || source.source_label, + campaign_id: campaignId, + category_ids: categoryIds, + recurring, + }, + }); + toast.success("Import updated"); + onClose(); + } catch (err) { + toast.error(errMsg(err, "Could not update the import")); + } + } + + return ( + +
+
+ Reads {source.source_label}. To read something else, + create a new import. +
+
+ + +
+
+ + { + setCampaignId(id); + setCampaignName(n); + }} + noneLabel="Only add to contacts" + /> +
+
+ + +
+
+
+
Keep in sync
+

Checks for new people every 30 minutes.

+
+ +
+
+
+ + +
+
+ ); +} + +function DialogShell({ + title, + onRequestClose, + wide, + children, +}: { + title: string; + onRequestClose: () => void; + wide?: boolean; + children: React.ReactNode; +}) { + return ( + + ev.stopPropagation()} + className={cn( + "w-full rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden flex flex-col max-h-[90dvh]", + wide ? "max-w-[860px] h-[min(90dvh,640px)]" : "max-w-[520px]", + )} + > +
+ + Salesforce +
+ {title} + +
+ {children} +
+
+ ); +} diff --git a/web/src/app/app/integrations/salesforce/_components/ImportTab.tsx b/web/src/app/app/integrations/salesforce/_components/ImportTab.tsx new file mode 100644 index 000000000..455bf9588 --- /dev/null +++ b/web/src/app/app/integrations/salesforce/_components/ImportTab.tsx @@ -0,0 +1,289 @@ +// Import: saved Salesforce sources (list views and Campaigns) that bring people +// into Warmbly, once or every 30 minutes. + +import React from "react"; +import { + AlertTriangleIcon, + DownloadCloudIcon, + Loader2Icon, + MoreHorizontalIcon, + PauseIcon, + PencilIcon, + PlayIcon, + PlusIcon, + RefreshCwIcon, + Trash2Icon, +} from "lucide-react"; +import toast from "react-hot-toast"; + +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuSeparator, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; +import { CategoryChip } from "@/components/app/contacts/CategoryPicker"; +import { useConfirm } from "@/hooks/context/confirm"; +import { useUserProfile } from "@/hooks/context/user"; +import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns"; +import { + useDeleteSalesforceImportSource, + useRunSalesforceImportSource, + useSalesforceImportSources, + useUpdateSalesforceImportSource, +} from "@/lib/api/hooks/app/integrations/useSalesforce"; +import { + SALESFORCE_IMPORT_OBJECT_LABELS, + type SalesforceImportSource, +} from "@/lib/api/models/app/integrations/Salesforce"; +import { cn } from "@/lib/utils"; + +import ImportDialog, { EditImportDialog } from "./ImportDialog"; +import { Pill, primaryBtn } from "./shared"; +import { absolute, ago, errMsg } from "./util"; + +export default function ImportTab({ connectionId }: { connectionId: string }) { + const sources = useSalesforceImportSources(connectionId); + const [creating, setCreating] = React.useState(false); + const [editing, setEditing] = React.useState(null); + const campaigns = useCampaigns({ query: "", folder: "" }); + const campaignName = React.useCallback( + (id?: string | null) => (id ? (campaigns.campaigns.find((c) => c.id === id)?.name ?? "A campaign") : null), + [campaigns.campaigns], + ); + + const list = sources.data ?? []; + + return ( +
+
+

+ Bring people in from a Salesforce list view or Campaign. Records stay linked, so activity logs back to + them. +

+ +
+ + {sources.isPending ? ( +
+ {[0, 1].map((i) => ( +
+ ))} +
+ ) : sources.isError ? ( +
+ {errMsg(sources.error, "Could not load imports")} +
+ ) : list.length === 0 ? ( +
+ +

No imports yet

+

+ Import a list view like “My open leads” or a Salesforce Campaign, and keep it in sync. +

+ +
+ ) : ( +
+ {list.map((s) => ( + setEditing(s)} + /> + ))} +
+ )} + + {creating && setCreating(false)} />} + {editing && ( + setEditing(null)} /> + )} +
+ ); +} + +function SourceRow({ + connectionId, + source: s, + campaign, + onEdit, +}: { + connectionId: string; + source: SalesforceImportSource; + campaign: string | null; + onEdit: () => void; +}) { + const confirm = useConfirm(); + const { user } = useUserProfile(); + const run = useRunSalesforceImportSource(connectionId); + const update = useUpdateSalesforceImportSource(connectionId); + const del = useDeleteSalesforceImportSource(connectionId); + + const categories = (s.category_ids ?? []) + .map((id) => (user.categories ?? []).find((c) => c.id === id)) + .filter((c): c is NonNullable => !!c); + const running = s.status === "running"; + const r = s.last_result; + + function runNow() { + run.mutate( + { connectionId, sourceId: s.id }, + { + onSuccess: () => toast.success("Import started"), + onError: (err) => toast.error(errMsg(err, "Could not start the import")), + }, + ); + } + function toggleEnabled() { + update.mutate( + { connectionId, sourceId: s.id, body: { enabled: !s.enabled } }, + { + onSuccess: () => toast.success(s.enabled ? "Import paused" : "Import resumed"), + onError: (err) => toast.error(errMsg(err, "Could not update the import")), + }, + ); + } + function remove() { + confirm.show( + `Delete the import “${s.name}”? Contacts it already brought in stay in Warmbly.`, + async () => { + try { + await del.mutateAsync({ connectionId, sourceId: s.id }); + toast.success("Import deleted"); + } catch (err) { + toast.error(errMsg(err, "Could not delete the import")); + throw err; + } + }, + ); + } + + return ( +
{ + if (e.key === "Enter") onEdit(); + }} + className="px-4 py-3 flex items-start gap-3 hover:bg-slate-50/60 transition-colors cursor-pointer outline-none focus-visible:bg-slate-50" + > +
+
+ {s.name} + {s.source_kind === "campaign" ? "Campaign" : SALESFORCE_IMPORT_OBJECT_LABELS[s.object]} + {s.recurring && s.enabled && Syncs every 30 min} + {!s.enabled && Paused} + +
+
+ From {s.source_label} + · + {campaign ? `Adds to ${campaign}` : "Contacts only"} + · + Last run {ago(s.last_run_at)} + · + {s.total_imported.toLocaleString()} imported in total +
+ {categories.length > 0 && ( +
+ {categories.map((c) => ( + + ))} +
+ )} + {r && ( +
+ + + + + + + + {r.failed > 0 && } + {r.truncated && Stopped at the per-run limit, continues next run} +
+ )} + {s.last_error && ( +

+ + {s.last_error} +

+ )} +
+
e.stopPropagation()}> + + + + + + } + onSelect={runNow} + disabled={running || !s.enabled} + > + Run now + + : } + onSelect={toggleEnabled} + > + {s.enabled ? "Pause" : "Resume"} + + } onSelect={onEdit}> + Edit + + + } onSelect={remove} danger> + Delete + + + +
+
+ ); +} + +function StatusBadge({ status }: { status: SalesforceImportSource["status"] }) { + if (status === "running") + return ( + + + Running + + ); + if (status === "error") return Error; + return Idle; +} + +function Count({ label, value, strong, tone }: { label: string; value: number; strong?: boolean; tone?: "rose" }) { + return ( + + + {value.toLocaleString()} + {" "} + {label} + + ); +} diff --git a/web/src/app/app/integrations/salesforce/_components/OverviewTab.tsx b/web/src/app/app/integrations/salesforce/_components/OverviewTab.tsx new file mode 100644 index 000000000..dd5a392d1 --- /dev/null +++ b/web/src/app/app/integrations/salesforce/_components/OverviewTab.tsx @@ -0,0 +1,265 @@ +// Overview: is the connection healthy, how much API it uses, what is moving. + +import { + AlertTriangleIcon, + CheckCircle2Icon, + ClockIcon, + Loader2Icon, + PowerIcon, + ShieldCheckIcon, + XCircleIcon, +} from "lucide-react"; +import toast from "react-hot-toast"; + +import { useSalesforcePermissionCheck } from "@/lib/api/hooks/app/integrations/useSalesforce"; +import type { SalesforceOverview } from "@/lib/api/models/app/integrations/Salesforce"; +import { cn } from "@/lib/utils"; + +import StatusPill, { HealthDot } from "../../_components/StatusPill"; +import { CardRow, Pill, SettingsCard, primaryBtn, secondaryBtn } from "./shared"; +import { absolute, ago, errMsg } from "./util"; + +export default function OverviewTab({ + connectionId, + overview, + loading, + onEnable, + enabling, + onOpenActivity, +}: { + connectionId: string; + overview?: SalesforceOverview; + loading: boolean; + onEnable: () => void; + enabling: boolean; + onOpenActivity: (status?: string) => void; +}) { + const check = useSalesforcePermissionCheck(connectionId); + + if (loading || !overview) { + return ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+ ); + } + + const { api, counts, org } = overview; + const checks = overview.checks ?? check.data?.checks; + + function runCheck() { + check.mutate(undefined, { + onError: (err) => toast.error(errMsg(err, "Could not run the permission check")), + }); + } + + return ( +
+ {!overview.settings_enabled && ( +
+ +
+

Sync is off for this connection

+

+ Nothing is logged to Salesforce and nothing is read back until you turn it on. Imports still run. +

+
+ +
+ )} + +
+ + +
+ + + + {overview.health} + +
+ {overview.health_detail && ( +

{overview.health_detail}

+ )} + + Sandbox : "Production" + } + /> + + + {org.id && } +
+
+ + + + + 0 + ? "Warmbly stops non-urgent calls at this budget and resumes tomorrow." + : "Automatic budget: a fifth of the org's daily allocation." + } + /> + + +
+ +
+
Sync
+
+ + + onOpenActivity("pending")} /> + 0 ? "rose" : undefined} + onClick={() => onOpenActivity("failed")} + cta={counts.failed > 0 ? "View in activity log" : undefined} + /> + onOpenActivity("skipped")} /> +
+
+ + + Last read from Salesforce{" "} + + {ago(overview.last_pull_at)} + + + {overview.last_pull_error && ( + + + {overview.last_pull_error} + + )} +
+
+ + + {check.isPending ? ( + + ) : ( + + )} + Run permission check + + } + > + {!checks || checks.length === 0 ? ( + +

+ {check.isPending ? "Checking…" : "Not run yet. It takes a few API calls."} +

+
+ ) : ( + checks.map((c) => ( + + {c.ok ? ( + + ) : ( + + )} +
+
{c.label}
+ {c.detail && ( +
+ {c.detail} +
+ )} +
+
+ )) + )} +
+
+ ); +} + +function InfoRow({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +function Meter({ label, used, max, hint }: { label: string; used: number; max: number; hint?: string }) { + const pct = max > 0 ? Math.min(100, Math.round((used / max) * 100)) : 0; + const tone = pct >= 90 ? "bg-rose-500" : pct >= 70 ? "bg-amber-500" : "bg-sky-600"; + return ( +
+
+ {label} + + {used.toLocaleString()} + {max > 0 ? ` / ${max.toLocaleString()}` : ""} + {max > 0 && {pct}%} + +
+
+
+
+ {hint &&

{hint}

} +
+ ); +} + +function Stat({ + label, + value, + tone, + onClick, + cta, +}: { + label: string; + value: number; + tone?: "emerald" | "rose"; + onClick?: () => void; + cta?: string; +}) { + const valueTone = tone === "rose" ? "text-rose-700" : tone === "emerald" ? "text-emerald-700" : "text-slate-900"; + const body = ( + <> +
{label}
+
+ {value.toLocaleString()} +
+ {cta &&
{cta}
} + + ); + if (!onClick) return
{body}
; + return ( + + ); +} diff --git a/web/src/app/app/integrations/salesforce/_components/SyncRulesTab.tsx b/web/src/app/app/integrations/salesforce/_components/SyncRulesTab.tsx new file mode 100644 index 000000000..7162f2169 --- /dev/null +++ b/web/src/app/app/integrations/salesforce/_components/SyncRulesTab.tsx @@ -0,0 +1,503 @@ +// Sync rules: who gets matched or created in Salesforce, what lands on the +// timeline, which Lead statuses Warmbly writes, and what Salesforce changes do +// to outreach. Edits the shared settings draft; the page owns the save bar. + +import React from "react"; +import { AlertTriangleIcon, InfoIcon } from "lucide-react"; + +import { NumberInput, TextInput } from "@/components/ui/field"; +import { SelectMenu } from "@/components/ui/select-menu"; +import { + OptionSelect, + Segmented, + Toggle, +} from "@/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox"; +import { useSalesforceMetadata, useSalesforceUsers } from "@/lib/api/hooks/app/integrations/useSalesforce"; +import { + SALESFORCE_ACTIVITY_KINDS, + SALESFORCE_ACTIVITY_LABELS, + SALESFORCE_REPLY_INTENTS, + type SalesforceActivityKind, + type SalesforceSettings, +} from "@/lib/api/models/app/integrations/Salesforce"; + +import { CardRow, ChipMultiPicker, PicklistSelect, SearchSelect, SettingsCard, ToggleRow } from "./shared"; +import { errMsg } from "./util"; + +type Patch = (fn: (s: SalesforceSettings) => SalesforceSettings) => void; + +const ACTIVITY_HINTS: Partial> = { + sent: "A completed Task for every campaign email.", + replied: "The reply, with its intent, as a completed Task.", + meeting_booked: "Booked through Calendly, Cal.com or added in Warmbly.", + bounced: "So reps know the address is dead.", + unsubscribed: "Logged alongside the opt-out flag.", + opened: "Costs one API call per open and clutters the timeline.", + clicked: "Costs one API call per click and clutters the timeline.", +}; + +export default function SyncRulesTab({ + connectionId, + draft, + patch, +}: { + connectionId: string; + draft: SalesforceSettings; + patch: Patch; +}) { + const meta = useSalesforceMetadata(connectionId); + const statuses = meta.data?.lead_statuses ?? []; + const sources = meta.data?.lead_sources ?? []; + + const m = draft.matching; + const a = draft.activity; + const w = draft.writeback; + const inb = draft.inbound; + const createNoun = m.create_as === "contact" ? "Contact" : "Lead"; + + const setMatching = (p: Partial) => + patch((s) => ({ ...s, matching: { ...s.matching, ...p } })); + const setActivity = (p: Partial) => + patch((s) => ({ ...s, activity: { ...s.activity, ...p } })); + const setWriteback = (p: Partial) => + patch((s) => ({ ...s, writeback: { ...s.writeback, ...p } })); + const setInbound = (p: Partial) => + patch((s) => ({ ...s, inbound: { ...s.inbound, ...p } })); + + function setReplyStatus(intent: string, value: string) { + patch((s) => { + const next = { ...s.writeback.lead_status_on_reply }; + if (value) next[intent] = value; + else delete next[intent]; + return { ...s, writeback: { ...s.writeback, lead_status_on_reply: next } }; + }); + } + + return ( +
+ {meta.isError && ( +
+ + + Could not read your Salesforce picklists ({errMsg(meta.error, "unknown error")}). Lead status + choices are limited to values already saved. + +
+ )} + +
+ patch((s) => ({ ...s, enabled: v }))} + ariaLabel="Sync with Salesforce" + /> + } + /> +
+ + + + setMatching({ prefer: v })} + options={[ + { value: "contact", label: "Contact" }, + { value: "lead", label: "Lead" }, + ]} + /> + } + /> + + +
When nobody matches
+ setMatching({ create_when: v })} + aria-label="When nobody matches" + options={[ + { value: "never", label: "Only log activity for people already in Salesforce", hint: "Nothing is created." }, + { + value: "reply", + label: `Create a ${createNoun} when someone replies or books a meeting`, + hint: "Recommended. Salesforce only gets people who engaged.", + }, + { value: "send", label: `Create a ${createNoun} on first email`, hint: "Every contacted person lands in Salesforce." }, + ]} + /> +
+ {m.create_when !== "never" && ( + <> + + setMatching({ create_as: v })} + options={[ + { value: "lead", label: "Lead" }, + { value: "contact", label: "Contact" }, + ]} + /> + } + /> + + +
+ Lead source + setMatching({ lead_source: v })} + placeholder="Warmbly" + maxLength={120} + className="w-full" + /> +

+ {sources.length > 0 + ? `Must match a Lead Source value if the picklist is restricted, such as ${sources + .slice(0, 3) + .map((v) => v.value) + .join(", ")}.` + : "Must match a Lead Source value if the picklist is restricted."} +

+
+ {m.create_as === "lead" && ( +
+ Initial Lead status + setMatching({ lead_status: v })} + values={statuses} + emptyLabel="Salesforce default" + loading={meta.isPending} + className="w-full" + aria-label="Initial Lead status" + /> +
+ )} +
+ +
Owner of created records
+ setMatching({ owner: v })} + aria-label="Owner of created records" + options={[ + { value: "connected_user", label: "The connected Salesforce user" }, + { value: "sender", label: "Salesforce user with the sending mailbox's email", hint: "Falls back to the connected user." }, + { value: "fixed", label: "A specific user" }, + ]} + /> + {m.owner === "fixed" && ( + setMatching({ owner_id: id })} + /> + )} +
+ + setMatching({ run_assignment_rules: v })} + ariaLabel="Run assignment rules" + /> + } + /> + + + )} +
+ + + {SALESFORCE_ACTIVITY_KINDS.map((k) => ( + + setActivity({ [k]: v } as Partial)} + ariaLabel={`Log ${SALESFORCE_ACTIVITY_LABELS[k]}`} + /> + } + /> + + ))} + +

+ + Opens and clicks are off by default: each one costs an API call, machine opens inflate them, and + they bury the conversations reps care about. +

+
+ + setActivity({ include_body: v })} ariaLabel="Include the email body" /> + } + /> + + + setActivity({ assign_to: v as SalesforceSettings["activity"]["assign_to"] })} + minWidth={260} + aria-label="Assign Tasks to" + options={[ + { value: "record_owner", label: "The record's owner" }, + { value: "sender", label: "The sender's Salesforce user" }, + { value: "connected_user", label: "The connected user" }, + ]} + /> + } + /> + + + setActivity({ relate_to_opportunity: v })} + ariaLabel="Relate to the open Opportunity" + /> + } + /> + +
+ + + + setWriteback({ lead_status_on_sent: v })} + values={statuses} + loading={meta.isPending} + className="w-56" + aria-label="Status after the first email" + /> + } + /> + + +
+
When they reply
+

+ A specific intent wins over “Any reply”. Out-of-office replies only move a status you set for them. +

+
+
+ {SALESFORCE_REPLY_INTENTS.map((intent) => ( +
+ {intent.label} + setReplyStatus(intent.value, v)} + values={statuses} + loading={meta.isPending} + className="w-56" + aria-label={`Status on ${intent.label}`} + /> +
+ ))} +
+
+ + setWriteback({ lead_status_on_meeting: v })} + values={statuses} + loading={meta.isPending} + className="w-56" + aria-label="Status when a meeting is booked" + /> + } + /> + + + setWriteback({ never_move_backwards: v })} + ariaLabel="Never move a status backwards" + /> + } + /> + +
+ + + +
+
Do-not-email sync
+

+ Keeps Salesforce's Email Opt Out (HasOptedOutOfEmail) and Warmbly's unsubscribes in step. +

+
+ setInbound({ opt_out: v })} + cols={2} + aria-label="Do-not-email sync" + options={[ + { value: "both", label: "Both ways", hint: "Recommended" }, + { value: "to_salesforce", label: "Warmbly to Salesforce only", hint: "Unsubscribes set Email Opt Out" }, + { value: "from_salesforce", label: "Salesforce to Warmbly only", hint: "Email Opt Out unsubscribes here" }, + { value: "off", label: "Off", hint: "Each side keeps its own list" }, + ]} + /> +
+ + setInbound({ pause_on_converted: v })} + ariaLabel="Pause outreach when a Lead is converted" + /> + } + /> + + +
+
Pause outreach at these Lead statuses
+

+ For example Unqualified, or a status your reps set when they take over. +

+
+ setInbound({ pause_on_statuses: v })} + options={statuses} + placeholder={meta.isPending ? "Loading statuses…" : "Click to add statuses…"} + /> +
+ + setInbound({ pause_on_open_opportunity: v })} + ariaLabel="Pause outreach when an Opportunity is open" + /> + } + /> + +
+ + + + patch((s) => ({ ...s, daily_api_budget: v }))} + min={0} + max={1_000_000} + step={500} + suffix="calls / day" + className="w-44" + /> + } + /> + + +
+ ); +} + +function FieldLabel({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +// Picks the Salesforce user that owns created records, searched server-side. +function OwnerPicker({ + connectionId, + value, + onChange, +}: { + connectionId: string; + value: string; + onChange: (id: string) => void; +}) { + const [q, setQ] = React.useState(""); + const [debounced, setDebounced] = React.useState(""); + React.useEffect(() => { + const t = window.setTimeout(() => setDebounced(q.trim()), 250); + return () => window.clearTimeout(t); + }, [q]); + const users = useSalesforceUsers(connectionId, debounced); + const [picked, setPicked] = React.useState<{ id: string; label: string } | null>(null); + + const options = (users.data ?? []).map((u) => ({ value: u.id, label: u.name, hint: u.email })); + const label = picked?.id === value ? picked.label : value; + + return ( +
+ { + setPicked({ id, label: o.label }); + onChange(id); + }} + options={options} + onQueryChange={setQ} + loading={users.isFetching} + placeholder="Choose a Salesforce user" + searchPlaceholder="Search by name or email…" + emptyText={users.isError ? errMsg(users.error, "Could not search users") : "No users match."} + className="w-full sm:w-80" + aria-label="Owner" + /> + {!value &&

Choose a user, or the settings cannot be saved.

} +
+ ); +} diff --git a/web/src/app/app/integrations/salesforce/_components/shared.tsx b/web/src/app/app/integrations/salesforce/_components/shared.tsx new file mode 100644 index 000000000..aff444648 --- /dev/null +++ b/web/src/app/app/integrations/salesforce/_components/shared.tsx @@ -0,0 +1,399 @@ +// Building blocks shared by the Salesforce settings tabs. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { ChevronDownIcon, Loader2Icon, PlusIcon, XIcon } from "lucide-react"; + +import { CheckSquare } from "@/components/ui/check-square"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; +import useClickOutside from "@/hooks/useClickOutside"; +import useFlipPlacement from "@/hooks/useFlipPlacement"; +import type { SalesforcePicklistValue } from "@/lib/api/models/app/integrations/Salesforce"; +import { cn } from "@/lib/utils"; + +export function SectionLabel({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} + +// A titled settings block: label + optional description above a bordered card. +export function SettingsCard({ + title, + description, + action, + children, +}: { + title: string; + description?: React.ReactNode; + action?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+
+ {title} + {description && ( +

{description}

+ )} +
+ {action} +
+
{children}
+
+ ); +} + +// One row inside a SettingsCard. +export function CardRow({ children, className }: { children: React.ReactNode; className?: string }) { + return
{children}
; +} + +export interface SearchOption { + value: string; + label: string; + hint?: string; + disabled?: boolean; + disabledReason?: string; +} + +// A single-select with a search header, for option lists too long to scan. +// Pass onQueryChange to search server-side instead of filtering locally. +export function SearchSelect({ + value, + onChange, + options, + placeholder = "Select…", + searchPlaceholder = "Search…", + valueLabel, + onQueryChange, + loading, + emptyText = "Nothing matches.", + className, + minWidth = 280, + disabled, + footer, + "aria-label": ariaLabel, +}: { + value: string; + onChange: (value: string, option: SearchOption) => void; + options: SearchOption[]; + placeholder?: string; + searchPlaceholder?: string; + // Label for a value not in the current options (a saved id, a remote search). + valueLabel?: string; + onQueryChange?: (q: string) => void; + loading?: boolean; + emptyText?: string; + className?: string; + minWidth?: number; + disabled?: boolean; + footer?: React.ReactNode; + "aria-label"?: string; +}) { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const current = options.find((o) => o.value === value); + const label = current?.label ?? (value ? valueLabel || value : ""); + + const q = query.trim().toLowerCase(); + const shown = onQueryChange + ? options + : q + ? options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q)) + : options; + + function setQ(v: string) { + setQuery(v); + onQueryChange?.(v); + } + + return ( + { + setOpen(o); + if (!o) setQ(""); + }} + > + + + + +
+ setQ(e.target.value)} + placeholder={searchPlaceholder} + autoFocus + className="flex-1 min-w-0 h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none" + /> + {loading && } +
+ {shown.map((o) => ( + onChange(o.value, o)} + > + + {o.label} + {(o.hint || (o.disabled && o.disabledReason)) && ( + + {o.disabled && o.disabledReason ? o.disabledReason : o.hint} + + )} + + + ))} + {shown.length === 0 && ( +
+ {loading ? "Loading…" : emptyText} +
+ )} + {footer} +
+
+ ); +} + +// A Salesforce picklist value, with an explicit empty choice ("Don't change"). +export function PicklistSelect({ + value, + onChange, + values, + emptyLabel = "Don't change", + className, + loading, + "aria-label": ariaLabel, +}: { + value: string; + onChange: (v: string) => void; + values: SalesforcePicklistValue[]; + emptyLabel?: string; + className?: string; + loading?: boolean; + "aria-label"?: string; +}) { + const options: SearchOption[] = [ + { value: "", label: emptyLabel }, + ...values.map((v) => ({ value: v.value, label: v.label || v.value })), + ]; + // A saved value the org no longer offers still shows, so it can be cleared. + if (value && !values.some((v) => v.value === value)) { + options.push({ value, label: `${value} (not in Salesforce)` }); + } + return ( + onChange(v)} + options={options} + placeholder={emptyLabel} + searchPlaceholder="Search statuses…" + loading={loading} + className={className} + minWidth={240} + aria-label={ariaLabel} + /> + ); +} + +// Multi-select chips with a searchable dropdown, in the contacts CategoryPicker style. +export function ChipMultiPicker({ + value, + onChange, + options, + placeholder = "Click to add…", + className, +}: { + value: string[]; + onChange: (next: string[]) => void; + options: SalesforcePicklistValue[]; + placeholder?: string; + className?: string; +}) { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const ref = React.useRef(null); + const triggerRef = React.useRef(null); + useClickOutside(open, () => setOpen(false), ref); + const placement = useFlipPlacement(triggerRef, open, 270); + + const labelOf = (v: string) => options.find((o) => o.value === v)?.label || v; + const q = query.trim().toLowerCase(); + const filtered = q + ? options.filter((o) => (o.label || o.value).toLowerCase().includes(q)) + : options; + + function toggle(v: string) { + onChange(value.includes(v) ? value.filter((x) => x !== v) : [...value, v]); + } + + return ( +
+
+ {value.length === 0 ? ( + + ) : ( +
+ {value.map((v) => ( + + {labelOf(v)} + + + ))} + +
+ )} +
+ + {open && ( + +
+ setQuery(e.target.value)} + placeholder="Search…" + autoFocus + className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none" + /> +
+
+ {filtered.length === 0 && ( +
Nothing matches.
+ )} + {filtered.map((o) => ( + + ))} +
+
+ )} +
+
+ ); +} + +// A labelled toggle row: title and help on the left, the control on the right. +export function ToggleRow({ + title, + description, + control, + children, +}: { + title: React.ReactNode; + description?: React.ReactNode; + control: React.ReactNode; + children?: React.ReactNode; +}) { + return ( +
+
+
{title}
+ {description &&

{description}

} + {children} +
+
{control}
+
+ ); +} + +export function Pill({ + tone = "slate", + children, + className, + title, +}: { + tone?: "slate" | "sky" | "emerald" | "amber" | "rose"; + children: React.ReactNode; + className?: string; + title?: string; +}) { + const cls = { + slate: "bg-slate-100 text-slate-600 border-slate-200", + sky: "bg-sky-50 text-sky-700 border-sky-100", + emerald: "bg-emerald-50 text-emerald-700 border-emerald-100", + amber: "bg-amber-50 text-amber-700 border-amber-200", + rose: "bg-rose-50 text-rose-700 border-rose-100", + }[tone]; + return ( + + {children} + + ); +} + +export const secondaryBtn = + "h-7 px-2.5 rounded-md border border-slate-200 bg-white hover:border-slate-300 text-slate-700 hover:text-slate-900 text-[12px] inline-flex items-center gap-1.5 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"; + +export const primaryBtn = + "h-7 px-3 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"; diff --git a/web/src/app/app/integrations/salesforce/_components/util.ts b/web/src/app/app/integrations/salesforce/_components/util.ts new file mode 100644 index 000000000..5cf406814 --- /dev/null +++ b/web/src/app/app/integrations/salesforce/_components/util.ts @@ -0,0 +1,32 @@ +import { errorMessage } from "@/lib/errors/message"; + +export function errMsg(err: unknown, fallback: string): string { + return errorMessage(err, fallback); +} + +export function isForbidden(err: unknown): boolean { + return (err as { status?: number } | null)?.status === 403; +} + +// "3 min ago" style relative time for sync lines. +export function ago(d: Date | string | null | undefined): string { + if (!d) return "never"; + const t = typeof d === "string" ? new Date(d).getTime() : d.getTime(); + if (Number.isNaN(t)) return "never"; + const sec = Math.max(0, Math.round((Date.now() - t) / 1000)); + if (sec < 45) return "just now"; + const min = Math.round(sec / 60); + if (min < 60) return `${min} min ago`; + const hr = Math.round(min / 60); + if (hr < 24) return `${hr} h ago`; + const day = Math.round(hr / 24); + if (day < 30) return `${day} d ago`; + return new Date(t).toLocaleDateString(); +} + +export function absolute(d: Date | string | null | undefined): string { + if (!d) return ""; + const dt = typeof d === "string" ? new Date(d) : d; + if (Number.isNaN(dt.getTime())) return ""; + return dt.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); +} diff --git a/web/src/components/app/contacts/contact-edit/ActivityTab.tsx b/web/src/components/app/contacts/contact-edit/ActivityTab.tsx index f58e095d0..7e2b0901e 100644 --- a/web/src/components/app/contacts/contact-edit/ActivityTab.tsx +++ b/web/src/components/app/contacts/contact-edit/ActivityTab.tsx @@ -1555,6 +1555,8 @@ export function sourceLabel(source?: string | null): string { return "Imported from a file"; case "sheet_sync": return "Synced from Google Sheets"; + case "crm_sync": + return "CRM sync"; case "api": return "Created via the API"; case "ai_assistant": @@ -1626,6 +1628,8 @@ function createdLabel(source?: string | null): string { return "Imported"; case "sheet_sync": return "Synced from sheet"; + case "crm_sync": + return "CRM sync"; case "api": return "Created via API"; case "campaign": diff --git a/web/src/components/app/contacts/contact-edit/OverviewTab.tsx b/web/src/components/app/contacts/contact-edit/OverviewTab.tsx index 906afa280..42d419eb5 100644 --- a/web/src/components/app/contacts/contact-edit/OverviewTab.tsx +++ b/web/src/components/app/contacts/contact-edit/OverviewTab.tsx @@ -3,6 +3,7 @@ // Composition: // - Suppression card (only when suppressed) // - HubSpot card (HubSpot mode): owner, lifecycle stage, lead status +// - Salesforce record (only when a Salesforce connection exists) // - Engagement: six flat stat tiles with a thin ratio bar where // a ratio over Sent makes sense // - Latest activity rail @@ -35,6 +36,7 @@ import OriginBadge from "@/components/app/engagement/OriginBadge"; import { labelInk } from "@/lib/utils"; import HubSpotContactCard from "@/components/app/crm/HubSpotContactCard"; import useCrmProvider from "@/hooks/useCrmProvider"; +import SalesforceContactCard from "@/components/app/integrations/SalesforceContactCard"; export default function OverviewTab({ contact, @@ -97,6 +99,7 @@ export default function OverviewTab({ )} {isHubSpot && } +
diff --git a/web/src/components/app/integrations/SalesforceContactCard.tsx b/web/src/components/app/integrations/SalesforceContactCard.tsx new file mode 100644 index 000000000..56f2b120c --- /dev/null +++ b/web/src/components/app/integrations/SalesforceContactCard.tsx @@ -0,0 +1,450 @@ +// The Salesforce side of a contact: the linked Lead or Contact, its owner, +// status, open opportunities and recent tasks, read live from Salesforce. +// Shown in the contact drawer and, compact, in the unibox contact rail. +// Renders nothing when the workspace has no Salesforce connection. + +import React from "react"; +import { Link } from "react-router-dom"; +import { + AlertTriangleIcon, + ExternalLinkIcon, + Loader2Icon, + PlusIcon, + RefreshCwIcon, + Settings2Icon, + UnlinkIcon, +} from "lucide-react"; +import toast from "react-hot-toast"; + +import { Logo } from "@/components/svg"; +import { SelectMenu } from "@/components/ui/select-menu"; +import { useConfirm } from "@/hooks/context/confirm"; +import { + useContactSalesforce, + useSyncContactSalesforce, + useUnlinkContactSalesforce, +} from "@/lib/api/hooks/app/integrations/useSalesforce"; +import type { + ContactSalesforcePanel, + ContactSalesforceRecord, +} from "@/lib/api/models/app/integrations/Salesforce"; +import { errorMessage } from "@/lib/errors/message"; +import { cn } from "@/lib/utils"; + +type Variant = "full" | "compact"; + +const NO_PERMISSION = "You need the integrations permission to change Salesforce records"; + +export default function SalesforceContactCard({ + contactId, + variant = "full", +}: { + contactId: string; + variant?: Variant; +}) { + const q = useContactSalesforce(contactId); + const status = (q.error as { status?: number } | null)?.status; + + // No connection, no access, or nothing to show: stay out of the way. + if (q.isPending) return null; + if (q.isError && (status === 403 || status === 404)) return null; + if (q.isError) { + return ( + +
+ + {errorMessage(q.error, "Could not reach Salesforce")} + +
+ + ); + } + const panel = q.data; + if (!panel || panel.connections.length === 0) return null; + + return ( + + {panel.records.length === 0 ? ( + + ) : ( +
+ {panel.records.map((r) => ( + 1} + variant={variant} + /> + ))} +
+ )} + + ); +} + +function Frame({ + variant, + refreshing, + children, +}: { + variant: Variant; + refreshing?: boolean; + children: React.ReactNode; +}) { + const spinner = refreshing && ; + if (variant === "compact") { + return ( +
+
+ Salesforce + {spinner} +
+ {children} +
+ ); + } + return ( +
+

+ Salesforce + {spinner} +

+ {children} +
+ ); +} + +function NotInSalesforce({ + contactId, + panel, + variant, +}: { + contactId: string; + panel: ContactSalesforcePanel; + variant: Variant; +}) { + const sync = useSyncContactSalesforce(contactId); + const [connectionId, setConnectionId] = React.useState(panel.connections[0]?.id ?? ""); + const [pending, setPending] = React.useState<"lead" | "contact" | null>(null); + + async function create(as: "lead" | "contact") { + setPending(as); + try { + const res = await sync.mutateAsync({ connection_id: connectionId || undefined, create_as: as }); + toast.success(res.records.length > 0 ? `Added to Salesforce as a ${as === "lead" ? "Lead" : "Contact"}` : "Synced"); + } catch (err) { + toast.error(errorMessage(err, "Could not add to Salesforce")); + } finally { + setPending(null); + } + } + + const btn = + "h-7 px-2 rounded-md border border-slate-200 bg-white hover:border-slate-300 text-[11.5px] text-slate-700 hover:text-slate-900 inline-flex items-center gap-1.5 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"; + + return ( +
+

Not in Salesforce yet

+

+ No Lead or Contact with this email. Activity is logged once the record exists. +

+ {panel.connections.length > 1 && ( +
+ ({ + value: c.id, + label: c.environment === "sandbox" ? `${c.label} (sandbox)` : c.label, + }))} + /> +
+ )} +
+ {(["lead", "contact"] as const).map((as) => ( + + ))} +
+
+ ); +} + +function RecordCard({ + contactId, + record: r, + canSync, + showConnection, + variant, +}: { + contactId: string; + record: ContactSalesforceRecord; + canSync: boolean; + showConnection: boolean; + variant: Variant; +}) { + const confirm = useConfirm(); + const sync = useSyncContactSalesforce(contactId); + const unlink = useUnlinkContactSalesforce(contactId); + const compact = variant === "compact"; + const openOpps = r.opportunities.filter((o) => !o.is_closed); + const opps = compact ? openOpps.slice(0, 2) : r.opportunities.slice(0, 5); + const tasks = r.tasks.slice(0, compact ? 2 : 5); + + function syncNow() { + sync.mutate( + { connection_id: r.connection_id }, + { + onSuccess: () => toast.success("Synced with Salesforce"), + onError: (err) => toast.error(errorMessage(err, "Sync failed")), + }, + ); + } + function askUnlink() { + confirm.show( + `Unlink ${r.name || "this record"} from this contact? Nothing is deleted in Salesforce. Warmbly stops logging activity to it until it is matched again.`, + async () => { + try { + await unlink.mutateAsync(r.link_id); + toast.success("Unlinked"); + } catch (err) { + toast.error(errorMessage(err, "Could not unlink")); + throw err; + } + }, + ); + } + + const actionBtn = + "h-6 px-1.5 rounded-md text-[11px] inline-flex items-center gap-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"; + + return ( +
+
+
+ + {r.object} + +
+
{r.name || r.email || "Unnamed"}
+ {r.title &&
{r.title}
} +
+ + {!compact && "Open in Salesforce"} + + +
+ + {(r.account || r.company) && ( +
+ {r.account ? ( + + {r.account.name} + + ) : ( + r.company + )} +
+ )} + +
+ {r.status && {r.status}} + {r.is_converted && Converted} + {r.opted_out && Opted out} + {r.stale && ( + + Stale + + )} + {showConnection && {r.connection_label}} + {r.owner && Owner {r.owner.name}} +
+ + {opps.length > 0 && ( + + )} + + {tasks.length > 0 && ( + + )} +
+ +
+ + {r.last_synced_at ? `Synced ${ago(r.last_synced_at)}` : "Not synced yet"} + {r.sync.pending > 0 && ` · ${r.sync.pending} pending`} + {r.sync.failed > 0 && · {r.sync.failed} failed} + + + + {!compact && ( + + + + )} +
+ {(r.sync.last_error || (r.stale && r.error)) && ( +
+ + {r.sync.last_error || r.error} +
+ )} +
+ ); +} + +function Sub({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function Tag({ + tone, + title, + children, +}: { + tone: "slate" | "emerald" | "rose" | "amber"; + title?: string; + children: React.ReactNode; +}) { + const cls = { + slate: "bg-slate-100 text-slate-600 border-slate-200", + emerald: "bg-emerald-50 text-emerald-700 border-emerald-100", + rose: "bg-rose-50 text-rose-700 border-rose-100", + amber: "bg-amber-50 text-amber-700 border-amber-200", + }[tone]; + return ( + + {children} + + ); +} + +function ago(d: string | Date): string { + const t = new Date(d).getTime(); + if (Number.isNaN(t)) return "a while ago"; + const sec = Math.max(0, Math.round((Date.now() - t) / 1000)); + if (sec < 45) return "just now"; + const min = Math.round(sec / 60); + if (min < 60) return `${min} min ago`; + const hr = Math.round(min / 60); + if (hr < 24) return `${hr} h ago`; + return `${Math.round(hr / 24)} d ago`; +} + +function shortDate(d: string): string { + // Salesforce dates are YYYY-MM-DD; parse as local so they do not shift a day. + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(d); + const dt = m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : new Date(d); + if (Number.isNaN(dt.getTime())) return d; + return dt.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +// The panel carries no currency code, so amounts show as plain numbers. +function money(n: number): string { + return n.toLocaleString(undefined, { maximumFractionDigits: 0 }); +} diff --git a/web/src/components/app/segments/SegmentPickers.tsx b/web/src/components/app/segments/SegmentPickers.tsx index f1f57c6b0..e3b7969d2 100644 --- a/web/src/components/app/segments/SegmentPickers.tsx +++ b/web/src/components/app/segments/SegmentPickers.tsx @@ -265,6 +265,7 @@ const ENUM_LABELS: Record = { campaign: "Added from a campaign", import: "Imported", sheet_sync: "Google Sheets sync", + crm_sync: "CRM sync", api: "API", ai_assistant: "AI assistant", form: "Form submission", diff --git a/web/src/components/app/unibox/ContactContextPanel.tsx b/web/src/components/app/unibox/ContactContextPanel.tsx index b319cb1b9..c4d1736e3 100644 --- a/web/src/components/app/unibox/ContactContextPanel.tsx +++ b/web/src/components/app/unibox/ContactContextPanel.tsx @@ -32,6 +32,7 @@ import { Link } from "react-router-dom"; import { TextInput } from "@/components/ui/field"; import NewMeetingDialog from "@/components/app/meetings/NewMeetingDialog"; import BookACallButton from "@/components/app/integrations/BookACallButton"; +import SalesforceContactCard from "@/components/app/integrations/SalesforceContactCard"; import { PopoverMenu, PopoverMenuContent, @@ -261,6 +262,8 @@ export default function ContactContextPanel({
)} + {/* The linked Salesforce record, when the workspace syncs with Salesforce. */} + {/* Campaigns, with pause / resume so a reply can hold follow-ups in place. */} diff --git a/web/src/hooks/useDocumentTitle.ts b/web/src/hooks/useDocumentTitle.ts index 27b775c45..425ec2ebe 100644 --- a/web/src/hooks/useDocumentTitle.ts +++ b/web/src/hooks/useDocumentTitle.ts @@ -101,6 +101,7 @@ const PARAM_ROUTES: ReadonlyArray = [ [/^\/app\/campaigns\/[^/]+\/steps$/, "Campaign steps"], [/^\/app\/campaigns\/[^/]+$/, "Campaign"], [/^\/app\/automations\/[^/]+$/, "Automation"], + [/^\/app\/integrations\/salesforce\/[^/]+$/, "Salesforce"], [/^\/app\/placement\/batches\/[^/]+$/, "Placement batch"], [/^\/app\/placement\/[^/]+$/, "Placement test"], [/^\/app\/forms\/[^/]+$/, "Form"], diff --git a/web/src/hooks/useRealtimeEvents.ts b/web/src/hooks/useRealtimeEvents.ts index 1dd6625a3..c2626b116 100644 --- a/web/src/hooks/useRealtimeEvents.ts +++ b/web/src/hooks/useRealtimeEvents.ts @@ -437,7 +437,7 @@ export function useRealtimeEvents() { ['integrations', 'slack', 'status'], ]) const connectionId = getString('connection_id') - if (connectionId) invalidate([['integrations', 'connection', connectionId]]) + if (connectionId) invalidate([['integrations', 'connection', connectionId], ['integrations', 'salesforce', connectionId]]) return } @@ -510,8 +510,8 @@ export function useRealtimeEvents() { team: [['teams']], role: [['organizations']], automation: [['automations']], - // Slack settings and member links are audited as integration writes too. - integration: [['integrations', 'connections'], ['integrations', 'slack'], ['crm', 'settings'], ['crm', 'owners']], + // Slack and Salesforce settings, member links and import sources are audited as integration writes too. + integration: [['integrations', 'connections'], ['integrations', 'slack'], ['integrations', 'salesforce'], ['crm', 'settings'], ['crm', 'owners']], lead_sync_source: [['lead-sync', 'sources']], meeting: [['meetings'], ['meetings', 'summary']], subscription: [['subscription'], ['organizations', 'limits']], diff --git a/web/src/lib/api/client/app/integrations/salesforce.ts b/web/src/lib/api/client/app/integrations/salesforce.ts new file mode 100644 index 000000000..0d7933883 --- /dev/null +++ b/web/src/lib/api/client/app/integrations/salesforce.ts @@ -0,0 +1,230 @@ +// Native Salesforce sync endpoints, all scoped to one connection id. + +import type { + ContactSalesforcePanel, + CreateSalesforceImportSourceInput, + SalesforceActivityPage, + SalesforceActivityStatus, + SalesforceCampaign, + SalesforceImportObject, + SalesforceImportPreview, + SalesforceImportSource, + SalesforceListView, + SalesforceMetadata, + SalesforceOverview, + SalesforceSettings, + SalesforceSettingsResponse, + SalesforceSourceKind, + SalesforceUser, + UpdateSalesforceImportSourceInput, +} from "@/lib/api/models/app/integrations/Salesforce"; +import Request from "../../Request"; + +const base = (id: string) => `/integrations/salesforce/${id}`; + +export async function getSalesforceOverview(id: string, checks = false): Promise { + return await Request({ + method: "GET", + url: `${base(id)}/overview`, + params: checks ? { checks: 1 } : undefined, + authorization: true, + }); +} + +export async function getSalesforceSettings(id: string): Promise { + return await Request({ + method: "GET", + url: `${base(id)}/settings`, + authorization: true, + }); +} + +export async function updateSalesforceSettings(input: { + connectionId: string; + settings: SalesforceSettings; +}): Promise<{ settings: SalesforceSettings }> { + return await Request<{ settings: SalesforceSettings }>({ + method: "PUT", + url: `${base(input.connectionId)}/settings`, + data: input.settings, + authorization: true, + }); +} + +export async function getSalesforceMetadata(id: string): Promise { + return await Request({ + method: "GET", + url: `${base(id)}/metadata`, + authorization: true, + }); +} + +export async function searchSalesforceUsers(id: string, q: string): Promise { + const body = await Request<{ data: SalesforceUser[] }>({ + method: "GET", + url: `${base(id)}/users`, + params: { q }, + authorization: true, + }); + return body?.data ?? []; +} + +export async function listSalesforceListViews(id: string, object: "Lead" | "Contact"): Promise { + const body = await Request<{ data: SalesforceListView[] }>({ + method: "GET", + url: `${base(id)}/list-views`, + params: { object }, + authorization: true, + }); + return body?.data ?? []; +} + +export async function searchSalesforceCampaigns(id: string, q: string): Promise { + const body = await Request<{ data: SalesforceCampaign[] }>({ + method: "GET", + url: `${base(id)}/campaigns`, + params: { q }, + authorization: true, + }); + return body?.data ?? []; +} + +export async function previewSalesforceImport(input: { + connectionId: string; + source_kind: SalesforceSourceKind; + object: SalesforceImportObject; + source_id: string; +}): Promise { + const { connectionId, ...body } = input; + return await Request({ + method: "POST", + url: `${base(connectionId)}/import/preview`, + data: body, + authorization: true, + }); +} + +export async function listSalesforceImportSources(id: string): Promise { + const body = await Request<{ data: SalesforceImportSource[] }>({ + method: "GET", + url: `${base(id)}/import-sources`, + authorization: true, + }); + return body?.data ?? []; +} + +export async function createSalesforceImportSource(input: { + connectionId: string; + body: CreateSalesforceImportSourceInput; +}): Promise { + return await Request({ + method: "POST", + url: `${base(input.connectionId)}/import-sources`, + data: input.body, + authorization: true, + }); +} + +export async function updateSalesforceImportSource(input: { + connectionId: string; + sourceId: string; + body: UpdateSalesforceImportSourceInput; +}): Promise { + return await Request({ + method: "PATCH", + url: `${base(input.connectionId)}/import-sources/${input.sourceId}`, + data: input.body, + authorization: true, + }); +} + +export async function runSalesforceImportSource(input: { + connectionId: string; + sourceId: string; +}): Promise { + return await Request({ + method: "POST", + url: `${base(input.connectionId)}/import-sources/${input.sourceId}/run`, + data: {}, + authorization: true, + }); +} + +export async function deleteSalesforceImportSource(input: { connectionId: string; sourceId: string }): Promise { + await Request({ + method: "DELETE", + url: `${base(input.connectionId)}/import-sources/${input.sourceId}`, + authorization: true, + }); +} + +export async function listSalesforceActivity(input: { + connectionId: string; + status?: SalesforceActivityStatus | ""; + contactId?: string; + cursor?: string | null; + limit?: number; +}): Promise { + const params: Record = {}; + if (input.status) params.status = input.status; + if (input.contactId) params.contact_id = input.contactId; + if (input.cursor) params.cursor = input.cursor; + if (input.limit) params.limit = input.limit; + return await Request({ + method: "GET", + url: `${base(input.connectionId)}/activity`, + params, + authorization: true, + }); +} + +export async function retrySalesforceActivity(input: { + connectionId: string; + ids?: string[]; +}): Promise<{ requeued: number }> { + return await Request<{ requeued: number }>({ + method: "POST", + url: `${base(input.connectionId)}/activity/retry`, + data: input.ids && input.ids.length > 0 ? { ids: input.ids } : {}, + authorization: true, + }); +} + +export async function salesforceSyncNow(id: string): Promise<{ ok: boolean }> { + return await Request<{ ok: boolean }>({ + method: "POST", + url: `${base(id)}/sync-now`, + data: {}, + authorization: true, + }); +} + +export async function getContactSalesforce(contactId: string): Promise { + return await Request({ + method: "GET", + url: `/contacts/${contactId}/salesforce`, + authorization: true, + }); +} + +export async function syncContactSalesforce(input: { + contactId: string; + connection_id?: string; + create_as?: "lead" | "contact"; +}): Promise { + const { contactId, ...body } = input; + return await Request({ + method: "POST", + url: `/contacts/${contactId}/salesforce/sync`, + data: body, + authorization: true, + }); +} + +export async function unlinkContactSalesforce(input: { contactId: string; linkId: string }): Promise { + await Request({ + method: "DELETE", + url: `/contacts/${input.contactId}/salesforce/links/${input.linkId}`, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/integrations/startIntegrationOAuth.ts b/web/src/lib/api/client/app/integrations/startIntegrationOAuth.ts index 3e3ed3552..c58ef840f 100644 --- a/web/src/lib/api/client/app/integrations/startIntegrationOAuth.ts +++ b/web/src/lib/api/client/app/integrations/startIntegrationOAuth.ts @@ -3,9 +3,12 @@ import Request from "../../Request"; // Starts an OAuth handshake. Returns the provider authorization URL the SPA // opens in a popup; the backend mints + stores the CSRF state / PKCE verifier. +// Salesforce also takes the org's environment and, for a My Domain login, its host. export default async function startIntegrationOAuth(input: { provider: string; label?: string; + environment?: "production" | "sandbox"; + domain?: string; }): Promise { return await Request({ method: "POST", diff --git a/web/src/lib/api/hooks/app/integrations/useSalesforce.ts b/web/src/lib/api/hooks/app/integrations/useSalesforce.ts new file mode 100644 index 000000000..39a138f3b --- /dev/null +++ b/web/src/lib/api/hooks/app/integrations/useSalesforce.ts @@ -0,0 +1,253 @@ +import { + keepPreviousData, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, + type InfiniteData, +} from "@tanstack/react-query"; +import { + createSalesforceImportSource, + deleteSalesforceImportSource, + getContactSalesforce, + getSalesforceMetadata, + getSalesforceOverview, + getSalesforceSettings, + listSalesforceActivity, + listSalesforceImportSources, + listSalesforceListViews, + previewSalesforceImport, + retrySalesforceActivity, + runSalesforceImportSource, + salesforceSyncNow, + searchSalesforceCampaigns, + searchSalesforceUsers, + syncContactSalesforce, + unlinkContactSalesforce, + updateSalesforceImportSource, + updateSalesforceSettings, +} from "@/lib/api/client/app/integrations/salesforce"; +import type { + ContactSalesforcePanel, + SalesforceActivityPage, + SalesforceActivityStatus, +} from "@/lib/api/models/app/integrations/Salesforce"; + +// Everything the realtime spine refreshes for entity_type "integration". +export const SALESFORCE_KEY = ["integrations", "salesforce"] as const; +// Lookups that each cost Salesforce API calls live outside that prefix, so a +// teammate's settings save does not re-describe the org for everyone. +const LOOKUP_KEY = "salesforce-lookup"; + +const sfKey = (id: string, ...rest: unknown[]) => [...SALESFORCE_KEY, id, ...rest]; + +export function contactSalesforceKey(contactId: string) { + return ["contacts", contactId, "salesforce"]; +} + +export function useSalesforceOverview(id: string) { + return useQuery({ + queryKey: sfKey(id, "overview"), + queryFn: () => getSalesforceOverview(id), + enabled: !!id, + staleTime: 15_000, + }); +} + +// Runs the permission check pass and folds the result into the overview cache. +export function useSalesforcePermissionCheck(id: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => getSalesforceOverview(id, true), + onSuccess: (data) => qc.setQueryData(sfKey(id, "overview"), data), + }); +} + +export function useSalesforceSettings(id: string) { + return useQuery({ + queryKey: sfKey(id, "settings"), + queryFn: () => getSalesforceSettings(id), + enabled: !!id, + staleTime: 30_000, + }); +} + +export function useUpdateSalesforceSettings(id: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: updateSalesforceSettings, + onSuccess: (res) => { + qc.setQueryData(sfKey(id, "settings"), (prev: unknown) => + prev && typeof prev === "object" ? { ...(prev as object), settings: res.settings } : prev, + ); + qc.invalidateQueries({ queryKey: sfKey(id, "overview") }); + }, + }); +} + +export function useSalesforceMetadata(id: string) { + return useQuery({ + queryKey: [LOOKUP_KEY, id, "metadata"], + queryFn: () => getSalesforceMetadata(id), + enabled: !!id, + staleTime: 10 * 60 * 1000, + }); +} + +export function useSalesforceUsers(id: string, q: string, enabled = true) { + return useQuery({ + queryKey: [LOOKUP_KEY, id, "users", q], + queryFn: () => searchSalesforceUsers(id, q), + enabled: !!id && enabled, + staleTime: 60_000, + placeholderData: keepPreviousData, + }); +} + +export function useSalesforceListViews(id: string, object: "Lead" | "Contact", enabled = true) { + return useQuery({ + queryKey: [LOOKUP_KEY, id, "list-views", object], + queryFn: () => listSalesforceListViews(id, object), + enabled: !!id && enabled, + staleTime: 5 * 60 * 1000, + }); +} + +export function useSalesforceCampaigns(id: string, q: string, enabled = true) { + return useQuery({ + queryKey: [LOOKUP_KEY, id, "campaigns", q], + queryFn: () => searchSalesforceCampaigns(id, q), + enabled: !!id && enabled, + staleTime: 60_000, + placeholderData: keepPreviousData, + }); +} + +export function useSalesforceImportPreview() { + return useMutation({ mutationFn: previewSalesforceImport }); +} + +// No realtime event marks a run finishing, so the list polls only while one runs. +export function useSalesforceImportSources(id: string) { + return useQuery({ + queryKey: sfKey(id, "import-sources"), + queryFn: () => listSalesforceImportSources(id), + enabled: !!id, + staleTime: 10_000, + refetchInterval: (q) => ((q.state.data ?? []).some((s) => s.status === "running") ? 3000 : false), + }); +} + +function useInvalidateSources(id: string) { + const qc = useQueryClient(); + return () => { + qc.invalidateQueries({ queryKey: sfKey(id, "import-sources") }); + qc.invalidateQueries({ queryKey: sfKey(id, "overview") }); + }; +} + +export function useCreateSalesforceImportSource(id: string) { + const refresh = useInvalidateSources(id); + const qc = useQueryClient(); + return useMutation({ + mutationFn: createSalesforceImportSource, + onSuccess: () => { + refresh(); + qc.invalidateQueries({ queryKey: ["contacts"] }); + }, + }); +} + +export function useUpdateSalesforceImportSource(id: string) { + const refresh = useInvalidateSources(id); + return useMutation({ mutationFn: updateSalesforceImportSource, onSuccess: refresh }); +} + +export function useRunSalesforceImportSource(id: string) { + const refresh = useInvalidateSources(id); + return useMutation({ mutationFn: runSalesforceImportSource, onSuccess: refresh }); +} + +export function useDeleteSalesforceImportSource(id: string) { + const refresh = useInvalidateSources(id); + return useMutation({ mutationFn: deleteSalesforceImportSource, onSuccess: refresh }); +} + +export function useSalesforceActivity(id: string, status: SalesforceActivityStatus | "", limit = 50) { + const query = useInfiniteQuery< + SalesforceActivityPage, + Error, + InfiniteData, + unknown[], + string | null + >({ + queryKey: sfKey(id, "activity", status, limit), + queryFn: ({ pageParam }) => listSalesforceActivity({ connectionId: id, status, cursor: pageParam, limit }), + initialPageParam: null, + getNextPageParam: (last) => (last.pagination?.has_more ? (last.pagination.next_cursor ?? undefined) : undefined), + placeholderData: keepPreviousData, + enabled: !!id, + staleTime: 15_000, + }); + const rows = query.data?.pages.flatMap((p) => p.data ?? []) ?? []; + return { ...query, rows }; +} + +export function useRetrySalesforceActivity(id: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: retrySalesforceActivity, + onSuccess: () => { + qc.invalidateQueries({ queryKey: sfKey(id, "activity") }); + qc.invalidateQueries({ queryKey: sfKey(id, "overview") }); + }, + }); +} + +export function useSalesforceSyncNow(id: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => salesforceSyncNow(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: [...SALESFORCE_KEY, id] }); + qc.invalidateQueries({ queryKey: ["integrations", "connection", id] }); + }, + }); +} + +// The contact drawer's Salesforce panel. Keyed under ["contacts", id] so a +// contact change refreshes it; each fetch reads Salesforce live, so it is +// cached for a minute rather than refetched on every focus. +export function useContactSalesforce(contactId: string | undefined, enabled = true) { + return useQuery({ + queryKey: contactSalesforceKey(contactId ?? ""), + queryFn: () => getContactSalesforce(contactId as string), + enabled: !!contactId && enabled, + staleTime: 60_000, + refetchOnWindowFocus: false, + retry: false, + }); +} + +export function useSyncContactSalesforce(contactId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { connection_id?: string; create_as?: "lead" | "contact" }) => + syncContactSalesforce({ contactId, ...input }), + onSuccess: (panel: ContactSalesforcePanel) => { + qc.setQueryData(contactSalesforceKey(contactId), panel); + qc.invalidateQueries({ queryKey: SALESFORCE_KEY }); + }, + }); +} + +export function useUnlinkContactSalesforce(contactId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (linkId: string) => unlinkContactSalesforce({ contactId, linkId }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: contactSalesforceKey(contactId) }); + qc.invalidateQueries({ queryKey: SALESFORCE_KEY }); + }, + }); +} diff --git a/web/src/lib/api/models/app/automations/meta.ts b/web/src/lib/api/models/app/automations/meta.ts index 5f8676915..f633b7bef 100644 --- a/web/src/lib/api/models/app/automations/meta.ts +++ b/web/src/lib/api/models/app/automations/meta.ts @@ -40,7 +40,7 @@ export const ACTION_LABELS: Record = { "discord.notify": "Send a Discord message", "hubspot.upsert_contact": "Create / update HubSpot contact", "pipedrive.upsert_person": "Create / update Pipedrive person", - "salesforce.upsert_contact": "Create / update Salesforce contact", + "salesforce.upsert_contact": "Create / update Salesforce record", "close.upsert_lead": "Create / update Close lead", "webhook.ping": "Send a webhook", // Native (Warmbly built-in) actions — no external connection needed. diff --git a/web/src/lib/api/models/app/contacts/Contact.ts b/web/src/lib/api/models/app/contacts/Contact.ts index d1e451ff4..c51cc85c2 100644 --- a/web/src/lib/api/models/app/contacts/Contact.ts +++ b/web/src/lib/api/models/app/contacts/Contact.ts @@ -21,9 +21,10 @@ export type LeadStatus = | "undeliverable"; // One contact's flow parked inside one campaign. source is "out_of_office" -// when an auto-reply parked it, "manual" when a member did, "crm" when HubSpot -// says the contact moved on (a deal, a lifecycle stage), and "cc" while the -// contact is copied on another lead's emails (reason is that lead's address); +// when an auto-reply parked it, "manual" when a member did, "crm" when the +// connected CRM says the contact moved on (a HubSpot deal or lifecycle stage, a +// Salesforce rule), and "cc" while the contact is copied on another lead's +// emails (reason is that lead's address); // `until` absent means the hold has no end and only a resume lifts it. export interface LeadHold { since: Date; @@ -62,14 +63,14 @@ export function holdSummary(hold: LeadHold): string { ? `Copied on the emails to ${hold.reason} · none of their own are sent` : "Copied on another lead's emails · none of their own are sent"; } - // A HubSpot hold's reason already names HubSpot ("A deal was opened in HubSpot: Acme"). + // A CRM hold's reason already names the CRM ("Salesforce: Lead Status is Qualified"). const what = hold.source === "out_of_office" ? "Out of office" : hold.source === "crm" ? hold.reason ? "Held" - : "Held by HubSpot" + : "Held by your CRM" : "Paused"; const why = hold.reason ? ` · ${hold.reason}` : ""; if (!hold.until) return `${what}${why} · until someone resumes it`; diff --git a/web/src/lib/api/models/app/contacts/ContactDetail.ts b/web/src/lib/api/models/app/contacts/ContactDetail.ts index 03320a7cd..3728c8105 100644 --- a/web/src/lib/api/models/app/contacts/ContactDetail.ts +++ b/web/src/lib/api/models/app/contacts/ContactDetail.ts @@ -50,6 +50,7 @@ export type ContactSource = | "campaign" | "import" | "sheet_sync" + | "crm_sync" | "api" | "ai_assistant" | "form" diff --git a/web/src/lib/api/models/app/integrations/Salesforce.ts b/web/src/lib/api/models/app/integrations/Salesforce.ts new file mode 100644 index 000000000..4686b49ab --- /dev/null +++ b/web/src/lib/api/models/app/integrations/Salesforce.ts @@ -0,0 +1,358 @@ +// Mirror of the backend's native Salesforce sync shapes (models/salesforce.go +// and the salesforce settings document), plus the labels the dashboard shows. + +export type SalesforceObject = "Lead" | "Contact"; +export type SalesforceEnvironment = "production" | "sandbox"; + +export type SalesforceCreateWhen = "never" | "reply" | "send"; +export type SalesforceOwnerMode = "connected_user" | "sender" | "fixed"; +export type SalesforceAssignTo = "record_owner" | "sender" | "connected_user"; +export type SalesforceOptOutSync = "both" | "to_salesforce" | "from_salesforce" | "off"; +export type SalesforceFieldDirection = "push" | "pull" | "both"; +export type SalesforceFieldPolicy = "overwrite" | "if_empty"; + +export interface SalesforceFieldMapRow { + object: SalesforceObject; + warmbly: string; + salesforce: string; + direction: SalesforceFieldDirection; + policy: SalesforceFieldPolicy; +} + +export interface SalesforceSettings { + enabled: boolean; + matching: { + prefer: "contact" | "lead"; + create_when: SalesforceCreateWhen; + create_as: "lead" | "contact"; + lead_source: string; + lead_status: string; + owner: SalesforceOwnerMode; + owner_id?: string; + run_assignment_rules: boolean; + }; + activity: { + sent: boolean; + replied: boolean; + opened: boolean; + clicked: boolean; + bounced: boolean; + unsubscribed: boolean; + meeting_booked: boolean; + include_body: boolean; + assign_to: SalesforceAssignTo; + relate_to_opportunity: boolean; + }; + writeback: { + lead_status_on_sent: string; + lead_status_on_reply: Record; + lead_status_on_meeting: string; + never_move_backwards: boolean; + }; + inbound: { + opt_out: SalesforceOptOutSync; + pause_on_converted: boolean; + pause_on_statuses: string[]; + pause_on_open_opportunity: boolean; + }; + field_map: SalesforceFieldMapRow[]; + daily_api_budget: number; +} + +export interface SalesforceWarmblyField { + key: string; + label: string; +} + +export interface SalesforceSettingsResponse { + settings: SalesforceSettings; + warmbly_fields: SalesforceWarmblyField[]; + // The server's defaults, for "Reset to defaults". + defaults: SalesforceSettings; +} + +export interface SalesforcePicklistValue { + value: string; + label: string; +} + +export interface SalesforceFieldInfo { + name: string; + label: string; + type: string; + createable: boolean; + updateable: boolean; + calculated: boolean; + custom: boolean; + picklist?: SalesforcePicklistValue[]; +} + +export interface SalesforceMetadata { + lead_fields: SalesforceFieldInfo[]; + contact_fields: SalesforceFieldInfo[]; + lead_statuses: SalesforcePicklistValue[]; + lead_sources: SalesforcePicklistValue[]; +} + +export interface SalesforceCheck { + key: string; + label: string; + ok: boolean; + detail?: string; +} + +export interface SalesforceOverview { + connection_id: string; + label: string; + status: string; + health: string; + health_detail?: string; + org: { + id?: string; + instance_url: string; + environment: SalesforceEnvironment; + login_host: string; + user_id?: string; + account?: string; + }; + api: { used: number; max: number; calls_today: number; budget: number }; + counts: { pending: number; synced_24h: number; failed: number; skipped_24h: number; linked_records: number }; + last_pull_at?: string | Date; + last_pull_error?: string; + settings_enabled: boolean; + checks?: SalesforceCheck[]; +} + +export interface SalesforceUser { + id: string; + name: string; + email: string; +} + +export interface SalesforceListView { + id: string; + label: string; + object: string; +} + +export interface SalesforceCampaign { + id: string; + name: string; + status: string; + type: string; + member_count: number; +} + +export type SalesforceSourceKind = "list_view" | "campaign"; +export type SalesforceImportObject = "Lead" | "Contact" | "CampaignMember"; + +export interface SalesforceImportPreviewRow { + record_id: string; + object: string; + name: string; + email: string; + company: string; + title: string; + owner_name: string; + status: string; + already_linked: boolean; +} + +export interface SalesforceImportPreview { + total: number; + sample: SalesforceImportPreviewRow[]; +} + +export interface SalesforceRunResult { + read: number; + imported: number; + updated: number; + linked: number; + skipped: number; + failed: number; + no_email: number; + // Email Opt Out set in Salesforce: suppressed instead of imported. + opted_out?: number; + truncated?: boolean; +} + +export interface SalesforceImportSource { + id: string; + connection_id: string; + name: string; + source_kind: SalesforceSourceKind; + object: SalesforceImportObject; + source_id: string; + source_label: string; + campaign_id?: string | null; + category_ids: string[] | null; + recurring: boolean; + enabled: boolean; + status: "idle" | "running" | "error"; + last_run_at?: string | Date | null; + last_result?: SalesforceRunResult | null; + last_error?: string; + total_imported: number; + created_at: string | Date; + updated_at: string | Date; +} + +export interface CreateSalesforceImportSourceInput { + name?: string; + source_kind: SalesforceSourceKind; + object: SalesforceImportObject; + source_id: string; + source_label: string; + campaign_id?: string; + category_ids?: string[]; + recurring: boolean; +} + +export interface UpdateSalesforceImportSourceInput { + name?: string; + campaign_id?: string | null; + category_ids?: string[]; + recurring?: boolean; + enabled?: boolean; +} + +export type SalesforceActivityKind = + | "sent" + | "opened" + | "clicked" + | "replied" + | "bounced" + | "unsubscribed" + | "meeting_booked"; + +export type SalesforceActivityStatus = "pending" | "synced" | "skipped" | "failed"; + +export interface SalesforceActivity { + id: string; + connection_id: string; + contact_id?: string; + contact_email: string; + kind: SalesforceActivityKind; + payload?: Record; + status: SalesforceActivityStatus; + attempts: number; + next_attempt_at: string | Date; + record_id?: string; + task_id?: string; + detail?: string; + occurred_at: string | Date; + created_at: string | Date; + processed_at?: string | Date | null; +} + +export interface SalesforceActivityPage { + data: SalesforceActivity[]; + pagination: { next_cursor?: string | null; has_more: boolean }; +} + +export interface ContactSalesforceRecord { + link_id: string; + connection_id: string; + connection_label: string; + object: SalesforceObject; + id: string; + url: string; + name: string; + title?: string; + company?: string; + email?: string; + phone?: string; + status?: string; + owner?: { id: string; name: string }; + account?: { id: string; name: string; url: string }; + is_converted: boolean; + opted_out: boolean; + lead_source?: string; + opportunities: { + id: string; + name: string; + stage: string; + amount?: number; + close_date?: string; + is_closed: boolean; + is_won: boolean; + url: string; + }[]; + tasks: { + id: string; + subject: string; + date?: string; + status: string; + owner_name?: string; + url: string; + from_warmbly: boolean; + }[]; + linked_by: "match" | "created" | "import" | "manual"; + last_synced_at?: string | Date; + last_pushed_at?: string | Date; + sync: { pending: number; failed: number; synced: number; last_error?: string }; + stale: boolean; + error?: string; +} + +export interface ContactSalesforcePanel { + connections: { id: string; label: string; environment: string; instance_url: string }[]; + records: ContactSalesforceRecord[]; + can_sync: boolean; +} + +// --- presentation ----------------------------------------------------------- + +export const SALESFORCE_ACTIVITY_LABELS: Record = { + sent: "Email sent", + replied: "Reply", + opened: "Opened", + clicked: "Clicked", + bounced: "Bounced", + unsubscribed: "Unsubscribed", + meeting_booked: "Meeting booked", +}; + +// Order the activity toggles are listed in Sync rules. +export const SALESFORCE_ACTIVITY_KINDS: SalesforceActivityKind[] = [ + "sent", + "replied", + "meeting_booked", + "bounced", + "unsubscribed", + "opened", + "clicked", +]; + +// Reply intents a lead status can be written for. "any" is the fallback. +export const SALESFORCE_REPLY_INTENTS: { value: string; label: string }[] = [ + { value: "any", label: "Any reply" }, + { value: "positive", label: "Positive" }, + { value: "question", label: "Question" }, + { value: "neutral", label: "Neutral" }, + { value: "negative", label: "Negative" }, + { value: "out_of_office", label: "Out of office" }, +]; + +export const SALESFORCE_DIRECTION_LABELS: Record = { + push: "Warmbly → Salesforce", + pull: "Salesforce → Warmbly", + both: "Two-way", +}; + +export const SALESFORCE_POLICY_LABELS: Record = { + overwrite: "Always overwrite", + if_empty: "Only fill blanks", +}; + +export const SALESFORCE_IMPORT_OBJECT_LABELS: Record = { + Lead: "Leads", + Contact: "Contacts", + CampaignMember: "Campaign members", +}; + +// The canonical Salesforce link for a record or task id on an instance. +export function salesforceRecordURL(instanceURL: string | undefined, id: string | undefined): string | null { + if (!instanceURL || !id) return null; + return `${instanceURL.replace(/\/+$/, "")}/${id}`; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 8ed127278..b64a9dcb9 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -44,6 +44,7 @@ import OAuthLayout from './app/oauth/layout'; import OAuthConsentPage from './app/oauth/authorize/page'; import IntegrationsPage from './app/app/integrations/page'; import HubSpotPage from './app/app/integrations/hubspot/page'; +import SalesforcePage from './app/app/integrations/salesforce/[id]/page'; import AutomationsPage from './app/app/automations/page'; import AutomationBuilderPage from './app/app/automations/[id]/page'; import AuditPage from './app/app/audit/page'; @@ -388,6 +389,10 @@ const router = createBrowserRouter([ path: "integrations/hubspot", element: , }, + { + path: "integrations/salesforce/:id", + element: , + }, { path: "automations", element: , From d112657cf29d8ed6f4b6e4df1551f63bf0cd1589 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sun, 4 Oct 2026 09:10:37 +0200 Subject: [PATCH 2/2] feat: wire suppression, subscription and hold dependencies into the consumer's Salesforce service, give Salesforce token refreshes their own single-flight keyspace, release the outbox lease in memory after each terminal write so writeback notes land and only on rows with a logged Task, drop stale Salesforce import previews, keep keys from the import row menu from opening the edit dialog, and document the Salesforce callback URL and OAuth variables --- cmd/consumer/main.go | 3 +++ docs/content/docs/development/configuration.mdx | 1 + docs/content/docs/guides/salesforce.mdx | 2 +- internal/app/integration/service.go | 3 ++- internal/app/salesforce/drain.go | 7 ++++++- .../integrations/salesforce/_components/ImportDialog.tsx | 8 ++++++-- .../app/integrations/salesforce/_components/ImportTab.tsx | 7 ++++++- 7 files changed, 25 insertions(+), 6 deletions(-) diff --git a/cmd/consumer/main.go b/cmd/consumer/main.go index 17bfbaefe..1aaa5b232 100644 --- a/cmd/consumer/main.go +++ b/cmd/consumer/main.go @@ -248,6 +248,9 @@ func main() { Repo: repository.NewSalesforceRepository(primaryDB.Pool), Integrations: integrationServiceC, Cipher: cipherService, + Holds: campaignProgressRepo, + Suppression: advancedRepo, + Subscription: contactRepo, }) integrationServiceC.SetSalesforce(salesforceC) webhookService.WireRecordSink(salesforceC.Recorder().Record) diff --git a/docs/content/docs/development/configuration.mdx b/docs/content/docs/development/configuration.mdx index 0eded7db5..0dfc9d2fc 100644 --- a/docs/content/docs/development/configuration.mdx +++ b/docs/content/docs/development/configuration.mdx @@ -508,6 +508,7 @@ Application permissions reach every mailbox in a consenting organization. We rec |---|---|---| | `_OAUTH_CLIENT_ID`, `_OAUTH_CLIENT_SECRET` | OAuth clients for the CRM and messaging integrations | unset | | `HUBSPOT_OAUTH_CLIENT_ID`, `HUBSPOT_OAUTH_CLIENT_SECRET` | Your HubSpot app. Needed on the backend and on every consumer: the consumer runs the [HubSpot mode](/guides/hubspot/) pull and sync queue and refreshes the token as it goes. The secret also verifies HubSpot's webhook signatures | unset | +| `SALESFORCE_OAUTH_CLIENT_ID`, `SALESFORCE_OAUTH_CLIENT_SECRET` | Your Salesforce app (see [Salesforce self-hosting](/guides/salesforce/#self-hosting)). Needed on the backend, which runs the sync, and on every consumer, which runs Salesforce automation actions and refreshes the token for them | unset | | `INTEGRATIONS_OAUTH_REDIRECT_URL` | Shared redirect URI for those flows | `BACKEND_PUBLIC_URL` (else `API_PUBLIC_URL`) plus `/integrations/oauth/callback` | | `SLACK_OAUTH_CLIENT_ID`, `SLACK_OAUTH_CLIENT_SECRET` | The instance's own [Slack app](/development/slack-app/). Unset, the dashboard reports Slack as not set up and no workspace can connect it | unset | | `SLACK_SIGNING_SECRET` | Verifies every request Slack sends to the events, interactivity and slash command URLs. Without it Slack still posts notifications and inbox conversations, but the assistant, buttons and `/warmbly` are off, and those URLs answer `503` `slack_not_configured` | unset | diff --git a/docs/content/docs/guides/salesforce.mdx b/docs/content/docs/guides/salesforce.mdx index 706b2baea..1a7c51bda 100644 --- a/docs/content/docs/guides/salesforce.mdx +++ b/docs/content/docs/guides/salesforce.mdx @@ -147,7 +147,7 @@ A self-hosted instance needs its own Salesforce app. Which kind depends on how m Whichever you create, configure it with: -- callback URL `/integrations/oauth/callback`, or your `INTEGRATIONS_OAUTH_REDIRECT_URL`. It must match exactly, including the scheme and no trailing slash; +- callback URL: the value of `INTEGRATIONS_OAUTH_REDIRECT_URL`, or when that is unset, `BACKEND_PUBLIC_URL` (else `API_PUBLIC_URL`) plus `/integrations/oauth/callback`. It must match exactly, including the scheme and no trailing slash; - OAuth scopes **Manage user data via APIs (api)**, **Perform requests at any time (refresh_token, offline_access)** and **Access the identity URL service (id, profile, email, address, phone)**; - **Require PKCE** on, and the **Web Server Flow** enabled with its client secret required; - refresh tokens **valid until revoked**. diff --git a/internal/app/integration/service.go b/internal/app/integration/service.go index 3f812adde..72df976ca 100644 --- a/internal/app/integration/service.go +++ b/internal/app/integration/service.go @@ -1262,7 +1262,8 @@ func (s *service) ProviderAccess(ctx context.Context, orgID, connID uuid.UUID, f } // Concurrent callers share one refresh: with refresh-token rotation, two // parallel refreshes would leave one holding a dead token. - key := connID.String() + // Its own keyspace: AccessToken shares the group with a different result type. + key := "provider:" + connID.String() if force { key += ":force" } diff --git a/internal/app/salesforce/drain.go b/internal/app/salesforce/drain.go index fe64d4b19..8aad09042 100644 --- a/internal/app/salesforce/drain.go +++ b/internal/app/salesforce/drain.go @@ -627,7 +627,9 @@ func (s *Service) writeback(ctx context.Context, c *conn, work []*pending, links if e := errs[p.contact.ID]; e != nil { if p.done { // The Task landed; the record update did not. Say so on the row. - s.annotate(ctx, p, "Task logged, but updating the record failed: "+e.Error()) + if p.a.TaskID != "" { + s.annotate(ctx, p, "Task logged, but updating the record failed: "+describeErr(e)) + } continue } s.fail(ctx, p, e) @@ -649,6 +651,8 @@ func (s *Service) finish(ctx context.Context, p *pending, status, taskID, detail } p.a.Attempts++ _ = s.Repo.FinishActivity(ctx, &p.a) + // FinishActivity released the lease; a later note on this row targets it unleased. + p.a.LeaseID = nil } func (s *Service) annotate(ctx context.Context, p *pending, detail string) { @@ -687,6 +691,7 @@ func (s *Service) fail(ctx context.Context, p *pending, err error) { p.a.Status = models.SalesforceActivityFailed } _ = s.Repo.FinishActivity(ctx, &p.a) + p.a.LeaseID = nil } func backoff(attempt int) time.Duration { diff --git a/web/src/app/app/integrations/salesforce/_components/ImportDialog.tsx b/web/src/app/app/integrations/salesforce/_components/ImportDialog.tsx index 2402e2813..d98142aba 100644 --- a/web/src/app/app/integrations/salesforce/_components/ImportDialog.tsx +++ b/web/src/app/app/integrations/salesforce/_components/ImportDialog.tsx @@ -109,13 +109,17 @@ export default function ImportDialog({ connectionId, onClose }: { connectionId: return () => document.removeEventListener("keydown", onKey); }, [requestClose]); + // The selection the newest preview request was for; an older answer is dropped. + const latestPreview = React.useRef(""); async function loadPreview() { const parts = kindParts(kind); - setPreviewFor(selectionKey); + const key = selectionKey; + latestPreview.current = key; + setPreviewFor(key); setPreview(null); try { const p = await previewM.mutateAsync({ connectionId, ...parts, source_id: sourceId }); - setPreview(p); + if (latestPreview.current === key) setPreview(p); } catch { // Rendered in the step from previewM.error. } diff --git a/web/src/app/app/integrations/salesforce/_components/ImportTab.tsx b/web/src/app/app/integrations/salesforce/_components/ImportTab.tsx index 455bf9588..3ff872cbe 100644 --- a/web/src/app/app/integrations/salesforce/_components/ImportTab.tsx +++ b/web/src/app/app/integrations/salesforce/_components/ImportTab.tsx @@ -174,7 +174,12 @@ function SourceRow({ tabIndex={0} onClick={onEdit} onKeyDown={(e) => { - if (e.key === "Enter") onEdit(); + // Only the row itself: keys from its menu bubble here through the portal. + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onEdit(); + } }} className="px-4 py-3 flex items-start gap-3 hover:bg-slate-50/60 transition-colors cursor-pointer outline-none focus-visible:bg-slate-50" >