@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.
Files changed (60) hide show
  1. package/package.json +7 -6
  2. package/src/app.ts +36 -1
  3. package/src/buckets/check-membership.ts +34 -15
  4. package/src/container.ts +33 -0
  5. package/src/env.ts +29 -0
  6. package/src/index.ts +47 -1
  7. package/src/journeys/define-journey.ts +26 -2
  8. package/src/journeys/journey-context.ts +5 -1
  9. package/src/lib/boot.ts +1 -1
  10. package/src/lib/bucket-emit.ts +47 -2
  11. package/src/lib/contacts.ts +1105 -18
  12. package/src/lib/email-service-types.ts +8 -0
  13. package/src/lib/ingestion.ts +63 -33
  14. package/src/lib/mailer.ts +88 -0
  15. package/src/lib/outbound.ts +216 -0
  16. package/src/lib/preferences.ts +137 -0
  17. package/src/lib/tracked.ts +204 -37
  18. package/src/lib/tracking-events.ts +67 -2
  19. package/src/lib/webhook-signing.ts +151 -0
  20. package/src/lists/define-list.ts +81 -0
  21. package/src/lists/registry-singleton.ts +39 -0
  22. package/src/lists/registry.ts +95 -0
  23. package/src/middleware/api-key.ts +33 -7
  24. package/src/middleware/rate-limit.ts +73 -49
  25. package/src/routes/_shared.ts +30 -0
  26. package/src/routes/admin/api-keys.ts +1 -1
  27. package/src/routes/admin/bulk.ts +7 -3
  28. package/src/routes/admin/contacts.ts +108 -59
  29. package/src/routes/admin/events.ts +65 -0
  30. package/src/routes/admin/index.ts +2 -0
  31. package/src/routes/admin/journeys.ts +3 -1
  32. package/src/routes/admin/preferences.ts +2 -2
  33. package/src/routes/admin/reporting.ts +3 -3
  34. package/src/routes/admin/timeline.ts +5 -2
  35. package/src/routes/admin/webhooks.ts +466 -0
  36. package/src/routes/campaigns/index.ts +252 -0
  37. package/src/routes/contacts/index.ts +231 -0
  38. package/src/routes/email/preferences.ts +27 -3
  39. package/src/routes/email/unsubscribe.ts +7 -49
  40. package/src/routes/emails/index.ts +133 -0
  41. package/src/routes/events/index.ts +119 -0
  42. package/src/routes/index.ts +52 -2
  43. package/src/routes/lists/index.ts +258 -0
  44. package/src/routes/tracking/click.ts +59 -18
  45. package/src/routes/tracking/open.ts +62 -24
  46. package/src/routes/webhooks/sources.ts +69 -10
  47. package/src/webhook-sources/define-webhook-source.ts +57 -5
  48. package/src/webhook-sources/presets/clerk.ts +185 -0
  49. package/src/webhook-sources/presets/index.ts +80 -0
  50. package/src/webhook-sources/presets/segment.ts +120 -0
  51. package/src/webhook-sources/presets/stripe.ts +147 -0
  52. package/src/webhook-sources/presets/supabase.ts +131 -0
  53. package/src/webhook-sources/verify.ts +172 -0
  54. package/src/worker.ts +12 -0
  55. package/src/workflows/bucket-backfill.ts +32 -21
  56. package/src/workflows/bucket-reconcile.ts +20 -5
  57. package/src/workflows/deliver-webhook.ts +399 -0
  58. package/src/workflows/import-contacts.ts +28 -20
  59. package/src/workflows/send-campaign.ts +589 -0
  60. package/src/routes/ingest.ts +0 -71
@@ -53,6 +53,12 @@ export interface SendTrackedEmailOptions<
53
53
  replyTo?: string | string[];
54
54
  skipPreferenceCheck?: boolean;
55
55
  baseUrl?: string;
56
+ /**
57
+ * Caller-supplied idempotency key (POST /v1/emails). A retry with the same key
58
+ * short-circuits to the prior `email_sends` row instead of dispatching a
59
+ * duplicate provider send.
60
+ */
61
+ idempotencyKey?: string;
56
62
  }
57
63
 
58
64
  export interface TrackedSendResult {
@@ -128,6 +134,8 @@ export interface EmailServiceSendOptions<
128
134
  headers?: Record<string, string>;
129
135
  replyTo?: string | string[];
130
136
  skipPreferenceCheck?: boolean;
137
+ /** Caller-supplied idempotency key (POST /v1/emails) — dedups duplicate sends. */
138
+ idempotencyKey?: string;
131
139
  }
132
140
 
