@hogsend/engine 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/package.json +7 -6
  2. package/src/app.ts +36 -1
  3. package/src/buckets/check-membership.ts +34 -15
  4. package/src/container.ts +33 -0
  5. package/src/env.ts +29 -0
  6. package/src/index.ts +47 -1
  7. package/src/journeys/define-journey.ts +26 -2
  8. package/src/journeys/journey-context.ts +5 -1
  9. package/src/lib/boot.ts +1 -1
  10. package/src/lib/bucket-emit.ts +47 -2
  11. package/src/lib/contacts.ts +1105 -18
  12. package/src/lib/email-service-types.ts +8 -0
  13. package/src/lib/ingestion.ts +63 -33
  14. package/src/lib/mailer.ts +88 -0
  15. package/src/lib/outbound.ts +216 -0
  16. package/src/lib/preferences.ts +137 -0
  17. package/src/lib/tracked.ts +204 -37
  18. package/src/lib/tracking-events.ts +67 -2
  19. package/src/lib/webhook-signing.ts +151 -0
  20. package/src/lists/define-list.ts +81 -0
  21. package/src/lists/registry-singleton.ts +39 -0
  22. package/src/lists/registry.ts +95 -0
  23. package/src/middleware/api-key.ts +33 -7
  24. package/src/middleware/rate-limit.ts +73 -49
  25. package/src/routes/_shared.ts +30 -0
  26. package/src/routes/admin/api-keys.ts +1 -1
  27. package/src/routes/admin/bulk.ts +7 -3
  28. package/src/routes/admin/contacts.ts +108 -59
  29. package/src/routes/admin/events.ts +65 -0
  30. package/src/routes/admin/index.ts +2 -0
  31. package/src/routes/admin/journeys.ts +3 -1
  32. package/src/routes/admin/preferences.ts +2 -2
  33. package/src/routes/admin/reporting.ts +3 -3
  34. package/src/routes/admin/timeline.ts +5 -2
  35. package/src/routes/admin/webhooks.ts +466 -0
  36. package/src/routes/campaigns/index.ts +252 -0
  37. package/src/routes/contacts/index.ts +231 -0
  38. package/src/routes/email/preferences.ts +27 -3
  39. package/src/routes/email/unsubscribe.ts +7 -49
  40. package/src/routes/emails/index.ts +133 -0
  41. package/src/routes/events/index.ts +119 -0
  42. package/src/routes/index.ts +52 -2
  43. package/src/routes/lists/index.ts +258 -0
  44. package/src/routes/tracking/click.ts +59 -18
  45. package/src/routes/tracking/open.ts +62 -24
  46. package/src/routes/webhooks/sources.ts +69 -10
  47. package/src/webhook-sources/define-webhook-source.ts +57 -5
  48. package/src/webhook-sources/presets/clerk.ts +185 -0
  49. package/src/webhook-sources/presets/index.ts +80 -0
  50. package/src/webhook-sources/presets/segment.ts +120 -0
  51. package/src/webhook-sources/presets/stripe.ts +147 -0
  52. package/src/webhook-sources/presets/supabase.ts +131 -0
  53. package/src/webhook-sources/verify.ts +172 -0
  54. package/src/worker.ts +12 -0
  55. package/src/workflows/bucket-backfill.ts +32 -21
  56. package/src/workflows/bucket-reconcile.ts +20 -5
  57. package/src/workflows/deliver-webhook.ts +399 -0
  58. package/src/workflows/import-contacts.ts +28 -20
  59. package/src/workflows/send-campaign.ts +589 -0
  60. package/src/routes/ingest.ts +0 -71
@@ -0,0 +1,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(),
@@ -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,16 @@ 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";
12
+ import { emitOutbound } from "../../lib/outbound.js";
10
13
 
