@hogsend/engine 0.8.0 → 0.9.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 +6 -6
- package/src/container.ts +80 -8
- package/src/destinations/define-destination.ts +104 -0
- package/src/destinations/presets/index.ts +94 -0
- package/src/destinations/presets/posthog.ts +71 -0
- package/src/destinations/presets/segment.ts +75 -0
- package/src/destinations/presets/slack.ts +66 -0
- package/src/destinations/presets/webhook.ts +37 -0
- package/src/destinations/registry-singleton.ts +78 -0
- package/src/env.ts +15 -0
- package/src/index.ts +25 -0
- package/src/journeys/define-journey.ts +0 -1
- package/src/journeys/journey-context.ts +1 -17
- package/src/lib/analytics-singleton.ts +7 -0
- package/src/lib/mailer.ts +21 -6
- package/src/lib/outbound.ts +7 -0
- package/src/lib/seed-posthog-destination.ts +93 -0
- package/src/lib/tracking-events.ts +11 -9
- package/src/lib/webhook-signing.ts +2 -1
- package/src/routes/admin/webhooks.ts +100 -9
- package/src/routes/tracking/click.ts +20 -25
- package/src/routes/tracking/open.ts +20 -27
- package/src/workflows/deliver-webhook.ts +137 -52
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { DefinedDestination } from "./define-destination.js";
|
|
2
|
+
import { PRESET_DESTINATIONS } from "./presets/index.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The process-wide destination registry, set once by `createHogsendClient` at
|
|
6
|
+
* startup and read by the delivery task (`workflows/deliver-webhook.ts`) to
|
|
7
|
+
* resolve a transform by `endpoint.kind`.
|
|
8
|
+
*
|
|
9
|
+
* Why a singleton (mirrors `lib/analytics-singleton.ts`): the durable
|
|
10
|
+
* `deliverWebhookTask` SELF-BOOTS — it opens its own `getDb()` from
|
|
11
|
+
* `process.env` and has NO client/container reference. So the registered
|
|
12
|
+
* transforms (presets + the consumer's `defineDestination()` destinations) MUST
|
|
13
|
+
* be reachable via a process singleton, exactly as analytics / the journey +
|
|
14
|
+
* bucket registries are. `createHogsendClient` runs in BOTH the API and worker,
|
|
15
|
+
* so by the time any worker task executes the registry has been installed.
|
|
16
|
+
*
|
|
17
|
+
* Resilient default: if a delivery task somehow runs in a process that never
|
|
18
|
+
* called `createHogsendClient` (a bare reaper re-drive in a test harness), the
|
|
19
|
+
* getter lazily falls back to the shipped {@link PRESET_DESTINATIONS} so the
|
|
20
|
+
* no-regression `webhook` + the `posthog` presets still resolve. Installing a
|
|
21
|
+
* registry via {@link setDestinationRegistry} replaces this fallback.
|
|
22
|
+
*/
|
|
23
|
+
export class DestinationRegistry {
|
|
24
|
+
private readonly byKind = new Map<string, DefinedDestination>();
|
|
25
|
+
|
|
26
|
+
constructor(destinations: DefinedDestination[] = []) {
|
|
27
|
+
for (const destination of destinations) {
|
|
28
|
+
// Last-writer-wins on id collision — the caller (container) orders the
|
|
29
|
+
// array so the consumer's destination wins over a preset of the same id.
|
|
30
|
+
this.byKind.set(destination.meta.id, destination);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Resolve a destination by its `kind` id, or `undefined` when unregistered. */
|
|
35
|
+
get(kind: string): DefinedDestination | undefined {
|
|
36
|
+
return this.byKind.get(kind);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Every registered destination (for diagnostics / catalog enumeration). */
|
|
40
|
+
getAll(): DefinedDestination[] {
|
|
41
|
+
return [...this.byKind.values()];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Number of registered destinations. */
|
|
45
|
+
count(): number {
|
|
46
|
+
return this.byKind.size;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The lazily-built fallback registry of just the shipped presets. */
|
|
51
|
+
let fallback: DestinationRegistry | undefined;
|
|
52
|
+
let installed: DestinationRegistry | undefined;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Install the resolved destination registry. Called by `createHogsendClient`
|
|
56
|
+
* after merging the env presets with the consumer's `opts.destinations`.
|
|
57
|
+
*/
|
|
58
|
+
export function setDestinationRegistry(registry: DestinationRegistry): void {
|
|
59
|
+
installed = registry;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read the destination registry. Returns the installed registry, or a lazily
|
|
64
|
+
* built preset-only fallback so a self-booting task always resolves the
|
|
65
|
+
* always-on `webhook` + `posthog` presets even before any container ran.
|
|
66
|
+
*/
|
|
67
|
+
export function getDestinationRegistry(): DestinationRegistry {
|
|
68
|
+
if (installed) return installed;
|
|
69
|
+
if (!fallback) {
|
|
70
|
+
fallback = new DestinationRegistry(Object.values(PRESET_DESTINATIONS));
|
|
71
|
+
}
|
|
72
|
+
return fallback;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Reset the installed registry — only for test cleanup. */
|
|
76
|
+
export function resetDestinationRegistry(): void {
|
|
77
|
+
installed = undefined;
|
|
78
|
+
}
|
package/src/env.ts
CHANGED
|
@@ -57,6 +57,12 @@ export const env = createEnv({
|
|
|
57
57
|
POSTHOG_API_KEY: z.string().min(1).optional(),
|
|
58
58
|
POSTHOG_HOST: z.string().url().optional(),
|
|
59
59
|
POSTHOG_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
60
|
+
// When true AND POSTHOG_API_KEY is set, the engine idempotently auto-seeds
|
|
61
|
+
// ONE kind="posthog" webhook endpoint subscribed to the email funnel so the
|
|
62
|
+
// full email lifecycle fans out to PostHog DURABLY (on the delivery spine).
|
|
63
|
+
// Default OFF to avoid a surprise double-emit alongside the existing
|
|
64
|
+
// fire-and-forget PostHog capture path.
|
|
65
|
+
ENABLE_POSTHOG_DESTINATION: z.coerce.boolean().default(false),
|
|
60
66
|
RESEND_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
61
67
|
ADMIN_API_KEY: z.string().min(1).optional(),
|
|
62
68
|
API_PUBLIC_URL: z.string().url().default("http://localhost:3002"),
|
|
@@ -96,6 +102,15 @@ export const env = createEnv({
|
|
|
96
102
|
// Preset enablement override: csv of preset ids, `"*"` (all with a secret),
|
|
97
103
|
// or `"none"`. Absent → auto-enable any preset whose secret is set.
|
|
98
104
|
ENABLED_WEBHOOK_PRESETS: z.string().optional(),
|
|
105
|
+
// --- Outbound destination presets (Phase 3) ---
|
|
106
|
+
// Which `defineDestination()` PRESETS are registered into the process
|
|
107
|
+
// destination registry the delivery task resolves by `endpoint.kind`. csv of
|
|
108
|
+
// ids (e.g. "segment,slack"), `"*"` (all presets), or `"none"`. Absent → the
|
|
109
|
+
// DEFAULT set (webhook + posthog). The `webhook` and `posthog` presets are
|
|
110
|
+
// ALWAYS registered regardless of this value, so the no-regression delivery
|
|
111
|
+
// path can never be turned off by misconfiguration. Set this to add the
|
|
112
|
+
// segment/slack presets (credentials still live per-endpoint in `config`).
|
|
113
|
+
ENABLED_DESTINATION_PRESETS: z.string().optional(),
|
|
99
114
|
},
|
|
100
115
|
runtimeEnv: process.env,
|
|
101
116
|
emptyStringAsUndefined: true,
|
package/src/index.ts
CHANGED
|
@@ -76,6 +76,31 @@ export {
|
|
|
76
76
|
type HogsendClientOptions,
|
|
77
77
|
type HogsendDefaults,
|
|
78
78
|
} from "./container.js";
|
|
79
|
+
// --- Outbound destinations: public authoring layer (Phase 3) ---
|
|
80
|
+
export {
|
|
81
|
+
type DefinedDestination,
|
|
82
|
+
type DestinationCtx,
|
|
83
|
+
type DestinationEnvelope,
|
|
84
|
+
type DestinationMeta,
|
|
85
|
+
type DestinationTransformResult,
|
|
86
|
+
defineDestination,
|
|
87
|
+
type WebhookEndpointRow,
|
|
88
|
+
} from "./destinations/define-destination.js";
|
|
89
|
+
export {
|
|
90
|
+
type DestinationPresetId,
|
|
91
|
+
destinationsFromEnv,
|
|
92
|
+
PRESET_DESTINATIONS,
|
|
93
|
+
posthogDestination,
|
|
94
|
+
segmentDestination,
|
|
95
|
+
slackDestination,
|
|
96
|
+
webhookDestination,
|
|
97
|
+
} from "./destinations/presets/index.js";
|
|
98
|
+
export {
|
|
99
|
+
DestinationRegistry,
|
|
100
|
+
getDestinationRegistry,
|
|
101
|
+
resetDestinationRegistry,
|
|
102
|
+
setDestinationRegistry,
|
|
103
|
+
} from "./destinations/registry-singleton.js";
|
|
79
104
|
// --- Env ---
|
|
80
105
|
export { API_VERSION, env } from "./env.js";
|
|
81
106
|
// --- Journeys ---
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
SleepCondition,
|
|
8
8
|
UserEventCondition,
|
|
9
9
|
} from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
10
|
-
import type { DurationObject
|
|
10
|
+
import type { DurationObject } from "@hogsend/core";
|
|
11
11
|
import { durationToMs, evaluateEventCondition } from "@hogsend/core";
|
|
12
12
|
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
13
13
|
import {
|
|
@@ -69,7 +69,6 @@ interface JourneyContextConfig {
|
|
|
69
69
|
};
|
|
70
70
|
registry: JourneyRegistry;
|
|
71
71
|
logger: Logger;
|
|
72
|
-
posthog?: PostHogService;
|
|
73
72
|
stateId: string;
|
|
74
73
|
userId: string;
|
|
75
74
|
userEmail: string;
|
|
@@ -140,7 +139,6 @@ export function createJourneyContext(
|
|
|
140
139
|
hatchetCtx,
|
|
141
140
|
registry,
|
|
142
141
|
logger,
|
|
143
|
-
posthog,
|
|
144
142
|
stateId,
|
|
145
143
|
userId,
|
|
146
144
|
userEmail,
|
|
@@ -316,10 +314,6 @@ export function createJourneyContext(
|
|
|
316
314
|
});
|
|
317
315
|
},
|
|
318
316
|
|
|
319
|
-
identify(properties) {
|
|
320
|
-
posthog?.identify(userId, properties);
|
|
321
|
-
},
|
|
322
|
-
|
|
323
317
|
guard: {
|
|
324
318
|
async isSubscribed() {
|
|
325
319
|
const prefs = await checkEmailPreferences({ db, userId });
|
|
@@ -390,15 +384,5 @@ export function createJourneyContext(
|
|
|
390
384
|
};
|
|
391
385
|
},
|
|
392
386
|
},
|
|
393
|
-
|
|
394
|
-
posthog: {
|
|
395
|
-
capture({ event, properties }) {
|
|
396
|
-
posthog?.captureEvent({
|
|
397
|
-
distinctId: userId,
|
|
398
|
-
event,
|
|
399
|
-
properties,
|
|
400
|
-
});
|
|
401
|
-
},
|
|
402
|
-
},
|
|
403
387
|
};
|
|
404
388
|
}
|
|
@@ -6,6 +6,13 @@ import { createOptionalSingleton } from "./singleton.js";
|
|
|
6
6
|
* the module-level task-execution sites that have no client reference of their
|
|
7
7
|
* own (the journey durable task in `define-journey`, the bucket PostHog sync).
|
|
8
8
|
*
|
|
9
|
+
* Its role is deliberately NARROW (see the `analytics?` option doc on
|
|
10
|
+
* {@link createHogsendClient}): the identity PULL (`getPersonProperties` for
|
|
11
|
+
* per-user timezone resolution) and the opt-in `bucket.syncToPostHog`
|
|
12
|
+
* person-property mirror. It is explicitly NOT the outbound-catalog firing
|
|
13
|
+
* path — the email/contact/journey/bucket lifecycle fans out durably via
|
|
14
|
+
* DESTINATIONS on the webhook spine, not through this singleton.
|
|
15
|
+
*
|
|
9
16
|
* Mirrors the journey/bucket-registry + client-schedule-defaults singletons:
|
|
10
17
|
* `createHogsendClient` runs in BOTH the API and worker processes, so by the
|
|
11
18
|
* time any worker task executes, the container has already installed the
|
package/src/lib/mailer.ts
CHANGED
|
@@ -210,9 +210,10 @@ export function createTrackedMailer(
|
|
|
210
210
|
case "email.opened":
|
|
211
211
|
case "email.clicked":
|
|
212
212
|
// First-party pixel/redirect is the SINGLE outbound emitter for
|
|
213
|
-
// open/click
|
|
214
|
-
//
|
|
215
|
-
// updates the DB status, it does NOT emit
|
|
213
|
+
// open/click — it now fires PER-HIT (every open/click → a delivery to
|
|
214
|
+
// every destination, owner decision 1). The provider-webhook echo is
|
|
215
|
+
// SUPPRESSED here: it only updates the DB status, it does NOT emit
|
|
216
|
+
// outbound (no double-source).
|
|
216
217
|
await updateEmailStatus(event.type, event.data.email_id);
|
|
217
218
|
break;
|
|
218
219
|
case "email.bounced":
|
|
@@ -229,6 +230,9 @@ export function createTrackedMailer(
|
|
|
229
230
|
break;
|
|
230
231
|
case "email.complained":
|
|
231
232
|
await updateEmailStatus(event.type, event.data.email_id);
|
|
233
|
+
// OUTBOUND `email.complained` — the provider webhook is the SINGLE
|
|
234
|
+
// source for complaints (no first-party signal exists).
|
|
235
|
+
await emitProviderEmailEvent("email.complained", event.data.email_id);
|
|
232
236
|
await handleComplaint(event.data.to);
|
|
233
237
|
break;
|
|
234
238
|
case "email.delivery_delayed":
|
|
@@ -279,8 +283,10 @@ export function createTrackedMailer(
|
|
|
279
283
|
}
|
|
280
284
|
|
|
281
285
|
/**
|
|
282
|
-
* Emit the provider-funnel outbound event (`email.delivered` /
|
|
283
|
-
* for a Resend `email_id`.
|
|
286
|
+
* Emit the provider-funnel outbound event (`email.delivered` /
|
|
287
|
+
* `email.bounced` / `email.complained`) for a Resend `email_id`. These three
|
|
288
|
+
* have no first-party signal — the provider webhook is their single source.
|
|
289
|
+
* Enriches via {@link resolveEmailSendContextByResendId}
|
|
284
290
|
* (the only handle a provider webhook holds is the Resend id). Fire-and-forget:
|
|
285
291
|
* a missing context (webhook racing the send-row commit) or a transient outbound
|
|
286
292
|
* error is logged and swallowed — never failing the webhook handler. No
|
|
@@ -288,7 +294,7 @@ export function createTrackedMailer(
|
|
|
288
294
|
* shared `Webhook-Id` is the subscriber-side dedup for any provider redelivery.
|
|
289
295
|
*/
|
|
290
296
|
function emitProviderEmailEvent(
|
|
291
|
-
event: "email.delivered" | "email.bounced",
|
|
297
|
+
event: "email.delivered" | "email.bounced" | "email.complained",
|
|
292
298
|
resendId: string,
|
|
293
299
|
bounce?: { bounceType?: string; bounceReason?: string },
|
|
294
300
|
): void {
|
|
@@ -321,6 +327,15 @@ export function createTrackedMailer(
|
|
|
321
327
|
},
|
|
322
328
|
});
|
|
323
329
|
}
|
|
330
|
+
if (event === "email.complained") {
|
|
331
|
+
return emitOutbound({
|
|
332
|
+
db: database,
|
|
333
|
+
hatchet,
|
|
334
|
+
logger: log,
|
|
335
|
+
event: "email.complained",
|
|
336
|
+
payload: base,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
324
339
|
return emitOutbound({
|
|
325
340
|
db: database,
|
|
326
341
|
hatchet,
|
package/src/lib/outbound.ts
CHANGED
|
@@ -33,6 +33,9 @@ interface EmailEventPayload {
|
|
|
33
33
|
userId: string | null;
|
|
34
34
|
to: string;
|
|
35
35
|
at: string;
|
|
36
|
+
// Optional enrichment (additive — older subscribers ignore absent keys).
|
|
37
|
+
category?: string;
|
|
38
|
+
subject?: string;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
interface BucketEventPayload {
|
|
@@ -82,6 +85,10 @@ export interface OutboundPayloads {
|
|
|
82
85
|
bounceType?: string;
|
|
83
86
|
bounceReason?: string;
|
|
84
87
|
};
|
|
88
|
+
"email.complained": EmailEventPayload & {
|
|
89
|
+
complaintType?: string;
|
|
90
|
+
reason?: string;
|
|
91
|
+
};
|
|
85
92
|
"journey.completed": {
|
|
86
93
|
journeyId: string;
|
|
87
94
|
journeyName: string;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { type Database, webhookEndpoints } from "@hogsend/db";
|
|
2
|
+
import { and, eq, isNull, sql } from "drizzle-orm";
|
|
3
|
+
import type { Logger } from "./logger.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Transaction-scoped advisory-lock key serializing the single-tenant PostHog
|
|
7
|
+
* seed across concurrent API + worker boots. An arbitrary fixed constant within
|
|
8
|
+
* int4 range (the single-arg `pg_advisory_xact_lock` overload casts to bigint).
|
|
9
|
+
*/
|
|
10
|
+
const SEED_ADVISORY_LOCK_KEY = 1426198835;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The email funnel a seeded PostHog destination subscribes to — the full
|
|
14
|
+
* lifecycle that reaches PostHog on NO path before this destination existed.
|
|
15
|
+
*/
|
|
16
|
+
const POSTHOG_FUNNEL_EVENTS = [
|
|
17
|
+
"email.sent",
|
|
18
|
+
"email.delivered",
|
|
19
|
+
"email.opened",
|
|
20
|
+
"email.clicked",
|
|
21
|
+
"email.bounced",
|
|
22
|
+
"email.complained",
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Idempotently seed ONE `kind="posthog"` webhook endpoint subscribed to the
|
|
27
|
+
* email funnel, so the full email lifecycle fans out to PostHog DURABLY on the
|
|
28
|
+
* delivery spine.
|
|
29
|
+
*
|
|
30
|
+
* Guarded against duplicates: it inserts only when no single-tenant
|
|
31
|
+
* (`organization_id IS NULL`) `kind="posthog"` endpoint already exists. Safe to
|
|
32
|
+
* call from BOTH the API and worker boots (both build the client) — the second
|
|
33
|
+
* caller finds the row and no-ops. Fire-and-forget at the call site: a transient
|
|
34
|
+
* seed failure must never block boot.
|
|
35
|
+
*/
|
|
36
|
+
export async function seedPostHogDestination(opts: {
|
|
37
|
+
db: Database;
|
|
38
|
+
logger: Logger;
|
|
39
|
+
apiKey: string;
|
|
40
|
+
host?: string;
|
|
41
|
+
}): Promise<{ seeded: boolean }> {
|
|
42
|
+
const { db, logger, apiKey, host } = opts;
|
|
43
|
+
|
|
44
|
+
// Serialize the check-then-insert across concurrent API + worker boots (both
|
|
45
|
+
// build the client) with a transaction-scoped advisory lock, so the race can
|
|
46
|
+
// never double-seed. A per-endpoint unique constraint is intentionally avoided
|
|
47
|
+
// — operators may legitimately create multiple PostHog endpoints by hand.
|
|
48
|
+
return db.transaction(async (tx) => {
|
|
49
|
+
await tx.execute(
|
|
50
|
+
sql`SELECT pg_advisory_xact_lock(${SEED_ADVISORY_LOCK_KEY})`,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const existing = await tx
|
|
54
|
+
.select({ id: webhookEndpoints.id })
|
|
55
|
+
.from(webhookEndpoints)
|
|
56
|
+
.where(
|
|
57
|
+
and(
|
|
58
|
+
isNull(webhookEndpoints.organizationId),
|
|
59
|
+
eq(webhookEndpoints.kind, "posthog"),
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
.limit(1);
|
|
63
|
+
|
|
64
|
+
if (existing.length > 0) {
|
|
65
|
+
return { seeded: false };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await tx.insert(webhookEndpoints).values({
|
|
69
|
+
url: "posthog://capture",
|
|
70
|
+
description:
|
|
71
|
+
"Auto-seeded PostHog destination (ENABLE_POSTHOG_DESTINATION)",
|
|
72
|
+
kind: "posthog",
|
|
73
|
+
config: {
|
|
74
|
+
apiKey,
|
|
75
|
+
...(host ? { host } : {}),
|
|
76
|
+
// Preserve continuity with the legacy fire-and-forget PostHog path,
|
|
77
|
+
// which captured clicks as "email.link_clicked"; the posthog transform
|
|
78
|
+
// remaps the canonical "email.clicked" back so existing PostHog funnels
|
|
79
|
+
// keep working after the cutover.
|
|
80
|
+
eventNames: { "email.clicked": "email.link_clicked" },
|
|
81
|
+
},
|
|
82
|
+
eventTypes: [...POSTHOG_FUNNEL_EVENTS],
|
|
83
|
+
secret: null,
|
|
84
|
+
secretPrefix: null,
|
|
85
|
+
disabled: false,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
logger.info("Seeded PostHog destination on the outbound spine", {
|
|
89
|
+
events: POSTHOG_FUNNEL_EVENTS.length,
|
|
90
|
+
});
|
|
91
|
+
return { seeded: true };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
2
|
-
import type { PostHogService } from "@hogsend/core";
|
|
3
2
|
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
4
3
|
import { type Database, emailSends, journeyStates } from "@hogsend/db";
|
|
5
4
|
import { eq } from "drizzle-orm";
|
|
@@ -98,7 +97,6 @@ export interface PushTrackingEventOpts {
|
|
|
98
97
|
hatchet: HatchetClient;
|
|
99
98
|
registry: JourneyRegistry;
|
|
100
99
|
logger: Logger;
|
|
101
|
-
posthog?: PostHogService;
|
|
102
100
|
event: string;
|
|
103
101
|
emailSendId: string;
|
|
104
102
|
properties?: Record<string, unknown>;
|
|
@@ -111,10 +109,20 @@ export interface PushTrackingEventOpts {
|
|
|
111
109
|
resolvedContext?: EmailSendContext | null;
|
|
112
110
|
}
|
|
113
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Re-push a first-party tracking event (open/click) back onto the INTERNAL bus
|
|
114
|
+
* (`ingestEvent`) for journey routing + `userEvents` persistence.
|
|
115
|
+
*
|
|
116
|
+
* NOTE (Phase 2): this NO LONGER fires a fire-and-forget PostHog `captureEvent`.
|
|
117
|
+
* PostHog now receives opens/clicks PER-HIT via the durable outbound spine — a
|
|
118
|
+
* `kind="posthog"` destination subscribed to `email.opened`/`email.clicked` (the
|
|
119
|
+
* tracking routes call `emitOutbound` alongside this). The legacy double-emit was
|
|
120
|
+
* removed so PostHog gets exactly one, durable copy of each hit.
|
|
121
|
+
*/
|
|
114
122
|
export async function pushTrackingEvent(
|
|
115
123
|
opts: PushTrackingEventOpts,
|
|
116
124
|
): Promise<void> {
|
|
117
|
-
const { db, hatchet, registry, logger,
|
|
125
|
+
const { db, hatchet, registry, logger, event, emailSendId } = opts;
|
|
118
126
|
|
|
119
127
|
const ctx =
|
|
120
128
|
opts.resolvedContext !== undefined
|
|
@@ -128,12 +136,6 @@ export async function pushTrackingEvent(
|
|
|
128
136
|
...opts.properties,
|
|
129
137
|
};
|
|
130
138
|
|
|
131
|
-
posthog?.captureEvent({
|
|
132
|
-
distinctId: ctx.userId,
|
|
133
|
-
event,
|
|
134
|
-
properties,
|
|
135
|
-
});
|
|
136
|
-
|
|
137
139
|
await ingestEvent({
|
|
138
140
|
db,
|
|
139
141
|
registry,
|
|
@@ -25,7 +25,7 @@ import { Webhook } from "svix";
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* The
|
|
28
|
+
* The 13-event catalog — the SINGLE source of truth (schema, routes, client,
|
|
29
29
|
* CLI all derive from this). The `webhook.test` sentinel is intentionally NOT a
|
|
30
30
|
* member (it is delivered out-of-band regardless of an endpoint's `eventTypes`).
|
|
31
31
|
*/
|
|
@@ -39,6 +39,7 @@ export const WEBHOOK_EVENT_TYPES = [
|
|
|
39
39
|
"email.opened",
|
|
40
40
|
"email.clicked",
|
|
41
41
|
"email.bounced",
|
|
42
|
+
"email.complained",
|
|
42
43
|
"journey.completed",
|
|
43
44
|
"bucket.entered",
|
|
44
45
|
"bucket.left",
|
|
@@ -3,6 +3,7 @@ import { webhookDeliveries, webhookEndpoints } from "@hogsend/db";
|
|
|
3
3
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
4
4
|
import { count, desc, eq } from "drizzle-orm";
|
|
5
5
|
import type { AppEnv } from "../../app.js";
|
|
6
|
+
import { PRESET_DESTINATIONS } from "../../destinations/presets/index.js";
|
|
6
7
|
import { errorSchema } from "../../lib/schemas.js";
|
|
7
8
|
import {
|
|
8
9
|
generateWebhookSecret,
|
|
@@ -24,17 +25,34 @@ import { deliverWebhookTask } from "../../workflows/deliver-webhook.js";
|
|
|
24
25
|
|
|
25
26
|
// The catalog enum for request validation — derived from the SINGLE source of
|
|
26
27
|
// truth in `webhook-signing.ts` (Section 1.3). `z.enum` needs a non-empty tuple,
|
|
27
|
-
// which `WEBHOOK_EVENT_TYPES` (
|
|
28
|
+
// which `WEBHOOK_EVENT_TYPES` (13 strings, `as const`) satisfies.
|
|
28
29
|
const eventTypeEnum = z.enum(
|
|
29
30
|
WEBHOOK_EVENT_TYPES as unknown as [WebhookEventType, ...WebhookEventType[]],
|
|
30
31
|
);
|
|
31
32
|
|
|
33
|
+
// The destination `kind` enum. "webhook" (default) is the signed POST; any
|
|
34
|
+
// other value selects a delivery-time transform adapter keyed by this column.
|
|
35
|
+
// This MUST cover every shipped preset (the skills + env.example tell operators
|
|
36
|
+
// to create segment/slack endpoints via this admin API / the `hs.webhooks` SDK),
|
|
37
|
+
// so it is derived from `PRESET_DESTINATIONS` — the single source of truth for
|
|
38
|
+
// the shipped `kind`s — rather than a hand-maintained literal list. A `kind`
|
|
39
|
+
// whose transform is not REGISTERED at delivery time still fails its delivery as
|
|
40
|
+
// a config error (DLQ); the registry, not the env, decides resolvability — so we
|
|
41
|
+
// accept any shipped preset id here regardless of `ENABLED_DESTINATION_PRESETS`.
|
|
42
|
+
const kindEnum = z.enum(
|
|
43
|
+
Object.keys(PRESET_DESTINATIONS) as unknown as [string, ...string[]],
|
|
44
|
+
);
|
|
45
|
+
|
|
32
46
|
const webhookEndpointSchema = z.object({
|
|
33
47
|
id: z.string(),
|
|
34
48
|
url: z.string(),
|
|
35
49
|
description: z.string().nullable(),
|
|
36
50
|
eventTypes: z.array(z.string()),
|
|
37
|
-
|
|
51
|
+
// Keyed destinations have no signing secret (null secretPrefix).
|
|
52
|
+
secretPrefix: z.string().nullable(),
|
|
53
|
+
kind: z.string(),
|
|
54
|
+
// REDACTED config view — credentials (e.g. config.apiKey) are masked.
|
|
55
|
+
config: z.record(z.string(), z.unknown()).nullable(),
|
|
38
56
|
status: z.enum(["enabled", "disabled"]),
|
|
39
57
|
organizationId: z.string().nullable(),
|
|
40
58
|
lastDeliveryAt: z.string().nullable(),
|
|
@@ -85,6 +103,11 @@ const createEndpointRoute = createRoute({
|
|
|
85
103
|
eventTypes: z.array(eventTypeEnum).min(1),
|
|
86
104
|
description: z.string().max(500).optional(),
|
|
87
105
|
disabled: z.boolean().optional(),
|
|
106
|
+
// Destination selector. Defaults to "webhook" (signed POST).
|
|
107
|
+
kind: kindEnum.optional(),
|
|
108
|
+
// Per-destination config for keyed adapters (e.g. PostHog's
|
|
109
|
+
// `{ apiKey, host }`). Ignored for kind="webhook".
|
|
110
|
+
config: z.record(z.string(), z.unknown()).optional(),
|
|
88
111
|
}),
|
|
89
112
|
},
|
|
90
113
|
},
|
|
@@ -94,7 +117,11 @@ const createEndpointRoute = createRoute({
|
|
|
94
117
|
201: {
|
|
95
118
|
content: {
|
|
96
119
|
"application/json": {
|
|
97
|
-
|
|
120
|
+
// `secret` is present ONLY for kind="webhook" (the one-time signing
|
|
121
|
+
// secret). Keyed destinations carry no secret.
|
|
122
|
+
schema: webhookEndpointSchema.extend({
|
|
123
|
+
secret: z.string().optional(),
|
|
124
|
+
}),
|
|
98
125
|
},
|
|
99
126
|
},
|
|
100
127
|
description: "Endpoint created — signing secret shown only once",
|
|
@@ -139,6 +166,8 @@ const updateEndpointRoute = createRoute({
|
|
|
139
166
|
eventTypes: z.array(eventTypeEnum).min(1).optional(),
|
|
140
167
|
description: z.string().max(500).nullable().optional(),
|
|
141
168
|
disabled: z.boolean().optional(),
|
|
169
|
+
kind: kindEnum.optional(),
|
|
170
|
+
config: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
142
171
|
}),
|
|
143
172
|
},
|
|
144
173
|
},
|
|
@@ -237,10 +266,30 @@ const testRoute = createRoute({
|
|
|
237
266
|
},
|
|
238
267
|
});
|
|
239
268
|
|
|
269
|
+
/** Config keys whose values are credentials and must be masked in responses. */
|
|
270
|
+
const REDACTED_CONFIG_KEYS = new Set(["apiKey", "apikey", "api_key"]);
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Mask credential values in a keyed destination's `config` for API responses —
|
|
274
|
+
* `config.apiKey` becomes `"***"` so a list/get never leaks the secret. Returns
|
|
275
|
+
* null when there is no config (kind="webhook").
|
|
276
|
+
*/
|
|
277
|
+
function redactConfig(
|
|
278
|
+
config: Record<string, unknown> | null,
|
|
279
|
+
): Record<string, unknown> | null {
|
|
280
|
+
if (!config) return null;
|
|
281
|
+
const out: Record<string, unknown> = {};
|
|
282
|
+
for (const [key, value] of Object.entries(config)) {
|
|
283
|
+
out[key] = REDACTED_CONFIG_KEYS.has(key) ? "***" : value;
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
|
|
240
288
|
/**
|
|
241
289
|
* Serialize an endpoint row for an API response. NEVER includes `secret` — the
|
|
242
290
|
* full `whsec_…` is surfaced only on create + rotate-secret via the dedicated
|
|
243
|
-
* response shapes. `
|
|
291
|
+
* response shapes. `config` is REDACTED (credentials masked). `status` is
|
|
292
|
+
* derived from the `disabled` boolean.
|
|
244
293
|
*/
|
|
245
294
|
function serializeEndpoint(row: typeof webhookEndpoints.$inferSelect) {
|
|
246
295
|
return {
|
|
@@ -249,6 +298,8 @@ function serializeEndpoint(row: typeof webhookEndpoints.$inferSelect) {
|
|
|
249
298
|
description: row.description,
|
|
250
299
|
eventTypes: row.eventTypes as string[],
|
|
251
300
|
secretPrefix: row.secretPrefix,
|
|
301
|
+
kind: row.kind,
|
|
302
|
+
config: redactConfig(row.config),
|
|
252
303
|
status: (row.disabled ? "disabled" : "enabled") as "enabled" | "disabled",
|
|
253
304
|
organizationId: row.organizationId,
|
|
254
305
|
lastDeliveryAt: row.lastDeliveryAt?.toISOString() ?? null,
|
|
@@ -292,7 +343,13 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
|
|
|
292
343
|
const { db } = c.get("container");
|
|
293
344
|
const body = c.req.valid("json");
|
|
294
345
|
|
|
295
|
-
const
|
|
346
|
+
const kind = body.kind ?? "webhook";
|
|
347
|
+
// Only the signed "webhook" kind gets a signing secret; keyed destinations
|
|
348
|
+
// authenticate via `config` (their secret/secretPrefix stay null).
|
|
349
|
+
const credentials =
|
|
350
|
+
kind === "webhook"
|
|
351
|
+
? generateWebhookSecret()
|
|
352
|
+
: { secret: null, secretPrefix: null };
|
|
296
353
|
|
|
297
354
|
const [created] = await db
|
|
298
355
|
.insert(webhookEndpoints)
|
|
@@ -301,15 +358,24 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
|
|
|
301
358
|
eventTypes: body.eventTypes,
|
|
302
359
|
description: body.description ?? null,
|
|
303
360
|
disabled: body.disabled ?? false,
|
|
304
|
-
|
|
305
|
-
|
|
361
|
+
kind,
|
|
362
|
+
config: body.config ?? null,
|
|
363
|
+
secret: credentials.secret,
|
|
364
|
+
secretPrefix: credentials.secretPrefix,
|
|
306
365
|
})
|
|
307
366
|
.returning();
|
|
308
367
|
|
|
309
368
|
if (!created) throw new Error("Failed to create webhook endpoint");
|
|
310
369
|
|
|
311
|
-
// The ONLY list/get-shaped response that also carries the full secret
|
|
312
|
-
|
|
370
|
+
// The ONLY list/get-shaped response that also carries the full secret — and
|
|
371
|
+
// ONLY for kind="webhook" (keyed destinations have no secret to surface).
|
|
372
|
+
if (kind === "webhook" && credentials.secret) {
|
|
373
|
+
return c.json(
|
|
374
|
+
{ ...serializeEndpoint(created), secret: credentials.secret },
|
|
375
|
+
201,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
return c.json(serializeEndpoint(created), 201);
|
|
313
379
|
})
|
|
314
380
|
.openapi(getEndpointRoute, async (c) => {
|
|
315
381
|
const { db } = c.get("container");
|
|
@@ -349,6 +415,19 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
|
|
|
349
415
|
if (body.eventTypes !== undefined) patch.eventTypes = body.eventTypes;
|
|
350
416
|
if (body.description !== undefined) patch.description = body.description;
|
|
351
417
|
if (body.disabled !== undefined) patch.disabled = body.disabled;
|
|
418
|
+
if (body.kind !== undefined) patch.kind = body.kind;
|
|
419
|
+
if (body.config !== undefined) patch.config = body.config;
|
|
420
|
+
|
|
421
|
+
// Foot-gun guard: an endpoint that ends up as a signed "webhook" with no
|
|
422
|
+
// secret would dead-letter every delivery (the webhook transform signs with
|
|
423
|
+
// the live secret). Mint one when switching to (or back to) kind="webhook"
|
|
424
|
+
// without an existing secret. Keyed kinds keep their null secret — harmless.
|
|
425
|
+
const effectiveKind = body.kind ?? existing.kind;
|
|
426
|
+
if (effectiveKind === "webhook" && !existing.secret) {
|
|
427
|
+
const { secret, secretPrefix } = generateWebhookSecret();
|
|
428
|
+
patch.secret = secret;
|
|
429
|
+
patch.secretPrefix = secretPrefix;
|
|
430
|
+
}
|
|
352
431
|
|
|
353
432
|
const [updated] = await db
|
|
354
433
|
.update(webhookEndpoints)
|
|
@@ -418,6 +497,14 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
|
|
|
418
497
|
// endpoint's `eventTypes`. Build a synthetic delivery row directly — it does
|
|
419
498
|
// NOT go through `emitOutbound` (which filters by subscription) — then enqueue
|
|
420
499
|
// the same durable delivery task the live emit path uses.
|
|
500
|
+
//
|
|
501
|
+
// The synthetic envelope routes THROUGH the per-kind delivery adapter (the
|
|
502
|
+
// delivery task resolves it by `endpoint.kind`), so the `data` must carry the
|
|
503
|
+
// fields every adapter needs to build a VALID request. For `kind="webhook"`
|
|
504
|
+
// the adapter only signs the frozen bytes, so the extra fields are harmless;
|
|
505
|
+
// for `kind="posthog"` the capture adapter coalesces `distinct_id` from
|
|
506
|
+
// `userId`/`to`/`userEmail`, so a synthetic recipient (`to`) is required or
|
|
507
|
+
// the test would POST a malformed capture with no distinct_id.
|
|
421
508
|
const webhookId = `msg_${randomUUID()}`;
|
|
422
509
|
const timestamp = new Date();
|
|
423
510
|
const envelope = {
|
|
@@ -428,6 +515,10 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
|
|
|
428
515
|
message: "Hogsend test event",
|
|
429
516
|
endpointId: endpoint.id,
|
|
430
517
|
sentAt: timestamp.toISOString(),
|
|
518
|
+
// Adapter-friendly synthetic identity so a keyed destination (e.g.
|
|
519
|
+
// posthog) builds a valid request from the test envelope.
|
|
520
|
+
userId: `test_${endpoint.id}`,
|
|
521
|
+
to: "test@hogsend.com",
|
|
431
522
|
},
|
|
432
523
|
};
|
|
433
524
|
|