@iann29/rastro 0.9.0 → 0.10.1
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 +9 -1
- package/agent/integration.md +23 -1
- package/agent/manifest.json +4 -4
- package/dist/client/federation.d.ts +114 -9
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +54 -2
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +642 -8
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +391 -9
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +14 -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 +278 -1
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/server.d.ts +4 -0
- package/dist/component/_generated/server.d.ts.map +1 -1
- package/dist/component/_generated/server.js.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 +10 -0
- package/dist/component/constants.js.map +1 -1
- package/dist/component/convex.config.d.ts +4 -0
- package/dist/component/convex.config.js +9 -0
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/errors.d.ts +1 -1
- package/dist/component/errors.d.ts.map +1 -1
- package/dist/component/errors.js.map +1 -1
- package/dist/component/leads.d.ts +69 -3
- package/dist/component/leads.d.ts.map +1 -1
- package/dist/component/leads.js +419 -16
- package/dist/component/leads.js.map +1 -1
- package/dist/component/meta.d.ts +358 -0
- package/dist/component/meta.d.ts.map +1 -0
- package/dist/component/meta.js +1295 -0
- package/dist/component/meta.js.map +1 -0
- package/dist/component/metaCapi.d.ts +162 -0
- package/dist/component/metaCapi.d.ts.map +1 -0
- package/dist/component/metaCapi.js +754 -0
- package/dist/component/metaCapi.js.map +1 -0
- package/dist/component/metaFake.d.ts +5 -0
- package/dist/component/metaFake.d.ts.map +1 -0
- package/dist/component/metaFake.js +225 -0
- package/dist/component/metaFake.js.map +1 -0
- package/dist/component/metaGraph.d.ts +165 -0
- package/dist/component/metaGraph.d.ts.map +1 -0
- package/dist/component/metaGraph.js +507 -0
- package/dist/component/metaGraph.js.map +1 -0
- package/dist/component/metaSync.d.ts +186 -0
- package/dist/component/metaSync.d.ts.map +1 -0
- package/dist/component/metaSync.js +961 -0
- package/dist/component/metaSync.js.map +1 -0
- package/dist/component/pipeline.d.ts +99 -0
- package/dist/component/pipeline.d.ts.map +1 -0
- package/dist/component/pipeline.js +337 -0
- package/dist/component/pipeline.js.map +1 -0
- package/dist/component/schema.d.ts +404 -2
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +168 -2
- package/dist/component/schema.js.map +1 -1
- package/dist/component/secrets.d.ts +15 -0
- package/dist/component/secrets.d.ts.map +1 -0
- package/dist/component/secrets.js +67 -0
- package/dist/component/secrets.js.map +1 -0
- package/dist/component/validators.d.ts +1052 -15
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +307 -3
- package/dist/component/validators.js.map +1 -1
- package/dist/tracker/generated.d.ts +1 -1
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +1 -1
- package/dist/tracker/generated.js.map +1 -1
- package/dist/ui/campaigns/CampaignsView.d.ts +48 -0
- package/dist/ui/campaigns/CampaignsView.d.ts.map +1 -0
- package/dist/ui/campaigns/CampaignsView.js +47 -0
- package/dist/ui/campaigns/CampaignsView.js.map +1 -0
- package/dist/ui/campaigns/Conversions.d.ts +16 -0
- package/dist/ui/campaigns/Conversions.d.ts.map +1 -0
- package/dist/ui/campaigns/Conversions.js +40 -0
- package/dist/ui/campaigns/Conversions.js.map +1 -0
- package/dist/ui/campaigns/Drawer.d.ts +24 -0
- package/dist/ui/campaigns/Drawer.d.ts.map +1 -0
- package/dist/ui/campaigns/Drawer.js +120 -0
- package/dist/ui/campaigns/Drawer.js.map +1 -0
- package/dist/ui/campaigns/Icons.d.ts +35 -0
- package/dist/ui/campaigns/Icons.d.ts.map +1 -0
- package/dist/ui/campaigns/Icons.js +41 -0
- package/dist/ui/campaigns/Icons.js.map +1 -0
- package/dist/ui/campaigns/Summary.d.ts +21 -0
- package/dist/ui/campaigns/Summary.d.ts.map +1 -0
- package/dist/ui/campaigns/Summary.js +56 -0
- package/dist/ui/campaigns/Summary.js.map +1 -0
- package/dist/ui/campaigns/Thumbnail.d.ts +11 -0
- package/dist/ui/campaigns/Thumbnail.d.ts.map +1 -0
- package/dist/ui/campaigns/Thumbnail.js +23 -0
- package/dist/ui/campaigns/Thumbnail.js.map +1 -0
- package/dist/ui/campaigns/Tree.d.ts +12 -0
- package/dist/ui/campaigns/Tree.d.ts.map +1 -0
- package/dist/ui/campaigns/Tree.js +105 -0
- package/dist/ui/campaigns/Tree.js.map +1 -0
- package/dist/ui/campaigns/glyphs.d.ts +20 -0
- package/dist/ui/campaigns/glyphs.d.ts.map +1 -0
- package/dist/ui/campaigns/glyphs.js +19 -0
- package/dist/ui/campaigns/glyphs.js.map +1 -0
- package/dist/ui/campaigns/index.d.ts +4 -0
- package/dist/ui/campaigns/index.d.ts.map +1 -0
- package/dist/ui/campaigns/index.js +3 -0
- package/dist/ui/campaigns/index.js.map +1 -0
- package/dist/ui/campaigns/labels.d.ts +53 -0
- package/dist/ui/campaigns/labels.d.ts.map +1 -0
- package/dist/ui/campaigns/labels.js +169 -0
- package/dist/ui/campaigns/labels.js.map +1 -0
- package/dist/ui/campaigns/model.d.ts +99 -0
- package/dist/ui/campaigns/model.d.ts.map +1 -0
- package/dist/ui/campaigns/model.js +230 -0
- package/dist/ui/campaigns/model.js.map +1 -0
- package/dist/ui/campaigns/modes.d.ts +18 -0
- package/dist/ui/campaigns/modes.d.ts.map +1 -0
- package/dist/ui/campaigns/modes.js +7 -0
- package/dist/ui/campaigns/modes.js.map +1 -0
- package/dist/ui/index.d.ts +21 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +20 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/integrations/AccountPicker.d.ts +26 -0
- package/dist/ui/integrations/AccountPicker.d.ts.map +1 -0
- package/dist/ui/integrations/AccountPicker.js +101 -0
- package/dist/ui/integrations/AccountPicker.js.map +1 -0
- package/dist/ui/integrations/ClientCards.d.ts +17 -0
- package/dist/ui/integrations/ClientCards.d.ts.map +1 -0
- package/dist/ui/integrations/ClientCards.js +42 -0
- package/dist/ui/integrations/ClientCards.js.map +1 -0
- package/dist/ui/integrations/ClientsTable.d.ts +36 -0
- package/dist/ui/integrations/ClientsTable.d.ts.map +1 -0
- package/dist/ui/integrations/ClientsTable.js +143 -0
- package/dist/ui/integrations/ClientsTable.js.map +1 -0
- package/dist/ui/integrations/Connect.d.ts +35 -0
- package/dist/ui/integrations/Connect.d.ts.map +1 -0
- package/dist/ui/integrations/Connect.js +62 -0
- package/dist/ui/integrations/Connect.js.map +1 -0
- package/dist/ui/integrations/Connection.d.ts +37 -0
- package/dist/ui/integrations/Connection.d.ts.map +1 -0
- package/dist/ui/integrations/Connection.js +108 -0
- package/dist/ui/integrations/Connection.js.map +1 -0
- package/dist/ui/integrations/Dialogs.d.ts +21 -0
- package/dist/ui/integrations/Dialogs.d.ts.map +1 -0
- package/dist/ui/integrations/Dialogs.js +34 -0
- package/dist/ui/integrations/Dialogs.js.map +1 -0
- package/dist/ui/integrations/EventsFeed.d.ts +15 -0
- package/dist/ui/integrations/EventsFeed.d.ts.map +1 -0
- package/dist/ui/integrations/EventsFeed.js +61 -0
- package/dist/ui/integrations/EventsFeed.js.map +1 -0
- package/dist/ui/integrations/EventsSheet.d.ts +34 -0
- package/dist/ui/integrations/EventsSheet.d.ts.map +1 -0
- package/dist/ui/integrations/EventsSheet.js +136 -0
- package/dist/ui/integrations/EventsSheet.js.map +1 -0
- package/dist/ui/integrations/Icons.d.ts +38 -0
- package/dist/ui/integrations/Icons.d.ts.map +1 -0
- package/dist/ui/integrations/Icons.js +40 -0
- package/dist/ui/integrations/Icons.js.map +1 -0
- package/dist/ui/integrations/IntegrationsView.d.ts +15 -0
- package/dist/ui/integrations/IntegrationsView.d.ts.map +1 -0
- package/dist/ui/integrations/IntegrationsView.js +141 -0
- package/dist/ui/integrations/IntegrationsView.js.map +1 -0
- package/dist/ui/integrations/Overlay.d.ts +18 -0
- package/dist/ui/integrations/Overlay.d.ts.map +1 -0
- package/dist/ui/integrations/Overlay.js +36 -0
- package/dist/ui/integrations/Overlay.js.map +1 -0
- package/dist/ui/integrations/Partner.d.ts +14 -0
- package/dist/ui/integrations/Partner.d.ts.map +1 -0
- package/dist/ui/integrations/Partner.js +38 -0
- package/dist/ui/integrations/Partner.js.map +1 -0
- package/dist/ui/integrations/RowMenu.d.ts +15 -0
- package/dist/ui/integrations/RowMenu.d.ts.map +1 -0
- package/dist/ui/integrations/RowMenu.js +47 -0
- package/dist/ui/integrations/RowMenu.js.map +1 -0
- package/dist/ui/integrations/UnlinkDialog.d.ts +18 -0
- package/dist/ui/integrations/UnlinkDialog.d.ts.map +1 -0
- package/dist/ui/integrations/UnlinkDialog.js +29 -0
- package/dist/ui/integrations/UnlinkDialog.js.map +1 -0
- package/dist/ui/integrations/audience.d.ts +12 -0
- package/dist/ui/integrations/audience.d.ts.map +1 -0
- package/dist/ui/integrations/audience.js +12 -0
- package/dist/ui/integrations/audience.js.map +1 -0
- package/dist/ui/integrations/format.d.ts +28 -0
- package/dist/ui/integrations/format.d.ts.map +1 -0
- package/dist/ui/integrations/format.js +101 -0
- package/dist/ui/integrations/format.js.map +1 -0
- package/dist/ui/integrations/index.d.ts +5 -0
- package/dist/ui/integrations/index.d.ts.map +1 -0
- package/dist/ui/integrations/index.js +3 -0
- package/dist/ui/integrations/index.js.map +1 -0
- package/dist/ui/integrations/modal.d.ts +10 -0
- package/dist/ui/integrations/modal.d.ts.map +1 -0
- package/dist/ui/integrations/modal.js +77 -0
- package/dist/ui/integrations/modal.js.map +1 -0
- package/dist/ui/integrations/model.d.ts +99 -0
- package/dist/ui/integrations/model.d.ts.map +1 -0
- package/dist/ui/integrations/model.js +164 -0
- package/dist/ui/integrations/model.js.map +1 -0
- package/dist/ui/integrations/parts.d.ts +36 -0
- package/dist/ui/integrations/parts.d.ts.map +1 -0
- package/dist/ui/integrations/parts.js +85 -0
- package/dist/ui/integrations/parts.js.map +1 -0
- package/dist/ui/integrations/types.d.ts +146 -0
- package/dist/ui/integrations/types.d.ts.map +1 -0
- package/dist/ui/integrations/types.js +2 -0
- package/dist/ui/integrations/types.js.map +1 -0
- package/dist/ui/metrics.d.ts +106 -0
- package/dist/ui/metrics.d.ts.map +1 -0
- package/dist/ui/metrics.js +166 -0
- package/dist/ui/metrics.js.map +1 -0
- package/dist/ui/portfolio/PortfolioView.d.ts +26 -0
- package/dist/ui/portfolio/PortfolioView.d.ts.map +1 -0
- package/dist/ui/portfolio/PortfolioView.js +216 -0
- package/dist/ui/portfolio/PortfolioView.js.map +1 -0
- package/dist/ui/portfolio/SpendChart.d.ts +10 -0
- package/dist/ui/portfolio/SpendChart.d.ts.map +1 -0
- package/dist/ui/portfolio/SpendChart.js +196 -0
- package/dist/ui/portfolio/SpendChart.js.map +1 -0
- package/dist/ui/portfolio/alerts.d.ts +74 -0
- package/dist/ui/portfolio/alerts.d.ts.map +1 -0
- package/dist/ui/portfolio/alerts.js +291 -0
- package/dist/ui/portfolio/alerts.js.map +1 -0
- package/dist/ui/portfolio/figures.d.ts +79 -0
- package/dist/ui/portfolio/figures.d.ts.map +1 -0
- package/dist/ui/portfolio/figures.js +103 -0
- package/dist/ui/portfolio/figures.js.map +1 -0
- package/dist/ui/portfolio/format.d.ts +8 -0
- package/dist/ui/portfolio/format.d.ts.map +1 -0
- package/dist/ui/portfolio/format.js +28 -0
- package/dist/ui/portfolio/format.js.map +1 -0
- package/dist/ui/sample.d.ts +536 -0
- package/dist/ui/sample.d.ts.map +1 -0
- package/dist/ui/sample.js +574 -0
- package/dist/ui/sample.js.map +1 -0
- package/dist/ui/seed.d.ts +188 -0
- package/dist/ui/seed.d.ts.map +1 -0
- package/dist/ui/seed.js +563 -0
- package/dist/ui/seed.js.map +1 -0
- package/dist/ui/ui.css +3424 -0
- package/docs/federation.md +46 -1
- package/docs/meta-ads.md +795 -0
- package/docs/upgrading.md +116 -0
- package/llms.txt +3 -0
- package/package.json +15 -3
- package/src/component/_generated/api.ts +14 -0
- package/src/component/_generated/component.ts +324 -1
- package/src/component/_generated/server.ts +4 -0
- package/src/component/constants.ts +10 -0
- package/src/component/convex.config.ts +9 -0
- package/src/component/errors.ts +5 -1
- package/src/component/leads.ts +517 -17
- package/src/component/meta.ts +1525 -0
- package/src/component/metaCapi.ts +921 -0
- package/src/component/metaFake.ts +277 -0
- package/src/component/metaGraph.ts +722 -0
- package/src/component/metaSync.ts +1164 -0
- package/src/component/pipeline.ts +410 -0
- package/src/component/schema.ts +197 -1
- package/src/component/secrets.ts +98 -0
- package/src/component/validators.ts +376 -3
- package/src/tracker/generated.ts +1 -1
- package/src/ui/seed.ts +755 -0
package/src/component/leads.ts
CHANGED
|
@@ -9,14 +9,29 @@ import {
|
|
|
9
9
|
import {
|
|
10
10
|
DAY_MS,
|
|
11
11
|
MAX_AD_CATALOG_BATCH,
|
|
12
|
+
MAX_LEAD_DAILY_DAYS,
|
|
12
13
|
MAX_LEAD_EVENTS_PER_LEAD,
|
|
13
14
|
MAX_LEAD_LIST,
|
|
14
15
|
MAX_LEAD_REPORT_ADS,
|
|
15
16
|
MAX_LEAD_REPORT_ROWS,
|
|
16
17
|
MAX_REPORT_RANGE_DAYS,
|
|
17
18
|
MAX_REPORT_SITES,
|
|
19
|
+
MAX_SITE_AD_ACCOUNTS,
|
|
20
|
+
MAX_AD_SPEND_ROWS,
|
|
21
|
+
MAX_SPEND_SUM_ROWS,
|
|
18
22
|
} from "./constants.js";
|
|
19
23
|
import { fail } from "./errors.js";
|
|
24
|
+
import {
|
|
25
|
+
enterColumn,
|
|
26
|
+
forgetLeadColumns,
|
|
27
|
+
recordColumnMove,
|
|
28
|
+
} from "./pipeline.js";
|
|
29
|
+
import {
|
|
30
|
+
enqueueNewLead,
|
|
31
|
+
enqueuePurchase,
|
|
32
|
+
parseMetaUserData,
|
|
33
|
+
removeLeadConversions,
|
|
34
|
+
} from "./metaCapi.js";
|
|
20
35
|
import { cleanString, isValidMoney, sanitizeOpaqueId } from "./sanitize.js";
|
|
21
36
|
import {
|
|
22
37
|
leadAdRowValidator,
|
|
@@ -25,6 +40,7 @@ import {
|
|
|
25
40
|
leadCampaignRowValidator,
|
|
26
41
|
leadChannelValidator,
|
|
27
42
|
leadJourneyValidator,
|
|
43
|
+
leadsDailyValidator,
|
|
28
44
|
leadsReportValidator,
|
|
29
45
|
leadStageChangeValidator,
|
|
30
46
|
leadSummaryValidator,
|
|
@@ -208,7 +224,16 @@ export const record = mutation({
|
|
|
208
224
|
ad: v.optional(leadAdValidator),
|
|
209
225
|
label: v.optional(v.string()),
|
|
210
226
|
visitorId: v.optional(v.string()),
|
|
227
|
+
/** The host's board column the lead is in (see `setPipeline`). */
|
|
228
|
+
column: v.optional(v.string()),
|
|
211
229
|
occurredAt: v.number(),
|
|
230
|
+
/**
|
|
231
|
+
* The contact's hashes, for the site's Meta pixel: `{ ph, externalId? }`
|
|
232
|
+
* (see metaCapi.ts). Any shape is accepted here and checked by
|
|
233
|
+
* `parseMetaUserData`, so a bad value never fails the call nor shows up
|
|
234
|
+
* in an error.
|
|
235
|
+
*/
|
|
236
|
+
meta: v.optional(v.any()),
|
|
212
237
|
},
|
|
213
238
|
returns: v.object({ leadId: v.id("leads"), created: v.boolean() }),
|
|
214
239
|
handler: async (ctx, args) => {
|
|
@@ -216,6 +241,7 @@ export const record = mutation({
|
|
|
216
241
|
if (!site) fail("NOT_FOUND", "site not found");
|
|
217
242
|
const now = Date.now();
|
|
218
243
|
const leadKey = opaque(args.leadKey, "leadKey");
|
|
244
|
+
const meta = parseMetaUserData(args.meta) ?? undefined;
|
|
219
245
|
const label = optionalText(args.label, MAX_LABEL);
|
|
220
246
|
const visitorId =
|
|
221
247
|
args.visitorId === undefined
|
|
@@ -223,10 +249,18 @@ export const record = mutation({
|
|
|
223
249
|
: opaque(args.visitorId, "visitorId");
|
|
224
250
|
const existing = await findLead(ctx, args.siteId, leadKey);
|
|
225
251
|
if (existing) {
|
|
226
|
-
const patch:
|
|
227
|
-
{};
|
|
252
|
+
const patch: Partial<Doc<"leads">> = {};
|
|
228
253
|
if (label && label !== existing.label) patch.label = label;
|
|
229
254
|
if (visitorId && !existing.visitorId) patch.visitorId = visitorId;
|
|
255
|
+
// The column is where the lead is now, so a later call may move it.
|
|
256
|
+
const moved = await enterColumn(
|
|
257
|
+
ctx,
|
|
258
|
+
existing,
|
|
259
|
+
args.column,
|
|
260
|
+
eventTime(args.occurredAt, now),
|
|
261
|
+
meta,
|
|
262
|
+
);
|
|
263
|
+
if (moved) Object.assign(patch, moved);
|
|
230
264
|
if (Object.keys(patch).length > 0) {
|
|
231
265
|
await ctx.db.patch("leads", existing._id, { ...patch, updatedAt: now });
|
|
232
266
|
}
|
|
@@ -252,6 +286,14 @@ export const record = mutation({
|
|
|
252
286
|
updatedAt: now,
|
|
253
287
|
};
|
|
254
288
|
const leadId = await ctx.db.insert("leads", lead);
|
|
289
|
+
const placed = await enterColumn(
|
|
290
|
+
ctx,
|
|
291
|
+
{ _id: leadId, siteId: args.siteId, column: undefined },
|
|
292
|
+
args.column,
|
|
293
|
+
firstAt,
|
|
294
|
+
meta,
|
|
295
|
+
);
|
|
296
|
+
if (placed) await ctx.db.patch("leads", leadId, placed);
|
|
255
297
|
await ctx.db.insert("leadEvents", {
|
|
256
298
|
siteId: args.siteId,
|
|
257
299
|
leadId,
|
|
@@ -265,6 +307,12 @@ export const record = mutation({
|
|
|
265
307
|
won: 0,
|
|
266
308
|
revenueCents: 0,
|
|
267
309
|
});
|
|
310
|
+
await enqueueNewLead(ctx, {
|
|
311
|
+
siteId: args.siteId,
|
|
312
|
+
leadId,
|
|
313
|
+
eventTime: firstAt,
|
|
314
|
+
meta,
|
|
315
|
+
});
|
|
268
316
|
return { leadId, created: true };
|
|
269
317
|
},
|
|
270
318
|
});
|
|
@@ -272,23 +320,35 @@ export const record = mutation({
|
|
|
272
320
|
/**
|
|
273
321
|
* Moves a lead through the host's pipeline, idempotent by `eventId`. A lead the
|
|
274
322
|
* host never recorded (a conversation older than the integration) is not
|
|
275
|
-
* invented here: the call returns `applied: false`.
|
|
323
|
+
* invented here: the call returns `applied: false`. `column` is the host's
|
|
324
|
+
* board column the lead is now in; a call may carry the column alone, when the
|
|
325
|
+
* lead moves on the board without changing stage.
|
|
276
326
|
*/
|
|
277
327
|
export const setStage = mutation({
|
|
278
328
|
args: {
|
|
279
329
|
siteId: v.id("sites"),
|
|
280
330
|
leadKey: v.string(),
|
|
281
|
-
stage: leadStageChangeValidator,
|
|
331
|
+
stage: v.optional(leadStageChangeValidator),
|
|
332
|
+
/** The host's board column the lead is now in (see `setPipeline`). */
|
|
333
|
+
column: v.optional(v.string()),
|
|
282
334
|
/** The sale value, in the site's currency, for `won`. */
|
|
283
335
|
revenueCents: v.optional(v.number()),
|
|
284
336
|
eventId: v.string(),
|
|
285
337
|
occurredAt: v.number(),
|
|
338
|
+
/**
|
|
339
|
+
* The contact's hashes, for the site's Meta pixel: `{ ph, externalId? }`
|
|
340
|
+
* (see metaCapi.ts). Any shape is accepted here and checked by
|
|
341
|
+
* `parseMetaUserData`, so a bad value never fails the call nor shows up
|
|
342
|
+
* in an error.
|
|
343
|
+
*/
|
|
344
|
+
meta: v.optional(v.any()),
|
|
286
345
|
},
|
|
287
346
|
returns: v.object({ applied: v.boolean() }),
|
|
288
347
|
handler: async (ctx, args) => {
|
|
289
348
|
const now = Date.now();
|
|
290
349
|
const leadKey = opaque(args.leadKey, "leadKey");
|
|
291
350
|
const eventId = opaque(args.eventId, "eventId");
|
|
351
|
+
const meta = parseMetaUserData(args.meta) ?? undefined;
|
|
292
352
|
const at = eventTime(args.occurredAt, now);
|
|
293
353
|
const revenueCents = args.revenueCents ?? 0;
|
|
294
354
|
if (!isValidMoney(revenueCents)) {
|
|
@@ -300,6 +360,9 @@ export const setStage = mutation({
|
|
|
300
360
|
if (args.stage !== "won" && args.revenueCents !== undefined) {
|
|
301
361
|
fail("INVALID_ARGUMENT", "revenueCents is only accepted with won");
|
|
302
362
|
}
|
|
363
|
+
if (args.stage === undefined && args.column === undefined) {
|
|
364
|
+
fail("INVALID_ARGUMENT", "pass a stage, a column, or both");
|
|
365
|
+
}
|
|
303
366
|
const lead = await findLead(ctx, args.siteId, leadKey);
|
|
304
367
|
if (!lead) return { applied: false };
|
|
305
368
|
const seen = await ctx.db
|
|
@@ -309,6 +372,25 @@ export const setStage = mutation({
|
|
|
309
372
|
)
|
|
310
373
|
.unique();
|
|
311
374
|
if (seen) return { applied: seen.leadId === lead._id };
|
|
375
|
+
if (args.stage === undefined) {
|
|
376
|
+
// A board move alone, idempotent by its own eventId. A move older than
|
|
377
|
+
// the lead's last one is recorded (so a retry stays a no-op) but never
|
|
378
|
+
// moves the lead back.
|
|
379
|
+
const column = args.column ?? "";
|
|
380
|
+
const move = await recordColumnMove(ctx, {
|
|
381
|
+
siteId: args.siteId,
|
|
382
|
+
leadId: lead._id,
|
|
383
|
+
eventId,
|
|
384
|
+
column,
|
|
385
|
+
at,
|
|
386
|
+
});
|
|
387
|
+
if (!move.fresh) return { applied: move.leadId === lead._id };
|
|
388
|
+
const moved = await enterColumn(ctx, lead, column, at, meta);
|
|
389
|
+
if (moved) {
|
|
390
|
+
await ctx.db.patch("leads", lead._id, { ...moved, updatedAt: now });
|
|
391
|
+
}
|
|
392
|
+
return { applied: true };
|
|
393
|
+
}
|
|
312
394
|
|
|
313
395
|
const delta: Counters = { leads: 0, qualified: 0, won: 0, revenueCents: 0 };
|
|
314
396
|
const patch: Partial<Doc<"leads">> = { stageAt: at, updatedAt: now };
|
|
@@ -352,7 +434,8 @@ export const setStage = mutation({
|
|
|
352
434
|
patch.stage = lead.qualifiedAt !== undefined ? "qualified" : "lead";
|
|
353
435
|
break;
|
|
354
436
|
}
|
|
355
|
-
await ctx
|
|
437
|
+
const moved = await enterColumn(ctx, lead, args.column, at, meta);
|
|
438
|
+
await ctx.db.patch("leads", lead._id, { ...patch, ...moved });
|
|
356
439
|
await ctx.db.insert("leadEvents", {
|
|
357
440
|
siteId: args.siteId,
|
|
358
441
|
leadId: lead._id,
|
|
@@ -362,6 +445,18 @@ export const setStage = mutation({
|
|
|
362
445
|
...(args.stage === "won" ? { revenueCents } : {}),
|
|
363
446
|
});
|
|
364
447
|
await bumpLead(ctx, lead, delta);
|
|
448
|
+
// Purchase only ever comes from a recorded sale, never from a column.
|
|
449
|
+
if (args.stage === "won" && meta) {
|
|
450
|
+
const site = await ctx.db.get("sites", args.siteId);
|
|
451
|
+
await enqueuePurchase(ctx, {
|
|
452
|
+
siteId: args.siteId,
|
|
453
|
+
leadId: lead._id,
|
|
454
|
+
eventTime: at,
|
|
455
|
+
valueCents: revenueCents,
|
|
456
|
+
currency: site?.currency ?? "USD",
|
|
457
|
+
meta,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
365
460
|
return { applied: true };
|
|
366
461
|
},
|
|
367
462
|
});
|
|
@@ -385,12 +480,14 @@ export const remove = mutation({
|
|
|
385
480
|
.withIndex("by_leadId_and_at", (range) => range.eq("leadId", lead._id))
|
|
386
481
|
.take(MAX_LEAD_EVENTS_PER_LEAD);
|
|
387
482
|
for (const event of events) await ctx.db.delete("leadEvents", event._id);
|
|
483
|
+
await removeLeadConversions(ctx, lead);
|
|
388
484
|
await bumpLead(ctx, lead, {
|
|
389
485
|
leads: -1,
|
|
390
486
|
qualified: lead.qualifiedAt !== undefined ? -1 : 0,
|
|
391
487
|
won: lead.stage === "won" ? -1 : 0,
|
|
392
488
|
revenueCents: lead.stage === "won" ? -lead.revenueCents : 0,
|
|
393
489
|
});
|
|
490
|
+
await forgetLeadColumns(ctx, lead);
|
|
394
491
|
await ctx.db.delete("leads", lead._id);
|
|
395
492
|
return { removed: true };
|
|
396
493
|
},
|
|
@@ -498,6 +595,128 @@ function cohortDays(from: number, to: number) {
|
|
|
498
595
|
return { first: Math.floor(from / DAY_MS) * DAY_MS, last: to };
|
|
499
596
|
}
|
|
500
597
|
|
|
598
|
+
type Spend = { spendCents: number; impressions: number; clicks: number };
|
|
599
|
+
type NullableSpend = { [K in keyof Spend]: number | null };
|
|
600
|
+
// Unknown spend (no synced ad account covers the row), never zero.
|
|
601
|
+
const NO_SPEND: NullableSpend = {
|
|
602
|
+
spendCents: null,
|
|
603
|
+
impressions: null,
|
|
604
|
+
clicks: null,
|
|
605
|
+
};
|
|
606
|
+
const zeroSpend = (): Spend => ({ spendCents: 0, impressions: 0, clicks: 0 });
|
|
607
|
+
/** Only the three spend figures of a row, whatever else it carries. */
|
|
608
|
+
const spendOf = (row: Spend): Spend => ({
|
|
609
|
+
spendCents: row.spendCents,
|
|
610
|
+
impressions: row.impressions,
|
|
611
|
+
clicks: row.clicks,
|
|
612
|
+
});
|
|
613
|
+
const addSpend = (into: Spend, row: Spend) => {
|
|
614
|
+
into.spendCents += row.spendCents;
|
|
615
|
+
into.impressions += row.impressions;
|
|
616
|
+
into.clicks += row.clicks;
|
|
617
|
+
};
|
|
618
|
+
/** Sums two nullable spends: unknown only when both are. */
|
|
619
|
+
function plusSpend(left: NullableSpend, right: NullableSpend): NullableSpend {
|
|
620
|
+
if (left.spendCents === null) return { ...NO_SPEND, ...pickSpend(right) };
|
|
621
|
+
if (right.spendCents === null) return pickSpend(left);
|
|
622
|
+
return {
|
|
623
|
+
spendCents: left.spendCents + right.spendCents,
|
|
624
|
+
impressions: (left.impressions ?? 0) + (right.impressions ?? 0),
|
|
625
|
+
clicks: (left.clicks ?? 0) + (right.clicks ?? 0),
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
const pickSpend = (row: NullableSpend): NullableSpend => ({
|
|
629
|
+
spendCents: row.spendCents,
|
|
630
|
+
impressions: row.impressions,
|
|
631
|
+
clicks: row.clicks,
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* What a site's Meta spend can be read from: its linked ad accounts that have
|
|
636
|
+
* finished a sync. An account still backfilling reports nothing yet, so its
|
|
637
|
+
* spend reads as unknown ("syncing"), never as zero.
|
|
638
|
+
*/
|
|
639
|
+
async function spendSources(ctx: QueryCtx, siteId: Id<"sites">) {
|
|
640
|
+
const links = await ctx.db
|
|
641
|
+
.query("siteAdAccounts")
|
|
642
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
643
|
+
.take(MAX_SITE_AD_ACCOUNTS);
|
|
644
|
+
const synced = links.filter((link) => link.lastSyncAt !== undefined);
|
|
645
|
+
return {
|
|
646
|
+
accounts: new Set(synced.map((link) => link.adAccountId)),
|
|
647
|
+
currencies: new Set(synced.map((link) => link.currency)),
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* A site's spend per account and day in the range, from the site-day sums.
|
|
653
|
+
* Its budget is its own, so Meta rows never cost the lead report its leads;
|
|
654
|
+
* past it the answer is `null` (the caller reports spend as incomplete).
|
|
655
|
+
*/
|
|
656
|
+
async function readSiteSpend(
|
|
657
|
+
ctx: QueryCtx,
|
|
658
|
+
budget: { rows: number },
|
|
659
|
+
siteId: Id<"sites">,
|
|
660
|
+
first: number,
|
|
661
|
+
lastDay: number,
|
|
662
|
+
) {
|
|
663
|
+
const rows = await ctx.db
|
|
664
|
+
.query("siteSpendDaily")
|
|
665
|
+
.withIndex("by_siteId_and_dayStart", (range) =>
|
|
666
|
+
range
|
|
667
|
+
.eq("siteId", siteId)
|
|
668
|
+
.gte("dayStart", first)
|
|
669
|
+
.lte("dayStart", lastDay),
|
|
670
|
+
)
|
|
671
|
+
.take(budget.rows + 1);
|
|
672
|
+
if (rows.length > budget.rows) {
|
|
673
|
+
budget.rows = 0;
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
budget.rows -= rows.length;
|
|
677
|
+
return rows;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* A site's spend per ad and day in the range (or one ad's), for the campaign
|
|
682
|
+
* tree. A budget of its own: past it the answer is `null` and the tree's
|
|
683
|
+
* spend is unknown, while the leads and the site totals stay exact.
|
|
684
|
+
*/
|
|
685
|
+
async function readAdSpend(
|
|
686
|
+
ctx: QueryCtx,
|
|
687
|
+
budget: { rows: number },
|
|
688
|
+
siteId: Id<"sites">,
|
|
689
|
+
first: number,
|
|
690
|
+
lastDay: number,
|
|
691
|
+
adId?: string,
|
|
692
|
+
) {
|
|
693
|
+
const query = adId
|
|
694
|
+
? ctx.db
|
|
695
|
+
.query("adSpendDaily")
|
|
696
|
+
.withIndex("by_siteId_and_adId_and_dayStart", (range) =>
|
|
697
|
+
range
|
|
698
|
+
.eq("siteId", siteId)
|
|
699
|
+
.eq("adId", adId)
|
|
700
|
+
.gte("dayStart", first)
|
|
701
|
+
.lte("dayStart", lastDay),
|
|
702
|
+
)
|
|
703
|
+
: ctx.db
|
|
704
|
+
.query("adSpendDaily")
|
|
705
|
+
.withIndex("by_siteId_and_dayStart", (range) =>
|
|
706
|
+
range
|
|
707
|
+
.eq("siteId", siteId)
|
|
708
|
+
.gte("dayStart", first)
|
|
709
|
+
.lte("dayStart", lastDay),
|
|
710
|
+
);
|
|
711
|
+
const rows = await query.take(budget.rows + 1);
|
|
712
|
+
if (rows.length > budget.rows) {
|
|
713
|
+
budget.rows = 0;
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
budget.rows -= rows.length;
|
|
717
|
+
return rows;
|
|
718
|
+
}
|
|
719
|
+
|
|
501
720
|
const zero = (): Counters => ({
|
|
502
721
|
leads: 0,
|
|
503
722
|
qualified: 0,
|
|
@@ -560,15 +779,52 @@ export const report = query({
|
|
|
560
779
|
handler: async (ctx, args) => {
|
|
561
780
|
const siteIds = validateSites(args.siteIds);
|
|
562
781
|
const { first, last } = cohortDays(args.from, args.to);
|
|
782
|
+
const lastDay = Math.floor(last / DAY_MS) * DAY_MS;
|
|
563
783
|
const currencies = new Set<string>();
|
|
564
784
|
const totals = zero();
|
|
565
785
|
const origins = new Map<string, Counters>();
|
|
566
|
-
|
|
786
|
+
type AdEntry = {
|
|
787
|
+
siteId: Id<"sites">;
|
|
788
|
+
counters: Counters;
|
|
789
|
+
spend: Spend | null;
|
|
790
|
+
accounts: Set<string>;
|
|
791
|
+
};
|
|
792
|
+
const ads = new Map<string, AdEntry>();
|
|
793
|
+
type SiteEntry = {
|
|
794
|
+
siteId: Id<"sites">;
|
|
795
|
+
counters: Counters;
|
|
796
|
+
paid: Counters;
|
|
797
|
+
spend: NullableSpend;
|
|
798
|
+
spendCurrency: string | null;
|
|
799
|
+
currencyMismatch: boolean;
|
|
800
|
+
};
|
|
801
|
+
const bySite: SiteEntry[] = [];
|
|
567
802
|
const budget = { rows: MAX_LEAD_REPORT_ROWS };
|
|
803
|
+
const sumBudget = { rows: MAX_SPEND_SUM_ROWS };
|
|
804
|
+
const adBudget = { rows: MAX_AD_SPEND_ROWS };
|
|
805
|
+
// Every currency the synced accounts of the requested sites bill in.
|
|
806
|
+
const spendCurrencies = new Set<string>();
|
|
807
|
+
let spendIncomplete = false;
|
|
808
|
+
let treeSpendKnown = true;
|
|
809
|
+
const adEntry = (
|
|
810
|
+
adId: string,
|
|
811
|
+
siteId: Id<"sites">,
|
|
812
|
+
accounts: Set<string>,
|
|
813
|
+
): AdEntry => {
|
|
814
|
+
let entry = ads.get(adId);
|
|
815
|
+
if (!entry) {
|
|
816
|
+
entry = { siteId, counters: zero(), spend: null, accounts };
|
|
817
|
+
ads.set(adId, entry);
|
|
818
|
+
}
|
|
819
|
+
return entry;
|
|
820
|
+
};
|
|
568
821
|
for (const siteId of siteIds) {
|
|
569
822
|
const site = await ctx.db.get("sites", siteId);
|
|
570
823
|
if (!site) fail("NOT_FOUND", "site not found");
|
|
571
|
-
|
|
824
|
+
const siteCurrency = site.currency ?? "USD";
|
|
825
|
+
currencies.add(siteCurrency);
|
|
826
|
+
const siteCounters = zero();
|
|
827
|
+
const paidCounters = zero();
|
|
572
828
|
for (const row of await readRollups(
|
|
573
829
|
ctx,
|
|
574
830
|
budget,
|
|
@@ -578,10 +834,13 @@ export const report = query({
|
|
|
578
834
|
last,
|
|
579
835
|
)) {
|
|
580
836
|
add(totals, row);
|
|
837
|
+
add(siteCounters, row);
|
|
838
|
+
if (row.key === AD_ORIGIN) add(paidCounters, row);
|
|
581
839
|
const counters = origins.get(row.key) ?? zero();
|
|
582
840
|
add(counters, row);
|
|
583
841
|
origins.set(row.key, counters);
|
|
584
842
|
}
|
|
843
|
+
const sources = await spendSources(ctx, siteId);
|
|
585
844
|
for (const row of await readRollups(
|
|
586
845
|
ctx,
|
|
587
846
|
budget,
|
|
@@ -590,11 +849,67 @@ export const report = query({
|
|
|
590
849
|
first,
|
|
591
850
|
last,
|
|
592
851
|
)) {
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
852
|
+
add(adEntry(row.key, siteId, sources.accounts).counters, row);
|
|
853
|
+
}
|
|
854
|
+
for (const currency of sources.currencies) spendCurrencies.add(currency);
|
|
855
|
+
|
|
856
|
+
// The site's spend, from the site-day sums of its synced accounts.
|
|
857
|
+
let spend: NullableSpend = NO_SPEND;
|
|
858
|
+
if (sources.accounts.size > 0) {
|
|
859
|
+
const sums = await readSiteSpend(
|
|
860
|
+
ctx,
|
|
861
|
+
sumBudget,
|
|
862
|
+
siteId,
|
|
863
|
+
first,
|
|
864
|
+
lastDay,
|
|
865
|
+
);
|
|
866
|
+
if (sums === null) {
|
|
867
|
+
spendIncomplete = true;
|
|
868
|
+
} else {
|
|
869
|
+
const total = zeroSpend();
|
|
870
|
+
for (const row of sums) {
|
|
871
|
+
if (sources.accounts.has(row.adAccountId)) addSpend(total, row);
|
|
872
|
+
}
|
|
873
|
+
spend = total;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
const mixed = sources.currencies.size > 1;
|
|
877
|
+
const spendCurrency =
|
|
878
|
+
sources.currencies.size === 1 ? [...sources.currencies][0]! : null;
|
|
879
|
+
bySite.push({
|
|
880
|
+
siteId,
|
|
881
|
+
counters: siteCounters,
|
|
882
|
+
paid: paidCounters,
|
|
883
|
+
// Two currencies cannot be summed: the site's spend is unknown.
|
|
884
|
+
spend: mixed ? NO_SPEND : spend,
|
|
885
|
+
spendCurrency,
|
|
886
|
+
currencyMismatch:
|
|
887
|
+
mixed || (spendCurrency !== null && spendCurrency !== siteCurrency),
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
// The campaign tree's spend, per ad, on a budget of its own.
|
|
891
|
+
if (sources.accounts.size > 0 && treeSpendKnown) {
|
|
892
|
+
const rows = await readAdSpend(ctx, adBudget, siteId, first, lastDay);
|
|
893
|
+
if (rows === null) {
|
|
894
|
+
spendIncomplete = true;
|
|
895
|
+
treeSpendKnown = false;
|
|
896
|
+
} else {
|
|
897
|
+
for (const row of rows) {
|
|
898
|
+
if (!sources.accounts.has(row.adAccountId)) continue;
|
|
899
|
+
const entry = adEntry(row.adId, siteId, sources.accounts);
|
|
900
|
+
entry.spend ??= zeroSpend();
|
|
901
|
+
addSpend(entry.spend, spendOf(row));
|
|
902
|
+
}
|
|
903
|
+
}
|
|
596
904
|
}
|
|
597
905
|
}
|
|
906
|
+
// Spend adds up only in one currency.
|
|
907
|
+
const oneCurrency = spendCurrencies.size <= 1;
|
|
908
|
+
if (!oneCurrency) treeSpendKnown = false;
|
|
909
|
+
let totalSpend: NullableSpend = NO_SPEND;
|
|
910
|
+
if (oneCurrency) {
|
|
911
|
+
for (const site of bySite) totalSpend = plusSpend(totalSpend, site.spend);
|
|
912
|
+
}
|
|
598
913
|
if (ads.size > MAX_LEAD_REPORT_ADS) {
|
|
599
914
|
fail(
|
|
600
915
|
"REPORT_INCOMPLETE",
|
|
@@ -609,10 +924,28 @@ export const report = query({
|
|
|
609
924
|
type AdsetRow = Infer<typeof leadAdsetRowValidator>;
|
|
610
925
|
type CampaignRow = Infer<typeof leadCampaignRowValidator>;
|
|
611
926
|
const campaigns = new Map<string, CampaignRow>();
|
|
612
|
-
|
|
927
|
+
const sum = (
|
|
928
|
+
row: { [K in keyof Spend]?: number | null },
|
|
929
|
+
spend: NullableSpend,
|
|
930
|
+
) => {
|
|
931
|
+
const next = plusSpend(
|
|
932
|
+
{
|
|
933
|
+
spendCents: row.spendCents ?? null,
|
|
934
|
+
impressions: row.impressions ?? null,
|
|
935
|
+
clicks: row.clicks ?? null,
|
|
936
|
+
},
|
|
937
|
+
spend,
|
|
938
|
+
);
|
|
939
|
+
row.spendCents = next.spendCents;
|
|
940
|
+
row.impressions = next.impressions;
|
|
941
|
+
row.clicks = next.clicks;
|
|
942
|
+
};
|
|
943
|
+
for (const [adId, entry] of ads) {
|
|
944
|
+
const { siteId, counters } = entry;
|
|
613
945
|
// 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
|
-
|
|
946
|
+
// nothing left there (removed leads leave zeroed rows behind), unless
|
|
947
|
+
// the ad spent: an ad burning budget without leads is the point.
|
|
948
|
+
if (counters.leads === 0 && entry.spend === null) continue;
|
|
616
949
|
const catalog = await ctx.db
|
|
617
950
|
.query("adCatalog")
|
|
618
951
|
.withIndex("by_siteId_and_adId", (range) =>
|
|
@@ -628,23 +961,41 @@ export const report = query({
|
|
|
628
961
|
)
|
|
629
962
|
.order("desc")
|
|
630
963
|
.first();
|
|
964
|
+
// An ad of a synced account that spent nothing in the range spent 0;
|
|
965
|
+
// one no synced account covers has unknown spend.
|
|
966
|
+
const spend: NullableSpend = !treeSpendKnown
|
|
967
|
+
? NO_SPEND
|
|
968
|
+
: entry.spend
|
|
969
|
+
? spendOf(entry.spend)
|
|
970
|
+
: catalog?.adAccountId && entry.accounts.has(catalog.adAccountId)
|
|
971
|
+
? zeroSpend()
|
|
972
|
+
: NO_SPEND;
|
|
973
|
+
const thumbnailUrl = catalog?.thumbnailStorageId
|
|
974
|
+
? await ctx.storage.getUrl(catalog.thumbnailStorageId)
|
|
975
|
+
: null;
|
|
631
976
|
const ad: AdRow = {
|
|
632
977
|
adId,
|
|
978
|
+
siteId,
|
|
633
979
|
adName: catalog?.adName ?? null,
|
|
634
980
|
sourceApp: latest?.ad?.sourceApp ?? null,
|
|
635
981
|
sourceUrl: latest?.ad?.sourceUrl ?? null,
|
|
636
982
|
title: latest?.ad?.title ?? null,
|
|
637
|
-
thumbnailUrl: catalog?.thumbnailUrl ?? null,
|
|
983
|
+
thumbnailUrl: thumbnailUrl ?? catalog?.thumbnailUrl ?? null,
|
|
984
|
+
creativeType: catalog?.creativeType ?? null,
|
|
985
|
+
adStatus: catalog?.adStatus ?? null,
|
|
638
986
|
...counters,
|
|
987
|
+
...spend,
|
|
639
988
|
};
|
|
640
989
|
const campaignKey = catalog?.campaignId ?? "";
|
|
641
990
|
const campaign = campaigns.get(campaignKey) ?? {
|
|
642
991
|
campaignId: catalog?.campaignId ?? null,
|
|
643
992
|
campaignName: catalog?.campaignName ?? null,
|
|
644
993
|
...zero(),
|
|
994
|
+
...NO_SPEND,
|
|
645
995
|
adsets: [],
|
|
646
996
|
};
|
|
647
997
|
add(campaign, counters);
|
|
998
|
+
sum(campaign, spend);
|
|
648
999
|
const adsetId = catalog?.adsetId ?? null;
|
|
649
1000
|
let adset: AdsetRow | undefined = campaign.adsets.find(
|
|
650
1001
|
(row) => row.adsetId === adsetId,
|
|
@@ -654,11 +1005,13 @@ export const report = query({
|
|
|
654
1005
|
adsetId,
|
|
655
1006
|
adsetName: catalog?.adsetName ?? null,
|
|
656
1007
|
...zero(),
|
|
1008
|
+
...NO_SPEND,
|
|
657
1009
|
ads: [],
|
|
658
1010
|
};
|
|
659
1011
|
campaign.adsets.push(adset);
|
|
660
1012
|
}
|
|
661
1013
|
add(adset, counters);
|
|
1014
|
+
sum(adset, spend);
|
|
662
1015
|
adset.ads.push(ad);
|
|
663
1016
|
campaigns.set(campaignKey, campaign);
|
|
664
1017
|
}
|
|
@@ -684,20 +1037,167 @@ export const report = query({
|
|
|
684
1037
|
right,
|
|
685
1038
|
),
|
|
686
1039
|
);
|
|
1040
|
+
// Spend buys the ad origin only; the organic ones stay unknown.
|
|
1041
|
+
if (!origins.has(AD_ORIGIN) && (totalSpend.spendCents ?? 0) > 0) {
|
|
1042
|
+
origins.set(AD_ORIGIN, zero());
|
|
1043
|
+
}
|
|
687
1044
|
return {
|
|
688
1045
|
from: args.from,
|
|
689
1046
|
to: args.to,
|
|
690
1047
|
currency: currencies.size === 1 ? [...currencies][0] : null,
|
|
691
|
-
totals,
|
|
1048
|
+
totals: { ...totals, ...totalSpend },
|
|
692
1049
|
origins: [...origins]
|
|
693
|
-
.map(([key, counters]) => ({
|
|
694
|
-
|
|
1050
|
+
.map(([key, counters]) => ({
|
|
1051
|
+
key,
|
|
1052
|
+
...counters,
|
|
1053
|
+
...(key === AD_ORIGIN ? totalSpend : NO_SPEND),
|
|
1054
|
+
}))
|
|
1055
|
+
.filter(
|
|
1056
|
+
(row) =>
|
|
1057
|
+
row.leads > 0 || (row.spendCents !== null && row.spendCents > 0),
|
|
1058
|
+
)
|
|
695
1059
|
.sort(byLeadsThenKey((row) => row.key)),
|
|
696
1060
|
campaigns: campaignRows,
|
|
1061
|
+
bySite: bySite.map((site) => ({
|
|
1062
|
+
siteId: site.siteId,
|
|
1063
|
+
...site.counters,
|
|
1064
|
+
paid: site.paid,
|
|
1065
|
+
spendCents: site.spend.spendCents,
|
|
1066
|
+
spendCurrency: site.spendCurrency,
|
|
1067
|
+
currencyMismatch: site.currencyMismatch,
|
|
1068
|
+
})),
|
|
1069
|
+
spendCurrency:
|
|
1070
|
+
oneCurrency && totalSpend.spendCents !== null
|
|
1071
|
+
? ([...spendCurrencies][0] ?? null)
|
|
1072
|
+
: null,
|
|
1073
|
+
currencyMismatch:
|
|
1074
|
+
!oneCurrency || bySite.some((site) => site.currencyMismatch),
|
|
1075
|
+
spendIncomplete,
|
|
697
1076
|
};
|
|
698
1077
|
},
|
|
699
1078
|
});
|
|
700
1079
|
|
|
1080
|
+
/**
|
|
1081
|
+
* The lead funnel per cohort day, for sparklines and the sales × spend chart:
|
|
1082
|
+
* every UTC day in the range, empty days included. With `adId`, only that
|
|
1083
|
+
* ad's leads; with `origin`, only that origin's (`ctwa_ad` for the leads the
|
|
1084
|
+
* ads bought). Spend is the day's Meta spend for the same scope, null where
|
|
1085
|
+
* no synced ad account covers it, for an organic origin, or when the sites'
|
|
1086
|
+
* accounts bill in more than one currency.
|
|
1087
|
+
*/
|
|
1088
|
+
export const daily = query({
|
|
1089
|
+
args: {
|
|
1090
|
+
siteIds: v.array(v.id("sites")),
|
|
1091
|
+
from: v.number(),
|
|
1092
|
+
to: v.number(),
|
|
1093
|
+
adId: v.optional(v.string()),
|
|
1094
|
+
origin: v.optional(v.string()),
|
|
1095
|
+
},
|
|
1096
|
+
returns: leadsDailyValidator,
|
|
1097
|
+
handler: async (ctx, args) => {
|
|
1098
|
+
const siteIds = validateSites(args.siteIds);
|
|
1099
|
+
const { first, last } = cohortDays(args.from, args.to);
|
|
1100
|
+
const lastDay = Math.floor(last / DAY_MS) * DAY_MS;
|
|
1101
|
+
if ((lastDay - first) / DAY_MS + 1 > MAX_LEAD_DAILY_DAYS) {
|
|
1102
|
+
fail(
|
|
1103
|
+
"INVALID_ARGUMENT",
|
|
1104
|
+
`a daily series spans at most ${MAX_LEAD_DAILY_DAYS} UTC days`,
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
if (args.adId !== undefined && args.origin !== undefined) {
|
|
1108
|
+
fail("INVALID_ARGUMENT", "pass adId or origin, not both");
|
|
1109
|
+
}
|
|
1110
|
+
if (args.origin !== undefined && !ENTRY_POINT.test(args.origin)) {
|
|
1111
|
+
invalid("origin");
|
|
1112
|
+
}
|
|
1113
|
+
const adId =
|
|
1114
|
+
args.adId === undefined ? undefined : opaque(args.adId, "adId");
|
|
1115
|
+
const key = adId ?? args.origin;
|
|
1116
|
+
const days = new Map<number, Counters>();
|
|
1117
|
+
for (let day = first; day <= lastDay; day += DAY_MS) days.set(day, zero());
|
|
1118
|
+
const budget = { rows: MAX_LEAD_REPORT_ROWS };
|
|
1119
|
+
const spendBudget = { rows: MAX_SPEND_SUM_ROWS };
|
|
1120
|
+
const spend = new Map<number, number>();
|
|
1121
|
+
const spendCurrencies = new Set<string>();
|
|
1122
|
+
let spendKnown = false;
|
|
1123
|
+
let spendBroken = false;
|
|
1124
|
+
for (const siteId of siteIds) {
|
|
1125
|
+
const site = await ctx.db.get("sites", siteId);
|
|
1126
|
+
if (!site) fail("NOT_FOUND", "site not found");
|
|
1127
|
+
for (const row of await readRollups(
|
|
1128
|
+
ctx,
|
|
1129
|
+
budget,
|
|
1130
|
+
siteId,
|
|
1131
|
+
adId === undefined ? "origin" : "ad",
|
|
1132
|
+
first,
|
|
1133
|
+
last,
|
|
1134
|
+
)) {
|
|
1135
|
+
if (key !== undefined && row.key !== key) continue;
|
|
1136
|
+
const counters = days.get(row.dayStart);
|
|
1137
|
+
if (counters) add(counters, row);
|
|
1138
|
+
}
|
|
1139
|
+
// Spend buys the ad leads: an organic origin has none to show.
|
|
1140
|
+
if (args.origin !== undefined && args.origin !== AD_ORIGIN) continue;
|
|
1141
|
+
const sources = await spendSources(ctx, siteId);
|
|
1142
|
+
if (sources.accounts.size === 0) continue;
|
|
1143
|
+
let rows: Array<{
|
|
1144
|
+
dayStart: number;
|
|
1145
|
+
spendCents: number;
|
|
1146
|
+
adAccountId: string;
|
|
1147
|
+
}> | null;
|
|
1148
|
+
if (adId === undefined) {
|
|
1149
|
+
rows = await readSiteSpend(ctx, spendBudget, siteId, first, lastDay);
|
|
1150
|
+
} else {
|
|
1151
|
+
rows = await readAdSpend(
|
|
1152
|
+
ctx,
|
|
1153
|
+
spendBudget,
|
|
1154
|
+
siteId,
|
|
1155
|
+
first,
|
|
1156
|
+
lastDay,
|
|
1157
|
+
adId,
|
|
1158
|
+
);
|
|
1159
|
+
const own = rows?.some((row) => sources.accounts.has(row.adAccountId));
|
|
1160
|
+
if (!own) {
|
|
1161
|
+
// One ad: its spend is known only if a synced account of this
|
|
1162
|
+
// site runs it.
|
|
1163
|
+
const catalog = await ctx.db
|
|
1164
|
+
.query("adCatalog")
|
|
1165
|
+
.withIndex("by_siteId_and_adId", (range) =>
|
|
1166
|
+
range.eq("siteId", siteId).eq("adId", adId),
|
|
1167
|
+
)
|
|
1168
|
+
.unique();
|
|
1169
|
+
if (
|
|
1170
|
+
!catalog?.adAccountId ||
|
|
1171
|
+
!sources.accounts.has(catalog.adAccountId)
|
|
1172
|
+
) {
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
if (rows === null) {
|
|
1178
|
+
spendBroken = true;
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
spendKnown = true;
|
|
1182
|
+
for (const currency of sources.currencies) spendCurrencies.add(currency);
|
|
1183
|
+
for (const row of rows) {
|
|
1184
|
+
if (!sources.accounts.has(row.adAccountId)) continue;
|
|
1185
|
+
spend.set(
|
|
1186
|
+
row.dayStart,
|
|
1187
|
+
(spend.get(row.dayStart) ?? 0) + row.spendCents,
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
// Two currencies do not add up; a series past its budget is not whole.
|
|
1192
|
+
const showSpend = spendKnown && !spendBroken && spendCurrencies.size <= 1;
|
|
1193
|
+
return [...days].map(([dayStart, counters]) => ({
|
|
1194
|
+
dayStart,
|
|
1195
|
+
...counters,
|
|
1196
|
+
spendCents: showSpend ? (spend.get(dayStart) ?? 0) : null,
|
|
1197
|
+
}));
|
|
1198
|
+
},
|
|
1199
|
+
});
|
|
1200
|
+
|
|
701
1201
|
// The click id stays in the host's deployment: it is for the Conversions API,
|
|
702
1202
|
// not for a dashboard.
|
|
703
1203
|
function summarize(lead: Doc<"leads">): Infer<typeof leadSummaryValidator> {
|