Skip to content
Draft
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
35 changes: 35 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copy to .env and fill in. Never commit real secrets.

# --- Salesforce auth (choose ONE style) ---
# Style A: username/password + security token
SFDC_USERNAME=
SFDC_PASSWORD=
SFDC_SECURITY_TOKEN=
# Optional connected-app credentials
SFDC_CONSUMER_KEY=
SFDC_CONSUMER_SECRET=
# "login" for production, "test" for a sandbox
SFDC_DOMAIN=login

# Style B: pre-obtained OAuth access token + instance URL
# SFDC_ACCESS_TOKEN=
# SFDC_INSTANCE_URL=https://your-instance.my.salesforce.com

# --- What to enrich ---
SFDC_SOBJECT=Account
SFDC_LAST_ENRICHED_FIELD=Last_Enriched__c
SFDC_DOMAIN_FIELD=Website
ENRICH_INTERVAL_DAYS=90
SFDC_BATCH_SIZE=200
MAX_RECORDS=0

# --- Enrichment provider: "mock" (offline, deterministic) or "http" ---
ENRICHMENT_PROVIDER=mock
ENRICHMENT_API_URL=
ENRICHMENT_API_KEY=
ENRICHMENT_TIMEOUT_SECONDS=30
# Optional override, e.g. "industry=Industry,employee_count=NumberOfEmployees"
ENRICHMENT_FIELD_MAPPING=

# --- Run controls ---
DRY_RUN=false
67 changes: 67 additions & 0 deletions .github/workflows/enrich-sfdc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: Enrich Salesforce (every 90 days)

on:
schedule:
# Runs at 06:00 UTC on the 1st of Jan, Apr, Jul, Oct — i.e. roughly every 90
# days / quarterly. GitHub cron cannot express a true "every 90 days" period,
# so the job also enforces a per-record 90-day freshness window in SOQL,
# meaning no record is ever re-enriched more often than every 90 days even if
# the job runs more frequently.
- cron: "0 6 1 */3 *"
workflow_dispatch:
inputs:
dry_run:
description: "Run without writing changes back to Salesforce"
type: boolean
default: false
max_records:
description: "Cap number of records processed (0 = no limit)"
type: string
default: "0"

concurrency:
group: enrich-sfdc
cancel-in-progress: false

jobs:
enrich:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install dependencies
run: pip install -r requirements.txt

- name: Run enrichment
env:
PYTHONPATH: src
# --- Salesforce auth (set these as repository/environment secrets) ---
SFDC_USERNAME: ${{ secrets.SFDC_USERNAME }}
SFDC_PASSWORD: ${{ secrets.SFDC_PASSWORD }}
SFDC_SECURITY_TOKEN: ${{ secrets.SFDC_SECURITY_TOKEN }}
SFDC_CONSUMER_KEY: ${{ secrets.SFDC_CONSUMER_KEY }}
SFDC_CONSUMER_SECRET: ${{ secrets.SFDC_CONSUMER_SECRET }}
SFDC_ACCESS_TOKEN: ${{ secrets.SFDC_ACCESS_TOKEN }}
SFDC_INSTANCE_URL: ${{ secrets.SFDC_INSTANCE_URL }}
SFDC_DOMAIN: ${{ vars.SFDC_DOMAIN || 'login' }}
# --- What to enrich ---
SFDC_SOBJECT: ${{ vars.SFDC_SOBJECT || 'Account' }}
SFDC_LAST_ENRICHED_FIELD: ${{ vars.SFDC_LAST_ENRICHED_FIELD || 'Last_Enriched__c' }}
SFDC_DOMAIN_FIELD: ${{ vars.SFDC_DOMAIN_FIELD || 'Website' }}
ENRICH_INTERVAL_DAYS: ${{ vars.ENRICH_INTERVAL_DAYS || '90' }}
# --- Enrichment provider ---
ENRICHMENT_PROVIDER: ${{ vars.ENRICHMENT_PROVIDER || 'mock' }}
ENRICHMENT_API_URL: ${{ vars.ENRICHMENT_API_URL }}
ENRICHMENT_API_KEY: ${{ secrets.ENRICHMENT_API_KEY }}
# --- Run controls ---
DRY_RUN: ${{ inputs.dry_run }}
MAX_RECORDS: ${{ inputs.max_records || '0' }}
run: python -m sfdc_enrichment -v
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
.env
.venv/
venv/
.pytest_cache/
*.egg-info/
.coverage
htmlcov/
128 changes: 127 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,127 @@
# misc
# SFDC Enrichment Workflow

