@hogsend/engine 0.5.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 (48) hide show
  1. package/package.json +6 -6
  2. package/src/buckets/bucket-access.ts +213 -0
  3. package/src/buckets/bucket-reactions.ts +225 -0
  4. package/src/buckets/check-membership.ts +35 -15
  5. package/src/buckets/define-bucket.ts +79 -8
  6. package/src/buckets/registry.ts +81 -0
  7. package/src/container.ts +69 -4
  8. package/src/env.ts +4 -0
  9. package/src/index.ts +27 -0
  10. package/src/journeys/journey-context.ts +5 -1
  11. package/src/lib/boot.ts +12 -2
  12. package/src/lib/bucket-emit.ts +49 -7
  13. package/src/lib/contacts.ts +1083 -18
  14. package/src/lib/email-service-types.ts +8 -0
  15. package/src/lib/ingestion.ts +63 -33
  16. package/src/lib/mailer.ts +1 -0
  17. package/src/lib/preferences.ts +106 -0
  18. package/src/lib/tracked.ts +159 -34
  19. package/src/lib/tracking-events.ts +1 -1
  20. package/src/lists/define-list.ts +81 -0
  21. package/src/lists/registry-singleton.ts +39 -0
  22. package/src/lists/registry.ts +95 -0
  23. package/src/middleware/api-key.ts +33 -7
  24. package/src/middleware/rate-limit.ts +73 -49
  25. package/src/routes/_shared.ts +30 -0
  26. package/src/routes/admin/api-keys.ts +1 -1
  27. package/src/routes/admin/buckets.ts +39 -9
  28. package/src/routes/admin/bulk.ts +7 -3
  29. package/src/routes/admin/contacts.ts +66 -57
  30. package/src/routes/admin/events.ts +65 -0
  31. package/src/routes/admin/journeys.ts +3 -1
  32. package/src/routes/admin/preferences.ts +2 -2
  33. package/src/routes/admin/reporting.ts +3 -3
  34. package/src/routes/admin/timeline.ts +5 -2
  35. package/src/routes/campaigns/index.ts +252 -0
  36. package/src/routes/contacts/index.ts +188 -0
  37. package/src/routes/email/preferences.ts +27 -3
  38. package/src/routes/email/unsubscribe.ts +7 -49
  39. package/src/routes/emails/index.ts +133 -0
  40. package/src/routes/events/index.ts +119 -0
  41. package/src/routes/index.ts +52 -2
  42. package/src/routes/lists/index.ts +222 -0
  43. package/src/worker.ts +25 -2
  44. package/src/workflows/bucket-backfill.ts +122 -22
  45. package/src/workflows/bucket-reconcile.ts +225 -12
  46. package/src/workflows/import-contacts.ts +28 -20
  47. package/src/workflows/send-campaign.ts +589 -0
  48. package/src/routes/ingest.ts +0 -71