133
141
  export interface EmailServiceWebhookOptions {
@@ -4,15 +4,27 @@ import type { JourneyRegistry } from "@hogsend/core/registry";
4
4
  import { type Database, journeyStates, userEvents } from "@hogsend/db";
5
5
  import { and, eq, inArray, isNull } from "drizzle-orm";
6
6
  import { checkBucketMembership } from "../buckets/check-membership.js";
7
- import { upsertContact } from "./contacts.js";
7
+ import { resolveOrCreateContact } from "./contacts.js";
8
8
  import type { Logger } from "./logger.js";
9
9
 
10
10
  export interface IngestEvent {
11
11
  event: string;
12
- userId: string;
13
- userEmail: string;
14
- properties: Record<string, unknown>;
12
+ /** D1: optional — email-only / anonymous events resolve a key downstream. */
13
+ userId?: string;
14
+ userEmail?: string;
15
+ /** D1: future anonymous→identified path. Threaded into the resolver. */
16
+ anonymousId?: string;
17
+ /** D2: → `user_events` + Hatchet `trigger.where`/`exitOn` ONLY. */
18
+ eventProperties: Record<string, unknown>;
19
+ /** D2: → `contacts.properties` merge ONLY. */
20
+ contactProperties?: Record<string, unknown>;
15
21
  idempotencyKey?: string;
22
+ /**
23
+ * Caller-supplied event time (§2.5 `timestamp`). When set, `user_events`
24
+ * `occurred_at` is stamped from it (backfill/replay) instead of defaulting to
25
+ * the ingest instant. Accepts a `Date` or an ISO-8601 string.
26
+ */
27
+ occurredAt?: Date | string;
16
28
  }
17
29
 
18
30
  export interface ExitResult {
@@ -35,14 +47,36 @@ export async function ingestEvent(opts: {
35
47
  }): Promise<IngestResult> {
36
48
  const { db, registry, hatchet, logger, event } = opts;
37
49
 
50
+ // (1) Resolve identity FIRST (awaited — no longer fire-and-forget). The
51
+ // contact-referencing tables join on a NOT NULL text key, so an email-only /
52
+ // anonymous event (D1 optional userId) needs a canonical key resolved before
53
+ // any insert (risk 2). The resolver applies ONLY contactProperties to
54
+ // `contacts.properties` (D2 split) and returns BOTH the canonical contact id
55
+ // AND its resolved string key (external_id ?? anonymous_id ?? contact.id —
56
+ // risk 1/6), so no second read-back of the contact row is needed.
57
+ const { resolvedKey } = await resolveOrCreateContact({
58
+ db,
59
+ userId: event.userId,
60
+ email: event.userEmail || undefined,
61
+ anonymousId: event.anonymousId,
62
+ contactProperties: event.contactProperties,
63
+ });
64
+
65
+ // Caller-supplied event time (backfill/replay). Coerced to a Date; undefined
66
+ // falls back to the `occurred_at` DB default (ingest instant).
67
+ const occurredAt = event.occurredAt ? new Date(event.occurredAt) : undefined;
68
+
69
+ // (2) Idempotency dedup + `user_events` insert keyed on the resolved key, with
70
+ // ONLY eventProperties in the properties bag (D2).
38
71
  if (event.idempotencyKey) {
39
72
  const result = await db
40
73
  .insert(userEvents)
41
74
  .values({
42
- userId: event.userId,
75
+ userId: resolvedKey,
43
76
  event: event.event,
44
- properties: event.properties,
77
+ properties: event.eventProperties,
45
78
  idempotencyKey: event.idempotencyKey,
79
+ ...(occurredAt ? { occurredAt } : {}),
46
80
  })
47
81
  .onConflictDoNothing({
48
82
  target: userEvents.idempotencyKey,
@@ -54,14 +88,17 @@ export async function ingestEvent(opts: {
54
88
  }
55
89
  } else {
56
90
  await db.insert(userEvents).values({
57
- userId: event.userId,
91
+ userId: resolvedKey,
58
92
  event: event.event,
59
- properties: event.properties,
93
+ properties: event.eventProperties,
94
+ ...(occurredAt ? { occurredAt } : {}),
60
95
  });
61
96
  }
62
97
 
98
+ // (3) Build the JSON-serializable subset of eventProperties for the Hatchet
99
+ // push payload (scalars only — the SDK serializes the envelope).
63
100
  const serializableProperties = Object.fromEntries(
64
- Object.entries(event.properties).filter(
101
+ Object.entries(event.eventProperties).filter(
65
102
  ([, v]) =>
66
103
  typeof v === "string" ||
67
104
  typeof v === "number" ||
@@ -70,57 +107,50 @@ export async function ingestEvent(opts: {
70
107
  ),
71
108
  ) as Record<string, string | number | boolean | null>;
72
109
 
110
+ // (4) Hatchet push + (5) checkExits, both keyed on the resolved key. The push
111
+ // payload wire key STAYS `properties` (bucket tests assert on it — risk 9).
73
112
  const [, exits] = await Promise.all([
74
113
  hatchet.events.push(event.event, {
75
- userId: event.userId,
76
- userEmail: event.userEmail,
114
+ userId: resolvedKey,
115
+ userEmail: event.userEmail ?? "",
77
116
  properties: serializableProperties,
78
117
  }),
79
118
  checkExits(db, registry, hatchet, logger, {
80
- userId: event.userId,
119
+ userId: resolvedKey,
81
120
  eventName: event.event,
82
- properties: event.properties,
83
- }),
84
- upsertContact({
85
- db,
86
- externalId: event.userId,
87
- email: event.userEmail || undefined,
88
- properties: event.properties,
89
- }).catch((err) => {
90
- logger.warn("Contact upsert failed", {
91
- userId: event.userId,
92
- error: err instanceof Error ? err.message : String(err),
93
- });
121
+ properties: event.eventProperties,
94
122
  }),
95
123
  ]);
96
124
 
97
- // Real-time bucket membership re-evaluation (Section 6.1). NOT part of the
98
- // Promise.all above: its property eval reads MERGED contact state, and its
99
- // bucket:entered/left emissions recurse back into ingestEvent (the recursion
100
- // guard in checkBucketMembership bounds them). Best-effort: a bucket failure
101
- // must not fail the ingest of the originating event.
125
+ // (6) Real-time bucket membership re-evaluation (Section 6.1). NOT part of the
126
+ // Promise.all above: its property eval reads contact state this-ingest
127
+ // contactProperties patch, and its bucket:entered/left emissions recurse back
128
+ // into ingestEvent (the recursion guard in checkBucketMembership bounds them).
129
+ // Best-effort: a bucket failure must not fail the ingest of the originating
130
+ // event.
102
131
  try {
103
132
  await checkBucketMembership({
104
133
  db,
105
134
  registry,
106
135
  hatchet,
107
136
  logger,
108
- userId: event.userId,
137
+ userId: resolvedKey,
109
138
  userEmail: event.userEmail || null,
110
139
  event: event.event,
111
- properties: event.properties,
140
+ eventProperties: event.eventProperties,
141
+ contactProperties: event.contactProperties ?? {},
112
142
  });
113
143
  } catch (err) {
114
144
  logger.warn("Bucket membership check failed", {
115
145
  event: event.event,
116
- userId: event.userId,
146
+ userId: resolvedKey,
117
147
  error: err instanceof Error ? err.message : String(err),
118
148
  });
119
149
  }
120
150
 
121
151
  logger.info("Event ingested", {
122
152
  event: event.event,
123
- userId: event.userId,
153
+ userId: resolvedKey,
124
154
  exits: exits.filter((e) => e.exited).length,
125
155
  });
126
156
 
package/src/lib/mailer.ts CHANGED
@@ -24,8 +24,17 @@ import type {
24
24
  SendResult,
25
25
  TrackedSendResult,
26
26
  } from "./email-service-types.js";
27
+ import { hatchet } from "./hatchet.js";
28
+ import { createLogger } from "./logger.js";
29
+ import { emitOutbound } from "./outbound.js";
27
30
  import type { PrepareTrackedHtmlFn } from "./tracked.js";
28
31
  import { sendTrackedEmail } from "./tracked.js";
32
+ import { resolveEmailSendContextByResendId } from "./tracking-events.js";
33
+
34
+ // Fallback logger for the provider-webhook outbound emit — `config.logger` is
35
+ // optional, but `emitOutbound` requires one. Mirrors the engine-lib singleton
36
+ // pattern (define-journey, preferences, tracked).
37
+ const emitLogger = createLogger(process.env.LOG_LEVEL);
29
38
 
30
39
  const WEBHOOK_TO_STATUS_FIELD: Partial<
31
40
  Record<WebhookEventType, keyof typeof emailSends.$inferSelect>
@@ -98,6 +107,7 @@ export function createTrackedMailer(
98
107
  headers: options.headers,
99
108
  replyTo: options.replyTo,
100
109
  skipPreferenceCheck: options.skipPreferenceCheck,
110
+ idempotencyKey: options.idempotencyKey,
101
111
  baseUrl: config.baseUrl,
102
112
  },
103
113
  });
@@ -186,9 +196,23 @@ export function createTrackedMailer(
186
196
  ): Promise<boolean> {
187
197
  switch (event.type) {
188
198
  case "email.sent":
199
+ // `email.sent` is emitted FIRST-PARTY from the tracked mailer's
200
+ // provider-accepted branch (lib/tracked.ts) with the rich payload — the
201
+ // provider-webhook echo only updates the DB status, it does NOT emit.
202
+ await updateEmailStatus(event.type, event.data.email_id);
203
+ break;
189
204
  case "email.delivered":
205
+ await updateEmailStatus(event.type, event.data.email_id);
206
+ // OUTBOUND `email.delivered` — the provider webhook is the SINGLE source
207
+ // for delivered/bounced (these have no first-party signal).
208
+ await emitProviderEmailEvent("email.delivered", event.data.email_id);
209
+ break;
190
210
  case "email.opened":
191
211
  case "email.clicked":
212
+ // First-party pixel/redirect is the SINGLE outbound emitter for
213
+ // open/click (gated on the first-touch null→set UPDATE in the tracking
214
+ // routes — risk 4). The provider-webhook echo is SUPPRESSED here: it only
215
+ // updates the DB status, it does NOT emit outbound (no double-source).
192
216
  await updateEmailStatus(event.type, event.data.email_id);
193
217
  break;
194
218
  case "email.bounced":
@@ -196,6 +220,11 @@ export function createTrackedMailer(
196
220
  bounceType: event.data.bounce?.type,
197
221
  bounceReason: event.data.bounce?.message,
198
222
  });
223
+ // OUTBOUND `email.bounced` with the bounce detail.
224
+ await emitProviderEmailEvent("email.bounced", event.data.email_id, {
225
+ bounceType: event.data.bounce?.type,
226
+ bounceReason: event.data.bounce?.message,
227
+ });
199
228
  await handleBounce(event.data.to);
200
229
  break;
201
230
  case "email.complained":
@@ -249,6 +278,65 @@ export function createTrackedMailer(
249
278
  .where(eq(emailPreferences.email, email));
250
279
  }
251
280
 
281
+ /**
282
+ * Emit the provider-funnel outbound event (`email.delivered` / `email.bounced`)
283
+ * for a Resend `email_id`. Enriches via {@link resolveEmailSendContextByResendId}
284
+ * (the only handle a provider webhook holds is the Resend id). Fire-and-forget:
285
+ * a missing context (webhook racing the send-row commit) or a transient outbound
286
+ * error is logged and swallowed — never failing the webhook handler. No
287
+ * `dedupeKey`: the provider path is not a Hatchet-retryable producer, and the
288
+ * shared `Webhook-Id` is the subscriber-side dedup for any provider redelivery.
289
+ */
290
+ function emitProviderEmailEvent(
291
+ event: "email.delivered" | "email.bounced",
292
+ resendId: string,
293
+ bounce?: { bounceType?: string; bounceReason?: string },
294
+ ): void {
295
+ if (!db) return;
296
+ const log = config.logger ?? emitLogger;
297
+ const database = db;
298
+ void resolveEmailSendContextByResendId(database, resendId)
299
+ .then((ctx) => {
300
+ if (!ctx) return;
301
+ const base = {
302
+ emailSendId: ctx.emailSendId,
303
+ resendId,
304
+ templateKey: ctx.templateKey,
305
+ userId: ctx.userId,
306
+ to: ctx.to,
307
+ at: new Date().toISOString(),
308
+ };
309
+ if (event === "email.bounced") {
310
+ return emitOutbound({
311
+ db: database,
312
+ hatchet,
313
+ logger: log,
314
+ event: "email.bounced",
315
+ payload: {
316
+ ...base,
317
+ ...(bounce?.bounceType ? { bounceType: bounce.bounceType } : {}),
318
+ ...(bounce?.bounceReason
319
+ ? { bounceReason: bounce.bounceReason }
320
+ : {}),
321
+ },
322
+ });
323
+ }
324
+ return emitOutbound({
325
+ db: database,
326
+ hatchet,
327
+ logger: log,
328
+ event: "email.delivered",
329
+ payload: base,
330
+ });
331
+ })
332
+ .catch((err: unknown) => {
333
+ log.warn(`emitOutbound ${event} failed`, {
334
+ resendId,
335
+ error: err instanceof Error ? err.message : String(err),
336
+ });
337
+ });
338
+ }
339
+
252
340
  async function updateEmailStatus(
253
341
  eventType: WebhookEventType,
254
342
  resendId: string,
@@ -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
+ }
@@ -0,0 +1,137 @@
1
+ import type { Database } from "@hogsend/db";
2
+ import { emailPreferences } from "@hogsend/db";
3
+ import { sql } from "drizzle-orm";
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);
10
+
11
+ /**
12
+ * Single source of truth for an `email_preferences` upsert: the `(user_id, email)`
13
+ * onConflict + the jsonb category-flip. Extracted from the private
14
+ * `upsertPreference` that used to live in `routes/email/unsubscribe.ts` (decision
15
+ * #9) so subscribe/unsubscribe routes, the preference center, list membership, and
16
+ * the unsubscribe-token flow all share ONE write.
17
+ *
18
+ * `externalId` is the `user_id` column value: the contact's `external_id` when it
19
+ * has one, else the contact `id` (uuid) fallback for an email-only contact (risk
20
+ * 10). `email` is REQUIRED — both columns are NOT NULL and form the PK.
21
+ */
22
+ export async function upsertEmailPreference(opts: {
23
+ db: Database;
24
+ externalId: string;
25
+ email: string;
26
+ update: {
27
+ unsubscribedAll?: boolean;
28
+ suppressed?: boolean;
29
+ categoryKey?: string;
30
+ categoryValue?: boolean;
31
+ };
32
+ }): Promise<void> {
33
+ const { db, externalId, email, update } = opts;
34
+
35
+ const setClause: Record<string, unknown> = { updatedAt: new Date() };
36
+
37
+ if (update.unsubscribedAll !== undefined) {
38
+ setClause.unsubscribedAll = update.unsubscribedAll;
39
+ }
40
+ if (update.suppressed !== undefined) {
41
+ setClause.suppressed = update.suppressed;
42
+ }
43
+ if (update.categoryKey !== undefined) {
44
+ const jsonValue = update.categoryValue ? "true" : "false";
45
+ setClause.categories = sql`jsonb_set(COALESCE(${emailPreferences.categories}, '{}'::jsonb), ${`{${update.categoryKey}}`}, ${jsonValue}::jsonb)`;
46
+ }
47
+
48
+ await db
49
+ .insert(emailPreferences)
50
+ .values({
51
+ userId: externalId,
52
+ email,
53
+ ...(update.unsubscribedAll !== undefined
54
+ ? { unsubscribedAll: update.unsubscribedAll }
55
+ : {}),
56
+ ...(update.suppressed !== undefined
57
+ ? { suppressed: update.suppressed }
58
+ : {}),
59
+ ...(update.categoryKey !== undefined
60
+ ? {
61
+ categories: { [update.categoryKey]: update.categoryValue ?? false },
62
+ }
63
+ : {}),
64
+ })
65
+ .onConflictDoUpdate({
66
+ target: [emailPreferences.userId, emailPreferences.email],
67
+ set: setClause,
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
+ }
95
+ }
96
+
97
+ /**
98
+ * D3 list-membership write. Resolves the caller's identity to the deterministic
99
+ * `(externalId | contactId fallback, email)` pair via `resolveRecipient`, then
100
+ * writes one category flip per list key through `upsertEmailPreference`.
101
+ *
102
+ * Requires a resolvable email — `email_preferences.email` is NOT NULL and the
103
+ * preference center / unsubscribe-token flow key on it (risk 10). The caller is
104
+ * expected to have already run `resolveOrCreateContact` (so the contact exists);
105
+ * this reads identity back. Throws if no email can be resolved — the route maps
106
+ * that to a 400 ("Contact has no email; cannot manage list membership").
107
+ */
108
+ export async function applyListMembership(opts: {
109
+ db: Database;
110
+ userId?: string;
111
+ email?: string;
112
+ lists: Record<string, boolean>;
113
+ }): Promise<void> {
114
+ const { db, userId, email, lists } = opts;
115
+
116
+ const entries = Object.entries(lists);
117
+ if (entries.length === 0) return;
118
+
119
+ const recipient = await resolveRecipient({ db, userId, email });
120
+ if (!recipient) {
121
+ throw new Error("Contact has no email; cannot manage list membership");
122
+ }
123
+
124
+ // `user_id` column = external_id when present, else the contact id (uuid)
125
+ // fallback — the SAME deterministic key used by subscribe writes,
126
+ // preference-center reads, and unsubscribe-token issuance (risk 10).
127
+ const externalId = recipient.externalId ?? recipient.contactId;
128
+
129
+ for (const [categoryKey, categoryValue] of entries) {
130
+ await upsertEmailPreference({
131
+ db,
132
+ externalId,
133
+ email: recipient.email,
134
+ update: { categoryKey, categoryValue },
135
+ });
136
+ }
137
+ }