A small, self-contained workflow that keeps Salesforce (SFDC) records enriched
with firmographic data on a rolling **90-day cadence**.

Every record is (re-)enriched at most once per 90 days: the job finds records
that were never enriched or whose `Last_Enriched__c` timestamp is older than 90
days, looks them up via an enrichment provider (by company domain), writes the
attributes back, and stamps `Last_Enriched__c` so the record is left alone until
the next window.

## How the 90-day cadence works

GitHub Actions `cron` cannot express a true "every 90 days" period, so the
cadence is enforced in **two complementary layers**:

1. **Schedule** — the workflow runs quarterly (`0 6 1 */3 *`, i.e. 06:00 UTC on
the 1st of Jan/Apr/Jul/Oct), roughly every 90 days.
2. **Per-record freshness window** — every run only selects records whose
`Last_Enriched__c` is null or older than `ENRICH_INTERVAL_DAYS` (default 90).
This means even if you run the job daily/weekly, no record is re-enriched
more often than every 90 days. This is the more robust interpretation of
"enrich every 90 days" and is safe to run on any schedule.

## Layout

```
.github/workflows/enrich-sfdc.yml # scheduled + manual GitHub Actions workflow
src/sfdc_enrichment/
config.py # env-driven configuration
salesforce_client.py # SOQL query + bulk update wrapper (simple-salesforce)
enrichment.py # provider interface + mock/http implementations
workflow.py # orchestration + 90-day freshness logic
cli.py # command-line entrypoint
tests/ # pytest suite (no live org required)
```

## Prerequisites (Salesforce)

Add a custom datetime field on the target object (default `Account`) named
`Last_Enriched__c` (API name). The workflow reads and writes this field to track
the 90-day window. You can point at a different object/field via env vars.

## Configuration

All configuration comes from environment variables (see `.env.example`).

| Variable | Default | Description |
| --- | --- | --- |
| `SFDC_USERNAME` / `SFDC_PASSWORD` / `SFDC_SECURITY_TOKEN` | – | Username/password auth |
| `SFDC_ACCESS_TOKEN` / `SFDC_INSTANCE_URL` | – | Token auth (alternative) |
| `SFDC_DOMAIN` | `login` | `login` (prod) or `test` (sandbox) |
| `SFDC_SOBJECT` | `Account` | Object to enrich |
| `SFDC_LAST_ENRICHED_FIELD` | `Last_Enriched__c` | Datetime field tracking enrichment |
| `SFDC_DOMAIN_FIELD` | `Website` | Field used as the enrichment lookup key |
| `ENRICH_INTERVAL_DAYS` | `90` | Re-enrichment window (days) |
| `SFDC_BATCH_SIZE` | `200` | Bulk update batch size |
| `MAX_RECORDS` | `0` | Cap records per run (0 = no limit) |
| `ENRICHMENT_PROVIDER` | `mock` | `mock` (offline) or `http` |
| `ENRICHMENT_API_URL` / `ENRICHMENT_API_KEY` | – | HTTP provider settings |
| `ENRICHMENT_FIELD_MAPPING` | – | e.g. `industry=Industry,employee_count=NumberOfEmployees` |
| `DRY_RUN` | `false` | Enrich but do not write back |

## Usage

Install dependencies and run locally:

```bash
pip install -r requirements.txt
cp .env.example .env # fill in credentials

# Preview the SOQL that will be used (no connection needed)
PYTHONPATH=src python -m sfdc_enrichment --show-query

# Dry run: query + enrich but do not write back
PYTHONPATH=src python -m sfdc_enrichment --dry-run -v

# Real run
PYTHONPATH=src python -m sfdc_enrichment -v
```

The command prints a JSON summary, e.g.:

```json
{
"candidates": 120,
"enriched": 118,
"skipped_no_data": 2,
"updated": 118,
"failed": 0,
"dry_run": false,
"errors": []
}
```

