@hogsend/engine 0.7.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.
@@ -0,0 +1,216 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
3
+ import {
4
+ type Database,
5
+ webhookDeliveries,
6
+ webhookEndpoints,
7
+ } from "@hogsend/db";
8
+ import { and, eq, isNull, sql } from "drizzle-orm";
9
+ import { deliverWebhookTask } from "../workflows/deliver-webhook.js";
10
+ import type { SerializedContact } from "./contacts.js";
11
+ import type { Logger } from "./logger.js";
12
+ import {
13
+ WEBHOOK_EVENT_TYPES,
14
+ type WebhookEventType,
15
+ } from "./webhook-signing.js";
16
+
17
+ export {
18
+ type ResendEmailSendContext,
19
+ resolveEmailSendContextByResendId,
20
+ } from "./tracking-events.js";
21
+
22
+ /**
23
+ * The outbound event catalog re-exported for the emit spine. Identical to the
24
+ * signing lib's {@link WEBHOOK_EVENT_TYPES} — the single source of truth.
25
+ */
26
+ export const OUTBOUND_EVENTS = WEBHOOK_EVENT_TYPES;
27
+ export type OutboundEventName = WebhookEventType;
28
+
29
+ interface EmailEventPayload {
30
+ emailSendId: string;
31
+ resendId: string | null;
32
+ templateKey: string | null;
33
+ userId: string | null;
34
+ to: string;
35
+ at: string;
36
+ }
37
+
38
+ interface BucketEventPayload {
39
+ bucketId: string;
40
+ bucketName: string;
41
+ userId: string;
42
+ userEmail: string | null;
43
+ transition: "entered" | "left";
44
+ entryCount: number;
45
+ source: string;
46
+ }
47
+
48
+ /**
49
+ * The typed per-event payload map. `data` in each delivered envelope is exactly
50
+ * `OutboundPayloads[E]` for the emitted event `E`. Producers (the 12 hook
51
+ * points) construct these; subscribers receive them under `envelope.data`.
52
+ */
53
+ export interface OutboundPayloads {
54
+ "contact.created": SerializedContact;
55
+ "contact.updated": SerializedContact;
56
+ "contact.deleted": {
57
+ id: string;
58
+ externalId: string | null;
59
+ email: string | null;
60
+ };
61
+ "contact.unsubscribed": {
62
+ externalId: string | null;
63
+ email: string | null;
64
+ category: string | null;
65
+ scope: "all" | "category";
66
+ };
67
+ "email.sent": {
68
+ emailSendId: string;
69
+ resendId: string;
70
+ templateKey: string | null;
71
+ to: string;
72
+ userId: string | null;
73
+ category: string | null;
74
+ journeyStateId: string | null;
75
+ subject: string;
76
+ sentAt: string;
77
+ };
78
+ "email.delivered": EmailEventPayload;
79
+ "email.opened": EmailEventPayload;
80
+ "email.clicked": EmailEventPayload & { linkUrl?: string; linkId?: string };
81
+ "email.bounced": EmailEventPayload & {
82
+ bounceType?: string;
83
+ bounceReason?: string;
84
+ };
85
+ "journey.completed": {
86
+ journeyId: string;
87
+ journeyName: string;
88
+ stateId: string;
89
+ userId: string;
90
+ userEmail: string;
91
+ completedAt: string;
92
+ };
93
+ "bucket.entered": BucketEventPayload;
94
+ "bucket.left": BucketEventPayload & { reason?: string };
95
+ }
96
+
97
+ /**
98
+ * The signed envelope shape written to `webhook_deliveries.payload` and sent
99
+ * verbatim to subscribers. `id` is the shared `Webhook-Id`; `timestamp` is the
100
+ * logical-event time (ISO); `data` is the typed per-event payload.
101
+ */
102
+ interface OutboundEnvelope<E extends OutboundEventName> {
103
+ id: string;
104
+ type: E;
105
+ timestamp: string;
106
+ data: OutboundPayloads[E];
107
+ }
108
+
109
+ /**
110
+ * THE fire-and-forget emit spine. It does NOT deliver — it selects the active,
111
+ * subscribed endpoints for `event`, inserts one `webhook_deliveries` row per
112
+ * endpoint (all sharing ONE `webhookId` = the `Webhook-Id` header), and enqueues
113
+ * the durable {@link deliverWebhookTask} per inserted row.
114
+ *
115
+ * Idempotency: when `dedupeKey` is provided, the unique
116
+ * `(endpointId, dedupeKey)` index makes a re-emit (e.g. a Hatchet retry of the
117
+ * producing task) a no-op via `onConflictDoNothing` — no duplicate row, no
118
+ * second enqueue. Events without a `dedupeKey` are never deduped (NULL keys are
119
+ * distinct in Postgres), which is correct for non-retryable emit points.
120
+ *
121
+ * NEVER throws to callers. Internal failures (endpoint select, insert, enqueue)
122
+ * are logged via `logger.warn` and swallowed so a transient outbound error can
123
+ * never fail a contact upsert / email send / journey step. Callers MUST STILL
124
+ * wrap the call as `void emitOutbound(...).catch(logger.warn)` — the `.catch` is
125
+ * defence-in-depth against a programming error that escapes this guard.
126
+ *
127
+ * Single-tenant: only endpoints with `organizationId IS NULL` are selected and
128
+ * the delivery rows are written with `organizationId ?? null`. Multi-tenant
129
+ * scoping is a later non-breaking change.
130
+ */
131
+ export async function emitOutbound<E extends OutboundEventName>(opts: {
132
+ db: Database;
133
+ hatchet: HatchetClient;
134
+ logger: Logger;
135
+ event: E;
136
+ payload: OutboundPayloads[E];
137
+ dedupeKey?: string;
138
+ organizationId?: string | null;
139
+ }): Promise<void> {
140
+ const { db, logger, event, payload, dedupeKey } = opts;
141
+ const organizationId = opts.organizationId ?? null;
142
+
143
+ try {
144
+ const webhookId = `msg_${randomUUID()}`;
145
+ const timestamp = new Date();
146
+
147
+ // (2) Active, subscribed endpoints. `event_types @> '["<event>"]'` matches
148
+ // the jsonb array containing this event. Single-tenant: organizationId IS
149
+ // NULL (NOT a hardcoded tenant — keeps the MT wiring non-breaking).
150
+ const endpoints = await db
151
+ .select({ id: webhookEndpoints.id })
152
+ .from(webhookEndpoints)
153
+ .where(
154
+ and(
155
+ eq(webhookEndpoints.disabled, false),
156
+ isNull(webhookEndpoints.organizationId),
157
+ sql`${webhookEndpoints.eventTypes} @> ${JSON.stringify([event])}::jsonb`,
158
+ ),
159
+ );
160
+
161
+ if (endpoints.length === 0) return;
162
+
163
+ // (3) The frozen envelope — signed + sent verbatim by the delivery task.
164
+ const envelope: OutboundEnvelope<E> = {
165
+ id: webhookId,
166
+ type: event,
167
+ timestamp: timestamp.toISOString(),
168
+ data: payload,
169
+ };
170
+
171
+ // (4) One delivery row per endpoint, sharing the webhookId. onConflictDoNothing
172
+ // on (endpointId, dedupeKey) is the producer-side fan-out idempotency guard.
173
+ const inserted = await db
174
+ .insert(webhookDeliveries)
175
+ .values(
176
+ endpoints.map((endpoint) => ({
177
+ endpointId: endpoint.id,
178
+ organizationId,
179
+ webhookId,
180
+ eventType: event,
181
+ dedupeKey: dedupeKey ?? null,
182
+ payload: envelope as unknown as Record<string, unknown>,
183
+ status: "pending" as const,
184
+ attemptCount: 0,
185
+ nextRetryAt: timestamp,
186
+ })),
187
+ )
188
+ .onConflictDoNothing({
189
+ target: [webhookDeliveries.endpointId, webhookDeliveries.dedupeKey],
190
+ })
191
+ .returning({ id: webhookDeliveries.id });
192
+
193
+ // (5) Enqueue the durable delivery task per freshly-inserted row,
194
+ // fire-and-forget. A failed enqueue is recovered by the reaper (the row is
195
+ // already `pending` with `nextRetryAt <= now`), so a broker hiccup here only
196
+ // delays — never drops — a delivery.
197
+ for (const row of inserted) {
198
+ void deliverWebhookTask
199
+ .runNoWait({ deliveryId: row.id })
200
+ .catch((error: unknown) => {
201
+ logger.warn("emitOutbound: deliverWebhookTask enqueue failed", {
202
+ deliveryId: row.id,
203
+ event,
204
+ error: error instanceof Error ? error.message : String(error),
205
+ });
206
+ });
207
+ }
208
+ } catch (error) {
209
+ // FAIL-SAFE: never propagate an outbound error onto the producer's hot path.
210
+ logger.warn("emitOutbound failed", {
211
+ event,
212
+ dedupeKey,
213
+ error: error instanceof Error ? error.message : String(error),
214
+ });
215
+ }
216
+ }
@@ -2,6 +2,11 @@ import type { Database } from "@hogsend/db";
2
2
  import { emailPreferences } from "@hogsend/db";
