@hogsend/engine 0.13.2 → 0.16.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 -7
- package/src/destinations/presets/posthog.ts +35 -0
- package/src/env.ts +7 -0
- package/src/index.ts +13 -0
- package/src/journeys/define-journey.ts +40 -3
- package/src/journeys/journey-context.ts +73 -5
- package/src/lib/identity-token.ts +112 -0
- package/src/lib/ingestion.ts +32 -1
- package/src/lib/outbound.ts +17 -0
- package/src/lib/seed-posthog-destination.ts +34 -2
- package/src/lib/semantic-click.ts +194 -0
- package/src/lib/tracking-events.ts +12 -4
- package/src/lib/tracking.ts +185 -21
- package/src/lib/webhook-signing.ts +2 -1
- package/src/routes/tracking/answer.ts +187 -0
- package/src/routes/tracking/click.ts +62 -2
- package/src/routes/tracking/identify.ts +71 -0
- package/src/routes/tracking/index.ts +4 -0
- package/src/worker.ts +2 -0
- package/src/workflows/confirm-semantic-click.ts +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -40,14 +40,14 @@
|
|
|
40
40
|
"svix": "^1.95.1",
|
|
41
41
|
"winston": "^3.19.0",
|
|
42
42
|
"zod": "^4.4.3",
|
|
43
|
-
"@hogsend/core": "^0.
|
|
44
|
-
"@hogsend/
|
|
45
|
-
"@hogsend/
|
|
46
|
-
"@hogsend/plugin-
|
|
47
|
-
"@hogsend/
|
|
43
|
+
"@hogsend/core": "^0.16.0",
|
|
44
|
+
"@hogsend/db": "^0.16.0",
|
|
45
|
+
"@hogsend/email": "^0.16.0",
|
|
46
|
+
"@hogsend/plugin-posthog": "^0.16.0",
|
|
47
|
+
"@hogsend/plugin-resend": "^0.16.0"
|
|
48
48
|
},
|
|
49
49
|
"optionalDependencies": {
|
|
50
|
-
"@hogsend/plugin-postmark": "^0.
|
|
50
|
+
"@hogsend/plugin-postmark": "^0.16.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/node": "^22.15.3",
|
|
@@ -53,6 +53,41 @@ export const posthogDestination = defineDestination({
|
|
|
53
53
|
userEmail?: string | null;
|
|
54
54
|
};
|
|
55
55
|
const distinctId = data.userId ?? data.to ?? data.userEmail ?? undefined;
|
|
56
|
+
// `email.action` is the semantic-link envelope: the CONSUMER's event name
|
|
57
|
+
// (data.event, e.g. "nps.submitted") is what PostHog should capture, with
|
|
58
|
+
// the author's properties flattened to the top level. Other catalog events
|
|
59
|
+
// capture under their spine name (with the optional remap).
|
|
60
|
+
if (envelope.type === "email.action") {
|
|
61
|
+
const action = envelope.data as {
|
|
62
|
+
event: string;
|
|
63
|
+
properties: Record<string, unknown> | null;
|
|
64
|
+
emailSendId: string;
|
|
65
|
+
templateKey: string | null;
|
|
66
|
+
linkId: string;
|
|
67
|
+
linkUrl: string;
|
|
68
|
+
to: string;
|
|
69
|
+
userId: string | null;
|
|
70
|
+
at: string;
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
url: `${host}/capture/`,
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "Content-Type": "application/json" },
|
|
76
|
+
body: JSON.stringify({
|
|
77
|
+
api_key: config.apiKey,
|
|
78
|
+
event: action.event,
|
|
79
|
+
distinct_id: distinctId,
|
|
80
|
+
timestamp: envelope.timestamp,
|
|
81
|
+
properties: {
|
|
82
|
+
...(action.properties ?? {}),
|
|
83
|
+
emailSendId: action.emailSendId,
|
|
84
|
+
templateKey: action.templateKey,
|
|
85
|
+
linkId: action.linkId,
|
|
86
|
+
$lib: "hogsend",
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
56
91
|
// Optional event-name remap (identity by default).
|
|
57
92
|
const eventName = config.eventNames?.[envelope.type] ?? envelope.type;
|
|
58
93
|
return {
|
package/src/env.ts
CHANGED
|
@@ -141,6 +141,13 @@ export const env = createEnv({
|
|
|
141
141
|
// Default OFF to avoid a surprise double-emit alongside the existing
|
|
142
142
|
// fire-and-forget PostHog capture path.
|
|
143
143
|
ENABLE_POSTHOG_DESTINATION: z.coerce.boolean().default(false),
|
|
144
|
+
/**
|
|
145
|
+
* Append a short-lived signed identity token (`hs_t`) to tracked-link
|
|
146
|
+
* redirect destinations, so the landing site can stitch the email click
|
|
147
|
+
* to its web session (`POST /v1/t/identify` + posthog.identify). Opt-in:
|
|
148
|
+
* it changes outbound URLs, which can break pre-signed destinations.
|
|
149
|
+
*/
|
|
150
|
+
TRACKING_IDENTITY_TOKEN: z.coerce.boolean().default(false),
|
|
144
151
|
RESEND_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
145
152
|
ADMIN_API_KEY: z.string().min(1).optional(),
|
|
146
153
|
API_PUBLIC_URL: z.string().url().default("http://localhost:3002"),
|
package/src/index.ts
CHANGED
|
@@ -205,6 +205,12 @@ export { checkEmailPreferences } from "./lib/enrollment-guards.js";
|
|
|
205
205
|
export { isFrequencyCapped } from "./lib/frequency-cap.js";
|
|
206
206
|
export { addrSpecOf, hostOfFromAddress } from "./lib/from-address.js";
|
|
207
207
|
export { hatchet } from "./lib/hatchet.js";
|
|
208
|
+
export {
|
|
209
|
+
generateIdentityToken,
|
|
210
|
+
type IdentityTokenPayload,
|
|
211
|
+
InvalidIdentityTokenError,
|
|
212
|
+
validateIdentityToken,
|
|
213
|
+
} from "./lib/identity-token.js";
|
|
208
214
|
// --- Ingestion pipeline ---
|
|
209
215
|
export {
|
|
210
216
|
type IngestEvent,
|
|
@@ -229,6 +235,13 @@ export {
|
|
|
229
235
|
} from "./lib/redis.js";
|
|
230
236
|
// --- Self-service password reset (engine-owned, self-contained email) ---
|
|
231
237
|
export { sendResetPasswordEmail } from "./lib/reset-email.js";
|
|
238
|
+
export {
|
|
239
|
+
type ConfirmSemanticClickInput,
|
|
240
|
+
type ConfirmSemanticClickResult,
|
|
241
|
+
confirmSemanticClick,
|
|
242
|
+
SEMANTIC_BURST_DISTINCT_LINKS,
|
|
243
|
+
SEMANTIC_BURST_WINDOW_MS,
|
|
244
|
+
} from "./lib/semantic-click.js";
|
|
232
245
|
export { type MountStudioResult, mountStudio } from "./lib/studio.js";
|
|
233
246
|
export {
|
|
234
247
|
type ResolveTimezoneInput,
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { JsonValue } from "@hatchet-dev/typescript-sdk/v1/types.js";
|
|
2
|
-
import { evaluatePropertyConditions } from "@hogsend/core";
|
|
2
|
+
import { criteriaBuilder, evaluatePropertyConditions } from "@hogsend/core";
|
|
3
3
|
import type {
|
|
4
4
|
JourneyMeta,
|
|
5
|
+
JourneyMetaInput,
|
|
5
6
|
JourneyRunFn,
|
|
6
7
|
JourneyUser,
|
|
8
|
+
JourneyWhere,
|
|
9
|
+
PropertyCondition,
|
|
7
10
|
} from "@hogsend/core/types";
|
|
8
11
|
import { contacts, journeyConfigs, journeyStates } from "@hogsend/db";
|
|
9
12
|
import { and, eq, inArray, notInArray } from "drizzle-orm";
|
|
@@ -37,11 +40,45 @@ export interface DefinedJourney {
|
|
|
37
40
|
task: ReturnType<typeof hatchet.durableTask>;
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a builder-function `where` to its `PropertyCondition[]` — a
|
|
45
|
+
* one-shot, definition-time call mirroring `defineBucket`'s criteria
|
|
46
|
+
* normalization. A declarative array passes straight through, so existing
|
|
47
|
+
* journeys are unaffected; downstream (registry parse, checkExits, admin
|
|
48
|
+
* routes, Studio) only ever sees plain data.
|
|
49
|
+
*/
|
|
50
|
+
function normalizeWhere(
|
|
51
|
+
where: JourneyWhere | undefined,
|
|
52
|
+
): PropertyCondition[] | undefined {
|
|
53
|
+
if (typeof where !== "function") return where;
|
|
54
|
+
const resolved = where(criteriaBuilder);
|
|
55
|
+
return Array.isArray(resolved) ? resolved : [resolved];
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
export function defineJourney(options: {
|
|
41
|
-
meta:
|
|
59
|
+
meta: JourneyMetaInput;
|
|
42
60
|
run: JourneyRunFn;
|
|
43
61
|
}): DefinedJourney {
|
|
44
|
-
const {
|
|
62
|
+
const { trigger, exitOn, ...rest } = options.meta;
|
|
63
|
+
const triggerWhere = normalizeWhere(trigger.where);
|
|
64
|
+
const meta: JourneyMeta = {
|
|
65
|
+
...rest,
|
|
66
|
+
trigger: {
|
|
67
|
+
event: trigger.event,
|
|
68
|
+
...(triggerWhere ? { where: triggerWhere } : {}),
|
|
69
|
+
},
|
|
70
|
+
...(exitOn
|
|
71
|
+
? {
|
|
72
|
+
exitOn: exitOn.map((exit) => {
|
|
73
|
+
const exitWhere = normalizeWhere(exit.where);
|
|
74
|
+
return {
|
|
75
|
+
event: exit.event,
|
|
76
|
+
...(exitWhere ? { where: exitWhere } : {}),
|
|
77
|
+
};
|
|
78
|
+
}),
|
|
79
|
+
}
|
|
80
|
+
: {}),
|
|
81
|
+
};
|
|
45
82
|
|
|
46
83
|
const task = hatchet.durableTask({
|
|
47
84
|
name: `journey-${meta.id}`,
|
|
@@ -22,11 +22,17 @@ import type {
|
|
|
22
22
|
IfPast,
|
|
23
23
|
JourneyContext,
|
|
24
24
|
TimeOfDayBuilder,
|
|
25
|
+
WaitForEventResult,
|
|
25
26
|
Weekday,
|
|
26
27
|
WhenBuilder,
|
|
27
28
|
} from "@hogsend/core/types";
|
|
28
|
-
import {
|
|
29
|
-
|
|
29
|
+
import {
|
|
30
|
+
type Database,
|
|
31
|
+
emailSends,
|
|
32
|
+
journeyStates,
|
|
33
|
+
userEvents,
|
|
34
|
+
} from "@hogsend/db";
|
|
35
|
+
import { and, count, desc, eq, gte, max, notInArray } from "drizzle-orm";
|
|
30
36
|
import { checkEmailPreferences } from "../lib/enrollment-guards.js";
|
|
31
37
|
import { ingestEvent } from "../lib/ingestion.js";
|
|
32
38
|
import type { Logger } from "../lib/logger.js";
|
|
@@ -206,7 +212,8 @@ export function createJourneyContext(
|
|
|
206
212
|
event: string,
|
|
207
213
|
timeout: DurationObject,
|
|
208
214
|
nodeId: string,
|
|
209
|
-
|
|
215
|
+
lookback?: DurationObject,
|
|
216
|
+
): Promise<WaitForEventResult> => {
|
|
210
217
|
// Reject a timeout longer than the journey task's executionTimeout up front
|
|
211
218
|
// so it fails fast at authoring time. (Eviction-capable engines may allow
|
|
212
219
|
// longer wall-clock waits, but we cap to the configured ceiling — raise
|
|
@@ -217,6 +224,41 @@ export function createJourneyContext(
|
|
|
217
224
|
);
|
|
218
225
|
}
|
|
219
226
|
|
|
227
|
+
// Optional lookback: the durable wait only matches events pushed AFTER it
|
|
228
|
+
// is established, so an answer landing in the gap (between a send and its
|
|
229
|
+
// wait, or between two back-to-back waits) would otherwise be permanently
|
|
230
|
+
// unobservable — its first-answer idempotency key is already claimed and
|
|
231
|
+
// can never re-push. A recent matching user_events row resolves the wait
|
|
232
|
+
// immediately, payload included.
|
|
233
|
+
if (lookback) {
|
|
234
|
+
const since = new Date(Date.now() - durationToMs(lookback));
|
|
235
|
+
const recent = await db
|
|
236
|
+
.select({ properties: userEvents.properties })
|
|
237
|
+
.from(userEvents)
|
|
238
|
+
.where(
|
|
239
|
+
and(
|
|
240
|
+
eq(userEvents.userId, userId),
|
|
241
|
+
eq(userEvents.event, event),
|
|
242
|
+
gte(userEvents.occurredAt, since),
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
.orderBy(desc(userEvents.occurredAt))
|
|
246
|
+
.limit(1);
|
|
247
|
+
const row = recent[0];
|
|
248
|
+
if (row) {
|
|
249
|
+
const scalars = Object.fromEntries(
|
|
250
|
+
Object.entries(row.properties ?? {}).filter(
|
|
251
|
+
([, v]) =>
|
|
252
|
+
typeof v === "string" ||
|
|
253
|
+
typeof v === "number" ||
|
|
254
|
+
typeof v === "boolean" ||
|
|
255
|
+
v === null,
|
|
256
|
+
),
|
|
257
|
+
) as NonNullable<WaitForEventResult["properties"]>;
|
|
258
|
+
return { timedOut: false, properties: scalars };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
220
262
|
await enterWait(nodeId);
|
|
221
263
|
|
|
222
264
|
// Wait for the user-scoped event or the timeout. The event branch filters on
|
|
@@ -241,9 +283,34 @@ export function createJourneyContext(
|
|
|
241
283
|
{}) as Record<string, unknown>;
|
|
242
284
|
const timedOut = !("event" in fired);
|
|
243
285
|
|
|
286
|
+
// Surface the matched event's payload (best-effort). The engine returns
|
|
287
|
+
// matches as `[{ id, data }]` where `data` is the pushed ingest payload
|
|
288
|
+
// ({ userId, userEmail, properties }); the pre-eviction path may hand the
|
|
289
|
+
// payload back un-wrapped — tolerate both, mirroring the CREATE-strip.
|
|
290
|
+
let properties: WaitForEventResult["properties"];
|
|
291
|
+
if (!timedOut) {
|
|
292
|
+
const matches = fired.event;
|
|
293
|
+
const first = Array.isArray(matches) ? matches[0] : matches;
|
|
294
|
+
const payload =
|
|
295
|
+
first && typeof first === "object" && "data" in first
|
|
296
|
+
? (first as { data?: unknown }).data
|
|
297
|
+
: first;
|
|
298
|
+
const candidate =
|
|
299
|
+
payload && typeof payload === "object" && "properties" in payload
|
|
300
|
+
? (payload as { properties?: unknown }).properties
|
|
301
|
+
: undefined;
|
|
302
|
+
if (
|
|
303
|
+
candidate &&
|
|
304
|
+
typeof candidate === "object" &&
|
|
305
|
+
!Array.isArray(candidate)
|
|
306
|
+
) {
|
|
307
|
+
properties = candidate as NonNullable<WaitForEventResult["properties"]>;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
244
311
|
await resumeFromWait();
|
|
245
312
|
|
|
246
|
-
return { timedOut };
|
|
313
|
+
return { timedOut, ...(properties ? { properties } : {}) };
|
|
247
314
|
};
|
|
248
315
|
|
|
249
316
|
return {
|
|
@@ -275,11 +342,12 @@ export function createJourneyContext(
|
|
|
275
342
|
);
|
|
276
343
|
},
|
|
277
344
|
|
|
278
|
-
async waitForEvent({ event, timeout, label }) {
|
|
345
|
+
async waitForEvent({ event, timeout, label, lookback }) {
|
|
279
346
|
return performWaitForEvent(
|
|
280
347
|
event,
|
|
281
348
|
timeout,
|
|
282
349
|
label ?? `wait-event:${event}`,
|
|
350
|
+
lookback,
|
|
283
351
|
);
|
|
284
352
|
},
|
|
285
353
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
createHash,
|
|
5
|
+
randomBytes,
|
|
6
|
+
} from "node:crypto";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Short-lived identity token appended to tracked-link redirects as `hs_t`
|
|
10
|
+
* (opt-in via TRACKING_IDENTITY_TOKEN). The landing site exchanges it at
|
|
11
|
+
* `POST /v1/t/identify` for the distinct id and calls `posthog.identify` —
|
|
12
|
+
* stitching the email click to the web session.
|
|
13
|
+
*
|
|
14
|
+
* ENCRYPTED (AES-256-GCM keyed off BETTER_AUTH_SECRET), not merely signed:
|
|
15
|
+
* the distinct id can fall back to an email address, and a signed-but-
|
|
16
|
+
* readable token would put a base64-decodable email in the URL — into
|
|
17
|
+
* browser history, referrers, and any script on the landing page. The GCM
|
|
18
|
+
* auth tag also covers integrity, so tampering fails decryption.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface IdentityTokenPayload {
|
|
22
|
+
/** The distinct id the landing site should identify as. */
|
|
23
|
+
distinctId: string;
|
|
24
|
+
emailSendId: string;
|
|
25
|
+
exp: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class InvalidIdentityTokenError extends Error {
|
|
29
|
+
constructor(message: string) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "InvalidIdentityTokenError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DEFAULT_EXPIRY_SECONDS = 60 * 60; // 1 hour — a click-to-landing hop
|
|
36
|
+
const IV_LENGTH = 12;
|
|
37
|
+
const TAG_LENGTH = 16;
|
|
38
|
+
|
|
39
|
+
function deriveKey(secret: string): Buffer {
|
|
40
|
+
return createHash("sha256").update(secret).digest();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function generateIdentityToken(opts: {
|
|
44
|
+
secret: string;
|
|
45
|
+
distinctId: string;
|
|
46
|
+
emailSendId: string;
|
|
47
|
+
expiresInSeconds?: number;
|
|
48
|
+
}): string {
|
|
49
|
+
const payload: IdentityTokenPayload = {
|
|
50
|
+
distinctId: opts.distinctId,
|
|
51
|
+
emailSendId: opts.emailSendId,
|
|
52
|
+
exp:
|
|
53
|
+
Math.floor(Date.now() / 1000) +
|
|
54
|
+
(opts.expiresInSeconds ?? DEFAULT_EXPIRY_SECONDS),
|
|
55
|
+
};
|
|
56
|
+
const iv = randomBytes(IV_LENGTH);
|
|
57
|
+
const cipher = createCipheriv("aes-256-gcm", deriveKey(opts.secret), iv);
|
|
58
|
+
const ciphertext = Buffer.concat([
|
|
59
|
+
cipher.update(JSON.stringify(payload), "utf-8"),
|
|
60
|
+
cipher.final(),
|
|
61
|
+
]);
|
|
62
|
+
return Buffer.concat([iv, ciphertext, cipher.getAuthTag()]).toString(
|
|
63
|
+
"base64url",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function validateIdentityToken(opts: {
|
|
68
|
+
token: string;
|
|
69
|
+
secret: string;
|
|
70
|
+
}): IdentityTokenPayload {
|
|
71
|
+
let raw: Buffer;
|
|
72
|
+
try {
|
|
73
|
+
raw = Buffer.from(opts.token, "base64url");
|
|
74
|
+
} catch {
|
|
75
|
+
throw new InvalidIdentityTokenError("Malformed token");
|
|
76
|
+
}
|
|
77
|
+
if (raw.length <= IV_LENGTH + TAG_LENGTH) {
|
|
78
|
+
throw new InvalidIdentityTokenError("Malformed token");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const iv = raw.subarray(0, IV_LENGTH);
|
|
82
|
+
const ciphertext = raw.subarray(IV_LENGTH, raw.length - TAG_LENGTH);
|
|
83
|
+
const tag = raw.subarray(raw.length - TAG_LENGTH);
|
|
84
|
+
|
|
85
|
+
let payload: IdentityTokenPayload;
|
|
86
|
+
try {
|
|
87
|
+
const decipher = createDecipheriv(
|
|
88
|
+
"aes-256-gcm",
|
|
89
|
+
deriveKey(opts.secret),
|
|
90
|
+
iv,
|
|
91
|
+
);
|
|
92
|
+
decipher.setAuthTag(tag);
|
|
93
|
+
const plaintext = Buffer.concat([
|
|
94
|
+
decipher.update(ciphertext),
|
|
95
|
+
decipher.final(),
|
|
96
|
+
]).toString("utf-8");
|
|
97
|
+
payload = JSON.parse(plaintext);
|
|
98
|
+
} catch {
|
|
99
|
+
throw new InvalidIdentityTokenError("Bad token");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (
|
|
103
|
+
typeof payload.distinctId !== "string" ||
|
|
104
|
+
typeof payload.exp !== "number"
|
|
105
|
+
) {
|
|
106
|
+
throw new InvalidIdentityTokenError("Invalid payload shape");
|
|
107
|
+
}
|
|
108
|
+
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
|
109
|
+
throw new InvalidIdentityTokenError("Token expired");
|
|
110
|
+
}
|
|
111
|
+
return payload;
|
|
112
|
+
}
|
package/src/lib/ingestion.ts
CHANGED
|
@@ -68,6 +68,7 @@ export async function ingestEvent(opts: {
|
|
|
68
68
|
|
|
69
69
|
// (2) Idempotency dedup + `user_events` insert keyed on the resolved key, with
|
|
70
70
|
// ONLY eventProperties in the properties bag (D2).
|
|
71
|
+
let idempotentInsertId: string | undefined;
|
|
71
72
|
if (event.idempotencyKey) {
|
|
72
73
|
const result = await db
|
|
73
74
|
.insert(userEvents)
|
|
@@ -86,6 +87,7 @@ export async function ingestEvent(opts: {
|
|
|
86
87
|
if (result.length === 0) {
|
|
87
88
|
return { stored: false, exits: [] };
|
|
88
89
|
}
|
|
90
|
+
idempotentInsertId = result[0]?.id;
|
|
89
91
|
} else {
|
|
90
92
|
await db.insert(userEvents).values({
|
|
91
93
|
userId: resolvedKey,
|
|
@@ -109,7 +111,13 @@ export async function ingestEvent(opts: {
|
|
|
109
111
|
|
|
110
112
|
// (4) Hatchet push + (5) checkExits, both keyed on the resolved key. The push
|
|
111
113
|
// payload wire key STAYS `properties` (bucket tests assert on it — risk 9).
|
|
112
|
-
|
|
114
|
+
//
|
|
115
|
+
// An idempotency claim must not outlive a FAILED publish: journeys were never
|
|
116
|
+
// notified, and the consumed key would make every retry a silent no-op (the
|
|
117
|
+
// event becomes permanently invisible to journeys/destinations). So on a push
|
|
118
|
+
// failure the just-inserted row is compensating-deleted before rethrowing —
|
|
119
|
+
// the caller's retry (same key) can then re-claim and re-publish.
|
|
120
|
+
const [pushResult, exitsResult] = await Promise.allSettled([
|
|
113
121
|
hatchet.events.push(event.event, {
|
|
114
122
|
userId: resolvedKey,
|
|
115
123
|
userEmail: event.userEmail ?? "",
|
|
@@ -121,6 +129,29 @@ export async function ingestEvent(opts: {
|
|
|
121
129
|
properties: event.eventProperties,
|
|
122
130
|
}),
|
|
123
131
|
]);
|
|
132
|
+
if (pushResult.status === "rejected") {
|
|
133
|
+
if (idempotentInsertId) {
|
|
134
|
+
try {
|
|
135
|
+
await db
|
|
136
|
+
.delete(userEvents)
|
|
137
|
+
.where(eq(userEvents.id, idempotentInsertId));
|
|
138
|
+
} catch (cleanupErr) {
|
|
139
|
+
logger.warn("ingestEvent: failed to roll back idempotency claim", {
|
|
140
|
+
event: event.event,
|
|
141
|
+
idempotencyKey: event.idempotencyKey,
|
|
142
|
+
error:
|
|
143
|
+
cleanupErr instanceof Error
|
|
144
|
+
? cleanupErr.message
|
|
145
|
+
: String(cleanupErr),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
throw pushResult.reason;
|
|
150
|
+
}
|
|
151
|
+
if (exitsResult.status === "rejected") {
|
|
152
|
+
throw exitsResult.reason;
|
|
153
|
+
}
|
|
154
|
+
const exits = exitsResult.value;
|
|
124
155
|
|
|
125
156
|
// (6) Real-time bucket membership re-evaluation (Section 6.1). NOT part of the
|
|
126
157
|
// Promise.all above: its property eval reads contact state ⊕ this-ingest
|
package/src/lib/outbound.ts
CHANGED
|
@@ -90,6 +90,23 @@ export interface OutboundPayloads {
|
|
|
90
90
|
"email.delivered": EmailEventPayload;
|
|
91
91
|
"email.opened": EmailEventPayload;
|
|
92
92
|
"email.clicked": EmailEventPayload & { linkUrl?: string; linkId?: string };
|
|
93
|
+
/**
|
|
94
|
+
* A SEMANTIC link answered — the in-email action event (consumer-named, e.g.
|
|
95
|
+
* "nps.submitted"). Emitted at most once per (send, event name): first
|
|
96
|
+
* answer wins, scanner bursts are suppressed. `event`/`properties` carry the
|
|
97
|
+
* consumer semantics; the rest is send context.
|
|
98
|
+
*/
|
|
99
|
+
"email.action": {
|
|
100
|
+
event: string;
|
|
101
|
+
properties: Record<string, unknown> | null;
|
|
102
|
+
emailSendId: string;
|
|
103
|
+
templateKey: string | null;
|
|
104
|
+
userId: string | null;
|
|
105
|
+
to: string;
|
|
106
|
+
at: string;
|
|
107
|
+
linkId: string;
|
|
108
|
+
linkUrl: string;
|
|
109
|
+
};
|
|
93
110
|
"email.bounced": EmailEventPayload & {
|
|
94
111
|
bounceType?: string;
|
|
95
112
|
bounceReason?: string;
|
|
@@ -18,6 +18,7 @@ const POSTHOG_FUNNEL_EVENTS = [
|
|
|
18
18
|
"email.delivered",
|
|
19
19
|
"email.opened",
|
|
20
20
|
"email.clicked",
|
|
21
|
+
"email.action",
|
|
21
22
|
"email.bounced",
|
|
22
23
|
"email.complained",
|
|
23
24
|
] as const;
|
|
@@ -51,7 +52,11 @@ export async function seedPostHogDestination(opts: {
|
|
|
51
52
|
);
|
|
52
53
|
|
|
53
54
|
const existing = await tx
|
|
54
|
-
.select({
|
|
55
|
+
.select({
|
|
56
|
+
id: webhookEndpoints.id,
|
|
57
|
+
url: webhookEndpoints.url,
|
|
58
|
+
eventTypes: webhookEndpoints.eventTypes,
|
|
59
|
+
})
|
|
55
60
|
.from(webhookEndpoints)
|
|
56
61
|
.where(
|
|
57
62
|
and(
|
|
@@ -61,7 +66,34 @@ export async function seedPostHogDestination(opts: {
|
|
|
61
66
|
)
|
|
62
67
|
.limit(1);
|
|
63
68
|
|
|
64
|
-
|
|
69
|
+
const found = existing[0];
|
|
70
|
+
if (found) {
|
|
71
|
+
// Reconcile the ENGINE-seeded row (identified by its sentinel URL) when
|
|
72
|
+
// the funnel list has grown since it was inserted — its stored
|
|
73
|
+
// eventTypes are a snapshot, and emitOutbound matches by jsonb
|
|
74
|
+
// containment, so a pre-upgrade row would silently never receive newer
|
|
75
|
+
// events (e.g. email.action). Operator-created endpoints are left
|
|
76
|
+
// untouched: subscriber-chooses-events is the contract there.
|
|
77
|
+
if (found.url === "posthog://capture") {
|
|
78
|
+
const current = Array.isArray(found.eventTypes)
|
|
79
|
+
? (found.eventTypes as string[])
|
|
80
|
+
: [];
|
|
81
|
+
const missing = POSTHOG_FUNNEL_EVENTS.filter(
|
|
82
|
+
(e) => !current.includes(e),
|
|
83
|
+
);
|
|
84
|
+
if (missing.length > 0) {
|
|
85
|
+
await tx
|
|
86
|
+
.update(webhookEndpoints)
|
|
87
|
+
.set({
|
|
88
|
+
eventTypes: [...current, ...missing],
|
|
89
|
+
updatedAt: new Date(),
|
|
90
|
+
})
|
|
91
|
+
.where(eq(webhookEndpoints.id, found.id));
|
|
92
|
+
logger.info("Reconciled seeded PostHog destination event types", {
|
|
93
|
+
added: missing,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
65
97
|
return { seeded: false };
|
|
66
98
|
}
|
|
67
99
|
|