@hogsend/engine 0.6.0 → 0.8.0
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/package.json +7 -6
- package/src/app.ts +36 -1
- package/src/buckets/check-membership.ts +34 -15
- package/src/container.ts +33 -0
- package/src/env.ts +29 -0
- package/src/index.ts +47 -1
- package/src/journeys/define-journey.ts +26 -2
- package/src/journeys/journey-context.ts +5 -1
- package/src/lib/boot.ts +1 -1
- package/src/lib/bucket-emit.ts +47 -2
- package/src/lib/contacts.ts +1105 -18
- package/src/lib/email-service-types.ts +8 -0
- package/src/lib/ingestion.ts +63 -33
- package/src/lib/mailer.ts +88 -0
- package/src/lib/outbound.ts +216 -0
- package/src/lib/preferences.ts +137 -0
- package/src/lib/tracked.ts +204 -37
- package/src/lib/tracking-events.ts +67 -2
- package/src/lib/webhook-signing.ts +151 -0
- package/src/lists/define-list.ts +81 -0
- package/src/lists/registry-singleton.ts +39 -0
- package/src/lists/registry.ts +95 -0
- package/src/middleware/api-key.ts +33 -7
- package/src/middleware/rate-limit.ts +73 -49
- package/src/routes/_shared.ts +30 -0
- package/src/routes/admin/api-keys.ts +1 -1
- package/src/routes/admin/bulk.ts +7 -3
- package/src/routes/admin/contacts.ts +108 -59
- package/src/routes/admin/events.ts +65 -0
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/admin/journeys.ts +3 -1
- package/src/routes/admin/preferences.ts +2 -2
- package/src/routes/admin/reporting.ts +3 -3
- package/src/routes/admin/timeline.ts +5 -2
- package/src/routes/admin/webhooks.ts +466 -0
- package/src/routes/campaigns/index.ts +252 -0
- package/src/routes/contacts/index.ts +231 -0
- package/src/routes/email/preferences.ts +27 -3
- package/src/routes/email/unsubscribe.ts +7 -49
- package/src/routes/emails/index.ts +133 -0
- package/src/routes/events/index.ts +119 -0
- package/src/routes/index.ts +52 -2
- package/src/routes/lists/index.ts +258 -0
- package/src/routes/tracking/click.ts +59 -18
- package/src/routes/tracking/open.ts +62 -24
- package/src/routes/webhooks/sources.ts +69 -10
- package/src/webhook-sources/define-webhook-source.ts +57 -5
- package/src/webhook-sources/presets/clerk.ts +185 -0
- package/src/webhook-sources/presets/index.ts +80 -0
- package/src/webhook-sources/presets/segment.ts +120 -0
- package/src/webhook-sources/presets/stripe.ts +147 -0
- package/src/webhook-sources/presets/supabase.ts +131 -0
- package/src/webhook-sources/verify.ts +172 -0
- package/src/worker.ts +12 -0
- package/src/workflows/bucket-backfill.ts +32 -21
- package/src/workflows/bucket-reconcile.ts +20 -5
- package/src/workflows/deliver-webhook.ts +399 -0
- package/src/workflows/import-contacts.ts +28 -20
- package/src/workflows/send-campaign.ts +589 -0
- package/src/routes/ingest.ts +0 -71
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,13 +35,14 @@
|
|
|
35
35
|
"papaparse": "^5.5.3",
|
|
36
36
|
"picocolors": "^1.1.1",
|
|
37
37
|
"resend": "^6.12.3",
|
|
38
|
+
"svix": "^1.95.1",
|
|
38
39
|
"winston": "^3.19.0",
|
|
39
40
|
"zod": "^4.4.3",
|
|
40
|
-
"@hogsend/core": "^0.
|
|
41
|
-
"@hogsend/
|
|
42
|
-
"@hogsend/
|
|
43
|
-
"@hogsend/plugin-
|
|
44
|
-
"@hogsend/
|
|
41
|
+
"@hogsend/core": "^0.8.0",
|
|
42
|
+
"@hogsend/email": "^0.8.0",
|
|
43
|
+
"@hogsend/plugin-posthog": "^0.8.0",
|
|
44
|
+
"@hogsend/plugin-resend": "^0.8.0",
|
|
45
|
+
"@hogsend/db": "^0.8.0"
|
|
45
46
|
},
|
|
46
47
|
"devDependencies": {
|
|
47
48
|
"@types/node": "^22.15.3",
|
package/src/app.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { errorHandler } from "./middleware/error-handler.js";
|
|
|
15
15
|
import { requestLogger } from "./middleware/request-logger.js";
|
|
16
16
|
import { registerRoutes } from "./routes/index.js";
|
|
17
17
|
import type { DefinedWebhookSource } from "./webhook-sources/define-webhook-source.js";
|
|
18
|
+
import { presetsFromEnv } from "./webhook-sources/presets/index.js";
|
|
18
19
|
|
|
19
20
|
type AuthSession = Awaited<ReturnType<Auth["api"]["getSession"]>>;
|
|
20
21
|
|
|
@@ -35,10 +36,32 @@ export interface CreateAppOptions {
|
|
|
35
36
|
middleware?: MiddlewareHandler[];
|
|
36
37
|
/** Webhook sources served at `/v1/webhooks/:sourceId`. */
|
|
37
38
|
webhookSources?: DefinedWebhookSource[];
|
|
39
|
+
/**
|
|
40
|
+
* Auto-enable the shipped integration presets (Clerk, Supabase, Stripe,
|
|
41
|
+
* Segment) for every preset whose env secret is configured (gated further by
|
|
42
|
+
* `ENABLED_WEBHOOK_PRESETS`). Consumer-supplied `webhookSources` always win on
|
|
43
|
+
* an id collision. Set `false` to opt out entirely. Default `true`.
|
|
44
|
+
*/
|
|
45
|
+
enablePresets?: boolean;
|
|
38
46
|
/** Override the default error handler. */
|
|
39
47
|
onError?: ErrorHandler<AppEnv>;
|
|
40
48
|
}
|
|
41
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Merge env-enabled presets with the consumer's explicit sources. The
|
|
52
|
+
* consumer-supplied source WINS on an id collision (so a hand-tuned override of
|
|
53
|
+
* a preset replaces the shipped one rather than registering a duplicate route).
|
|
54
|
+
*/
|
|
55
|
+
function dedupeById(sources: DefinedWebhookSource[]): DefinedWebhookSource[] {
|
|
56
|
+
const byId = new Map<string, DefinedWebhookSource>();
|
|
57
|
+
for (const source of sources) {
|
|
58
|
+
// Last write wins; callers order presets BEFORE consumer sources so the
|
|
59
|
+
// consumer override lands last.
|
|
60
|
+
byId.set(source.meta.id, source);
|
|
61
|
+
}
|
|
62
|
+
return [...byId.values()];
|
|
63
|
+
}
|
|
64
|
+
|
|
42
65
|
export function createApp(
|
|
43
66
|
container: HogsendClient,
|
|
44
67
|
opts: CreateAppOptions = {},
|
|
@@ -96,7 +119,19 @@ export function createApp(
|
|
|
96
119
|
return c.json({ needsSetup: existing.length === 0 });
|
|
97
120
|
});
|
|
98
121
|
|
|
99
|
-
|
|
122
|
+
// Merge env-enabled presets ahead of the consumer's explicit sources so a
|
|
123
|
+
// consumer override of a preset id wins (decision #13). `enablePresets`
|
|
124
|
+
// defaults true; setting only `STRIPE_WEBHOOK_SECRET` auto-mounts Stripe at
|
|
125
|
+
// `POST /v1/webhooks/stripe` and nothing else.
|
|
126
|
+
const enablePresets = opts.enablePresets ?? true;
|
|
127
|
+
const webhookSources = enablePresets
|
|
128
|
+
? dedupeById([
|
|
129
|
+
...presetsFromEnv(container.env),
|
|
130
|
+
...(opts.webhookSources ?? []),
|
|
131
|
+
])
|
|
132
|
+
: (opts.webhookSources ?? []);
|
|
133
|
+
|
|
134
|
+
registerRoutes(app, { webhookSources });
|
|
100
135
|
|
|
101
136
|
// Serve the Studio SPA at /studio/* (static layer, no auth — the SPA gates
|
|
102
137
|
// itself via /v1/auth/status + login; data endpoints stay behind requireAdmin).
|
|
@@ -51,7 +51,16 @@ export async function checkBucketMembership(opts: {
|
|
|
51
51
|
userId: string;
|
|
52
52
|
userEmail: string | null;
|
|
53
53
|
event: string;
|
|
54
|
-
|
|
54
|
+
/**
|
|
55
|
+
* D2: the event payload — candidate-narrowing ONLY. It NO LONGER participates
|
|
56
|
+
* in property eval (the raw-payload overlay was the bucket-side conflation).
|
|
57
|
+
*/
|
|
58
|
+
eventProperties: Record<string, unknown>;
|
|
59
|
+
/**
|
|
60
|
+
* D2: this-ingest contact-property patch, overlaid on the read contact row so
|
|
61
|
+
* the very first event after a property change evaluates correctly (risk 7).
|
|
62
|
+
*/
|
|
63
|
+
contactProperties?: Record<string, unknown>;
|
|
55
64
|
/** Optional override; defaults to the process bucket-registry singleton. */
|
|
56
65
|
bucketRegistry?: ReturnType<typeof getBucketRegistrySingleton>;
|
|
57
66
|
}): Promise<BucketTransition[]> {
|
|
@@ -63,7 +72,8 @@ export async function checkBucketMembership(opts: {
|
|
|
63
72
|
userId,
|
|
64
73
|
userEmail,
|
|
65
74
|
event,
|
|
66
|
-
|
|
75
|
+
eventProperties,
|
|
76
|
+
contactProperties: contactPropertiesPatch,
|
|
67
77
|
} = opts;
|
|
68
78
|
|
|
69
79
|
// (1) Recursion guard — MUST be first. bucket:-prefixed events are transition
|
|
@@ -81,12 +91,18 @@ export async function checkBucketMembership(opts: {
|
|
|
81
91
|
|
|
82
92
|
// (2) Candidate narrowing — the UNION of buckets referencing this event name
|
|
83
93
|
// (eventIndex + the degenerate wildcard set) and buckets referencing any
|
|
84
|
-
// property present in
|
|
94
|
+
// property present in EITHER bag (propertyIndex): the eventProperties drive
|
|
95
|
+
// event-shaped criteria narrowing, the contactProperties patch surfaces a
|
|
96
|
+
// contact-property change so a property-criteria bucket is re-checked on the
|
|
97
|
+
// first event that mutates it. Section 6.2.
|
|
85
98
|
const candidateMap = new Map<string, BucketMeta>();
|
|
86
99
|
for (const bucket of bucketRegistry.getByReferencedEvent(event)) {
|
|
87
100
|
candidateMap.set(bucket.id, bucket);
|
|
88
101
|
}
|
|
89
|
-
for (const key of
|
|
102
|
+
for (const key of [
|
|
103
|
+
...Object.keys(eventProperties ?? {}),
|
|
104
|
+
...Object.keys(contactPropertiesPatch ?? {}),
|
|
105
|
+
]) {
|
|
90
106
|
for (const bucket of bucketRegistry.getByReferencedProperty(key)) {
|
|
91
107
|
candidateMap.set(bucket.id, bucket);
|
|
92
108
|
}
|
|
@@ -109,19 +125,20 @@ export async function checkBucketMembership(opts: {
|
|
|
109
125
|
return [];
|
|
110
126
|
}
|
|
111
127
|
|
|
112
|
-
// (3) Property predicates evaluate against
|
|
113
|
-
// event payload (Section 6.1 rule #3
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
128
|
+
// (3) Property predicates evaluate against contact state ⊕ this-ingest
|
|
129
|
+
// contactProperties patch — NOT the raw event payload (Section 6.1 rule #3 /
|
|
130
|
+
// D2). Read the EXISTING contacts row ONCE iff any surviving candidate
|
|
131
|
+
// references a property — pure event/count buckets skip the read entirely.
|
|
132
|
+
// `ingestEvent` already awaited `resolveOrCreateContact` before us, so the row
|
|
133
|
+
// exists by the resolved key; the patch overlay still covers the read-after-
|
|
134
|
+
// write gap on a contact's very first event (risk 7).
|
|
118
135
|
const needsContactState = candidates.some(
|
|
119
136
|
(bucket) =>
|
|
120
137
|
bucket.criteria != null &&
|
|
121
138
|
collectPropertyNames(bucket.criteria).length > 0,
|
|
122
139
|
);
|
|
123
140
|
|
|
124
|
-
let
|
|
141
|
+
let storedContactProps: Record<string, unknown> = {};
|
|
125
142
|
let contactDeleted = false;
|
|
126
143
|
if (needsContactState) {
|
|
127
144
|
const [contact] = await db
|
|
@@ -133,7 +150,7 @@ export async function checkBucketMembership(opts: {
|
|
|
133
150
|
.where(eq(contacts.externalId, userId))
|
|
134
151
|
.limit(1);
|
|
135
152
|
if (contact) {
|
|
136
|
-
|
|
153
|
+
storedContactProps =
|
|
137
154
|
(contact.properties as Record<string, unknown> | null) ?? {};
|
|
138
155
|
contactDeleted = contact.deletedAt != null;
|
|
139
156
|
}
|
|
@@ -144,10 +161,12 @@ export async function checkBucketMembership(opts: {
|
|
|
144
161
|
return [];
|
|
145
162
|
}
|
|
146
163
|
|
|
147
|
-
//
|
|
164
|
+
// this-ingest contactProperties patch overlays stored contact state. The raw
|
|
165
|
+
// event payload is REMOVED from property eval (D2 — bucket prop-criteria see
|
|
166
|
+
// contact state only).
|
|
148
167
|
const journeyContext: Record<string, unknown> = {
|
|
149
|
-
...
|
|
150
|
-
...(
|
|
168
|
+
...storedContactProps,
|
|
169
|
+
...(contactPropertiesPatch ?? {}),
|
|
151
170
|
};
|
|
152
171
|
|
|
153
172
|
const transitions: BucketTransition[] = [];
|
package/src/container.ts
CHANGED
|
@@ -36,6 +36,8 @@ import { createLogger, type Logger } from "./lib/logger.js";
|
|
|
36
36
|
import { createTrackedMailer } from "./lib/mailer.js";
|
|
37
37
|
import { getPostHog } from "./lib/posthog.js";
|
|
38
38
|
import { prepareTrackedHtml } from "./lib/tracking.js";
|
|
39
|
+
import type { DefinedList } from "./lists/define-list.js";
|
|
40
|
+
import { buildListRegistry, type ListRegistry } from "./lists/registry.js";
|
|
39
41
|
|
|
40
42
|
export interface HogsendDefaults {
|
|
41
43
|
/** Global fallback IANA timezone for scheduling. Defaults to "UTC". */
|
|
@@ -70,6 +72,14 @@ export interface HogsendClient {
|
|
|
70
72
|
* Empty when no buckets are wired.
|
|
71
73
|
*/
|
|
72
74
|
bucketRegistry: BucketRegistry;
|
|
75
|
+
/**
|
|
76
|
+
* The email-list registry (D3): code-defined subscription categories layered
|
|
77
|
+
* on `email_preferences.categories`, with the LOCKED polarity rule that is the
|
|
78
|
+
* single source of truth for the mailer's suppression check AND the preference
|
|
79
|
+
* center. Built and installed as the process singleton at client build (read
|
|
80
|
+
* elsewhere via `getListRegistry()`). Empty when no lists are wired.
|
|
81
|
+
*/
|
|
82
|
+
listRegistry: ListRegistry;
|
|
73
83
|
hatchet: HatchetClient;
|
|
74
84
|
/**
|
|
75
85
|
* The client repo's migration journal (`migrations/meta/_journal.json`),
|
|
@@ -91,6 +101,13 @@ export interface HogsendClientOptions {
|
|
|
91
101
|
journeys?: DefinedJourney[];
|
|
92
102
|
/** Buckets to register in the {@link BucketRegistry}. Defaults to none. */
|
|
93
103
|
buckets?: DefinedBucket[];
|
|
104
|
+
/**
|
|
105
|
+
* Email lists (D3) to register in the {@link ListRegistry}. Each is a
|
|
106
|
+
* `defineList()` subscription category (id + name + `defaultOptIn`). The
|
|
107
|
+
* registry drives the mailer's list-aware suppression check and the
|
|
108
|
+
* preference center. Defaults to none (empty registry ⇒ legacy opt-in).
|
|
109
|
+
*/
|
|
110
|
+
lists?: DefinedList[];
|
|
94
111
|
/**
|
|
95
112
|
* Email is a first-class channel. Its config is grouped here rather than
|
|
96
113
|
* spread across top-level args — the engine owns the cohesive email pipeline
|
|
@@ -131,6 +148,11 @@ export interface HogsendClientOptions {
|
|
|
131
148
|
* `env.ENABLED_BUCKETS`.
|
|
132
149
|
*/
|
|
133
150
|
enabledBuckets?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Comma-separated ids (or `*`) controlling which lists load. Defaults to
|
|
153
|
+
* `env.ENABLED_LISTS`.
|
|
154
|
+
*/
|
|
155
|
+
enabledLists?: string;
|
|
134
156
|
/**
|
|
135
157
|
* The client repo's migration journal for the `schema.client` health block.
|
|
136
158
|
* Defaults to `{ entries: [] }` (empty client track ⇒ trivially in sync).
|
|
@@ -237,6 +259,15 @@ export function createHogsendClient(
|
|
|
237
259
|
bucket.membersIterator = accessor.membersIterator;
|
|
238
260
|
}
|
|
239
261
|
|
|
262
|
+
// Build + install the list registry singleton (D3). Runs in BOTH the API and
|
|
263
|
+
// worker (both call createHogsendClient), so `getListRegistry()` resolves the
|
|
264
|
+
// wired lists in the mailer's suppression check and the preference center in
|
|
265
|
+
// either process. `buildListRegistry` installs the process singleton.
|
|
266
|
+
const listRegistry = buildListRegistry(
|
|
267
|
+
opts.lists ?? [],
|
|
268
|
+
opts.enabledLists ?? env.ENABLED_LISTS,
|
|
269
|
+
);
|
|
270
|
+
|
|
240
271
|
const provider =
|
|
241
272
|
opts.email?.provider ??
|
|
242
273
|
createResendProvider({
|
|
@@ -293,6 +324,7 @@ export function createHogsendClient(
|
|
|
293
324
|
// keep these at debug for non-boot contexts (tests, REPL, library use).
|
|
294
325
|
logger.debug(`Journey registry loaded: ${registry.count()} journeys`);
|
|
295
326
|
logger.debug(`Bucket registry loaded: ${bucketRegistry.count()} buckets`);
|
|
327
|
+
logger.debug(`List registry loaded: ${listRegistry.count()} lists`);
|
|
296
328
|
|
|
297
329
|
return {
|
|
298
330
|
env,
|
|
@@ -306,6 +338,7 @@ export function createHogsendClient(
|
|
|
306
338
|
analytics,
|
|
307
339
|
registry,
|
|
308
340
|
bucketRegistry,
|
|
341
|
+
listRegistry,
|
|
309
342
|
hatchet: opts.overrides?.hatchet ?? hatchet,
|
|
310
343
|
clientJournal: opts.clientJournal ?? { entries: [] },
|
|
311
344
|
defaults,
|
package/src/env.ts
CHANGED
|
@@ -65,8 +65,37 @@ export const env = createEnv({
|
|
|
65
65
|
// Evaluated at worker boot — a toggle requires a worker restart; only the
|
|
66
66
|
// bucket_configs DB override is hot.
|
|
67
67
|
ENABLED_BUCKETS: z.string().default("*"),
|
|
68
|
+
// Email lists (D3): same `"*"`-or-csv contract as ENABLED_JOURNEYS /
|
|
69
|
+
// ENABLED_BUCKETS. Filters which `defineList()` lists are registered into the
|
|
70
|
+
// process ListRegistry (the suppression-polarity + preference-center source).
|
|
71
|
+
ENABLED_LISTS: z.string().default("*"),
|
|
68
72
|
// Cadence for the engine-owned bucket reconcile cron (time-based leaves).
|
|
69
73
|
BUCKET_RECONCILE_CRON: z.string().default("*/5 * * * *"),
|
|
74
|
+
// --- Outbound webhooks (Section 1.5/1.8) ---
|
|
75
|
+
// Cadence for the engine-owned outbound-delivery reaper cron (the retry
|
|
76
|
+
// scheduler + orphan-`sending` recovery). Declared for parity with
|
|
77
|
+
// BUCKET_RECONCILE_CRON; the delivery task also reads it raw off process.env.
|
|
78
|
+
OUTBOUND_WEBHOOK_REAPER_CRON: z.string().optional(),
|
|
79
|
+
// Delivery tunables — read raw off process.env inside the durable task;
|
|
80
|
+
// declared here so they are part of the validated env contract. All optional
|
|
81
|
+
// with task-internal defaults (MAX_ATTEMPTS 8, TIMEOUT 15s, BASE 5s,
|
|
82
|
+
// MAX_DELAY 6h, STUCK_AFTER 5min).
|
|
83
|
+
OUTBOUND_WEBHOOK_MAX_ATTEMPTS: z.coerce.number().optional(),
|
|
84
|
+
OUTBOUND_WEBHOOK_TIMEOUT_MS: z.coerce.number().optional(),
|
|
85
|
+
OUTBOUND_WEBHOOK_BASE_DELAY_MS: z.coerce.number().optional(),
|
|
86
|
+
OUTBOUND_WEBHOOK_MAX_DELAY_MS: z.coerce.number().optional(),
|
|
87
|
+
OUTBOUND_WEBHOOK_STUCK_AFTER_MS: z.coerce.number().optional(),
|
|
88
|
+
// --- Integration presets (Section 2.2) ---
|
|
89
|
+
// Signature-source secrets. The webhook route resolves a preset's secret via
|
|
90
|
+
// env[source.auth.envKey]; a signature source FAILS CLOSED when its secret is
|
|
91
|
+
// unset. Setting one auto-enables that preset at POST /v1/webhooks/<id>.
|
|
92
|
+
CLERK_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
93
|
+
SUPABASE_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
94
|
+
STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
95
|
+
SEGMENT_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
96
|
+
// Preset enablement override: csv of preset ids, `"*"` (all with a secret),
|
|
97
|
+
// or `"none"`. Absent → auto-enable any preset whose secret is set.
|
|
98
|
+
ENABLED_WEBHOOK_PRESETS: z.string().optional(),
|
|
70
99
|
},
|
|
71
100
|
runtimeEnv: process.env,
|
|
72
101
|
emptyStringAsUndefined: true,
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,6 @@ export type {
|
|
|
16
16
|
PostHogService,
|
|
17
17
|
SendResult,
|
|
18
18
|
WebhookEvent,
|
|
19
|
-
WebhookEventType,
|
|
20
19
|
WebhookHandlerMap,
|
|
21
20
|
} from "@hogsend/core";
|
|
22
21
|
// Core helpers used by content journeys (days/hours/minutes, condition + journey
|
|
@@ -150,6 +149,13 @@ export {
|
|
|
150
149
|
// --- Logging ---
|
|
151
150
|
export { createLogger, type Logger } from "./lib/logger.js";
|
|
152
151
|
export { createTrackedMailer } from "./lib/mailer.js";
|
|
152
|
+
// --- Outbound webhooks: emit spine (Section 1.4) ---
|
|
153
|
+
export {
|
|
154
|
+
emitOutbound,
|
|
155
|
+
OUTBOUND_EVENTS,
|
|
156
|
+
type OutboundEventName,
|
|
157
|
+
type OutboundPayloads,
|
|
158
|
+
} from "./lib/outbound.js";
|
|
153
159
|
export { getPostHog } from "./lib/posthog.js";
|
|
154
160
|
export { getRedisIfConnected } from "./lib/redis.js";
|
|
155
161
|
export { type MountStudioResult, mountStudio } from "./lib/studio.js";
|
|
@@ -174,14 +180,48 @@ export {
|
|
|
174
180
|
export {
|
|
175
181
|
pushTrackingEvent,
|
|
176
182
|
resolveEmailSendContext,
|
|
183
|
+
resolveEmailSendContextByResendId,
|
|
177
184
|
} from "./lib/tracking-events.js";
|
|
185
|
+
// --- Outbound webhooks: signing core (Section 1.2) ---
|
|
186
|
+
export {
|
|
187
|
+
generateWebhookSecret,
|
|
188
|
+
type SignedWebhook,
|
|
189
|
+
signWebhook,
|
|
190
|
+
verifyWebhookSignature,
|
|
191
|
+
WEBHOOK_EVENT_TYPES,
|
|
192
|
+
type WebhookEventType,
|
|
193
|
+
} from "./lib/webhook-signing.js";
|
|
194
|
+
// --- Lists (D3) ---
|
|
195
|
+
export {
|
|
196
|
+
type DefinedList,
|
|
197
|
+
defineList,
|
|
198
|
+
type ListMeta,
|
|
199
|
+
} from "./lists/define-list.js";
|
|
200
|
+
export { buildListRegistry, ListRegistry } from "./lists/registry.js";
|
|
201
|
+
export {
|
|
202
|
+
getListRegistry,
|
|
203
|
+
resetListRegistry,
|
|
204
|
+
setListRegistry,
|
|
205
|
+
} from "./lists/registry-singleton.js";
|
|
178
206
|
// --- Webhook sources ---
|
|
179
207
|
export {
|
|
180
208
|
type DefinedWebhookSource,
|
|
181
209
|
defineWebhookSource,
|
|
210
|
+
verifySignature,
|
|
211
|
+
type WebhookSourceAuth,
|
|
182
212
|
type WebhookSourceCtx,
|
|
183
213
|
type WebhookSourceMeta,
|
|
184
214
|
} from "./webhook-sources/define-webhook-source.js";
|
|
215
|
+
// --- Integration presets (Section 2.3/2.4) ---
|
|
216
|
+
export {
|
|
217
|
+
clerkSource,
|
|
218
|
+
PRESET_SOURCES,
|
|
219
|
+
type PresetId,
|
|
220
|
+
presetsFromEnv,
|
|
221
|
+
segmentSource,
|
|
222
|
+
stripeSource,
|
|
223
|
+
supabaseSource,
|
|
224
|
+
} from "./webhook-sources/presets/index.js";
|
|
185
225
|
export {
|
|
186
226
|
type CreateWorkerOptions,
|
|
187
227
|
createWorker,
|
|
@@ -199,6 +239,12 @@ export {
|
|
|
199
239
|
bucketReconcileTask,
|
|
200
240
|
} from "./workflows/bucket-reconcile.js";
|
|
201
241
|
export { checkAlertsTask } from "./workflows/check-alerts.js";
|
|
242
|
+
// --- Outbound webhooks: durable delivery task + reaper (Section 1.5) ---
|
|
243
|
+
export {
|
|
244
|
+
deliverWebhookTask,
|
|
245
|
+
reapDueWebhookDeliveriesTask,
|
|
246
|
+
} from "./workflows/deliver-webhook.js";
|
|
202
247
|
export { importContactsTask } from "./workflows/import-contacts.js";
|
|
248
|
+
export { sendCampaignTask } from "./workflows/send-campaign.js";
|
|
203
249
|
// --- Built-in Hatchet workflow tasks ---
|
|
204
250
|
export { sendEmailTask } from "./workflows/send-email.js";
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "../lib/enrollment-guards.js";
|
|
16
16
|
import { hatchet } from "../lib/hatchet.js";
|
|
17
17
|
import { createLogger } from "../lib/logger.js";
|
|
18
|
+
import { emitOutbound } from "../lib/outbound.js";
|
|
18
19
|
import { resolveTimezoneWithSource } from "../lib/timezone.js";
|
|
19
20
|
import { getClientScheduleDefaults } from "./client-defaults-singleton.js";
|
|
20
21
|
import { JOURNEY_EXECUTION_TIMEOUT } from "./constants.js";
|
|
@@ -190,12 +191,13 @@ export function defineJourney(options: {
|
|
|
190
191
|
try {
|
|
191
192
|
await options.run(user, ctx);
|
|
192
193
|
|
|
194
|
+
const completedAt = new Date();
|
|
193
195
|
await db
|
|
194
196
|
.update(journeyStates)
|
|
195
197
|
.set({
|
|
196
198
|
status: "completed",
|
|
197
|
-
completedAt
|
|
198
|
-
updatedAt:
|
|
199
|
+
completedAt,
|
|
200
|
+
updatedAt: completedAt,
|
|
199
201
|
})
|
|
200
202
|
.where(eq(journeyStates.id, stateId));
|
|
201
203
|
|
|
@@ -205,6 +207,28 @@ export function defineJourney(options: {
|
|
|
205
207
|
userId,
|
|
206
208
|
});
|
|
207
209
|
|
|
210
|
+
// OUTBOUND `journey.completed` — fired alongside the internal
|
|
211
|
+
// `journey:completed` push. Runs in the WORKER (this durable task), so it
|
|
212
|
+
// uses the engine `db`/`hatchet`/`logger` singletons. `dedupeKey` =
|
|
213
|
+
// `journey.completed:<stateId>`: a Hatchet re-execution recomputes the
|
|
214
|
+
// identical key and the unique `(endpointId, dedupeKey)` index absorbs the
|
|
215
|
+
// duplicate (risk 3). `journey:failed` is NOT in the catalog → no emit.
|
|
216
|
+
void emitOutbound({
|
|
217
|
+
db,
|
|
218
|
+
hatchet,
|
|
219
|
+
logger,
|
|
220
|
+
event: "journey.completed",
|
|
221
|
+
dedupeKey: `journey.completed:${stateId}`,
|
|
222
|
+
payload: {
|
|
223
|
+
journeyId: meta.id,
|
|
224
|
+
journeyName: meta.name,
|
|
225
|
+
stateId,
|
|
226
|
+
userId,
|
|
227
|
+
userEmail,
|
|
228
|
+
completedAt: completedAt.toISOString(),
|
|
229
|
+
},
|
|
230
|
+
}).catch(logger.warn);
|
|
231
|
+
|
|
208
232
|
return { stateId, status: "completed" };
|
|
209
233
|
} catch (err) {
|
|
210
234
|
// The journey reached a terminal state (exitOn / cancel) while suspended
|
|
@@ -298,6 +298,10 @@ export function createJourneyContext(
|
|
|
298
298
|
userEmail: targetEmail,
|
|
299
299
|
properties,
|
|
300
300
|
}) {
|
|
301
|
+
// Keep the PUBLIC `TriggerOptions.properties` field name (decision #13 —
|
|
302
|
+
// renaming it would break consumer journeys + scaffold). Map it to the
|
|
303
|
+
// engine-internal `eventProperties` bag here; no `contactProperties` by
|
|
304
|
+
// default (a future `TriggerOptions.contactProperties` is deferred).
|
|
301
305
|
await ingestEvent({
|
|
302
306
|
db,
|
|
303
307
|
registry,
|
|
@@ -307,7 +311,7 @@ export function createJourneyContext(
|
|
|
307
311
|
event,
|
|
308
312
|
userId: targetUserId,
|
|
309
313
|
userEmail: targetEmail ?? userEmail,
|
|
310
|
-
|
|
314
|
+
eventProperties: properties ?? {},
|
|
311
315
|
},
|
|
312
316
|
});
|
|
313
317
|
},
|
package/src/lib/boot.ts
CHANGED
|
@@ -171,6 +171,6 @@ export function reportWorkerReady(info: WorkerReadyInfo): void {
|
|
|
171
171
|
` ${ok} ${tasks}`,
|
|
172
172
|
"",
|
|
173
173
|
` ${dim("Listening — journeys fire as events arrive.")}`,
|
|
174
|
-
` ${dim("Send one:")} ${color.cyan("POST /v1/
|
|
174
|
+
` ${dim("Send one:")} ${color.cyan("POST /v1/events")} ${dim("· or Studio › Debug")}`,
|
|
175
175
|
]);
|
|
176
176
|
}
|
package/src/lib/bucket-emit.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { BucketLeaveReason } from "../buckets/bucket-reactions.js";
|
|
|
6
6
|
import { syncBucketToPostHog } from "./bucket-posthog-sync.js";
|
|
7
7
|
import { ingestEvent } from "./ingestion.js";
|
|
8
8
|
import type { Logger } from "./logger.js";
|
|
9
|
+
import { emitOutbound } from "./outbound.js";
|
|
9
10
|
|
|
10
11
|
export type BucketTransitionKind = "entered" | "left" | "dwell";
|
|
11
12
|
|
|
@@ -123,7 +124,7 @@ export async function emitBucketTransition(opts: {
|
|
|
123
124
|
event: eventName,
|
|
124
125
|
userId,
|
|
125
126
|
userEmail: userEmail ?? "",
|
|
126
|
-
properties,
|
|
127
|
+
eventProperties: properties,
|
|
127
128
|
idempotencyKey,
|
|
128
129
|
},
|
|
129
130
|
});
|
|
@@ -141,9 +142,53 @@ export async function emitBucketTransition(opts: {
|
|
|
141
142
|
event: genericEvent,
|
|
142
143
|
userId,
|
|
143
144
|
userEmail: userEmail ?? "",
|
|
144
|
-
properties,
|
|
145
|
+
eventProperties: properties,
|
|
145
146
|
idempotencyKey: `bucket:${bucket.id}:${userId}:${kind}:${epoch}:generic`,
|
|
146
147
|
},
|
|
147
148
|
});
|
|
148
149
|
}
|
|
150
|
+
|
|
151
|
+
// OUTBOUND `bucket.entered` / `bucket.left` — this is the SINGLE producer for
|
|
152
|
+
// all three transition origins (real-time / reconcile / fast-expiry), so the
|
|
153
|
+
// emit lives here once. GATED to enter/leave (dwell is a recurring membership
|
|
154
|
+
// tick, not in the catalog — same gate as the PostHog mirror above). The
|
|
155
|
+
// `dedupeKey` is the SAME deterministic `idempotencyKey` all three producers
|
|
156
|
+
// compute, so a Hatchet re-execution converges to ONE emission via the unique
|
|
157
|
+
// `(endpointId, dedupeKey)` index (risk 3). Fire-and-forget.
|
|
158
|
+
if (kind === "entered") {
|
|
159
|
+
void emitOutbound({
|
|
160
|
+
db,
|
|
161
|
+
hatchet,
|
|
162
|
+
logger,
|
|
163
|
+
event: "bucket.entered",
|
|
164
|
+
dedupeKey: idempotencyKey,
|
|
165
|
+
payload: {
|
|
166
|
+
bucketId: bucket.id,
|
|
167
|
+
bucketName: bucket.name,
|
|
168
|
+
userId,
|
|
169
|
+
userEmail,
|
|
170
|
+
transition: "entered",
|
|
171
|
+
entryCount: epoch,
|
|
172
|
+
source,
|
|
173
|
+
},
|
|
174
|
+
}).catch(logger.warn);
|
|
175
|
+
} else if (kind === "left") {
|
|
176
|
+
void emitOutbound({
|
|
177
|
+
db,
|
|
178
|
+
hatchet,
|
|
179
|
+
logger,
|
|
180
|
+
event: "bucket.left",
|
|
181
|
+
dedupeKey: idempotencyKey,
|
|
182
|
+
payload: {
|
|
183
|
+
bucketId: bucket.id,
|
|
184
|
+
bucketName: bucket.name,
|
|
185
|
+
userId,
|
|
186
|
+
userEmail,
|
|
187
|
+
transition: "left",
|
|
188
|
+
entryCount: epoch,
|
|
189
|
+
source,
|
|
190
|
+
...(reason != null ? { reason } : {}),
|
|
191
|
+
},
|
|
192
|
+
}).catch(logger.warn);
|
|
193
|
+
}
|
|
149
194
|
}
|