@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
@@ -0,0 +1,399 @@
1
+ import {
2
+ deadLetterQueue,
3
+ webhookDeliveries,
4
+ webhookEndpoints,
5
+ } from "@hogsend/db";
6
+ import { and, eq, lt, or, sql } from "drizzle-orm";
7
+ import { getDb } from "../lib/db.js";
8
+ import { hatchet } from "../lib/hatchet.js";
9
+ import { createLogger } from "../lib/logger.js";
10
+ import { signWebhook } from "../lib/webhook-signing.js";
11
+
12
+ /**
13
+ * Outbound webhook delivery — the durable per-(event × endpoint) POST attempt
14
+ * plus the reaper cron that schedules retries and recovers orphaned `sending`
15
+ * rows.
16
+ *
17
+ * Delivery model (Section 1.5, LOCKED decision 5/6): one `webhook_deliveries`
18
+ * row + one `runNoWait` per endpoint (independent retry/backoff/dead-letter),
19
+ * with a 1-minute reaper cron as the retry scheduler AND the orphan-`sending`
20
+ * recovery — mirroring `reapStuckCampaignsTask`. Hatchet's own retry is OFF
21
+ * (`retries: 0`); `nextRetryAt` is the single retry clock.
22
+ *
23
+ * The task signs from the FROZEN `payload` envelope on the row + the LIVE
24
+ * endpoint secret read at delivery time, so a rotate-secret invalidates
25
+ * in-flight deliveries to a compromised secret (acceptable under at-least-once).
26
+ * The `body` that `signWebhook` produces is the EXACT bytes that are POSTed —
27
+ * the payload is never re-serialized between sign and send (Open Risk 8).
28
+ */
29
+
30
+ /** Statuses that are TERMINAL — a duplicate/late enqueue must not re-deliver. */
31
+ const TERMINAL_STATUSES = ["delivered", "failed", "discarded"] as const;
32
+
33
+ /** Max delivery attempts before the row is dead-lettered (env-tunable). */
34
+ const MAX_ATTEMPTS = Number(process.env.OUTBOUND_WEBHOOK_MAX_ATTEMPTS ?? 8);
35
+ /** Per-attempt POST timeout (AbortController), ms. */
36
+ const TIMEOUT_MS = Number(process.env.OUTBOUND_WEBHOOK_TIMEOUT_MS ?? 15000);
37
+ /** Exponential backoff base, ms. delay = BASE * 2^attempt + jitter(0..BASE). */
38
+ const BASE_DELAY_MS = Number(
39
+ process.env.OUTBOUND_WEBHOOK_BASE_DELAY_MS ?? 5000,
40
+ );
41
+ /** Backoff ceiling, ms (default 6h). */
42
+ const MAX_DELAY_MS = Number(
43
+ process.env.OUTBOUND_WEBHOOK_MAX_DELAY_MS ?? 6 * 60 * 60 * 1000,
44
+ );
45
+ /** A `sending` row older than this (no live run) is re-driven by the reaper. */
46
+ const STUCK_AFTER_MS = Number(
47
+ process.env.OUTBOUND_WEBHOOK_STUCK_AFTER_MS ?? 5 * 60 * 1000,
48
+ );
49
+
50
+ /** Response-body snippet cap persisted for forensics (≤1KB). */
51
+ const SNIPPET_MAX = 1024;
52
+
53
+ /**
54
+ * Exponential backoff with full jitter, capped at `MAX_DELAY_MS`:
55
+ * min(BASE * 2^attempt + jitter(0..BASE), MAX_DELAY).
56
+ * `attempt` is the (already-incremented) attempt count, so the FIRST retry after
57
+ * one failed attempt waits ~`BASE * 2` (a real backoff, not a near-zero retry).
58
+ */
59
+ function backoffMs(attempt: number): number {
60
+ const exp = BASE_DELAY_MS * 2 ** attempt;
61
+ const jitter = Math.floor(Math.random() * BASE_DELAY_MS);
62
+ return Math.min(exp + jitter, MAX_DELAY_MS);
63
+ }
64
+
65
+ /**
66
+ * Retry classification (mirrors `plugin-resend` `isRetryableStatusCode`, with
67
+ * the `408`/`429` carve-outs from Section 1.5 step 7). Network/timeout failures
68
+ * (no HTTP status) are retryable and handled by the caller (status === null).
69
+ *
70
+ * A persistent 4xx (e.g. `410 Gone`, `400 Bad Request`) is NOT retryable — a
71
+ * misconfigured/decommissioned endpoint should fast-fail, not burn 8 attempts.
72
+ * `408 Request Timeout` and `429 Too Many Requests` are the retryable 4xx
73
+ * exceptions; everything `>= 500` is retryable.
74
+ */
75
+ function isRetryableStatus(status: number): boolean {
76
+ if (status === 408 || status === 429) return true;
77
+ if (status >= 500) return true;
78
+ return false;
79
+ }
80
+
81
+ /**
82
+ * One durable delivery attempt for a single `webhook_deliveries` row.
83
+ *
84
+ * `retries: 0` — the reaper (driven off `nextRetryAt`) is the retry scheduler,
85
+ * NOT Hatchet's own backoff (which would double up on the reaper's). The CAS to
86
+ * `sending` (step 3) prevents an overlapping reaper re-drive from double-POSTing
87
+ * the same row.
88
+ */
89
+ export const deliverWebhookTask = hatchet.task({
90
+ name: "deliver-webhook",
91
+ retries: 0,
92
+ executionTimeout: "30s",
93
+ fn: async (input: { deliveryId: string }) => {
94
+ const db = getDb();
95
+ const logger = createLogger(process.env.LOG_LEVEL ?? "info");
96
+
97
+ // (1) Load the delivery row. Absent → nothing to do (a hard delete cascaded
98
+ // it away between enqueue and run).
99
+ const [row] = await db
100
+ .select()
101
+ .from(webhookDeliveries)
102
+ .where(eq(webhookDeliveries.id, input.deliveryId))
103
+ .limit(1);
104
+ if (!row) {
105
+ return { status: "skipped", reason: "not_found" as const };
106
+ }
107
+ // Already terminal — a duplicate/late enqueue (or a reaper re-drive that
108
+ // raced a just-finished run) must not re-deliver.
109
+ if ((TERMINAL_STATUSES as readonly string[]).includes(row.status)) {
110
+ return { status: row.status, skipped: true };
111
+ }
112
+
113
+ // (2) Load the endpoint. Absent (cascade-deleted) OR disabled → `discarded`:
114
+ // an operator action, NOT a delivery error, so it is NOT dead-lettered.
115
+ const [endpoint] = await db
116
+ .select()
117
+ .from(webhookEndpoints)
118
+ .where(eq(webhookEndpoints.id, row.endpointId))
119
+ .limit(1);
120
+ if (!endpoint || endpoint.disabled) {
121
+ await db
122
+ .update(webhookDeliveries)
123
+ .set({
124
+ status: "discarded",
125
+ nextRetryAt: null,
126
+ updatedAt: new Date(),
127
+ })
128
+ .where(eq(webhookDeliveries.id, row.id));
129
+ return {
130
+ status: "discarded" as const,
131
+ reason: endpoint
132
+ ? ("endpoint_disabled" as const)
133
+ : ("endpoint_deleted" as const),
134
+ };
135
+ }
136
+
137
+ // (3) CAS the row to `sending` so an overlapping reaper re-drive cannot
138
+ // double-POST. The status guard (still non-terminal) makes a concurrent
139
+ // claim affect zero rows; the loser of the race bails out here.
140
+ const claimed = await db
141
+ .update(webhookDeliveries)
142
+ .set({
143
+ status: "sending",
144
+ lastAttemptAt: new Date(),
145
+ updatedAt: new Date(),
146
+ })
147
+ .where(
148
+ and(
149
+ eq(webhookDeliveries.id, row.id),
150
+ eq(webhookDeliveries.status, row.status),
151
+ ),
152
+ )
153
+ .returning({ id: webhookDeliveries.id });
154
+ if (claimed.length === 0) {
155
+ return { status: "skipped", reason: "lost_cas" as const };
156
+ }
157
+
158
+ // (4) Sign from the FROZEN row payload + the LIVE endpoint secret. `body` is
159
+ // the EXACT bytes signed AND sent — never re-serialize between sign and send
160
+ // (Open Risk 8).
161
+ const { headers, body } = signWebhook({
162
+ id: row.webhookId,
163
+ timestamp: Math.floor(Date.now() / 1000),
164
+ payload: row.payload,
165
+ secret: endpoint.secret,
166
+ });
167
+
168
+ // (5) POST with an AbortController timeout. A network error / timeout leaves
169
+ // `responseStatus` null (a retryable failure, handled below).
170
+ const controller = new AbortController();
171
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
172
+ let responseStatus: number | null = null;
173
+ let responseBodySnippet: string | null = null;
174
+ let lastError: string | null = null;
175
+ try {
176
+ const res = await fetch(endpoint.url, {
177
+ method: "POST",
178
+ headers,
179
+ body,
180
+ signal: controller.signal,
181
+ });
182
+ responseStatus = res.status;
183
+ const text = await res.text().catch(() => "");
184
+ responseBodySnippet = text ? text.slice(0, SNIPPET_MAX) : null;
185
+ if (responseStatus < 200 || responseStatus >= 300) {
186
+ lastError = `HTTP ${responseStatus}`;
187
+ }
188
+ } catch (err) {
189
+ lastError =
190
+ err instanceof Error
191
+ ? controller.signal.aborted
192
+ ? `Timeout after ${TIMEOUT_MS}ms`
193
+ : err.message
194
+ : String(err);
195
+ } finally {
196
+ clearTimeout(timer);
197
+ }
198
+
199
+ const now = new Date();
200
+
201
+ // (6) 2xx → delivered (TERMINAL). Also bump the endpoint's lastDeliveryAt.
202
+ if (
203
+ responseStatus !== null &&
204
+ responseStatus >= 200 &&
205
+ responseStatus < 300
206
+ ) {
207
+ await db
208
+ .update(webhookDeliveries)
209
+ .set({
210
+ status: "delivered",
211
+ attemptCount: row.attemptCount + 1,
212
+ responseStatus,
213
+ responseBodySnippet,
214
+ deliveredAt: now,
215
+ nextRetryAt: null,
216
+ lastError: null,
217
+ lastAttemptAt: now,
218
+ updatedAt: now,
219
+ })
220
+ .where(eq(webhookDeliveries.id, row.id));
221
+ await db
222
+ .update(webhookEndpoints)
223
+ .set({ lastDeliveryAt: now, updatedAt: now })
224
+ .where(eq(webhookEndpoints.id, endpoint.id));
225
+ logger.info("deliver-webhook: delivered", {
226
+ deliveryId: row.id,
227
+ endpointId: endpoint.id,
228
+ eventType: row.eventType,
229
+ responseStatus,
230
+ });
231
+ return { status: "delivered" as const, responseStatus };
232
+ }
233
+
234
+ const attemptCount = row.attemptCount + 1;
235
+
236
+ // (7) Persistent-4xx fast-fail: a non-retryable client error (anything 4xx
237
+ // except 408/429) is permanent after attempt >= 2 — a `410 Gone` must not
238
+ // burn 8 attempts. The `>= 2` guard tolerates a single transient 4xx blip
239
+ // before declaring the endpoint mis-configured.
240
+ const httpFastFail =
241
+ responseStatus !== null &&
242
+ !isRetryableStatus(responseStatus) &&
243
+ attemptCount >= 2;
244
+
245
+ // (8) Retryable failure with attempts remaining → back to `pending` with the
246
+ // next backoff deadline; the reaper re-drives it once `nextRetryAt` passes.
247
+ if (!httpFastFail && attemptCount < MAX_ATTEMPTS) {
248
+ const nextRetryAt = new Date(now.getTime() + backoffMs(attemptCount));
249
+ await db
250
+ .update(webhookDeliveries)
251
+ .set({
252
+ status: "pending",
253
+ attemptCount,
254
+ responseStatus,
255
+ responseBodySnippet,
256
+ nextRetryAt,
257
+ lastError,
258
+ lastAttemptAt: now,
259
+ updatedAt: now,
260
+ })
261
+ .where(eq(webhookDeliveries.id, row.id));
262
+ logger.warn("deliver-webhook: retry scheduled", {
263
+ deliveryId: row.id,
264
+ endpointId: endpoint.id,
265
+ attemptCount,
266
+ responseStatus,
267
+ nextRetryAt: nextRetryAt.toISOString(),
268
+ error: lastError,
269
+ });
270
+ return {
271
+ status: "pending" as const,
272
+ attemptCount,
273
+ nextRetryAt: nextRetryAt.toISOString(),
274
+ };
275
+ }
276
+
277
+ // (9) Exhausted (attempts >= MAX) OR a persistent-4xx fast-fail → `failed`
278
+ // (TERMINAL) + a forensic `dead_letter_queue` mirror, in one transaction so
279
+ // the terminal status and the DLQ row commit together. This is the DLQ's
280
+ // first real producer (LOCKED decision 8).
281
+ const exhaustError = `Exhausted ${attemptCount}: ${lastError ?? "unknown"}`;
282
+ await db.transaction(async (tx) => {
283
+ await tx
284
+ .update(webhookDeliveries)
285
+ .set({
286
+ status: "failed",
287
+ attemptCount,
288
+ responseStatus,
289
+ responseBodySnippet,
290
+ nextRetryAt: null,
291
+ lastError,
292
+ lastAttemptAt: now,
293
+ updatedAt: now,
294
+ })
295
+ .where(eq(webhookDeliveries.id, row.id));
296
+ await tx.insert(deadLetterQueue).values({
297
+ source: "webhook-delivery",
298
+ sourceId: row.id,
299
+ payload: {
300
+ endpointId: endpoint.id,
301
+ url: endpoint.url,
302
+ eventType: row.eventType,
303
+ webhookId: row.webhookId,
304
+ body: row.payload,
305
+ },
306
+ error: exhaustError,
307
+ retryCount: attemptCount,
308
+ status: "pending",
309
+ });
310
+ });
311
+ logger.error("deliver-webhook: failed (dead-lettered)", {
312
+ deliveryId: row.id,
313
+ endpointId: endpoint.id,
314
+ eventType: row.eventType,
315
+ attemptCount,
316
+ responseStatus,
317
+ fastFail: httpFastFail,
318
+ error: lastError,
319
+ });
320
+ return { status: "failed" as const, attemptCount, fastFail: httpFastFail };
321
+ },
322
+ });
323
+
324
+ /** Max rows a single reaper sweep re-drives (bounds the per-tick fan-out). */
325
+ const REAPER_BATCH = 500;
326
+
327
+ /**
328
+ * Engine-owned reaper cron for outbound webhook deliveries (Section 1.5, cloned
329
+ * from `reapStuckCampaignsTask`). It is BOTH the retry scheduler AND the
330
+ * orphan-`sending` recovery:
331
+ *
332
+ * - A `pending` row whose `nextRetryAt` has passed (or is null — a freshly
333
+ * enqueued row whose `runNoWait` failed at emit time) is re-driven.
334
+ * - A `sending` row whose worker died mid-POST (OOM/SIGKILL/timeout, so the JS
335
+ * never reached a terminal write) is re-driven once it is older than
336
+ * `STUCK_AFTER_MS` (measured from `updatedAt`, which step 3's CAS bumped).
337
+ *
338
+ * Recovery is `deliverWebhookTask.run({ deliveryId })`; the delivery task's own
339
+ * `sending` CAS guard makes an overlap with a still-live run safe (the loser
340
+ * no-ops). Self-bootstraps `db`/`logger` from `process.env` (cron runs have no
341
+ * request container).
342
+ */
343
+ export const reapDueWebhookDeliveriesTask = hatchet.task({
344
+ name: "reap-due-webhook-deliveries",
345
+ onCrons: [process.env.OUTBOUND_WEBHOOK_REAPER_CRON ?? "*/1 * * * *"],
346
+ retries: 1,
347
+ executionTimeout: "120s",
348
+ fn: async () => {
349
+ const db = getDb();
350
+ const logger = createLogger(process.env.LOG_LEVEL ?? "info");
351
+
352
+ const now = new Date();
353
+ const stuckBefore = new Date(now.getTime() - STUCK_AFTER_MS);
354
+
355
+ // Due-pending (retry clock elapsed or never set) OR stale-sending (orphan).
356
+ const due = await db
357
+ .select({ id: webhookDeliveries.id })
358
+ .from(webhookDeliveries)
359
+ .where(
360
+ or(
361
+ and(
362
+ eq(webhookDeliveries.status, "pending"),
363
+ or(
364
+ sql`${webhookDeliveries.nextRetryAt} is null`,
365
+ lt(webhookDeliveries.nextRetryAt, now),
366
+ ),
367
+ ),
368
+ and(
369
+ eq(webhookDeliveries.status, "sending"),
370
+ lt(webhookDeliveries.updatedAt, stuckBefore),
371
+ ),
372
+ ),
373
+ )
374
+ .orderBy(webhookDeliveries.nextRetryAt)
375
+ .limit(REAPER_BATCH);
376
+
377
+ let reDriven = 0;
378
+ for (const row of due) {
379
+ try {
380
+ await deliverWebhookTask.run({ deliveryId: row.id });
381
+ reDriven += 1;
382
+ } catch (err) {
383
+ logger.warn("reap-due-webhook-deliveries: re-drive failed", {
384
+ deliveryId: row.id,
385
+ error: err instanceof Error ? err.message : String(err),
386
+ });
387
+ }
388
+ }
389
+
390
+ if (due.length > 0) {
391
+ logger.info("reap-due-webhook-deliveries: swept", {
392
+ candidates: due.length,
393
+ reDriven,
394
+ });
395
+ }
396
+
397
+ return { candidates: due.length, reDriven };
398
+ },
399
+ });
@@ -1,11 +1,17 @@
1
1
  import { createDatabase, importJobs } from "@hogsend/db";
