Skip to content
Open
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
17 changes: 17 additions & 0 deletions connectors/distribution/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
POSTIZ_BASE_URL=https://api.postiz.com
POSTIZ_API_KEY=xxx
META_ACCESS_TOKEN=xxx
META_AD_ACCOUNT_ID=act_1234567890
TIKTOK_ACCESS_TOKEN=xxx
TIKTOK_ADVERTISER_ID=7000000000000000000
KLAVIYO_API_KEY=pk_xxx
# Optional base-url overrides (staging / local mock). Defaults are the live API hosts.
# META_GRAPH_BASE_URL=https://graph.facebook.com
# TIKTOK_BASE_URL=https://business-api.tiktok.com
# KLAVIYO_BASE_URL=https://a.klaviyo.com
# POSTIZ_BASE_URL=https://api.postiz.com
DISPATCH_LOG_PATH=./var/distribution/dispatch_log.db
SHOPLOOP_MCP_TOKEN=xxx
SHOPLOOP_MCP_PORT=8789
TEMPORAL_ADDRESS=localhost:7233
TEMPORAL_NAMESPACE=shoploop
6 changes: 6 additions & 0 deletions connectors/distribution/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
dist
*.tsbuildinfo
.env
.env.local
var/
61 changes: 61 additions & 0 deletions connectors/distribution/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# DESIGN — @shoploop/connector-distribution

## Why this shape

The moat is the **shared `AdapterInterface`**, not any one channel. Future channels (Reddit ads,
Pinterest, X ads) just implement `dispatch()` / `status()` and register in `adapters/index.ts`.
MCP tools, Temporal activities, and the CLI never special-case a channel — they route through
`buildAdapter(channel, cfg)` and `dispatchChannel()`.

```
CreativePack ──> dispatchChannel() ──> buildAdapter(channel) ──> adapter.dispatch()
│ │
└── dispatch_log (LibSQL, idempotent) ─────────┘
```

## DRAFT-only is a hard rule, enforced twice

1. **Construction**: paid adapters only ever build PAUSED (Meta) / DISABLE (TikTok) payloads.
2. **Runtime guard**: `assertNoLivePublish()` recursively scans *every* outbound payload before
it leaves the process. Meta throws `LivePublishForbidden` on any `status`/`effective_status`/
`configured_status === ACTIVE`; TikTok throws on any `operation_status !== DISABLE`. This
defends against a future edit (or a malicious mutation) that flips a status field — the test
suite asserts both guards throw.

There is no live-publish branch anywhere in this package. Live mode is a separate PR.

## Idempotency

Keyed by `(pack_id, channel, audience_id)` at two layers so retries are safe regardless of where
they originate:

- **dispatch_log** (LibSQL): the source of truth for "did this already go out?". A non-failed row
short-circuits the adapter call; a failed row is retried and its state updated.
- **Temporal workflow id** (`dispatch:<channel>:<packId>:<audienceHash>`, `REJECT_DUPLICATE`):
collapses duplicate workflow starts before an activity even runs.

## Copy assembler

Single place that turns a pack + `brand_rules.voice` into channel-shaped copy, applying length
caps (Meta 125/30, TikTok ≤100, Postiz per-network, Klaviyo subject/preview) and throwing
`BrandVoiceViolation` on forbidden phrases. Klaviyo carries only subject + preview text — the
Flow template owns the full email body.

## Fan-out partial success

`FanoutPackWorkflow` uses `fanoutDispatch()` (a pure, types-only helper safe for the workflow
sandbox) over `Promise.allSettled`. One channel throwing or returning `state=failed` is captured
in `failed[]` while the rest land in `ok[]` — the workflow always resolves with a full result set.

## Adapter notes

