@hogsend/engine 0.13.2 → 0.16.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/destinations/presets/posthog.ts +35 -0
- package/src/env.ts +7 -0
- package/src/index.ts +13 -0
- package/src/journeys/define-journey.ts +40 -3
- package/src/journeys/journey-context.ts +73 -5
- package/src/lib/identity-token.ts +112 -0
- package/src/lib/ingestion.ts +32 -1
- package/src/lib/outbound.ts +17 -0
- package/src/lib/seed-posthog-destination.ts +34 -2
- package/src/lib/semantic-click.ts +194 -0
- package/src/lib/tracking-events.ts +12 -4
- package/src/lib/tracking.ts +185 -21
- package/src/lib/webhook-signing.ts +2 -1
- package/src/routes/tracking/answer.ts +187 -0
- package/src/routes/tracking/click.ts +62 -2
- package/src/routes/tracking/identify.ts +71 -0
- package/src/routes/tracking/index.ts +4 -0
- package/src/worker.ts +2 -0
- package/src/workflows/confirm-semantic-click.ts +37 -0
|
@@ -2,12 +2,14 @@ import { emailSends, linkClicks, trackedLinks } from "@hogsend/db";
|
|
|
2
2
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
3
|
import { and, eq, isNull, sql } from "drizzle-orm";
|
|
4
4
|
import type { AppEnv } from "../../app.js";
|
|
5
|
+
import { generateIdentityToken } from "../../lib/identity-token.js";
|
|
5
6
|
import { emitOutbound } from "../../lib/outbound.js";
|
|
6
7
|
import { EMAIL_LINK_CLICKED } from "../../lib/tracking-event-names.js";
|
|
7
8
|
import {
|
|
8
9
|
pushTrackingEvent,
|
|
9
10
|
resolveEmailSendContext,
|
|
10
11
|
} from "../../lib/tracking-events.js";
|
|
12
|
+
import { confirmSemanticClickTask } from "../../workflows/confirm-semantic-click.js";
|
|
11
13
|
|
|
12
14
|
const clickRoute = createRoute({
|
|
13
15
|
method: "get",
|
|
@@ -36,6 +38,8 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
36
38
|
id: trackedLinks.id,
|
|
37
39
|
originalUrl: trackedLinks.originalUrl,
|
|
38
40
|
emailSendId: trackedLinks.emailSendId,
|
|
41
|
+
event: trackedLinks.event,
|
|
42
|
+
eventProperties: trackedLinks.eventProperties,
|
|
39
43
|
})
|
|
40
44
|
.from(trackedLinks)
|
|
41
45
|
.where(eq(trackedLinks.id, id))
|
|
@@ -85,13 +89,69 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
85
89
|
|
|
86
90
|
const { hatchet, registry, logger } = c.get("container");
|
|
87
91
|
|
|
92
|
+
// SEMANTIC link: the click is a PROVISIONAL answer. Confirmation is
|
|
93
|
+
// deferred past the scanner-burst window (a Hatchet task) so the gate can
|
|
94
|
+
// see the WHOLE burst — an inline check could never suppress a scanner's
|
|
95
|
+
// first click. The task claims the send's answer slot (first answer wins)
|
|
96
|
+
// and emits the consumer event + email.action outbound.
|
|
97
|
+
if (link.event) {
|
|
98
|
+
void confirmSemanticClickTask
|
|
99
|
+
.runNoWait({
|
|
100
|
+
trackedLinkId: link.id,
|
|
101
|
+
clickedAt: new Date().toISOString(),
|
|
102
|
+
})
|
|
103
|
+
.catch((err: unknown) => {
|
|
104
|
+
logger.warn("Failed to enqueue semantic click confirmation", {
|
|
105
|
+
linkId: link.id,
|
|
106
|
+
event: link.event,
|
|
107
|
+
error: err instanceof Error ? err.message : String(err),
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Cross-device identity stitch (opt-in): append a short-lived signed
|
|
113
|
+
// `hs_t` token to the destination so the landing site can identify the
|
|
114
|
+
// session. This is the ONE path that needs the send context BEFORE the
|
|
115
|
+
// redirect — the awaited resolve is shared with the async chain below so
|
|
116
|
+
// the read still happens once.
|
|
117
|
+
let redirectUrl = link.originalUrl;
|
|
118
|
+
let preResolved: Awaited<
|
|
119
|
+
ReturnType<typeof resolveEmailSendContext>
|
|
120
|
+
> | null = null;
|
|
121
|
+
let preResolvedSet = false;
|
|
122
|
+
if (env.TRACKING_IDENTITY_TOKEN) {
|
|
123
|
+
preResolved = await resolveEmailSendContext(db, link.emailSendId);
|
|
124
|
+
preResolvedSet = true;
|
|
125
|
+
if (preResolved?.userId) {
|
|
126
|
+
try {
|
|
127
|
+
const url = new URL(link.originalUrl);
|
|
128
|
+
url.searchParams.set(
|
|
129
|
+
"hs_t",
|
|
130
|
+
generateIdentityToken({
|
|
131
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
132
|
+
distinctId: preResolved.userId,
|
|
133
|
+
emailSendId: link.emailSendId,
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
redirectUrl = url.toString();
|
|
137
|
+
} catch {
|
|
138
|
+
// Unparseable destination — redirect untouched rather than break it.
|
|
139
|
+
redirectUrl = link.originalUrl;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
88
144
|
// Resolve the send context ONCE (off the response path) and feed both the
|
|
89
145
|
// re-ingest and the PER-HIT outbound emit — avoiding a duplicate
|
|
90
146
|
// `resolveEmailSendContext` read on the click hot path. NO `dedupeKey`: a
|
|
91
147
|
// NULL dedupe key is distinct in Postgres, so every click creates a fresh
|
|
92
148
|
// delivery to every subscribed destination (per-hit, not first-touch).
|
|
93
149
|
const emailSendId = link.emailSendId;
|
|
94
|
-
void
|
|
150
|
+
void (
|
|
151
|
+
preResolvedSet
|
|
152
|
+
? Promise.resolve(preResolved)
|
|
153
|
+
: resolveEmailSendContext(db, emailSendId)
|
|
154
|
+
)
|
|
95
155
|
.then(async (ctx) => {
|
|
96
156
|
await pushTrackingEvent({
|
|
97
157
|
db,
|
|
@@ -134,6 +194,6 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
134
194
|
})
|
|
135
195
|
.catch(logger.warn);
|
|
136
196
|
|
|
137
|
-
return c.redirect(
|
|
197
|
+
return c.redirect(redirectUrl, 302);
|
|
138
198
|
},
|
|
139
199
|
);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
2
|
+
import type { AppEnv } from "../../app.js";
|
|
3
|
+
import {
|
|
4
|
+
InvalidIdentityTokenError,
|
|
5
|
+
validateIdentityToken,
|
|
6
|
+
} from "../../lib/identity-token.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Exchange a redirect identity token (`hs_t`) for the distinct id. Called by
|
|
10
|
+
* the LANDING SITE's frontend (CORS is open app-wide) after the user arrives
|
|
11
|
+
* from a tracked email link; the site then calls `posthog.identify` (or its
|
|
12
|
+
* analytics equivalent) with the result — ideally gated behind whatever
|
|
13
|
+
* analytics consent the site already operates under.
|
|
14
|
+
*
|
|
15
|
+
* Possession of a fresh signed token IS the authorization (the same trust
|
|
16
|
+
* model as unsubscribe links): tokens are signed with BETTER_AUTH_SECRET,
|
|
17
|
+
* expire after an hour, and resolve to nothing but the distinct id + send id.
|
|
18
|
+
*/
|
|
19
|
+
const identifyRoute = createRoute({
|
|
20
|
+
method: "post",
|
|
21
|
+
path: "/identify",
|
|
22
|
+
tags: ["Tracking"],
|
|
23
|
+
summary: "Exchange a redirect identity token for the distinct id",
|
|
24
|
+
request: {
|
|
25
|
+
body: {
|
|
26
|
+
content: {
|
|
27
|
+
"application/json": {
|
|
28
|
+
schema: z.object({ token: z.string().min(1).max(2048) }),
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
responses: {
|
|
34
|
+
200: {
|
|
35
|
+
description: "Resolved identity",
|
|
36
|
+
content: {
|
|
37
|
+
"application/json": {
|
|
38
|
+
schema: z.object({
|
|
39
|
+
distinctId: z.string(),
|
|
40
|
+
emailSendId: z.string().optional(),
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
400: { description: "Invalid or expired token" },
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const identifyRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
50
|
+
identifyRoute,
|
|
51
|
+
async (c) => {
|
|
52
|
+
const { token } = c.req.valid("json");
|
|
53
|
+
const { env } = c.get("container");
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const payload = validateIdentityToken({
|
|
57
|
+
token,
|
|
58
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
59
|
+
});
|
|
60
|
+
return c.json(
|
|
61
|
+
{ distinctId: payload.distinctId, emailSendId: payload.emailSendId },
|
|
62
|
+
200,
|
|
63
|
+
);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err instanceof InvalidIdentityTokenError) {
|
|
66
|
+
return c.body(null, 400);
|
|
67
|
+
}
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
);
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
2
|
import type { AppEnv } from "../../app.js";
|
|
3
|
+
import { answerRouter } from "./answer.js";
|
|
3
4
|
import { clickRouter } from "./click.js";
|
|
5
|
+
import { identifyRouter } from "./identify.js";
|
|
4
6
|
import { openRouter } from "./open.js";
|
|
5
7
|
|
|
6
8
|
export const trackingRouter = new OpenAPIHono<AppEnv>();
|
|
7
9
|
|
|
8
10
|
trackingRouter.route("/", clickRouter);
|
|
9
11
|
trackingRouter.route("/", openRouter);
|
|
12
|
+
trackingRouter.route("/", answerRouter);
|
|
13
|
+
trackingRouter.route("/", identifyRouter);
|
package/src/worker.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "./workflows/bucket-backfill.js";
|
|
17
17
|
import { bucketReconcileTask } from "./workflows/bucket-reconcile.js";
|
|
18
18
|
import { checkAlertsTask } from "./workflows/check-alerts.js";
|
|
19
|
+
import { confirmSemanticClickTask } from "./workflows/confirm-semantic-click.js";
|
|
19
20
|
import {
|
|
20
21
|
deliverWebhookTask,
|
|
21
22
|
reapDueWebhookDeliveriesTask,
|
|
@@ -81,6 +82,7 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
|
|
|
81
82
|
reapStuckCampaignsTask,
|
|
82
83
|
deliverWebhookTask,
|
|
83
84
|
reapDueWebhookDeliveriesTask,
|
|
85
|
+
confirmSemanticClickTask,
|
|
84
86
|
checkAlertsTask,
|
|
85
87
|
bucketReconcileTask,
|
|
86
88
|
bucketBackfillTask,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { getJourneyRegistrySingleton } from "../journeys/registry-singleton.js";
|
|
2
|
+
import { getDb } from "../lib/db.js";
|
|
3
|
+
import { hatchet } from "../lib/hatchet.js";
|
|
4
|
+
import { createLogger } from "../lib/logger.js";
|
|
5
|
+
import {
|
|
6
|
+
type ConfirmSemanticClickInput,
|
|
7
|
+
confirmSemanticClick,
|
|
8
|
+
} from "../lib/semantic-click.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Deferred confirmation of a semantic-link answer, enqueued per candidate
|
|
12
|
+
* click by the click route. The deferral (≈ the burst window, 30s) is the
|
|
13
|
+
* point: an inline gate can never suppress a scanner's FIRST click because
|
|
14
|
+
* the rest of the burst hasn't happened yet — this task judges the click with
|
|
15
|
+
* the whole window visible on both sides.
|
|
16
|
+
*
|
|
17
|
+
* Retries are safe: the claim is an idempotency-keyed `user_events` insert
|
|
18
|
+
* whose failed-publish path rolls back inside `ingestEvent`, the stamp is
|
|
19
|
+
* `IS NULL`-guarded, and the outbound emit carries a `dedupeKey`. Self-
|
|
20
|
+
* bootstraps deps from the process (cron-style; no request container).
|
|
21
|
+
*/
|
|
22
|
+
export const confirmSemanticClickTask = hatchet.task({
|
|
23
|
+
name: "confirm-semantic-click",
|
|
24
|
+
retries: 3,
|
|
25
|
+
executionTimeout: "90s",
|
|
26
|
+
fn: async (input: ConfirmSemanticClickInput) => {
|
|
27
|
+
return confirmSemanticClick(
|
|
28
|
+
{
|
|
29
|
+
db: getDb(),
|
|
30
|
+
hatchet,
|
|
31
|
+
registry: getJourneyRegistrySingleton(),
|
|
32
|
+
logger: createLogger(process.env.LOG_LEVEL ?? "info"),
|
|
33
|
+
},
|
|
34
|
+
input,
|
|
35
|
+
);
|
|
36
|
+
},
|
|
37
|
+
});
|