@@ -0,0 +1,39 @@
1
+ import { ListRegistry } from "./registry.js";
2
+
3
+ /**
4
+ * Process-singleton for the {@link ListRegistry}, installed by
5
+ * `createHogsendClient` / `buildListRegistry` at startup.
6
+ *
7
+ * Unlike the journey/bucket singletons (which `throw` if read before set), this
8
+ * one is EMPTY-DEFAULT and NEVER throws: `tracked.ts`'s `checkSuppression` runs
9
+ * in BOTH the API and the worker, and a send issued before
10
+ * `createHogsendClient` installs the registry — or a test that constructs no
11
+ * client at all — must still resolve. An empty registry yields legacy behaviour
12
+ * (every unknown id → opt-in default → blocked only on explicit `false`), so the
13
+ * pre-init / no-client path degrades safely instead of crashing (open risk #12).
14
+ */
15
+ // Lazily constructed: `registry.ts` ↔ `registry-singleton.ts` form an ESM
16
+ // import cycle (registry.ts imports `setListRegistry`; this file imports the
17
+ // `ListRegistry` class). A top-level `new ListRegistry()` here would touch the
18
+ // class while it is still in its temporal dead zone if `registry.ts` is the
19
+ // module that loads first. Deferring construction to first read (after both
20
+ // modules finish evaluating) sidesteps the cycle.
21
+ let registry: ListRegistry | undefined;
22
+
23
+ /** Read the installed registry, or an empty (legacy-behaviour) default. */
24
+ export function getListRegistry(): ListRegistry {
25
+ if (registry === undefined) {
26
+ registry = new ListRegistry();
27
+ }
28
+ return registry;
29
+ }
30
+
31
+ /** Install the process registry (called by `buildListRegistry`). */
32
+ export function setListRegistry(next: ListRegistry): void {
33
+ registry = next;
34
+ }
35
+
36
+ /** Reset to an empty registry — for test cleanup. */
37
+ export function resetListRegistry(): void {
38
+ registry = new ListRegistry();
39
+ }
@@ -0,0 +1,95 @@
1
+ import { parseEnabledFilter } from "../journeys/registry.js";
2
+ import type { DefinedList, ListMeta } from "./define-list.js";
3
+ import { setListRegistry } from "./registry-singleton.js";
4
+
5
+ /**
6
+ * In-process index of the defined email lists (D3). Mirrors `BucketRegistry` /
7
+ * `JourneyRegistry`: a plain id-keyed map plus the polarity helpers that are the
8
+ * SINGLE SOURCE OF TRUTH for "is this category subscribed", consumed by the
9
+ * mailer's suppression check AND the preference-center render so the two never
10
+ * drift.
11
+ *
12
+ * A list shares the `email_preferences.categories` JSONB key namespace with the
13
+ * built-in `transactional`/`journey` categories. The registry only knows about
14
+ * defined lists — an UNKNOWN id resolves to legacy opt-in behaviour via
15
+ * {@link ListRegistry.isSubscribedByDefault} (`?? true`).
16
+ */
17
+ export class ListRegistry {
18
+ private lists: Map<string, ListMeta> = new Map();
19
+
20
+ register(list: ListMeta): void {
21
+ this.lists.set(list.id, list);
22
+ }
23
+
24
+ get(id: string): ListMeta | undefined {
25
+ return this.lists.get(id);
26
+ }
27
+
28
+ getAll(): ListMeta[] {
29
+ return Array.from(this.lists.values());
30
+ }
31
+
32
+ getEnabled(): ListMeta[] {
33
+ return this.getAll().filter((l) => l.enabled);
34
+ }
35
+
36
+ has(id: string): boolean {
37
+ return this.lists.has(id);
38
+ }
39
+
40
+ count(): number {
41
+ return this.lists.size;
42
+ }
43
+
44
+ /**
45
+ * The list's default polarity. An unknown id (not a defined list — e.g. a
46
+ * built-in `transactional`/`journey` category, or a stale list) defaults to
47
+ * `true` (opt-in), preserving legacy behaviour: blocked only on explicit
48
+ * `false`.
49
+ */
50
+ isSubscribedByDefault(id: string): boolean {
51
+ return this.get(id)?.defaultOptIn ?? true;
52
+ }
53
+
54
+ /**
55
+ * The LOCKED polarity rule (§2.6). Given the stored `categories` map and a
56
+ * category id, decide whether the recipient is subscribed:
57
+ * - opt-in default (`defaultOptIn true`): subscribed unless explicitly `false`
58
+ * - opt-out default (`defaultOptIn false`): subscribed only when explicitly `true`
59
+ *
60
+ * Unknown ids fall through to opt-in default (via
61
+ * {@link ListRegistry.isSubscribedByDefault}), matching legacy semantics.
62
+ */
63
+ isSubscribed(categories: Record<string, boolean>, id: string): boolean {
64
+ const defaultOptIn = this.isSubscribedByDefault(id);
65
+ return defaultOptIn ? categories[id] !== false : categories[id] === true;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Build a {@link ListRegistry} from an injected array of lists, applying the
71
+ * enabled filter, and install it as the process singleton (so the mailer's
72
+ * suppression check and the preference center can resolve it). Returns the
73
+ * registry.
74
+ *
75
+ * `parseEnabledFilter` (journeys/registry.ts) is reused as-is — `ENABLED_LISTS`
76
+ * honours the same `"*"`-or-csv contract as `ENABLED_JOURNEYS` /
77
+ * `ENABLED_BUCKETS`. Disabled lists (filtered out OR `enabled: false`) are NOT
78
+ * registered, so an unknown id resolves to legacy opt-in.
79
+ */
80
+ export function buildListRegistry(
81
+ lists: DefinedList[],
82
+ enabledFilter?: string,
83
+ ): ListRegistry {
84
+ const registry = new ListRegistry();
85
+ const enabled = parseEnabledFilter(enabledFilter);
86
+
87
+ for (const list of lists) {
88
+ if (enabled === "*" || enabled.has(list.meta.id)) {
89
+ registry.register(list.meta);
90
+ }
91
+ }
92
+
93
+ setListRegistry(registry);
94
+ return registry;
95
+ }
@@ -16,6 +16,38 @@ const SCOPE_HIERARCHY: Record<string, number> = {
16
16
  "full-admin": 2,
17
17
  };
18
18
 
19
+ /**
20
+ * Single source of truth for scope checks.
21
+ *
22
+ * - Hierarchical scopes (`read` < `journey-admin` < `full-admin`): the key
23
+ * passes when the MAX hierarchical rank it holds is >= the required rank.
24
+ * - Orthogonal scopes (e.g. `ingest`): NOT part of the hierarchy. A key must
25
+ * either be granted the scope explicitly OR hold `full-admin` (which implies
26
+ * every orthogonal data-plane scope).
27
+ *
28
+ * This fixes the latent bug where an orthogonal required scope was looked up in
29
+ * SCOPE_HIERARCHY (`?? 0`), letting ANY authenticated key satisfy it.
30
+ */
31
+ export function hasScope(keyScopes: string[], required: string): boolean {
32
+ const requiredRank = SCOPE_HIERARCHY[required];
33
+
34
+ if (requiredRank === undefined) {
35
+ // Orthogonal scope (e.g. "ingest"): explicit grant or full-admin implies it.
36
+ return keyScopes.includes(required) || keyScopes.includes("full-admin");
37
+ }
38
+
39
+ // Hierarchical scope: highest rank held must clear the required rank.
40
+ let maxRank = Number.NEGATIVE_INFINITY;
41
+ for (const scope of keyScopes) {
42
+ const rank = SCOPE_HIERARCHY[scope];
43
+ if (rank !== undefined && rank > maxRank) {
44
+ maxRank = rank;
45
+ }
46
+ }
47
+
48
+ return maxRank >= requiredRank;
49
+ }
50
+
19
51
  const KEY_CACHE = new Map<
20
52
  string,
21
53
  {
@@ -108,19 +140,13 @@ export const requireApiKey = createMiddleware<AppEnv>(async (c, next) => {
108
140
  });
109
141
 
110
142
  export function requireScope(scope: string) {
111
- const required = SCOPE_HIERARCHY[scope] ?? 0;
112
-
113
143
  return createMiddleware<AppEnv>(async (c, next) => {
114
144
  const apiKey = c.get("apiKey");
115
145
  if (!apiKey) {
116
146
  return c.json({ error: "Unauthorized" }, 401);
117
147
  }
118
148
 
119
- const maxScope = Math.max(
120
- ...apiKey.scopes.map((s: string) => SCOPE_HIERARCHY[s] ?? 0),
121
- );
122
-
123
- if (maxScope < required) {
149
+ if (!hasScope(apiKey.scopes, scope)) {
124
150
  return c.json({ error: "Forbidden: insufficient scope" }, 403);
125
151
  }
126
152
 
@@ -2,64 +2,88 @@ import { createMiddleware } from "hono/factory";
2
2
  import type { AppEnv } from "../app.js";
3
3
  import { getRedis } from "../lib/redis.js";
4
4
 
5
- const WINDOW_MS = 60_000;
6
- const MAX_REQUESTS = 100;
7
-
8
- const memoryStore = new Map<string, number[]>();
9
- let cleanupCounter = 0;
5
+ const DEFAULT_WINDOW_MS = 60_000;
6
+ const DEFAULT_MAX_REQUESTS = 100;
7
+ const DEFAULT_PREFIX = "ratelimit";
10
8
  const MAX_KEYS = 10_000;
11
9
 
12
- export const rateLimit = createMiddleware<AppEnv>(async (c, next) => {
13
- if (process.env.NODE_ENV === "test") return next();
10
+ export interface RateLimitOptions {
11
+ windowMs?: number;
12
+ max?: number;
13
+ prefix?: string;
14
+ }
14
15
 
15
- const apiKey = c.get("apiKey");
16
- const keyId = apiKey?.id ?? c.get("user")?.id ?? "anonymous";
17
- const now = Date.now();
16
+ /**
17
+ * Build a sliding-window rate-limit middleware.
18
+ *
19
+ * Each instance owns an isolated in-memory fallback store (keyed by `prefix`)
20
+ * and a distinct Redis key namespace, so two middlewares with different
21
+ * prefixes (e.g. "ratelimit" vs "ratelimit:emails") never share a budget —
22
+ * an email send must not consume the contact-upsert sliding window.
23
+ */
24
+ export function createRateLimit(opts: RateLimitOptions = {}) {
25
+ const windowMs = opts.windowMs ?? DEFAULT_WINDOW_MS;
26
+ const max = opts.max ?? DEFAULT_MAX_REQUESTS;
27
+ const prefix = opts.prefix ?? DEFAULT_PREFIX;
18
28
 
19
- let count: number;
29
+ // Per-instance memory store so prefixes stay budget-isolated in the
30
+ // Redis-less fallback path too.
31
+ const memoryStore = new Map<string, number[]>();
32
+ let cleanupCounter = 0;
20
33
 
21
- try {
22
- const redis = getRedis();
23
- if (redis) {
24
- const windowKey = `ratelimit:${keyId}`;
25
- const pipeline = redis.pipeline();
26
- pipeline.zremrangebyscore(windowKey, 0, now - WINDOW_MS);
27
- pipeline.zcard(windowKey);
28
- pipeline.zadd(windowKey, now, `${now}:${Math.random()}`);
29
- pipeline.expire(windowKey, Math.ceil(WINDOW_MS / 1000));
34
+ return createMiddleware<AppEnv>(async (c, next) => {
35
+ if (process.env.NODE_ENV === "test") return next();
30
36
 
31
- const results = await pipeline.exec();
32
- count = (results?.[1]?.[1] as number) ?? 0;
33
- } else {
34
- throw new Error("No Redis");
35
- }
36
- } catch {
37
- const entries = memoryStore.get(keyId) ?? [];
38
- const cutoff = now - WINDOW_MS;
39
- const valid = entries.filter((t) => t > cutoff);
40
- valid.push(now);
41
- memoryStore.set(keyId, valid);
42
- count = valid.length - 1;
37
+ const apiKey = c.get("apiKey");
38
+ const keyId = apiKey?.id ?? c.get("user")?.id ?? "anonymous";
39
+ const now = Date.now();
40
+
41
+ let count: number;
42
+
43
+ try {
44
+ const redis = getRedis();
45
+ if (redis) {
46
+ const windowKey = `${prefix}:${keyId}`;
47
+ const pipeline = redis.pipeline();
48
+ pipeline.zremrangebyscore(windowKey, 0, now - windowMs);
49
+ pipeline.zcard(windowKey);
50
+ pipeline.zadd(windowKey, now, `${now}:${Math.random()}`);
51
+ pipeline.expire(windowKey, Math.ceil(windowMs / 1000));
43
52
 
44
- if (++cleanupCounter % 100 === 0 && memoryStore.size > MAX_KEYS) {
45
- const sweepCutoff = now - WINDOW_MS;
46
- for (const [key, entries] of memoryStore) {
47
- const active = entries.filter((t) => t > sweepCutoff);
48
- if (active.length === 0) memoryStore.delete(key);
49
- else memoryStore.set(key, active);
53
+ const results = await pipeline.exec();
54
+ count = (results?.[1]?.[1] as number) ?? 0;
55
+ } else {
56
+ throw new Error("No Redis");
57
+ }
58
+ } catch {
59
+ const entries = memoryStore.get(keyId) ?? [];
60
+ const cutoff = now - windowMs;
61
+ const valid = entries.filter((t) => t > cutoff);
62
+ valid.push(now);
63
+ memoryStore.set(keyId, valid);
64
+ count = valid.length - 1;
65
+
66
+ if (++cleanupCounter % 100 === 0 && memoryStore.size > MAX_KEYS) {
67
+ const sweepCutoff = now - windowMs;
68
+ for (const [key, ts] of memoryStore) {
69
+ const active = ts.filter((t) => t > sweepCutoff);
70
+ if (active.length === 0) memoryStore.delete(key);
71
+ else memoryStore.set(key, active);
72
+ }
50
73
  }
51
74
  }
52
- }
53
75
 
54
- c.header("X-RateLimit-Limit", String(MAX_REQUESTS));
55
- c.header(
56
- "X-RateLimit-Remaining",
57
- String(Math.max(0, MAX_REQUESTS - count - 1)),
58
- );
76
+ c.header("X-RateLimit-Limit", String(max));
77
+ c.header("X-RateLimit-Remaining", String(Math.max(0, max - count - 1)));
78
+
79
+ if (count >= max) {
80
+ // SDKs map Retry-After to RateLimitError.retryAfter (seconds).
81
+ c.header("Retry-After", String(Math.ceil(windowMs / 1000)));
82
+ return c.json({ error: "Rate limit exceeded" }, 429);
83
+ }
59
84
 
60
- if (count >= MAX_REQUESTS) {
61
- return c.json({ error: "Rate limit exceeded" }, 429);
62
- }
85
+ return next();
86
+ });
87
+ }
63
88
 
64
- return next();
65
- });
89
+ export const rateLimit = createRateLimit();
@@ -0,0 +1,30 @@
1
+ import type { Context } from "hono";
2
+
3
+ /**
4
+ * The data-plane identity guard shared by `/v1/contacts`, `/v1/events`,
5
+ * `/v1/emails`, and `/v1/lists`: a request must carry at least one resolvable
6
+ * identity key. Returns the 400 JSON response when BOTH keys are absent (so the
7
+ * caller can `return guard;`), or `null` to proceed.
8
+ *
9
+ * `/v1/emails` names its recipient field `to` rather than `email`; pass
10
+ * `{ field: "to" }` so the 400 message matches that route's wording exactly.
11
+ */
12
+ export function requireIdentity(
13
+ c: Context,
14
+ identity: { email?: string; userId?: string },
15
+ opts?: { field?: "email" | "to" },
16
+ ) {
17
+ if (!identity.email && !identity.userId) {
18
+ const field = opts?.field ?? "email";
19
+ return c.json({ error: `${field} or userId is required` }, 400);
20
+ }
21
+ return null;
22
+ }
23
+
24
+ /**
25
+ * Extract a human-readable message from an `applyListMembership` failure,
26
+ * matching the wording every data-plane route used inline.
27
+ */
28
+ export function listMembershipError(err: unknown): string {
29
+ return err instanceof Error ? err.message : "Failed to apply list membership";
30
+ }
@@ -58,7 +58,7 @@ const createKeyRoute = createRoute({
58
58
  schema: z.object({
59
59
  name: z.string().min(1).max(100),
60
60
  scopes: z
61
- .array(z.enum(["read", "journey-admin", "full-admin"]))
61
+ .array(z.enum(["read", "journey-admin", "full-admin", "ingest"]))
62
62
  .min(1)
63
63
  .default(["read"]),
64
64
  expiresAt: z.string().datetime().optional(),
@@ -123,6 +123,8 @@ const getRoute = createRoute({
123
123
  id: z.string(),
124
124
  name: z.string(),
125
125
  trigger: z.string(),
126
+ sourceBucketId: z.string().nullable(),
127
+ owned: z.boolean(),
126
128
  }),
127
129
  ),
128
130
  recentMembers: z.array(memberSchema),
@@ -332,27 +334,55 @@ export const bucketsRouter = new OpenAPIHono<AppEnv>()
332
334
  counts[row.status as keyof typeof emptyCounts] = row.count;
333
335
  }
334
336
 
335
- // Which journeys this bucket feeds cross-reference the bucket's emitted
336
- // transition events against the journey registry's trigger index. A journey
337
- // bound to the per-bucket alias `bucket:entered:<id>` (the recommended,
338
- // narrowly-routed binding) or the generic `bucket:entered` is woken by this
339
- // bucket's joins.
337
+ // Which journeys this bucket feeds. Two sources, owned-first:
338
+ // 1. Owned reactions journeys generated by `bucket.on()`, tagged with
339
+ // `sourceBucketId === id`. Discovered by scanning the registry; surfaced
340
+ // with `owned: true`.
341
+ // 2. External bindings — hand-written journeys bound to the bucket's emitted
342
+ // transition events via the per-bucket alias `bucket:entered:<id>` (the
343
+ // recommended, narrowly-routed binding) or the generic `bucket:entered`.
344
+ // Surfaced with `owned: false`. Owned wins on collision.
345
+ const feedsMap = new Map<
346
+ string,
347
+ {
348
+ id: string;
349
+ name: string;
350
+ trigger: string;
351
+ sourceBucketId: string | null;
352
+ owned: boolean;
353
+ }
354
+ >();
355
+
356
+ // Owned reactions: scan the journey registry for sourceBucketId === id.
357
+ for (const journey of registry
358
+ .getAll()
359
+ .filter((j) => j.sourceBucketId === id)) {
360
+ feedsMap.set(journey.id, {
361
+ id: journey.id,
362
+ name: journey.name,
363
+ trigger: journey.trigger.event,
364
+ sourceBucketId: id,
365
+ owned: true,
366
+ });
367
+ }
368
+
369
+ // External bindings: the existing alias + generic cross-reference. Skip any
370
+ // journey already surfaced as owned (owned wins).
340
371
  const feedEvents = [
341
372
  `bucket:entered:${id}`,
342
373
  `bucket:left:${id}`,
343
374
  "bucket:entered",
344
375
  "bucket:left",
345
376
  ];
346
- const feedsMap = new Map<
347
- string,
348
- { id: string; name: string; trigger: string }
349
- >();
350
377
  for (const evt of feedEvents) {
351
378
  for (const journey of registry.getByTriggerEvent(evt)) {
379
+ if (feedsMap.has(journey.id)) continue;
352
380
  feedsMap.set(journey.id, {
353
381
  id: journey.id,
354
382
  name: journey.name,
355
383
  trigger: evt,
384
+ sourceBucketId: journey.sourceBucketId ?? null,
385
+ owned: false,
356
386
  });
357
387
  }
358
388
  }
@@ -308,7 +308,7 @@ export const bulkRouter = new OpenAPIHono<AppEnv>()
308
308
  const header = "externalId,email,properties,firstSeenAt,lastSeenAt";
309
309
  const csvRows = rows.map(
310
310
  (r) =>
311
- `${r.externalId},${r.email ?? ""},${JSON.stringify(r.properties ?? {}).replace(/,/g, ";")},${r.firstSeenAt.toISOString()},${r.lastSeenAt.toISOString()}`,
311
+ `${r.externalId ?? ""},${r.email ?? ""},${JSON.stringify(r.properties ?? {}).replace(/,/g, ";")},${r.firstSeenAt.toISOString()},${r.lastSeenAt.toISOString()}`,
312
312
  );
313
313
  const csv = [header, ...csvRows].join("\n");
314
314
 
@@ -389,7 +389,9 @@ export const bulkRouter = new OpenAPIHono<AppEnv>()
389
389
  event: event.event,
390
390
  userId: event.userId,
391
391
  userEmail: "",
392
- properties: (event.properties as Record<string, unknown>) ?? {},
392
+ // Stored `user_events.properties` is the event-property bag (D2).
393
+ eventProperties:
394
+ (event.properties as Record<string, unknown>) ?? {},
393
395
  },
394
396
  }),
395
397
  ),
@@ -481,7 +483,9 @@ export const bulkRouter = new OpenAPIHono<AppEnv>()
481
483
  event: journey.trigger.event,
482
484
  userId: user.userId,
483
485
  userEmail: user.userEmail,
484
- properties: user.properties ?? {},
486
+ // Public batch-enroll request field stays `properties`
487
+ // (decision #14); maps to the event-property bag (D2).
488
+ eventProperties: user.properties ?? {},
485
489
  },
486
490
  }),
487
491
  ),
@@ -5,12 +5,15 @@ import type { AppEnv } from "../../app.js";
5
5
  import {
6
6
  contactSearchFilter,
7
7
  resolveContact,
8
+ resolveOrCreateContact,
9
+ serializeContact as serializeContactRow,
8
10
  serializePrefs,
9
11
  } from "../../lib/contacts.js";
10
12
 
11
13
  const contactSchema = z.object({
12
14
  id: z.string(),
13
- externalId: z.string(),
15
+ externalId: z.string().nullable(),
16
+ anonymousId: z.string().nullable(),
14
17
  email: z.string().nullable(),
15
18
  properties: z.record(z.string(), z.unknown()),
16
19
  firstSeenAt: z.string(),
@@ -95,16 +98,20 @@ const createRoute_ = createRoute({
95
98
  method: "post",
96
99
  path: "/",
97
100
  tags: ["Admin"],
98
- summary: "Create a contact",
101
+ summary: "Create or upsert a contact",
99
102
  request: {
100
103
  body: {
101
104
  content: {
102
105
  "application/json": {
103
- schema: z.object({
104
- externalId: z.string().min(1),
105
- email: z.string().email().optional(),
106
- properties: z.record(z.string(), z.unknown()).optional(),
107
- }),
106
+ schema: z
107
+ .object({
108
+ externalId: z.string().min(1).optional(),
109
+ email: z.string().email().optional(),
110
+ properties: z.record(z.string(), z.unknown()).optional(),
111
+ })
112
+ .refine((b) => Boolean(b.externalId || b.email), {
113
+ message: "Provide at least one of externalId or email",
114
+ }),
108
115
  },
109
116
  },
110
117
  },
@@ -116,15 +123,15 @@ const createRoute_ = createRoute({
116
123
  schema: z.object({ contact: contactSchema }),
117
124
  },
118
125
  },
119
- description: "Contact created",
126
+ description: "Contact created or upserted",
120
127
  },
121
- 409: {
128
+ 400: {
122
129
  content: {
123
130
  "application/json": {
124
131
  schema: z.object({ error: z.string() }),
125
132
  },
126
133
  },
127
- description: "Contact with this externalId already exists",
134
+ description: "Missing identity (externalId or email required)",
128
135
  },
129
136
  },
130
137
  });
@@ -195,18 +202,8 @@ const deleteRoute = createRoute({
195
202
  },
196
203
  });
197
204
 
198
- function serializeContact(row: typeof contacts.$inferSelect) {
199
- return {
200
- id: row.id,
201
- externalId: row.externalId,
202
- email: row.email,
203
- properties: (row.properties ?? {}) as Record<string, unknown>,
204
- firstSeenAt: row.firstSeenAt.toISOString(),
205
- lastSeenAt: row.lastSeenAt.toISOString(),
206
- createdAt: row.createdAt.toISOString(),
207
- updatedAt: row.updatedAt.toISOString(),
208
- };
209
- }
205
+ const serializeContact = (row: typeof contacts.$inferSelect) =>
206
+ serializeContactRow(row, { includeAnonymousId: true });
210
207
 
211
208
  export const contactsRouter = new OpenAPIHono<AppEnv>()
212
209
  .openapi(listRoute, async (c) => {
@@ -249,10 +246,12 @@ export const contactsRouter = new OpenAPIHono<AppEnv>()
249
246
  return c.json({ error: "Contact not found" }, 404);
250
247
  }
251
248
 
249
+ // email_preferences.user_id uses external_id when present, else the contact
250
+ // uuid as the deterministic fallback (risk 10 — email-only contacts).
252
251
  const prefRows = await db
253
252
  .select()
254
253
  .from(emailPreferences)
255
- .where(eq(emailPreferences.userId, contact.externalId))
254
+ .where(eq(emailPreferences.userId, contact.externalId ?? contact.id))
256
255
  .limit(1);
257
256
 
258
257
  const prefs = prefRows[0] ? serializePrefs(prefRows[0]) : null;
@@ -266,28 +265,17 @@ export const contactsRouter = new OpenAPIHono<AppEnv>()
266
265
  const { db } = c.get("container");
267
266
  const body = c.req.valid("json");
268
267
 
269
- const existing = await db
270
- .select({ id: contacts.id })
271
- .from(contacts)
272
- .where(eq(contacts.externalId, body.externalId))
273
- .limit(1);
274
-
275
- if (existing.length > 0) {
276
- return c.json(
277
- { error: "Contact with this externalId already exists" },
278
- 409,
279
- );
280
- }
281
-
282
- const [created] = await db
283
- .insert(contacts)
284
- .values({
285
- externalId: body.externalId,
286
- email: body.email ?? null,
287
- properties: body.properties ?? {},
288
- })
289
- .returning();
268
+ // Delegate to the identity resolver (D1): it upserts/merges on the provided
269
+ // identity keys (externalId and/or email), so the hand-rolled existence
270
+ // check + raw insert + 409 are gone (§5). Read the row back to serialize.
271
+ const { id } = await resolveOrCreateContact({
272
+ db,
273
+ userId: body.externalId,
274
+ email: body.email,
275
+ contactProperties: body.properties,
276
+ });
290
277
 
278
+ const created = await resolveContact({ db, id });
291
279
  if (!created) {
292
280
  throw new Error("Failed to create contact");
293
281
  }
@@ -304,20 +292,41 @@ export const contactsRouter = new OpenAPIHono<AppEnv>()
304
292
  return c.json({ error: "Contact not found" }, 404);
305
293
  }
306
294
 
307
- const [updated] = await db
308
- .update(contacts)
309
- .set({
310
- ...(body.email !== undefined ? { email: body.email } : {}),
311
- ...(body.properties
312
- ? {
313
- properties: sql`COALESCE(${contacts.properties}, '{}'::jsonb) || ${JSON.stringify(body.properties)}::jsonb`,
314
- }
315
- : {}),
316
- updatedAt: new Date(),
317
- })
318
- .where(eq(contacts.id, current.id))
319
- .returning();
295
+ const hasIdentityKey = Boolean(
296
+ current.externalId || current.anonymousId || body.email || current.email,
297
+ );
298
+
299
+ if (hasIdentityKey) {
300
+ // Delegate the email-fill + property merge to the resolver, keyed on the
301
+ // contact's canonical identity so the COALESCE||patch merge lives in one
302
+ // place (§5). Passing the existing externalId/anonymousId/email keeps the
303
+ // resolver on the fill-in-link path; a NEW email that already belongs to
304
+ // another contact correctly merges (the partial-unique index would have
305
+ // rejected a blind set anyway).
306
+ await resolveOrCreateContact({
307
+ db,
308
+ userId: current.externalId ?? undefined,
309
+ email: body.email ?? current.email ?? undefined,
310
+ anonymousId: current.anonymousId ?? undefined,
311
+ contactProperties: body.properties,
312
+ });
313
+ } else {
314
+ // Degenerate contact with no identity keys (resolver requires >=1 key):
315
+ // update it directly by uuid.
316
+ await db
317
+ .update(contacts)
318
+ .set({
319
+ ...(body.properties
320
+ ? {
321
+ properties: sql`COALESCE(${contacts.properties}, '{}'::jsonb) || ${JSON.stringify(body.properties)}::jsonb`,
322
+ }
323
+ : {}),
324
+ updatedAt: new Date(),
325
+ })
326
+ .where(eq(contacts.id, current.id));
327
+ }
320
328
 
329
+ const updated = await resolveContact({ db, id });
321
330
  if (!updated) {
322
331
  throw new Error("Failed to update contact");
323
332
  }