- **Postiz** (organic): fans out one POST per requested network, aggregates `network:postId` pairs
into a comma-separated `external_id`. Immediate ⇒ `sent`, scheduled ⇒ `scheduled`. A single
network failing does not sink the others.
- **Meta** (paid_draft): Marketing API v20.0. Campaign→AdSet→Ad all PAUSED; creative via
`object_story_spec` (`link_data` for image, `video_data` for video). `external_id = ad_id`.
- **TikTok** (paid_draft): Marketing API v1.3. Campaign→AdGroup→Ad all DISABLE. Treats non-zero
response `code` as an error. `external_id = ad_id`.
- **Klaviyo** (transactional): POST `/api/events` with metric `shoploop.creative_pack.ready` and a
stable `unique_id` (the dispatch key) so Klaviyo de-dupes too. 202 and 409 are both success.
No list subscribes — that is the landing page's job.
72 changes: 72 additions & 0 deletions connectors/distribution/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# @shoploop/connector-distribution

Distribution layer: pushes **Creative Packs** (from open-imagine) to channels. Four adapters
behind one shared `AdapterInterface`. MCP, Temporal, and the CLI are thin shells over the same
dispatch core.

> CONTRACT: consumes `open-imagine/contracts/creative_pack.v1.json` (referenced, not vendored).

## Channels

| Channel | Mode | What it creates | DRAFT invariant |
|---------|-----------------|-------------------------------------------------------------|-----------------|
| Postiz | `organic` | One schedule per network (meta_page, tiktok_business, ig_business, x) | n/a (organic) |
| Meta | `paid_draft` | Campaign → Ad Set → Ad, all `status=PAUSED` | `status=ACTIVE` ⇒ throws `LivePublishForbidden` |
| TikTok | `paid_draft` | Campaign → Ad Group → Ad, all `operation_status=DISABLE` | `operation_status≠DISABLE` ⇒ throws `LivePublishForbidden` |
| Klaviyo | `transactional` | `shoploop.creative_pack.ready` Server Event (Flow owns send)| n/a (event only) |

**Paid is DRAFT-only in this PR.** There is no live-publish code path, not even behind a flag.
If a payload would spend money, the runtime guard throws before the request leaves the process.

## Install / build / test

```bash
npm install
npm run typecheck
npm run build
npm test
```

## CLI

```bash
shoploop-distribution dispatch --pack <pack.json> --channels <all|csv> [--brand-rules <rules.json>] [--schedule <iso>]
shoploop-distribution mcp # MCP HTTP/SSE on :8789
shoploop-distribution worker # Temporal worker, queue: shoploop-distribution
```

Varitea acceptance — `--channels all` produces one dispatch per channel with channel URLs:

```
postiz sent meta_page:pz_…,tiktok_business:pz_…,ig_business:pz_…,x:pz_… https://postiz.local/p/x
meta draft mad_1 https://business.facebook.com/adsmanager/manage/ads?act=…&selected_ad_ids=mad_1
tiktok draft ttad_1 https://ads.tiktok.com/i18n/perf/creative?aadvid=…
klaviyo sent pack_varitea_oolong_hero_001:klaviyo:high-mountain-oolong:default
```

## Idempotency

Every dispatch is keyed by `(pack_id, channel, audience_id)`:

- **LibSQL `dispatch_log`** — a prior non-failed row short-circuits; failed rows retry.
- **Temporal workflow id** — `dispatch:<channel>:<packId>:<audienceHash>` with
`WorkflowIdReusePolicy=REJECT_DUPLICATE`.

## Temporal

- `DispatchPackWorkflow` — single-channel dispatch via the adapter.
- `FanoutPackWorkflow` — fans out to all configured channels in parallel; one channel failing
does not fail the workflow (partial success returned).

## MCP tools

`dispatch_pack`, `get_dispatch`, `list_recent_dispatches`, `cancel_dispatch`, `list_channels`.

## Env

See `.env.example`. Base-url overrides exist for staging/local mocks; defaults are the live hosts.

## Cost (dogfood — Varitea, ~5 packs/wk × 4 channels)

Postiz $0–$20/mo · Meta/TikTok $0 (DRAFT-only, no spend) · Klaviyo included · LibSQL $0 ·
co-located on the existing t4g.small. **Total: $0–$20/mo.**
2 changes: 2 additions & 0 deletions connectors/distribution/bin/cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env node
import "../dist/cli.js";
Loading