@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
|
@@ -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
|
-
|
|
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
|
|
6
|
-
const
|
|
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
|
|
13
|
-
|
|
10
|
+
export interface RateLimitOptions {
|
|
11
|
+
windowMs?: number;
|
|
12
|
+
max?: number;
|
|
13
|
+
prefix?: string;
|
|
14
|
+
}
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
85
|
+
return next();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
63
88
|
|
|
64
|
-
|
|
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(),
|
package/src/routes/admin/bulk.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
129
|
+
400: {
|
|
122
130
|
content: {
|
|
123
131
|
"application/json": {
|
|
124
132
|
schema: z.object({ error: z.string() }),
|
|
125
133
|
},
|
|
126
134
|
},
|
|
127
|
-
description: "
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
308
|
-
.
|
|
309
|
-
|
|
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) => {
|