@hogsend/engine 0.6.0 → 0.7.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 (43) hide show
  1. package/package.json +6 -6
  2. package/src/buckets/check-membership.ts +34 -15
  3. package/src/container.ts +33 -0
  4. package/src/env.ts +4 -0
  5. package/src/index.ts +13 -0
  6. package/src/journeys/journey-context.ts +5 -1
  7. package/src/lib/boot.ts +1 -1
  8. package/src/lib/bucket-emit.ts +2 -2
  9. package/src/lib/contacts.ts +1083 -18
  10. package/src/lib/email-service-types.ts +8 -0
  11. package/src/lib/ingestion.ts +63 -33
  12. package/src/lib/mailer.ts +1 -0
  13. package/src/lib/preferences.ts +106 -0
  14. package/src/lib/tracked.ts +159 -34
  15. package/src/lib/tracking-events.ts +1 -1
  16. package/src/lists/define-list.ts +81 -0
  17. package/src/lists/registry-singleton.ts +39 -0
  18. package/src/lists/registry.ts +95 -0
  19. package/src/middleware/api-key.ts +33 -7
  20. package/src/middleware/rate-limit.ts +73 -49
  21. package/src/routes/_shared.ts +30 -0
  22. package/src/routes/admin/api-keys.ts +1 -1
  23. package/src/routes/admin/bulk.ts +7 -3
  24. package/src/routes/admin/contacts.ts +66 -57
  25. package/src/routes/admin/events.ts +65 -0
  26. package/src/routes/admin/journeys.ts +3 -1
  27. package/src/routes/admin/preferences.ts +2 -2
  28. package/src/routes/admin/reporting.ts +3 -3
  29. package/src/routes/admin/timeline.ts +5 -2
  30. package/src/routes/campaigns/index.ts +252 -0
  31. package/src/routes/contacts/index.ts +188 -0
  32. package/src/routes/email/preferences.ts +27 -3
  33. package/src/routes/email/unsubscribe.ts +7 -49
  34. package/src/routes/emails/index.ts +133 -0
  35. package/src/routes/events/index.ts +119 -0
  36. package/src/routes/index.ts +52 -2
  37. package/src/routes/lists/index.ts +222 -0
  38. package/src/worker.ts +6 -0
  39. package/src/workflows/bucket-backfill.ts +32 -21
  40. package/src/workflows/bucket-reconcile.ts +20 -5
  41. package/src/workflows/import-contacts.ts +28 -20
  42. package/src/workflows/send-campaign.ts +589 -0
  43. package/src/routes/ingest.ts +0 -71
