@hogsend/engine 0.26.0 → 0.27.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/index.ts +7 -0
- package/src/lib/identity-token.ts +6 -0
- package/src/lib/links.ts +106 -0
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/admin/links.ts +453 -0
- package/src/routes/tracking/identify.ts +47 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -40,14 +40,14 @@
|
|
|
40
40
|
"svix": "^1.95.1",
|
|
41
41
|
"winston": "^3.19.0",
|
|
42
42
|
"zod": "^4.4.3",
|
|
43
|
-
"@hogsend/
|
|
44
|
-
"@hogsend/
|
|
45
|
-
"@hogsend/email": "^0.
|
|
46
|
-
"@hogsend/plugin-posthog": "^0.
|
|
47
|
-
"@hogsend/plugin-resend": "^0.
|
|
43
|
+
"@hogsend/core": "^0.27.0",
|
|
44
|
+
"@hogsend/db": "^0.27.0",
|
|
45
|
+
"@hogsend/email": "^0.27.0",
|
|
46
|
+
"@hogsend/plugin-posthog": "^0.27.0",
|
|
47
|
+
"@hogsend/plugin-resend": "^0.27.0"
|
|
48
48
|
},
|
|
49
49
|
"optionalDependencies": {
|
|
50
|
-
"@hogsend/plugin-postmark": "^0.
|
|
50
|
+
"@hogsend/plugin-postmark": "^0.27.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/node": "^22.15.3",
|
package/src/index.ts
CHANGED
|
@@ -327,6 +327,13 @@ export {
|
|
|
327
327
|
releaseLeaderLease,
|
|
328
328
|
renewLeaderLease,
|
|
329
329
|
} from "./lib/leader-lease.js";
|
|
330
|
+
// --- Managed tracked links (channel-agnostic mint — Studio/Discord/share) ---
|
|
331
|
+
export {
|
|
332
|
+
type LinkType,
|
|
333
|
+
type MintedLink,
|
|
334
|
+
type MintLinkOptions,
|
|
335
|
+
mintLink,
|
|
336
|
+
} from "./lib/links.js";
|
|
330
337
|
// --- Logging ---
|
|
331
338
|
export { createLogger, type Logger } from "./lib/logger.js";
|
|
332
339
|
export { createTrackedMailer } from "./lib/mailer.js";
|
|
@@ -64,6 +64,12 @@ export class InvalidIdentityTokenError extends Error {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
const DEFAULT_EXPIRY_SECONDS = 60 * 60; // 1 hour — a click-to-landing hop
|
|
67
|
+
/**
|
|
68
|
+
* The single-use burn sentinel (`POST /v1/t/identify`) lives in Redis for the
|
|
69
|
+
* token's full validity window, so a reshared token can't replay a merge while
|
|
70
|
+
* it would still validate. Kept equal to the token lifetime.
|
|
71
|
+
*/
|
|
72
|
+
export const IDENTITY_TOKEN_TTL_SECONDS = DEFAULT_EXPIRY_SECONDS;
|
|
67
73
|
const IV_LENGTH = 12;
|
|
68
74
|
const TAG_LENGTH = 16;
|
|
69
75
|
|
package/src/lib/links.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { type Database, links, trackedLinks } from "@hogsend/db";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The channel-agnostic MANAGED tracked-link mint — the counterpart to the email
|
|
6
|
+
* HTML-rewrite path (`prepareTrackedHtml`). Any non-email channel (the Studio
|
|
7
|
+
* Links UI, a Discord DM/channel post, SMS, a share link) mints through here.
|
|
8
|
+
*
|
|
9
|
+
* It inserts a durable `links` row (the operator/campaign identity that the
|
|
10
|
+
* Studio lists + manages) plus a `tracked_links` click-counter row pointing back
|
|
11
|
+
* at it, and returns the `/v1/t/c/:id` redirect URL. Email does NOT use this — it
|
|
12
|
+
* rewrites HTML at send time and keeps `tracked_links.link_id` NULL, so the two
|
|
13
|
+
* remain independent consumers of the same click spine.
|
|
14
|
+
*
|
|
15
|
+
* SHARE-SAFE INVARIANT: a link is identity-bearing (carries a `distinctId` the
|
|
16
|
+
* click can stitch + may mint a single-use `hs_t`) ONLY when `type: "personal"`
|
|
17
|
+
* AND an explicit `distinctId` is passed. A `"public"` link NEVER carries a
|
|
18
|
+
* person token — a shared/reshared public link attributes by campaign only.
|
|
19
|
+
*/
|
|
20
|
+
export type LinkType = "personal" | "public";
|
|
21
|
+
|
|
22
|
+
export interface MintLinkOptions {
|
|
23
|
+
db: Database;
|
|
24
|
+
/** The destination URL the redirect 302s to. Must be http(s). */
|
|
25
|
+
url: string;
|
|
26
|
+
/** Public base URL of this instance (the tracking host) — the redirect prefix. */
|
|
27
|
+
baseUrl: string;
|
|
28
|
+
/** Originating channel: "studio" | "discord" | "sms" | "referral" | … (open). */
|
|
29
|
+
source: string;
|
|
30
|
+
/** "personal" (1:1, identity-bearing) | "public" (shareable). Default "public". */
|
|
31
|
+
type?: LinkType;
|
|
32
|
+
/** Operator-facing name (Studio list). */
|
|
33
|
+
label?: string;
|
|
34
|
+
/** UTM-style campaign grouping (public links). */
|
|
35
|
+
campaign?: string;
|
|
36
|
+
/**
|
|
37
|
+
* The canonical contact key a click should stitch — honoured ONLY for
|
|
38
|
+
* `type: "personal"`; dropped for public links (the share-safe invariant).
|
|
39
|
+
*/
|
|
40
|
+
distinctId?: string;
|
|
41
|
+
/** The admin actor who minted it (Studio). */
|
|
42
|
+
createdBy?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MintedLink {
|
|
46
|
+
/** The `links` row id (the managed identity). */
|
|
47
|
+
linkId: string;
|
|
48
|
+
/** The `tracked_links` row id — the `:id` in the redirect URL. */
|
|
49
|
+
trackedLinkId: string;
|
|
50
|
+
/** The short redirect URL: `${baseUrl}/v1/t/c/:id`. */
|
|
51
|
+
url: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reject a non-http(s) destination at mint time. The click route 302s to the
|
|
56
|
+
* stored URL verbatim, so giving operators a UI to mint these would otherwise
|
|
57
|
+
* widen the latent open-redirect into `javascript:`/`data:` territory.
|
|
58
|
+
*/
|
|
59
|
+
function assertHttpUrl(url: string): void {
|
|
60
|
+
let parsed: URL;
|
|
61
|
+
try {
|
|
62
|
+
parsed = new URL(url);
|
|
63
|
+
} catch {
|
|
64
|
+
throw new Error(`mintLink: invalid destination URL: ${url}`);
|
|
65
|
+
}
|
|
66
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`mintLink: destination must be http(s), got "${parsed.protocol}"`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function mintLink(opts: MintLinkOptions): Promise<MintedLink> {
|
|
74
|
+
assertHttpUrl(opts.url);
|
|
75
|
+
const type: LinkType = opts.type ?? "public";
|
|
76
|
+
// A public link must NEVER carry a person token — drop any distinctId.
|
|
77
|
+
const distinctId = type === "personal" ? (opts.distinctId ?? null) : null;
|
|
78
|
+
|
|
79
|
+
const linkId = randomUUID();
|
|
80
|
+
const trackedLinkId = randomUUID();
|
|
81
|
+
|
|
82
|
+
await opts.db.insert(links).values({
|
|
83
|
+
id: linkId,
|
|
84
|
+
originalUrl: opts.url,
|
|
85
|
+
type,
|
|
86
|
+
label: opts.label ?? null,
|
|
87
|
+
campaign: opts.campaign ?? null,
|
|
88
|
+
source: opts.source,
|
|
89
|
+
distinctId,
|
|
90
|
+
createdBy: opts.createdBy ?? null,
|
|
91
|
+
});
|
|
92
|
+
await opts.db.insert(trackedLinks).values({
|
|
93
|
+
id: trackedLinkId,
|
|
94
|
+
linkId,
|
|
95
|
+
emailSendId: null,
|
|
96
|
+
distinctId,
|
|
97
|
+
source: opts.source,
|
|
98
|
+
originalUrl: opts.url,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
linkId,
|
|
103
|
+
trackedLinkId,
|
|
104
|
+
url: `${opts.baseUrl}/v1/t/c/${trackedLinkId}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -17,6 +17,7 @@ import { emailsRouter } from "./emails.js";
|
|
|
17
17
|
import { eventsRouter } from "./events.js";
|
|
18
18
|
import { journeyLogsRouter } from "./journey-logs.js";
|
|
19
19
|
import { journeysRouter } from "./journeys.js";
|
|
20
|
+
import { linksRouter } from "./links.js";
|
|
20
21
|
import { metricsRouter } from "./metrics.js";
|
|
21
22
|
import { preferencesRouter } from "./preferences.js";
|
|
22
23
|
import { providerCredentialsRouter } from "./provider-credentials.js";
|
|
@@ -38,6 +39,7 @@ adminRouter.route("/journeys", journeysRouter);
|
|
|
38
39
|
adminRouter.route("/buckets", bucketsRouter);
|
|
39
40
|
adminRouter.route("/events", eventsRouter);
|
|
40
41
|
adminRouter.route("/emails", emailsRouter);
|
|
42
|
+
adminRouter.route("/links", linksRouter);
|
|
41
43
|
adminRouter.route("/journey-logs", journeyLogsRouter);
|
|
42
44
|
adminRouter.route("/metrics", metricsRouter);
|
|
43
45
|
adminRouter.route("/reporting", reportingRouter);
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { linkClicks, links, trackedLinks } from "@hogsend/db";
|
|
2
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
|
+
import { and, count, desc, eq, inArray, isNull, sql, sum } from "drizzle-orm";
|
|
4
|
+
import type { AppEnv } from "../../app.js";
|
|
5
|
+
import { mintLink } from "../../lib/links.js";
|
|
6
|
+
import { errorSchema } from "../../lib/schemas.js";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Admin CRUD for managed (operator-owned) tracked links — the surface behind
|
|
10
|
+
// the Studio "Links" view. A `links` row is the durable, named identity of a
|
|
11
|
+
// tracked link; the click counter + per-hit `link_clicks` live in
|
|
12
|
+
// `tracked_links` (which back-references via `link_id`). Email's per-send
|
|
13
|
+
// rewritten links are a SEPARATE consumer of the same click spine (they keep
|
|
14
|
+
// `tracked_links.link_id` NULL) and are NOT listed here — this view is the
|
|
15
|
+
// managed/standalone surface only.
|
|
16
|
+
//
|
|
17
|
+
// The click count is computed ON READ by summing `tracked_links.click_count`
|
|
18
|
+
// for the link's `tracked_links` rows — there is deliberately NO denormalized
|
|
19
|
+
// counter on `links`, so a click never has to write back to this table.
|
|
20
|
+
//
|
|
21
|
+
// One FLAT `Link` shape is returned everywhere: `url` is the short redirect URL
|
|
22
|
+
// and `clickCount` is the computed count, both baked onto the row. `GET /:id`
|
|
23
|
+
// adds a `clicks` array. Archive returns the (now-archived) flat link.
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
// Resolves the minting actor across the two admin auth paths (mirrors the audit
|
|
27
|
+
// middleware): an API key carries a `name`; a Better-Auth session carries a
|
|
28
|
+
// `user` whose email we record. Stored verbatim on `links.created_by`.
|
|
29
|
+
function resolveActor(c: {
|
|
30
|
+
get: (k: "apiKey" | "user") => unknown;
|
|
31
|
+
}): string | null {
|
|
32
|
+
const apiKey = c.get("apiKey") as { name?: string } | undefined;
|
|
33
|
+
if (apiKey?.name) return apiKey.name;
|
|
34
|
+
const user = c.get("user") as { email?: string } | null | undefined;
|
|
35
|
+
return user?.email ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Shared response shapes
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const linkSchema = z.object({
|
|
43
|
+
id: z.string(),
|
|
44
|
+
// The link's redirect tracked-row id (one per managed link). Absent only if a
|
|
45
|
+
// link somehow has no tracked row — kept nullable to stay total.
|
|
46
|
+
trackedLinkId: z.string().nullable(),
|
|
47
|
+
originalUrl: z.string(),
|
|
48
|
+
type: z.enum(["personal", "public"]),
|
|
49
|
+
label: z.string().nullable(),
|
|
50
|
+
campaign: z.string().nullable(),
|
|
51
|
+
source: z.string(),
|
|
52
|
+
distinctId: z.string().nullable(),
|
|
53
|
+
createdBy: z.string().nullable(),
|
|
54
|
+
// Computed on read (summed across the link's tracked_links rows).
|
|
55
|
+
clickCount: z.number(),
|
|
56
|
+
// The short redirect URL: `${API_PUBLIC_URL}/v1/t/c/:trackedLinkId`.
|
|
57
|
+
url: z.string(),
|
|
58
|
+
archivedAt: z.string().nullable(),
|
|
59
|
+
createdAt: z.string(),
|
|
60
|
+
updatedAt: z.string(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const clickSchema = z.object({
|
|
64
|
+
id: z.string(),
|
|
65
|
+
trackedLinkId: z.string(),
|
|
66
|
+
ipAddress: z.string().nullable(),
|
|
67
|
+
userAgent: z.string().nullable(),
|
|
68
|
+
clickedAt: z.string(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const linkDetailSchema = linkSchema.extend({
|
|
72
|
+
clicks: z.array(clickSchema),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
type LinkRow = typeof links.$inferSelect;
|
|
76
|
+
type ClickAgg = { clicks: number; trackedLinkId: string | null };
|
|
77
|
+
|
|
78
|
+
// The short redirect URL for a link's tracked row, or a bare prefix if the link
|
|
79
|
+
// has no tracked row (should not happen for a minted link, but keep it total).
|
|
80
|
+
function shortUrlFor(baseUrl: string, trackedLinkId: string | null): string {
|
|
81
|
+
return `${baseUrl}/v1/t/c/${trackedLinkId ?? ""}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function serializeLink(
|
|
85
|
+
row: LinkRow,
|
|
86
|
+
agg: ClickAgg | undefined,
|
|
87
|
+
baseUrl: string,
|
|
88
|
+
): z.infer<typeof linkSchema> {
|
|
89
|
+
const trackedLinkId = agg?.trackedLinkId ?? null;
|
|
90
|
+
return {
|
|
91
|
+
id: row.id,
|
|
92
|
+
trackedLinkId,
|
|
93
|
+
originalUrl: row.originalUrl,
|
|
94
|
+
// The column is a free text; mintLink only ever writes these two values.
|
|
95
|
+
type: row.type === "personal" ? "personal" : "public",
|
|
96
|
+
label: row.label,
|
|
97
|
+
campaign: row.campaign,
|
|
98
|
+
source: row.source,
|
|
99
|
+
distinctId: row.distinctId,
|
|
100
|
+
createdBy: row.createdBy,
|
|
101
|
+
clickCount: agg?.clicks ?? 0,
|
|
102
|
+
url: shortUrlFor(baseUrl, trackedLinkId),
|
|
103
|
+
archivedAt: row.archivedAt ? row.archivedAt.toISOString() : null,
|
|
104
|
+
createdAt: row.createdAt.toISOString(),
|
|
105
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Routes
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
const createLinkRoute = createRoute({
|
|
114
|
+
method: "post",
|
|
115
|
+
path: "/",
|
|
116
|
+
tags: ["Admin — Links"],
|
|
117
|
+
summary: "Mint a managed tracked link",
|
|
118
|
+
request: {
|
|
119
|
+
body: {
|
|
120
|
+
content: {
|
|
121
|
+
"application/json": {
|
|
122
|
+
schema: z.object({
|
|
123
|
+
url: z.string().url(),
|
|
124
|
+
type: z.enum(["personal", "public"]).default("public"),
|
|
125
|
+
label: z.string().optional(),
|
|
126
|
+
campaign: z.string().optional(),
|
|
127
|
+
// Honoured ONLY for personal links (the share-safe invariant in
|
|
128
|
+
// mintLink drops it for public). A canonical contact key the click
|
|
129
|
+
// should stitch the visitor's anon session into.
|
|
130
|
+
distinctId: z.string().optional(),
|
|
131
|
+
}),
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
responses: {
|
|
137
|
+
200: {
|
|
138
|
+
content: { "application/json": { schema: linkSchema } },
|
|
139
|
+
description: "Minted link",
|
|
140
|
+
},
|
|
141
|
+
400: {
|
|
142
|
+
content: { "application/json": { schema: errorSchema } },
|
|
143
|
+
description: "Invalid destination URL",
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const listLinksRoute = createRoute({
|
|
149
|
+
method: "get",
|
|
150
|
+
path: "/",
|
|
151
|
+
tags: ["Admin — Links"],
|
|
152
|
+
summary: "List managed links (newest first)",
|
|
153
|
+
request: {
|
|
154
|
+
query: z.object({
|
|
155
|
+
limit: z.coerce.number().min(1).max(200).default(50),
|
|
156
|
+
offset: z.coerce.number().min(0).default(0),
|
|
157
|
+
type: z.enum(["personal", "public"]).optional(),
|
|
158
|
+
includeArchived: z.coerce.boolean().default(false),
|
|
159
|
+
}),
|
|
160
|
+
},
|
|
161
|
+
responses: {
|
|
162
|
+
200: {
|
|
163
|
+
content: {
|
|
164
|
+
"application/json": {
|
|
165
|
+
schema: z.object({
|
|
166
|
+
links: z.array(linkSchema),
|
|
167
|
+
total: z.number(),
|
|
168
|
+
limit: z.number(),
|
|
169
|
+
offset: z.number(),
|
|
170
|
+
}),
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
description: "Managed link list",
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const getLinkRoute = createRoute({
|
|
179
|
+
method: "get",
|
|
180
|
+
path: "/{id}",
|
|
181
|
+
tags: ["Admin — Links"],
|
|
182
|
+
summary: "Get a managed link with recent clicks",
|
|
183
|
+
request: { params: z.object({ id: z.string().uuid() }) },
|
|
184
|
+
responses: {
|
|
185
|
+
200: {
|
|
186
|
+
content: { "application/json": { schema: linkDetailSchema } },
|
|
187
|
+
description: "Link detail",
|
|
188
|
+
},
|
|
189
|
+
404: {
|
|
190
|
+
content: { "application/json": { schema: errorSchema } },
|
|
191
|
+
description: "Link not found",
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const updateLinkRoute = createRoute({
|
|
197
|
+
method: "patch",
|
|
198
|
+
path: "/{id}",
|
|
199
|
+
tags: ["Admin — Links"],
|
|
200
|
+
summary: "Update a managed link (label + campaign)",
|
|
201
|
+
request: {
|
|
202
|
+
params: z.object({ id: z.string().uuid() }),
|
|
203
|
+
body: {
|
|
204
|
+
content: {
|
|
205
|
+
"application/json": {
|
|
206
|
+
schema: z.object({
|
|
207
|
+
label: z.string().nullable().optional(),
|
|
208
|
+
campaign: z.string().nullable().optional(),
|
|
209
|
+
}),
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
responses: {
|
|
215
|
+
200: {
|
|
216
|
+
content: { "application/json": { schema: linkSchema } },
|
|
217
|
+
description: "Updated link",
|
|
218
|
+
},
|
|
219
|
+
404: {
|
|
220
|
+
content: { "application/json": { schema: errorSchema } },
|
|
221
|
+
description: "Link not found",
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const archiveLinkRoute = createRoute({
|
|
227
|
+
method: "delete",
|
|
228
|
+
path: "/{id}",
|
|
229
|
+
tags: ["Admin — Links"],
|
|
230
|
+
summary: "Archive a managed link (soft-delete)",
|
|
231
|
+
request: { params: z.object({ id: z.string().uuid() }) },
|
|
232
|
+
responses: {
|
|
233
|
+
200: {
|
|
234
|
+
content: { "application/json": { schema: linkSchema } },
|
|
235
|
+
description: "Archived link",
|
|
236
|
+
},
|
|
237
|
+
404: {
|
|
238
|
+
content: { "application/json": { schema: errorSchema } },
|
|
239
|
+
description: "Link not found",
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
type Db = AppEnv["Variables"]["container"]["db"];
|
|
245
|
+
|
|
246
|
+
// Aggregates each link's tracked_links rows in ONE grouped query: the summed
|
|
247
|
+
// click_count (computed on read — no denormalized counter on `links`) and the
|
|
248
|
+
// link's redirect id (a managed link has exactly one tracked row, minted
|
|
249
|
+
// alongside it). Returns a map keyed by link id; links with no tracked rows are
|
|
250
|
+
// simply absent (callers default to 0 / a bare prefix).
|
|
251
|
+
async function aggregateFor(
|
|
252
|
+
db: Db,
|
|
253
|
+
linkIds: string[],
|
|
254
|
+
): Promise<Map<string, ClickAgg>> {
|
|
255
|
+
const map = new Map<string, ClickAgg>();
|
|
256
|
+
if (linkIds.length === 0) return map;
|
|
257
|
+
const rows = await db
|
|
258
|
+
.select({
|
|
259
|
+
linkId: trackedLinks.linkId,
|
|
260
|
+
clicks: sql<number>`coalesce(${sum(trackedLinks.clickCount)}, 0)`.mapWith(
|
|
261
|
+
Number,
|
|
262
|
+
),
|
|
263
|
+
trackedLinkId: sql<string>`min(${trackedLinks.id}::text)`,
|
|
264
|
+
})
|
|
265
|
+
.from(trackedLinks)
|
|
266
|
+
.where(inArray(trackedLinks.linkId, linkIds))
|
|
267
|
+
.groupBy(trackedLinks.linkId);
|
|
268
|
+
for (const r of rows) {
|
|
269
|
+
if (r.linkId) {
|
|
270
|
+
map.set(r.linkId, {
|
|
271
|
+
clicks: r.clicks,
|
|
272
|
+
trackedLinkId: r.trackedLinkId ?? null,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return map;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export const linksRouter = new OpenAPIHono<AppEnv>()
|
|
280
|
+
.openapi(createLinkRoute, async (c) => {
|
|
281
|
+
const { db, env } = c.get("container");
|
|
282
|
+
const body = c.req.valid("json");
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const minted = await mintLink({
|
|
286
|
+
db,
|
|
287
|
+
url: body.url,
|
|
288
|
+
baseUrl: env.API_PUBLIC_URL,
|
|
289
|
+
source: "studio",
|
|
290
|
+
type: body.type,
|
|
291
|
+
label: body.label,
|
|
292
|
+
campaign: body.campaign,
|
|
293
|
+
distinctId: body.distinctId,
|
|
294
|
+
createdBy: resolveActor(c) ?? undefined,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const [row] = await db
|
|
298
|
+
.select()
|
|
299
|
+
.from(links)
|
|
300
|
+
.where(eq(links.id, minted.linkId))
|
|
301
|
+
.limit(1);
|
|
302
|
+
|
|
303
|
+
if (!row) {
|
|
304
|
+
return c.json({ error: "Mint succeeded but link not found" }, 400);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return c.json(
|
|
308
|
+
serializeLink(
|
|
309
|
+
row,
|
|
310
|
+
{ clicks: 0, trackedLinkId: minted.trackedLinkId },
|
|
311
|
+
env.API_PUBLIC_URL,
|
|
312
|
+
),
|
|
313
|
+
200,
|
|
314
|
+
);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
const message = err instanceof Error ? err.message : "Mint failed";
|
|
317
|
+
return c.json({ error: message }, 400);
|
|
318
|
+
}
|
|
319
|
+
})
|
|
320
|
+
.openapi(listLinksRoute, async (c) => {
|
|
321
|
+
const { db, env } = c.get("container");
|
|
322
|
+
const { limit, offset, type, includeArchived } = c.req.valid("query");
|
|
323
|
+
|
|
324
|
+
const where = and(
|
|
325
|
+
includeArchived ? undefined : isNull(links.archivedAt),
|
|
326
|
+
type ? eq(links.type, type) : undefined,
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const [rows, totalRows] = await Promise.all([
|
|
330
|
+
db
|
|
331
|
+
.select()
|
|
332
|
+
.from(links)
|
|
333
|
+
.where(where)
|
|
334
|
+
.orderBy(desc(links.createdAt))
|
|
335
|
+
.limit(limit)
|
|
336
|
+
.offset(offset),
|
|
337
|
+
db.select({ value: count() }).from(links).where(where),
|
|
338
|
+
]);
|
|
339
|
+
|
|
340
|
+
const agg = await aggregateFor(
|
|
341
|
+
db,
|
|
342
|
+
rows.map((r) => r.id),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
return c.json(
|
|
346
|
+
{
|
|
347
|
+
links: rows.map((row) =>
|
|
348
|
+
serializeLink(row, agg.get(row.id), env.API_PUBLIC_URL),
|
|
349
|
+
),
|
|
350
|
+
total: totalRows[0]?.value ?? 0,
|
|
351
|
+
limit,
|
|
352
|
+
offset,
|
|
353
|
+
},
|
|
354
|
+
200,
|
|
355
|
+
);
|
|
356
|
+
})
|
|
357
|
+
.openapi(getLinkRoute, async (c) => {
|
|
358
|
+
const { db, env } = c.get("container");
|
|
359
|
+
const { id } = c.req.valid("param");
|
|
360
|
+
|
|
361
|
+
const [row] = await db
|
|
362
|
+
.select()
|
|
363
|
+
.from(links)
|
|
364
|
+
.where(eq(links.id, id))
|
|
365
|
+
.limit(1);
|
|
366
|
+
|
|
367
|
+
if (!row) {
|
|
368
|
+
return c.json({ error: "Link not found" }, 404);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Recent clicks joined via the link's tracked_links rows, newest first,
|
|
372
|
+
// capped. The aggregate gives the summed count + the redirect id.
|
|
373
|
+
const [agg, clickRows] = await Promise.all([
|
|
374
|
+
aggregateFor(db, [id]),
|
|
375
|
+
db
|
|
376
|
+
.select({
|
|
377
|
+
id: linkClicks.id,
|
|
378
|
+
trackedLinkId: linkClicks.trackedLinkId,
|
|
379
|
+
ipAddress: linkClicks.ipAddress,
|
|
380
|
+
userAgent: linkClicks.userAgent,
|
|
381
|
+
clickedAt: linkClicks.clickedAt,
|
|
382
|
+
})
|
|
383
|
+
.from(linkClicks)
|
|
384
|
+
.innerJoin(trackedLinks, eq(linkClicks.trackedLinkId, trackedLinks.id))
|
|
385
|
+
.where(eq(trackedLinks.linkId, id))
|
|
386
|
+
.orderBy(desc(linkClicks.clickedAt))
|
|
387
|
+
.limit(50),
|
|
388
|
+
]);
|
|
389
|
+
|
|
390
|
+
return c.json(
|
|
391
|
+
{
|
|
392
|
+
...serializeLink(row, agg.get(id), env.API_PUBLIC_URL),
|
|
393
|
+
clicks: clickRows.map((cl) => ({
|
|
394
|
+
id: cl.id,
|
|
395
|
+
trackedLinkId: cl.trackedLinkId,
|
|
396
|
+
ipAddress: cl.ipAddress,
|
|
397
|
+
userAgent: cl.userAgent,
|
|
398
|
+
clickedAt: cl.clickedAt.toISOString(),
|
|
399
|
+
})),
|
|
400
|
+
},
|
|
401
|
+
200,
|
|
402
|
+
);
|
|
403
|
+
})
|
|
404
|
+
.openapi(updateLinkRoute, async (c) => {
|
|
405
|
+
const { db, env } = c.get("container");
|
|
406
|
+
const { id } = c.req.valid("param");
|
|
407
|
+
const body = c.req.valid("json");
|
|
408
|
+
|
|
409
|
+
const patch: Partial<Pick<LinkRow, "label" | "campaign">> & {
|
|
410
|
+
updatedAt: Date;
|
|
411
|
+
} = { updatedAt: new Date() };
|
|
412
|
+
if (body.label !== undefined) patch.label = body.label;
|
|
413
|
+
if (body.campaign !== undefined) patch.campaign = body.campaign;
|
|
414
|
+
|
|
415
|
+
const [updated] = await db
|
|
416
|
+
.update(links)
|
|
417
|
+
.set(patch)
|
|
418
|
+
.where(eq(links.id, id))
|
|
419
|
+
.returning();
|
|
420
|
+
|
|
421
|
+
if (!updated) {
|
|
422
|
+
return c.json({ error: "Link not found" }, 404);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const agg = await aggregateFor(db, [updated.id]);
|
|
426
|
+
return c.json(
|
|
427
|
+
serializeLink(updated, agg.get(updated.id), env.API_PUBLIC_URL),
|
|
428
|
+
200,
|
|
429
|
+
);
|
|
430
|
+
})
|
|
431
|
+
.openapi(archiveLinkRoute, async (c) => {
|
|
432
|
+
const { db, env } = c.get("container");
|
|
433
|
+
const { id } = c.req.valid("param");
|
|
434
|
+
|
|
435
|
+
const archivedAt = new Date();
|
|
436
|
+
// Archive only if not already archived — a second DELETE is a 404, not a
|
|
437
|
+
// silent re-archive. History (link_clicks via tracked_links) survives.
|
|
438
|
+
const [archived] = await db
|
|
439
|
+
.update(links)
|
|
440
|
+
.set({ archivedAt, updatedAt: archivedAt })
|
|
441
|
+
.where(and(eq(links.id, id), isNull(links.archivedAt)))
|
|
442
|
+
.returning();
|
|
443
|
+
|
|
444
|
+
if (!archived) {
|
|
445
|
+
return c.json({ error: "Link not found" }, 404);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const agg = await aggregateFor(db, [archived.id]);
|
|
449
|
+
return c.json(
|
|
450
|
+
serializeLink(archived, agg.get(archived.id), env.API_PUBLIC_URL),
|
|
451
|
+
200,
|
|
452
|
+
);
|
|
453
|
+
});
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
2
3
|
import type { AppEnv } from "../../app.js";
|
|
3
4
|
import {
|
|
5
|
+
IDENTITY_TOKEN_TTL_SECONDS,
|
|
4
6
|
InvalidIdentityTokenError,
|
|
5
7
|
validateIdentityToken,
|
|
6
8
|
} from "../../lib/identity-token.js";
|
|
9
|
+
import { getRedis } from "../../lib/redis.js";
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* Exchange a redirect identity token (`hs_t`) for the distinct id, AND — when
|
|
@@ -82,6 +85,50 @@ export const identifyRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
82
85
|
throw err;
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
// SINGLE-USE BURN (anti-reshare): a tracked link can be forwarded, so the
|
|
89
|
+
// `hs_t` token rides into other inboxes. The control is single-use — the
|
|
90
|
+
// FIRST exchange of a token wins; a second exchange of the SAME token must
|
|
91
|
+
// NOT fire another identify/merge (a reshared link can't keep folding the
|
|
92
|
+
// subject around). Key on a sha256 of the RAW token (never the plaintext id)
|
|
93
|
+
// and claim it atomically with SET … NX; if the claim loses (`null`), the
|
|
94
|
+
// token is already spent → return a 200 no-op (never error the landing
|
|
95
|
+
// page). TTL matches the token lifetime so the sentinel covers the whole
|
|
96
|
+
// window in which the token would still validate.
|
|
97
|
+
// The resolve-only response (no server merge) — returned when the token is
|
|
98
|
+
// already spent.
|
|
99
|
+
const resolveOnly = {
|
|
100
|
+
distinctId: payload.distinctId,
|
|
101
|
+
src: payload.src,
|
|
102
|
+
emailSendId: payload.emailSendId,
|
|
103
|
+
};
|
|
104
|
+
const burnKey = `hs_t:burn:${createHash("sha256").update(token).digest("hex")}`;
|
|
105
|
+
try {
|
|
106
|
+
const claimed = await getRedis().set(
|
|
107
|
+
burnKey,
|
|
108
|
+
"1",
|
|
109
|
+
"EX",
|
|
110
|
+
IDENTITY_TOKEN_TTL_SECONDS,
|
|
111
|
+
"NX",
|
|
112
|
+
);
|
|
113
|
+
if (claimed === null) {
|
|
114
|
+
// Token already spent — no-op (no identify/merge), still 200 the page.
|
|
115
|
+
logger.info("identify: token already spent (single-use burn)", {
|
|
116
|
+
src: payload.src,
|
|
117
|
+
});
|
|
118
|
+
return c.json(resolveOnly, 200);
|
|
119
|
+
}
|
|
120
|
+
} catch (err) {
|
|
121
|
+
// Redis unavailable — degrade to the normal identify. The burn works when
|
|
122
|
+
// Redis is up (≈ always); a Redis-down window restores the pre-burn merge
|
|
123
|
+
// behaviour rather than coupling the exchange to Redis liveness or dropping
|
|
124
|
+
// legitimate first-time merges. Never fail the exchange. (A stricter deploy
|
|
125
|
+
// could fail CLOSED here — skip the merge — to also close the narrow
|
|
126
|
+
// reshare-during-outage replay window; we accept it as best-effort.)
|
|
127
|
+
logger.warn("identify: single-use burn unavailable (degrading)", {
|
|
128
|
+
error: err instanceof Error ? err.message : String(err),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
85
132
|
// MF-5 — fire the alias FIRE-AND-FORGET (never await on the response path)
|
|
86
133
|
// and respond synchronously. The token-proven canonical key is the survivor;
|
|
87
134
|
// the caller's own session is the absorbed (anonymous) side. A provider
|