@hogsend/engine 0.22.0 → 0.23.1
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/admin/bulk.ts +21 -3
- package/src/routes/admin/preferences.ts +8 -0
- package/src/routes/admin/reporting.ts +11 -0
- package/src/routes/admin/suppressions.ts +12 -2
- 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
|
@@ -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
|
},
|
|
@@ -6,26 +6,40 @@ import {
|
|
|
6
6
|
} from "../../lib/identity-token.js";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Exchange a redirect identity token (`hs_t`) for the distinct id
|
|
10
|
-
* the
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* Exchange a redirect identity token (`hs_t`) for the distinct id, AND — when
|
|
10
|
+
* the caller supplies its own browser anon id (`currentDistinctId`) and the
|
|
11
|
+
* active analytics provider can merge — fire a SERVER-SIDE `alias` folding the
|
|
12
|
+
* caller's own anon session INTO the token's canonical subject. Called by the
|
|
13
|
+
* LANDING SITE's frontend (CORS is open app-wide) after the user arrives from a
|
|
14
|
+
* tracked link.
|
|
14
15
|
*
|
|
15
16
|
* Possession of a fresh signed token IS the authorization (the same trust
|
|
16
17
|
* model as unsubscribe links): tokens are signed with BETTER_AUTH_SECRET,
|
|
17
|
-
* expire after an hour, and resolve to nothing but the
|
|
18
|
+
* expire after an hour, and resolve to nothing but the canonical key + src.
|
|
19
|
+
*
|
|
20
|
+
* ANTI-HIJACK (MF-4): the route NEVER passes `currentDistinctId` as the
|
|
21
|
+
* survivor and NEVER server-identifies it. A forwarded-token holder can, at
|
|
22
|
+
* worst, fold THEIR OWN anon session into the subject — never overwrite the
|
|
23
|
+
* subject, never become the subject, never name a victim's anon id (they don't
|
|
24
|
+
* know it). A scanner following the redirect runs no posthog-js, so it supplies
|
|
25
|
+
* no `currentDistinctId` and the merge no-ops — the exchange is inert for
|
|
26
|
+
* headless prefetch.
|
|
18
27
|
*/
|
|
19
28
|
const identifyRoute = createRoute({
|
|
20
29
|
method: "post",
|
|
21
30
|
path: "/identify",
|
|
22
31
|
tags: ["Tracking"],
|
|
23
|
-
summary: "Exchange a redirect identity token
|
|
32
|
+
summary: "Exchange a redirect identity token + optionally alias the caller",
|
|
24
33
|
request: {
|
|
25
34
|
body: {
|
|
26
35
|
content: {
|
|
27
36
|
"application/json": {
|
|
28
|
-
schema: z.object({
|
|
37
|
+
schema: z.object({
|
|
38
|
+
token: z.string().min(1).max(2048),
|
|
39
|
+
// The caller's OWN browser anon distinct id, to be folded INTO the
|
|
40
|
+
// token subject. Optional — absent = legacy resolve-only behaviour.
|
|
41
|
+
currentDistinctId: z.string().min(1).max(200).optional(),
|
|
42
|
+
}),
|
|
29
43
|
},
|
|
30
44
|
},
|
|
31
45
|
},
|
|
@@ -35,8 +49,11 @@ const identifyRoute = createRoute({
|
|
|
35
49
|
description: "Resolved identity",
|
|
36
50
|
content: {
|
|
37
51
|
"application/json": {
|
|
52
|
+
// ONE response schema across §6 (MF-5): `src` is the new field,
|
|
53
|
+
// `emailSendId` retained for the one-minor deprecation window.
|
|
38
54
|
schema: z.object({
|
|
39
55
|
distinctId: z.string(),
|
|
56
|
+
src: z.string(),
|
|
40
57
|
emailSendId: z.string().optional(),
|
|
41
58
|
}),
|
|
42
59
|
},
|
|
@@ -49,23 +66,53 @@ const identifyRoute = createRoute({
|
|
|
49
66
|
export const identifyRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
50
67
|
identifyRoute,
|
|
51
68
|
async (c) => {
|
|
52
|
-
const { token } = c.req.valid("json");
|
|
53
|
-
const { env } = c.get("container");
|
|
69
|
+
const { token, currentDistinctId } = c.req.valid("json");
|
|
70
|
+
const { env, analytics, logger } = c.get("container");
|
|
54
71
|
|
|
72
|
+
let payload: ReturnType<typeof validateIdentityToken>;
|
|
55
73
|
try {
|
|
56
|
-
|
|
74
|
+
payload = validateIdentityToken({
|
|
57
75
|
token,
|
|
58
76
|
secret: env.BETTER_AUTH_SECRET,
|
|
59
77
|
});
|
|
60
|
-
return c.json(
|
|
61
|
-
{ distinctId: payload.distinctId, emailSendId: payload.emailSendId },
|
|
62
|
-
200,
|
|
63
|
-
);
|
|
64
78
|
} catch (err) {
|
|
65
79
|
if (err instanceof InvalidIdentityTokenError) {
|
|
66
80
|
return c.body(null, 400);
|
|
67
81
|
}
|
|
68
82
|
throw err;
|
|
69
83
|
}
|
|
84
|
+
|
|
85
|
+
// MF-5 — fire the alias FIRE-AND-FORGET (never await on the response path)
|
|
86
|
+
// and respond synchronously. The token-proven canonical key is the survivor;
|
|
87
|
+
// the caller's own session is the absorbed (anonymous) side. A provider
|
|
88
|
+
// without `identityMerge` (or no provider) skips the merge cleanly — the
|
|
89
|
+
// client falls back to its existing best-effort `posthog.identify`.
|
|
90
|
+
if (
|
|
91
|
+
currentDistinctId &&
|
|
92
|
+
analytics?.capabilities.identityMerge &&
|
|
93
|
+
analytics.mergeIdentities &&
|
|
94
|
+
currentDistinctId !== payload.distinctId
|
|
95
|
+
) {
|
|
96
|
+
try {
|
|
97
|
+
analytics.mergeIdentities({
|
|
98
|
+
distinctId: payload.distinctId,
|
|
99
|
+
alias: currentDistinctId,
|
|
100
|
+
});
|
|
101
|
+
} catch (err) {
|
|
102
|
+
// Best-effort — a provider error must never fail the exchange.
|
|
103
|
+
logger.warn("identify: mergeIdentities failed", {
|
|
104
|
+
error: err instanceof Error ? err.message : String(err),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return c.json(
|
|
110
|
+
{
|
|
111
|
+
distinctId: payload.distinctId,
|
|
112
|
+
src: payload.src,
|
|
113
|
+
emailSendId: payload.emailSendId,
|
|
114
|
+
},
|
|
115
|
+
200,
|
|
116
|
+
);
|
|
70
117
|
},
|
|
71
118
|
);
|