@@ -1,9 +1,27 @@
1
- import { contacts, type Database, type emailPreferences } from "@hogsend/db";
2
- import { and, eq, ilike, isNull, or, sql } from "drizzle-orm";
1
+ import {
2
+ bucketMemberships,
3
+ contactAliases,
4
+ contacts,
5
+ type Database,
6
+ emailPreferences,
7
+ emailSends,
8
+ journeyStates,
9
+ userEvents,
10
+ } from "@hogsend/db";
11
+ import { and, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
3
12
 
4
13
  const UUID_REGEX =
5
14
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
15
 
16
+ /**
17
+ * The transaction handle drizzle hands to a `db.transaction(cb)` callback. It
18
+ * exposes the same `.select/.insert/.update/.execute/.query` surface as the
19
+ * top-level `Database`, so the merge helpers below accept it interchangeably.
20
+ */
21
+ type Tx = Parameters<Parameters<Database["transaction"]>[0]>[0];
22
+
23
+ type ContactRow = typeof contacts.$inferSelect;
24
+
7
25
  export function contactWhereClause(id: string) {
8
26
  return UUID_REGEX.test(id)
9
27
  ? eq(contacts.id, id)
@@ -20,6 +38,49 @@ export async function resolveContact(opts: { db: Database; id: string }) {
20
38
  return rows[0] ?? null;
21
39
  }
22
40
 
41
+ interface SerializedContact {
42
+ id: string;
43
+ externalId: string | null;
44
+ email: string | null;
45
+ properties: Record<string, unknown>;
46
+ firstSeenAt: string;
47
+ lastSeenAt: string;
48
+ createdAt: string;
49
+ updatedAt: string;
50
+ }
51
+
52
+ /**
53
+ * Serialize a contact row to its JSON shape (timestamps → ISO strings). The
54
+ * PUBLIC `/v1/contacts` Contact shape (§2.5 / `@hogsend/client`) does NOT include
55
+ * `anonymousId`; the admin surface does. `includeAnonymousId` toggles that single
56
+ * field (and the return type) so both routes share one serializer without
57
+ * diverging the public type.
58
+ */
59
+ export function serializeContact(
60
+ row: ContactRow,
61
+ opts: { includeAnonymousId: true },
62
+ ): SerializedContact & { anonymousId: string | null };
63
+ export function serializeContact(
64
+ row: ContactRow,
65
+ opts?: { includeAnonymousId?: false },
66
+ ): SerializedContact;
67
+ export function serializeContact(
68
+ row: ContactRow,
69
+ opts?: { includeAnonymousId?: boolean },
70
+ ): SerializedContact & { anonymousId?: string | null } {
71
+ return {
72
+ id: row.id,
73
+ externalId: row.externalId,
74
+ ...(opts?.includeAnonymousId ? { anonymousId: row.anonymousId } : {}),
75
+ email: row.email,
76
+ properties: (row.properties ?? {}) as Record<string, unknown>,
77
+ firstSeenAt: row.firstSeenAt.toISOString(),
78
+ lastSeenAt: row.lastSeenAt.toISOString(),
79
+ createdAt: row.createdAt.toISOString(),
80
+ updatedAt: row.updatedAt.toISOString(),
81
+ };
82
+ }
83
+
23
84
  export function serializePrefs(row: typeof emailPreferences.$inferSelect) {
24
85
  return {
25
86
  id: row.id,
@@ -38,31 +99,1035 @@ export function contactSearchFilter(search: string) {
38
99
  return or(
39
100
  ilike(contacts.email, `%${search}%`),
40
101
  ilike(contacts.externalId, `%${search}%`),
102
+ ilike(contacts.anonymousId, `%${search}%`),
41
103
  );
42
104
  }
43
105
 
44
- export async function upsertContact(opts: {
106
+ /**
107
+ * Normalized, sendable email: `trim` + `toLowerCase`. No dot/+tag stripping —
108
+ * we store the NORMALIZED RAW email (D1), so the address must still deliver.
109
+ */
110
+ export function normalizeEmail(raw: string): string {
111
+ return raw.trim().toLowerCase();
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Identity resolution
116
+ // ---------------------------------------------------------------------------
117
+
118
+ type Kind = "external" | "email" | "anonymous";
119
+
120
+ interface ResolveKey {
121
+ kind: Kind;
122
+ value: string;
123
+ }
124
+
125
+ /**
126
+ * Look up the single live contact owning `(kind, value)`, falling back to
127
+ * `contact_aliases` on a miss so a stale (loser/promoted) key still resolves to
128
+ * the SURVIVOR (risk 5). Returns the contact row or null.
129
+ */
130
+ async function findByKey(tx: Tx, key: ResolveKey): Promise<ContactRow | null> {
131
+ const column =
132
+ key.kind === "external"
133
+ ? contacts.externalId
134
+ : key.kind === "email"
135
+ ? contacts.email
136
+ : contacts.anonymousId;
137
+
138
+ const direct = await tx
139
+ .select()
140
+ .from(contacts)
141
+ .where(and(eq(column, key.value), isNull(contacts.deletedAt)))
142
+ .limit(1);
143
+ if (direct[0]) return direct[0];
144
+
145
+ // Alias fallback: the key may sit on a soft-deleted loser row.
146
+ const alias = await tx
147
+ .select({ contactId: contactAliases.contactId })
148
+ .from(contactAliases)
149
+ .where(
150
+ and(
151
+ eq(contactAliases.aliasKind, key.kind),
152
+ eq(contactAliases.aliasValue, key.value),
153
+ ),
154
+ )
155
+ .limit(1);
156
+ if (!alias[0]) return null;
157
+
158
+ const aliased = await tx
159
+ .select()
160
+ .from(contacts)
161
+ .where(and(eq(contacts.id, alias[0].contactId), isNull(contacts.deletedAt)))
162
+ .limit(1);
163
+ return aliased[0] ?? null;
164
+ }
165
+
166
+ /**
167
+ * Merge `patch` onto the existing jsonb properties (§2.1 contract): additive
168
+ * `COALESCE(existing,'{}') || patch` where the patch wins on key conflict AND an
169
+ * explicit `null` value in the patch CLEARS that key (it is not stored as JSON
170
+ * null). `jsonb_strip_nulls` over the merged result drops every null-valued key
171
+ * — so `{ plan: null }` removes `plan` rather than leaving `"plan": null`.
172
+ *
173
+ * Caveat: `jsonb_strip_nulls` also strips any PRE-EXISTING null-valued keys on
174
+ * the contact, which is the intended "null === unset" model (the condition
175
+ * engine already treats JSON null and absent identically).
176
+ */
177
+ function mergePropertiesSql(patch: Record<string, unknown>) {
178
+ return sql`jsonb_strip_nulls(COALESCE(${contacts.properties}, '{}'::jsonb) || ${JSON.stringify(patch)}::jsonb)`;
179
+ }
180
+
181
+ /**
182
+ * The JS analogue of {@link mergePropertiesSql} for the in-memory merge-fold:
183
+ * spread-merge then drop null-valued keys so explicit null clears a key (§2.1).
184
+ */
185
+ function stripNulls(props: Record<string, unknown>): Record<string, unknown> {
186
+ const out: Record<string, unknown> = {};
187
+ for (const [k, v] of Object.entries(props)) {
188
+ if (v !== null) out[k] = v;
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /** SURVIVOR RULE: identified (has external_id) > anonymous; then OLDEST
194
+ * firstSeenAt; final tie-break lowest id. */
195
+ function pickSurvivor(rows: ContactRow[]): {
196
+ survivor: ContactRow;
197
+ losers: ContactRow[];
198
+ } {
199
+ const sorted = [...rows].sort((a, b) => {
200
+ const aIdent = a.externalId ? 0 : 1;
201
+ const bIdent = b.externalId ? 0 : 1;
202
+ if (aIdent !== bIdent) return aIdent - bIdent;
203
+ const aSeen = a.firstSeenAt.getTime();
204
+ const bSeen = b.firstSeenAt.getTime();
205
+ if (aSeen !== bSeen) return aSeen - bSeen;
206
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
207
+ });
208
+ const [survivor, ...losers] = sorted;
209
+ if (!survivor) {
210
+ // Unreachable: callers only invoke this with >= 2 candidates.
211
+ throw new Error("pickSurvivor called with no candidates");
212
+ }
213
+ return { survivor, losers };
214
+ }
215
+
216
+ /** The canonical text user_id key for a contact: external_id ?? anonymous_id ??
217
+ * id. This is what the 5 contact-referencing tables join on (risk 1). */
218
+ export function contactKey(row: ContactRow): string {
219
+ return row.externalId ?? row.anonymousId ?? row.id;
220
+ }
221
+
222
+ /**
223
+ * The SQL analogue of {@link contactKey}: the canonical text user_id key as a
224
+ * `coalesce(external_id, anonymous_id, id::text)` fragment. The `::text` cast on
225
+ * `id` (uuid) is required — `coalesce(text, text, uuid)` is rejected by Postgres
226
+ * (42804). Used by every set-based query that projects/joins on the resolved key
227
+ * (bucket backfill + reconcile) so the cast lives in exactly one place.
228
+ */
229
+ export function contactKeySql() {
230
+ return sql<string>`coalesce(${contacts.externalId}, ${contacts.anonymousId}, ${contacts.id}::text)`;
231
+ }
232
+
233
+ /**
234
+ * THE resolver (D1). Transactional. Resolves any combination of identity keys
235
+ * (external_id / email / anonymous_id, in any subset — incl. anon-only or
236
+ * email-only) to a single canonical `contacts` row, handling three cases:
237
+ *
238
+ * - create — no existing row owns any provided key.
239
+ * - fill-in-link — exactly one row matches; missing keys are filled and a
240
+ * `'promote'` alias is recorded for each newly-attached key.
241
+ * - collide-MERGE — 2-3 distinct rows match; a survivor is chosen (SURVIVOR
242
+ * RULE) and the losers are re-pointed across all 5 tables,
243
+ * folded, soft-deleted, and aliased (9-step order).
244
+ *
245
+ * INSERT RACE strategy: a `pg_advisory_xact_lock(hashtext(kind||value))` is taken
246
+ * per provided key at the TOP of the tx (before any SELECT). Two concurrent
247
+ * resolves for the same key serialize on the lock, so the second sees the first's
248
+ * insert and links/merges instead of racing a duplicate row. The lock is held
249
+ * until the tx commits/rolls back (xact-scoped) — no manual unlock.
250
+ */
251
+ export async function resolveOrCreateContact(opts: {
45
252
  db: Database;
46
- externalId: string;
253
+ userId?: string;
47
254
  email?: string;
48
- properties?: Record<string, unknown>;
49
- }): Promise<void> {
50
- const { db, externalId, email, properties } = opts;
51
-
52
- await db
53
- .insert(contacts)
54
- .values({
55
- externalId,
56
- email: email || null,
57
- properties: properties ?? {},
255
+ anonymousId?: string;
256
+ contactProperties?: Record<string, unknown>;
257
+ }): Promise<{
258
+ id: string;
259
+ /**
260
+ * The contact's canonical text user_id key AFTER this resolve
261
+ * (`external_id ?? anonymous_id ?? id`), i.e. {@link contactKey} of the final
262
+ * row — for a merge, the SURVIVOR's key. Lets callers (ingestEvent) key the
263
+ * history tables without a second read-back of the contact row.
264
+ */
265
+ resolvedKey: string;
266
+ created: boolean;
267
+ linked: boolean;
268
+ merged: boolean;
269
+ }> {
270
+ const { db, contactProperties } = opts;
271
+ const userId = opts.userId?.trim() || undefined;
272
+ const email = opts.email ? normalizeEmail(opts.email) : undefined;
273
+ const anonymousId = opts.anonymousId?.trim() || undefined;
274
+
275
+ const keys: ResolveKey[] = [];
276
+ if (userId) keys.push({ kind: "external", value: userId });
277
+ if (email) keys.push({ kind: "email", value: email });
278
+ if (anonymousId) keys.push({ kind: "anonymous", value: anonymousId });
279
+
280
+ if (keys.length === 0) {
281
+ throw new Error(
282
+ "resolveOrCreateContact requires at least one of userId, email, anonymousId",
283
+ );
284
+ }
285
+
286
+ const patch = contactProperties ?? {};
287
+ const hasPatch = Object.keys(patch).length > 0;
288
+
289
+ return db.transaction(async (tx) => {
290
+ // (0) Advisory locks per key — serialize concurrent resolves on the same
291
+ // identity so the INSERT race can't mint duplicates. Sorted to keep a stable
292
+ // acquisition order across callers (deadlock-safe).
293
+ const lockArgs = keys
294
+ .map((k) => `${k.kind}:${k.value}`)
295
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
296
+ for (const arg of lockArgs) {
297
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${arg}))`);
298
+ }
299
+
300
+ // (1) Resolve every provided key to its owning live contact (alias-aware).
301
+ const matched = await Promise.all(keys.map((k) => findByKey(tx, k)));
302
+
303
+ const distinct = new Map<string, ContactRow>();
304
+ for (const row of matched) {
305
+ if (row) distinct.set(row.id, row);
306
+ }
307
+ const candidates = [...distinct.values()];
308
+
309
+ // --- CASE: create (no existing row) ---
310
+ if (candidates.length === 0) {
311
+ const inserted = await tx
312
+ .insert(contacts)
313
+ .values({
314
+ externalId: userId ?? null,
315
+ email: email ?? null,
316
+ anonymousId: anonymousId ?? null,
317
+ // §2.1: explicit null clears a key — never persist a null-valued prop.
318
+ properties: stripNulls(patch),
319
+ })
320
+ .returning();
321
+ const createdRow = inserted[0];
322
+ if (!createdRow) throw new Error("Contact insert returned no row");
323
+ return {
324
+ id: createdRow.id,
325
+ resolvedKey: contactKey(createdRow),
326
+ created: true,
327
+ linked: false,
328
+ merged: false,
329
+ };
330
+ }
331
+
332
+ // --- CASE: fill-in-link (single existing row) ---
333
+ const single = candidates[0];
334
+ if (candidates.length === 1 && single) {
335
+ const { id, resolvedKey } = await fillInLink(tx, single, {
336
+ userId,
337
+ email,
338
+ anonymousId,
339
+ patch,
340
+ hasPatch,
341
+ });
342
+ return { id, resolvedKey, created: false, linked: true, merged: false };
343
+ }
344
+
345
+ // --- CASE: collide-MERGE (2-3 distinct rows) ---
346
+ const { id, resolvedKey } = await mergeContacts(tx, candidates, {
347
+ userId,
348
+ email,
349
+ anonymousId,
350
+ patch,
351
+ hasPatch,
352
+ });
353
+ return { id, resolvedKey, created: false, linked: true, merged: true };
354
+ });
355
+ }
356
+
357
+ interface ResolveCtx {
358
+ userId?: string;
359
+ email?: string;
360
+ anonymousId?: string;
361
+ patch: Record<string, unknown>;
362
+ hasPatch: boolean;
363
+ }
364
+
365
+ /**
366
+ * Single matching row: fill any identity keys it is missing, record a `'promote'`
367
+ * alias for each newly-attached key (provenance + belt-and-suspenders so the key
368
+ * still resolves through the alias path), and apply the property patch.
369
+ */
370
+ async function fillInLink(
371
+ tx: Tx,
372
+ row: ContactRow,
373
+ ctx: ResolveCtx,
374
+ ): Promise<{ id: string; resolvedKey: string }> {
375
+ const set: Record<string, unknown> = {
376
+ lastSeenAt: new Date(),
377
+ updatedAt: new Date(),
378
+ };
379
+ const promoted: ResolveKey[] = [];
380
+
381
+ // The contact's canonical string key BEFORE this fill (external_id ??
382
+ // anonymous_id ?? id). Attaching an external_id (or anonymous_id where none
383
+ // existed) flips this key — its existing string-keyed history must follow
384
+ // (risk 1), else entry-limit guards / history checks query under the new key
385
+ // and silently miss the pre-link history.
386
+ const oldKey = contactKey(row);
387
+ let nextExternalId = row.externalId;
388
+ let nextAnonymousId = row.anonymousId;
389
+
390
+ if (ctx.userId && !row.externalId) {
391
+ set.externalId = ctx.userId;
392
+ nextExternalId = ctx.userId;
393
+ promoted.push({ kind: "external", value: ctx.userId });
394
+ }
395
+ if (ctx.email && !row.email) {
396
+ set.email = ctx.email;
397
+ promoted.push({ kind: "email", value: ctx.email });
398
+ }
399
+ if (ctx.anonymousId && !row.anonymousId) {
400
+ set.anonymousId = ctx.anonymousId;
401
+ nextAnonymousId = ctx.anonymousId;
402
+ promoted.push({ kind: "anonymous", value: ctx.anonymousId });
403
+ }
404
+ if (ctx.hasPatch) {
405
+ set.properties = mergePropertiesSql(ctx.patch);
406
+ }
407
+
408
+ await tx.update(contacts).set(set).where(eq(contacts.id, row.id));
409
+
410
+ // Re-point the contact's own history if the canonical key flipped. The
411
+ // updated row (with its new email/keys) is what foldJourneyStates/email_sends
412
+ // denormalize into.
413
+ const newKey = nextExternalId ?? nextAnonymousId ?? row.id;
414
+ if (newKey !== oldKey) {
415
+ const updatedRow: ContactRow = {
416
+ ...row,
417
+ externalId: nextExternalId,
418
+ anonymousId: nextAnonymousId,
419
+ email: (set.email as string | undefined) ?? row.email,
420
+ };
421
+ await repointOwnHistory(tx, oldKey, newKey, updatedRow);
422
+ }
423
+
424
+ for (const key of promoted) {
425
+ await tx
426
+ .insert(contactAliases)
427
+ .values({
428
+ contactId: row.id,
429
+ aliasKind: key.kind,
430
+ aliasValue: key.value,
431
+ fromContactId: null,
432
+ reason: "promote",
433
+ })
434
+ .onConflictDoNothing({
435
+ target: [contactAliases.aliasKind, contactAliases.aliasValue],
436
+ });
437
+ }
438
+
439
+ // `newKey` IS the post-fill canonical key (external_id ?? anonymous_id ?? id) —
440
+ // the same value the old read-back derived.
441
+ return { id: row.id, resolvedKey: newKey };
442
+ }
443
+
444
+ /**
445
+ * 2-3 distinct rows collide. Pick the survivor (SURVIVOR RULE) and execute the
446
+ * LOCKED 9-step re-point order, ALL in this one tx. Returns survivor id.
447
+ */
448
+ async function mergeContacts(
449
+ tx: Tx,
450
+ candidates: ContactRow[],
451
+ ctx: ResolveCtx,
452
+ ): Promise<{ id: string; resolvedKey: string }> {
453
+ const { survivor, losers } = pickSurvivor(candidates);
454
+ const survivorKey = contactKey(survivor);
455
+
456
+ for (const loser of losers) {
457
+ const loserStrKeys = [loser.externalId, loser.anonymousId, loser.id].filter(
458
+ (k): k is string => Boolean(k),
459
+ );
460
+ // The id is the last-resort key for a loser that has neither external nor
461
+ // anonymous id (its user_id rows were keyed on contacts.id).
462
+ const loserKeysToRewrite = loserStrKeys;
463
+
464
+ // (ii) user_events.user_id rewrite.
465
+ await tx
466
+ .update(userEvents)
467
+ .set({ userId: survivorKey })
468
+ .where(inArray(userEvents.userId, loserKeysToRewrite));
469
+
470
+ // (iii) journey_states — exit the loser's duplicate active/waiting row when
471
+ // the survivor already holds an active/waiting row in the same journey
472
+ // (respect uq_user_journey_active), THEN rewrite user_id/user_email.
473
+ await foldJourneyStates(tx, survivorKey, loserKeysToRewrite, survivor);
474
+
475
+ // (iv) email_sends rewrite user_id + userEmail to survivor's.
476
+ await tx
477
+ .update(emailSends)
478
+ .set({
479
+ userId: survivorKey,
480
+ ...(survivor.email ? { userEmail: survivor.email } : {}),
481
+ })
482
+ .where(inArray(emailSends.userId, loserKeysToRewrite));
483
+
484
+ // (v) bucket_memberships — soft-leave the loser's duplicate active
485
+ // membership when the survivor already holds one in the same bucket (respect
486
+ // uq_user_bucket_active, preserve survivor's dwell clock), THEN rewrite.
487
+ await foldBucketMemberships(tx, survivorKey, loserKeysToRewrite);
488
+
489
+ // (vi) email_preferences FOLD (never blind-rewrite — risk 6).
490
+ await foldEmailPreferences(tx, loserKeysToRewrite, survivorKey);
491
+
492
+ // (ix) RECORD aliases for each loser key → survivor.
493
+ await recordMergeAliases(tx, survivor.id, loser);
494
+ }
495
+
496
+ // (vii) FOLD properties: survivor wins over losers; then the call's patch wins
497
+ // last. timezone = survivor ?? loser; firstSeenAt = least.
498
+ let foldedProps: Record<string, unknown> = {};
499
+ for (const loser of losers) {
500
+ foldedProps = { ...foldedProps, ...((loser.properties ?? {}) as object) };
501
+ }
502
+ foldedProps = { ...foldedProps, ...((survivor.properties ?? {}) as object) };
503
+ if (ctx.hasPatch) {
504
+ foldedProps = { ...foldedProps, ...ctx.patch };
505
+ }
506
+ // §2.1: an explicit null in the call's patch clears a key — drop null-valued
507
+ // keys from the folded result (matching mergePropertiesSql's strip-nulls).
508
+ foldedProps = stripNulls(foldedProps);
509
+
510
+ const survivorTimezone =
511
+ survivor.timezone ?? losers.find((l) => l.timezone)?.timezone ?? null;
512
+ const earliestFirstSeen = [survivor, ...losers].reduce(
513
+ (min, r) => (r.firstSeenAt < min ? r.firstSeenAt : min),
514
+ survivor.firstSeenAt,
515
+ );
516
+
517
+ // Fill any identity keys the survivor is missing but a loser owned / the call
518
+ // supplied, so the merged row carries the full identity.
519
+ const survivorSet: Record<string, unknown> = {
520
+ properties: foldedProps,
521
+ timezone: survivorTimezone,
522
+ firstSeenAt: earliestFirstSeen,
523
+ lastSeenAt: new Date(),
524
+ updatedAt: new Date(),
525
+ };
526
+ if (!survivor.externalId) {
527
+ const fromLoser = losers.find((l) => l.externalId)?.externalId;
528
+ const next = ctx.userId ?? fromLoser;
529
+ if (next) survivorSet.externalId = next;
530
+ }
531
+ if (!survivor.email) {
532
+ const fromLoser = losers.find((l) => l.email)?.email;
533
+ const next = ctx.email ?? fromLoser;
534
+ if (next) survivorSet.email = next;
535
+ }
536
+ if (!survivor.anonymousId) {
537
+ const fromLoser = losers.find((l) => l.anonymousId)?.anonymousId;
538
+ const next = ctx.anonymousId ?? fromLoser;
539
+ if (next) survivorSet.anonymousId = next;
540
+ }
541
+
542
+ // (viii) Soft-delete the losers FIRST — frees their external_id/email/
543
+ // anonymous_id from the partial-unique indexes (WHERE deleted_at IS NULL) —
544
+ // THEN copy keys onto the survivor. Reverse order self-collides (risk 4).
545
+ await tx
546
+ .update(contacts)
547
+ .set({ deletedAt: new Date(), updatedAt: new Date() })
548
+ .where(
549
+ inArray(
550
+ contacts.id,
551
+ losers.map((l) => l.id),
552
+ ),
553
+ );
554
+
555
+ await tx
556
+ .update(contacts)
557
+ .set(survivorSet)
558
+ .where(eq(contacts.id, survivor.id));
559
+
560
+ // If the survivor's canonical key flipped (it had no external_id/anonymous_id
561
+ // and the merge promoted one from the call/loser), re-point the survivor's OWN
562
+ // history — including everything the loser rewrites just pointed at the old
563
+ // survivorKey — onto the new key (risk 1). Without this, a survivor whose
564
+ // history was keyed on its uuid/anonymous_id is orphaned the moment it gains
565
+ // an external_id mid-merge.
566
+ const newSurvivorKey =
567
+ (survivorSet.externalId as string | undefined) ??
568
+ survivor.externalId ??
569
+ (survivorSet.anonymousId as string | undefined) ??
570
+ survivor.anonymousId ??
571
+ survivor.id;
572
+ if (newSurvivorKey !== survivorKey) {
573
+ const updatedSurvivor: ContactRow = {
574
+ ...survivor,
575
+ externalId:
576
+ (survivorSet.externalId as string | undefined) ?? survivor.externalId,
577
+ anonymousId:
578
+ (survivorSet.anonymousId as string | undefined) ?? survivor.anonymousId,
579
+ email: (survivorSet.email as string | undefined) ?? survivor.email,
580
+ };
581
+ await repointOwnHistory(tx, survivorKey, newSurvivorKey, updatedSurvivor);
582
+ }
583
+
584
+ // `newSurvivorKey` IS the post-merge canonical key of the survivor — the same
585
+ // value the old read-back derived for the merged row.
586
+ return { id: survivor.id, resolvedKey: newSurvivorKey };
587
+ }
588
+
589
+ /**
590
+ * journey_states fold. `uq_user_journey_active` is a FULL (non-partial) unique
591
+ * index on `(user_id, journey_id, status)` — it constrains EVERY status, not
592
+ * just active/waiting. So a blind rewrite of loser rows onto the survivor key
593
+ * collides whenever the survivor already holds a row for the SAME
594
+ * (journey_id, status) — including the common terminal case where both
595
+ * identities completed/failed/exited the same journey.
596
+ *
597
+ * Fix: build the survivor's occupied (journey_id|status) set over ALL statuses.
598
+ * For active/waiting collisions, EXIT the loser's row first (preserve the
599
+ * survivor's live run). For any OTHER collision (terminal, or an active row that
600
+ * collides after exiting), DELETE the loser's duplicate — the survivor already
601
+ * records that state. Rewrite only the non-colliding remainder onto the survivor
602
+ * key (+ survivor email). Re-check 'exited' occupancy after exiting so a
603
+ * just-exited loser row that would now duplicate a pre-existing survivor
604
+ * 'exited' row is dropped rather than rewritten.
605
+ */
606
+ async function foldJourneyStates(
607
+ tx: Tx,
608
+ survivorKey: string,
609
+ loserKeys: string[],
610
+ survivor: ContactRow,
611
+ ): Promise<void> {
612
+ const ACTIVE = new Set<string>(["active", "waiting"]);
613
+
614
+ // Every (journey_id|status) pair the survivor already holds (ALL statuses).
615
+ const survivorRows = await tx
616
+ .select({
617
+ journeyId: journeyStates.journeyId,
618
+ status: journeyStates.status,
619
+ })
620
+ .from(journeyStates)
621
+ .where(
622
+ and(
623
+ eq(journeyStates.userId, survivorKey),
624
+ isNull(journeyStates.deletedAt),
625
+ ),
626
+ );
627
+ // Running occupied set — mutated as we exit/rewrite loser rows so two loser
628
+ // rows in the same journey/status (3-way merge) can't collide with each other.
629
+ const occupied = new Set(
630
+ survivorRows.map((s) => `${s.journeyId}|${s.status}`),
631
+ );
632
+
633
+ const loserRows = await tx
634
+ .select({
635
+ id: journeyStates.id,
636
+ journeyId: journeyStates.journeyId,
637
+ status: journeyStates.status,
638
+ })
639
+ .from(journeyStates)
640
+ .where(
641
+ and(
642
+ inArray(journeyStates.userId, loserKeys),
643
+ isNull(journeyStates.deletedAt),
644
+ ),
645
+ );
646
+
647
+ const idsToExit: string[] = [];
648
+ const idsToDelete: string[] = [];
649
+ const idsToRewrite: string[] = [];
650
+
651
+ for (const l of loserRows) {
652
+ const key = `${l.journeyId}|${l.status}`;
653
+ if (occupied.has(key)) {
654
+ if (ACTIVE.has(l.status)) {
655
+ // Survivor (or a prior loser) already holds a live row in this
656
+ // journey/status — exit the loser's so the live run continues. Only do
657
+ // so if the resulting 'exited' slot is itself free; otherwise drop it.
658
+ const exitedKey = `${l.journeyId}|exited`;
659
+ if (occupied.has(exitedKey)) {
660
+ idsToDelete.push(l.id);
661
+ } else {
662
+ idsToExit.push(l.id);
663
+ occupied.add(exitedKey);
664
+ }
665
+ } else {
666
+ // Terminal collision (both completed/failed/exited the same journey) —
667
+ // the survivor already records this state; drop the loser duplicate.
668
+ idsToDelete.push(l.id);
669
+ }
670
+ } else {
671
+ // Free slot — rewrite onto the survivor key. Claim it so a sibling loser
672
+ // row in the same journey/status routes to exit/delete instead.
673
+ idsToRewrite.push(l.id);
674
+ occupied.add(key);
675
+ }
676
+ }
677
+
678
+ if (idsToDelete.length > 0) {
679
+ await tx
680
+ .delete(journeyStates)
681
+ .where(inArray(journeyStates.id, idsToDelete));
682
+ }
683
+
684
+ if (idsToExit.length > 0) {
685
+ await tx
686
+ .update(journeyStates)
687
+ .set({ status: "exited", exitedAt: new Date(), updatedAt: new Date() })
688
+ .where(inArray(journeyStates.id, idsToExit));
689
+ }
690
+
691
+ // Rewrite both the originally non-colliding rows AND the just-exited rows onto
692
+ // the survivor key (the exited rows now sit in claimed-free 'exited' slots).
693
+ const rewriteIds = [...idsToRewrite, ...idsToExit];
694
+ if (rewriteIds.length > 0) {
695
+ await tx
696
+ .update(journeyStates)
697
+ .set({
698
+ userId: survivorKey,
699
+ ...(survivor.email ? { userEmail: survivor.email } : {}),
700
+ updatedAt: new Date(),
701
+ })
702
+ .where(inArray(journeyStates.id, rewriteIds));
703
+ }
704
+ }
705
+
706
+ /**
707
+ * bucket_memberships fold: if the survivor already holds an ACTIVE membership in
708
+ * a bucket where a loser key also holds one, soft-LEAVE the loser's row first
709
+ * (uq_user_bucket_active forbids two active rows for the same (user, bucket);
710
+ * preserve the survivor's dwell clock), then rewrite the rest onto the survivor.
711
+ */
712
+ async function foldBucketMemberships(
713
+ tx: Tx,
714
+ survivorKey: string,
715
+ loserKeys: string[],
716
+ ): Promise<void> {
717
+ const survivorActive = await tx
718
+ .select({ bucketId: bucketMemberships.bucketId })
719
+ .from(bucketMemberships)
720
+ .where(
721
+ and(
722
+ eq(bucketMemberships.userId, survivorKey),
723
+ eq(bucketMemberships.status, "active"),
724
+ isNull(bucketMemberships.deletedAt),
725
+ ),
726
+ );
727
+ const occupied = new Set(survivorActive.map((s) => s.bucketId));
728
+
729
+ const loserActive = await tx
730
+ .select({
731
+ id: bucketMemberships.id,
732
+ bucketId: bucketMemberships.bucketId,
733
+ })
734
+ .from(bucketMemberships)
735
+ .where(
736
+ and(
737
+ inArray(bucketMemberships.userId, loserKeys),
738
+ eq(bucketMemberships.status, "active"),
739
+ isNull(bucketMemberships.deletedAt),
740
+ ),
741
+ );
742
+
743
+ const idsToLeave = loserActive
744
+ .filter((l) => occupied.has(l.bucketId))
745
+ .map((l) => l.id);
746
+
747
+ if (idsToLeave.length > 0) {
748
+ await tx
749
+ .update(bucketMemberships)
750
+ .set({ status: "left", leftAt: new Date(), updatedAt: new Date() })
751
+ .where(inArray(bucketMemberships.id, idsToLeave));
752
+ }
753
+
754
+ await tx
755
+ .update(bucketMemberships)
756
+ .set({ userId: survivorKey, updatedAt: new Date() })
757
+ .where(inArray(bucketMemberships.userId, loserKeys));
758
+ }
759
+
760
+ /**
761
+ * email_preferences FOLD (risk 6 — suppression/unsubscribe must NEVER be lost).
762
+ * For each of the loser's pref rows, fold it into whatever currently sits at
763
+ * `(survivorKey, email)`:
764
+ * unsubscribedAll = OR, suppressed = OR, bounceCount = MAX,
765
+ * categories = merge with FALSE winning on conflict (unsub never lost),
766
+ * suppressedAt / lastBounceAt = earliest non-null.
767
+ * The TARGET row is re-read fresh per loser pref (NOT a cached map), so a
768
+ * 3-way merge where two losers each carry a pref for the SAME email folds
769
+ * loser2 into loser1's already-folded result instead of colliding on
770
+ * `uq(user_id, email)` (risk 3). The loser row is deleted after folding.
771
+ */
772
+ async function foldEmailPreferences(
773
+ tx: Tx,
774
+ loserKeys: string[],
775
+ survivorKey: string,
776
+ ): Promise<void> {
777
+ if (loserKeys.length === 0) return;
778
+
779
+ const loserPrefs = await tx
780
+ .select()
781
+ .from(emailPreferences)
782
+ .where(inArray(emailPreferences.userId, loserKeys));
783
+
784
+ if (loserPrefs.length === 0) return;
785
+
786
+ const earliest = (a: Date | null, b: Date | null): Date | null => {
787
+ if (!a) return b;
788
+ if (!b) return a;
789
+ return a < b ? a : b;
790
+ };
791
+
792
+ for (const lp of loserPrefs) {
793
+ // Re-read the CURRENT target row for (survivorKey, lp.email) — it may be the
794
+ // original survivor pref, a prior loser's just-folded pref, or absent.
795
+ const targetRows = await tx
796
+ .select()
797
+ .from(emailPreferences)
798
+ .where(
799
+ and(
800
+ eq(emailPreferences.userId, survivorKey),
801
+ eq(emailPreferences.email, lp.email),
802
+ ),
803
+ )
804
+ .limit(1);
805
+ const target = targetRows[0];
806
+
807
+ if (!target) {
808
+ // The (survivorKey, lp.email) slot is free — re-point the loser row.
809
+ await tx
810
+ .update(emailPreferences)
811
+ .set({ userId: survivorKey, updatedAt: new Date() })
812
+ .where(eq(emailPreferences.id, lp.id));
813
+ continue;
814
+ }
815
+
816
+ // FOLD into the target row, FALSE wins on category conflict.
817
+ const foldedCategories: Record<string, boolean> = {
818
+ ...((lp.categories ?? {}) as Record<string, boolean>),
819
+ ...((target.categories ?? {}) as Record<string, boolean>),
820
+ };
821
+ for (const [k, lv] of Object.entries(
822
+ (lp.categories ?? {}) as Record<string, boolean>,
823
+ )) {
824
+ const tv = target.categories?.[k];
825
+ if (lv === false || tv === false) foldedCategories[k] = false;
826
+ }
827
+
828
+ await tx
829
+ .update(emailPreferences)
830
+ .set({
831
+ unsubscribedAll: target.unsubscribedAll || lp.unsubscribedAll,
832
+ suppressed: target.suppressed || lp.suppressed,
833
+ bounceCount: Math.max(target.bounceCount, lp.bounceCount),
834
+ categories: foldedCategories,
835
+ suppressedAt: earliest(target.suppressedAt, lp.suppressedAt),
836
+ lastBounceAt: earliest(target.lastBounceAt, lp.lastBounceAt),
837
+ updatedAt: new Date(),
838
+ })
839
+ .where(eq(emailPreferences.id, target.id));
840
+
841
+ // The loser row would collide with the target on (survivorKey, email) if
842
+ // re-pointed — its data is folded in, so drop it.
843
+ await tx.delete(emailPreferences).where(eq(emailPreferences.id, lp.id));
844
+ }
845
+ }
846
+
847
+ /**
848
+ * Re-point a contact's OWN string-keyed history when its canonical key flips
849
+ * (risk 1). resolvedKey downstream is `external_id ?? anonymous_id ?? id`, so
850
+ * when fill-in-link attaches an external_id to a previously email-only/anon
851
+ * contact (or merge promotes the survivor's external_id), the contact's
852
+ * canonical key changes and its existing user_events/journey_states/email_sends/
853
+ * bucket_memberships/email_preferences rows (keyed on the OLD key) would be
854
+ * silently orphaned. Rewrite them from `oldKey` to `newKey`, applying the SAME
855
+ * active/terminal dedupe as the merge fold so the rewrite can't violate
856
+ * uq_user_journey_active / uq_user_bucket_active / uq(user_id,email).
857
+ *
858
+ * No-op when oldKey === newKey (the canonical key did not change).
859
+ */
860
+ async function repointOwnHistory(
861
+ tx: Tx,
862
+ oldKey: string,
863
+ newKey: string,
864
+ row: ContactRow,
865
+ ): Promise<void> {
866
+ if (oldKey === newKey) return;
867
+
868
+ // user_events: no unique constraint on user_id — blind rewrite.
869
+ await tx
870
+ .update(userEvents)
871
+ .set({ userId: newKey })
872
+ .where(eq(userEvents.userId, oldKey));
873
+
874
+ // journey_states + bucket_memberships: dedupe against the survivor/new key's
875
+ // existing rows (the folds already handle the collision logic).
876
+ await foldJourneyStates(tx, newKey, [oldKey], row);
877
+ await foldBucketMemberships(tx, newKey, [oldKey]);
878
+
879
+ // email_sends: no unique constraint on user_id — blind rewrite.
880
+ await tx
881
+ .update(emailSends)
882
+ .set({
883
+ userId: newKey,
884
+ ...(row.email ? { userEmail: row.email } : {}),
58
885
  })
886
+ .where(eq(emailSends.userId, oldKey));
887
+
888
+ // email_preferences: FOLD into the new key's rows (uq(user_id, email)).
889
+ await foldEmailPreferences(tx, [oldKey], newKey);
890
+ }
891
+
892
+ /** RECORD a contact_aliases row per loser key → survivor (reason 'merge'). */
893
+ async function recordMergeAliases(
894
+ tx: Tx,
895
+ survivorId: string,
896
+ loser: ContactRow,
897
+ ): Promise<void> {
898
+ const aliasRows: {
899
+ contactId: string;
900
+ aliasKind: Kind;
901
+ aliasValue: string;
902
+ fromContactId: string;
903
+ reason: string;
904
+ }[] = [];
905
+ if (loser.externalId) {
906
+ aliasRows.push({
907
+ contactId: survivorId,
908
+ aliasKind: "external",
909
+ aliasValue: loser.externalId,
910
+ fromContactId: loser.id,
911
+ reason: "merge",
912
+ });
913
+ }
914
+ if (loser.email) {
915
+ aliasRows.push({
916
+ contactId: survivorId,
917
+ aliasKind: "email",
918
+ aliasValue: loser.email,
919
+ fromContactId: loser.id,
920
+ reason: "merge",
921
+ });
922
+ }
923
+ if (loser.anonymousId) {
924
+ aliasRows.push({
925
+ contactId: survivorId,
926
+ aliasKind: "anonymous",
927
+ aliasValue: loser.anonymousId,
928
+ fromContactId: loser.id,
929
+ reason: "merge",
930
+ });
931
+ }
932
+
933
+ if (aliasRows.length === 0) return;
934
+
935
+ // On conflict (a stale key already aliases somewhere), re-point it to this
936
+ // survivor — the most recent merge wins.
937
+ await tx
938
+ .insert(contactAliases)
939
+ .values(aliasRows)
59
940
  .onConflictDoUpdate({
60
- target: contacts.externalId,
941
+ target: [contactAliases.aliasKind, contactAliases.aliasValue],
61
942
  set: {
62
- ...(email ? { email } : {}),
63
- properties: sql`COALESCE(${contacts.properties}, '{}'::jsonb) || ${JSON.stringify(properties ?? {})}::jsonb`,
64
- lastSeenAt: new Date(),
943
+ contactId: survivorId,
944
+ fromContactId: loser.id,
945
+ reason: "merge",
65
946
  updatedAt: new Date(),
66
947
  },
67
948
  });
68
949
  }
950
+
951
+ // ---------------------------------------------------------------------------
952
+ // Retained wrapper + public-route helpers
953
+ // ---------------------------------------------------------------------------
954
+
955
+ /**
956
+ * Retained thin wrapper so existing callers (`ingestion.ts`,
957
+ * `import-contacts.ts`) keep compiling. `externalId` is now OPTIONAL and its
958
+ * `properties` are forwarded as `contactProperties`. Delegates to the real
959
+ * `resolveOrCreateContact` (the old `onConflictDoUpdate(target: externalId)`
960
+ * upsert couldn't create email-only/anon contacts or merge — decision #9 / §5).
961
+ */
962
+ export async function upsertContact(opts: {
963
+ db: Database;
964
+ externalId?: string;
965
+ email?: string;
966
+ anonymousId?: string;
967
+ properties?: Record<string, unknown>;
968
+ }): Promise<{
969
+ id: string;
970
+ resolvedKey: string;
971
+ created: boolean;
972
+ linked: boolean;
973
+ merged: boolean;
974
+ }> {
975
+ return resolveOrCreateContact({
976
+ db: opts.db,
977
+ userId: opts.externalId,
978
+ email: opts.email,
979
+ anonymousId: opts.anonymousId,
980
+ contactProperties: opts.properties,
981
+ });
982
+ }
983
+
984
+ /**
985
+ * Find non-deleted contacts by email or external id. Used by the public
986
+ * `/v1/contacts/find` route. Email is normalized before lookup.
987
+ */
988
+ export async function findContacts(opts: {
989
+ db: Database;
990
+ email?: string;
991
+ userId?: string;
992
+ }): Promise<ContactRow[]> {
993
+ const { db } = opts;
994
+ const email = opts.email ? normalizeEmail(opts.email) : undefined;
995
+ const userId = opts.userId?.trim() || undefined;
996
+
997
+ const clauses = [];
998
+ if (email) clauses.push(eq(contacts.email, email));
999
+ if (userId) clauses.push(eq(contacts.externalId, userId));
1000
+ if (clauses.length === 0) return [];
1001
+
1002
+ return db
1003
+ .select()
1004
+ .from(contacts)
1005
+ .where(and(or(...clauses), isNull(contacts.deletedAt)));
1006
+ }
1007
+
1008
+ /**
1009
+ * Soft-delete a contact resolved by email or external id (sets `deletedAt`).
1010
+ * Returns true iff a live row was found and soft-deleted.
1011
+ */
1012
+ export async function softDeleteContact(opts: {
1013
+ db: Database;
1014
+ email?: string;
1015
+ userId?: string;
1016
+ }): Promise<boolean> {
1017
+ const { db } = opts;
1018
+ const email = opts.email ? normalizeEmail(opts.email) : undefined;
1019
+ const userId = opts.userId?.trim() || undefined;
1020
+
1021
+ const clauses = [];
1022
+ if (email) clauses.push(eq(contacts.email, email));
1023
+ if (userId) clauses.push(eq(contacts.externalId, userId));
1024
+ if (clauses.length === 0) return false;
1025
+
1026
+ const updated = await db
1027
+ .update(contacts)
1028
+ .set({ deletedAt: new Date(), updatedAt: new Date() })
1029
+ .where(and(or(...clauses), isNull(contacts.deletedAt)))
1030
+ .returning({ id: contacts.id });
1031
+
1032
+ return updated.length > 0;
1033
+ }
1034
+
1035
+ /**
1036
+ * Resolve a sendable recipient for `/v1/emails` and `applyListMembership`.
1037
+ * Returns the contact's normalized email plus the identity needed to denormalize
1038
+ * a send row / key an `email_preferences` write. Returns null when no resolvable
1039
+ * email exists (the caller maps that to a 404/400).
1040
+ *
1041
+ * Lookup precedence: a normalized `email` arg short-circuits; otherwise resolve
1042
+ * the contact by `userId` (external id, alias-aware) and read back its email.
1043
+ * `externalId` is the contact's external id (may be null for an email-only
1044
+ * contact); `contactId` is the uuid fallback for the `email_preferences.user_id`
1045
+ * column (risk 10) when externalId is null.
1046
+ */
1047
+ export async function resolveRecipient(opts: {
1048
+ db: Database;
1049
+ userId?: string;
1050
+ email?: string;
1051
+ }): Promise<{
1052
+ email: string;
1053
+ externalId: string | null;
1054
+ contactId: string;
1055
+ } | null> {
1056
+ const { db } = opts;
1057
+ const email = opts.email ? normalizeEmail(opts.email) : undefined;
1058
+ const userId = opts.userId?.trim() || undefined;
1059
+
1060
+ // Resolve the owning contact, preferring email then userId. Use a direct +
1061
+ // alias-aware lookup so a stale (merged) key still resolves.
1062
+ let row: ContactRow | null = null;
1063
+
1064
+ if (email) {
1065
+ const byEmail = await db
1066
+ .select()
1067
+ .from(contacts)
1068
+ .where(and(eq(contacts.email, email), isNull(contacts.deletedAt)))
1069
+ .limit(1);
1070
+ row = byEmail[0] ?? null;
1071
+ if (!row) {
1072
+ const aliased = await resolveViaAlias(db, "email", email);
1073
+ row = aliased;
1074
+ }
1075
+ // Email arg is authoritative as the send target even if no contact row
1076
+ // exists yet — return it so a brand-new address can still be emailed.
1077
+ if (!row) {
1078
+ return { email, externalId: null, contactId: email };
1079
+ }
1080
+ } else if (userId) {
1081
+ const byExternal = await db
1082
+ .select()
1083
+ .from(contacts)
1084
+ .where(and(eq(contacts.externalId, userId), isNull(contacts.deletedAt)))
1085
+ .limit(1);
1086
+ row = byExternal[0] ?? null;
1087
+ if (!row) {
1088
+ row = await resolveViaAlias(db, "external", userId);
1089
+ }
1090
+ if (!row?.email) return null;
1091
+ } else {
1092
+ return null;
1093
+ }
1094
+
1095
+ if (!row?.email) {
1096
+ // Email path with a matched row that has no email is impossible (matched on
1097
+ // email), so this only guards the userId path's missing-email case.
1098
+ return null;
1099
+ }
1100
+
1101
+ return {
1102
+ email: row.email,
1103
+ externalId: row.externalId,
1104
+ contactId: row.id,
1105
+ };
1106
+ }
1107
+
1108
+ /** Alias-aware lookup helper for resolveRecipient (mirrors findByKey but on the
1109
+ * top-level db handle, no tx). */
1110
+ async function resolveViaAlias(
1111
+ db: Database,
1112
+ kind: Kind,
1113
+ value: string,
1114
+ ): Promise<ContactRow | null> {
1115
+ const alias = await db
1116
+ .select({ contactId: contactAliases.contactId })
1117
+ .from(contactAliases)
1118
+ .where(
1119
+ and(
1120
+ eq(contactAliases.aliasKind, kind),
1121
+ eq(contactAliases.aliasValue, value),
1122
+ ),
1123
+ )
1124
+ .limit(1);
1125
+ if (!alias[0]) return null;
1126
+
1127
+ const rows = await db
1128
+ .select()
1129
+ .from(contacts)
1130
+ .where(and(eq(contacts.id, alias[0].contactId), isNull(contacts.deletedAt)))
1131
+ .limit(1);
1132
+ return rows[0] ?? null;
1133
+ }