Skip to content

Commit 2355832

Browse files
committed
fix: keep historical Loops profiles stable across cutover settings
1 parent 17b4c8e commit 2355832

4 files changed

Lines changed: 71 additions & 10 deletions

File tree

packages/database/loops/lifecycle.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,19 @@ export type LoopsRuntimeConfig = {
2222
teammateJoinedAt?: string | null;
2323
};
2424

25+
export function enrollmentWindow(signupAt: string, config: LoopsRuntimeConfig) {
26+
return {
27+
recentSignup:
28+
config.enrollmentEnabled &&
29+
Date.parse(signupAt) >= config.enrollmentAfter.getTime(),
30+
recentJoin:
31+
config.enrollmentEnabled &&
32+
Boolean(config.teammateJoinedAt) &&
33+
Date.parse(isoDate(config.teammateJoinedAt ?? "")) >=
34+
config.enrollmentAfter.getTime(),
35+
};
36+
}
37+
2538
export function lifecycleUpdate(
2639
source: LoopsProfileSource,
2740
remote: LifecycleContact,
@@ -42,20 +55,15 @@ export function lifecycleUpdate(
4255
!["unsubscribed", "suppressed"].includes(remote.capConsent ?? "");
4356
const subscribed =
4457
globallySubscribed && remote.mailingLists[config.listId] === true;
45-
const recentSignup =
46-
Date.parse(profile.capSignupAt) >= config.enrollmentAfter.getTime();
58+
const enrollment = enrollmentWindow(profile.capSignupAt, config);
4759
const imported = Boolean(remote.capImportedAt);
48-
const recentJoin =
49-
teammate &&
50-
Boolean(config.teammateJoinedAt) &&
51-
Date.parse(isoDate(config.teammateJoinedAt ?? "")) >=
52-
config.enrollmentAfter.getTime();
5360
const eligible =
5461
config.enrollmentEnabled &&
5562
source.signedUp &&
5663
subscribed &&
5764
!source.pendingInvite &&
58-
((!imported && recentSignup) || recentJoin) &&
65+
((!imported && enrollment.recentSignup) ||
66+
(teammate && enrollment.recentJoin)) &&
5967
audience !== "unknown";
6068
const {
6169
subscribed: _subscribed,

packages/database/loops/worker.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
LoopsRequestError,
1313
} from "./client";
1414
import {
15+
enrollmentWindow,
1516
type LifecycleContact,
1617
type LoopsRuntimeConfig,
1718
lifecycleUpdate,
@@ -263,8 +264,10 @@ export async function runLoopsSync(customerCopy: CustomerCopy) {
263264
profile: profileFingerprint(localProfile),
264265
signedUp: source.signedUp,
265266
pendingInvite: source.pendingInvite,
266-
enrollmentEnabled: config.enrollmentEnabled,
267-
enrollmentAfter: config.enrollmentAfter.toISOString(),
267+
enrollment: enrollmentWindow(
268+
localProfile.capSignupAt,
269+
jobConfig,
270+
),
268271
listId: config.listId,
269272
teammateJoinedAt: job.teammateJoinedAt,
270273
}),

scripts/loops/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ Deploy the generated schema before code that selects the new columns. The unship
8080

8181
With the schema available and test configuration set, `bun scripts/loops/seed.ts` reports how many completed signups would be queued. `--apply` queues them without sending anything. `bun scripts/loops/sync.ts --apply` processes a bounded batch using the same worker as the cron route. Test mode fails closed without an allowlist. Set `LOOPS_SYNC_MODE=production` only as part of the reviewed cutover. Seed old completed accounts once after the final suppression reconciliation; subsequent signups enter the queue through the application.
8282

83+
Sync fingerprints include the contact's effective signup and teammate-join eligibility, rather than the raw enrollment switch or cutoff date. Enabling enrollment or moving a future cutoff does not invalidate every historical profile. A signup or join crossing the cutoff, or disabling an eligible contact's enrollment, still changes its fingerprint and requires synchronization.
84+
8385
## Independent delivery check
8486

8587
`.github/workflows/loops-safety.yml` runs every five minutes, independently of the Cap cron worker. It is disabled until the repository variable `LOOPS_WATCHDOG_ENABLED` is explicitly set to `true`. Configure GitHub secrets `LOOPS_API_KEY` and `LOOPS_HEALTH_SECRET`, and configure the same health secret in Cap. Scheduled Actions run from the default branch, so this protection is not deployed while the PR remains unmerged.

scripts/loops/lifecycle.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
22
import { customerCopy } from "../../emails/customer-copy";
33
import type { LoopsClient } from "../../packages/database/loops/client";
44
import {
5+
enrollmentWindow,
56
type LifecycleContact,
67
type LoopsRuntimeConfig,
78
lifecycleUpdate,
@@ -21,6 +22,53 @@ const config: LoopsRuntimeConfig = {
2122
allowedEmails: new Set([email]),
2223
};
2324

25+
describe("enrollment fingerprint", () => {
26+
test("historical accounts stay unchanged when enrollment is enabled or the future cutoff moves", () => {
27+
const signup = "2026-09-10T10:00:00.000Z";
28+
const held = enrollmentWindow(signup, {
29+
...config,
30+
enrollmentEnabled: false,
31+
});
32+
expect(held).toEqual({ recentSignup: false, recentJoin: false });
33+
expect(enrollmentWindow(signup, config)).toEqual(held);
34+
expect(
35+
enrollmentWindow(signup, {
36+
...config,
37+
enrollmentAfter: new Date("2026-09-12T00:00:00Z"),
38+
}),
39+
).toEqual(held);
40+
});
41+
42+
test("eligible signups change when enrollment is disabled or the cutoff crosses their signup", () => {
43+
const signup = "2026-09-11T10:00:00.000Z";
44+
expect(enrollmentWindow(signup, config)).toEqual({
45+
recentSignup: true,
46+
recentJoin: false,
47+
});
48+
expect(
49+
enrollmentWindow(signup, { ...config, enrollmentEnabled: false }),
50+
).toEqual({ recentSignup: false, recentJoin: false });
51+
expect(
52+
enrollmentWindow(signup, {
53+
...config,
54+
enrollmentAfter: new Date("2026-09-11T10:00:00.001Z"),
55+
}),
56+
).toEqual({ recentSignup: false, recentJoin: false });
57+
});
58+
59+
test("new teammate joins change historical accounts at the exact cutoff", () => {
60+
const signup = "2026-09-10T10:00:00.000Z";
61+
const joined = { ...config, teammateJoinedAt: "2026-09-11 00:00:00" };
62+
expect(enrollmentWindow(signup, joined)).toEqual({
63+
recentSignup: false,
64+
recentJoin: true,
65+
});
66+
expect(
67+
enrollmentWindow(signup, { ...joined, enrollmentEnabled: false }),
68+
).toEqual({ recentSignup: false, recentJoin: false });
69+
});
70+
});
71+
2472
function fixture(): LoopsProfileSource {
2573
return {
2674
input: {

0 commit comments

Comments
 (0)