Skip to content

Commit f82c3cb

Browse files
Merge pull request #36 from ctrlplanedev/init-salesforce-sync
feat: Init salesforce sync
2 parents 63e2ac1 + 3226b30 commit f82c3cb

File tree

10 files changed

+944
-22
lines changed

10 files changed

+944
-22
lines changed

Makefile

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,22 @@ LDFLAGS = -X github.com/ctrlplanedev/cli/cmd/ctrlc/root/version.Version=$(VERSIO
55
-X github.com/ctrlplanedev/cli/cmd/ctrlc/root/version.GitCommit=$(COMMIT) \
66
-X github.com/ctrlplanedev/cli/cmd/ctrlc/root/version.BuildDate=$(DATE)
77

8-
.PHONY: build
98
build:
109
go build -ldflags "$(LDFLAGS)" -o bin/ctrlc ./cmd/ctrlc
1110

12-
.PHONY: install
1311
install:
1412
go install -ldflags "$(LDFLAGS)" ./cmd/ctrlc
1513

16-
.PHONY: test
1714
test:
1815
go test -v ./...
1916

20-
.PHONY: clean
2117
clean:
22-
rm -rf bin/
18+
rm -rf bin/
19+
20+
lint:
21+
golangci-lint run ./...
22+
23+
format:
24+
go fmt ./...
25+
26+
.PHONY: build install test clean lint format
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Salesforce Sync
2+
3+
Sync Salesforce CRM data (Accounts, Opportunities) into Ctrlplane as resources.
4+
5+
## Quick Start
6+
7+
```bash
8+
# Set credentials (via environment or flags)
9+
export SALESFORCE_DOMAIN="https://mycompany.my.salesforce.com"
10+
export SALESFORCE_CONSUMER_KEY="your-key"
11+
export SALESFORCE_CONSUMER_SECRET="your-secret"
12+
13+
# Sync all accounts
14+
ctrlc sync salesforce accounts
15+
16+
# Sync opportunities with filters
17+
ctrlc sync salesforce opportunities --where="IsWon = true AND Amount > 50000"
18+
19+
# Map custom fields to metadata
20+
ctrlc sync salesforce accounts \
21+
--metadata="account/tier=Tier__c" \
22+
--metadata="account/health=Customer_Health__c"
23+
```
24+
25+
## Authentication
26+
27+
Requires Salesforce OAuth2 credentials from a Connected App with `api` and `refresh_token` scopes.
28+
29+
Credentials can be provided via:
30+
- Environment variables: `SALESFORCE_DOMAIN`, `SALESFORCE_CONSUMER_KEY`, `SALESFORCE_CONSUMER_SECRET`
31+
- Command flags: `--salesforce-domain`, `--salesforce-consumer-key`, `--salesforce-consumer-secret`
32+
33+
## Common Flags
34+
35+
| Flag | Description | Default |
36+
|------|-------------|---------|
37+
| `--provider`, `-p` | Resource provider name | Auto-generated from domain |
38+
| `--metadata` | Map Salesforce fields to metadata | Built-in defaults |
39+
| `--where` | SOQL WHERE clause filter | None |
40+
| `--limit` | Maximum records to sync | 0 (no limit) |
41+
| `--list-all-fields` | Log available Salesforce fields | false |
42+
43+
## Metadata Mappings
44+
45+
Map any Salesforce field (including custom fields) to Ctrlplane metadata:
46+
47+
```bash
48+
# Format: metadata-key=SalesforceFieldName
49+
--metadata="account/tier=Tier__c"
50+
--metadata="opportunity/stage-custom=Custom_Stage__c"
51+
```
52+
53+
- Custom fields typically end with `__c`
54+
- Use `--list-all-fields` to discover available fields
55+
- All metadata values are stored as strings
56+
57+
## Resource Examples
58+
59+
### Account Resource
60+
```json
61+
{
62+
"version": "ctrlplane.dev/crm/account/v1",
63+
"kind": "SalesforceAccount",
64+
"name": "Acme Corporation",
65+
"identifier": "001XX000003DHPh",
66+
"config": {
67+
"name": "Acme Corporation",
68+
"type": "Customer",
69+
"salesforceAccount": {
70+
"recordId": "001XX000003DHPh",
71+
"ownerId": "005XX000001SvogAAC",
72+
// ... address, dates, etc.
73+
}
74+
},
75+
"metadata": {
76+
"account/id": "001XX000003DHPh",
77+
"account/type": "Customer",
78+
// Custom fields from --metadata
79+
"account/tier": "Enterprise"
80+
}
81+
}
82+
```
83+
84+
### Opportunity Resource
85+
```json
86+
{
87+
"version": "ctrlplane.dev/crm/opportunity/v1",
88+
"kind": "SalesforceOpportunity",
89+
"name": "Enterprise Deal",
90+
"identifier": "006XX000003DHPh",
91+
"config": {
92+
"amount": 250000,
93+
"stage": "Negotiation",
94+
"salesforceOpportunity": {
95+
"recordId": "006XX000003DHPh",
96+
"accountId": "001XX000003DHPh",
97+
// ... dates, fiscal info, etc.
98+
}
99+
},
100+
"metadata": {
101+
"opportunity/amount": "250000",
102+
"opportunity/stage": "Negotiation"
103+
}
104+
}
105+
```
106+
107+
## Advanced Usage
108+
109+
### Filtering with SOQL
110+
111+
```bash
112+
# Complex account filters
113+
ctrlc sync salesforce accounts \
114+
--where="Type = 'Customer' AND AnnualRevenue > 1000000"
115+
116+
# Filter opportunities by custom fields
117+
ctrlc sync salesforce opportunities \
118+
--where="Custom_Field__c != null AND Stage = 'Closed Won'"
119+
```
120+
121+
### Pagination
122+
123+
- Automatically handles large datasets with ID-based pagination
124+
- Fetches up to 1000 records per API call
125+
- Use `--limit` to restrict total records synced
126+
127+
### Default Provider Names
128+
129+
If no `--provider` is specified, names are auto-generated from your Salesforce subdomain:
130+
- `https://acme.my.salesforce.com``acme-salesforce-accounts`
131+
132+
## Implementation Notes
133+
134+
- Uses `map[string]any` for Salesforce's dynamic schema
135+
- Null values are omitted from resources
136+
- Numbers and booleans are preserved in config, converted to strings in metadata
137+
- Dates are formatted to RFC3339 where applicable
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package accounts
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/MakeNowJust/heredoc/v2"
8+
"github.com/charmbracelet/log"
9+
"github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/salesforce/common"
10+
"github.com/ctrlplanedev/cli/internal/api"
11+
"github.com/k-capehart/go-salesforce/v2"
12+
"github.com/spf13/cobra"
13+
"github.com/spf13/viper"
14+
)
15+
16+
func NewSalesforceAccountsCmd() *cobra.Command {
17+
var name string
18+
var metadataMappings map[string]string
19+
var limit int
20+
var listAllFields bool
21+
var whereClause string
22+
23+
cmd := &cobra.Command{
24+
Use: "accounts",
25+
Short: "Sync Salesforce accounts into Ctrlplane",
26+
Example: heredoc.Doc(`
27+
# Sync all Salesforce accounts
28+
$ ctrlc sync salesforce accounts \
29+
--salesforce-domain="https://mycompany.my.salesforce.com" \
30+
--salesforce-consumer-key="your-key" \
31+
--salesforce-consumer-secret="your-secret"
32+
33+
# Sync accounts with a specific filter
34+
$ ctrlc sync salesforce accounts --where="Customer_Health__c != null"
35+
36+
# Sync accounts and list all available fields in logs
37+
$ ctrlc sync salesforce accounts --list-all-fields
38+
39+
# Sync accounts with custom provider name
40+
$ ctrlc sync salesforce accounts --provider my-salesforce
41+
42+
# Sync with custom metadata mappings
43+
$ ctrlc sync salesforce accounts \
44+
--metadata="account/id=Id" \
45+
--metadata="account/owner-id=OwnerId" \
46+
--metadata="account/tier=Tier__c" \
47+
--metadata="account/region=Region__c" \
48+
--metadata="account/annual-revenue=Annual_Revenue__c" \
49+
--metadata="account/health=Customer_Health__c"
50+
51+
# Sync with a limit on number of records
52+
$ ctrlc sync salesforce accounts --limit 500
53+
54+
# Combine filters with metadata mappings
55+
$ ctrlc sync salesforce accounts \
56+
--salesforce-domain="https://mycompany.my.salesforce.com" \
57+
--salesforce-consumer-key="your-key" \
58+
--salesforce-consumer-secret="your-secret" \
59+
--where="Type = 'Customer' AND AnnualRevenue > 1000000" \
60+
--metadata="account/revenue=AnnualRevenue"
61+
`),
62+
RunE: func(cmd *cobra.Command, args []string) error {
63+
domain := viper.GetString("salesforce-domain")
64+
consumerKey := viper.GetString("salesforce-consumer-key")
65+
consumerSecret := viper.GetString("salesforce-consumer-secret")
66+
67+
log.Info("Syncing Salesforce accounts into Ctrlplane", "domain", domain)
68+
69+
ctx := context.Background()
70+
71+
sf, err := common.InitSalesforceClient(domain, consumerKey, consumerSecret)
72+
if err != nil {
73+
return err
74+
}
75+
76+
resources, err := processAccounts(ctx, sf, metadataMappings, limit, listAllFields, whereClause)
77+
if err != nil {
78+
return err
79+
}
80+
81+
if name == "" {
82+
subdomain := common.GetSalesforceSubdomain(domain)
83+
name = fmt.Sprintf("%s-salesforce-accounts", subdomain)
84+
}
85+
86+
return common.UpsertToCtrlplane(ctx, resources, name)
87+
},
88+
}
89+
90+
cmd.Flags().StringVarP(&name, "provider", "p", "", "Name of the resource provider")
91+
cmd.Flags().StringToStringVar(&metadataMappings, "metadata", map[string]string{}, "Custom metadata mappings (format: metadata/key=SalesforceField)")
92+
cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of records to sync (0 = no limit)")
93+
cmd.Flags().BoolVar(&listAllFields, "list-all-fields", false, "List all available Salesforce fields in the logs")
94+
cmd.Flags().StringVar(&whereClause, "where", "", "SOQL WHERE clause to filter records (e.g., \"Customer_Health__c != null\")")
95+
96+
return cmd
97+
}
98+
99+
func processAccounts(ctx context.Context, sf *salesforce.Salesforce, metadataMappings map[string]string, limit int, listAllFields bool, whereClause string) ([]api.CreateResource, error) {
100+
additionalFields := make([]string, 0, len(metadataMappings))
101+
for _, fieldName := range metadataMappings {
102+
additionalFields = append(additionalFields, fieldName)
103+
}
104+
105+
var accounts []map[string]any
106+
err := common.QuerySalesforceObject(ctx, sf, "Account", limit, listAllFields, &accounts, additionalFields, whereClause)
107+
if err != nil {
108+
return nil, err
109+
}
110+
111+
log.Info("Found Salesforce accounts", "count", len(accounts))
112+
113+
resources := []api.CreateResource{}
114+
for _, account := range accounts {
115+
resource := transformAccountToResource(account, metadataMappings)
116+
resources = append(resources, resource)
117+
}
118+
119+
return resources, nil
120+
}
121+
122+
func transformAccountToResource(account map[string]any, metadataMappings map[string]string) api.CreateResource {
123+
metadata := map[string]string{}
124+
common.AddToMetadata(metadata, "account/id", account["Id"])
125+
common.AddToMetadata(metadata, "account/owner-id", account["OwnerId"])
126+
common.AddToMetadata(metadata, "account/industry", account["Industry"])
127+
common.AddToMetadata(metadata, "account/billing-city", account["BillingCity"])
128+
common.AddToMetadata(metadata, "account/billing-state", account["BillingState"])
129+
common.AddToMetadata(metadata, "account/billing-country", account["BillingCountry"])
130+
common.AddToMetadata(metadata, "account/website", account["Website"])
131+
common.AddToMetadata(metadata, "account/phone", account["Phone"])
132+
common.AddToMetadata(metadata, "account/type", account["Type"])
133+
common.AddToMetadata(metadata, "account/source", account["AccountSource"])
134+
common.AddToMetadata(metadata, "account/shipping-city", account["ShippingCity"])
135+
common.AddToMetadata(metadata, "account/parent-id", account["ParentId"])
136+
common.AddToMetadata(metadata, "account/employees", account["NumberOfEmployees"])
137+
138+
for metadataKey, fieldName := range metadataMappings {
139+
if value, exists := account[fieldName]; exists {
140+
common.AddToMetadata(metadata, metadataKey, value)
141+
}
142+
}
143+
144+
config := map[string]interface{}{
145+
"name": fmt.Sprintf("%v", account["Name"]),
146+
"industry": fmt.Sprintf("%v", account["Industry"]),
147+
"id": fmt.Sprintf("%v", account["Id"]),
148+
"type": fmt.Sprintf("%v", account["Type"]),
149+
"phone": fmt.Sprintf("%v", account["Phone"]),
150+
"website": fmt.Sprintf("%v", account["Website"]),
151+
152+
"salesforceAccount": map[string]interface{}{
153+
"recordId": fmt.Sprintf("%v", account["Id"]),
154+
"ownerId": fmt.Sprintf("%v", account["OwnerId"]),
155+
"parentId": fmt.Sprintf("%v", account["ParentId"]),
156+
"type": fmt.Sprintf("%v", account["Type"]),
157+
"accountSource": fmt.Sprintf("%v", account["AccountSource"]),
158+
"numberOfEmployees": account["NumberOfEmployees"],
159+
"description": fmt.Sprintf("%v", account["Description"]),
160+
"billingAddress": map[string]interface{}{
161+
"street": fmt.Sprintf("%v", account["BillingStreet"]),
162+
"city": fmt.Sprintf("%v", account["BillingCity"]),
163+
"state": fmt.Sprintf("%v", account["BillingState"]),
164+
"postalCode": fmt.Sprintf("%v", account["BillingPostalCode"]),
165+
"country": fmt.Sprintf("%v", account["BillingCountry"]),
166+
"latitude": account["BillingLatitude"],
167+
"longitude": account["BillingLongitude"],
168+
},
169+
"shippingAddress": map[string]interface{}{
170+
"street": fmt.Sprintf("%v", account["ShippingStreet"]),
171+
"city": fmt.Sprintf("%v", account["ShippingCity"]),
172+
"state": fmt.Sprintf("%v", account["ShippingState"]),
173+
"postalCode": fmt.Sprintf("%v", account["ShippingPostalCode"]),
174+
"country": fmt.Sprintf("%v", account["ShippingCountry"]),
175+
"latitude": account["ShippingLatitude"],
176+
"longitude": account["ShippingLongitude"],
177+
},
178+
"createdDate": fmt.Sprintf("%v", account["CreatedDate"]),
179+
"lastModifiedDate": fmt.Sprintf("%v", account["LastModifiedDate"]),
180+
"isDeleted": account["IsDeleted"],
181+
"photoUrl": fmt.Sprintf("%v", account["PhotoUrl"]),
182+
},
183+
}
184+
185+
return api.CreateResource{
186+
Version: "ctrlplane.dev/crm/account/v1",
187+
Kind: "SalesforceAccount",
188+
Name: fmt.Sprintf("%v", account["Name"]),
189+
Identifier: fmt.Sprintf("%v", account["Id"]),
190+
Config: config,
191+
Metadata: metadata,
192+
}
193+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package common
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/k-capehart/go-salesforce/v2"
7+
)
8+
9+
func InitSalesforceClient(domain, consumerKey, consumerSecret string) (*salesforce.Salesforce, error) {
10+
sf, err := salesforce.Init(salesforce.Creds{
11+
Domain: domain,
12+
ConsumerKey: consumerKey,
13+
ConsumerSecret: consumerSecret,
14+
})
15+
if err != nil {
16+
return nil, fmt.Errorf("failed to initialize Salesforce client: %w", err)
17+
}
18+
return sf, nil
19+
}

0 commit comments

Comments
 (0)