11
14
  const contactSchema = z.object({
12
15
  id: z.string(),
13
- externalId: z.string(),
16
+ externalId: z.string().nullable(),
17
+ anonymousId: z.string().nullable(),
14
18
  email: z.string().nullable(),
15
19
  properties: z.record(z.string(), z.unknown()),
16
20
  firstSeenAt: z.string(),
@@ -95,16 +99,20 @@ const createRoute_ = createRoute({
95
99
  method: "post",
96
100
  path: "/",
97
101
  tags: ["Admin"],
98
- summary: "Create a contact",
102
+ summary: "Create or upsert a contact",
99
103
  request: {
100
104
  body: {
101
105
  content: {
102
106
  "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
- }),
107
+ schema: z
108
+ .object({
109
+ externalId: z.string().min(1).optional(),
110
+ email: z.string().email().optional(),
111
+ properties: z.record(z.string(), z.unknown()).optional(),
112
+ })
113
+ .refine((b) => Boolean(b.externalId || b.email), {
114
+ message: "Provide at least one of externalId or email",
115
+ }),
108
116
  },
109
117
  },
110
118
  },
@@ -116,15 +124,15 @@ const createRoute_ = createRoute({
116
124
  schema: z.object({ contact: contactSchema }),
117
125
  },
118
126
  },
119
- description: "Contact created",
127
+ description: "Contact created or upserted",
120
128
  },
121
- 409: {
129
+ 400: {
122
130
  content: {
123
131
  "application/json": {
124
132
  schema: z.object({ error: z.string() }),
125
133
  },
126
134
  },
127
- description: "Contact with this externalId already exists",
135
+ description: "Missing identity (externalId or email required)",
128
136
  },
129
137
  },
130
138
  });
@@ -195,18 +203,8 @@ const deleteRoute = createRoute({
195
203
  },
196
204
  });
197
205
 
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
- }
206
+ const serializeContact = (row: typeof contacts.$inferSelect) =>
207
+ serializeContactRow(row, { includeAnonymousId: true });
210
208
 
211
209
  export const contactsRouter = new OpenAPIHono<AppEnv>()
212
210
  .openapi(listRoute, async (c) => {
@@ -249,10 +247,12 @@ export const contactsRouter = new OpenAPIHono<AppEnv>()
249
247
  return c.json({ error: "Contact not found" }, 404);
250
248
  }
251
249
 
250
+ // email_preferences.user_id uses external_id when present, else the contact
251
+ // uuid as the deterministic fallback (risk 10 — email-only contacts).
252
252
  const prefRows = await db
253
253
  .select()
254
254
  .from(emailPreferences)
255
- .where(eq(emailPreferences.userId, contact.externalId))
255
+ .where(eq(emailPreferences.userId, contact.externalId ?? contact.id))
256
256
  .limit(1);
257
257
 
258
258
  const prefs = prefRows[0] ? serializePrefs(prefRows[0]) : null;
@@ -263,39 +263,49 @@ export const contactsRouter = new OpenAPIHono<AppEnv>()
263
263
  );
264
264
  })
265
265
  .openapi(createRoute_, async (c) => {
266
- const { db } = c.get("container");
266
+ const { db, hatchet, logger } = c.get("container");
267
267
  const body = c.req.valid("json");
268
268
 
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();
269
+ // Delegate to the identity resolver (D1): it upserts/merges on the provided
270
+ // identity keys (externalId and/or email), so the hand-rolled existence
271
+ // check + raw insert + 409 are gone (§5). Read the row back to serialize.
272
+ const {
273
+ id,
274
+ created: wasCreated,
275
+ linked,
276
+ merged,
277
+ } = await resolveOrCreateContact({
278
+ db,
279
+ userId: body.externalId,
280
+ email: body.email,
281
+ contactProperties: body.properties,
282
+ });
290
283
 
284
+ const created = await resolveContact({ db, id });
291
285
  if (!created) {
292
286
  throw new Error("Failed to create contact");
293
287
  }
294
288
 
289
+ // INTENT-LAYER outbound emit (decision #3): admin upsert mirrors the public
290
+ // route — `contact.created` on a real creation, `contact.updated` when an
291
+ // existing contact was linked/merged with a non-empty property delta.
292
+ const hadPropertyDelta = Boolean(
293
+ body.properties && Object.keys(body.properties).length > 0,
294
+ );
295
+ if (wasCreated || (linked || merged ? hadPropertyDelta : false)) {
296
+ void emitOutbound({
297
+ db,
298
+ hatchet,
299
+ logger,
300
+ event: wasCreated ? "contact.created" : "contact.updated",
301
+ payload: serializeContactRow(created),
302
+ }).catch(logger.warn);
303
+ }
304
+
295
305
  return c.json({ contact: serializeContact(created) }, 201);
296
306
  })
