Skip to content

Commit 081a337

Browse files
committed
feat: prepare Loops lifecycle drafts and held audience migration
1 parent 172733b commit 081a337

14 files changed

Lines changed: 2366 additions & 0 deletions

scripts/loops/README.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Cap lifecycle email migration
2+
3+
This prepares Cap's Bento audience and replacement Loops journeys. Provisioning creates drafts only. Importing and syncing keep `capLifecycleEnabled=false`, `capOnboardingEligible=false`, and `capLifecycleStage=idle`. No script activates workflows, sends messages, or replays historical events.
4+
5+
The September 2026 repository audit found no Bento SDK, environment variable, or send call to replace. Bento's marketing automations were configured outside this repository. Existing Resend authentication, invitation, billing, notification, support, and BAA emails remain in place.
6+
7+
## Audience rules
8+
9+
| Audience | Evidence | Draft journey |
10+
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
11+
| Independent free user | Independent signup or a matching personal organization created at signup, with no paid entitlement or uncertain customer history | Welcome, recording help on day 2, sharing help on day 5, plan guidance on day 9 |
12+
| Customer | Active/trialing/paid/past-due Cap Pro, a paid organization seat, or a currently valid desktop/self-hosted license | Plan-specific welcome, a reusable recording idea on day 3, feedback on day 7 |
13+
| Teammate | Invitation, SSO, another owner's organization, third-party subscription, or a previously recorded teammate flag | Workspace setup, then handoff guidance on day 3; no promotional journey |
14+
| Former customer | Canceled cloud subscription with no desktop/self-hosted license history | Feedback request after 14 days |
15+
| Unknown | Missing identity/origin, stale customer tags, expired license evidence, or ambiguous entitlement | No journey or campaign audience |
16+
17+
Teammate evidence takes priority and is retained after membership changes. A future independently initiated purchase does not automatically erase that history. Customer classification uses Cap and the separate license database, not Bento's historical customer tag alone. A license's active flag is insufficient without a future renewal date.
18+
19+
Every journey requires a global subscription, positive consent, the correct audience, and both enrollment flags. Its audience filter applies downstream. Free/former promotional journeys also require `capTeammate=false` and `capPromotionalEligible=true`. Recording/sharing branches skip the corresponding reminder when the known cloud milestone is complete; local-only recordings cannot be observed by this sync.
20+
21+
All imported contacts remain held, including recent signups. Historical contacts must never be enrolled by replaying a contact-created event or bulk changing their lifecycle stage.
22+
23+
## Consent and private data
24+
25+
`prepare.ts` excludes source opt-outs, suppression flags, bounces, complaints, and conflicting negative subscription fields from the positive import. It also produces a normalized-email SHA-256 registry for every source row. Retain that registry even when excluded addresses are not created in Loops. Hashes are sensitive matching identifiers, not anonymous data.
26+
27+
Reconcile opt-outs already present in the destination by setting those existing records to unsubscribed. Do not import excluded addresses as new subscribed contacts. Loops-native global and mailing-list opt-outs must survive migration and later syncs. The normal sync never writes `subscribed=true` to an existing contact and never replaces mailing-list preferences. Missing imported contacts are not recreated.
28+
29+
Keep exports, prepared contacts, CSVs, registry files, receipts, database connection strings, and API keys outside Git, in a private directory. Do not expose license keys; the classifier only needs email, entitlement kind, active status, and renewal date.
30+
31+
## Commands
32+
33+
Run from the repository root with Bun 1.4. Supply `LOOPS_API_KEY` through the process environment. Every API command checks the exact expected team name. Cap's migration account is **Cap Software, Inc.**; a separate Loops account named **Cap** must not be used.
34+
35+
```sh
36+
bun scripts/loops/provision.ts --team 'Cap Software, Inc.' --dry-run
37+
bun scripts/loops/provision.ts --team 'Cap Software, Inc.' --state "$LOOPS_PRIVATE_DIR/provision-state.json" --mailing-list "$LOOPS_MAILING_LIST_ID" --apply
38+
bun scripts/loops/verify.ts --team 'Cap Software, Inc.' --state "$LOOPS_PRIVATE_DIR/provision-state.json" --mailing-list "$LOOPS_MAILING_LIST_ID"
39+
bun scripts/loops/prepare.ts --sources "$LOOPS_PRIVATE_DIR" --output "$LOOPS_PRIVATE_DIR/import"
40+
bun scripts/loops/import.ts --team 'Cap Software, Inc.' --contacts "$LOOPS_PRIVATE_DIR/import/contacts.json" --receipt "$LOOPS_PRIVATE_DIR/import/receipt.jsonl" --mailing-list "$LOOPS_MAILING_LIST_ID"
41+
```
42+
43+
Review the dry-run counts, then add `--apply` to import. Use the same receipt to resume after interruption; existing identities are checked and enriched without resubscribing. Invalid rows are recorded privately. Correct their input before using `--retry-invalid`. A network-interrupted create may have succeeded, so reconcile through the same importer rather than deleting/recreating contacts.
44+
45+
The source directory must contain arrays in `bento-contacts.json`, `cap-users.json`, `cap-memberships.json`, `license-licenses.json`, `cap-invites.json`, `cap-sso.json`, and `cap-videos.json`. Their shapes are defined in `profile.ts`, `prepare.ts`, and `sources.ts`. Preserve original source CSVs. Fresh source snapshots are required; preparation refuses files older than 24 hours.
46+
47+
The native Loops CSV importer can be used for the reviewed positive file. Check every column mapping, select the migration mailing list, and leave **Trigger workflows** off. Loops matches user ID before email; resolve destination identity conflicts first. Export the destination afterward and reconcile all intended addresses, opt-outs, audience fields, and enrollment holds. Native CSV imports and API receipts are separate records.
48+
49+
Provisioning uses revision checks and a private receipt to resume partially created resources. Run `verify.ts` after every provisioning pass: it independently reads the actual graph, downstream filters, branch reconnections, timing, message text/links, fallback, campaign segments, mailing lists, draft states, and Guardian results. Investigate verification failures before editing a managed draft manually or reusing its receipt.
50+
51+
## Repository rollout
52+
53+
The generated migration adds `users.marketingOrigin` and `marketing_contacts`. Apply the schema through the normal reviewed PlanetScale deployment process **before deploying code that selects the new column**. No production database migration is performed by preparation or provisioning.
54+
55+
New signup, invite acceptance, and SSO provisioning record origin without subscribing anyone. Existing invitation/membership/SSO evidence supplements this field for the historical import. The registry has no user foreign key so an opt-out can survive account deletion.
56+
57+
After the schema is deployed, dry-run the registry seed:
58+
59+
```sh
60+
bun scripts/loops/seed.ts --registry "$LOOPS_PRIVATE_DIR/import/consent-registry.json" --contacts "$LOOPS_PRIVATE_DIR/import/contacts.json"
61+
```
62+
63+
Applying requires `DATABASE_URL`, `LOOPS_SYNC_ENABLED=true`, and `--apply`. Registry merges retain existing opt-outs and teammate history. Initial denied records store their hash without a raw email; an already stored email is retained when a later opt-out must be propagated.
64+
65+
The held-contact sync additionally requires read-only `LOOPS_LICENSE_DATABASE_URL`:
66+
67+
```sh
68+
bun scripts/loops/sync.ts --team 'Cap Software, Inc.' --mailing-list "$LOOPS_MAILING_LIST_ID" --limit 100
69+
```
70+
71+
Review the dry run before adding `--apply`. Use a single runner and bounded batches. The sync refreshes entitlement/origin/milestone fields, preserves consent, compares profile fingerprints, rechecks remote consent at least daily when drained, and refuses a source snapshot older than ten minutes. Repeated runs drain pending changes. It remains a held-contact sync, not an enrollment service.
72+
73+
## Before any activation
74+
75+
Activation is deliberately outside this migration's approved scope. These gates remain necessary:
76+
77+
1. Review actual draft copy and audiences, resolve the sending-domain DMARC warning, and configure rotated credentials in the intended environment.
78+
2. Deploy the reviewed schema/code, seed the complete suppression registry, and prove the held sync with real free, paid, licensed, invited, SSO, unsubscribed, deleted, and changed-email cases.
79+
3. Implement and verify explicit new-consent capture, lifecycle enrollment for new eligible contacts, bounded retries, duplicate prevention, and an observed sync schedule. This branch does not infer marketing consent from account creation.
80+
4. Add a freshness mechanism and monitoring before live sends. Downstream filters use the last synced fields; `capVerifiedAt` is evidence, not a native expiry guarantee. Prove purchase/invite/opt-out transitions remove contacts before later promotional steps. Do not enable the current held sync alongside active enrollment because it deliberately returns contacts to idle.
81+
5. Reconcile a fresh Bento delta at cutover. Confirm no campaign/flow is queued to send twice, then disable old Bento marketing automations only as part of the approved cutover. Preserve source history and opt-outs before retiring Bento.
82+
83+
## Validation
84+
85+
```sh
86+
bun test scripts/loops/profile.test.ts
87+
bun run biome check scripts/loops packages/database/schema.ts packages/database/auth/drizzle-adapter.ts packages/database/auth/sso.ts apps/web/app/api/invite/accept/route.ts
88+
bun run tsc -b packages/database
89+
bun run --cwd apps/web next typegen
90+
bun run tsc -b apps/web
91+
```
92+
93+
Unit tests cover consent conflicts, conservative customer classification, teammate suppression, imported enrollment holds, identity preservation, and stable sync fingerprints. They do not prove production email delivery or a live cutover.

