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