297
307
  .openapi(updateRoute, async (c) => {
298
- const { db } = c.get("container");
308
+ const { db, hatchet, logger } = c.get("container");
299
309
  const { id } = c.req.valid("param");
300
310
  const body = c.req.valid("json");
301
311
 
@@ -304,24 +314,63 @@ export const contactsRouter = new OpenAPIHono<AppEnv>()
304
314
  return c.json({ error: "Contact not found" }, 404);
305
315
  }
306
316
 
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();
317
+ const hasIdentityKey = Boolean(
318
+ current.externalId || current.anonymousId || body.email || current.email,
319
+ );
320
320
 
321
+ if (hasIdentityKey) {
322
+ // Delegate the email-fill + property merge to the resolver, keyed on the
323
+ // contact's canonical identity so the COALESCE||patch merge lives in one
324
+ // place (§5). Passing the existing externalId/anonymousId/email keeps the
325
+ // resolver on the fill-in-link path; a NEW email that already belongs to
326
+ // another contact correctly merges (the partial-unique index would have
327
+ // rejected a blind set anyway).
328
+ await resolveOrCreateContact({
329
+ db,
330
+ userId: current.externalId ?? undefined,
331
+ email: body.email ?? current.email ?? undefined,
332
+ anonymousId: current.anonymousId ?? undefined,
333
+ contactProperties: body.properties,
334
+ });
335
+ } else {
336
+ // Degenerate contact with no identity keys (resolver requires >=1 key):
337
+ // update it directly by uuid.
338
+ await db
339
+ .update(contacts)
340
+ .set({
341
+ ...(body.properties
342
+ ? {
343
+ properties: sql`COALESCE(${contacts.properties}, '{}'::jsonb) || ${JSON.stringify(body.properties)}::jsonb`,
344
+ }
345
+ : {}),
346
+ updatedAt: new Date(),
347
+ })
348
+ .where(eq(contacts.id, current.id));
349
+ }
350
+
351
+ const updated = await resolveContact({ db, id });
321
352
  if (!updated) {
322
353
  throw new Error("Failed to update contact");
323
354
  }
324
355
 
356
+ // INTENT-LAYER outbound emit (decision #3): the admin update is an explicit
357
+ // edit — emit `contact.updated` on a non-empty property delta or a filled
358
+ // email (a newly-attached identity). Fire-and-forget; the serialized updated
359
+ // row is the catalog payload.
360
+ const hadPropertyDelta = Boolean(
361
+ body.properties && Object.keys(body.properties).length > 0,
362
+ );
363
+ const filledEmail = Boolean(body.email && body.email !== current.email);
364
+ if (hadPropertyDelta || filledEmail) {
365
+ void emitOutbound({
366
+ db,
367
+ hatchet,
368
+ logger,
369
+ event: "contact.updated",
370
+ payload: serializeContactRow(updated),
371
+ }).catch(logger.warn);
372
+ }
373
+
325
374
  return c.json({ contact: serializeContact(updated) }, 200);
326
375
  })
327
376
  .openapi(deleteRoute, async (c) => {