scripts/loops/api.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
export type Json =
2+
| null
3+
| boolean
4+
| number
5+
| string
6+
| Json[]
7+
| { [key: string]: Json };
8+
9+
export class LoopsApiError extends Error {
10+
constructor(
11+
public status: number,
12+
public path: string,
13+
public details: unknown,
14+
) {
15+
super(`Loops ${status} at ${path}`);
16+
}
17+
}
18+
19+
export class LoopsApi {
20+
private nextRequestAt = 0;
21+
22+
constructor(
23+
private key: string,
24+
private intervalMs = 750,
25+
) {
26+
if (!key) throw new Error("LOOPS_API_KEY is required");
27+
if (intervalMs < 125)
28+
throw new Error("Keep requests below the Loops team rate limit");
29+
}
30+
31+
async request<T>(path: string, method = "GET", body?: unknown): Promise<T> {
32+
for (let attempt = 0; ; attempt++) {
33+
const scheduledAt = Math.max(Date.now(), this.nextRequestAt);
34+
this.nextRequestAt = scheduledAt + this.intervalMs;
35+
await new Promise((resolve) =>
36+
setTimeout(resolve, Math.max(0, scheduledAt - Date.now())),
37+
);
38+
let response: Response;
39+
let result: unknown;
40+
try {
41+
response = await fetch(`https://app.loops.so/api/v1/${path}`, {
42+
method,
43+
headers: {
44+
Authorization: `Bearer ${this.key}`,
45+
"Content-Type": "application/json",
46+
},
47+
body: body === undefined ? undefined : JSON.stringify(body),
48+
signal: AbortSignal.timeout(30_000),
49+
});
50+
result = await response.json();
51+
} catch (error) {
52+
if (attempt >= 4 || (method !== "GET" && method !== "PUT")) throw error;
53+
this.nextRequestAt = Math.max(
54+
this.nextRequestAt,
55+
Date.now() + 1000 * 2 ** attempt,
56+
);
57+
continue;
58+
}
59+
if (response.ok) return result as T;
60+
const retryable =
61+
response.status === 429 ||
62+
(response.status >= 500 && (method === "GET" || method === "PUT"));
63+
if (!retryable || attempt >= 4) {
64+
throw new LoopsApiError(response.status, path, result);
65+
}
66+
const retryAfter = Number(response.headers.get("retry-after"));
67+
this.nextRequestAt = Math.max(
68+
this.nextRequestAt,
69+
Date.now() +
70+
Math.max(
71+
Number.isFinite(retryAfter) ? retryAfter * 1000 : 0,
72+
1000 * 2 ** attempt,
73+
),
74+
);
75+
}
76+
}
77+
78+
async list<T>(path: string): Promise<T[]> {
79+
const rows: T[] = [];
80+
let cursor: string | null = null;
81+
do {
82+
const page: { data: T[]; pagination: { nextCursor: string | null } } =
83+
await this.request(
84+
`${path}?perPage=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`,
85+
);
86+
rows.push(...page.data);
87+
cursor = page.pagination.nextCursor;
88+
} while (cursor);
89+
return rows;
90+
}
91+
}

