@hogsend/cli 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +1985 -807
- package/dist/bin.js.map +1 -1
- package/package.json +4 -4
- package/skills/hogsend-integrate/SKILL.md +198 -0
- package/skills/hogsend-integrate/references/auth-billing-seams.md +199 -0
- package/skills/hogsend-integrate/references/framework-recipes.md +208 -0
- package/skills/hogsend-integrate/references/verification.md +86 -0
- package/skills/hogsend-migrate/SKILL.md +147 -0
- package/skills/hogsend-migrate/references/customerio-mapping.md +93 -0
- package/skills/hogsend-migrate/references/cutover-checklist.md +136 -0
- package/skills/hogsend-migrate/references/loops-mapping.md +132 -0
- package/skills/hogsend-migrate/references/resend-broadcasts-mapping.md +120 -0
- package/src/__tests__/dev.test.ts +323 -0
- package/src/__tests__/dns-apply.test.ts +297 -0
- package/src/__tests__/dns.test.ts +143 -0
- package/src/__tests__/domain-command.test.ts +216 -0
- package/src/__tests__/proc.test.ts +177 -0
- package/src/__tests__/setup-steps.test.ts +363 -0
- package/src/commands/dev.ts +444 -0
- package/src/commands/domain.ts +437 -0
- package/src/commands/events.ts +4 -1
- package/src/commands/index.ts +4 -0
- package/src/commands/setup.ts +34 -163
- package/src/lib/dns-apply.ts +218 -0
- package/src/lib/dns.ts +217 -0
- package/src/lib/proc.ts +189 -0
- package/src/lib/setup-steps.ts +333 -0
- package/studio/assets/index-CSXAjTbe.js +265 -0
- package/studio/assets/index-DCsT0fnT.css +1 -0
- package/studio/index.html +2 -2
- package/studio/assets/index-BBOTQnww.js +0 -250
- package/studio/assets/index-DnfpcXbb.css +0 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Verification — prove the pipe works with the `hogsend` CLI
|
|
2
|
+
|
|
3
|
+
After wiring, verify end-to-end with the `hogsend` CLI (ships with
|
|
4
|
+
`@hogsend/cli`; run via `pnpm dlx @hogsend/cli` or a global install). It reads
|
|
5
|
+
the same env names the host app uses:
|
|
6
|
+
|
|
7
|
+
- **Base URL:** `--url <baseUrl>` > `HOGSEND_API_URL` > default
|
|
8
|
+
`http://localhost:3002`.
|
|
9
|
+
- **Data key** (writes — `events send`): `--data-key` > `HOGSEND_DATA_KEY` >
|
|
10
|
+
`HOGSEND_API_KEY`. The ingest key the host app already has works here.
|
|
11
|
+
- **Admin key** (reads — `events <userId>`, `contacts`, `stats`):
|
|
12
|
+
`--admin-key` > `HOGSEND_ADMIN_KEY` > `ADMIN_API_KEY`. Ask the user for one
|
|
13
|
+
if the host env only carries the ingest key.
|
|
14
|
+
|
|
15
|
+
Always pass `--json` when parsing output programmatically.
|
|
16
|
+
|
|
17
|
+
## 0. Is the instance reachable?
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
hogsend doctor --json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Expect verdict `ok` (unauthenticated `/v1/health` — needs no key). If
|
|
24
|
+
`unreachable`, fix `HOGSEND_API_URL` before debugging anything else.
|
|
25
|
+
|
|
26
|
+
## 1. Fire a synthetic event through the data plane
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
TEST_ID="test_agent_$(date +%s)"
|
|
30
|
+
hogsend events send signup --user-id "$TEST_ID" --prop source=integration-test --json
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Expected output (202 path):
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{ "stored": true, "exits": [] }
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`stored: true` = the event row is durably written. This proves base URL + key +
|
|
40
|
+
scope are right, independent of the host app's code.
|
|
41
|
+
|
|
42
|
+
## 2. Confirm it landed (read path, admin key)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
hogsend events "$TEST_ID" --json
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Expect one `signup` event with `properties.source = "integration-test"`. Also
|
|
49
|
+
useful:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
hogsend contacts get "$TEST_ID" --json # contact upserted by ingestion
|
|
53
|
+
hogsend contacts timeline "$TEST_ID" --json # merged event/email/journey view
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 3. Exercise the REAL seam
|
|
57
|
+
|
|
58
|
+
Trigger the actual code path you wired — sign a test user up, or replay a
|
|
59
|
+
provider webhook (`stripe trigger checkout.session.completed` with the Stripe
|
|
60
|
+
CLI, Clerk's "Send Example" button). Then re-run step 2 with the real user's
|
|
61
|
+
id/email and confirm the event + contact appear.
|
|
62
|
+
|
|
63
|
+
## 4. Did anything react?
|
|
64
|
+
|
|
65
|
+
If journeys are defined on the Hogsend side, the send result's `exits` array
|
|
66
|
+
and `hogsend journeys list --json` (enabled journeys + state counts) show
|
|
67
|
+
whether the event enrolled/exited anyone. No journeys reacting is fine at this
|
|
68
|
+
stage — wiring the host comes first; authoring journeys is the
|
|
69
|
+
hogsend-authoring-journeys skill.
|
|
70
|
+
|
|
71
|
+
## Triage table
|
|
72
|
+
|
|
73
|
+
| Symptom | Likely cause |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `doctor` verdict `unreachable` | wrong `HOGSEND_API_URL`, instance down |
|
|
76
|
+
| 401 on `events send` | key lacks the `ingest` scope, or wrong key entirely |
|
|
77
|
+
| 401 on `events <userId>` | read path needs the ADMIN key, not the ingest key |
|
|
78
|
+
| `stored: false` | deduped — you re-sent an `idempotencyKey` already used |
|
|
79
|
+
| send ok, but nothing from the real seam | host code path not firing — log inside the seam, check the server actually restarted with new env |
|
|
80
|
+
| `RateLimitError` in app logs | back off `retryAfter` seconds; batch less aggressively |
|
|
81
|
+
|
|
82
|
+
## Clean up
|
|
83
|
+
|
|
84
|
+
Synthetic `test_agent_*` contacts can be removed via the SDK
|
|
85
|
+
(`hogsend.contacts.delete({ userId: TEST_ID })`) or left — they're inert
|
|
86
|
+
without an email address.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hogsend-migrate
|
|
3
|
+
description: Use when migrating a product OFF Loops, Customer.io, or Resend Broadcasts/Audiences onto Hogsend — auditing the existing email-SaaS integration (SDK calls in code + GUI-side workflows/campaigns/segments/templates), mapping each source concept to its Hogsend equivalent (contacts.upsert, events.send, defineJourney journeys, four-file react-email templates, defineList lists, buckets, campaigns, webhook sources), then executing an incremental dual-write → verify → switch → remove cutover that preserves unsubscribes/suppression. NOT for a greenfield integration with no incumbent (that is hogsend-integrate) and NOT for authoring mechanics themselves (delegate to the hogsend-authoring-* skills).
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
author: withSeismic
|
|
7
|
+
version: "1.0.0"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Migrate to Hogsend (from Loops / Customer.io / Resend Broadcasts)
|
|
11
|
+
|
|
12
|
+
A migration has two halves: the **code half** (SDK calls you can grep) and the
|
|
13
|
+
**GUI half** (workflows, campaigns, segments, templates that live only in the
|
|
14
|
+
source platform's dashboard). This skill audits both, maps every source concept
|
|
15
|
+
to its Hogsend equivalent, and runs an incremental cutover — never a big-bang
|
|
16
|
+
switch.
|
|
17
|
+
|
|
18
|
+
Two codebases are in play. The **host product** keeps (rewired) SDK calls — work
|
|
19
|
+
there follows the **hogsend-integrate** patterns. The **Hogsend app** (a
|
|
20
|
+
scaffolded `create-hogsend` project) is where journeys, templates, and lists are
|
|
21
|
+
authored — mechanics delegate to the **hogsend-authoring-\*** skills.
|
|
22
|
+
|
|
23
|
+
## Non-negotiable safety rules (read before anything else)
|
|
24
|
+
|
|
25
|
+
1. **Import unsubscribes BEFORE any Hogsend send.** A migration that emails one
|
|
26
|
+
unsubscribed person is a failed migration (and a legal problem). Export the
|
|
27
|
+
source platform's suppression/unsubscribe list first and apply it (cutover
|
|
28
|
+
reference shows how). Never bulk-unsubscribe everyone "to be safe" either —
|
|
29
|
+
polarity matters.
|
|
30
|
+
2. **Dual-write before switching.** Old and new pipelines run side by side
|
|
31
|
+
until verified. Hogsend writes are idempotent-friendly (`idempotencyKey`),
|
|
32
|
+
so dual-writing is cheap.
|
|
33
|
+
3. **One sender of each email at a time.** When a Hogsend journey goes live,
|
|
34
|
+
pause the corresponding source-platform workflow in the same change window —
|
|
35
|
+
duplicated sends erode trust faster than missed ones.
|
|
36
|
+
|
|
37
|
+
## Step 1 — audit what exists
|
|
38
|
+
|
|
39
|
+
**Code half** — grep the host product (full per-platform greps + before/after
|
|
40
|
+
snippets in the matching reference):
|
|
41
|
+
|
|
42
|
+
| Platform | Grep for |
|
|
43
|
+
|---|---|
|
|
44
|
+
| Loops | `loops` dep (`loops` / `@loops/*`), `sendEvent`, `updateContact`, `sendTransactionalEmail`, `transactionalId` |
|
|
45
|
+
| Customer.io | `customerio-node` dep, `cio.identify`, `cio.track`, `trackAnonymous`, `triggerBroadcast`, in-app `_cio` snippet |
|
|
46
|
+
| Resend Broadcasts | `resend.broadcasts.`, `resend.audiences.`, `resend.contacts.` — DISTINCT from plain `resend.emails.send` (transactional, may stay) |
|
|
47
|
+
|
|
48
|
+
**GUI half** — not in the codebase; ask the user to export or screenshot from
|
|
49
|
+
the source dashboard:
|
|
50
|
+
|
|
51
|
+
- workflows/campaigns (triggers, delays, branches, exit rules)
|
|
52
|
+
- segments/audiences (definitions, member counts)
|
|
53
|
+
- templates (HTML/content + which are transactional vs marketing)
|
|
54
|
+
- the suppression/unsubscribe list (CRITICAL — rule 1)
|
|
55
|
+
- API keys' event names actually flowing (last 30 days), to scope the event
|
|
56
|
+
vocabulary
|
|
57
|
+
|
|
58
|
+
Output of this step: an inventory table (source asset → type → destination in
|
|
59
|
+
Hogsend → owner) the user signs off on.
|
|
60
|
+
|
|
61
|
+
## Step 2 — map concepts (per-platform tables)
|
|
62
|
+
|
|
63
|
+
The shared shape — almost everything lands in one of seven Hogsend concepts:
|
|
64
|
+
|
|
65
|
+
| Source concept (any platform) | Hogsend equivalent |
|
|
66
|
+
|---|---|
|
|
67
|
+
| Contact / person / audience member | contact — `hs.contacts.upsert` (`@hogsend/client`) |
|
|
68
|
+
| Event / track call | `hs.events.send` (`eventProperties` vs `contactProperties` split) |
|
|
69
|
+
| Workflow / loop / campaign-with-delays | journey — `defineJourney()` in the Hogsend app's `src/journeys/` |
|
|
70
|
+
| Transactional email | `hs.emails.send` + a four-file react-email template in `src/emails/` |
|
|
71
|
+
| Mailing list / audience / topic | list — `defineList()` (polarity via `defaultOptIn`) |
|
|
72
|
+
| Behavioral segment | bucket — `defineBucket()` (criteria-driven, real-time) |
|
|
73
|
+
| One-off broadcast/newsletter | campaign — `hs.campaigns.send` / `hogsend campaigns send` (targets a list or bucket) |
|
|
74
|
+
|
|
75
|
+
Platform-specific tables, audit greps, and rewrite examples:
|
|
76
|
+
|
|
77
|
+
- **Loops** → `references/loops-mapping.md`
|
|
78
|
+
- **Customer.io** → `references/customerio-mapping.md`
|
|
79
|
+
- **Resend Broadcasts/Audiences** → `references/resend-broadcasts-mapping.md`
|
|
80
|
+
(special case: Resend can REMAIN the delivery wire — Hogsend's default
|
|
81
|
+
`EmailProvider` is Resend, so this migration replaces the orchestration
|
|
82
|
+
layer, not necessarily the sending infrastructure)
|
|
83
|
+
|
|
84
|
+
## Step 3 — generate the Hogsend setup
|
|
85
|
+
|
|
86
|
+
Work in the Hogsend app, one source asset at a time. This skill decides WHAT to
|
|
87
|
+
build; the authoring skills own HOW:
|
|
88
|
+
|
|
89
|
+
- **Each workflow/campaign-with-logic → a `defineJourney()`** in
|
|
90
|
+
`src/journeys/`. Translate: entry trigger → `trigger: { event, where? }`;
|
|
91
|
+
re-entry rules → `entryLimit: "once" | "once_per_period" | "unlimited"`;
|
|
92
|
+
goal/exit rules → `exitOn`; delays → `await ctx.sleep({ duration: days(n) })`;
|
|
93
|
+
branches → plain TypeScript `if` on `ctx.history.hasEvent(...)`. Mechanics +
|
|
94
|
+
registration ritual → **hogsend-authoring-journeys**; condition syntax →
|
|
95
|
+
**hogsend-conditions**.
|
|
96
|
+
- **Each template → the four-file contract** (`src/emails/<name>.tsx` +
|
|
97
|
+
`types.ts` props + `registry.ts` entry + `templates.d.ts` augmentation).
|
|
98
|
+
Port content into react-email components; subjects/preview/category live on
|
|
99
|
+
the registry entry. → **hogsend-authoring-emails**.
|
|
100
|
+
- **Each list/audience/topic → `defineList({ id, name, defaultOptIn })`** in
|
|
101
|
+
`src/lists/`. Get `defaultOptIn` right: a newsletter people subscribed to is
|
|
102
|
+
opt-in (`defaultOptIn: false`); product/account notices are typically opt-out
|
|
103
|
+
(`defaultOptIn: true`). → **hogsend-authoring-lists**.
|
|
104
|
+
- **Each behavioral segment → a bucket** (criteria tree over events/properties)
|
|
105
|
+
when it gates journeys or campaigns. → **hogsend-authoring-buckets**.
|
|
106
|
+
- **Inbound webhooks worth keeping** (the source platform was receiving
|
|
107
|
+
provider webhooks that should now hit Hogsend) → `defineWebhookSource()` or
|
|
108
|
+
the built-in Clerk/Supabase/Stripe/Segment presets. →
|
|
109
|
+
**hogsend-webhooks-and-workflows**.
|
|
110
|
+
- **Outbound syncs** (source platform pushed data to other tools) → webhook
|
|
111
|
+
endpoints / keyed destinations (`hs.webhooks.create` with `kind`). →
|
|
112
|
+
**hogsend-authoring-destinations**.
|
|
113
|
+
|
|
114
|
+
Then rewire the host product's SDK calls to `@hogsend/client` equivalents
|
|
115
|
+
(per-platform before/after in the references; client patterns in
|
|
116
|
+
**hogsend-client-sdk** / **hogsend-integrate**).
|
|
117
|
+
|
|
118
|
+
## Step 4 — incremental cutover (dual-write → verify → switch → remove)
|
|
119
|
+
|
|
120
|
+
The full checklist with commands is `references/cutover-checklist.md`. The
|
|
121
|
+
stages:
|
|
122
|
+
|
|
123
|
+
1. **Dual-write.** Import contacts + suppression state (suppression FIRST).
|
|
124
|
+
Add Hogsend calls BESIDE the existing SDK calls — same handlers, both fire.
|
|
125
|
+
Journeys are authored but disabled (`enabled: false` or left out of
|
|
126
|
+
`ENABLED_JOURNEYS`). Nothing user-visible changes.
|
|
127
|
+
2. **Verify.** Compare event volume between platforms over a few days
|
|
128
|
+
(`hogsend stats --json`, `hogsend events <userId> --json` spot checks).
|
|
129
|
+
Enable each journey for a seed/test contact, walk the full flow, diff
|
|
130
|
+
rendered templates against the originals (`hogsend emails send <template>
|
|
131
|
+
--to you@…`).
|
|
132
|
+
3. **Switch.** Per flow: enable the Hogsend journey
|
|
133
|
+
(`hogsend journeys enable <id>` or `ENABLED_JOURNEYS`) and pause the source
|
|
134
|
+
workflow in the same window. Flip transactional sends to `hs.emails.send`.
|
|
135
|
+
Run broadcasts as Hogsend campaigns.
|
|
136
|
+
4. **Remove.** After a clean soak (1-2 send cycles), delete the old SDK calls,
|
|
137
|
+
uninstall the dep, revoke the old API keys, export a final archive from the
|
|
138
|
+
source platform, then close the account.
|
|
139
|
+
|
|
140
|
+
## Task playbooks — load the matching reference
|
|
141
|
+
|
|
142
|
+
- **Migrating from Loops** → `references/loops-mapping.md`
|
|
143
|
+
- **Migrating from Customer.io** → `references/customerio-mapping.md`
|
|
144
|
+
- **Migrating from Resend Broadcasts/Audiences** →
|
|
145
|
+
`references/resend-broadcasts-mapping.md`
|
|
146
|
+
- **Executing the cutover (imports, verification commands, switch order,
|
|
147
|
+
rollback)** → `references/cutover-checklist.md`
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Customer.io → Hogsend mapping
|
|
2
|
+
|
|
3
|
+
Audit greps, the concept table, and before/after rewrites for a codebase using
|
|
4
|
+
Customer.io. Customer.io's API surface varies by SDK (`customerio-node` Track
|
|
5
|
+
vs App API clients) and product tier — treat the left-hand column as "look
|
|
6
|
+
for", and verify against the project's actual calls.
|
|
7
|
+
|
|
8
|
+
## Audit greps (code half)
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
grep -rn "customerio" package.json # commonly `customerio-node`
|
|
12
|
+
grep -rn "TrackClient\|APIClient\|cio\." src/
|
|
13
|
+
grep -rn "identify\|\.track(\|trackAnonymous\|triggerBroadcast" src/
|
|
14
|
+
grep -rn "_cio" src/ public/ # in-app/browser snippet
|
|
15
|
+
grep -rn "CUSTOMERIO\|CIO_" . --include="*.env*"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
GUI half (export/screenshot from the Customer.io dashboard): campaigns (entry
|
|
19
|
+
trigger, filters, delays, branches, goal/exit settings), segments (definitions
|
|
20
|
+
+ whether data-driven or manual), broadcasts/newsletters, transactional
|
|
21
|
+
messages, subscription preferences/topics, and the suppression list.
|
|
22
|
+
|
|
23
|
+
## Concept mapping
|
|
24
|
+
|
|
25
|
+
| Customer.io concept | Hogsend equivalent | Notes |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| Person + attributes (`identify`) | contact — `hs.contacts.upsert({ userId, email?, properties })` | CIO is id-keyed; Hogsend takes `userId` and/or `email`. Attributes → `properties` |
|
|
28
|
+
| `track(id, event, data)` | `hs.events.send({ userId, name, eventProperties, contactProperties? })` | CIO has ONE data bag; Hogsend has two. Sort each field: per-occurrence → `eventProperties`, durable → `contactProperties` |
|
|
29
|
+
| Anonymous events (`trackAnonymous`) | no direct equivalent | Hogsend writes need an identity (`email`/`userId`). Defer anonymous tracking to your product-analytics tool; ingest on identification |
|
|
30
|
+
| Campaign (triggered workflow) | journey — `defineJourney()` | Trigger → `trigger.event` (+ `where` for filters); frequency settings → `entryLimit`; goal/exit → `exitOn`; delays → `ctx.sleep`; time-windows → `ctx.when`; "wait until event" → `ctx.waitForEvent` |
|
|
31
|
+
| Segment (data-driven) | bucket — `defineBucket()` criteria tree | Real-time membership; journeys can trigger on `bucket.entered`/`bucket.left` transitions → hogsend-authoring-buckets |
|
|
32
|
+
| Segment (manual) | list — `defineList()` + explicit membership writes | Manual segments are membership-by-assignment, which is what lists are |
|
|
33
|
+
| Broadcast / newsletter | campaign — `hs.campaigns.send({ list \| bucket, template })` | API-triggered broadcasts (`triggerBroadcast`) also map here |
|
|
34
|
+
| Transactional message | `hs.emails.send({ to \| userId, template, props })` | Each transactional message id becomes a template key in `src/emails/` |
|
|
35
|
+
| Subscription preferences / topics | lists (`defineList`) + the built-in preference center | Topic opt-ins → `defaultOptIn: false`; suppression import → `cutover-checklist.md` |
|
|
36
|
+
| Reporting webhooks (CIO → your systems) | webhook endpoints / keyed destinations — `hs.webhooks.create({ url, eventTypes, kind?, config? })` | Hogsend emits `email.*`, `contact.*`, `journey.completed`, `bucket.*` on a durable signed spine |
|
|
37
|
+
|
|
38
|
+
## Before / after
|
|
39
|
+
|
|
40
|
+
Identify + track (host product code):
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// BEFORE (customerio-node Track API — shape varies by version)
|
|
44
|
+
cio.identify("u_1", { email: "ada@example.com", plan: "pro" });
|
|
45
|
+
cio.track("u_1", { name: "subscription_started", data: { plan: "pro", amount: 49 } });
|
|
46
|
+
|
|
47
|
+
// AFTER (@hogsend/client)
|
|
48
|
+
import { hogsend } from "../lib/hogsend.js";
|
|
49
|
+
|
|
50
|
+
await hogsend.contacts.upsert({
|
|
51
|
+
userId: "u_1",
|
|
52
|
+
email: "ada@example.com",
|
|
53
|
+
properties: { plan: "pro" },
|
|
54
|
+
});
|
|
55
|
+
await hogsend.events.send({
|
|
56
|
+
userId: "u_1",
|
|
57
|
+
name: "subscription_started",
|
|
58
|
+
eventProperties: { plan: "pro", amount: 49 }, // facts about THIS event
|
|
59
|
+
contactProperties: { plan: "pro" }, // durable fact on the person
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Campaign → journey translation guide (authored in the Hogsend app —
|
|
64
|
+
mechanics → hogsend-authoring-journeys, conditions → hogsend-conditions):
|
|
65
|
+
|
|
66
|
+
| Campaign builder block | Journey code |
|
|
67
|
+
|---|---|
|
|
68
|
+
| "Person enters when event X" | `trigger: { event: "X" }` |
|
|
69
|
+
| "…and attribute filter" | `trigger: { where: { type: "property", … } }` |
|
|
70
|
+
| "Can enter once / every N days" | `entryLimit: "once"` / `"once_per_period"` |
|
|
71
|
+
| "Exit when goal event Y" | `exitOn: [{ event: "Y" }]` |
|
|
72
|
+
| "Wait 3 days" | `await ctx.sleep({ duration: days(3) })` |
|
|
73
|
+
| "Wait until Tuesday 9am (their tz)" | `ctx.when` scheduler + `ctx.sleepUntil` |
|
|
74
|
+
| "Wait up to 7 days for event Z" | `await ctx.waitForEvent({ event: "Z", timeout: days(7) })` |
|
|
75
|
+
| "True/false branch on behavior" | `if` on `ctx.history.hasEvent(...)` / `ctx.history.email(...)` |
|
|
76
|
+
| "Send email" | `await sendEmail({ …, journeyStateId: user.stateId })` |
|
|
77
|
+
|
|
78
|
+
## Customer.io-specific gotchas
|
|
79
|
+
|
|
80
|
+
- **One data bag → two.** The single `data` object on `cio.track` must be
|
|
81
|
+
consciously split into `eventProperties` vs `contactProperties`. Default
|
|
82
|
+
per-field to `eventProperties`; promote to `contactProperties` only what a
|
|
83
|
+
later check on the person should read.
|
|
84
|
+
- **Anonymous → identified flows have no Hogsend equivalent** — plan to start
|
|
85
|
+
lifecycle tracking at identification (signup), which is where journeys
|
|
86
|
+
almost always start anyway.
|
|
87
|
+
- **In-app `_cio` snippet** (browser tracking) is product analytics, not
|
|
88
|
+
lifecycle orchestration. Don't port it to Hogsend; if the team uses PostHog,
|
|
89
|
+
that's its home, and Hogsend can consume those signals later.
|
|
90
|
+
- **Liquid templates** don't transplant. Re-author as react-email with typed
|
|
91
|
+
props; conditional content blocks become plain JSX conditionals.
|
|
92
|
+
- **Campaign analytics history stays behind.** Export CSVs for the record;
|
|
93
|
+
Hogsend metrics start at cutover.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Cutover checklist — dual-write → verify → switch → remove
|
|
2
|
+
|
|
3
|
+
The execution playbook. Work flow-by-flow, never big-bang. Keys: data-plane
|
|
4
|
+
writes use the ingest key (`HOGSEND_API_KEY`); admin reads/writes use an admin
|
|
5
|
+
key (`HOGSEND_ADMIN_KEY` / `ADMIN_API_KEY`). `--json` on every CLI call you
|
|
6
|
+
parse.
|
|
7
|
+
|
|
8
|
+
## Stage 0 — imports (BEFORE anything sends)
|
|
9
|
+
|
|
10
|
+
### 0a. Contacts
|
|
11
|
+
|
|
12
|
+
Bulk path — the admin import endpoint (admin key):
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
POST /v1/admin/contacts/import
|
|
16
|
+
{ "format": "csv" | "json", "data": "<file contents>", "fileName?": "..." }
|
|
17
|
+
→ 202 { jobId, status }
|
|
18
|
+
GET /v1/admin/contacts/import/{jobId} → processed/failed counts + errors
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Rows carry `externalId?`, `email?`, `properties?` (CSV: those columns; at least
|
|
22
|
+
one identity per row). **The bulk import does NOT carry list membership or
|
|
23
|
+
unsubscribe state** — handle those in 0b/0c.
|
|
24
|
+
|
|
25
|
+
Scripted alternative (and the way to set list membership in the same pass):
|
|
26
|
+
loop the export through the SDK —
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
for (const row of exported) {
|
|
30
|
+
await hogsend.contacts.upsert({
|
|
31
|
+
email: row.email,
|
|
32
|
+
userId: row.externalId,
|
|
33
|
+
properties: row.properties,
|
|
34
|
+
lists: { newsletter: row.subscribedToNewsletter === true },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`contacts.upsert` is a true upsert — re-running the loop is safe.
|
|
40
|
+
|
|
41
|
+
### 0b. List membership
|
|
42
|
+
|
|
43
|
+
Either inline via `lists` on the upsert (above), or per-list:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
await hogsend.lists.subscribe({ list: "newsletter", email: row.email });
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Mind polarity: for an opt-in list (`defaultOptIn: false`) you only need to
|
|
50
|
+
subscribe the members; for an opt-out list you only need to record the
|
|
51
|
+
unsubscribes.
|
|
52
|
+
|
|
53
|
+
### 0c. Suppression / unsubscribes (THE critical import)
|
|
54
|
+
|
|
55
|
+
- **List-level unsubscribes** →
|
|
56
|
+
`hogsend.lists.unsubscribe({ list, email })` per contact+list.
|
|
57
|
+
- **Global unsubscribes** ("never email this person") → the admin preferences
|
|
58
|
+
route, per contact (UUID or externalId both work as `{contactId}`):
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
PUT /v1/admin/contacts/{contactId}/preferences
|
|
62
|
+
{ "unsubscribedAll": true }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
(`suppressed: true` is also accepted there — use it for hard-bounced/complained
|
|
66
|
+
addresses from the source platform's suppression export.)
|
|
67
|
+
|
|
68
|
+
**Gate: do not proceed to any send until a spot check passes** — pick 3 known
|
|
69
|
+
unsubscribed addresses, confirm via
|
|
70
|
+
`hogsend contacts get <id> --json` / the preferences route that they show
|
|
71
|
+
`unsubscribedAll: true`, and confirm a test `hogsend emails send` to one of
|
|
72
|
+
them is refused by the preference check.
|
|
73
|
+
|
|
74
|
+
## Stage 1 — dual-write
|
|
75
|
+
|
|
76
|
+
- Add `@hogsend/client` calls BESIDE the existing SDK calls (same handlers,
|
|
77
|
+
both fire). Use `idempotencyKey` on webhook-driven events.
|
|
78
|
+
- Author journeys/templates/lists in the Hogsend app, but keep journeys OFF:
|
|
79
|
+
`enabled: false` in meta, or excluded from the `ENABLED_JOURNEYS` env
|
|
80
|
+
(comma-separated ids; `*` = all).
|
|
81
|
+
- Keep the source platform fully live. Nothing user-visible changes.
|
|
82
|
+
|
|
83
|
+
## Stage 2 — verify (run a few days)
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
hogsend doctor --json # instance healthy
|
|
87
|
+
hogsend stats --json # totalContacts matches the import
|
|
88
|
+
hogsend events <userId> --json # spot-check: events arriving for real users
|
|
89
|
+
hogsend contacts timeline <id> --json # merged activity view for a known user
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- **Volume:** compare daily event counts per event name against the source
|
|
93
|
+
platform's numbers. Investigate gaps > a few percent.
|
|
94
|
+
- **Journeys:** enroll a seed contact (your own address):
|
|
95
|
+
`hogsend events send signup --email you@yourco.com --json`, temporarily
|
|
96
|
+
enable the journey (`hogsend journeys enable <id>`), walk the full flow —
|
|
97
|
+
delays, branches, sends — then disable again if the soak isn't done.
|
|
98
|
+
- **Templates:** send each to yourself
|
|
99
|
+
(`hogsend emails send <template> --to you@yourco.com --prop key=value`) and
|
|
100
|
+
diff against the source platform's rendering. Check the unsubscribe link
|
|
101
|
+
resolves.
|
|
102
|
+
|
|
103
|
+
## Stage 3 — switch (per flow, old OFF in the same window)
|
|
104
|
+
|
|
105
|
+
For each lifecycle flow, in one change window:
|
|
106
|
+
|
|
107
|
+
1. Enable the Hogsend journey — `hogsend journeys enable <id> --json` (or ship
|
|
108
|
+
`enabled: true` / add to `ENABLED_JOURNEYS` and deploy).
|
|
109
|
+
2. Pause the corresponding workflow/campaign in the source platform's
|
|
110
|
+
dashboard.
|
|
111
|
+
3. Watch the first real enrollments: `hogsend journeys get <id> --json`
|
|
112
|
+
(state counts + recent states).
|
|
113
|
+
|
|
114
|
+
For transactional: flip each call site from the old SDK to `hs.emails.send`
|
|
115
|
+
(delete the old call, don't dual-send transactional — a user must never get
|
|
116
|
+
two password resets). For broadcasts: run the next one as a Hogsend campaign
|
|
117
|
+
(`hogsend campaigns send --list … --template …`; progress via
|
|
118
|
+
`hogsend campaigns status <id> --json`).
|
|
119
|
+
|
|
120
|
+
## Stage 4 — remove
|
|
121
|
+
|
|
122
|
+
After 1-2 clean send cycles:
|
|
123
|
+
|
|
124
|
+
- Delete the old SDK calls and the dep (`pnpm remove <pkg>`); remove its env
|
|
125
|
+
vars from every environment.
|
|
126
|
+
- Revoke the source platform's API keys.
|
|
127
|
+
- Export final archives (campaign history, analytics) — they don't migrate.
|
|
128
|
+
- Re-export the source's suppression list ONE more time and re-run 0c — people
|
|
129
|
+
unsubscribed during the dual-write window must not be lost.
|
|
130
|
+
- Close/downgrade the account.
|
|
131
|
+
|
|
132
|
+
## Rollback
|
|
133
|
+
|
|
134
|
+
Until Stage 4, rollback is cheap and symmetrical: disable the Hogsend journey
|
|
135
|
+
(`hogsend journeys disable <id>`), resume the source workflow. That symmetry is
|
|
136
|
+
the reason for flow-by-flow switching — keep it until you delete the old code.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Loops → Hogsend mapping
|
|
2
|
+
|
|
3
|
+
Audit greps, the concept table, and before/after rewrites for a codebase using
|
|
4
|
+
Loops (loops.so). Loops' own API may differ by SDK version — treat the
|
|
5
|
+
left-hand column as "look for", and verify against the project's actual calls.
|
|
6
|
+
|
|
7
|
+
## Audit greps (code half)
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
grep -rn "loops" package.json # the SDK dep (commonly `loops`)
|
|
11
|
+
grep -rn "sendEvent\|updateContact\|sendTransactionalEmail\|createContact" src/
|
|
12
|
+
grep -rn "transactionalId" src/ # transactional template ids
|
|
13
|
+
grep -rn "LOOPS_API_KEY\|loops.so" . --include="*.env*" --include="*.ts"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
GUI half (ask the user to export/screenshot from the Loops dashboard): Loops
|
|
17
|
+
(the workflows), audiences/segments, mailing lists + their opt-in state,
|
|
18
|
+
email templates, and the unsubscribed-contacts export.
|
|
19
|
+
|
|
20
|
+
## Concept mapping
|
|
21
|
+
|
|
22
|
+
| Loops concept | Hogsend equivalent | Notes |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| Contact (+ contact properties) | contact — `hs.contacts.upsert({ email, userId?, properties })` | Loops keys contacts on email; carry the app's user id as `userId` too — Hogsend identity is `email` and/or `userId` |
|
|
25
|
+
| `sendEvent` (event + event/contact properties) | `hs.events.send({ …, eventProperties, contactProperties })` | Keep the bag split deliberate: per-occurrence facts → `eventProperties` (drive journey `trigger.where`/`exitOn`); durable facts → `contactProperties` |
|
|
26
|
+
| Loop (the visual workflow) | journey — `defineJourney()` in `src/journeys/` | Entry trigger → `trigger.event`; "enter once" → `entryLimit: "once"`; goal/exit → `exitOn`; timers → `ctx.sleep({ duration: days(n) })`; audience filters → `trigger.where` conditions |
|
|
27
|
+
| Transactional email (`sendTransactionalEmail` + `transactionalId`) | `hs.emails.send({ to | userId, template, props })` + a four-file template | Each `transactionalId` becomes a template key in the Hogsend app's `src/emails/` registry; `dataVariables` become typed `props` |
|
|
28
|
+
| Mailing list (opt-in/opt-out toggle) | list — `defineList({ id, name, defaultOptIn })` | Loops' "public" opt-in lists → `defaultOptIn: false`; default-subscribed lists → `defaultOptIn: true` |
|
|
29
|
+
| Audience / segment filter | bucket (`defineBucket`) when behavioral; `trigger.where` when it's just an entry filter | Don't over-build: a one-condition audience is usually just a `where` clause |
|
|
30
|
+
| Campaign (one-off blast) | campaign — `hs.campaigns.send({ list | bucket, template, props })` | Or `hogsend campaigns send --list … --template …` |
|
|
31
|
+
| Unsubscribed contacts | suppression import — see `cutover-checklist.md` | Import BEFORE any send |
|
|
32
|
+
|
|
33
|
+
## Before / after
|
|
34
|
+
|
|
35
|
+
Contact + event (host product code):
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// BEFORE (Loops — shape varies by SDK version)
|
|
39
|
+
await loops.updateContact("ada@example.com", { plan: "pro" });
|
|
40
|
+
await loops.sendEvent({
|
|
41
|
+
email: "ada@example.com",
|
|
42
|
+
eventName: "subscription_started",
|
|
43
|
+
eventProperties: { plan: "pro" },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// AFTER (@hogsend/client)
|
|
47
|
+
import { hogsend } from "../lib/hogsend.js";
|
|
48
|
+
|
|
49
|
+
await hogsend.contacts.upsert({
|
|
50
|
+
email: "ada@example.com",
|
|
51
|
+
userId: user.id,
|
|
52
|
+
properties: { plan: "pro" },
|
|
53
|
+
});
|
|
54
|
+
await hogsend.events.send({
|
|
55
|
+
email: "ada@example.com",
|
|
56
|
+
userId: user.id,
|
|
57
|
+
name: "subscription_started",
|
|
58
|
+
eventProperties: { plan: "pro" },
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Transactional:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// BEFORE (Loops)
|
|
66
|
+
await loops.sendTransactionalEmail({
|
|
67
|
+
transactionalId: "clxyz...",
|
|
68
|
+
email: "ada@example.com",
|
|
69
|
+
dataVariables: { resetUrl },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// AFTER — template authored in the Hogsend app first
|
|
73
|
+
// (four-file contract → hogsend-authoring-emails skill)
|
|
74
|
+
await hogsend.emails.send({
|
|
75
|
+
to: "ada@example.com",
|
|
76
|
+
template: "password-reset",
|
|
77
|
+
props: { resetUrl },
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A Loop becomes a journey (authored in the HOGSEND app, not the host —
|
|
82
|
+
mechanics → hogsend-authoring-journeys):
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// "When signup occurs → wait 2 days → if no project created, send nudge"
|
|
86
|
+
import { days } from "@hogsend/core";
|
|
87
|
+
import { defineJourney, sendEmail } from "@hogsend/engine";
|
|
88
|
+
import { Events, Templates } from "./constants/index.js";
|
|
89
|
+
|
|
90
|
+
export const onboardingNudge = defineJourney({
|
|
91
|
+
meta: {
|
|
92
|
+
id: "onboarding-nudge",
|
|
93
|
+
name: "Onboarding nudge",
|
|
94
|
+
enabled: false, // stays off until the cutover switch stage
|
|
95
|
+
trigger: { event: Events.SIGNUP },
|
|
96
|
+
entryLimit: "once",
|
|
97
|
+
suppress: days(7),
|
|
98
|
+
exitOn: [{ event: Events.PROJECT_CREATED }],
|
|
99
|
+
},
|
|
100
|
+
run: async (user, ctx) => {
|
|
101
|
+
await ctx.sleep({ duration: days(2), label: "post-signup" });
|
|
102
|
+
const { found } = await ctx.history.hasEvent({
|
|
103
|
+
userId: user.id,
|
|
104
|
+
event: Events.PROJECT_CREATED,
|
|
105
|
+
});
|
|
106
|
+
if (!found) {
|
|
107
|
+
await sendEmail({
|
|
108
|
+
to: user.email,
|
|
109
|
+
userId: user.id,
|
|
110
|
+
journeyStateId: user.stateId,
|
|
111
|
+
template: Templates.ONBOARDING_NUDGE,
|
|
112
|
+
journeyName: user.journeyName,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Loops-specific gotchas
|
|
120
|
+
|
|
121
|
+
- **Email-keyed identity.** Loops contacts are keyed on email. If the app has
|
|
122
|
+
stable user ids, send BOTH on every Hogsend write so identities link
|
|
123
|
+
(`linked: true` in the upsert result confirms a merge).
|
|
124
|
+
- **Event property names become your journey vocabulary.** Journeys'
|
|
125
|
+
`trigger.where` matches `eventProperties` keys — keep names stable across the
|
|
126
|
+
rewrite, or update both sides together.
|
|
127
|
+
- **Template content is GUI-side.** Export each template's content from the
|
|
128
|
+
Loops editor; you are re-authoring it as react-email, not copying HTML
|
|
129
|
+
verbatim (use the Hogsend app's `src/emails/_components/` chrome).
|
|
130
|
+
- **Loops' unsubscribe is per-mailing-list + global.** Map list-level
|
|
131
|
+
unsubscribes to `hs.lists.unsubscribe({ list, email })` and global ones to
|
|
132
|
+
the admin preferences route (`unsubscribedAll`) — see `cutover-checklist.md`.
|