@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.
- package/package.json +7 -6
- package/src/app.ts +36 -1
- package/src/buckets/check-membership.ts +34 -15
- package/src/container.ts +33 -0
- package/src/env.ts +29 -0
- package/src/index.ts +47 -1
- package/src/journeys/define-journey.ts +26 -2
- package/src/journeys/journey-context.ts +5 -1
- package/src/lib/boot.ts +1 -1
- package/src/lib/bucket-emit.ts +47 -2
- package/src/lib/contacts.ts +1105 -18
- package/src/lib/email-service-types.ts +8 -0
- package/src/lib/ingestion.ts +63 -33
- package/src/lib/mailer.ts +88 -0
- package/src/lib/outbound.ts +216 -0
- package/src/lib/preferences.ts +137 -0
- package/src/lib/tracked.ts +204 -37
- package/src/lib/tracking-events.ts +67 -2
- package/src/lib/webhook-signing.ts +151 -0
- package/src/lists/define-list.ts +81 -0
- package/src/lists/registry-singleton.ts +39 -0
- package/src/lists/registry.ts +95 -0
- package/src/middleware/api-key.ts +33 -7
- package/src/middleware/rate-limit.ts +73 -49
- package/src/routes/_shared.ts +30 -0
- package/src/routes/admin/api-keys.ts +1 -1
- package/src/routes/admin/bulk.ts +7 -3
- package/src/routes/admin/contacts.ts +108 -59
- package/src/routes/admin/events.ts +65 -0
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/admin/journeys.ts +3 -1
- package/src/routes/admin/preferences.ts +2 -2
- package/src/routes/admin/reporting.ts +3 -3
- package/src/routes/admin/timeline.ts +5 -2
- package/src/routes/admin/webhooks.ts +466 -0
- package/src/routes/campaigns/index.ts +252 -0
- package/src/routes/contacts/index.ts +231 -0
- package/src/routes/email/preferences.ts +27 -3
- package/src/routes/email/unsubscribe.ts +7 -49
- package/src/routes/emails/index.ts +133 -0
- package/src/routes/events/index.ts +119 -0
- package/src/routes/index.ts +52 -2
- package/src/routes/lists/index.ts +258 -0
- package/src/routes/tracking/click.ts +59 -18
- package/src/routes/tracking/open.ts +62 -24
- package/src/routes/webhooks/sources.ts +69 -10
- package/src/webhook-sources/define-webhook-source.ts +57 -5
- package/src/webhook-sources/presets/clerk.ts +185 -0
- package/src/webhook-sources/presets/index.ts +80 -0
- package/src/webhook-sources/presets/segment.ts +120 -0
- package/src/webhook-sources/presets/stripe.ts +147 -0
- package/src/webhook-sources/presets/supabase.ts +131 -0
- package/src/webhook-sources/verify.ts +172 -0
- package/src/worker.ts +12 -0
- package/src/workflows/bucket-backfill.ts +32 -21
- package/src/workflows/bucket-reconcile.ts +20 -5
- package/src/workflows/deliver-webhook.ts +399 -0
- package/src/workflows/import-contacts.ts +28 -20
- package/src/workflows/send-campaign.ts +589 -0
- package/src/routes/ingest.ts +0 -71
package/src/lib/contacts.ts
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
+
export 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,1057 @@ 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
|
-
|
|
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
|
-
|
|
253
|
+
userId?: string;
|
|
47
254
|
email?: string;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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:
|
|
941
|
+
target: [contactAliases.aliasKind, contactAliases.aliasValue],
|
|
61
942
|
set: {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
*
|
|
1011
|
+
* Returns `{ deleted }` plus the soft-deleted row's identity (`id`,
|
|
1012
|
+
* `externalId`, `email`) so the delete route can both make its 404 decision
|
|
1013
|
+
* (`deleted`) AND emit the `contact.deleted` outbound webhook with the real
|
|
1014
|
+
* identity — without a second read-back. `deleted` is false (and the identity
|
|
1015
|
+
* fields absent) when no live row matched.
|
|
1016
|
+
*/
|
|
1017
|
+
export async function softDeleteContact(opts: {
|
|
1018
|
+
db: Database;
|
|
1019
|
+
email?: string;
|
|
1020
|
+
userId?: string;
|
|
1021
|
+
}): Promise<{
|
|
1022
|
+
deleted: boolean;
|
|
1023
|
+
id?: string;
|
|
1024
|
+
externalId?: string | null;
|
|
1025
|
+
email?: string | null;
|
|
1026
|
+
}> {
|
|
1027
|
+
const { db } = opts;
|
|
1028
|
+
const email = opts.email ? normalizeEmail(opts.email) : undefined;
|
|
1029
|
+
const userId = opts.userId?.trim() || undefined;
|
|
1030
|
+
|
|
1031
|
+
const clauses = [];
|
|
1032
|
+
if (email) clauses.push(eq(contacts.email, email));
|
|
1033
|
+
if (userId) clauses.push(eq(contacts.externalId, userId));
|
|
1034
|
+
if (clauses.length === 0) return { deleted: false };
|
|
1035
|
+
|
|
1036
|
+
const updated = await db
|
|
1037
|
+
.update(contacts)
|
|
1038
|
+
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
|
1039
|
+
.where(and(or(...clauses), isNull(contacts.deletedAt)))
|
|
1040
|
+
.returning({
|
|
1041
|
+
id: contacts.id,
|
|
1042
|
+
externalId: contacts.externalId,
|
|
1043
|
+
email: contacts.email,
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
const row = updated[0];
|
|
1047
|
+
if (!row) return { deleted: false };
|
|
1048
|
+
|
|
1049
|
+
return {
|
|
1050
|
+
deleted: true,
|
|
1051
|
+
id: row.id,
|
|
1052
|
+
externalId: row.externalId,
|
|
1053
|
+
email: row.email,
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Resolve a sendable recipient for `/v1/emails` and `applyListMembership`.
|
|
1059
|
+
* Returns the contact's normalized email plus the identity needed to denormalize
|
|
1060
|
+
* a send row / key an `email_preferences` write. Returns null when no resolvable
|
|
1061
|
+
* email exists (the caller maps that to a 404/400).
|
|
1062
|
+
*
|
|
1063
|
+
* Lookup precedence: a normalized `email` arg short-circuits; otherwise resolve
|
|
1064
|
+
* the contact by `userId` (external id, alias-aware) and read back its email.
|
|
1065
|
+
* `externalId` is the contact's external id (may be null for an email-only
|
|
1066
|
+
* contact); `contactId` is the uuid fallback for the `email_preferences.user_id`
|
|
1067
|
+
* column (risk 10) when externalId is null.
|
|
1068
|
+
*/
|
|
1069
|
+
export async function resolveRecipient(opts: {
|
|
1070
|
+
db: Database;
|
|
1071
|
+
userId?: string;
|
|
1072
|
+
email?: string;
|
|
1073
|
+
}): Promise<{
|
|
1074
|
+
email: string;
|
|
1075
|
+
externalId: string | null;
|
|
1076
|
+
contactId: string;
|
|
1077
|
+
} | null> {
|
|
1078
|
+
const { db } = opts;
|
|
1079
|
+
const email = opts.email ? normalizeEmail(opts.email) : undefined;
|
|
1080
|
+
const userId = opts.userId?.trim() || undefined;
|
|
1081
|
+
|
|
1082
|
+
// Resolve the owning contact, preferring email then userId. Use a direct +
|
|
1083
|
+
// alias-aware lookup so a stale (merged) key still resolves.
|
|
1084
|
+
let row: ContactRow | null = null;
|
|
1085
|
+
|
|
1086
|
+
if (email) {
|
|
1087
|
+
const byEmail = await db
|
|
1088
|
+
.select()
|
|
1089
|
+
.from(contacts)
|
|
1090
|
+
.where(and(eq(contacts.email, email), isNull(contacts.deletedAt)))
|
|
1091
|
+
.limit(1);
|
|
1092
|
+
row = byEmail[0] ?? null;
|
|
1093
|
+
if (!row) {
|
|
1094
|
+
const aliased = await resolveViaAlias(db, "email", email);
|
|
1095
|
+
row = aliased;
|
|
1096
|
+
}
|
|
1097
|
+
// Email arg is authoritative as the send target even if no contact row
|
|
1098
|
+
// exists yet — return it so a brand-new address can still be emailed.
|
|
1099
|
+
if (!row) {
|
|
1100
|
+
return { email, externalId: null, contactId: email };
|
|
1101
|
+
}
|
|
1102
|
+
} else if (userId) {
|
|
1103
|
+
const byExternal = await db
|
|
1104
|
+
.select()
|
|
1105
|
+
.from(contacts)
|
|
1106
|
+
.where(and(eq(contacts.externalId, userId), isNull(contacts.deletedAt)))
|
|
1107
|
+
.limit(1);
|
|
1108
|
+
row = byExternal[0] ?? null;
|
|
1109
|
+
if (!row) {
|
|
1110
|
+
row = await resolveViaAlias(db, "external", userId);
|
|
1111
|
+
}
|
|
1112
|
+
if (!row?.email) return null;
|
|
1113
|
+
} else {
|
|
1114
|
+
return null;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
if (!row?.email) {
|
|
1118
|
+
// Email path with a matched row that has no email is impossible (matched on
|
|
1119
|
+
// email), so this only guards the userId path's missing-email case.
|
|
1120
|
+
return null;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
return {
|
|
1124
|
+
email: row.email,
|
|
1125
|
+
externalId: row.externalId,
|
|
1126
|
+
contactId: row.id,
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** Alias-aware lookup helper for resolveRecipient (mirrors findByKey but on the
|
|
1131
|
+
* top-level db handle, no tx). */
|
|
1132
|
+
async function resolveViaAlias(
|
|
1133
|
+
db: Database,
|
|
1134
|
+
kind: Kind,
|
|
1135
|
+
value: string,
|
|
1136
|
+
): Promise<ContactRow | null> {
|
|
1137
|
+
const alias = await db
|
|
1138
|
+
.select({ contactId: contactAliases.contactId })
|
|
1139
|
+
.from(contactAliases)
|
|
1140
|
+
.where(
|
|
1141
|
+
and(
|
|
1142
|
+
eq(contactAliases.aliasKind, kind),
|
|
1143
|
+
eq(contactAliases.aliasValue, value),
|
|
1144
|
+
),
|
|
1145
|
+
)
|
|
1146
|
+
.limit(1);
|
|
1147
|
+
if (!alias[0]) return null;
|
|
1148
|
+
|
|
1149
|
+
const rows = await db
|
|
1150
|
+
.select()
|
|
1151
|
+
.from(contacts)
|
|
1152
|
+
.where(and(eq(contacts.id, alias[0].contactId), isNull(contacts.deletedAt)))
|
|
1153
|
+
.limit(1);
|
|
1154
|
+
return rows[0] ?? null;
|
|
1155
|
+
}
|