3
3
  import { sql } from "drizzle-orm";
4
4
  import { resolveRecipient } from "./contacts.js";
5
+ import { hatchet } from "./hatchet.js";
6
+ import { createLogger } from "./logger.js";
7
+ import { emitOutbound } from "./outbound.js";
8
+
9
+ const logger = createLogger(process.env.LOG_LEVEL);
5
10
 
6
11
  /**
7
12
  * Single source of truth for an `email_preferences` upsert: the `(user_id, email)`
@@ -61,6 +66,32 @@ export async function upsertEmailPreference(opts: {
61
66
  target: [emailPreferences.userId, emailPreferences.email],
62
67
  set: setClause,
63
68
  });
69
+
70
+ // OUTBOUND `contact.unsubscribed` — this is the SINGLE choke for ALL preference
71
+ // writes (token unsub, preference center, list-membership flips), so the emit
72
+ // lives here once. GATED to a genuine opt-OUT only: a full unsubscribe
73
+ // (`unsubscribedAll === true`) or a category flip to false. A resubscribe
74
+ // (`unsubscribedAll === false` / `categoryValue === true`) does NOT emit. Uses
75
+ // the engine `hatchet`/`logger` singletons (this lib has no request container);
76
+ // fire-and-forget so a transient outbound error never fails the pref write.
77
+ const isUnsubscribe =
78
+ update.unsubscribedAll === true || update.categoryValue === false;
79
+ if (isUnsubscribe) {
80
+ const scope: "all" | "category" =
81
+ update.unsubscribedAll === true ? "all" : "category";
82
+ void emitOutbound({
83
+ db,
84
+ hatchet,
85
+ logger,
86
+ event: "contact.unsubscribed",
87
+ payload: {
88
+ externalId,
89
+ email,
90
+ category: update.categoryKey ?? null,
91
+ scope,
92
+ },
93
+ }).catch(logger.warn);
94
+ }
64
95
  }
65
96
 
66
97
  /**
@@ -20,7 +20,15 @@ import type {
20
20
  TrackedSendResult,
21
21
  } from "./email-service-types.js";
22
22
  import { isFrequencyCapped } from "./frequency-cap.js";
23
- import type { Logger } from "./logger.js";
23
+ import { hatchet } from "./hatchet.js";
24
+ import { createLogger, type Logger } from "./logger.js";
25
+ import { emitOutbound } from "./outbound.js";
26
+
27
+ // Module-level fallback logger for the outbound emit — the tracked-mailer's
28
+ // `logger` dep is optional, but `emitOutbound` requires one. Mirrors the
29
+ // `createLogger(process.env.LOG_LEVEL)` singleton pattern used elsewhere in the
30
+ // engine libs (define-journey, preferences).
31
+ const emitLogger = createLogger(process.env.LOG_LEVEL);
24
32
 
25
33
  export type PrepareTrackedHtmlFn = (opts: {
26
34
  html: string;
@@ -270,16 +278,50 @@ export async function sendTrackedEmail<K extends TemplateName>(
270
278
  replyTo: options.replyTo,
271
279
  });
272
280
 
281
+ const sentAt = new Date();
273
282
  await db
274
283
  .update(emailSends)
275
284
  .set({
276
285
  resendId: result.id,
277
286
  status: "sent",
278
- sentAt: new Date(),
279
- updatedAt: new Date(),
287
+ sentAt,
288
+ updatedAt: sentAt,
280
289
  })
281
290
  .where(eq(emailSends.id, emailSendId));
282
291
 
292
+ // OUTBOUND `email.sent` — fired ONLY on a real provider-accepted send (this
293
+ // success branch). Suppressed/frequency-capped/failed branches and the
294
+ // `db === undefined` mailer fallback do NOT reach here, so they never emit.
295
+ // `dedupeKey` = `email.sent:<emailSendId>`: this runs inside the tracked
296
+ // mailer which a journey (a Hatchet-retryable durable task) invokes, so a
297
+ // re-execution recomputes the identical key and the unique
298
+ // `(endpointId, dedupeKey)` index absorbs the duplicate. STRICTLY
299
+ // fire-and-forget: an un-caught reject here would bubble into the catch below
300
+ // and wrongly re-mark the (already sent) row `failed` (risk 2).
301
+ void emitOutbound({
302
+ db,
303
+ hatchet,
304
+ logger: logger ?? emitLogger,
305
+ event: "email.sent",
306
+ dedupeKey: `email.sent:${emailSendId}`,
307
+ payload: {
308
+ emailSendId,
309
+ resendId: result.id,
310
+ templateKey: options.templateKey,
311
+ to: options.to,
312
+ userId: options.userId ?? null,
313
+ category: effectiveCategory ?? null,
314
+ journeyStateId: options.journeyStateId ?? null,
315
+ subject,
316
+ sentAt: sentAt.toISOString(),
317
+ },
318
+ }).catch((err: unknown) => {
319
+ (logger ?? emitLogger).warn("emitOutbound email.sent failed", {
320
+ emailSendId,
321
+ error: err instanceof Error ? err.message : String(err),
322
+ });
323
+ });
324
+
283
325
  return {
284
326
  emailSendId,
285
327
  resendId: result.id,
@@ -10,6 +10,8 @@ interface EmailSendContext {
10
10
  userId: string;
11
11
  userEmail: string;
12
12
  templateKey: string | null;
13
+ resendId: string | null;
14
+ to: string;
13
15
  }
14
16
 
15
17
  export async function resolveEmailSendContext(
@@ -20,6 +22,7 @@ export async function resolveEmailSendContext(
20
22
  .select({
21
23
  toEmail: emailSends.toEmail,
22
24
  templateKey: emailSends.templateKey,
25
+ resendId: emailSends.resendId,
23
26
  userId: journeyStates.userId,
24
27
  userEmail: journeyStates.userEmail,
25
28
  })
@@ -35,6 +38,58 @@ export async function resolveEmailSendContext(
35
38
  userId: row.userId ?? row.toEmail,
36
39
  userEmail: row.userEmail ?? row.toEmail,
37
40
  templateKey: row.templateKey,
41
+ resendId: row.resendId,
42
+ to: row.toEmail,
43
+ };
44
+ }
45
+
46
+ export interface ResendEmailSendContext {
47
+ emailSendId: string;
48
+ userId: string;
49
+ userEmail: string;
50
+ templateKey: string | null;
51
+ to: string;
52
+ }
53
+
54
+ /**
55
+ * Mirror of {@link resolveEmailSendContext} that resolves by the provider's
56
+ * `resendId` instead of the internal `email_sends.id`. Used by the
57
+ * provider-webhook enrichment path (`email.delivered`/`email.bounced`) where the
58
+ * only handle we hold is the Resend `email_id`.
59
+ *
60
+ * Returns the internal `emailSendId` plus the same denormalized identity
61
+ * (`userId`/`userEmail` fall back to the recipient address, exactly like the
62
+ * id-keyed resolver) and `to` recipient. Returns null when no send row carries
63
+ * that `resendId` yet (e.g. a webhook arriving before the send row is committed).
64
+ */
65
+ export async function resolveEmailSendContextByResendId(
66
+ db: Database,
67
+ resendId: string,
68
+ ): Promise<ResendEmailSendContext | null> {
69
+ const rows = await db
70
+ .select({
71
+ emailSendId: emailSends.id,
72
+ toEmail: emailSends.toEmail,
73
+ templateKey: emailSends.templateKey,
74
+ userId: journeyStates.userId,
75
+ userEmail: journeyStates.userEmail,
76
+ sendUserId: emailSends.userId,
77
+ sendUserEmail: emailSends.userEmail,
78
+ })
79
+ .from(emailSends)
80
+ .leftJoin(journeyStates, eq(emailSends.journeyStateId, journeyStates.id))
81
+ .where(eq(emailSends.resendId, resendId))
82
+ .limit(1);
83
+
84
+ const row = rows[0];
85
+ if (!row) return null;
86
+
87
+ return {
88
+ emailSendId: row.emailSendId,
89
+ userId: row.userId ?? row.sendUserId ?? row.toEmail,
90
+ userEmail: row.userEmail ?? row.sendUserEmail ?? row.toEmail,
91
+ templateKey: row.templateKey,
92
+ to: row.toEmail,
38
93
  };
39
94
  }
