@iann29/rastro 0.6.0 → 0.8.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.
Files changed (63) hide show
  1. package/README.md +90 -0
  2. package/agent/integration.md +19 -0
  3. package/agent/manifest.json +4 -4
  4. package/dist/client/federation.d.ts +34 -9
  5. package/dist/client/federation.d.ts.map +1 -1
  6. package/dist/client/federation.js +14 -2
  7. package/dist/client/federation.js.map +1 -1
  8. package/dist/client/index.d.ts +365 -5
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +98 -6
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/component/_generated/api.d.ts +2 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -1
  14. package/dist/component/_generated/api.js.map +1 -1
  15. package/dist/component/_generated/component.d.ts +163 -0
  16. package/dist/component/_generated/component.d.ts.map +1 -1
  17. package/dist/component/constants.d.ts +5 -0
  18. package/dist/component/constants.d.ts.map +1 -1
  19. package/dist/component/constants.js +8 -0
  20. package/dist/component/constants.js.map +1 -1
  21. package/dist/component/ingest.d.ts.map +1 -1
  22. package/dist/component/ingest.js +7 -5
  23. package/dist/component/ingest.js.map +1 -1
  24. package/dist/component/leads.d.ts +190 -0
  25. package/dist/component/leads.d.ts.map +1 -0
  26. package/dist/component/leads.js +689 -0
  27. package/dist/component/leads.js.map +1 -0
  28. package/dist/component/reports.d.ts +3 -0
  29. package/dist/component/reports.d.ts.map +1 -1
  30. package/dist/component/reports.js +16 -5
  31. package/dist/component/reports.js.map +1 -1
  32. package/dist/component/sanitize.d.ts +3 -1
  33. package/dist/component/sanitize.d.ts.map +1 -1
  34. package/dist/component/sanitize.js +30 -1
  35. package/dist/component/sanitize.js.map +1 -1
  36. package/dist/component/schema.d.ts +122 -1
  37. package/dist/component/schema.js +39 -1
  38. package/dist/component/schema.js.map +1 -1
  39. package/dist/component/sites.d.ts +4 -0
  40. package/dist/component/sites.d.ts.map +1 -1
  41. package/dist/component/sites.js +13 -2
  42. package/dist/component/sites.js.map +1 -1
  43. package/dist/component/validators.d.ts +696 -1
  44. package/dist/component/validators.d.ts.map +1 -1
  45. package/dist/component/validators.js +130 -0
  46. package/dist/component/validators.js.map +1 -1
  47. package/dist/tracker/generated.d.ts +1 -1
  48. package/dist/tracker/generated.js +1 -1
  49. package/docs/federation-setup.md +29 -0
  50. package/docs/upgrading.md +57 -0
  51. package/llms.txt +2 -0
  52. package/package.json +1 -1
  53. package/src/component/_generated/api.ts +2 -0
  54. package/src/component/_generated/component.ts +186 -0
  55. package/src/component/constants.ts +8 -0
  56. package/src/component/ingest.ts +8 -3
  57. package/src/component/leads.ts +823 -0
  58. package/src/component/reports.ts +23 -5
  59. package/src/component/sanitize.ts +46 -1
  60. package/src/component/schema.ts +45 -0
  61. package/src/component/sites.ts +14 -1
  62. package/src/component/validators.ts +165 -0
  63. package/src/tracker/generated.ts +1 -1
