@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.
@@ -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 resolveEmailSendContext(db, emailSendId)
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(link.originalUrl, 302);
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
+ });