40
95
 
@@ -47,6 +102,13 @@ export interface PushTrackingEventOpts {
47
102
  event: string;
48
103
  emailSendId: string;
49
104
  properties?: Record<string, unknown>;
105
+ /**
106
+ * Pre-resolved send context. When provided (including `null`), the duplicate
107
+ * `resolveEmailSendContext` read is skipped — the tracking routes resolve once
108
+ * and share the result with the outbound emit on the hot path. Omit to resolve
109
+ * lazily.
110
+ */
111
+ resolvedContext?: EmailSendContext | null;
50
112
  }
51
113
 
52
114
  export async function pushTrackingEvent(
@@ -54,7 +116,10 @@ export async function pushTrackingEvent(
54
116
  ): Promise<void> {
55
117
  const { db, hatchet, registry, logger, posthog, event, emailSendId } = opts;
56
118
 
57
- const ctx = await resolveEmailSendContext(db, emailSendId);
119
+ const ctx =
120
+ opts.resolvedContext !== undefined
121
+ ? opts.resolvedContext
122
+ : await resolveEmailSendContext(db, emailSendId);
58
123
  if (!ctx) return;
59
124
 
60
125
  const properties: Record<string, unknown> = {
@@ -0,0 +1,151 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { Webhook } from "svix";
3
+
4
+ /**
5
+ * Outbound webhook signing core.
6
+ *
7
+ * Hogsend emits a Svix-style HMAC-SHA256 signed event stream. The signing
8
+ * scheme is the Standard Webhooks spec (the same one `svix` implements and that
9
+ * `plugin-resend` consumes for inbound Resend webhooks):
10
+ *
11
+ * signedContent = `${id}.${timestampSeconds}.${body}`
12
+ * signature = base64( HMAC_SHA256( base64decode(secret without `whsec_`), signedContent ) )
13
+ * header value = `v1,${signature}`
14
+ *
15
+ * Pure `node:crypto` equivalent (documented for the SDK / spec consumers and
16
+ * subscriber-side verification without a `svix` dependency):
17
+ *
18
+ * import { createHmac, timingSafeEqual } from "node:crypto";
19
+ * const key = Buffer.from(secret.slice(6), "base64"); // drop the `whsec_` prefix
20
+ * const sig = createHmac("sha256", key)
21
+ * .update(`${id}.${ts}.${body}`)
22
+ * .digest("base64");
23
+ * const header = `v1,${sig}`;
24
+ * // compare each space-delimited `v1,<sig>` candidate with timingSafeEqual.
25
+ */
26
+
27
+ /**
28
+ * The 12-event catalog — the SINGLE source of truth (schema, routes, client,
29
+ * CLI all derive from this). The `webhook.test` sentinel is intentionally NOT a
30
+ * member (it is delivered out-of-band regardless of an endpoint's `eventTypes`).
31
+ */
32
+ export const WEBHOOK_EVENT_TYPES = [
33
+ "contact.created",
34
+ "contact.updated",
35
+ "contact.deleted",
36
+ "contact.unsubscribed",
37
+ "email.sent",
38
+ "email.delivered",
39
+ "email.opened",
40
+ "email.clicked",
41
+ "email.bounced",
42
+ "journey.completed",
43
+ "bucket.entered",
44
+ "bucket.left",
45
+ ] as const;
46
+
47
+ export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number];
48
+
49
+ /**
50
+ * Generate a new `whsec_<base64(32 bytes)>` signing secret plus its display
51
+ * prefix (safe to surface on list/get responses).
52
+ *
53
+ * NOTE: the secret body is STANDARD base64, not base64url. svix (via
54
+ * standardwebhooks → @stablelib/base64) strips the `whsec_` prefix and decodes
55
+ * the remainder with a STRICT standard-base64 decoder that rejects the `-`/`_`
56
+ * characters base64url emits (~74% of base64url secrets contain one and fail
57
+ * `new Webhook(secret)`). Standard base64 round-trips cleanly through both svix
58
+ * and the `node:crypto` fallback (`Buffer.from(secret.slice(6), "base64")`).
59
+ */
60
+ export function generateWebhookSecret(): {
61
+ secret: string;
62
+ secretPrefix: string;
63
+ } {
64
+ const secret = `whsec_${randomBytes(32).toString("base64")}`;
65
+ return { secret, secretPrefix: secret.slice(0, 12) };
66
+ }
67
+
68
+ export interface SignedWebhook {
69
+ headers: {
70
+ "Webhook-Id": string;
71
+ "Webhook-Timestamp": string;
72
+ "Webhook-Signature": string;
73
+ "Content-Type": "application/json";
74
+ };
75
+ /**
76
+ * The EXACT bytes that were signed AND must be sent. Never re-stringify the
77
+ * payload between signing and sending — the signature covers these bytes.
78
+ */
79
+ body: string;
80
+ }
81
+
82
+ /**
83
+ * Sign an outbound webhook payload, producing the Standard Webhooks header set
84
+ * (`Webhook-Id` / `Webhook-Timestamp` / `Webhook-Signature`) plus the exact
85
+ * `body` bytes that were signed.
86
+ *
87
+ * `timestamp` is unix SECONDS — the caller passes `Math.floor(Date.now()/1000)`.
88
+ * `payload` is JSON-stringified when an object; a string is used verbatim.
89
+ */
90
+ export function signWebhook(opts: {
91
+ id: string;
92
+ timestamp: number;
93
+ payload: unknown;
94
+ secret: string;
95
+ }): SignedWebhook {
96
+ const body =
97
+ typeof opts.payload === "string"
98
+ ? opts.payload
99
+ : JSON.stringify(opts.payload);
100
+
101
+ const wh = new Webhook(opts.secret);
102
+ // svix's `sign` takes the timestamp as a Date and floors it to seconds
103
+ // internally; pass the canonical seconds back through a Date to keep the exact
104
+ // value the caller intended.
105
+ const signature = wh.sign(opts.id, new Date(opts.timestamp * 1000), body);
106
+
107
+ return {
108
+ headers: {
109
+ "Webhook-Id": opts.id,
110
+ "Webhook-Timestamp": String(opts.timestamp),
111
+ "Webhook-Signature": signature,
112
+ "Content-Type": "application/json",
113
+ },
114
+ body,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Consumer/test-facing verification of an inbound Hogsend webhook. Enforces the
120
+ * 5-minute timestamp tolerance and uses a constant-time signature compare (both
121
+ * inside svix). Throws on a bad signature or stale timestamp.
122
+ *
123
+ * Accepts either Title-Case (`Webhook-Id`) or lowercase (`webhook-id`) header
124
+ * keys — and the `svix-*` aliases — by normalizing the header map first.
125
+ */
126
+ export function verifyWebhookSignature(opts: {
127
+ payload: string;
128
+ headers: Record<string, string>;
129
+ secret: string;
130
+ }): unknown {
131
+ const lowered: Record<string, string> = {};
132
+ for (const [key, value] of Object.entries(opts.headers)) {
133
+ lowered[key.toLowerCase()] = value;
134
+ }
135
+
136
+ // Coalesce to "" so a genuinely-absent header reaches svix as an empty
137
+ // string — svix then throws its own clear "Missing required header" rather
138
+ // than a type error here.
139
+ const id = lowered["webhook-id"] ?? lowered["svix-id"] ?? "";
140
+ const timestamp =
141
+ lowered["webhook-timestamp"] ?? lowered["svix-timestamp"] ?? "";
142
+ const signature =
143
+ lowered["webhook-signature"] ?? lowered["svix-signature"] ?? "";
144
+
145
+ const wh = new Webhook(opts.secret);
146
+ return wh.verify(opts.payload, {
147
+ "webhook-id": id,
148
+ "webhook-timestamp": timestamp,
149
+ "webhook-signature": signature,
150
+ });
151
+ }
@@ -9,6 +9,7 @@ import {
9
9
  serializeContact as serializeContactRow,
10
10
  serializePrefs,
11
11
  } from "../../lib/contacts.js";
12
+ import { emitOutbound } from "../../lib/outbound.js";
12
13
 
13
14
  const contactSchema = z.object({
14
15
  id: z.string(),
@@ -262,13 +263,18 @@ export const contactsRouter = new OpenAPIHono<AppEnv>()
262
263
  );
263
264
  })
264
265
  .openapi(createRoute_, async (c) => {
265
- const { db } = c.get("container");
266
+ const { db, hatchet, logger } = c.get("container");
266
267
  const body = c.req.valid("json");
267
268
 
268
269
  // Delegate to the identity resolver (D1): it upserts/merges on the provided
269
270
  // identity keys (externalId and/or email), so the hand-rolled existence
270
271
  // check + raw insert + 409 are gone (§5). Read the row back to serialize.
271
- const { id } = await resolveOrCreateContact({
272
+ const {
273
+ id,
274
+ created: wasCreated,
275
+ linked,
276
+ merged,
277
+ } = await resolveOrCreateContact({
272
278
  db,
273
279
  userId: body.externalId,
274
280
  email: body.email,
@@ -280,10 +286,26 @@ export const contactsRouter = new OpenAPIHono<AppEnv>()
280
286
  throw new Error("Failed to create contact");
281
287
  }
282
288
 
289
+ // INTENT-LAYER outbound emit (decision #3): admin upsert mirrors the public
290
+ // route — `contact.created` on a real creation, `contact.updated` when an
291
+ // existing contact was linked/merged with a non-empty property delta.
292
+ const hadPropertyDelta = Boolean(
293
+ body.properties && Object.keys(body.properties).length > 0,
294
+ );
295
+ if (wasCreated || (linked || merged ? hadPropertyDelta : false)) {
296
+ void emitOutbound({
297
+ db,
298
+ hatchet,
299
+ logger,
300
+ event: wasCreated ? "contact.created" : "contact.updated",
301
+ payload: serializeContactRow(created),
302
+ }).catch(logger.warn);
303
+ }
304
+
283
305
  return c.json({ contact: serializeContact(created) }, 201);
284
306
  })
285
307
  .openapi(updateRoute, async (c) => {
286
- const { db } = c.get("container");
308
+ const { db, hatchet, logger } = c.get("container");
287
309
  const { id } = c.req.valid("param");
288
310
  const body = c.req.valid("json");
289
311
 
@@ -331,6 +353,24 @@ export const contactsRouter = new OpenAPIHono<AppEnv>()
331
353
  throw new Error("Failed to update contact");
332
354
  }
333
355
 
356
+ // INTENT-LAYER outbound emit (decision #3): the admin update is an explicit
357
+ // edit — emit `contact.updated` on a non-empty property delta or a filled
358
+ // email (a newly-attached identity). Fire-and-forget; the serialized updated
359
+ // row is the catalog payload.
360
+ const hadPropertyDelta = Boolean(
361
+ body.properties && Object.keys(body.properties).length > 0,
362
+ );
363
+ const filledEmail = Boolean(body.email && body.email !== current.email);
364
+ if (hadPropertyDelta || filledEmail) {
365
+ void emitOutbound({
366
+ db,
367
+ hatchet,
368
+ logger,
369
+ event: "contact.updated",
370
+ payload: serializeContactRow(updated),
371
+ }).catch(logger.warn);
372
+ }
373
+
334
374
  return c.json({ contact: serializeContact(updated) }, 200);
335
375
  })
336
376
  .openapi(deleteRoute, async (c) => {
@@ -20,6 +20,7 @@ import { reportingRouter } from "./reporting.js";
20
20
  import { suppressionsRouter } from "./suppressions.js";
21
21
  import { templatesRouter } from "./templates.js";
22
22
  import { timelineRouter } from "./timeline.js";
23
+ import { webhooksRouter } from "./webhooks.js";
23
24
 
24
25
  export const adminRouter = new OpenAPIHono<AppEnv>();
25
26
  adminRouter.use("*", requireAdmin);
@@ -39,6 +40,7 @@ adminRouter.route("/reporting", reportingRouter);
39
40
  adminRouter.route("/templates", templatesRouter);
40
41
  adminRouter.route("/suppressions", suppressionsRouter);
41
42
  adminRouter.route("/api-keys", apiKeysRouter);
43
+ adminRouter.route("/webhooks", webhooksRouter);
42
44
  adminRouter.route("/audit-logs", auditLogsRouter);
43
45
  adminRouter.route("/alerts", alertsRouter);
44
46
  adminRouter.route("/dlq", dlqRouter);