2
2
  import { eq } from "drizzle-orm";
3
3
  import Papa from "papaparse";
4
- import { upsertContact } from "../lib/contacts.js";
4
+ import { resolveOrCreateContact } from "../lib/contacts.js";
5
5
  import { hatchet } from "../lib/hatchet.js";
6
6
 
7
7
  const BATCH_SIZE = 500;
8
8
 
9
+ interface ImportRow {
10
+ externalId?: string;
11
+ email?: string;
12
+ properties?: Record<string, unknown>;
13
+ }
14
+
9
15
  export const importContactsTask = hatchet.task({
10
16
  name: "import-contacts",
11
17
  retries: 0,
@@ -20,11 +26,7 @@ export const importContactsTask = hatchet.task({
20
26
  .set({ status: "processing", updatedAt: new Date() })
21
27
  .where(eq(importJobs.id, input.jobId));
22
28
 
23
- let rows: Array<{
24
- externalId: string;
25
- email?: string;
26
- properties?: Record<string, unknown>;
27
- }>;
29
+ let rows: ImportRow[];
28
30
 
29
31
  try {
30
32
  if (input.format === "json") {
@@ -61,14 +63,22 @@ export const importContactsTask = hatchet.task({
61
63
  for (let i = 0; i < rows.length; i += BATCH_SIZE) {
62
64
  const batch = rows.slice(i, i + BATCH_SIZE);
63
65
  const results = await Promise.allSettled(
64
- batch.map((row, idx) =>
65
- upsertContact({
66
+ batch.map((row, idx) => {
67
+ // Accept email-only rows (D1): require AT LEAST one identity key,
68
+ // not externalId specifically. The resolver upserts/merges on
69
+ // whichever keys are present.
70
+ if (!row.externalId && !row.email) {
71
+ return Promise.reject(
72
+ new Error("Row has neither externalId nor email"),
73
+ );
74
+ }
75
+ return resolveOrCreateContact({
66
76
  db,
67
- externalId: row.externalId,
77
+ userId: row.externalId,
68
78
  email: row.email,
69
- properties: row.properties,
70
- }).then(() => ({ index: i + idx, ok: true })),
71
- ),
79
+ contactProperties: row.properties,
80
+ }).then(() => ({ index: i + idx, ok: true }));
81
+ }),
72
82
  );
73
83
 
74
84
  results.forEach((result, batchIdx) => {
@@ -108,25 +118,23 @@ export const importContactsTask = hatchet.task({
108
118
  },
109
119
  });
110
120
 
111
- function parseCsv(data: string): Array<{
112
- externalId: string;
113
- email?: string;
114
- properties?: Record<string, unknown>;
115
- }> {
121
+ function parseCsv(data: string): ImportRow[] {
116
122
  const result = Papa.parse<Record<string, string>>(data, {
117
123
  header: true,
118
124
  skipEmptyLines: true,
119
125
  });
120
126
 
121
- if (!result.meta.fields?.includes("externalId")) {
122
- throw new Error("CSV must have an externalId column");
127
+ const fields = result.meta.fields ?? [];
128
+ // Accept email-only imports (D1): require AT LEAST one identity column.
129
+ if (!fields.includes("externalId") && !fields.includes("email")) {
130
+ throw new Error("CSV must have an externalId or email column");
123
131
  }
124
132
 
125
133
  return result.data.map((row) => {
126
134
  const { externalId, email, ...rest } = row;
127
135
  const properties = Object.keys(rest).length > 0 ? rest : undefined;
128
136
  return {
129
- externalId: externalId ?? "",
137
+ externalId: externalId || undefined,
130
138
  email: email || undefined,
131
139
  properties: properties as Record<string, unknown> | undefined,
132
140
  };