@iann29/rastro 0.4.0 → 0.6.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/README.md +146 -24
- package/agent/integration.md +66 -29
- package/agent/manifest.json +19 -8
- package/agent/manifest.schema.json +28 -9
- package/dist/client/federation.d.ts +38 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +18 -1
- package/dist/client/federation.js.map +1 -1
- package/dist/client/identity.d.ts +11 -0
- package/dist/client/identity.d.ts.map +1 -0
- package/dist/client/identity.js +123 -0
- package/dist/client/identity.js.map +1 -0
- package/dist/client/index.d.ts +501 -11
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +220 -4
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +8 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +158 -1
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/constants.d.ts +2 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +7 -0
- package/dist/component/constants.js.map +1 -1
- package/dist/component/coverage.d.ts +1 -0
- package/dist/component/coverage.d.ts.map +1 -1
- package/dist/component/coverage.js +6 -1
- package/dist/component/coverage.js.map +1 -1
- package/dist/component/eventStore.d.ts +2 -0
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +51 -1
- package/dist/component/http.js.map +1 -1
- package/dist/component/ingest.d.ts +2 -0
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +72 -22
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/origin.d.ts +70 -0
- package/dist/component/origin.d.ts.map +1 -0
- package/dist/component/origin.js +230 -0
- package/dist/component/origin.js.map +1 -0
- package/dist/component/people.d.ts +79 -0
- package/dist/component/people.d.ts.map +1 -0
- package/dist/component/people.js +249 -0
- package/dist/component/people.js.map +1 -0
- package/dist/component/platforms.d.ts +33 -0
- package/dist/component/platforms.d.ts.map +1 -0
- package/dist/component/platforms.js +328 -0
- package/dist/component/platforms.js.map +1 -0
- package/dist/component/reports.d.ts +35 -74
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +59 -14
- package/dist/component/reports.js.map +1 -1
- package/dist/component/sanitize.d.ts +5 -0
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +33 -1
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +101 -7
- package/dist/component/schema.js +45 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/trackedLinks.d.ts +91 -0
- package/dist/component/trackedLinks.d.ts.map +1 -0
- package/dist/component/trackedLinks.js +314 -0
- package/dist/component/trackedLinks.js.map +1 -0
- package/dist/component/useragent.d.ts +6 -0
- package/dist/component/useragent.d.ts.map +1 -1
- package/dist/component/useragent.js +9 -0
- package/dist/component/useragent.js.map +1 -1
- package/dist/component/validators.d.ts +101 -11
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +62 -1
- package/dist/component/validators.js.map +1 -1
- package/dist/component/visitors.d.ts +38 -2
- package/dist/component/visitors.d.ts.map +1 -1
- package/dist/component/visitors.js +162 -42
- package/dist/component/visitors.js.map +1 -1
- package/dist/react/index.d.ts +9 -5
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +36 -5
- package/dist/react/index.js.map +1 -1
- package/dist/tracker/generated.d.ts +6 -6
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +6 -6
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.d.ts +3 -1
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +56 -5
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/docs/federation.md +24 -0
- package/docs/identity.md +307 -0
- package/docs/upgrading.md +190 -19
- package/llms.txt +11 -2
- package/package.json +5 -3
- package/src/component/_generated/api.ts +8 -0
- package/src/component/_generated/component.ts +251 -1
- package/src/component/constants.ts +7 -0
- package/src/component/coverage.ts +7 -1
- package/src/component/http.ts +56 -1
- package/src/component/ingest.ts +100 -28
- package/src/component/origin.ts +273 -0
- package/src/component/people.ts +321 -0
- package/src/component/platforms.ts +359 -0
- package/src/component/reports.ts +84 -13
- package/src/component/sanitize.ts +39 -1
- package/src/component/schema.ts +53 -0
- package/src/component/trackedLinks.ts +384 -0
- package/src/component/useragent.ts +11 -0
- package/src/component/validators.ts +120 -0
- package/src/component/visitors.ts +232 -55
- package/src/tracker/generated.ts +6 -6
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { v, type Infer } from "convex/values";
|
|
2
|
+
import { internal } from "./_generated/api.js";
|
|
3
|
+
import { internalMutation, mutation, query } from "./_generated/server.js";
|
|
4
|
+
import {
|
|
5
|
+
DAY_MS,
|
|
6
|
+
MAX_TRACKED_LINKS_PER_SITE,
|
|
7
|
+
TRACKED_LINK_CLICK_SHARDS,
|
|
8
|
+
} from "./constants.js";
|
|
9
|
+
import { fail } from "./errors.js";
|
|
10
|
+
import { cleanString, originAllowed, sanitizeOpaqueId } from "./sanitize.js";
|
|
11
|
+
import {
|
|
12
|
+
trackedLinkChannelValidator,
|
|
13
|
+
trackedLinkFieldsValidator,
|
|
14
|
+
} from "./validators.js";
|
|
15
|
+
|
|
16
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/;
|
|
17
|
+
const PLATFORM = /^[a-z0-9_]{2,24}$/;
|
|
18
|
+
const MAX_DESTINATION_LENGTH = 2048;
|
|
19
|
+
const MAX_CAMPAIGN_LENGTH = 64;
|
|
20
|
+
const RECENT_DAYS = 30;
|
|
21
|
+
// Each shard's all-time counter sits beside the daily ones under this day, so
|
|
22
|
+
// a link's total is TRACKED_LINK_CLICK_SHARDS rows however long it has lived.
|
|
23
|
+
const ALL_TIME = 0;
|
|
24
|
+
const PURGE_BATCH = 500;
|
|
25
|
+
|
|
26
|
+
type Channel = Infer<typeof trackedLinkChannelValidator>;
|
|
27
|
+
|
|
28
|
+
const trackedLinkDocumentValidator = trackedLinkFieldsValidator.extend({
|
|
29
|
+
_id: v.id("trackedLinks"),
|
|
30
|
+
_creationTime: v.number(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const redirectValidator = trackedLinkFieldsValidator.pick(
|
|
34
|
+
"slug",
|
|
35
|
+
"destination",
|
|
36
|
+
"platform",
|
|
37
|
+
"channel",
|
|
38
|
+
"campaign",
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
export function isTrackedLinkSlug(value: string): boolean {
|
|
42
|
+
return SLUG.test(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Where a click lands: the destination with the link's utm_* appended. A key
|
|
47
|
+
* the destination already carries wins, and its own query keeps its exact
|
|
48
|
+
* encoding because the tags are appended as text.
|
|
49
|
+
*/
|
|
50
|
+
export function trackedLinkLocation(
|
|
51
|
+
link: Infer<typeof redirectValidator>,
|
|
52
|
+
): string {
|
|
53
|
+
const url = new URL(link.destination);
|
|
54
|
+
const utm: Array<[string, string]> = [
|
|
55
|
+
["utm_source", link.platform],
|
|
56
|
+
["utm_medium", link.channel],
|
|
57
|
+
["utm_campaign", link.campaign ?? link.slug],
|
|
58
|
+
["utm_content", link.slug],
|
|
59
|
+
];
|
|
60
|
+
const tags = new URLSearchParams(
|
|
61
|
+
utm.filter(([key]) => !url.searchParams.has(key)),
|
|
62
|
+
).toString();
|
|
63
|
+
if (tags) url.search = url.search ? `${url.search}&${tags}` : `?${tags}`;
|
|
64
|
+
return url.href;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sanitizeLinkSlug(value: string): string {
|
|
68
|
+
const slug = value.trim().toLowerCase();
|
|
69
|
+
if (!isTrackedLinkSlug(slug)) {
|
|
70
|
+
fail(
|
|
71
|
+
"INVALID_ARGUMENT",
|
|
72
|
+
"slug must be 3 to 32 lowercase letters, digits or hyphens, starting and ending with a letter or digit",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return slug;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sanitizePlatform(value: string): string {
|
|
79
|
+
const platform = value.trim().toLowerCase();
|
|
80
|
+
if (!PLATFORM.test(platform)) {
|
|
81
|
+
fail(
|
|
82
|
+
"INVALID_ARGUMENT",
|
|
83
|
+
"platform must be 2 to 24 lowercase letters, digits or underscores",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return platform;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sanitizeCampaign(value: string | undefined): string | undefined {
|
|
90
|
+
return value === undefined
|
|
91
|
+
? undefined
|
|
92
|
+
: cleanString(value, MAX_CAMPAIGN_LENGTH) || undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// No open redirect: the destination is an absolute http(s) URL on one of the
|
|
96
|
+
// site's own domains, the same rule the ingestion origin check applies.
|
|
97
|
+
function sanitizeDestination(value: string, domains: string[]): string {
|
|
98
|
+
let url: URL | undefined;
|
|
99
|
+
try {
|
|
100
|
+
if (value.length <= MAX_DESTINATION_LENGTH) {
|
|
101
|
+
url = new URL(cleanString(value, MAX_DESTINATION_LENGTH));
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
url = undefined;
|
|
105
|
+
}
|
|
106
|
+
if (
|
|
107
|
+
!url ||
|
|
108
|
+
(url.protocol !== "http:" && url.protocol !== "https:") ||
|
|
109
|
+
url.username ||
|
|
110
|
+
url.password
|
|
111
|
+
) {
|
|
112
|
+
fail(
|
|
113
|
+
"INVALID_ARGUMENT",
|
|
114
|
+
"destination must be an absolute http(s) URL without credentials",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (!originAllowed(url.href, domains)) {
|
|
118
|
+
fail(
|
|
119
|
+
"INVALID_ARGUMENT",
|
|
120
|
+
"destination must be on one of the site's domains",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return url.href;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const create = mutation({
|
|
127
|
+
args: {
|
|
128
|
+
siteId: v.id("sites"),
|
|
129
|
+
slug: v.string(),
|
|
130
|
+
destination: v.string(),
|
|
131
|
+
platform: v.string(),
|
|
132
|
+
channel: trackedLinkChannelValidator,
|
|
133
|
+
campaign: v.optional(v.string()),
|
|
134
|
+
createdBy: v.string(),
|
|
135
|
+
},
|
|
136
|
+
returns: v.id("trackedLinks"),
|
|
137
|
+
handler: async (ctx, args) => {
|
|
138
|
+
const site = await ctx.db.get("sites", args.siteId);
|
|
139
|
+
if (!site) fail("NOT_FOUND", "site not found");
|
|
140
|
+
const slug = sanitizeLinkSlug(args.slug);
|
|
141
|
+
const destination = sanitizeDestination(args.destination, site.domains);
|
|
142
|
+
const platform = sanitizePlatform(args.platform);
|
|
143
|
+
let createdBy: string;
|
|
144
|
+
try {
|
|
145
|
+
createdBy = sanitizeOpaqueId(args.createdBy, "createdBy");
|
|
146
|
+
} catch (error) {
|
|
147
|
+
fail(
|
|
148
|
+
"INVALID_ARGUMENT",
|
|
149
|
+
error instanceof Error ? error.message : "invalid createdBy",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const taken = await ctx.db
|
|
153
|
+
.query("trackedLinks")
|
|
154
|
+
.withIndex("by_slug", (range) => range.eq("slug", slug))
|
|
155
|
+
.unique();
|
|
156
|
+
if (taken) fail("CONFLICT", "another tracked link already uses this slug");
|
|
157
|
+
const links = await ctx.db
|
|
158
|
+
.query("trackedLinks")
|
|
159
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
160
|
+
.take(MAX_TRACKED_LINKS_PER_SITE);
|
|
161
|
+
if (links.length >= MAX_TRACKED_LINKS_PER_SITE) {
|
|
162
|
+
fail("LIMIT_EXCEEDED", "tracked link limit reached", {
|
|
163
|
+
limit: MAX_TRACKED_LINKS_PER_SITE,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return await ctx.db.insert("trackedLinks", {
|
|
167
|
+
siteId: args.siteId,
|
|
168
|
+
slug,
|
|
169
|
+
destination,
|
|
170
|
+
platform,
|
|
171
|
+
channel: args.channel,
|
|
172
|
+
campaign: sanitizeCampaign(args.campaign),
|
|
173
|
+
createdAt: Date.now(),
|
|
174
|
+
createdBy,
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
/** Everything but the slug, which every link already printed carries. */
|
|
180
|
+
export const update = mutation({
|
|
181
|
+
args: {
|
|
182
|
+
siteId: v.id("sites"),
|
|
183
|
+
linkId: v.id("trackedLinks"),
|
|
184
|
+
destination: v.optional(v.string()),
|
|
185
|
+
platform: v.optional(v.string()),
|
|
186
|
+
channel: v.optional(trackedLinkChannelValidator),
|
|
187
|
+
campaign: v.optional(v.union(v.string(), v.null())),
|
|
188
|
+
disabled: v.optional(v.boolean()),
|
|
189
|
+
},
|
|
190
|
+
returns: v.null(),
|
|
191
|
+
handler: async (ctx, args) => {
|
|
192
|
+
const link = await ctx.db.get("trackedLinks", args.linkId);
|
|
193
|
+
if (!link || link.siteId !== args.siteId) {
|
|
194
|
+
fail("NOT_FOUND", "tracked link not found");
|
|
195
|
+
}
|
|
196
|
+
const patch: {
|
|
197
|
+
destination?: string;
|
|
198
|
+
platform?: string;
|
|
199
|
+
channel?: Channel;
|
|
200
|
+
campaign?: string;
|
|
201
|
+
disabledAt?: number;
|
|
202
|
+
} = {};
|
|
203
|
+
if (args.destination !== undefined) {
|
|
204
|
+
const site = await ctx.db.get("sites", args.siteId);
|
|
205
|
+
if (!site) fail("NOT_FOUND", "site not found");
|
|
206
|
+
patch.destination = sanitizeDestination(args.destination, site.domains);
|
|
207
|
+
}
|
|
208
|
+
if (args.platform !== undefined) {
|
|
209
|
+
patch.platform = sanitizePlatform(args.platform);
|
|
210
|
+
}
|
|
211
|
+
if (args.channel !== undefined) patch.channel = args.channel;
|
|
212
|
+
if (args.campaign !== undefined) {
|
|
213
|
+
patch.campaign = sanitizeCampaign(args.campaign ?? undefined);
|
|
214
|
+
}
|
|
215
|
+
if (args.disabled !== undefined) {
|
|
216
|
+
patch.disabledAt = args.disabled
|
|
217
|
+
? (link.disabledAt ?? Date.now())
|
|
218
|
+
: undefined;
|
|
219
|
+
}
|
|
220
|
+
await ctx.db.patch("trackedLinks", args.linkId, patch);
|
|
221
|
+
return null;
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
export const remove = mutation({
|
|
226
|
+
args: { siteId: v.id("sites"), linkId: v.id("trackedLinks") },
|
|
227
|
+
returns: v.null(),
|
|
228
|
+
handler: async (ctx, args): Promise<null> => {
|
|
229
|
+
const link = await ctx.db.get("trackedLinks", args.linkId);
|
|
230
|
+
if (!link || link.siteId !== args.siteId) {
|
|
231
|
+
fail("NOT_FOUND", "tracked link not found");
|
|
232
|
+
}
|
|
233
|
+
await ctx.db.delete("trackedLinks", args.linkId);
|
|
234
|
+
await ctx.scheduler.runAfter(0, internal.trackedLinks.purgeClicks, {
|
|
235
|
+
linkId: args.linkId,
|
|
236
|
+
});
|
|
237
|
+
return null;
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The site's links, newest first, with their clicks and bot hits: all time,
|
|
243
|
+
* and over the 30 UTC days ending on the day of `now`. The caller passes the
|
|
244
|
+
* clock so the subscription does not go stale.
|
|
245
|
+
*/
|
|
246
|
+
export const list = query({
|
|
247
|
+
args: { siteId: v.id("sites"), now: v.number() },
|
|
248
|
+
returns: v.array(
|
|
249
|
+
trackedLinkDocumentValidator.extend({
|
|
250
|
+
clicks: v.number(),
|
|
251
|
+
bots: v.number(),
|
|
252
|
+
clicksLast30Days: v.number(),
|
|
253
|
+
botsLast30Days: v.number(),
|
|
254
|
+
}),
|
|
255
|
+
),
|
|
256
|
+
handler: async (ctx, args) => {
|
|
257
|
+
if (!Number.isSafeInteger(args.now) || args.now < RECENT_DAYS * DAY_MS) {
|
|
258
|
+
fail("INVALID_ARGUMENT", "now must be a Unix time in milliseconds");
|
|
259
|
+
}
|
|
260
|
+
const since =
|
|
261
|
+
Math.floor(args.now / DAY_MS) * DAY_MS - (RECENT_DAYS - 1) * DAY_MS;
|
|
262
|
+
const links = await ctx.db
|
|
263
|
+
.query("trackedLinks")
|
|
264
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
265
|
+
.order("desc")
|
|
266
|
+
.take(MAX_TRACKED_LINKS_PER_SITE);
|
|
267
|
+
const sum = (rows: Array<{ clicks: number; bots: number }>) =>
|
|
268
|
+
rows.reduce(
|
|
269
|
+
(total, row) => ({
|
|
270
|
+
clicks: total.clicks + row.clicks,
|
|
271
|
+
bots: total.bots + row.bots,
|
|
272
|
+
}),
|
|
273
|
+
{ clicks: 0, bots: 0 },
|
|
274
|
+
);
|
|
275
|
+
// Up to 4 + 120 counter rows per link, so a full site of 200 links whose
|
|
276
|
+
// every link fills every shard every day reads about 24,800 rows, inside
|
|
277
|
+
// the 32,000 documents a query may read. Raising either cap needs a
|
|
278
|
+
// finished day's shards folded into one row first.
|
|
279
|
+
return await Promise.all(
|
|
280
|
+
links.map(async (link) => {
|
|
281
|
+
const total = sum(
|
|
282
|
+
await ctx.db
|
|
283
|
+
.query("trackedLinkClicks")
|
|
284
|
+
.withIndex("by_linkId_and_dayStart_and_shard", (range) =>
|
|
285
|
+
range.eq("linkId", link._id).eq("dayStart", ALL_TIME),
|
|
286
|
+
)
|
|
287
|
+
.take(TRACKED_LINK_CLICK_SHARDS),
|
|
288
|
+
);
|
|
289
|
+
const recent = sum(
|
|
290
|
+
await ctx.db
|
|
291
|
+
.query("trackedLinkClicks")
|
|
292
|
+
.withIndex("by_linkId_and_dayStart_and_shard", (range) =>
|
|
293
|
+
range
|
|
294
|
+
.eq("linkId", link._id)
|
|
295
|
+
.gte("dayStart", since)
|
|
296
|
+
.lt("dayStart", since + RECENT_DAYS * DAY_MS),
|
|
297
|
+
)
|
|
298
|
+
.take(RECENT_DAYS * TRACKED_LINK_CLICK_SHARDS),
|
|
299
|
+
);
|
|
300
|
+
return {
|
|
301
|
+
...link,
|
|
302
|
+
clicks: total.clicks,
|
|
303
|
+
bots: total.bots,
|
|
304
|
+
clicksLast30Days: recent.clicks,
|
|
305
|
+
botsLast30Days: recent.bots,
|
|
306
|
+
};
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Counts one click on a live link and returns where it goes, or null for an
|
|
314
|
+
* unknown or disabled slug. Bots are counted apart and never as clicks. The
|
|
315
|
+
* destination is checked against the site's domains again, so a domain the
|
|
316
|
+
* site has since dropped stops redirecting.
|
|
317
|
+
*/
|
|
318
|
+
export const recordClick = internalMutation({
|
|
319
|
+
args: { slug: v.string(), bot: v.boolean() },
|
|
320
|
+
returns: v.union(redirectValidator, v.null()),
|
|
321
|
+
handler: async (ctx, args) => {
|
|
322
|
+
const link = await ctx.db
|
|
323
|
+
.query("trackedLinks")
|
|
324
|
+
.withIndex("by_slug", (range) => range.eq("slug", args.slug))
|
|
325
|
+
.unique();
|
|
326
|
+
if (!link || link.disabledAt !== undefined) return null;
|
|
327
|
+
const site = await ctx.db.get("sites", link.siteId);
|
|
328
|
+
if (!site || !originAllowed(link.destination, site.domains)) return null;
|
|
329
|
+
const now = Date.now();
|
|
330
|
+
const shard = Math.floor(Math.random() * TRACKED_LINK_CLICK_SHARDS);
|
|
331
|
+
for (const dayStart of [Math.floor(now / DAY_MS) * DAY_MS, ALL_TIME]) {
|
|
332
|
+
const row = await ctx.db
|
|
333
|
+
.query("trackedLinkClicks")
|
|
334
|
+
.withIndex("by_linkId_and_dayStart_and_shard", (range) =>
|
|
335
|
+
range
|
|
336
|
+
.eq("linkId", link._id)
|
|
337
|
+
.eq("dayStart", dayStart)
|
|
338
|
+
.eq("shard", shard),
|
|
339
|
+
)
|
|
340
|
+
.unique();
|
|
341
|
+
if (row) {
|
|
342
|
+
await ctx.db.patch(
|
|
343
|
+
"trackedLinkClicks",
|
|
344
|
+
row._id,
|
|
345
|
+
args.bot ? { bots: row.bots + 1 } : { clicks: row.clicks + 1 },
|
|
346
|
+
);
|
|
347
|
+
} else {
|
|
348
|
+
await ctx.db.insert("trackedLinkClicks", {
|
|
349
|
+
linkId: link._id,
|
|
350
|
+
dayStart,
|
|
351
|
+
shard,
|
|
352
|
+
clicks: args.bot ? 0 : 1,
|
|
353
|
+
bots: args.bot ? 1 : 0,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
slug: link.slug,
|
|
359
|
+
destination: link.destination,
|
|
360
|
+
platform: link.platform,
|
|
361
|
+
channel: link.channel,
|
|
362
|
+
...(link.campaign === undefined ? {} : { campaign: link.campaign }),
|
|
363
|
+
};
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
/** Deletes a removed link's counters a batch at a time. */
|
|
368
|
+
export const purgeClicks = internalMutation({
|
|
369
|
+
args: { linkId: v.id("trackedLinks") },
|
|
370
|
+
returns: v.null(),
|
|
371
|
+
handler: async (ctx, args): Promise<null> => {
|
|
372
|
+
const rows = await ctx.db
|
|
373
|
+
.query("trackedLinkClicks")
|
|
374
|
+
.withIndex("by_linkId_and_dayStart_and_shard", (range) =>
|
|
375
|
+
range.eq("linkId", args.linkId),
|
|
376
|
+
)
|
|
377
|
+
.take(PURGE_BATCH);
|
|
378
|
+
for (const row of rows) await ctx.db.delete("trackedLinkClicks", row._id);
|
|
379
|
+
if (rows.length === PURGE_BATCH) {
|
|
380
|
+
await ctx.scheduler.runAfter(0, internal.trackedLinks.purgeClicks, args);
|
|
381
|
+
}
|
|
382
|
+
return null;
|
|
383
|
+
},
|
|
384
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { PLATFORMS } from "./platforms.js";
|
|
2
|
+
|
|
1
3
|
export type ClientClassification = {
|
|
2
4
|
browser: string;
|
|
3
5
|
os: string;
|
|
@@ -100,6 +102,15 @@ export function classifyUserAgent(userAgent: string): ClientClassification {
|
|
|
100
102
|
};
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The platform whose in-app browser sent the request (Instagram, Facebook,
|
|
107
|
+
* TikTok…), from the marker each app appends to the User-Agent. The system
|
|
108
|
+
* browser a messaging app hands a link to carries none.
|
|
109
|
+
*/
|
|
110
|
+
export function inAppPlatform(userAgent: string): string | undefined {
|
|
111
|
+
return PLATFORMS.find((row) => row.agent?.test(userAgent))?.id;
|
|
112
|
+
}
|
|
113
|
+
|
|
103
114
|
function matchAgent(
|
|
104
115
|
table: readonly (readonly [RegExp, string])[],
|
|
105
116
|
userAgent: string,
|
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
import { v, type Infer } from "convex/values";
|
|
2
2
|
|
|
3
|
+
export const personAttributeValueValidator = v.union(
|
|
4
|
+
v.string(),
|
|
5
|
+
v.number(),
|
|
6
|
+
v.boolean(),
|
|
7
|
+
);
|
|
8
|
+
export const personAttributesValidator = v.record(
|
|
9
|
+
v.string(),
|
|
10
|
+
personAttributeValueValidator,
|
|
11
|
+
);
|
|
12
|
+
export type PersonAttributes = Infer<typeof personAttributesValidator>;
|
|
13
|
+
export const personFilterValidator = v.union(
|
|
14
|
+
v.object({
|
|
15
|
+
field: v.union(
|
|
16
|
+
v.literal("name"),
|
|
17
|
+
v.literal("email"),
|
|
18
|
+
v.literal("visitorId"),
|
|
19
|
+
),
|
|
20
|
+
prefix: v.string(),
|
|
21
|
+
}),
|
|
22
|
+
v.object({
|
|
23
|
+
field: v.literal("attribute"),
|
|
24
|
+
key: v.string(),
|
|
25
|
+
value: personAttributeValueValidator,
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
export type PersonFilter = Infer<typeof personFilterValidator>;
|
|
29
|
+
|
|
30
|
+
/** A profile supplied by the product's trusted backend, shared by its visits. */
|
|
31
|
+
export const visitorProfileValidator = v.object({
|
|
32
|
+
visitorId: v.string(),
|
|
33
|
+
name: v.optional(v.string()),
|
|
34
|
+
email: v.optional(v.string()),
|
|
35
|
+
attributes: v.optional(personAttributesValidator),
|
|
36
|
+
identifiedAt: v.number(),
|
|
37
|
+
updatedAt: v.number(),
|
|
38
|
+
});
|
|
39
|
+
export type VisitorProfile = Infer<typeof visitorProfileValidator>;
|
|
40
|
+
|
|
41
|
+
export const identifyVisitorFields = {
|
|
42
|
+
visitorId: v.string(),
|
|
43
|
+
previousVisitorId: v.optional(v.string()),
|
|
44
|
+
// Omission preserves the value; null explicitly removes it.
|
|
45
|
+
name: v.optional(v.union(v.string(), v.null())),
|
|
46
|
+
email: v.optional(v.union(v.string(), v.null())),
|
|
47
|
+
attributes: v.optional(
|
|
48
|
+
v.record(v.string(), v.union(personAttributeValueValidator, v.null())),
|
|
49
|
+
),
|
|
50
|
+
};
|
|
51
|
+
|
|
3
52
|
export const eventTypeValidator = v.union(
|
|
4
53
|
v.literal("pageview"),
|
|
5
54
|
v.literal("click"),
|
|
@@ -50,6 +99,9 @@ export const trackerEventValidator = v.object({
|
|
|
50
99
|
// Web Vitals measurement: milliseconds, or CLS scaled by 1000. Only "vital"
|
|
51
100
|
// events carry it; sanitization strips it from every other type.
|
|
52
101
|
value: v.optional(v.number()),
|
|
102
|
+
// The names of the click-ID keys on the landing URL (`fbclid`, `gclid`…),
|
|
103
|
+
// never their values; sanitization keeps only the known ones (origin.ts).
|
|
104
|
+
clid: v.optional(v.array(v.string())),
|
|
53
105
|
});
|
|
54
106
|
|
|
55
107
|
export const ingestContextValidator = v.object({
|
|
@@ -61,8 +113,41 @@ export const ingestContextValidator = v.object({
|
|
|
61
113
|
os: v.optional(v.string()),
|
|
62
114
|
device: v.optional(v.string()),
|
|
63
115
|
visitorKey: v.optional(v.string()),
|
|
116
|
+
// The platform whose in-app browser sent the batch (useragent.ts), derived
|
|
117
|
+
// by the HTTP action so the raw User-Agent never reaches a mutation.
|
|
118
|
+
inApp: v.optional(v.string()),
|
|
64
119
|
});
|
|
65
120
|
|
|
121
|
+
// How a session arrived (channel) and which rule proved it (evidence); see
|
|
122
|
+
// origin.ts. "legacy" is never stored: reports mark a session recorded before
|
|
123
|
+
// origins existed with it when they classify that session on read.
|
|
124
|
+
export const channelValidator = v.union(
|
|
125
|
+
v.literal("direct"),
|
|
126
|
+
v.literal("organic_search"),
|
|
127
|
+
v.literal("paid_search"),
|
|
128
|
+
v.literal("organic_social"),
|
|
129
|
+
v.literal("paid_social"),
|
|
130
|
+
v.literal("messaging"),
|
|
131
|
+
v.literal("email"),
|
|
132
|
+
v.literal("affiliate"),
|
|
133
|
+
v.literal("ai"),
|
|
134
|
+
v.literal("display"),
|
|
135
|
+
v.literal("referral"),
|
|
136
|
+
// A link printed off the web: a QR code, a flyer, a receipt. Only a tracked
|
|
137
|
+
// link can say so, since the scan itself carries no referrer.
|
|
138
|
+
v.literal("offline"),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
export const evidenceValidator = v.union(
|
|
142
|
+
v.literal("utm"),
|
|
143
|
+
v.literal("clickId"),
|
|
144
|
+
v.literal("referrer"),
|
|
145
|
+
v.literal("inApp"),
|
|
146
|
+
v.literal("affiliate"),
|
|
147
|
+
v.literal("none"),
|
|
148
|
+
v.literal("legacy"),
|
|
149
|
+
);
|
|
150
|
+
|
|
66
151
|
export const batchedEventValidator = trackerEventValidator.extend({
|
|
67
152
|
...ingestContextValidator.fields,
|
|
68
153
|
aggregateCountry: v.optional(v.string()),
|
|
@@ -94,6 +179,10 @@ export const sessionFieldsValidator = v.object({
|
|
|
94
179
|
exitPath: v.string(),
|
|
95
180
|
referrer: v.optional(v.string()),
|
|
96
181
|
source: v.string(),
|
|
182
|
+
// The first touch's origin (origin.ts); absent on sessions recorded before.
|
|
183
|
+
platform: v.optional(v.string()),
|
|
184
|
+
channel: v.optional(channelValidator),
|
|
185
|
+
evidence: v.optional(evidenceValidator),
|
|
97
186
|
utmSource: v.optional(v.string()),
|
|
98
187
|
utmMedium: v.optional(v.string()),
|
|
99
188
|
utmCampaign: v.optional(v.string()),
|
|
@@ -155,6 +244,10 @@ export const dimensionTypeValidator = v.union(
|
|
|
155
244
|
// host of an outbound click (never its path).
|
|
156
245
|
v.literal("medium"),
|
|
157
246
|
v.literal("outbound"),
|
|
247
|
+
// The session's origin platform, channel and evidence (origin.ts).
|
|
248
|
+
v.literal("platform"),
|
|
249
|
+
v.literal("channel"),
|
|
250
|
+
v.literal("evidence"),
|
|
158
251
|
);
|
|
159
252
|
|
|
160
253
|
export const dimensionSlotValidator = v.object({
|
|
@@ -254,6 +347,31 @@ export const affiliateFieldsValidator = v.object({
|
|
|
254
347
|
updatedAt: v.number(),
|
|
255
348
|
});
|
|
256
349
|
|
|
350
|
+
// Where a tracked link was published: chosen at creation and carried to the
|
|
351
|
+
// landing page as its utm_medium.
|
|
352
|
+
export const trackedLinkChannelValidator = v.union(
|
|
353
|
+
v.literal("organic_social"),
|
|
354
|
+
v.literal("paid_social"),
|
|
355
|
+
v.literal("messaging"),
|
|
356
|
+
v.literal("email"),
|
|
357
|
+
v.literal("affiliate"),
|
|
358
|
+
v.literal("display"),
|
|
359
|
+
v.literal("referral"),
|
|
360
|
+
v.literal("offline"),
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
export const trackedLinkFieldsValidator = v.object({
|
|
364
|
+
siteId: v.id("sites"),
|
|
365
|
+
slug: v.string(),
|
|
366
|
+
destination: v.string(),
|
|
367
|
+
platform: v.string(),
|
|
368
|
+
channel: trackedLinkChannelValidator,
|
|
369
|
+
campaign: v.optional(v.string()),
|
|
370
|
+
createdAt: v.number(),
|
|
371
|
+
createdBy: v.string(),
|
|
372
|
+
disabledAt: v.optional(v.number()),
|
|
373
|
+
});
|
|
374
|
+
|
|
257
375
|
export const ingestResultValidator = v.object({
|
|
258
376
|
accepted: v.number(),
|
|
259
377
|
duplicates: v.number(),
|
|
@@ -278,3 +396,5 @@ export type IngestContext = Infer<typeof ingestContextValidator>;
|
|
|
278
396
|
export type EventProperties = Infer<typeof eventPropertiesValidator>;
|
|
279
397
|
export type FunnelStep = Infer<typeof funnelStepValidator>;
|
|
280
398
|
export type EventType = Infer<typeof eventTypeValidator>;
|
|
399
|
+
export type Channel = Infer<typeof channelValidator>;
|
|
400
|
+
export type Evidence = Infer<typeof evidenceValidator>;
|