Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,22 @@ LDFLAGS = -X github.com/ctrlplanedev/cli/cmd/ctrlc/root/version.Version=$(VERSIO
-X github.com/ctrlplanedev/cli/cmd/ctrlc/root/version.GitCommit=$(COMMIT) \
-X github.com/ctrlplanedev/cli/cmd/ctrlc/root/version.BuildDate=$(DATE)

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

.PHONY: install
install:
go install -ldflags "$(LDFLAGS)" ./cmd/ctrlc

.PHONY: test
test:
go test -v ./...

.PHONY: clean
clean:
rm -rf bin/
rm -rf bin/

lint:
golangci-lint run ./...

format:
go fmt ./...

.PHONY: build install test clean lint format
137 changes: 137 additions & 0 deletions cmd/ctrlc/root/sync/salesforce/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Salesforce Sync

Sync Salesforce CRM data (Accounts, Opportunities) into Ctrlplane as resources.

## Quick Start

```bash
# Set credentials (via environment or flags)
export SALESFORCE_DOMAIN="https://mycompany.my.salesforce.com"
export SALESFORCE_CONSUMER_KEY="your-key"
export SALESFORCE_CONSUMER_SECRET="your-secret"

# Sync all accounts
ctrlc sync salesforce accounts

# Sync opportunities with filters
ctrlc sync salesforce opportunities --where="IsWon = true AND Amount > 50000"

# Map custom fields to metadata
ctrlc sync salesforce accounts \
--metadata="account/tier=Tier__c" \
--metadata="account/health=Customer_Health__c"
```

## Authentication

Requires Salesforce OAuth2 credentials from a Connected App with `api` and `refresh_token` scopes.

Credentials can be provided via:
- Environment variables: `SALESFORCE_DOMAIN`, `SALESFORCE_CONSUMER_KEY`, `SALESFORCE_CONSUMER_SECRET`
- Command flags: `--salesforce-domain`, `--salesforce-consumer-key`, `--salesforce-consumer-secret`

## Common Flags

| Flag | Description | Default |
|------|-------------|---------|
| `--provider`, `-p` | Resource provider name | Auto-generated from domain |
| `--metadata` | Map Salesforce fields to metadata | Built-in defaults |
| `--where` | SOQL WHERE clause filter | None |
| `--limit` | Maximum records to sync | 0 (no limit) |
| `--list-all-fields` | Log available Salesforce fields | false |

## Metadata Mappings

Map any Salesforce field (including custom fields) to Ctrlplane metadata:

```bash
# Format: metadata-key=SalesforceFieldName
--metadata="account/tier=Tier__c"
--metadata="opportunity/stage-custom=Custom_Stage__c"
```

- Custom fields typically end with `__c`
- Use `--list-all-fields` to discover available fields
- All metadata values are stored as strings

## Resource Examples

### Account Resource
```json
{
"version": "ctrlplane.dev/crm/account/v1",
"kind": "SalesforceAccount",
"name": "Acme Corporation",
"identifier": "001XX000003DHPh",
"config": {
"name": "Acme Corporation",
"type": "Customer",
"salesforceAccount": {
"recordId": "001XX000003DHPh",
"ownerId": "005XX000001SvogAAC",
// ... address, dates, etc.
}
},
"metadata": {
"account/id": "001XX000003DHPh",
"account/type": "Customer",
// Custom fields from --metadata
"account/tier": "Enterprise"
}
}
```

### Opportunity Resource
```json
{
"version": "ctrlplane.dev/crm/opportunity/v1",
"kind": "SalesforceOpportunity",
"name": "Enterprise Deal",
"identifier": "006XX000003DHPh",
"config": {
"amount": 250000,
"stage": "Negotiation",
"salesforceOpportunity": {
"recordId": "006XX000003DHPh",
"accountId": "001XX000003DHPh",
// ... dates, fiscal info, etc.
}
},
"metadata": {
"opportunity/amount": "250000",
"opportunity/stage": "Negotiation"
}
}
```

## Advanced Usage

### Filtering with SOQL

```bash
# Complex account filters
ctrlc sync salesforce accounts \
--where="Type = 'Customer' AND AnnualRevenue > 1000000"

# Filter opportunities by custom fields
ctrlc sync salesforce opportunities \
--where="Custom_Field__c != null AND Stage = 'Closed Won'"
```

### Pagination

- Automatically handles large datasets with ID-based pagination
- Fetches up to 1000 records per API call
- Use `--limit` to restrict total records synced

### Default Provider Names

If no `--provider` is specified, names are auto-generated from your Salesforce subdomain:
- `https://acme.my.salesforce.com` → `acme-salesforce-accounts`

## Implementation Notes

- Uses `map[string]any` for Salesforce's dynamic schema
- Null values are omitted from resources
- Numbers and booleans are preserved in config, converted to strings in metadata
- Dates are formatted to RFC3339 where applicable
193 changes: 193 additions & 0 deletions cmd/ctrlc/root/sync/salesforce/accounts/accounts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package accounts

import (
"context"
"fmt"

"github.com/MakeNowJust/heredoc/v2"
"github.com/charmbracelet/log"
"github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/salesforce/common"
"github.com/ctrlplanedev/cli/internal/api"
"github.com/k-capehart/go-salesforce/v2"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

func NewSalesforceAccountsCmd() *cobra.Command {
var name string
var metadataMappings map[string]string
var limit int
var listAllFields bool
var whereClause string

cmd := &cobra.Command{
Use: "accounts",
Short: "Sync Salesforce accounts into Ctrlplane",
Example: heredoc.Doc(`
# Sync all Salesforce accounts
$ ctrlc sync salesforce accounts \
--salesforce-domain="https://mycompany.my.salesforce.com" \
--salesforce-consumer-key="your-key" \
--salesforce-consumer-secret="your-secret"

# Sync accounts with a specific filter
$ ctrlc sync salesforce accounts --where="Customer_Health__c != null"

# Sync accounts and list all available fields in logs
$ ctrlc sync salesforce accounts --list-all-fields

# Sync accounts with custom provider name
$ ctrlc sync salesforce accounts --provider my-salesforce

# Sync with custom metadata mappings
$ ctrlc sync salesforce accounts \
--metadata="account/id=Id" \
--metadata="account/owner-id=OwnerId" \
--metadata="account/tier=Tier__c" \
--metadata="account/region=Region__c" \
--metadata="account/annual-revenue=Annual_Revenue__c" \
--metadata="account/health=Customer_Health__c"

# Sync with a limit on number of records
$ ctrlc sync salesforce accounts --limit 500

# Combine filters with metadata mappings
$ ctrlc sync salesforce accounts \
--salesforce-domain="https://mycompany.my.salesforce.com" \
--salesforce-consumer-key="your-key" \
--salesforce-consumer-secret="your-secret" \
--where="Type = 'Customer' AND AnnualRevenue > 1000000" \
--metadata="account/revenue=AnnualRevenue"
`),
RunE: func(cmd *cobra.Command, args []string) error {
domain := viper.GetString("salesforce-domain")
consumerKey := viper.GetString("salesforce-consumer-key")
consumerSecret := viper.GetString("salesforce-consumer-secret")

log.Info("Syncing Salesforce accounts into Ctrlplane", "domain", domain)

ctx := context.Background()

sf, err := common.InitSalesforceClient(domain, consumerKey, consumerSecret)
if err != nil {
return err
}

resources, err := processAccounts(ctx, sf, metadataMappings, limit, listAllFields, whereClause)
if err != nil {
return err
}

if name == "" {
subdomain := common.GetSalesforceSubdomain(domain)
name = fmt.Sprintf("%s-salesforce-accounts", subdomain)
}

return common.UpsertToCtrlplane(ctx, resources, name)
},
}

cmd.Flags().StringVarP(&name, "provider", "p", "", "Name of the resource provider")
cmd.Flags().StringToStringVar(&metadataMappings, "metadata", map[string]string{}, "Custom metadata mappings (format: metadata/key=SalesforceField)")
cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of records to sync (0 = no limit)")
cmd.Flags().BoolVar(&listAllFields, "list-all-fields", false, "List all available Salesforce fields in the logs")
cmd.Flags().StringVar(&whereClause, "where", "", "SOQL WHERE clause to filter records (e.g., \"Customer_Health__c != null\")")

return cmd
}

func processAccounts(ctx context.Context, sf *salesforce.Salesforce, metadataMappings map[string]string, limit int, listAllFields bool, whereClause string) ([]api.CreateResource, error) {
additionalFields := make([]string, 0, len(metadataMappings))
for _, fieldName := range metadataMappings {
additionalFields = append(additionalFields, fieldName)
}

var accounts []map[string]any
err := common.QuerySalesforceObject(ctx, sf, "Account", limit, listAllFields, &accounts, additionalFields, whereClause)
if err != nil {
return nil, err
}

log.Info("Found Salesforce accounts", "count", len(accounts))

resources := []api.CreateResource{}
for _, account := range accounts {
resource := transformAccountToResource(account, metadataMappings)
resources = append(resources, resource)
}

return resources, nil
}

func transformAccountToResource(account map[string]any, metadataMappings map[string]string) api.CreateResource {
metadata := map[string]string{}
common.AddToMetadata(metadata, "account/id", account["Id"])
common.AddToMetadata(metadata, "account/owner-id", account["OwnerId"])
common.AddToMetadata(metadata, "account/industry", account["Industry"])
common.AddToMetadata(metadata, "account/billing-city", account["BillingCity"])
common.AddToMetadata(metadata, "account/billing-state", account["BillingState"])
common.AddToMetadata(metadata, "account/billing-country", account["BillingCountry"])
common.AddToMetadata(metadata, "account/website", account["Website"])
common.AddToMetadata(metadata, "account/phone", account["Phone"])
common.AddToMetadata(metadata, "account/type", account["Type"])
common.AddToMetadata(metadata, "account/source", account["AccountSource"])
common.AddToMetadata(metadata, "account/shipping-city", account["ShippingCity"])
common.AddToMetadata(metadata, "account/parent-id", account["ParentId"])
common.AddToMetadata(metadata, "account/employees", account["NumberOfEmployees"])

for metadataKey, fieldName := range metadataMappings {
if value, exists := account[fieldName]; exists {
common.AddToMetadata(metadata, metadataKey, value)
}
}

config := map[string]interface{}{
"name": fmt.Sprintf("%v", account["Name"]),
"industry": fmt.Sprintf("%v", account["Industry"]),
"id": fmt.Sprintf("%v", account["Id"]),
"type": fmt.Sprintf("%v", account["Type"]),
"phone": fmt.Sprintf("%v", account["Phone"]),
"website": fmt.Sprintf("%v", account["Website"]),

"salesforceAccount": map[string]interface{}{
"recordId": fmt.Sprintf("%v", account["Id"]),
"ownerId": fmt.Sprintf("%v", account["OwnerId"]),
"parentId": fmt.Sprintf("%v", account["ParentId"]),
"type": fmt.Sprintf("%v", account["Type"]),
"accountSource": fmt.Sprintf("%v", account["AccountSource"]),
"numberOfEmployees": account["NumberOfEmployees"],
"description": fmt.Sprintf("%v", account["Description"]),
"billingAddress": map[string]interface{}{
"street": fmt.Sprintf("%v", account["BillingStreet"]),
"city": fmt.Sprintf("%v", account["BillingCity"]),
"state": fmt.Sprintf("%v", account["BillingState"]),
"postalCode": fmt.Sprintf("%v", account["BillingPostalCode"]),
"country": fmt.Sprintf("%v", account["BillingCountry"]),
"latitude": account["BillingLatitude"],
"longitude": account["BillingLongitude"],
},
"shippingAddress": map[string]interface{}{
"street": fmt.Sprintf("%v", account["ShippingStreet"]),
"city": fmt.Sprintf("%v", account["ShippingCity"]),
"state": fmt.Sprintf("%v", account["ShippingState"]),
"postalCode": fmt.Sprintf("%v", account["ShippingPostalCode"]),
"country": fmt.Sprintf("%v", account["ShippingCountry"]),
"latitude": account["ShippingLatitude"],
"longitude": account["ShippingLongitude"],
},
"createdDate": fmt.Sprintf("%v", account["CreatedDate"]),
"lastModifiedDate": fmt.Sprintf("%v", account["LastModifiedDate"]),
"isDeleted": account["IsDeleted"],
"photoUrl": fmt.Sprintf("%v", account["PhotoUrl"]),
},
}

return api.CreateResource{
Version: "ctrlplane.dev/crm/account/v1",
Kind: "SalesforceAccount",
Name: fmt.Sprintf("%v", account["Name"]),
Identifier: fmt.Sprintf("%v", account["Id"]),
Config: config,
Metadata: metadata,
}
}
19 changes: 19 additions & 0 deletions cmd/ctrlc/root/sync/salesforce/common/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package common

import (
"fmt"

"github.com/k-capehart/go-salesforce/v2"
)

func InitSalesforceClient(domain, consumerKey, consumerSecret string) (*salesforce.Salesforce, error) {
sf, err := salesforce.Init(salesforce.Creds{
Domain: domain,
ConsumerKey: consumerKey,
ConsumerSecret: consumerSecret,
})
if err != nil {
return nil, fmt.Errorf("failed to initialize Salesforce client: %w", err)
}
return sf, nil
}
Loading