@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.
@@ -0,0 +1,194 @@
1
+ import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
2
+ import type { JourneyRegistry } from "@hogsend/core/registry";
3
+ import {
4
+ type Database,
5
+ linkClicks,
6
+ trackedLinks,
7
+ userEvents,
8
+ } from "@hogsend/db";
9
+ import { and, countDistinct, eq, gte, isNull, lte } from "drizzle-orm";
10
+ import type { Logger } from "./logger.js";
11
+ import { emitOutbound } from "./outbound.js";
12
+ import {
13
+ pushTrackingEvent,
14
+ resolveEmailSendContext,
15
+ } from "./tracking-events.js";
16
+
17
+ /**
18
+ * Scanner-burst window: SafeLinks/Proofpoint-style scanners follow EVERY link
19
+ * in an email within seconds of delivery; humans don't. Confirmation of a
20
+ * semantic answer is DEFERRED until the window around the candidate click has
21
+ * fully elapsed, so the gate sees the WHOLE burst — including clicks that land
22
+ * AFTER the candidate. An inline check could never suppress a scanner's first
23
+ * click (the burst isn't visible yet); this one can.
24
+ */
25
+ export const SEMANTIC_BURST_WINDOW_MS = 30_000;
26
+ export const SEMANTIC_BURST_DISTINCT_LINKS = 3;
27
+
28
+ // Type alias (NOT interface) so it picks up an implicit index signature and
29
+ // satisfies Hatchet's JsonObject task-input constraint.
30
+ export type ConfirmSemanticClickInput = {
31
+ trackedLinkId: string;
32
+ /** ISO instant of the candidate click. */
33
+ clickedAt: string;
34
+ };
35
+
36
+ export type ConfirmSemanticClickResult =
37
+ | { status: "confirmed"; event: string }
38
+ | { status: "suppressed"; distinctLinks: number }
39
+ /** Another link's answer claimed this send's slot first. */
40
+ | { status: "lost" }
41
+ | { status: "skipped"; reason: string };
42
+
43
+ export interface ConfirmSemanticClickDeps {
44
+ db: Database;
45
+ hatchet: HatchetClient;
46
+ registry: JourneyRegistry;
47
+ logger: Logger;
48
+ }
49
+
50
+ /**
51
+ * Confirm (or suppress) one provisional semantic-link answer. Idempotent end
52
+ * to end, so the wrapping Hatchet task can retry safely:
53
+ *
54
+ * 1. Sleep out the remainder of the burst window past the candidate click.
55
+ * 2. Count DISTINCT links of the send clicked inside the window around the
56
+ * candidate — at/over the threshold the whole burst is scanner traffic
57
+ * and the answer is suppressed (the raw clicks stay recorded).
58
+ * 3. Claim the send's answer slot via `ingestEvent` with the
59
+ * `sem:<emailSendId>:<event>` idempotency key (first answer wins; a
60
+ * failed Hatchet publish rolls the claim back inside `ingestEvent`, so a
61
+ * retry re-claims).
62
+ * 4. If we claimed (or a crashed earlier attempt of THIS link did — detected
63
+ * by the stored row's `linkId`), stamp `semanticEmittedAt` and emit the
64
+ * `email.action` outbound envelope with the same key as `dedupeKey`, so
65
+ * re-runs are per-endpoint no-ops.
66
+ */
67
+ export async function confirmSemanticClick(
68
+ deps: ConfirmSemanticClickDeps,
69
+ input: ConfirmSemanticClickInput,
70
+ ): Promise<ConfirmSemanticClickResult> {
71
+ const { db, hatchet, registry, logger } = deps;
72
+
73
+ const clickedAtMs = Date.parse(input.clickedAt);
74
+ if (Number.isNaN(clickedAtMs)) {
75
+ return { status: "skipped", reason: "bad_clicked_at" };
76
+ }
77
+
78
+ const rows = await db
79
+ .select({
80
+ id: trackedLinks.id,
81
+ emailSendId: trackedLinks.emailSendId,
82
+ originalUrl: trackedLinks.originalUrl,
83
+ event: trackedLinks.event,
84
+ eventProperties: trackedLinks.eventProperties,
85
+ })
86
+ .from(trackedLinks)
87
+ .where(eq(trackedLinks.id, input.trackedLinkId))
88
+ .limit(1);
89
+ const link = rows[0];
90
+ if (!link?.event) {
91
+ return { status: "skipped", reason: "not_semantic" };
92
+ }
93
+ const semanticEvent = link.event;
94
+
95
+ // (1) Let the burst window close before judging the click.
96
+ const remainingMs = clickedAtMs + SEMANTIC_BURST_WINDOW_MS - Date.now();
97
+ if (remainingMs > 0) {
98
+ await new Promise((resolve) => setTimeout(resolve, remainingMs));
99
+ }
100
+
101
+ // (2) Whole-burst check: distinct links of this send clicked in the window
102
+ // AROUND the candidate (before AND after — the deferral is what makes the
103
+ // "after" half visible).
104
+ const windowStart = new Date(clickedAtMs - SEMANTIC_BURST_WINDOW_MS);
105
+ const windowEnd = new Date(clickedAtMs + SEMANTIC_BURST_WINDOW_MS);
106
+ const burst = await db
107
+ .select({ n: countDistinct(linkClicks.trackedLinkId) })
108
+ .from(linkClicks)
109
+ .innerJoin(trackedLinks, eq(linkClicks.trackedLinkId, trackedLinks.id))
110
+ .where(
111
+ and(
112
+ eq(trackedLinks.emailSendId, link.emailSendId),
113
+ gte(linkClicks.clickedAt, windowStart),
114
+ lte(linkClicks.clickedAt, windowEnd),
115
+ ),
116
+ );
117
+ const distinctLinks = burst[0]?.n ?? 0;
118
+ if (distinctLinks >= SEMANTIC_BURST_DISTINCT_LINKS) {
119
+ logger.warn("Semantic answer suppressed: scanner-like click burst", {
120
+ emailSendId: link.emailSendId,
121
+ linkId: link.id,
122
+ event: semanticEvent,
123
+ distinctLinks,
124
+ });
125
+ return { status: "suppressed", distinctLinks };
126
+ }
127
+
128
+ const ctx = await resolveEmailSendContext(db, link.emailSendId);
129
+ if (!ctx) {
130
+ return { status: "skipped", reason: "no_send_context" };
131
+ }
132
+
133
+ // (3) Claim the answer slot. Duplicate key → stored=false BEFORE the Hatchet
134
+ // push, so journeys/destinations see at most one answer per (send, event).
135
+ const semKey = `sem:${link.emailSendId}:${semanticEvent}`;
136
+ const result = await pushTrackingEvent({
137
+ db,
138
+ hatchet,
139
+ registry,
140
+ logger,
141
+ event: semanticEvent,
142
+ emailSendId: link.emailSendId,
143
+ properties: {
144
+ ...(link.eventProperties ?? {}),
145
+ linkId: link.id,
146
+ },
147
+ resolvedContext: ctx,
148
+ idempotencyKey: semKey,
149
+ });
150
+
151
+ // (4) Claimer determination. stored=false usually means another link won —
152
+ // but if the stored row carries THIS link's id, it is a crashed earlier
153
+ // attempt of this very confirmation, and the (idempotent) tail must re-run.
154
+ let isClaimer = result?.stored ?? false;
155
+ if (!isClaimer) {
156
+ const existing = await db
157
+ .select({ properties: userEvents.properties })
158
+ .from(userEvents)
159
+ .where(eq(userEvents.idempotencyKey, semKey))
160
+ .limit(1);
161
+ isClaimer = existing[0]?.properties?.linkId === link.id;
162
+ if (!isClaimer) {
163
+ return { status: "lost" };
164
+ }
165
+ }
166
+
167
+ await db
168
+ .update(trackedLinks)
169
+ .set({ semanticEmittedAt: new Date(), updatedAt: new Date() })
170
+ .where(
171
+ and(eq(trackedLinks.id, link.id), isNull(trackedLinks.semanticEmittedAt)),
172
+ );
173
+
174
+ await emitOutbound({
175
+ db,
176
+ hatchet,
177
+ logger,
178
+ event: "email.action",
179
+ dedupeKey: semKey,
180
+ payload: {
181
+ event: semanticEvent,
182
+ properties: link.eventProperties ?? null,
183
+ emailSendId: link.emailSendId,
184
+ templateKey: ctx.templateKey ?? null,
185
+ userId: ctx.userId ?? null,
186
+ to: ctx.to ?? ctx.userEmail ?? "",
187
+ at: new Date().toISOString(),
188
+ linkId: link.id,
189
+ linkUrl: link.originalUrl,
190
+ },
191
+ });
192
+
193
+ return { status: "confirmed", event: semanticEvent };
194
+ }
@@ -2,7 +2,7 @@ import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
2
2
  import type { JourneyRegistry } from "@hogsend/core/registry";
