@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.
- package/package.json +6 -6
- package/src/buckets/bucket-access.ts +213 -0
- package/src/buckets/bucket-reactions.ts +225 -0
- package/src/buckets/check-membership.ts +35 -15
- package/src/buckets/define-bucket.ts +79 -8
- package/src/buckets/registry.ts +81 -0
- package/src/container.ts +69 -4
- package/src/env.ts +4 -0
- package/src/index.ts +27 -0
- package/src/journeys/journey-context.ts +5 -1
- package/src/lib/boot.ts +12 -2
- package/src/lib/bucket-emit.ts +49 -7
- package/src/lib/contacts.ts +1083 -18
- package/src/lib/email-service-types.ts +8 -0
- package/src/lib/ingestion.ts +63 -33
- package/src/lib/mailer.ts +1 -0
- package/src/lib/preferences.ts +106 -0
- package/src/lib/tracked.ts +159 -34
- package/src/lib/tracking-events.ts +1 -1
- 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/buckets.ts +39 -9
- package/src/routes/admin/bulk.ts +7 -3
- package/src/routes/admin/contacts.ts +66 -57
- package/src/routes/admin/events.ts +65 -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/campaigns/index.ts +252 -0
- package/src/routes/contacts/index.ts +188 -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 +222 -0
- package/src/worker.ts +25 -2
- package/src/workflows/bucket-backfill.ts +122 -22
- package/src/workflows/bucket-reconcile.ts +225 -12
- 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(),
|
|
@@ -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
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
// bucket's
|
|
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
|
}
|
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,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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
128
|
+
400: {
|
|
122
129
|
content: {
|
|
123
130
|
"application/json": {
|
|
124
131
|
schema: z.object({ error: z.string() }),
|
|
125
132
|
},
|
|
126
133
|
},
|
|
127
|
-
description: "
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
308
|
-
.
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
}
|