@@ -0,0 +1,823 @@
1
+ import { v, type Infer } from "convex/values";
2
+ import type { Doc, Id } from "./_generated/dataModel.js";
3
+ import {
4
+ mutation,
5
+ query,
6
+ type MutationCtx,
7
+ type QueryCtx,
8
+ } from "./_generated/server.js";
9
+ import {
10
+ DAY_MS,
11
+ MAX_AD_CATALOG_BATCH,
12
+ MAX_LEAD_EVENTS_PER_LEAD,
13
+ MAX_LEAD_LIST,
14
+ MAX_LEAD_REPORT_ADS,
15
+ MAX_LEAD_REPORT_ROWS,
16
+ MAX_REPORT_RANGE_DAYS,
17
+ MAX_REPORT_SITES,
18
+ } from "./constants.js";
19
+ import { fail } from "./errors.js";
20
+ import { cleanString, isValidMoney, sanitizeOpaqueId } from "./sanitize.js";
21
+ import {
22
+ leadAdRowValidator,
23
+ leadAdValidator,
24
+ leadAdsetRowValidator,
25
+ leadCampaignRowValidator,
26
+ leadChannelValidator,
27
+ leadJourneyValidator,
28
+ leadsReportValidator,
29
+ leadStageChangeValidator,
30
+ leadSummaryValidator,
31
+ } from "./validators.js";
32
+
33
+ /*
34
+ * Leads: conversations the host's server reports, with no web session behind
35
+ * them. A WhatsApp lead that came from a Click-to-WhatsApp ad never touched the
36
+ * site, so none of the tracker's tables can hold it.
37
+ *
38
+ * Counting is by cohort: every stage a lead reaches is counted on the UTC day
39
+ * the lead first arrived, so a campaign's row for a period reads "of the leads
40
+ * it brought then, how many qualified and how many bought". `qualified` counts
41
+ * leads that ever qualified (a sale implies it); `won` and revenue count the
42
+ * leads that are sold now, so undoing a sale takes it back out.
43
+ */
44
+
45
+ const MIN_TIME = Date.UTC(2015, 0, 1);
46
+ const ENTRY_POINT = /^[a-z0-9_]{1,48}$/;
47
+ const SOURCE_APP = /^[a-z0-9_]{2,24}$/;
48
+ const AD_ID = /^[A-Za-z0-9_-]{1,64}$/;
49
+ const MAX_LABEL = 80;
50
+ const MAX_TITLE = 200;
51
+ const MAX_URL = 512;
52
+ const MAX_CLICK_ID = 512;
53
+ const MAX_NAME = 200;
54
+ /** Every lead from any ad falls in this origin bucket, whatever its entry point. */
55
+ export const AD_ORIGIN = "ctwa_ad";
56
+ /** The bucket for a lead the channel gave no entry point for. */
57
+ export const NO_ORIGIN = "none";
58
+
59
+ type LeadAd = Infer<typeof leadAdValidator>;
60
+ type Counters = {
61
+ leads: number;
62
+ qualified: number;
63
+ won: number;
64
+ revenueCents: number;
65
+ };
66
+
67
+ function invalid(field: string, error?: unknown): never {
68
+ fail(
69
+ "INVALID_ARGUMENT",
70
+ error instanceof Error ? error.message : `${field} is invalid`,
71
+ );
72
+ }
73
+
74
+ function opaque(value: string, field: string): string {
75
+ try {
76
+ return sanitizeOpaqueId(value, field);
77
+ } catch (error) {
78
+ invalid(field, error);
79
+ }
80
+ }
81
+
82
+ function eventTime(value: number, now: number): number {
83
+ if (!Number.isSafeInteger(value) || value < MIN_TIME) {
84
+ fail("INVALID_ARGUMENT", "occurredAt must be a Unix time in milliseconds");
85
+ }
86
+ // A host clock ahead of ours never files a lead in the future.
87
+ return Math.min(value, now);
88
+ }
89
+
90
+ function optionalText(value: string | undefined, max: number) {
91
+ if (value === undefined) return undefined;
92
+ return cleanString(value, max) || undefined;
93
+ }
94
+
95
+ function httpUrl(value: string | undefined): string | undefined {
96
+ const text = optionalText(value, MAX_URL);
97
+ if (!text) return undefined;
98
+ try {
99
+ const url = new URL(text);
100
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
101
+ if (url.username || url.password) return undefined;
102
+ // The path names the post; the query is tracking noise.
103
+ return `${url.origin}${url.pathname}`.slice(0, MAX_URL);
104
+ } catch {
105
+ return undefined;
106
+ }
107
+ }
108
+
109
+ function sanitizeAd(ad: LeadAd | undefined): LeadAd | undefined {
110
+ if (!ad) return undefined;
111
+ const adId = ad.adId?.trim();
112
+ if (adId !== undefined && !AD_ID.test(adId)) invalid("ad.adId");
113
+ const sourceApp = ad.sourceApp?.trim().toLowerCase();
114
+ const clean: LeadAd = {
115
+ ...(adId ? { adId } : {}),
116
+ ...(sourceApp && SOURCE_APP.test(sourceApp) ? { sourceApp } : {}),
117
+ ...(httpUrl(ad.sourceUrl) ? { sourceUrl: httpUrl(ad.sourceUrl) } : {}),
118
+ ...(optionalText(ad.ctwaClid, MAX_CLICK_ID)
119
+ ? { ctwaClid: optionalText(ad.ctwaClid, MAX_CLICK_ID) }
120
+ : {}),
121
+ ...(optionalText(ad.title, MAX_TITLE)
122
+ ? { title: optionalText(ad.title, MAX_TITLE) }
123
+ : {}),
124
+ };
125
+ return Object.keys(clean).length > 0 ? clean : undefined;
126
+ }
127
+
128
+ function sanitizeEntryPoint(value: string | undefined): string | undefined {
129
+ if (value === undefined) return undefined;
130
+ const entryPoint = value.trim().toLowerCase();
131
+ if (!ENTRY_POINT.test(entryPoint)) invalid("entryPoint");
132
+ return entryPoint;
133
+ }
134
+
135
+ function originKeyFor(entryPoint: string | undefined, ad: LeadAd | undefined) {
136
+ if (ad || entryPoint === AD_ORIGIN) return AD_ORIGIN;
137
+ return entryPoint ?? NO_ORIGIN;
138
+ }
139
+
140
+ async function bumpRollup(
141
+ ctx: MutationCtx,
142
+ lead: Pick<Doc<"leads">, "siteId" | "dayStart">,
143
+ dimension: "origin" | "ad",
144
+ key: string,
145
+ delta: Counters,
146
+ ) {
147
+ if (!delta.leads && !delta.qualified && !delta.won && !delta.revenueCents) {
148
+ return;
149
+ }
150
+ const row = await ctx.db
151
+ .query("leadDailyRollups")
152
+ .withIndex("by_siteId_and_dimension_and_dayStart_and_key", (range) =>
153
+ range
154
+ .eq("siteId", lead.siteId)
155
+ .eq("dimension", dimension)
156
+ .eq("dayStart", lead.dayStart)
157
+ .eq("key", key),
158
+ )
159
+ .unique();
160
+ if (row) {
161
+ await ctx.db.patch("leadDailyRollups", row._id, {
162
+ leads: row.leads + delta.leads,
163
+ qualified: row.qualified + delta.qualified,
164
+ won: row.won + delta.won,
165
+ revenueCents: row.revenueCents + delta.revenueCents,
166
+ });
167
+ } else {
168
+ await ctx.db.insert("leadDailyRollups", {
169
+ siteId: lead.siteId,
170
+ dayStart: lead.dayStart,
171
+ dimension,
172
+ key,
173
+ ...delta,
174
+ });
175
+ }
176
+ }
177
+
178
+ /** Applies one lead's change to every rollup row it is counted in. */
179
+ async function bumpLead(
180
+ ctx: MutationCtx,
181
+ lead: Pick<Doc<"leads">, "siteId" | "dayStart" | "originKey" | "ad">,
182
+ delta: Counters,
183
+ ) {
184
+ await bumpRollup(ctx, lead, "origin", lead.originKey, delta);
185
+ if (lead.ad?.adId) await bumpRollup(ctx, lead, "ad", lead.ad.adId, delta);
186
+ }
187
+
188
+ async function findLead(ctx: QueryCtx, siteId: Id<"sites">, leadKey: string) {
189
+ return await ctx.db
190
+ .query("leads")
191
+ .withIndex("by_siteId_and_leadKey", (range) =>
192
+ range.eq("siteId", siteId).eq("leadKey", leadKey),
193
+ )
194
+ .unique();
195
+ }
196
+
197
+ /**
198
+ * Records a lead the first time the host sees its conversation, idempotent by
199
+ * `leadKey`. The first touch wins: a later call never changes where the lead
200
+ * came from, only fills a label or the web visitor it lacked.
201
+ */
202
+ export const record = mutation({
203
+ args: {
204
+ siteId: v.id("sites"),
205
+ leadKey: v.string(),
206
+ channel: leadChannelValidator,
207
+ entryPoint: v.optional(v.string()),
208
+ ad: v.optional(leadAdValidator),
209
+ label: v.optional(v.string()),
210
+ visitorId: v.optional(v.string()),
211
+ occurredAt: v.number(),
212
+ },
213
+ returns: v.object({ leadId: v.id("leads"), created: v.boolean() }),
214
+ handler: async (ctx, args) => {
215
+ const site = await ctx.db.get("sites", args.siteId);
216
+ if (!site) fail("NOT_FOUND", "site not found");
217
+ const now = Date.now();
218
+ const leadKey = opaque(args.leadKey, "leadKey");
219
+ const label = optionalText(args.label, MAX_LABEL);
220
+ const visitorId =
221
+ args.visitorId === undefined
222
+ ? undefined
223
+ : opaque(args.visitorId, "visitorId");
224
+ const existing = await findLead(ctx, args.siteId, leadKey);
225
+ if (existing) {
226
+ const patch: { label?: string; visitorId?: string; updatedAt?: number } =
227
+ {};
228
+ if (label && label !== existing.label) patch.label = label;
229
+ if (visitorId && !existing.visitorId) patch.visitorId = visitorId;
230
+ if (Object.keys(patch).length > 0) {
231
+ await ctx.db.patch("leads", existing._id, { ...patch, updatedAt: now });
232
+ }
233
+ return { leadId: existing._id, created: false };
234
+ }
235
+ const entryPoint = sanitizeEntryPoint(args.entryPoint);
236
+ const ad = sanitizeAd(args.ad);
237
+ const firstAt = eventTime(args.occurredAt, now);
238
+ const lead = {
239
+ siteId: args.siteId,
240
+ leadKey,
241
+ channel: args.channel,
242
+ ...(entryPoint ? { entryPoint } : {}),
243
+ originKey: originKeyFor(entryPoint, ad),
244
+ ...(ad ? { ad } : {}),
245
+ ...(label ? { label } : {}),
246
+ ...(visitorId ? { visitorId } : {}),
247
+ stage: "lead" as const,
248
+ revenueCents: 0,
249
+ firstAt,
250
+ dayStart: Math.floor(firstAt / DAY_MS) * DAY_MS,
251
+ stageAt: firstAt,
252
+ updatedAt: now,
253
+ };
254
+ const leadId = await ctx.db.insert("leads", lead);
255
+ await ctx.db.insert("leadEvents", {
256
+ siteId: args.siteId,
257
+ leadId,
258
+ eventId: `created:${leadKey}`,
259
+ type: "created",
260
+ at: firstAt,
261
+ });
262
+ await bumpLead(ctx, lead, {
263
+ leads: 1,
264
+ qualified: 0,
265
+ won: 0,
266
+ revenueCents: 0,
267
+ });
268
+ return { leadId, created: true };
269
+ },
270
+ });
271
+
272
+ /**
273
+ * Moves a lead through the host's pipeline, idempotent by `eventId`. A lead the
274
+ * host never recorded (a conversation older than the integration) is not
275
+ * invented here: the call returns `applied: false`.
276
+ */
277
+ export const setStage = mutation({
278
+ args: {
279
+ siteId: v.id("sites"),
280
+ leadKey: v.string(),
281
+ stage: leadStageChangeValidator,
282
+ /** The sale value, in the site's currency, for `won`. */
283
+ revenueCents: v.optional(v.number()),
284
+ eventId: v.string(),
285
+ occurredAt: v.number(),
286
+ },
287
+ returns: v.object({ applied: v.boolean() }),
288
+ handler: async (ctx, args) => {
289
+ const now = Date.now();
290
+ const leadKey = opaque(args.leadKey, "leadKey");
291
+ const eventId = opaque(args.eventId, "eventId");
292
+ const at = eventTime(args.occurredAt, now);
293
+ const revenueCents = args.revenueCents ?? 0;
294
+ if (!isValidMoney(revenueCents)) {
295
+ fail(
296
+ "INVALID_ARGUMENT",
297
+ "revenueCents must be an integer between 0 and 1000000000",
298
+ );
299
+ }
300
+ if (args.stage !== "won" && args.revenueCents !== undefined) {
301
+ fail("INVALID_ARGUMENT", "revenueCents is only accepted with won");
302
+ }
303
+ const lead = await findLead(ctx, args.siteId, leadKey);
304
+ if (!lead) return { applied: false };
305
+ const seen = await ctx.db
306
+ .query("leadEvents")
307
+ .withIndex("by_siteId_and_eventId", (range) =>
308
+ range.eq("siteId", args.siteId).eq("eventId", eventId),
309
+ )
310
+ .unique();
311
+ if (seen) return { applied: seen.leadId === lead._id };
312
+
313
+ const delta: Counters = { leads: 0, qualified: 0, won: 0, revenueCents: 0 };
314
+ const patch: Partial<Doc<"leads">> = { stageAt: at, updatedAt: now };
315
+ const unsell = () => {
316
+ if (lead.stage !== "won") return;
317
+ delta.won -= 1;
318
+ delta.revenueCents -= lead.revenueCents;
319
+ patch.revenueCents = 0;
320
+ };
321
+ switch (args.stage) {
322
+ case "qualified":
323
+ if (lead.qualifiedAt === undefined) {
324
+ delta.qualified += 1;
325
+ patch.qualifiedAt = at;
326
+ }
327
+ // A sold lead stays sold; qualifying again only stamps the history.
328
+ if (lead.stage !== "won") patch.stage = "qualified";
329
+ break;
330
+ case "won":
331
+ if (lead.qualifiedAt === undefined) {
332
+ delta.qualified += 1;
333
+ patch.qualifiedAt = at;
334
+ }
335
+ if (lead.stage === "won") {
336
+ // A corrected sale value replaces the old one.
337
+ delta.revenueCents += revenueCents - lead.revenueCents;
338
+ } else {
339
+ delta.won += 1;
340
+ delta.revenueCents += revenueCents;
341
+ }
342
+ patch.stage = "won";
343
+ patch.revenueCents = revenueCents;
344
+ patch.wonAt = at;
345
+ break;
346
+ case "lost":
347
+ unsell();
348
+ patch.stage = "lost";
349
+ break;
350
+ case "open":
351
+ unsell();
352
+ patch.stage = lead.qualifiedAt !== undefined ? "qualified" : "lead";
353
+ break;
354
+ }
355
+ await ctx.db.patch("leads", lead._id, patch);
356
+ await ctx.db.insert("leadEvents", {
357
+ siteId: args.siteId,
358
+ leadId: lead._id,
359
+ eventId,
360
+ type: args.stage,
361
+ at,
362
+ ...(args.stage === "won" ? { revenueCents } : {}),
363
+ });
364
+ await bumpLead(ctx, lead, delta);
365
+ return { applied: true };
366
+ },
367
+ });
368
+
369
+ /**
370
+ * Forgets a lead and its history, taking it back out of every count. For the
371
+ * host's own deletions (a contact removed, a tenant wiped).
372
+ */
373
+ export const remove = mutation({
374
+ args: { siteId: v.id("sites"), leadKey: v.string() },
375
+ returns: v.object({ removed: v.boolean() }),
376
+ handler: async (ctx, args) => {
377
+ const lead = await findLead(
378
+ ctx,
379
+ args.siteId,
380
+ opaque(args.leadKey, "leadKey"),
381
+ );
382
+ if (!lead) return { removed: false };
383
+ const events = await ctx.db
384
+ .query("leadEvents")
385
+ .withIndex("by_leadId_and_at", (range) => range.eq("leadId", lead._id))
386
+ .take(MAX_LEAD_EVENTS_PER_LEAD);
387
+ for (const event of events) await ctx.db.delete("leadEvents", event._id);
388
+ await bumpLead(ctx, lead, {
389
+ leads: -1,
390
+ qualified: lead.qualifiedAt !== undefined ? -1 : 0,
391
+ won: lead.stage === "won" ? -1 : 0,
392
+ revenueCents: lead.stage === "won" ? -lead.revenueCents : 0,
393
+ });
394
+ await ctx.db.delete("leads", lead._id);
395
+ return { removed: true };
396
+ },
397
+ });
398
+
399
+ /**
400
+ * Names for the ads leads came from, as the host reads them from the Meta
401
+ * Marketing API. Upserts by ad id; a missing field keeps its stored value.
402
+ */
403
+ export const upsertAdCatalog = mutation({
404
+ args: {
405
+ siteId: v.id("sites"),
406
+ ads: v.array(
407
+ v.object({
408
+ adId: v.string(),
409
+ adName: v.optional(v.string()),
410
+ adsetId: v.optional(v.string()),
411
+ adsetName: v.optional(v.string()),
412
+ campaignId: v.optional(v.string()),
413
+ campaignName: v.optional(v.string()),
414
+ thumbnailUrl: v.optional(v.string()),
415
+ }),
416
+ ),
417
+ },
418
+ returns: v.object({ upserted: v.number() }),
419
+ handler: async (ctx, args) => {
420
+ const site = await ctx.db.get("sites", args.siteId);
421
+ if (!site) fail("NOT_FOUND", "site not found");
422
+ if (args.ads.length > MAX_AD_CATALOG_BATCH) {
423
+ fail("BATCH_TOO_LARGE", `at most ${MAX_AD_CATALOG_BATCH} ads per call`);
424
+ }
425
+ const now = Date.now();
426
+ const id = (value: string | undefined, field: string) => {
427
+ if (value === undefined) return undefined;
428
+ const trimmed = value.trim();
429
+ if (!AD_ID.test(trimmed)) invalid(field);
430
+ return trimmed;
431
+ };
432
+ for (const ad of args.ads) {
433
+ const fields = {
434
+ adName: optionalText(ad.adName, MAX_NAME),
435
+ adsetId: id(ad.adsetId, "adsetId"),
436
+ adsetName: optionalText(ad.adsetName, MAX_NAME),
437
+ campaignId: id(ad.campaignId, "campaignId"),
438
+ campaignName: optionalText(ad.campaignName, MAX_NAME),
439
+ thumbnailUrl: httpUrl(ad.thumbnailUrl),
440
+ };
441
+ const defined = Object.fromEntries(
442
+ Object.entries(fields).filter(([, value]) => value !== undefined),
443
+ );
444
+ const adId = id(ad.adId, "adId")!;
445
+ const row = await ctx.db
446
+ .query("adCatalog")
447
+ .withIndex("by_siteId_and_adId", (range) =>
448
+ range.eq("siteId", args.siteId).eq("adId", adId),
449
+ )
450
+ .unique();
451
+ if (row) {
452
+ await ctx.db.patch("adCatalog", row._id, {
453
+ ...defined,
454
+ updatedAt: now,
455
+ });
456
+ } else {
457
+ await ctx.db.insert("adCatalog", {
458
+ siteId: args.siteId,
459
+ adId,
460
+ ...defined,
461
+ updatedAt: now,
462
+ });
463
+ }
464
+ }
465
+ return { upserted: args.ads.length };
466
+ },
467
+ });
468
+
469
+ function validateSites<T>(siteIds: T[]): T[] {
470
+ const unique = [...new Set(siteIds)];
471
+ if (
472
+ unique.length < 1 ||
473
+ unique.length > MAX_REPORT_SITES ||
474
+ unique.length !== siteIds.length
475
+ ) {
476
+ fail(
477
+ "INVALID_ARGUMENT",
478
+ `siteIds must contain 1-${MAX_REPORT_SITES} unique IDs`,
479
+ );
480
+ }
481
+ return unique;
482
+ }
483
+
484
+ /** The cohort days a report covers: every UTC day that overlaps [from, to]. */
485
+ function cohortDays(from: number, to: number) {
486
+ if (
487
+ !Number.isSafeInteger(from) ||
488
+ !Number.isSafeInteger(to) ||
489
+ from < 0 ||
490
+ to < from ||
491
+ to - from >= MAX_REPORT_RANGE_DAYS * DAY_MS
492
+ ) {
493
+ fail(
494
+ "INVALID_ARGUMENT",
495
+ `range must use inclusive integer milliseconds and span at most ${MAX_REPORT_RANGE_DAYS} UTC days`,
496
+ );
497
+ }
498
+ return { first: Math.floor(from / DAY_MS) * DAY_MS, last: to };
499
+ }
500
+
501
+ const zero = (): Counters => ({
502
+ leads: 0,
503
+ qualified: 0,
504
+ won: 0,
505
+ revenueCents: 0,
506
+ });
507
+ const add = (into: Counters, row: Counters) => {
508
+ into.leads += row.leads;
509
+ into.qualified += row.qualified;
510
+ into.won += row.won;
511
+ into.revenueCents += row.revenueCents;
512
+ };
513
+
514
+ /**
515
+ * One site's rollup rows for a dimension, charged against the report's single
516
+ * row budget (shared by every site and both dimensions, so ten sites cannot
517
+ * multiply it past the documents a query may read).
518
+ */
519
+ async function readRollups(
520
+ ctx: QueryCtx,
521
+ budget: { rows: number },
522
+ siteId: Id<"sites">,
523
+ dimension: "origin" | "ad",
524
+ first: number,
525
+ last: number,
526
+ ) {
527
+ const rows = await ctx.db
528
+ .query("leadDailyRollups")
529
+ .withIndex("by_siteId_and_dimension_and_dayStart_and_key", (range) =>
530
+ range
531
+ .eq("siteId", siteId)
532
+ .eq("dimension", dimension)
533
+ .gte("dayStart", first)
534
+ .lte("dayStart", last),
535
+ )
536
+ .take(budget.rows + 1);
537
+ if (rows.length > budget.rows) {
538
+ fail("REPORT_INCOMPLETE", "lead report exceeds its row budget", {
539
+ maximumRows: MAX_LEAD_REPORT_ROWS,
540
+ });
541
+ }
542
+ budget.rows -= rows.length;
543
+ return rows;
544
+ }
545
+
546
+ const byLeadsThenKey =
547
+ <T extends Counters>(key: (row: T) => string) =>
548
+ (left: T, right: T) =>
549
+ right.won - left.won ||
550
+ right.leads - left.leads ||
551
+ key(left).localeCompare(key(right));
552
+
553
+ /**
554
+ * Leads, qualified, sales and revenue over the cohort days in the range: in
555
+ * total, per origin and per campaign → ad set → ad.
556
+ */
557
+ export const report = query({
558
+ args: { siteIds: v.array(v.id("sites")), from: v.number(), to: v.number() },
559
+ returns: leadsReportValidator,
560
+ handler: async (ctx, args) => {
561
+ const siteIds = validateSites(args.siteIds);
562
+ const { first, last } = cohortDays(args.from, args.to);
563
+ const currencies = new Set<string>();
564
+ const totals = zero();
565
+ const origins = new Map<string, Counters>();
566
+ const ads = new Map<string, { siteId: Id<"sites">; counters: Counters }>();
567
+ const budget = { rows: MAX_LEAD_REPORT_ROWS };
568
+ for (const siteId of siteIds) {
569
+ const site = await ctx.db.get("sites", siteId);
570
+ if (!site) fail("NOT_FOUND", "site not found");
571
+ currencies.add(site.currency ?? "USD");
572
+ for (const row of await readRollups(
573
+ ctx,
574
+ budget,
575
+ siteId,
576
+ "origin",
577
+ first,
578
+ last,
579
+ )) {
580
+ add(totals, row);
581
+ const counters = origins.get(row.key) ?? zero();
582
+ add(counters, row);
583
+ origins.set(row.key, counters);
584
+ }
585
+ for (const row of await readRollups(
586
+ ctx,
587
+ budget,
588
+ siteId,
589
+ "ad",
590
+ first,
591
+ last,
592
+ )) {
593
+ const entry = ads.get(row.key) ?? { siteId, counters: zero() };
594
+ add(entry.counters, row);
595
+ ads.set(row.key, entry);
596
+ }
597
+ }
598
+ if (ads.size > MAX_LEAD_REPORT_ADS) {
599
+ fail(
600
+ "REPORT_INCOMPLETE",
601
+ "lead report names more ads than it can resolve",
602
+ {
603
+ maximumAds: MAX_LEAD_REPORT_ADS,
604
+ },
605
+ );
606
+ }
607
+
608
+ type AdRow = Infer<typeof leadAdRowValidator>;
609
+ type AdsetRow = Infer<typeof leadAdsetRowValidator>;
610
+ type CampaignRow = Infer<typeof leadCampaignRowValidator>;
611
+ const campaigns = new Map<string, CampaignRow>();
612
+ for (const [adId, { siteId, counters }] of ads) {
613
+ // Every stage counts on the lead's own cohort row, so no leads means
614
+ // nothing left there (removed leads leave zeroed rows behind).
615
+ if (counters.leads === 0) continue;
616
+ const catalog = await ctx.db
617
+ .query("adCatalog")
618
+ .withIndex("by_siteId_and_adId", (range) =>
619
+ range.eq("siteId", siteId).eq("adId", adId),
620
+ )
621
+ .unique();
622
+ // What the latest message from this ad carried, for ads the catalog
623
+ // has not named yet (and for the post link either way).
624
+ const latest = await ctx.db
625
+ .query("leads")
626
+ .withIndex("by_siteId_and_ad_and_firstAt", (range) =>
627
+ range.eq("siteId", siteId).eq("ad.adId", adId),
628
+ )
629
+ .order("desc")
630
+ .first();
631
+ const ad: AdRow = {
632
+ adId,
633
+ adName: catalog?.adName ?? null,
634
+ sourceApp: latest?.ad?.sourceApp ?? null,
635
+ sourceUrl: latest?.ad?.sourceUrl ?? null,
636
+ title: latest?.ad?.title ?? null,
637
+ thumbnailUrl: catalog?.thumbnailUrl ?? null,
638
+ ...counters,
639
+ };
640
+ const campaignKey = catalog?.campaignId ?? "";
641
+ const campaign = campaigns.get(campaignKey) ?? {
642
+ campaignId: catalog?.campaignId ?? null,
643
+ campaignName: catalog?.campaignName ?? null,
644
+ ...zero(),
645
+ adsets: [],
646
+ };
647
+ add(campaign, counters);
648
+ const adsetId = catalog?.adsetId ?? null;
649
+ let adset: AdsetRow | undefined = campaign.adsets.find(
650
+ (row) => row.adsetId === adsetId,
651
+ );
652
+ if (!adset) {
653
+ adset = {
654
+ adsetId,
655
+ adsetName: catalog?.adsetName ?? null,
656
+ ...zero(),
657
+ ads: [],
658
+ };
659
+ campaign.adsets.push(adset);
660
+ }
661
+ add(adset, counters);
662
+ adset.ads.push(ad);
663
+ campaigns.set(campaignKey, campaign);
664
+ }
665
+
666
+ const campaignRows = [...campaigns.values()]
667
+ .map((campaign) => ({
668
+ ...campaign,
669
+ adsets: campaign.adsets
670
+ .map((adset) => ({
671
+ ...adset,
672
+ ads: adset.ads.sort(byLeadsThenKey((row) => row.adId)),
673
+ }))
674
+ .sort(byLeadsThenKey((row) => row.adsetId ?? "")),
675
+ }))
676
+ // Uncatalogued ads go last: they are the ones waiting for a name.
677
+ .sort((left, right) =>
678
+ left.campaignId === null
679
+ ? 1
680
+ : right.campaignId === null
681
+ ? -1
682
+ : byLeadsThenKey<CampaignRow>((row) => row.campaignId ?? "")(
683
+ left,
684
+ right,
685
+ ),
686
+ );
687
+ return {
688
+ from: args.from,
689
+ to: args.to,
690
+ currency: currencies.size === 1 ? [...currencies][0] : null,
691
+ totals,
692
+ origins: [...origins]
693
+ .map(([key, counters]) => ({ key, ...counters }))
694
+ .filter((row) => row.leads > 0)
695
+ .sort(byLeadsThenKey((row) => row.key)),
696
+ campaigns: campaignRows,
697
+ };
698
+ },
699
+ });
700
+
701
+ // The click id stays in the host's deployment: it is for the Conversions API,
702
+ // not for a dashboard.
703
+ function summarize(lead: Doc<"leads">): Infer<typeof leadSummaryValidator> {
704
+ return {
705
+ leadId: lead._id,
706
+ siteId: lead.siteId,
707
+ label: lead.label ?? null,
708
+ channel: lead.channel,
709
+ originKey: lead.originKey,
710
+ entryPoint: lead.entryPoint ?? null,
711
+ adId: lead.ad?.adId ?? null,
712
+ sourceApp: lead.ad?.sourceApp ?? null,
713
+ stage: lead.stage,
714
+ revenueCents: lead.revenueCents,
715
+ firstAt: lead.firstAt,
716
+ stageAt: lead.stageAt,
717
+ hasVisitor: lead.visitorId !== undefined,
718
+ };
719
+ }
720
+
721
+ /**
722
+ * The newest leads that arrived in the range, optionally from one ad. Pages
723
+ * with `before` (the last `firstAt` returned).
724
+ */
725
+ export const list = query({
726
+ args: {
727
+ siteIds: v.array(v.id("sites")),
728
+ from: v.number(),
729
+ to: v.number(),
730
+ adId: v.optional(v.string()),
731
+ before: v.optional(v.number()),
732
+ limit: v.optional(v.number()),
733
+ },
734
+ returns: v.array(leadSummaryValidator),
735
+ handler: async (ctx, args) => {
736
+ const siteIds = validateSites(args.siteIds);
737
+ cohortDays(args.from, args.to);
738
+ const limit = Math.min(
739
+ Math.max(Math.floor(args.limit ?? 25), 1),
740
+ MAX_LEAD_LIST,
741
+ );
742
+ const upper = Math.min(
743
+ args.to,
744
+ (args.before ?? Number.MAX_SAFE_INTEGER) - 1,
745
+ );
746
+ const adId = args.adId?.trim();
747
+ if (adId !== undefined && !AD_ID.test(adId)) invalid("adId");
748
+ const pages = await Promise.all(
749
+ siteIds.map((siteId) =>
750
+ (adId
751
+ ? ctx.db
752
+ .query("leads")
753
+ .withIndex("by_siteId_and_ad_and_firstAt", (range) =>
754
+ range
755
+ .eq("siteId", siteId)
756
+ .eq("ad.adId", adId)
757
+ .gte("firstAt", args.from)
758
+ .lte("firstAt", upper),
759
+ )
760
+ : ctx.db
761
+ .query("leads")
762
+ .withIndex("by_siteId_and_firstAt", (range) =>
763
+ range
764
+ .eq("siteId", siteId)
765
+ .gte("firstAt", args.from)
766
+ .lte("firstAt", upper),
767
+ )
768
+ )
769
+ .order("desc")
770
+ .take(limit),
771
+ ),
772
+ );
773
+ return pages
774
+ .flat()
775
+ .sort((left, right) => right.firstAt - left.firstAt)
776
+ .slice(0, limit)
777
+ .map(summarize);
778
+ },
779
+ });
780
+
781
+ /** One lead with its history, oldest first. */
782
+ export const journey = query({
783
+ args: { siteId: v.id("sites"), leadId: v.string() },
784
+ returns: leadJourneyValidator,
785
+ handler: async (ctx, args) => {
786
+ const leadId = ctx.db.normalizeId("leads", args.leadId);
787
+ if (!leadId) return null;
788
+ const lead = await ctx.db.get("leads", leadId);
789
+ if (!lead || lead.siteId !== args.siteId) return null;
790
+ const catalog = lead.ad?.adId
791
+ ? await ctx.db
792
+ .query("adCatalog")
793
+ .withIndex("by_siteId_and_adId", (range) =>
794
+ range.eq("siteId", lead.siteId).eq("adId", lead.ad!.adId!),
795
+ )
796
+ .unique()
797
+ : null;
798
+ const events = await ctx.db
799
+ .query("leadEvents")
800
+ .withIndex("by_leadId_and_at", (range) => range.eq("leadId", lead._id))
801
+ .take(MAX_LEAD_EVENTS_PER_LEAD);
802
+ return {
803
+ lead: summarize(lead),
804
+ ad: lead.ad
805
+ ? {
806
+ adId: lead.ad.adId ?? null,
807
+ adName: catalog?.adName ?? null,
808
+ adsetName: catalog?.adsetName ?? null,
809
+ campaignName: catalog?.campaignName ?? null,
810
+ sourceApp: lead.ad.sourceApp ?? null,
811
+ sourceUrl: lead.ad.sourceUrl ?? null,
812
+ title: lead.ad.title ?? null,
813
+ }
814
+ : null,
815
+ visitorId: lead.visitorId ?? null,
816
+ events: events.map((event) => ({
817
+ type: event.type,
818
+ at: event.at,
819
+ revenueCents: event.revenueCents ?? null,
820
+ })),
821
+ };
822
+ },
823
+ });