3
3
  import { type Database, emailSends, journeyStates } from "@hogsend/db";
4
4
  import { eq } from "drizzle-orm";
5
- import { ingestEvent } from "./ingestion.js";
5
+ import { type IngestResult, ingestEvent } from "./ingestion.js";
6
6
  import type { Logger } from "./logger.js";
7
7
 
8
8
  interface EmailSendContext {
@@ -122,6 +122,13 @@ export interface PushTrackingEventOpts {
122
122
  * lazily.
123
123
  */
124
124
  resolvedContext?: EmailSendContext | null;
125
+ /**
126
+ * Threaded straight into `ingestEvent` — a duplicate key returns
127
+ * `{ stored: false }` BEFORE the Hatchet push, so journeys never see the
128
+ * duplicate. Semantic link answers use `sem:<emailSendId>:<event>` for
129
+ * first-answer-per-send semantics.
130
+ */
131
+ idempotencyKey?: string;
125
132
  }
126
133
 
127
134
  /**
@@ -136,14 +143,14 @@ export interface PushTrackingEventOpts {
136
143
  */
137
144
  export async function pushTrackingEvent(
138
145
  opts: PushTrackingEventOpts,
139
- ): Promise<void> {
146
+ ): Promise<IngestResult | undefined> {
140
147
  const { db, hatchet, registry, logger, event, emailSendId } = opts;
141
148
 
142
149
  const ctx =
143
150
  opts.resolvedContext !== undefined
144
151
  ? opts.resolvedContext
145
152
  : await resolveEmailSendContext(db, emailSendId);
146
- if (!ctx) return;
153
+ if (!ctx) return undefined;
147
154
 
148
155
  const properties: Record<string, unknown> = {
149
156
  emailSendId,
@@ -151,7 +158,7 @@ export async function pushTrackingEvent(
151
158
  ...opts.properties,
152
159
  };
153
160
 
154
- await ingestEvent({
161
+ return await ingestEvent({
155
162
  db,
156
163
  registry,
157
164
  hatchet,
@@ -161,6 +168,7 @@ export async function pushTrackingEvent(
161
168
  userId: ctx.userId,
162
169
  userEmail: ctx.userEmail,
163
170
  eventProperties: properties,
171
+ idempotencyKey: opts.idempotencyKey,
164
172
  },
165
173
  });
166
174
  }
@@ -1,14 +1,129 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import type { Database } from "@hogsend/db";
2
3
  import { trackedLinks } from "@hogsend/db";
4
+ import {
5
+ EMAIL_ACTION_EVENT_ATTR,
6
+ EMAIL_ACTION_PROPS_ATTR,
7
+ HOSTED_ANSWER_HREF,
8
+ } from "@hogsend/email";
3
9
 
4
- const HREF_RE = /href="(https?:\/\/[^"]+)"/gi;
10
+ const ANCHOR_RE = /<a\b[^>]*>/gi;
11
+ const HREF_RE = /\bhref="(https?:\/\/[^"]+)"/i;
12
+ const SENTINEL_HREF_RE = new RegExp(`\\bhref="${HOSTED_ANSWER_HREF}"`, "i");
13
+ const EVENT_ATTR_RE = new RegExp(
14
+ `\\b${EMAIL_ACTION_EVENT_ATTR}="([^"]*)"`,
15
+ "i",
16
+ );
17
+ const PROPS_ATTR_RE = new RegExp(
18
+ `\\b${EMAIL_ACTION_PROPS_ATTR}="([^"]*)"`,
19
+ "i",
20
+ );
21
+ const STRIP_SEMANTIC_ATTRS_RE = new RegExp(
22
+ `\\s*(?:${EMAIL_ACTION_EVENT_ATTR}|${EMAIL_ACTION_PROPS_ATTR})="[^"]*"`,
23
+ "gi",
24
+ );
5
25
 