## GitHub Actions

The workflow in `.github/workflows/enrich-sfdc.yml` runs on the quarterly
schedule and can be triggered manually (`workflow_dispatch`) with `dry_run` and
`max_records` inputs.

Set these as repository **secrets** (sensitive) and **variables** (non-secret):

- Secrets: `SFDC_USERNAME`, `SFDC_PASSWORD`, `SFDC_SECURITY_TOKEN`,
`SFDC_CONSUMER_KEY`, `SFDC_CONSUMER_SECRET`, `SFDC_ACCESS_TOKEN`,
`SFDC_INSTANCE_URL`, `ENRICHMENT_API_KEY` (set only those you use).
- Variables: `SFDC_DOMAIN`, `SFDC_SOBJECT`, `SFDC_LAST_ENRICHED_FIELD`,
`SFDC_DOMAIN_FIELD`, `ENRICH_INTERVAL_DAYS`, `ENRICHMENT_PROVIDER`,
`ENRICHMENT_API_URL`.

## Enrichment providers

- **`mock`** — deterministic offline data derived from the domain. Great for
dry runs, local dev, and CI (no API key, no network).
- **`http`** — calls `GET {ENRICHMENT_API_URL}?domain=<domain>` with a bearer
token and normalizes common vendor response shapes. To integrate a specific
vendor (Clearbit, ZoomInfo, Apollo, ...) implement `EnrichmentProvider` in
`enrichment.py` and register it in `build_provider`.

## Development

```bash
pip install -r requirements-dev.txt
PYTHONPATH=src python -m pytest
```

The test suite runs fully offline (no Salesforce org or API key required).
3 changes: 3 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
addopts = -q
2 changes: 2 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-r requirements.txt
pytest>=8.0.0
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
simple-salesforce>=1.12.6
requests>=2.31.0
python-dotenv>=1.0.1
9 changes: 9 additions & 0 deletions src/sfdc_enrichment/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Salesforce (SFDC) enrichment workflow.

A small, self-contained package that keeps Salesforce records enriched with
firmographic data on a rolling 90-day cadence.
"""

__version__ = "0.1.0"

__all__ = ["__version__"]
4 changes: 4 additions & 0 deletions src/sfdc_enrichment/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .cli import main

if __name__ == "__main__":
raise SystemExit(main())
88 changes: 88 additions & 0 deletions src/sfdc_enrichment/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Command-line entrypoint for the SFDC enrichment workflow."""

from __future__ import annotations

import argparse
import json
import logging
import sys

from .config import ConfigError, load_config
from .enrichment import build_provider
from .salesforce_client import SalesforceClient
from .workflow import build_candidate_query, run_enrichment


def _configure_logging(verbose: bool) -> None:
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)


def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="sfdc-enrich",
description="Enrich Salesforce records on a rolling 90-day cadence.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Query and enrich but do not write anything back to Salesforce.",
)
parser.add_argument(
"--max-records",
type=int,
default=None,
help="Cap the number of records processed in this run (0 = no limit).",
)
parser.add_argument(
"--show-query",
action="store_true",
help="Print the SOQL that would be used and exit.",
)
parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging.")
return parser


def main(argv: list[str] | None = None) -> int:
args = build_arg_parser().parse_args(argv)
_configure_logging(args.verbose)

# dotenv is optional; load a local .env if present for convenience.
try:
from dotenv import load_dotenv

load_dotenv()
except Exception: # pragma: no cover - dotenv is optional
pass

try:
config = load_config()
if args.dry_run:
config.dry_run = True
if args.max_records is not None:
config.max_records = args.max_records

if args.show_query:
print(build_candidate_query(config))
return 0

config.salesforce.validate()
sf_client = SalesforceClient(config.salesforce)
provider = build_provider(config.enrichment)
summary = run_enrichment(config, sf_client, provider)
except ConfigError as exc:
print(f"Configuration error: {exc}", file=sys.stderr)
return 2
except Exception as exc: # noqa: BLE001
logging.getLogger(__name__).exception("Enrichment run failed")
print(f"Run failed: {exc}", file=sys.stderr)
return 1

print(json.dumps(summary.as_dict(), indent=2))
return 0 if summary.failed == 0 else 1


if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
Loading