scripts/loops/import.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { appendFile, chmod, readFile } from "node:fs/promises";
2+
import { parseArgs } from "node:util";
3+
import { LoopsApi, LoopsApiError } from "./api";
4+
import { type ContactProfile, emailHash } from "./profile";
5+
import { importContactUpdate, type RemoteContact } from "./sync-policy";
6+
7+
type Receipt = {
8+
hash: string;
9+
status: "created" | "existing" | "updated" | "invalid";
10+
id?: string;
11+
detail?: unknown;
12+
};
13+
const { values } = parseArgs({
14+
options: {
15+
contacts: { type: "string" },
16+
receipt: { type: "string" },
17+
team: { type: "string" },
18+
apply: { type: "boolean", default: false },
19+
limit: { type: "string" },
20+
"mailing-list": { type: "string" },
21+
"retry-invalid": { type: "boolean", default: false },
22+
},
23+
});
24+
if (!values.contacts || !values.receipt || !values.team)
25+
throw new Error("Pass --contacts, --receipt and --team");
26+
const filename = values.receipt;
27+
const profiles: ContactProfile[] = JSON.parse(
28+
await readFile(values.contacts, "utf8"),
29+
);
30+
const hashes = new Set<string>();
31+
for (const profile of profiles) {
32+
const hash = emailHash(profile.email);
33+
if (
34+
hashes.has(hash) ||
35+
!profile.subscribed ||
36+
profile.capConsent !== "subscribed" ||
37+
profile.capLifecycleEnabled ||
38+
profile.capOnboardingEligible ||
39+
profile.capLifecycleStage !== "idle"
40+
)
41+
throw new Error(
42+
"Import must contain unique, positively subscribed, held contacts",
43+
);
44+
hashes.add(hash);
45+
}
46+
const prior: Receipt[] = [];
47+
try {
48+
for (const line of (await readFile(filename, "utf8"))
49+
.split("\n")
50+
.filter(Boolean))
51+
prior.push(JSON.parse(line));
52+
} catch (error) {
53+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
54+
throw error;
55+
}
56+
const latest = new Map(prior.map((row) => [row.hash, row]));
57+
const done = new Set(
58+
[...latest.values()]
59+
.filter(
60+
(row) =>
61+
row.status !== "existing" &&
62+
!(values["retry-invalid"] && row.status === "invalid"),
63+
)
64+
.map((row) => row.hash),
65+
);
66+
const limit = values.limit ? Number(values.limit) : profiles.length;
67+
if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("Invalid limit");
68+
const pending = profiles
69+
.filter((profile) => !done.has(emailHash(profile.email)))
70+
.slice(0, limit);
71+
const api = new LoopsApi(process.env.LOOPS_API_KEY ?? "", 150);
72+
const identity = await api.request<{ success: boolean; teamName: string }>(
73+
"api-key",
74+
);
75+
if (!identity.success || identity.teamName !== values.team)
76+
throw new Error("Wrong Loops team");
77+
const workflows = await api.list<{ id: string }>("workflows");
78+
for (const row of workflows) {
79+
const workflow = await api.request<{ status: string }>(`workflows/${row.id}`);
80+
if (workflow.status !== "Draft")
81+
throw new Error("Contact import requires all workflows to remain drafts");
82+
}
83+
const lists = await api.request<{ id: string; name: string }[]>("lists");
84+
const listId =
85+
values["mailing-list"] ??
86+
lists.find((list) => list.name === "Product updates and tips")?.id;
87+
if (listId && !lists.some((list) => list.id === listId))
88+
throw new Error("Unknown mailing list");
89+
console.log(
90+
JSON.stringify({
91+
team: identity.teamName,
92+
mode: values.apply ? "create missing contacts" : "dry run",
93+
total: profiles.length,
94+
alreadyProcessed: done.size,
95+
pending: pending.length,
96+
mailingListId: listId ?? null,
97+
}),
98+
);
99+
if (!values.apply) process.exit(0);
100+
await appendFile(filename, "", { mode: 0o600 });
101+
await chmod(filename, 0o600);
102+
let next = 0;
103+
let stopped = false;
104+
let completed = 0;
105+
const counts = { created: 0, existing: 0, updated: 0, invalid: 0 };
106+
const worker = async () => {
107+
while (!stopped && next < pending.length) {
108+
const profile = pending[next++];
109+
if (!profile) return;
110+
const hash = emailHash(profile.email);
111+
let receipt: Receipt;
112+
try {
113+
const result = await api.request<{ success: boolean; id: string }>(
114+
"contacts/create",
115+
"POST",
116+
{ ...profile, ...(listId ? { mailingLists: { [listId]: true } } : {}) },
117+
);
118+
if (!result.success || !result.id)
119+
throw new Error(
120+
"Unexpected create response; reconcile before retrying",
121+
);
122+
receipt = { hash, status: "created", id: result.id };
123+
} catch (error) {
124+
if (error instanceof LoopsApiError && error.status === 409) {
125+
const contacts = await api.request<RemoteContact[]>(
126+
`contacts/find?email=${encodeURIComponent(profile.email)}`,
127+
);
128+
const contact = contacts[0];
129+
if (contacts.length !== 1 || !contact) {
130+
stopped = true;
131+
throw new Error(
132+
"Resolve the existing contact identity before resuming",
133+
);
134+
}
135+
await api.request(
136+
"contacts/update",
137+
"PUT",
138+
importContactUpdate(profile, contact, listId),
139+
);
140+
receipt = { hash, status: "updated", id: contact.id };
141+
} else if (error instanceof LoopsApiError && error.status === 400)
142+
receipt = { hash, status: "invalid", detail: error.details };
143+
else {
144+
stopped = true;
145+
throw error;
146+
}
147+
}
148+
await appendFile(filename, `${JSON.stringify(receipt)}\n`);
149+
counts[receipt.status]++;
150+
completed++;
151+
if (completed % 250 === 0)
152+
console.log(
153+
JSON.stringify({ processed: done.size + completed, ...counts }),
154+
);
155+
}
156+
};
157+
const results = await Promise.allSettled(
158+
Array.from({ length: 8 }, async () => {
159+
try {
160+
await worker();
161+
} catch (error) {
162+
stopped = true;
163+
throw error;
164+
}
165+
}),
166+
);
167+
console.log(
168+
JSON.stringify({
169+
processed: done.size + completed,
170+
...counts,
171+
remaining: profiles.length - done.size - completed,
172+
}),
173+
);
174+
for (const result of results)
175+
if (result.status === "rejected") {
176+
console.error(
177+
result.reason instanceof LoopsApiError
178+
? `Import stopped with HTTP ${result.reason.status}; resume with the same receipt`
179+
: "Import stopped; reconcile the receipt before resuming",
180+
);
181+
process.exitCode = 1;
182+
}

0 commit comments

Comments
 (0)