6
26
  const SKIP_PATTERNS = ["/v1/email/unsubscribe", "/v1/email/preferences"];
7
27
 
28
+ // Engine-owned event vocabularies (both `email.opened` dot-style and
29
+ // `journey:completed` colon-style exist) — a consumer semantic event in these
30
+ // namespaces would corrupt insights or trigger engine-internal logic.
31
+ const RESERVED_EVENT_NAME_RE = /^(?:email|journey|bucket|contact)[.:]/;
32
+
33
+ // Semantic payloads re-emit on every answer and persist indefinitely — keep
34
+ // them small and scalar (non-scalars don't survive the Hatchet wire anyway).
35
+ const MAX_PROPS_JSON_LENGTH = 2048;
36
+
8
37
  function shouldSkipUrl(url: string): boolean {
9
38
  return SKIP_PATTERNS.some((pattern) => url.includes(pattern));
10
39
  }
11
40
 
41
+ // React entity-escapes attribute values at render time. Decode the five
42
+ // entities it emits; `&amp;` LAST so `&amp;quot;` round-trips to `&quot;`.
43
+ function decodeAttributeValue(value: string): string {
44
+ return value
45
+ .replace(/&quot;/g, '"')
46
+ .replace(/&#x27;/g, "'")
47
+ .replace(/&lt;/g, "<")
48
+ .replace(/&gt;/g, ">")
49
+ .replace(/&amp;/g, "&");
50
+ }
51
+
52
+ interface SemanticAttrs {
53
+ event: string;
54
+ /** Raw (encoded) props attribute — part of the dedupe key. */
55
+ propsRaw: string | null;
56
+ properties: Record<string, unknown> | null;
57
+ }
58
+
59
+ /**
60
+ * Extract + validate the semantic metadata off one `<a …>` tag. Returns null
61
+ * for a plain link. Throws on author error — a semantic link that can't be
62
+ * honored must fail the SEND loudly, not degrade into a silent plain link.
63
+ */
64
+ function parseSemanticAttrs(tag: string): SemanticAttrs | null {
65
+ const eventMatch = tag.match(EVENT_ATTR_RE);
66
+ if (!eventMatch) return null;
67
+
68
+ const event = decodeAttributeValue(eventMatch[1] ?? "").trim();
69
+ if (!event) {
70
+ throw new Error(`Semantic link has an empty ${EMAIL_ACTION_EVENT_ATTR}`);
71
+ }
72
+ if (RESERVED_EVENT_NAME_RE.test(event)) {
73
+ throw new Error(
74
+ `Semantic link event "${event}" uses a reserved namespace (email/journey/bucket/contact)`,
75
+ );
76
+ }
77
+
78
+ const propsMatch = tag.match(PROPS_ATTR_RE);
79
+ if (!propsMatch) return { event, propsRaw: null, properties: null };
80
+
81
+ const propsRaw = propsMatch[1] ?? "";
82
+ const decoded = decodeAttributeValue(propsRaw);
83
+ if (decoded.length > MAX_PROPS_JSON_LENGTH) {
84
+ throw new Error(
85
+ `Semantic link "${event}" properties exceed ${MAX_PROPS_JSON_LENGTH} chars`,
86
+ );
87
+ }
88
+
89
+ let parsed: unknown;
90
+ try {
91
+ parsed = JSON.parse(decoded);
92
+ } catch {
93
+ throw new Error(
94
+ `Semantic link "${event}" has unparseable ${EMAIL_ACTION_PROPS_ATTR}`,
95
+ );
96
+ }
97
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
98
+ throw new Error(
99
+ `Semantic link "${event}" properties must be a JSON object`,
100
+ );
101
+ }
102
+ for (const [key, value] of Object.entries(parsed)) {
103
+ const t = typeof value;
104
+ if (value !== null && t !== "string" && t !== "number" && t !== "boolean") {
105
+ throw new Error(
106
+ `Semantic link "${event}" property "${key}" must be a scalar (string/number/boolean/null)`,
107
+ );
108
+ }
109
+ }
110
+
111
+ return {
112
+ event,
113
+ propsRaw,
114
+ properties: parsed as Record<string, unknown>,
115
+ };
116
+ }
117
+
118
+ // One tracked_links row per distinct (url, event, props) tuple: identical
119
+ // semantic links share a row; the same URL under DIFFERENT events/props must
120
+ // NOT collapse (the old URL-only dedupe would merge "yes" and "no" answers
121
+ // that point at the same thanks page).
122
+ function linkKey(url: string, semantic: SemanticAttrs | null): string {
123
+ const sep = String.fromCharCode(0);
124
+ return [url, semantic?.event ?? "", semantic?.propsRaw ?? ""].join(sep);
125
+ }
126
+
12
127
  export async function rewriteLinks(opts: {
13
128
  html: string;
14
129
  emailSendId: string;
@@ -17,32 +132,81 @@ export async function rewriteLinks(opts: {
17
132
  }): Promise<string> {
18
133
  const { html, emailSendId, baseUrl, db } = opts;
19
134
 
20
- const uniqueUrls = new Set<string>();
135
+ const pending = new Map<
136
+ string,
137
+ { id: string; url: string; semantic: SemanticAttrs | null }
138
+ >();
21
139
 
22
- for (const match of html.matchAll(HREF_RE)) {
23
- const url = match[1];
24
- if (url && !shouldSkipUrl(url)) {
25
- uniqueUrls.add(url);
26
- }
27
- }
140
+ for (const match of html.matchAll(ANCHOR_RE)) {
141
+ const tag = match[0];
142
+ const semantic = parseSemanticAttrs(tag);
28
143
 
29
- if (uniqueUrls.size === 0) return html;
144
+ // Sentinel destination: the engine-hosted answer page. Only meaningful on
145
+ // a semantic link, and resolvable only here (the page URL embeds the
146
+ // tracked link's own id, generated client-side below).
147
+ if (SENTINEL_HREF_RE.test(tag)) {
148
+ if (!semantic) {
149
+ throw new Error(
150
+ `href="${HOSTED_ANSWER_HREF}" is only valid on a semantic link (EmailAction)`,
151
+ );
152
+ }
153
+ const key = linkKey(HOSTED_ANSWER_HREF, semantic);
154
+ if (!pending.has(key)) {
155
+ const id = randomUUID();
156
+ pending.set(key, {
157
+ id,
158
+ url: `${baseUrl}/v1/t/a/${id}`,
159
+ semantic,
160
+ });
161
+ }
162
+ continue;
163
+ }
30
164
 
31
- const urlList = [...uniqueUrls];
32
- const rows = await db
33
- .insert(trackedLinks)
34
- .values(urlList.map((url) => ({ emailSendId, originalUrl: url })))
35
- .returning({ id: trackedLinks.id, originalUrl: trackedLinks.originalUrl });
165
+ const url = tag.match(HREF_RE)?.[1];
166
+ if (!url || shouldSkipUrl(url)) {
167
+ if (semantic) {
168
+ throw new Error(
169
+ `Semantic link "${semantic.event}" needs an absolute http(s) href outside unsubscribe/preference URLs`,
170
+ );
171
+ }
172
+ continue;
173
+ }
36
174
 
37
- const urlToId = new Map<string, string>();
38
- for (const row of rows) {
39
- urlToId.set(row.originalUrl, row.id);
175
+ const key = linkKey(url, semantic);
176
+ if (!pending.has(key)) {
177
+ pending.set(key, { id: randomUUID(), url, semantic });
178
+ }
40
179
  }
41
180
 
42
- return html.replace(HREF_RE, (full, url: string) => {
43
- if (shouldSkipUrl(url)) return full;
44
- const linkId = urlToId.get(url);
45
- return linkId ? `href="${baseUrl}/v1/t/c/${linkId}"` : full;
181
+ if (pending.size === 0) return html;
182
+
183
+ await db.insert(trackedLinks).values(
184
+ [...pending.values()].map((link) => ({
185
+ id: link.id,
186
+ emailSendId,
187
+ originalUrl: link.url,
188
+ event: link.semantic?.event,
189
+ eventProperties: link.semantic?.properties ?? undefined,
190
+ })),
191
+ );
192
+
193
+ return html.replace(ANCHOR_RE, (tag) => {
194
+ if (SENTINEL_HREF_RE.test(tag)) {
195
+ const link = pending.get(
196
+ linkKey(HOSTED_ANSWER_HREF, parseSemanticAttrs(tag)),
197
+ );
198
+ if (!link) return tag;
199
+ return tag
200
+ .replace(SENTINEL_HREF_RE, `href="${baseUrl}/v1/t/c/${link.id}"`)
201
+ .replace(STRIP_SEMANTIC_ATTRS_RE, "");
202
+ }
203
+ const url = tag.match(HREF_RE)?.[1];
204
+ if (!url || shouldSkipUrl(url)) return tag;
205
+ const link = pending.get(linkKey(url, parseSemanticAttrs(tag)));
206
+ if (!link) return tag;
207
+ return tag
208
+ .replace(HREF_RE, `href="${baseUrl}/v1/t/c/${link.id}"`)
209
+ .replace(STRIP_SEMANTIC_ATTRS_RE, "");
46
210
  });
47
211
  }
48
212
 
@@ -25,7 +25,7 @@ import { Webhook } from "svix";
25
25
  */
26
26
 
27
27
  /**
28
- * The 13-event catalog — the SINGLE source of truth (schema, routes, client,
28
+ * The 14-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
31
  */
@@ -38,6 +38,7 @@ export const WEBHOOK_EVENT_TYPES = [
38
38
  "email.delivered",
39
39
  "email.opened",
40
40
  "email.clicked",
41
+ "email.action",
41
42
  "email.bounced",
42
43
  "email.complained",
43
44
  "journey.completed",
@@ -0,0 +1,187 @@
1
+ import { trackedLinks } from "@hogsend/db";
2
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
+ import { eq } from "drizzle-orm";
4
+ import type { AppEnv } from "../../app.js";
5
+ import { htmlPage } from "../../lib/html.js";
6
+ import {
7
+ pushTrackingEvent,
8
+ resolveEmailSendContext,
9
+ } from "../../lib/tracking-events.js";
10
+
11
+ /**
12
+ * The hosted answer page — the engine-served landing for a semantic link
13
+ * whose author has no page of their own (`href={HOSTED_ANSWER_HREF}` in
14
+ * `EmailAction`). Possession of the unguessable link id is the auth, the
15
+ * same trust model as unsubscribe.
16
+ *
17
+ * GET shows the recorded answer and an optional free-text box; POST ingests
18
+ * the comment as `<event>.comment` — a real consumer event journeys and
19
+ * destinations can react to. ONE comment per (send, event): the
20
+ * `semc:` idempotency key mirrors first-answer-wins, which also caps what a
21
+ * forwarded link can inject.
22
+ */
23
+
24
+ const COMMENT_MAX = 2000;
25
+
26
+ function escapeHtml(value: string): string {
27
+ return value
28
+ .replace(/&/g, "&amp;")
29
+ .replace(/</g, "&lt;")
30
+ .replace(/>/g, "&gt;")
31
+ .replace(/"/g, "&quot;")
32
+ .replace(/'/g, "&#x27;");
33
+ }
34
+
35
+ function describeAnswer(properties: Record<string, unknown> | null): string {
36
+ if (!properties) return "";
37
+ const parts = Object.entries(properties)
38
+ .filter(([, v]) => v !== null && v !== undefined)
39
+ .map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(String(v))}`);
40
+ return parts.join(" · ");
41
+ }
42
+
43
+ async function loadSemanticLink(
44
+ db: AppEnv["Variables"]["container"]["db"],
45
+ id: string,
46
+ ) {
47
+ const rows = await db
48
+ .select({
49
+ id: trackedLinks.id,
50
+ emailSendId: trackedLinks.emailSendId,
51
+ event: trackedLinks.event,
52
+ eventProperties: trackedLinks.eventProperties,
53
+ })
54
+ .from(trackedLinks)
55
+ .where(eq(trackedLinks.id, id))
56
+ .limit(1);
57
+ const link = rows[0];
58
+ return link?.event ? link : null;
59
+ }
60
+
61
+ const answerPageRoute = createRoute({
62
+ method: "get",
63
+ path: "/a/:id",
64
+ tags: ["Tracking"],
65
+ summary: "Hosted answer page for a semantic link",
66
+ request: {
67
+ params: z.object({ id: z.string().uuid() }),
68
+ },
69
+ responses: {
70
+ 200: {
71
+ description: "Answer page",
72
+ content: { "text/html": { schema: z.string() } },
73
+ },
74
+ 404: { description: "Not a semantic link" },
75
+ },
76
+ });
77
+
78
+ const answerCommentRoute = createRoute({
79
+ method: "post",
80
+ path: "/a/:id",
81
+ tags: ["Tracking"],
82
+ summary: "Attach a free-text comment to a semantic answer",
83
+ request: {
84
+ params: z.object({ id: z.string().uuid() }),
85
+ body: {
86
+ content: {
87
+ "application/x-www-form-urlencoded": {
88
+ schema: z.object({ comment: z.string().min(1).max(COMMENT_MAX) }),
89
+ },
90
+ },
91
+ },
92
+ },
93
+ responses: {
94
+ 200: {
95
+ description: "Comment recorded",
96
+ content: { "text/html": { schema: z.string() } },
97
+ },
98
+ 404: { description: "Not a semantic link" },
99
+ },
100
+ });
101
+
102
+ export const answerRouter = new OpenAPIHono<AppEnv>()
103
+ .openapi(answerPageRoute, async (c) => {
104
+ const { id } = c.req.valid("param");
105
+ const { db } = c.get("container");
106
+
107
+ const link = await loadSemanticLink(db, id);
108
+ if (!link) {
109
+ return c.html(
110
+ htmlPage({
111
+ title: "Not found",
112
+ body: "<h1>Nothing here</h1><p>This link doesn't lead anywhere.</p>",
113
+ }),
114
+ 404,
115
+ );
116
+ }
117
+
118
+ const answer = describeAnswer(link.eventProperties);
119
+ return c.html(
120
+ htmlPage({
121
+ title: "Thanks — answer recorded",
122
+ body: `
123
+ <h1>Thanks — that's recorded.</h1>
124
+ ${answer ? `<p>Your answer: <strong>${answer}</strong></p>` : ""}
125
+ <p>Anything you'd like to add? A sentence here goes straight to the team.</p>
126
+ <form method="post" action="">
127
+ <textarea name="comment" rows="4" maxlength="${COMMENT_MAX}" required
128
+ style="width:100%;padding:10px;border:1px solid #e5e7eb;border-radius:8px;font:inherit"></textarea>
129
+ <button type="submit"
130
+ style="margin-top:12px;padding:10px 18px;border:0;border-radius:8px;background:#1a1a1a;color:#fff;font:inherit;cursor:pointer">
131
+ Send
132
+ </button>
133
+ </form>`,
134
+ }),
135
+ );
136
+ })
137
+ .openapi(answerCommentRoute, async (c) => {
138
+ const { id } = c.req.valid("param");
139
+ const { comment } = c.req.valid("form");
140
+ const { db, hatchet, registry, logger } = c.get("container");
141
+
142
+ const link = await loadSemanticLink(db, id);
143
+ if (!link) {
144
+ return c.html(
145
+ htmlPage({
146
+ title: "Not found",
147
+ body: "<h1>Nothing here</h1><p>This link doesn't lead anywhere.</p>",
148
+ }),
149
+ 404,
150
+ );
151
+ }
152
+
153
+ const ctx = await resolveEmailSendContext(db, link.emailSendId);
154
+ if (ctx) {
155
+ // `<event>.comment` is a consumer-namespace event — journeys can wait
156
+ // on it and destinations receive it like any other. First comment per
157
+ // (send, event) wins; repeats are no-ops.
158
+ await pushTrackingEvent({
159
+ db,
160
+ hatchet,
161
+ registry,
162
+ logger,
163
+ event: `${link.event}.comment`,
164
+ emailSendId: link.emailSendId,
165
+ properties: {
166
+ comment,
167
+ parentEvent: link.event,
168
+ ...(link.eventProperties ?? {}),
169
+ linkId: link.id,
170
+ },
171
+ resolvedContext: ctx,
172
+ idempotencyKey: `semc:${link.emailSendId}:${link.event}`,
173
+ }).catch((err) => {
174
+ logger.warn("Failed to ingest answer comment", {
175
+ linkId: link.id,
176
+ error: err instanceof Error ? err.message : String(err),
177
+ });
178
+ });
179
+ }
180
+
181
+ return c.html(
182
+ htmlPage({
183
+ title: "Thank you",
184
+ body: "<h1>Thank you.</h1><p>Your note is on its way to the team. You can close this tab.</p>",
185
+ }),
186
+ );
187
+ });