@hogsend/engine 0.22.0 → 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/container.ts +21 -0
- package/src/index.ts +13 -0
- package/src/lib/analytics-identity.ts +112 -0
- package/src/lib/contacts.ts +113 -19
- package/src/lib/identity-service.ts +107 -0
- package/src/lib/identity-token.ts +65 -5
- package/src/lib/ingestion.ts +52 -2
- package/src/lib/outbound.ts +17 -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/contacts/index.ts +7 -0
- package/src/routes/events/index.ts +16 -1
- 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/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
|
|
|
@@ -58,8 +63,17 @@ export async function ingestEvent(opts: {
|
|
|
58
63
|
hatchet: HatchetClient;
|
|
59
64
|
logger: Logger;
|
|
60
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;
|
|
61
75
|
}): Promise<IngestResult> {
|
|
62
|
-
const { db, registry, hatchet, logger, event } = opts;
|
|
76
|
+
const { db, registry, hatchet, logger, event, analytics } = opts;
|
|
63
77
|
|
|
64
78
|
// (1) Resolve identity FIRST (awaited — no longer fire-and-forget). The
|
|
65
79
|
// contact-referencing tables join on a NOT NULL text key, so an email-only /
|
|
@@ -68,7 +82,13 @@ export async function ingestEvent(opts: {
|
|
|
68
82
|
// `contacts.properties` (D2 split) and returns BOTH the canonical contact id
|
|
69
83
|
// AND its resolved string key (external_id ?? anonymous_id ?? contact.id —
|
|
70
84
|
// risk 1/6), so no second read-back of the contact row is needed.
|
|
71
|
-
const {
|
|
85
|
+
const {
|
|
86
|
+
id: contactId,
|
|
87
|
+
resolvedKey,
|
|
88
|
+
mergedKeys,
|
|
89
|
+
mergedIdentifiedKeys,
|
|
90
|
+
merged,
|
|
91
|
+
} = await resolveOrCreateContact({
|
|
72
92
|
db,
|
|
73
93
|
userId: event.userId,
|
|
74
94
|
email: event.userEmail || undefined,
|
|
@@ -112,6 +132,36 @@ export async function ingestEvent(opts: {
|
|
|
112
132
|
});
|
|
113
133
|
}
|
|
114
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
|
+
|
|
115
165
|
// (3) Build the JSON-serializable subset of eventProperties for the Hatchet
|
|
116
166
|
// push payload (scalars only — the SDK serializes the envelope).
|
|
117
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
|
|
@@ -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];
|
|
@@ -39,6 +39,10 @@ const upsertRoute = createRoute({
|
|
|
39
39
|
schema: z.object({
|
|
40
40
|
email: z.string().email().optional(),
|
|
41
41
|
userId: z.string().min(1).optional(),
|
|
42
|
+
// §4: caller's analytics anon id — the resolver's 2nd-precedence
|
|
43
|
+
// key. An EXTRA, never a third identity arm: `requireIdentity`
|
|
44
|
+
// still requires email or userId below.
|
|
45
|
+
anonymousId: z.string().min(1).max(200).optional(),
|
|
42
46
|
properties: z.record(z.string(), z.unknown()).optional(),
|
|
43
47
|
lists: z.record(z.string(), z.boolean()).optional(),
|
|
44
48
|
}),
|
|
@@ -142,6 +146,9 @@ export const contactsRouter = new OpenAPIHono<AppEnv>()
|
|
|
142
146
|
db,
|
|
143
147
|
userId: body.userId,
|
|
144
148
|
email: body.email,
|
|
149
|
+
// §4: 2nd-precedence resolver key (zero-merge stitch). Identity is still
|
|
150
|
+
// enforced via `requireIdentity` (email/userId) above.
|
|
151
|
+
anonymousId: body.anonymousId,
|
|
145
152
|
contactProperties: body.properties,
|
|
146
153
|
});
|
|
147
154
|
|
|
@@ -9,6 +9,14 @@ const eventRequestSchema = z.object({
|
|
|
9
9
|
name: z.string().min(1),
|
|
10
10
|
email: z.string().email().optional(),
|
|
11
11
|
userId: z.string().min(1).optional(),
|
|
12
|
+
// §4: the caller's analytics anon id (e.g. posthog-js `get_distinct_id()`).
|
|
13
|
+
// 2nd in the resolver's key precedence (`external → email → anonymous →
|
|
14
|
+
// discord`), so when no `external_id` is attached the contact's canonical key
|
|
15
|
+
// BECOMES this value — the browser's own anon events and the server's captures
|
|
16
|
+
// then land on ONE analytics person with zero merge calls. An EXTRA, never a
|
|
17
|
+
// third identity arm: `requireIdentity` still requires email or userId
|
|
18
|
+
// (anon-only public ingest is an abuse vector).
|
|
19
|
+
anonymousId: z.string().min(1).max(200).optional(),
|
|
12
20
|
eventProperties: z.record(z.string(), z.unknown()).optional(),
|
|
13
21
|
contactProperties: z.record(z.string(), z.unknown()).optional(),
|
|
14
22
|
lists: z.record(z.string(), z.boolean()).optional(),
|
|
@@ -68,7 +76,7 @@ const eventRoute = createRoute({
|
|
|
68
76
|
export const eventsRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
69
77
|
eventRoute,
|
|
70
78
|
async (c) => {
|
|
71
|
-
const { db, registry, hatchet, logger } = c.get("container");
|
|
79
|
+
const { db, registry, hatchet, logger, analytics } = c.get("container");
|
|
72
80
|
const body = c.req.valid("json");
|
|
73
81
|
|
|
74
82
|
const guard = requireIdentity(c, body);
|
|
@@ -83,10 +91,17 @@ export const eventsRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
83
91
|
registry,
|
|
84
92
|
hatchet,
|
|
85
93
|
logger,
|
|
94
|
+
// §5.3: thread the active analytics provider so a collide-MERGE / key-flip
|
|
95
|
+
// fires the provider-neutral `mergeIdentities` stitch. Absent ⇒ no-op.
|
|
96
|
+
analytics,
|
|
86
97
|
event: {
|
|
87
98
|
event: body.name,
|
|
88
99
|
userId: body.userId,
|
|
89
100
|
userEmail: body.email,
|
|
101
|
+
// §4: 2nd-precedence resolver key — lets the contact's canonical key
|
|
102
|
+
// equal the browser anon id (zero-merge stitch). Identity is still
|
|
103
|
+
// enforced via `requireIdentity` (email/userId) above.
|
|
104
|
+
anonymousId: body.anonymousId,
|
|
90
105
|
eventProperties: body.eventProperties ?? {},
|
|
91
106
|
contactProperties: body.contactProperties,
|
|
92
107
|
idempotencyKey,
|
|
@@ -150,8 +150,15 @@ export const answerRouter = new OpenAPIHono<AppEnv>()
|
|
|
150
150
|
);
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
// The answer/comment flow is EMAIL-semantic (it re-ingests a
|
|
154
|
+
// `<event>.comment` keyed on the send). A non-email semantic link has no
|
|
155
|
+
// send to attribute the comment to — `emailSendId` is nullable since the
|
|
156
|
+
// identity-stitching minor, so narrow it here.
|
|
157
|
+
const emailSendId = link.emailSendId;
|
|
158
|
+
const ctx = emailSendId
|
|
159
|
+
? await resolveEmailSendContext(db, emailSendId)
|
|
160
|
+
: null;
|
|
161
|
+
if (ctx && emailSendId) {
|
|
155
162
|
// `<event>.comment` is a consumer-namespace event — journeys can wait
|
|
156
163
|
// on it and destinations receive it like any other. First comment per
|
|
157
164
|
// (send, event) wins; repeats are no-ops.
|
|
@@ -161,7 +168,7 @@ export const answerRouter = new OpenAPIHono<AppEnv>()
|
|
|
161
168
|
registry,
|
|
162
169
|
logger,
|
|
163
170
|
event: `${link.event}.comment`,
|
|
164
|
-
emailSendId
|
|
171
|
+
emailSendId,
|
|
165
172
|
properties: {
|
|
166
173
|
comment,
|
|
167
174
|
parentEvent: link.event,
|
|
@@ -169,7 +176,7 @@ export const answerRouter = new OpenAPIHono<AppEnv>()
|
|
|
169
176
|
linkId: link.id,
|
|
170
177
|
},
|
|
171
178
|
resolvedContext: ctx,
|
|
172
|
-
idempotencyKey: `semc:${
|
|
179
|
+
idempotencyKey: `semc:${emailSendId}:${link.event}`,
|
|
173
180
|
}).catch((err) => {
|
|
174
181
|
logger.warn("Failed to ingest answer comment", {
|
|
175
182
|
linkId: link.id,
|
|
@@ -38,6 +38,8 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
38
38
|
id: trackedLinks.id,
|
|
39
39
|
originalUrl: trackedLinks.originalUrl,
|
|
40
40
|
emailSendId: trackedLinks.emailSendId,
|
|
41
|
+
distinctId: trackedLinks.distinctId,
|
|
42
|
+
source: trackedLinks.source,
|
|
41
43
|
event: trackedLinks.event,
|
|
42
44
|
eventProperties: trackedLinks.eventProperties,
|
|
43
45
|
})
|
|
@@ -56,10 +58,17 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
56
58
|
null;
|
|
57
59
|
const userAgent = c.req.header("user-agent") ?? null;
|
|
58
60
|
|
|
61
|
+
// The linkClicks insert + clickCount increment stay UNCONDITIONAL — every
|
|
62
|
+
// tracked link counts clicks, email or not.
|
|
63
|
+
//
|
|
59
64
|
// First-touch state UPDATE: the `WHERE clickedAt IS NULL` sets `clickedAt`
|
|
60
65
|
// exactly once (the first click), which is the row-level state we keep. The
|
|
61
66
|
// outbound emit is NO LONGER gated on this — every destination must receive
|
|
62
|
-
// EVERY click (owner decision 1), so the emit below fires per-hit.
|
|
67
|
+
// EVERY click (owner decision 1), so the emit below fires per-hit. The
|
|
68
|
+
// emailSends update is GATED on `emailSendId != null` (MF-6): a non-email
|
|
69
|
+
// link has no send row to mark. This was previously safe-by-accident
|
|
70
|
+
// (`WHERE id = NULL` matches nothing) — the gate makes it explicit.
|
|
71
|
+
const emailSendId = link.emailSendId;
|
|
63
72
|
await Promise.all([
|
|
64
73
|
db.insert(linkClicks).values({
|
|
65
74
|
trackedLinkId: link.id,
|
|
@@ -73,18 +82,22 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
73
82
|
updatedAt: new Date(),
|
|
74
83
|
})
|
|
75
84
|
.where(eq(trackedLinks.id, link.id)),
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
...(emailSendId
|
|
86
|
+
? [
|
|
87
|
+
db
|
|
88
|
+
.update(emailSends)
|
|
89
|
+
.set({
|
|
90
|
+
clickedAt: new Date(),
|
|
91
|
+
updatedAt: new Date(),
|
|
92
|
+
})
|
|
93
|
+
.where(
|
|
94
|
+
and(
|
|
95
|
+
eq(emailSends.id, emailSendId),
|
|
96
|
+
isNull(emailSends.clickedAt),
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
]
|
|
100
|
+
: []),
|
|
88
101
|
]);
|
|
89
102
|
|
|
90
103
|
const { hatchet, registry, logger } = c.get("container");
|
|
@@ -93,8 +106,11 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
93
106
|
// deferred past the scanner-burst window (a Hatchet task) so the gate can
|
|
94
107
|
// see the WHOLE burst — an inline check could never suppress a scanner's
|
|
95
108
|
// first click. The task claims the send's answer slot (first answer wins)
|
|
96
|
-
// and emits the consumer event + email.action outbound.
|
|
97
|
-
|
|
109
|
+
// and emits the consumer event + email.action outbound. GATED on
|
|
110
|
+
// `emailSendId != null` (MF-6): the confirm task is email-semantic (it
|
|
111
|
+
// claims a send's answer slot + emits `email.action`), so a non-email
|
|
112
|
+
// semantic link would have no send to confirm against.
|
|
113
|
+
if (link.event && emailSendId) {
|
|
98
114
|
void confirmSemanticClickTask
|
|
99
115
|
.runNoWait({
|
|
100
116
|
trackedLinkId: link.id,
|
|
@@ -110,27 +126,48 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
110
126
|
}
|
|
111
127
|
|
|
112
128
|
// Cross-device identity stitch (opt-in): append a short-lived signed
|
|
113
|
-
// `hs_t` token to the destination so the landing site can
|
|
114
|
-
// session
|
|
115
|
-
//
|
|
116
|
-
//
|
|
129
|
+
// `hs_t` token to the destination so the landing site can fold its own anon
|
|
130
|
+
// session INTO the subject at `/v1/t/identify`. Two mint sources by link
|
|
131
|
+
// type (§6.5): a stitch-bearing NON-email link mints from its own
|
|
132
|
+
// `distinct_id` (`src: "<source>:<id>"`); an EMAIL link mints from the
|
|
133
|
+
// resolved send context; a BROADCAST link (no `distinct_id`, no send) mints
|
|
134
|
+
// nothing. The token is minted at CLICK time only — never stored on the
|
|
135
|
+
// shareable `/v1/t/c/:id` artifact (§6.3). The awaited send resolve is
|
|
136
|
+
// shared with the async chain below so the read still happens once.
|
|
117
137
|
let redirectUrl = link.originalUrl;
|
|
118
138
|
let preResolved: Awaited<
|
|
119
139
|
ReturnType<typeof resolveEmailSendContext>
|
|
120
140
|
> | null = null;
|
|
121
141
|
let preResolvedSet = false;
|
|
122
142
|
if (env.TRACKING_IDENTITY_TOKEN) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (
|
|
143
|
+
let tokenDistinctId: string | null = null;
|
|
144
|
+
let tokenSrc: string | null = null;
|
|
145
|
+
if (link.distinctId) {
|
|
146
|
+
// Stitch-bearing non-email link: the subject is the link's own
|
|
147
|
+
// `distinct_id` (canonical key). No send resolve needed.
|
|
148
|
+
tokenDistinctId = link.distinctId;
|
|
149
|
+
tokenSrc = `${link.source ?? "link"}:${link.id}`;
|
|
150
|
+
} else if (emailSendId) {
|
|
151
|
+
// Email link: resolve the recipient's canonical key from the send row.
|
|
152
|
+
preResolved = await resolveEmailSendContext(db, emailSendId);
|
|
153
|
+
preResolvedSet = true;
|
|
154
|
+
if (preResolved?.userId) {
|
|
155
|
+
tokenDistinctId = preResolved.userId;
|
|
156
|
+
tokenSrc = `email:${emailSendId}`;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// else: broadcast link (no distinctId, no send) — mint nothing.
|
|
160
|
+
|
|
161
|
+
if (tokenDistinctId && tokenSrc) {
|
|
126
162
|
try {
|
|
127
163
|
const url = new URL(link.originalUrl);
|
|
128
164
|
url.searchParams.set(
|
|
129
165
|
"hs_t",
|
|
130
166
|
generateIdentityToken({
|
|
131
167
|
secret: env.BETTER_AUTH_SECRET,
|
|
132
|
-
distinctId:
|
|
133
|
-
|
|
168
|
+
distinctId: tokenDistinctId,
|
|
169
|
+
src: tokenSrc,
|
|
170
|
+
emailSendId: emailSendId ?? undefined,
|
|
134
171
|
}),
|
|
135
172
|
);
|
|
136
173
|
redirectUrl = url.toString();
|
|
@@ -141,58 +178,80 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
141
178
|
}
|
|
142
179
|
}
|
|
143
180
|
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
// `
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
hatchet,
|
|
159
|
-
registry,
|
|
160
|
-
logger,
|
|
161
|
-
event: EMAIL_LINK_CLICKED,
|
|
162
|
-
emailSendId,
|
|
163
|
-
properties: { linkUrl: link.originalUrl, linkId: link.id },
|
|
164
|
-
resolvedContext: ctx,
|
|
165
|
-
}).catch((err) => {
|
|
166
|
-
logger.warn("Failed to push click tracking event", {
|
|
167
|
-
linkId: link.id,
|
|
168
|
-
error: err instanceof Error ? err.message : String(err),
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
// Only emit when the send-context resolved. A missing emailSends row
|
|
173
|
-
// (orphaned tracked link / deleted send) has no userId or recipient to
|
|
174
|
-
// attribute, and a keyed destination (PostHog) would otherwise receive
|
|
175
|
-
// an empty distinct_id. A normal click always resolves a non-null userId.
|
|
176
|
-
if (ctx) {
|
|
177
|
-
await emitOutbound({
|
|
181
|
+
// PER-HIT outbound emit, off the response path. EMAIL links re-ingest the
|
|
182
|
+
// first-party `email.link_clicked` event (journey routing + userEvents) and
|
|
183
|
+
// emit `email.clicked`; NON-email links emit `link.clicked` instead — never
|
|
184
|
+
// a malformed `email.clicked` (MF-missing #3). NO `dedupeKey`: a NULL dedupe
|
|
185
|
+
// key is distinct in Postgres, so every click creates a fresh delivery to
|
|
186
|
+
// every subscribed destination (per-hit, not first-touch).
|
|
187
|
+
if (emailSendId) {
|
|
188
|
+
void (
|
|
189
|
+
preResolvedSet
|
|
190
|
+
? Promise.resolve(preResolved)
|
|
191
|
+
: resolveEmailSendContext(db, emailSendId)
|
|
192
|
+
)
|
|
193
|
+
.then(async (ctx) => {
|
|
194
|
+
await pushTrackingEvent({
|
|
178
195
|
db,
|
|
179
196
|
hatchet,
|
|
197
|
+
registry,
|
|
180
198
|
logger,
|
|
181
|
-
event:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
to: ctx.to ?? ctx.userEmail ?? "",
|
|
188
|
-
at: new Date().toISOString(),
|
|
189
|
-
linkUrl: link.originalUrl,
|
|
199
|
+
event: EMAIL_LINK_CLICKED,
|
|
200
|
+
emailSendId,
|
|
201
|
+
properties: { linkUrl: link.originalUrl, linkId: link.id },
|
|
202
|
+
resolvedContext: ctx,
|
|
203
|
+
}).catch((err) => {
|
|
204
|
+
logger.warn("Failed to push click tracking event", {
|
|
190
205
|
linkId: link.id,
|
|
191
|
-
|
|
206
|
+
error: err instanceof Error ? err.message : String(err),
|
|
207
|
+
});
|
|
192
208
|
});
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
209
|
+
|
|
210
|
+
// Only emit when the send-context resolved. A missing emailSends row
|
|
211
|
+
// (orphaned tracked link / deleted send) has no userId or recipient to
|
|
212
|
+
// attribute, and a keyed destination (PostHog) would otherwise receive
|
|
213
|
+
// an empty distinct_id. A normal click always resolves a non-null userId.
|
|
214
|
+
if (ctx) {
|
|
215
|
+
await emitOutbound({
|
|
216
|
+
db,
|
|
217
|
+
hatchet,
|
|
218
|
+
logger,
|
|
219
|
+
event: "email.clicked",
|
|
220
|
+
payload: {
|
|
221
|
+
emailSendId,
|
|
222
|
+
messageId: ctx.messageId ?? null,
|
|
223
|
+
templateKey: ctx.templateKey ?? null,
|
|
224
|
+
userId: ctx.userId ?? null,
|
|
225
|
+
to: ctx.to ?? ctx.userEmail ?? "",
|
|
226
|
+
at: new Date().toISOString(),
|
|
227
|
+
linkUrl: link.originalUrl,
|
|
228
|
+
linkId: link.id,
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
.catch(logger.warn);
|
|
234
|
+
} else {
|
|
235
|
+
// Non-email tracked link: emit the catalogued `link.clicked` (NOT
|
|
236
|
+
// `email.clicked`). `userId` is the link's stitch subject when
|
|
237
|
+
// identity-bearing, else null for a broadcast link. No re-ingest — the
|
|
238
|
+
// first-party `email.link_clicked` bus event is email-semantic.
|
|
239
|
+
void emitOutbound({
|
|
240
|
+
db,
|
|
241
|
+
hatchet,
|
|
242
|
+
logger,
|
|
243
|
+
event: "link.clicked",
|
|
244
|
+
payload: {
|
|
245
|
+
linkId: link.id,
|
|
246
|
+
source: link.source ?? null,
|
|
247
|
+
userId: link.distinctId ?? null,
|
|
248
|
+
emailSendId: null,
|
|
249
|
+
messageId: null,
|
|
250
|
+
linkUrl: link.originalUrl,
|
|
251
|
+
at: new Date().toISOString(),
|
|
252
|
+
},
|
|
253
|
+
}).catch(logger.warn);
|
|
254
|
+
}
|
|
196
255
|
|
|
197
256
|
return c.redirect(redirectUrl, 302);
|
|
198
257
|
},
|