@hogsend/engine 0.21.1 → 0.23.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 -7
- package/src/app.ts +37 -30
- package/src/connectors/define-connector.ts +205 -0
- package/src/connectors/presets/index.ts +31 -0
- package/src/connectors/registry-singleton.ts +79 -0
- package/src/container.ts +94 -0
- package/src/env.ts +5 -0
- package/src/index.ts +69 -0
- package/src/lib/analytics-identity.ts +112 -0
- package/src/lib/connector-link-codes.ts +218 -0
- package/src/lib/connector-state.ts +114 -0
- package/src/lib/contacts.ts +233 -26
- package/src/lib/discord-gateway-heartbeat.ts +164 -0
- package/src/lib/identity-service.ts +107 -0
- package/src/lib/identity-token.ts +65 -5
- package/src/lib/ingestion.ts +58 -2
- package/src/lib/outbound.ts +17 -0
- package/src/lib/provider-credentials.ts +11 -0
- package/src/lib/semantic-click.ts +15 -6
- package/src/lib/tracking-events.ts +5 -1
- package/src/lib/tracking.ts +37 -0
- package/src/lib/webhook-signing.ts +7 -1
- package/src/routes/admin/connectors.ts +466 -0
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/connectors/index.ts +279 -0
- package/src/routes/contacts/index.ts +7 -0
- package/src/routes/events/index.ts +16 -1
- package/src/routes/index.ts +17 -4
- package/src/routes/tracking/answer.ts +11 -4
- package/src/routes/tracking/click.ts +130 -71
- package/src/routes/tracking/identify.ts +62 -15
- package/src/routes/webhooks/index.ts +3 -3
- package/src/routes/webhooks/sources.ts +20 -10
- package/src/webhook-sources/define-webhook-source.ts +44 -39
- package/src/webhook-sources/verify.ts +4 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { AnalyticsProvider } from "@hogsend/core";
|
|
2
|
+
import type { Database } from "@hogsend/db";
|
|
3
|
+
import {
|
|
4
|
+
logResidualTwins,
|
|
5
|
+
mergeAnalyticsIdentities,
|
|
6
|
+
} from "./analytics-identity.js";
|
|
7
|
+
import { resolveOrCreateContact } from "./contacts.js";
|
|
8
|
+
import type { Logger } from "./logger.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Args for {@link IdentityService.linkContact} — the same identity-attach inputs
|
|
12
|
+
* `resolveOrCreateContact` accepts (at least one of `userId`/`email`/
|
|
13
|
+
* `anonymousId`/`discordId` is required by the resolver), minus the `db` (the
|
|
14
|
+
* service closes over the container's db).
|
|
15
|
+
*/
|
|
16
|
+
export interface LinkContactArgs {
|
|
17
|
+
userId?: string;
|
|
18
|
+
email?: string;
|
|
19
|
+
anonymousId?: string;
|
|
20
|
+
discordId?: string;
|
|
21
|
+
contactProperties?: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The container-held identity helper (`client.identity`). It exists so any
|
|
26
|
+
* identity-attach OUTSIDE the `/v1/events` ingest path — most notably Discord
|
|
27
|
+
* `/link` (§7), but also any consumer wiring — folds two keys into one analytics
|
|
28
|
+
* person through the SAME engine emission used by `ingestEvent` (§5.3), rather
|
|
29
|
+
* than each consumer hand-rolling its own `resolveOrCreateContact` +
|
|
30
|
+
* `mergeIdentities` plumbing (the bespoke path the spec calls out as the bug).
|
|
31
|
+
*/
|
|
32
|
+
export interface IdentityService {
|
|
33
|
+
/**
|
|
34
|
+
* Resolve / merge a contact AND propagate the analytics merge in one call.
|
|
35
|
+
*
|
|
36
|
+
* Wraps `resolveOrCreateContact` (the resolver stays analytics-free — it takes
|
|
37
|
+
* only `db`) then, on a collide-MERGE or canonical-key flip that absorbed an
|
|
38
|
+
* anonymous/uuid key, fans out the provider-neutral `mergeIdentities` primitive
|
|
39
|
+
* via {@link mergeAnalyticsIdentities} with `reason: "discord_link"`. MF-2:
|
|
40
|
+
* `mergedKeys` already excludes identified `external_id`s (the resolver split
|
|
41
|
+
* them out) — only the safe anon/uuid keys are aliased; the excluded
|
|
42
|
+
* identified twins surface as `identity.merge.residual_twin` for observability.
|
|
43
|
+
*
|
|
44
|
+
* The SURVIVOR RULE makes `resolvedKey` the survivor (`distinctId`) and each
|
|
45
|
+
* loser its absorbed `alias` — e.g. on a Discord `/link` that merges the
|
|
46
|
+
* discord-keyed contact into the email contact, `distinctId = resolvedKey`
|
|
47
|
+
* (survivor, email/external) and `alias = <discord-contact uuid>` (the
|
|
48
|
+
* loser's anon/uuid key the Discord-platform events were captured under).
|
|
49
|
+
*
|
|
50
|
+
* Best-effort and analytics-non-load-bearing: the merge emission never throws
|
|
51
|
+
* (the helper swallows provider errors), so a missing/incapable provider
|
|
52
|
+
* no-ops cleanly — the contact resolve still happened and is returned.
|
|
53
|
+
*/
|
|
54
|
+
linkContact(args: LinkContactArgs): ReturnType<typeof resolveOrCreateContact>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build the {@link IdentityService} bound to a container's db + active analytics
|
|
59
|
+
* provider. `analytics` is undefined when nothing is configured (the merge
|
|
60
|
+
* emission no-ops); the resolver itself is unaffected.
|
|
61
|
+
*/
|
|
62
|
+
export function createIdentityService(deps: {
|
|
63
|
+
db: Database;
|
|
64
|
+
analytics?: AnalyticsProvider;
|
|
65
|
+
logger?: Logger;
|
|
66
|
+
}): IdentityService {
|
|
67
|
+
const { db, analytics, logger } = deps;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
async linkContact(args) {
|
|
71
|
+
const result = await resolveOrCreateContact({ db, ...args });
|
|
72
|
+
|
|
73
|
+
const {
|
|
74
|
+
id: contactId,
|
|
75
|
+
resolvedKey,
|
|
76
|
+
mergedKeys,
|
|
77
|
+
mergedIdentifiedKeys,
|
|
78
|
+
} = result;
|
|
79
|
+
|
|
80
|
+
// §5.3 emission point 1, reused (§7): fire the analytics merge ONLY when
|
|
81
|
+
// the resolver actually folded keys this call. MF-2: `mergedKeys` carries
|
|
82
|
+
// the safe anon/uuid losers (the discord-contact uuid on a `/link` merge);
|
|
83
|
+
// identified `external_id`s are excluded by the resolver and surfaced as
|
|
84
|
+
// residual twins below — never aliased (the merge PostHog refuses, R2/R4).
|
|
85
|
+
if (mergedKeys?.length) {
|
|
86
|
+
mergeAnalyticsIdentities({
|
|
87
|
+
analytics,
|
|
88
|
+
survivorKey: resolvedKey,
|
|
89
|
+
loserKeys: mergedKeys,
|
|
90
|
+
reason: "discord_link",
|
|
91
|
+
contactId,
|
|
92
|
+
logger,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (mergedIdentifiedKeys?.length) {
|
|
96
|
+
logResidualTwins({
|
|
97
|
+
survivorKey: resolvedKey,
|
|
98
|
+
identifiedLoserKeys: mergedIdentifiedKeys,
|
|
99
|
+
contactId,
|
|
100
|
+
logger,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return result;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -8,8 +8,12 @@ import {
|
|
|
8
8
|
/**
|
|
9
9
|
* Short-lived identity token appended to tracked-link redirects as `hs_t`
|
|
10
10
|
* (opt-in via TRACKING_IDENTITY_TOKEN). The landing site exchanges it at
|
|
11
|
-
* `POST /v1/t/identify
|
|
12
|
-
*
|
|
11
|
+
* `POST /v1/t/identify`, where the engine fires a SERVER-SIDE `alias` folding
|
|
12
|
+
* the caller's own anon session into the token's canonical id — stitching the
|
|
13
|
+
* click to the web session. Minted for EMAIL links by default; non-email
|
|
14
|
+
* (Discord/referral) links carry a token only when explicitly stitch-bearing
|
|
15
|
+
* (`tracked_links.distinct_id` set) — referral links are token-less by default
|
|
16
|
+
* (MF-4 anti-hijack).
|
|
13
17
|
*
|
|
14
18
|
* ENCRYPTED (AES-256-GCM keyed off BETTER_AUTH_SECRET), not merely signed:
|
|
15
19
|
* the distinct id can fall back to an email address, and a signed-but-
|
|
@@ -18,11 +22,38 @@ import {
|
|
|
18
22
|
* auth tag also covers integrity, so tampering fails decryption.
|
|
19
23
|
*/
|
|
20
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The only merge mode a token may authorize: fold the CALLER's own anonymous
|
|
27
|
+
* session INTO the token's canonical `distinctId`. There is deliberately no
|
|
28
|
+
* "become the subject" / overwrite mode — that is the anti-hijack invariant.
|
|
29
|
+
*/
|
|
30
|
+
export type IdentityTokenScope = "anon-absorb";
|
|
31
|
+
|
|
21
32
|
export interface IdentityTokenPayload {
|
|
22
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* The canonical contact key the landing site should fold INTO — the ONLY
|
|
35
|
+
* ever-identified id. NEVER a per-link or anonymous id.
|
|
36
|
+
*/
|
|
23
37
|
distinctId: string;
|
|
24
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Where the token was minted: `"email:<sendId>"` | `"link:<linkId>"`.
|
|
40
|
+
* Referral links are excluded by default (they carry no identity token).
|
|
41
|
+
*/
|
|
42
|
+
src: string;
|
|
43
|
+
/**
|
|
44
|
+
* The authorized merge mode. Only `"anon-absorb"` is ever minted. OPTIONAL on
|
|
45
|
+
* the wire for the rolling-deploy window (MF-7): a token minted by the still-old
|
|
46
|
+
* click route carries no `scope`, so `validateIdentityToken` treats a MISSING
|
|
47
|
+
* scope as `"anon-absorb"` (allow) and rejects only a PRESENT-and-wrong value.
|
|
48
|
+
*/
|
|
49
|
+
scope?: IdentityTokenScope;
|
|
25
50
|
exp: number;
|
|
51
|
+
/**
|
|
52
|
+
* @deprecated Alias of `src` for ONE minor (mirrors the `resendId` → `messageId`
|
|
53
|
+
* deprecation window). Old tokens carry only `emailSendId`; new email tokens
|
|
54
|
+
* carry both. Reads should prefer `src`.
|
|
55
|
+
*/
|
|
56
|
+
emailSendId?: string;
|
|
26
57
|
}
|
|
27
58
|
|
|
28
59
|
export class InvalidIdentityTokenError extends Error {
|
|
@@ -43,11 +74,27 @@ function deriveKey(secret: string): Buffer {
|
|
|
43
74
|
export function generateIdentityToken(opts: {
|
|
44
75
|
secret: string;
|
|
45
76
|
distinctId: string;
|
|
46
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Mint provenance: `"email:<sendId>"` | `"link:<linkId>"`. When omitted, falls
|
|
79
|
+
* back to `email:<emailSendId>` for the legacy email-link caller.
|
|
80
|
+
*/
|
|
81
|
+
src?: string;
|
|
82
|
+
/** Defaults to `"anon-absorb"` — the only mode a token may authorize. */
|
|
83
|
+
scope?: IdentityTokenScope;
|
|
84
|
+
/**
|
|
85
|
+
* @deprecated Pass `src` instead. Kept for the one-minor deprecation window so
|
|
86
|
+
* existing email-link callers compile unchanged; mirrored into the payload's
|
|
87
|
+
* deprecated `emailSendId` field and used to synthesize `src` when `src` is
|
|
88
|
+
* absent.
|
|
89
|
+
*/
|
|
90
|
+
emailSendId?: string;
|
|
47
91
|
expiresInSeconds?: number;
|
|
48
92
|
}): string {
|
|
93
|
+
const src = opts.src ?? (opts.emailSendId ? `email:${opts.emailSendId}` : "");
|
|
49
94
|
const payload: IdentityTokenPayload = {
|
|
50
95
|
distinctId: opts.distinctId,
|
|
96
|
+
src,
|
|
97
|
+
scope: opts.scope ?? "anon-absorb",
|
|
51
98
|
emailSendId: opts.emailSendId,
|
|
52
99
|
exp:
|
|
53
100
|
Math.floor(Date.now() / 1000) +
|
|
@@ -108,5 +155,18 @@ export function validateIdentityToken(opts: {
|
|
|
108
155
|
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
|
109
156
|
throw new InvalidIdentityTokenError("Token expired");
|
|
110
157
|
}
|
|
158
|
+
// MF-7 — missing-scope-ALLOW. The API and worker deploy independently from
|
|
159
|
+
// the same image, so a token minted by the still-old click route carries no
|
|
160
|
+
// `scope`. Treat a MISSING scope as the only legal mode (`"anon-absorb"`);
|
|
161
|
+
// reject ONLY a present-and-wrong value. Old tokens (no `scope`, no `src`)
|
|
162
|
+
// still validate — this check never widened the required-shape gate above.
|
|
163
|
+
if (payload.scope !== undefined && payload.scope !== "anon-absorb") {
|
|
164
|
+
throw new InvalidIdentityTokenError("Unsupported token scope");
|
|
165
|
+
}
|
|
166
|
+
// Backfill `src` from the deprecated `emailSendId` for old tokens, so the one
|
|
167
|
+
// response schema (`{ distinctId, src, emailSendId? }`) is always populated.
|
|
168
|
+
if (typeof payload.src !== "string" || payload.src.length === 0) {
|
|
169
|
+
payload.src = payload.emailSendId ? `email:${payload.emailSendId}` : "";
|
|
170
|
+
}
|
|
111
171
|
return payload;
|
|
112
172
|
}
|
package/src/lib/ingestion.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
2
|
+
import type { AnalyticsProvider } from "@hogsend/core";
|
|
2
3
|
import { evaluatePropertyConditions } from "@hogsend/core";
|
|
3
4
|
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
4
5
|
import { type Database, journeyStates, userEvents } from "@hogsend/db";
|
|
5
6
|
import { and, eq, inArray, isNull } from "drizzle-orm";
|
|
6
7
|
import { checkBucketMembership } from "../buckets/check-membership.js";
|
|
8
|
+
import {
|
|
9
|
+
logResidualTwins,
|
|
10
|
+
mergeAnalyticsIdentities,
|
|
11
|
+
} from "./analytics-identity.js";
|
|
7
12
|
import { resolveOrCreateContact } from "./contacts.js";
|
|
8
13
|
import type { Logger } from "./logger.js";
|
|
9
14
|
|
|
@@ -14,6 +19,11 @@ export interface IngestEvent {
|
|
|
14
19
|
userEmail?: string;
|
|
15
20
|
/** D1: future anonymous→identified path. Threaded into the resolver. */
|
|
16
21
|
anonymousId?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Discord user id (snowflake). Resolves a `discord`-keyed contact (a later
|
|
24
|
+
* per-member link merges it into the email contact).
|
|
25
|
+
*/
|
|
26
|
+
discordId?: string;
|
|
17
27
|
/** D2: → `user_events` + Hatchet `trigger.where`/`exitOn` ONLY. */
|
|
18
28
|
eventProperties: Record<string, unknown>;
|
|
19
29
|
/** D2: → `contacts.properties` merge ONLY. */
|
|
@@ -53,8 +63,17 @@ export async function ingestEvent(opts: {
|
|
|
53
63
|
hatchet: HatchetClient;
|
|
54
64
|
logger: Logger;
|
|
55
65
|
event: IngestEvent;
|
|
66
|
+
/**
|
|
67
|
+
* The active analytics provider (`c.get("container").analytics`). When the
|
|
68
|
+
* identity resolve folds two keys into one (collide-MERGE or canonical-key
|
|
69
|
+
* flip), the engine fires the provider-neutral `mergeIdentities` primitive so
|
|
70
|
+
* the analytics person store stitches the same way the contact store did
|
|
71
|
+
* (§5.3). Optional: absent ⇒ DB-only resolve (no stitch), exactly as before; a
|
|
72
|
+
* provider without `identityMerge` no-ops cleanly.
|
|
73
|
+
*/
|
|
74
|
+
analytics?: AnalyticsProvider;
|
|
56
75
|
}): Promise<IngestResult> {
|
|
57
|
-
const { db, registry, hatchet, logger, event } = opts;
|
|
76
|
+
const { db, registry, hatchet, logger, event, analytics } = opts;
|
|
58
77
|
|
|
59
78
|
// (1) Resolve identity FIRST (awaited — no longer fire-and-forget). The
|
|
60
79
|
// contact-referencing tables join on a NOT NULL text key, so an email-only /
|
|
@@ -63,11 +82,18 @@ export async function ingestEvent(opts: {
|
|
|
63
82
|
// `contacts.properties` (D2 split) and returns BOTH the canonical contact id
|
|
64
83
|
// AND its resolved string key (external_id ?? anonymous_id ?? contact.id —
|
|
65
84
|
// risk 1/6), so no second read-back of the contact row is needed.
|
|
66
|
-
const {
|
|
85
|
+
const {
|
|
86
|
+
id: contactId,
|
|
87
|
+
resolvedKey,
|
|
88
|
+
mergedKeys,
|
|
89
|
+
mergedIdentifiedKeys,
|
|
90
|
+
merged,
|
|
91
|
+
} = await resolveOrCreateContact({
|
|
67
92
|
db,
|
|
68
93
|
userId: event.userId,
|
|
69
94
|
email: event.userEmail || undefined,
|
|
70
95
|
anonymousId: event.anonymousId,
|
|
96
|
+
discordId: event.discordId,
|
|
71
97
|
contactProperties: event.contactProperties,
|
|
72
98
|
});
|
|
73
99
|
|
|
@@ -106,6 +132,36 @@ export async function ingestEvent(opts: {
|
|
|
106
132
|
});
|
|
107
133
|
}
|
|
108
134
|
|
|
135
|
+
// (2b) §5.3 — fire the provider-neutral identity merge at the two resolver
|
|
136
|
+
// outcomes where two keys fold into one (collide-MERGE or canonical-key flip).
|
|
137
|
+
// Placed INSIDE the idempotency-guarded block (after a FRESH insert; the
|
|
138
|
+
// duplicate path returned early above) so a Hatchet/client retry with the same
|
|
139
|
+
// idempotencyKey does NOT re-fire `alias` — honoring the "only at the moment
|
|
140
|
+
// two keys first become one" contract (PostHog `alias` is harmless on replay
|
|
141
|
+
// but firing per-retry adds queue noise). MF-2: `mergedKeys` already excludes
|
|
142
|
+
// identified `external_id`s (the resolver split them out); fire only the safe
|
|
143
|
+
// anon/uuid keys, and surface the excluded identified twins for observability.
|
|
144
|
+
if (mergedKeys?.length || mergedIdentifiedKeys?.length) {
|
|
145
|
+
if (mergedKeys?.length) {
|
|
146
|
+
mergeAnalyticsIdentities({
|
|
147
|
+
analytics,
|
|
148
|
+
survivorKey: resolvedKey,
|
|
149
|
+
loserKeys: mergedKeys,
|
|
150
|
+
reason: merged ? "collide_merge" : "key_flip",
|
|
151
|
+
contactId,
|
|
152
|
+
logger,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (mergedIdentifiedKeys?.length) {
|
|
156
|
+
logResidualTwins({
|
|
157
|
+
survivorKey: resolvedKey,
|
|
158
|
+
identifiedLoserKeys: mergedIdentifiedKeys,
|
|
159
|
+
contactId,
|
|
160
|
+
logger,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
109
165
|
// (3) Build the JSON-serializable subset of eventProperties for the Hatchet
|
|
110
166
|
// push payload (scalars only — the SDK serializes the envelope).
|
|
111
167
|
const serializableProperties = Object.fromEntries(
|
package/src/lib/outbound.ts
CHANGED
|
@@ -90,6 +90,23 @@ export interface OutboundPayloads {
|
|
|
90
90
|
"email.delivered": EmailEventPayload;
|
|
91
91
|
"email.opened": EmailEventPayload;
|
|
92
92
|
"email.clicked": EmailEventPayload & { linkUrl?: string; linkId?: string };
|
|
93
|
+
/**
|
|
94
|
+
* A NON-email tracked link was clicked (Discord/referral/ad-hoc
|
|
95
|
+
* `createTrackedLink`). The deliberate counterpart to `email.clicked` — a
|
|
96
|
+
* non-email click has no `email_sends` row, so it carries `emailSendId: null`
|
|
97
|
+
* and `messageId: null` and never masquerades as an email click
|
|
98
|
+
* (MF-missing #3). `userId` is the link's stitch subject (`distinct_id`) when
|
|
99
|
+
* the link is identity-bearing, else null for a broadcast link.
|
|
100
|
+
*/
|
|
101
|
+
"link.clicked": {
|
|
102
|
+
linkId: string;
|
|
103
|
+
source: string | null;
|
|
104
|
+
userId: string | null;
|
|
105
|
+
emailSendId: null;
|
|
106
|
+
messageId: null;
|
|
107
|
+
linkUrl: string;
|
|
108
|
+
at: string;
|
|
109
|
+
};
|
|
93
110
|
/**
|
|
94
111
|
* A SEMANTIC link answered — the in-email action event (consumer-named, e.g.
|
|
95
112
|
* "nps.submitted"). Emitted at most once per (send, event name): first
|
|
@@ -54,6 +54,17 @@ export interface DerivedCredentialPayload {
|
|
|
54
54
|
projectApiKey?: string;
|
|
55
55
|
projectId?: string;
|
|
56
56
|
privateHost?: string;
|
|
57
|
+
// --- Discord (kind="derived", providerId="discord") ---
|
|
58
|
+
// Server-derived during `hogsend connect discord`: the app id + public key
|
|
59
|
+
// are read by the admin connect-info / interactions routes; the guild id is
|
|
60
|
+
// captured from the bot-install OAuth grant; the bot token + client secret
|
|
61
|
+
// (when stored here rather than env) feed the gateway worker / code exchange.
|
|
62
|
+
// All optional — the store is provider-neutral and additive.
|
|
63
|
+
discordAppId?: string;
|
|
64
|
+
discordPublicKey?: string;
|
|
65
|
+
discordClientSecret?: string;
|
|
66
|
+
discordBotToken?: string;
|
|
67
|
+
discordGuildId?: string;
|
|
57
68
|
}
|
|
58
69
|
|
|
59
70
|
/** Row metadata — everything EXCEPT token material. Safe to surface. */
|
|
@@ -90,6 +90,15 @@ export async function confirmSemanticClick(
|
|
|
90
90
|
if (!link?.event) {
|
|
91
91
|
return { status: "skipped", reason: "not_semantic" };
|
|
92
92
|
}
|
|
93
|
+
// The confirm path is EMAIL-semantic end to end (it claims a send's answer
|
|
94
|
+
// slot keyed on `emailSendId` and emits `email.action`). The click route only
|
|
95
|
+
// enqueues this task for links with a non-null `emailSendId`, but `emailSendId`
|
|
96
|
+
// is nullable since the identity-stitching minor — guard defensively and
|
|
97
|
+
// narrow the type for the rest of the function.
|
|
98
|
+
if (!link.emailSendId) {
|
|
99
|
+
return { status: "skipped", reason: "non_email_link" };
|
|
100
|
+
}
|
|
101
|
+
const emailSendId = link.emailSendId;
|
|
93
102
|
const semanticEvent = link.event;
|
|
94
103
|
|
|
95
104
|
// (1) Let the burst window close before judging the click.
|
|
@@ -109,7 +118,7 @@ export async function confirmSemanticClick(
|
|
|
109
118
|
.innerJoin(trackedLinks, eq(linkClicks.trackedLinkId, trackedLinks.id))
|
|
110
119
|
.where(
|
|
111
120
|
and(
|
|
112
|
-
eq(trackedLinks.emailSendId,
|
|
121
|
+
eq(trackedLinks.emailSendId, emailSendId),
|
|
113
122
|
gte(linkClicks.clickedAt, windowStart),
|
|
114
123
|
lte(linkClicks.clickedAt, windowEnd),
|
|
115
124
|
),
|
|
@@ -117,7 +126,7 @@ export async function confirmSemanticClick(
|
|
|
117
126
|
const distinctLinks = burst[0]?.n ?? 0;
|
|
118
127
|
if (distinctLinks >= SEMANTIC_BURST_DISTINCT_LINKS) {
|
|
119
128
|
logger.warn("Semantic answer suppressed: scanner-like click burst", {
|
|
120
|
-
emailSendId
|
|
129
|
+
emailSendId,
|
|
121
130
|
linkId: link.id,
|
|
122
131
|
event: semanticEvent,
|
|
123
132
|
distinctLinks,
|
|
@@ -125,21 +134,21 @@ export async function confirmSemanticClick(
|
|
|
125
134
|
return { status: "suppressed", distinctLinks };
|
|
126
135
|
}
|
|
127
136
|
|
|
128
|
-
const ctx = await resolveEmailSendContext(db,
|
|
137
|
+
const ctx = await resolveEmailSendContext(db, emailSendId);
|
|
129
138
|
if (!ctx) {
|
|
130
139
|
return { status: "skipped", reason: "no_send_context" };
|
|
131
140
|
}
|
|
132
141
|
|
|
133
142
|
// (3) Claim the answer slot. Duplicate key → stored=false BEFORE the Hatchet
|
|
134
143
|
// push, so journeys/destinations see at most one answer per (send, event).
|
|
135
|
-
const semKey = `sem:${
|
|
144
|
+
const semKey = `sem:${emailSendId}:${semanticEvent}`;
|
|
136
145
|
const result = await pushTrackingEvent({
|
|
137
146
|
db,
|
|
138
147
|
hatchet,
|
|
139
148
|
registry,
|
|
140
149
|
logger,
|
|
141
150
|
event: semanticEvent,
|
|
142
|
-
emailSendId
|
|
151
|
+
emailSendId,
|
|
143
152
|
properties: {
|
|
144
153
|
...(link.eventProperties ?? {}),
|
|
145
154
|
linkId: link.id,
|
|
@@ -180,7 +189,7 @@ export async function confirmSemanticClick(
|
|
|
180
189
|
payload: {
|
|
181
190
|
event: semanticEvent,
|
|
182
191
|
properties: link.eventProperties ?? null,
|
|
183
|
-
emailSendId
|
|
192
|
+
emailSendId,
|
|
184
193
|
templateKey: ctx.templateKey ?? null,
|
|
185
194
|
userId: ctx.userId ?? null,
|
|
186
195
|
to: ctx.to ?? ctx.userEmail ?? "",
|
|
@@ -15,8 +15,12 @@ interface EmailSendContext {
|
|
|
15
15
|
|
|
16
16
|
export async function resolveEmailSendContext(
|
|
17
17
|
db: Database,
|
|
18
|
-
emailSendId: string,
|
|
18
|
+
emailSendId: string | null,
|
|
19
19
|
): Promise<EmailSendContext | null> {
|
|
20
|
+
// A non-email tracked link (Discord/referral/ad-hoc `createTrackedLink`) has
|
|
21
|
+
// a NULL `email_send_id` — there is no send row to resolve, so short-circuit
|
|
22
|
+
// to null rather than issue a `WHERE id = NULL` query that matches nothing.
|
|
23
|
+
if (!emailSendId) return null;
|
|
20
24
|
const rows = await db
|
|
21
25
|
.select({
|
|
22
26
|
toEmail: emailSends.toEmail,
|
package/src/lib/tracking.ts
CHANGED
|
@@ -240,3 +240,40 @@ export async function prepareTrackedHtml(opts: {
|
|
|
240
240
|
});
|
|
241
241
|
return result;
|
|
242
242
|
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The mint surface for a NON-email tracked link (Discord, referral, ad-hoc).
|
|
246
|
+
* Inserts a `tracked_links` row with a NULL `emailSendId` and returns the
|
|
247
|
+
* `/v1/t/c/:id` redirect URL to use in place of the raw destination.
|
|
248
|
+
*
|
|
249
|
+
* This is the SINGLE chokepoint enforcing "broadcast links carry no subject":
|
|
250
|
+
* a link only becomes identity-bearing when the caller EXPLICITLY passes
|
|
251
|
+
* `distinctId` (the canonical contact key the click should stitch into). Per
|
|
252
|
+
* MF-4, the referral path does NOT pass `distinctId` by default (referral
|
|
253
|
+
* pages are shareable → broadcast), and the Discord destination passes
|
|
254
|
+
* `distinctId: undefined`. The `hs_t` mint at click time is still gated by
|
|
255
|
+
* `TRACKING_IDENTITY_TOKEN` (default false); a row with a NULL `distinctId`
|
|
256
|
+
* never mints a token regardless.
|
|
257
|
+
*/
|
|
258
|
+
export async function createTrackedLink(opts: {
|
|
259
|
+
db: Database;
|
|
260
|
+
url: string;
|
|
261
|
+
/**
|
|
262
|
+
* The canonical contact key a click should fold the visitor's anon session
|
|
263
|
+
* into. OMIT for a broadcast link (the safe default) — only an explicit,
|
|
264
|
+
* single-subject, non-shareable link should pass this.
|
|
265
|
+
*/
|
|
266
|
+
distinctId?: string;
|
|
267
|
+
source: "discord" | "referral" | "link";
|
|
268
|
+
baseUrl: string;
|
|
269
|
+
}): Promise<string> {
|
|
270
|
+
const id = randomUUID();
|
|
271
|
+
await opts.db.insert(trackedLinks).values({
|
|
272
|
+
id,
|
|
273
|
+
emailSendId: null,
|
|
274
|
+
distinctId: opts.distinctId ?? null,
|
|
275
|
+
source: opts.source,
|
|
276
|
+
originalUrl: opts.url,
|
|
277
|
+
});
|
|
278
|
+
return `${opts.baseUrl}/v1/t/c/${id}`;
|
|
279
|
+
}
|
|
@@ -25,9 +25,14 @@ import { Webhook } from "svix";
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* The
|
|
28
|
+
* The 15-event catalog — the SINGLE source of truth (schema, routes, client,
|
|
29
29
|
* CLI all derive from this). The `webhook.test` sentinel is intentionally NOT a
|
|
30
30
|
* member (it is delivered out-of-band regardless of an endpoint's `eventTypes`).
|
|
31
|
+
*
|
|
32
|
+
* `link.clicked` is the NON-email click event: a click on a tracked link that
|
|
33
|
+
* has no email send (Discord/referral/ad-hoc `createTrackedLink`). It is the
|
|
34
|
+
* deliberate counterpart to `email.clicked` so a non-email click never fires a
|
|
35
|
+
* malformed `email.clicked` (MF-missing #3).
|
|
31
36
|
*/
|
|
32
37
|
export const WEBHOOK_EVENT_TYPES = [
|
|
33
38
|
"contact.created",
|
|
@@ -44,6 +49,7 @@ export const WEBHOOK_EVENT_TYPES = [
|
|
|
44
49
|
"journey.completed",
|
|
45
50
|
"bucket.entered",
|
|
46
51
|
"bucket.left",
|
|
52
|
+
"link.clicked",
|
|
47
53
|
] as const;
|
|
48
54
|
|
|
49
55
|
export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number];
|