@aglyn/plugins-marketing 1.0.0-beta.229 → 1.0.0-beta.231
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 +2 -2
- package/package.json +16 -15
- package/src/lib/components/campaign-conversions-card.js +23 -11
- package/src/lib/components/campaign-conversions-card.js.map +1 -1
- package/src/lib/components/campaign-list-zones.d.ts +33 -0
- package/src/lib/components/campaign-list-zones.js +30 -0
- package/src/lib/components/campaign-list-zones.js.map +1 -0
- package/src/lib/components/campaign-report-card.js +20 -3
- package/src/lib/components/campaign-report-card.js.map +1 -1
- package/src/lib/components/campaigns-card.js +42 -13
- package/src/lib/components/campaigns-card.js.map +1 -1
- package/src/lib/components/experiment-zones.d.ts +11 -0
- package/src/lib/components/experiment-zones.js.map +1 -1
- package/src/lib/components/host-experiments-card.component.js +5 -1
- package/src/lib/components/host-experiments-card.component.js.map +1 -1
- package/src/lib/components/host-overlays-card.component.js +139 -24
- package/src/lib/components/host-overlays-card.component.js.map +1 -1
- package/src/lib/components/marketing-insight-zones.d.ts +28 -0
- package/src/lib/components/marketing-insight-zones.js +30 -0
- package/src/lib/components/marketing-insight-zones.js.map +1 -0
- package/src/lib/components/overlay-zones.d.ts +84 -0
- package/src/lib/components/overlay-zones.js +38 -0
- package/src/lib/components/overlay-zones.js.map +1 -0
- package/src/lib/components/site-runtime.js +10 -1
- package/src/lib/components/site-runtime.js.map +1 -1
- package/src/lib/model/campaign-list-query.d.ts +2 -2
- package/src/lib/model/campaign-list-query.js.map +1 -1
- package/src/lib/model/index.d.ts +1 -0
- package/src/lib/model/index.js +1 -0
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/model/overlay-drafts.d.ts +97 -0
- package/src/lib/model/overlay-drafts.js +192 -0
- package/src/lib/model/overlay-drafts.js.map +1 -0
- package/src/lib/plugin.js +12 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/email-events.js +8 -0
- package/src/lib/server/email-events.js.map +1 -1
- package/src/lib/server/marketing-ai-capabilities.d.ts +24 -0
- package/src/lib/server/marketing-ai-capabilities.js +225 -0
- package/src/lib/server/marketing-ai-capabilities.js.map +1 -0
- package/src/lib/server/marketing-drafts.d.ts +86 -0
- package/src/lib/server/marketing-drafts.js +354 -0
- package/src/lib/server/marketing-drafts.js.map +1 -0
- package/src/lib/server/marketing-figures.d.ts +18 -0
- package/src/lib/server/marketing-figures.js +259 -1
- package/src/lib/server/marketing-figures.js.map +1 -1
- package/src/lib/server/overlay-figures.d.ts +36 -0
- package/src/lib/server/overlay-figures.js +158 -0
- package/src/lib/server/overlay-figures.js.map +1 -0
- package/src/lib/server.js +10 -0
- package/src/lib/server.js.map +1 -1
|
@@ -19,9 +19,29 @@ import { EMAIL_CREATED_AT_FIELD, emailSendTimeMs } from "../model/email-record.j
|
|
|
19
19
|
import { CAMPAIGN_SEND_HOST_FIELD } from "../model/campaign-container.js";
|
|
20
20
|
import { orgCampaignSends } from "./campaign-org-refs.js";
|
|
21
21
|
import { compareVariants, summarizeVariantStats } from "../model/experiments.js";
|
|
22
|
+
import { CAMPAIGN_CONVERSION_KINDS } from "../model/campaign-conversions.js";
|
|
23
|
+
import { EMAIL_ATTRIBUTION_WINDOW_DAYS } from "../model/campaign-revenue.js";
|
|
24
|
+
import { CAMPAIGN_ATTRIBUTIONS_COLLECTION } from "./campaign-attribution-store.js";
|
|
22
25
|
const MARKETING_PLUGIN_ID = 'marketing';
|
|
23
26
|
/** Sends a campaign table reads, newest first, before it keeps those sent in the window. */ export const CAMPAIGN_FIGURES_READ_LIMIT = 100;
|
|
24
27
|
/** Experiments an experiments table reads. */ export const EXPERIMENT_FIGURES_READ_LIMIT = 20;
|
|
28
|
+
/**
|
|
29
|
+
* The channels a conversion is counted by, in reading order. Each is an
|
|
30
|
+
* aggregation count over the attribution records — `kind`, `channel` and the
|
|
31
|
+
* window on `convertedAtMs` — so the table costs a read per thousand index
|
|
32
|
+
* entries rather than a read per record, and never loads one.
|
|
33
|
+
*/ export const CONVERSION_FIGURE_CHANNELS = [
|
|
34
|
+
'email',
|
|
35
|
+
'page',
|
|
36
|
+
'web',
|
|
37
|
+
'sequence'
|
|
38
|
+
];
|
|
39
|
+
/** How a conversions table names each kind: the record it credits, in the console's words. */ const CONVERSION_KIND_LABELS = {
|
|
40
|
+
form: 'Form submissions',
|
|
41
|
+
lead: 'Leads',
|
|
42
|
+
contact: 'Contacts',
|
|
43
|
+
booking: 'Bookings'
|
|
44
|
+
};
|
|
25
45
|
const WINDOWS = [
|
|
26
46
|
7,
|
|
27
47
|
14,
|
|
@@ -248,8 +268,246 @@ export function marketingFigureReaders(firestore) {
|
|
|
248
268
|
}
|
|
249
269
|
];
|
|
250
270
|
}
|
|
271
|
+
/** A stored count as a non-negative integer. */ const whole = (raw)=>{
|
|
272
|
+
const value = Number(raw);
|
|
273
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
274
|
+
};
|
|
275
|
+
/**
|
|
276
|
+
* What the site's campaigns CAUSED and EARNED, as two more tables (AGL-3603):
|
|
277
|
+
* the conversions credited to a campaign touch in the window, and the revenue
|
|
278
|
+
* credited to the campaign emails sent in it. Same rules the Conversions
|
|
279
|
+
* section and a campaign's report print — the kinds are never added together,
|
|
280
|
+
* currencies are never added together, net is gross less refunded — and the
|
|
281
|
+
* same honesty about what is NOT here: a conversion credited to nothing has no
|
|
282
|
+
* record, so it is not counted, and the notes say so.
|
|
283
|
+
*/ export function marketingOutcomeFigureReaders(firestore) {
|
|
284
|
+
return [
|
|
285
|
+
{
|
|
286
|
+
id: 'marketing.conversions',
|
|
287
|
+
label: 'Campaign conversions',
|
|
288
|
+
description: 'Conversions credited to a campaign touch on the site in the window — form submissions, leads, contacts and bookings, one row each — counted by the touch that earned the credit: a campaign email, a page filed under a campaign, a link labeled for a campaign, or a sales sequence email.',
|
|
289
|
+
scope: 'site',
|
|
290
|
+
windows: WINDOWS,
|
|
291
|
+
read: async (request)=>{
|
|
292
|
+
if (!request.hostId) return {
|
|
293
|
+
ok: false,
|
|
294
|
+
status: 400,
|
|
295
|
+
error: 'Open a site to read its conversions'
|
|
296
|
+
};
|
|
297
|
+
if (!WINDOWS.includes(request.days)) {
|
|
298
|
+
return {
|
|
299
|
+
ok: false,
|
|
300
|
+
status: 400,
|
|
301
|
+
error: 'That window is not one conversions are read over'
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const { current } = pluginFigureWindows(request.now, request.days);
|
|
305
|
+
/*
|
|
306
|
+
* Aggregation counts, never the records: per kind, one over every
|
|
307
|
+
* channel and one per channel, each an equality on `kind` (and
|
|
308
|
+
* `channel`) with the window on `convertedAtMs`. Served by the two
|
|
309
|
+
* composite indexes the index file declares for them.
|
|
310
|
+
*/ const inWindow = (kind, channel)=>{
|
|
311
|
+
let query = firestore().collection('hosts').doc(request.hostId).collection(CAMPAIGN_ATTRIBUTIONS_COLLECTION).where('kind', '==', kind);
|
|
312
|
+
if (channel) query = query.where('channel', '==', channel);
|
|
313
|
+
return query.where('convertedAtMs', '>=', current.startMs).where('convertedAtMs', '<', current.endMs).count().get().then((snapshot)=>whole(snapshot.data().count));
|
|
314
|
+
};
|
|
315
|
+
const rows = await Promise.all(CAMPAIGN_CONVERSION_KINDS.map(async (kind)=>{
|
|
316
|
+
const [credited, ...byChannel] = await Promise.all([
|
|
317
|
+
inWindow(kind, null),
|
|
318
|
+
...CONVERSION_FIGURE_CHANNELS.map((channel)=>inWindow(kind, channel))
|
|
319
|
+
]);
|
|
320
|
+
const row = {
|
|
321
|
+
kind: CONVERSION_KIND_LABELS[kind],
|
|
322
|
+
credited
|
|
323
|
+
};
|
|
324
|
+
CONVERSION_FIGURE_CHANNELS.forEach((channel, index)=>{
|
|
325
|
+
row[channel] = byChannel[index];
|
|
326
|
+
});
|
|
327
|
+
return row;
|
|
328
|
+
}));
|
|
329
|
+
return {
|
|
330
|
+
ok: true,
|
|
331
|
+
table: {
|
|
332
|
+
title: 'Campaign conversions',
|
|
333
|
+
source: {
|
|
334
|
+
label: 'Conversions',
|
|
335
|
+
path: 'marketing/conversions'
|
|
336
|
+
},
|
|
337
|
+
period: {
|
|
338
|
+
from: current.from,
|
|
339
|
+
to: current.to,
|
|
340
|
+
days: request.days
|
|
341
|
+
},
|
|
342
|
+
columns: [
|
|
343
|
+
{
|
|
344
|
+
key: 'kind',
|
|
345
|
+
label: 'Conversion',
|
|
346
|
+
kind: 'text'
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
key: 'credited',
|
|
350
|
+
label: 'Credited to a campaign',
|
|
351
|
+
kind: 'count'
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
key: 'email',
|
|
355
|
+
label: 'From a campaign email',
|
|
356
|
+
kind: 'count'
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
key: 'page',
|
|
360
|
+
label: 'From a campaign page',
|
|
361
|
+
kind: 'count'
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
key: 'web',
|
|
365
|
+
label: 'From a labeled link',
|
|
366
|
+
kind: 'count'
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
key: 'sequence',
|
|
370
|
+
label: 'From a sequence email',
|
|
371
|
+
kind: 'count'
|
|
372
|
+
}
|
|
373
|
+
],
|
|
374
|
+
rows,
|
|
375
|
+
omitted: 0,
|
|
376
|
+
notes: [
|
|
377
|
+
'Each row counts a different record of the same visits — one form submission can also make a lead and a contact — so the rows are never added together.',
|
|
378
|
+
'Only conversions credited to a campaign touch are counted; a conversion credited to nothing has no record and is not in this table.'
|
|
379
|
+
]
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
id: 'marketing.revenue',
|
|
386
|
+
label: 'Campaign revenue',
|
|
387
|
+
description: 'Revenue credited to each campaign email sent from the site in the window: orders credited, gross, refunded and net, one row per email and currency, with amounts in each row’s currency.',
|
|
388
|
+
scope: 'site',
|
|
389
|
+
windows: WINDOWS,
|
|
390
|
+
read: async (request)=>{
|
|
391
|
+
if (!request.hostId) return {
|
|
392
|
+
ok: false,
|
|
393
|
+
status: 400,
|
|
394
|
+
error: 'Open a site to read its campaign revenue'
|
|
395
|
+
};
|
|
396
|
+
if (!WINDOWS.includes(request.days)) {
|
|
397
|
+
return {
|
|
398
|
+
ok: false,
|
|
399
|
+
status: 400,
|
|
400
|
+
error: 'That window is not one campaign revenue is read over'
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
if (!request.orgId) return {
|
|
404
|
+
ok: false,
|
|
405
|
+
status: 400,
|
|
406
|
+
error: 'This site is not part of an organization'
|
|
407
|
+
};
|
|
408
|
+
const { current } = pluginFigureWindows(request.now, request.days);
|
|
409
|
+
// The campaigns table's own read: the site's sends, newest first.
|
|
410
|
+
const sendsRef = orgCampaignSends(firestore(), request.orgId);
|
|
411
|
+
const snapshot = await sendsRef.where(CAMPAIGN_SEND_HOST_FIELD, '==', request.hostId).orderBy(EMAIL_CREATED_AT_FIELD, 'desc').limit(CAMPAIGN_FIGURES_READ_LIMIT).get();
|
|
412
|
+
const sends = snapshot.docs.filter((doc)=>{
|
|
413
|
+
var _doc_data;
|
|
414
|
+
const record = (_doc_data = doc.data()) != null ? _doc_data : {};
|
|
415
|
+
const sentAt = emailSendTimeMs(record);
|
|
416
|
+
return Boolean(record['sentAt']) && sentAt >= current.startMs && sentAt < current.endMs;
|
|
417
|
+
}).slice(0, PLUGIN_FIGURE_MAX_ROWS);
|
|
418
|
+
/*
|
|
419
|
+
* One keyed read per send for its revenue rollup, bounded by the rows
|
|
420
|
+
* a table may carry — the rollup is its own document so the history
|
|
421
|
+
* list never pays for it, and this is the one place that does.
|
|
422
|
+
*/ const rollups = sends.length ? await firestore().getAll(...sends.map((doc)=>sendsRef.doc(doc.id).collection('reports').doc('revenue'))) : [];
|
|
423
|
+
const rows = [];
|
|
424
|
+
const currencies = new Set();
|
|
425
|
+
sends.forEach((doc, index)=>{
|
|
426
|
+
var _ref, _rollup_byCurrency;
|
|
427
|
+
var _rollups_index;
|
|
428
|
+
const rollup = (_ref = (_rollups_index = rollups[index]) == null ? void 0 : _rollups_index.data()) != null ? _ref : {};
|
|
429
|
+
for (const [currency, totals] of Object.entries((_rollup_byCurrency = rollup.byCurrency) != null ? _rollup_byCurrency : {})){
|
|
430
|
+
var _doc_get;
|
|
431
|
+
const grossCents = whole(totals == null ? void 0 : totals.grossCents);
|
|
432
|
+
const refundedCents = whole(totals == null ? void 0 : totals.refundedCents);
|
|
433
|
+
const orders = whole(totals == null ? void 0 : totals.orders);
|
|
434
|
+
if (!orders && !grossCents) continue;
|
|
435
|
+
currencies.add(currency.toUpperCase());
|
|
436
|
+
rows.push({
|
|
437
|
+
campaign: String((_doc_get = doc.get('subject')) != null ? _doc_get : '').trim() || 'A campaign with no subject',
|
|
438
|
+
currency: currency.toUpperCase(),
|
|
439
|
+
orders,
|
|
440
|
+
gross: grossCents / 100,
|
|
441
|
+
refunded: refundedCents / 100,
|
|
442
|
+
// Clamped where it is shown, as the campaign report clamps it.
|
|
443
|
+
net: Math.max(0, grossCents - refundedCents) / 100
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
const [only] = currencies;
|
|
448
|
+
const money = currencies.size === 1 && /^[A-Z]{3}$/.test(only != null ? only : '');
|
|
449
|
+
const amount = (key, label)=>money ? {
|
|
450
|
+
key,
|
|
451
|
+
label,
|
|
452
|
+
kind: 'money',
|
|
453
|
+
currency: only
|
|
454
|
+
} : {
|
|
455
|
+
key,
|
|
456
|
+
label: `${label} (in the row’s currency)`,
|
|
457
|
+
kind: 'number'
|
|
458
|
+
};
|
|
459
|
+
return {
|
|
460
|
+
ok: true,
|
|
461
|
+
table: {
|
|
462
|
+
title: 'Campaign revenue',
|
|
463
|
+
source: {
|
|
464
|
+
label: 'Campaigns',
|
|
465
|
+
path: 'marketing/campaigns'
|
|
466
|
+
},
|
|
467
|
+
period: {
|
|
468
|
+
from: current.from,
|
|
469
|
+
to: current.to,
|
|
470
|
+
days: request.days
|
|
471
|
+
},
|
|
472
|
+
columns: [
|
|
473
|
+
{
|
|
474
|
+
key: 'campaign',
|
|
475
|
+
label: 'Campaign email',
|
|
476
|
+
kind: 'text'
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
key: 'currency',
|
|
480
|
+
label: 'Currency',
|
|
481
|
+
kind: 'text'
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
key: 'orders',
|
|
485
|
+
label: 'Orders credited',
|
|
486
|
+
kind: 'count'
|
|
487
|
+
},
|
|
488
|
+
amount('gross', 'Gross'),
|
|
489
|
+
amount('refunded', 'Refunded'),
|
|
490
|
+
amount('net', 'Net')
|
|
491
|
+
],
|
|
492
|
+
rows: rows.slice(0, PLUGIN_FIGURE_MAX_ROWS),
|
|
493
|
+
omitted: Math.max(0, rows.length - PLUGIN_FIGURE_MAX_ROWS),
|
|
494
|
+
notes: [
|
|
495
|
+
`Revenue is credited to the last campaign email whose link the buyer clicked within ${EMAIL_ATTRIBUTION_WINDOW_DAYS} days, for the emails sent in the window; amounts in different currencies are never added together.`,
|
|
496
|
+
...snapshot.docs.length === CAMPAIGN_FIGURES_READ_LIMIT ? [
|
|
497
|
+
`Only the ${CAMPAIGN_FIGURES_READ_LIMIT} most recent emails were looked at.`
|
|
498
|
+
] : []
|
|
499
|
+
]
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
];
|
|
505
|
+
}
|
|
251
506
|
/** Registers the campaign and A/B testing readers from the console surface, where insight jobs run. */ export function registerMarketingFigureReaders(firestore) {
|
|
252
|
-
for (const reader of
|
|
507
|
+
for (const reader of [
|
|
508
|
+
...marketingFigureReaders(firestore),
|
|
509
|
+
...marketingOutcomeFigureReaders(firestore)
|
|
510
|
+
]){
|
|
253
511
|
registerPluginFigureReader(reader, {
|
|
254
512
|
pluginId: MARKETING_PLUGIN_ID
|
|
255
513
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/marketing-figures.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n PLUGIN_FIGURE_MAX_ROWS,\n pluginFigureWindows,\n registerPluginFigureReader,\n type PluginFigureReader,\n type PluginFigureRow,\n} from '@aglyn/aglyn/plugin-manager/plugin-figures'\nimport {\n type SendRate,\n type SendStats,\n} from '@aglyn/shared-ui-email-campaigns/model/send-report'\nimport { campaignReport } from '../model/campaign-report'\nimport {\n EMAIL_CREATED_AT_FIELD,\n emailSendTimeMs,\n} from '../model/email-record'\nimport { CAMPAIGN_SEND_HOST_FIELD } from '../model/campaign-container'\nimport { orgCampaignSends } from './campaign-org-refs'\nimport { compareVariants, summarizeVariantStats, type HostExperiment } from '../model/experiments'\n\n/**\n * A site's campaign and A/B testing results as figure tables another plugin\n * reads by id (AGL-2915).\n *\n * The campaign table is what each send's report shows — delivered, and the\n * open and click rates over delivered — through `campaignReport`, the one\n * reader of a send's counters. The experiments table is what the A/B testing\n * section shows, through the same variant comparison. No recipient, address\n * or visitor reaches either: a campaign is named by its subject, a variant by\n * its name.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nconst MARKETING_PLUGIN_ID = 'marketing'\n\n/** Sends a campaign table reads, newest first, before it keeps those sent in the window. */\nexport const CAMPAIGN_FIGURES_READ_LIMIT = 100\n\n/** Experiments an experiments table reads. */\nexport const EXPERIMENT_FIGURES_READ_LIMIT = 20\n\nconst WINDOWS: readonly number[] = [7, 14, 30, 90]\n\nconst percent = (rate: SendRate | null): number | null =>\n rate ? Math.round(rate.value * 1_000) / 10 : null\n\n/** Rates pooled across sends: every numerator over every denominator, never an average of rates. */\nfunction pooled(rates: ReadonlyArray<SendRate | null>): number | null {\n const known = rates.filter((rate): rate is SendRate => rate !== null)\n const denominator = known.reduce((sum, rate) => sum + rate.denominator, 0)\n if (!denominator) return null\n return Math.round((known.reduce((sum, rate) => sum + rate.numerator, 0) / denominator) * 1_000) / 10\n}\n\nexport function marketingFigureReaders(firestore: () => Firestore): PluginFigureReader[] {\n return [\n {\n id: 'marketing.campaigns',\n label: 'Campaigns',\n description:\n 'Each campaign email sent from the site in the window: how many were delivered and the share of delivered messages opened and clicked, with every campaign together on the first row.',\n scope: 'site',\n windows: WINDOWS,\n read: async (request) => {\n if (!request.hostId) return { ok: false, status: 400, error: 'Open a site to read its campaigns' }\n if (!WINDOWS.includes(request.days)) {\n return { ok: false, status: 400, error: 'That window is not one campaigns are read over' }\n }\n if (!request.orgId) return { ok: false, status: 400, error: 'This site is not part of an organization' }\n const { current } = pluginFigureWindows(request.now, request.days)\n /*\n * The organization's sends, narrowed to the ones sent as this site.\n * Served by the composite index on the site field and the creation\n * stamp — the equality and the ordering together are one index.\n */\n const snapshot = await orgCampaignSends(firestore(), request.orgId)\n .where(CAMPAIGN_SEND_HOST_FIELD, '==', request.hostId)\n .orderBy(EMAIL_CREATED_AT_FIELD, 'desc')\n .limit(CAMPAIGN_FIGURES_READ_LIMIT)\n .get()\n const sends = snapshot.docs\n .map((doc) => doc.data() ?? {})\n .filter((record) => {\n const sentAt = emailSendTimeMs(record)\n return Boolean(record['sentAt']) && sentAt >= current.startMs && sentAt < current.endMs\n })\n .map((record) => ({\n subject: String(record['subject'] ?? '').trim() || 'A campaign with no subject',\n report: campaignReport(record['stats'] as SendStats | undefined),\n }))\n const rows: PluginFigureRow[] = [\n {\n campaign: 'All campaigns',\n delivered: sends.every((send) => send.report.delivered === null)\n ? null\n : sends.reduce((sum, send) => sum + (send.report.delivered ?? 0), 0),\n openRate: pooled(sends.map((send) => send.report.rates.open)),\n clickRate: pooled(sends.map((send) => send.report.rates.click)),\n },\n ...sends.slice(0, PLUGIN_FIGURE_MAX_ROWS - 1).map((send) => ({\n campaign: send.subject,\n delivered: send.report.delivered,\n openRate: percent(send.report.rates.open),\n clickRate: percent(send.report.rates.click),\n })),\n ]\n return {\n ok: true,\n table: {\n title: 'Campaigns',\n source: { label: 'Campaigns', path: 'marketing/campaigns' },\n period: { from: current.from, to: current.to, days: request.days },\n columns: [\n { key: 'campaign', label: 'Campaign', kind: 'text' },\n { key: 'delivered', label: 'Delivered', kind: 'count' },\n { key: 'openRate', label: 'Opened', kind: 'percent' },\n { key: 'clickRate', label: 'Clicked', kind: 'percent' },\n ],\n rows,\n omitted: Math.max(0, sends.length - (PLUGIN_FIGURE_MAX_ROWS - 1)),\n notes: [\n 'Rates are over delivered messages, and a campaign with no delivery recorded has none.',\n ...(snapshot.docs.length === CAMPAIGN_FIGURES_READ_LIMIT\n ? [`Only the ${CAMPAIGN_FIGURES_READ_LIMIT} most recent emails were looked at.`]\n : []),\n ],\n },\n }\n },\n },\n {\n id: 'marketing.experiments',\n label: 'A/B tests',\n description:\n 'Each running or finished A/B test on the site: every variant’s visitors shown it, conversions and conversion rate, with the lift over the first variant and how confident that lift is.',\n scope: 'site',\n windows: [],\n feature: 'abTesting',\n read: async (request) => {\n if (!request.hostId) return { ok: false, status: 400, error: 'Open a site to read its A/B tests' }\n const experimentsRef = firestore().collection('hosts').doc(request.hostId).collection('experiments')\n const snapshot = await experimentsRef.limit(EXPERIMENT_FIGURES_READ_LIMIT).get()\n const rows: PluginFigureRow[] = []\n for (const doc of snapshot.docs) {\n const experiment = (doc.data() ?? {}) as Partial<HostExperiment>\n if (experiment.status !== 'running' && experiment.status !== 'done') continue\n const variants = (experiment.variants ?? []).slice(0, 4)\n if (!variants.length) continue\n const stats = await doc.ref.collection('stats').get()\n const byVariant = new Map(stats.docs.map((entry) => [entry.id, entry.data() ?? {}]))\n const control = byVariant.get(variants[0].id) ?? {}\n variants.forEach((variant, index) => {\n const own = byVariant.get(variant.id) ?? {}\n const summary = summarizeVariantStats(own)\n const comparison = index === 0 ? null : compareVariants(control, own)\n rows.push({\n test: String(experiment.name ?? '').trim() || doc.id,\n variant: String(variant.name ?? '').trim() || variant.id,\n shown: summary.exposures,\n conversions: summary.conversions,\n rate: summary.exposures ? Math.round(summary.rate * 1_000) / 10 : null,\n lift: comparison?.lift === null || comparison === null ? null : Math.round(comparison.lift * 1_000) / 10,\n confidence:\n comparison?.confidence === null || comparison === null\n ? null\n : Math.round(comparison.confidence * 1_000) / 10,\n })\n })\n }\n return {\n ok: true,\n table: {\n title: 'A/B tests',\n source: { label: 'A/B testing', path: 'marketing/experiments' },\n period: null,\n columns: [\n { key: 'test', label: 'Test', kind: 'text' },\n { key: 'variant', label: 'Variant', kind: 'text' },\n { key: 'shown', label: 'Shown', kind: 'count' },\n { key: 'conversions', label: 'Conversions', kind: 'count' },\n { key: 'rate', label: 'Conversion rate', kind: 'percent' },\n { key: 'lift', label: 'Lift over the first variant', kind: 'change' },\n { key: 'confidence', label: 'Confidence in the lift', kind: 'percent' },\n ],\n rows: rows.slice(0, PLUGIN_FIGURE_MAX_ROWS),\n omitted: Math.max(0, rows.length - PLUGIN_FIGURE_MAX_ROWS),\n notes: ['Figures are totals since each test started; the first variant of a test is its control.'],\n },\n }\n },\n },\n ]\n}\n\n/** Registers the campaign and A/B testing readers from the console surface, where insight jobs run. */\nexport function registerMarketingFigureReaders(firestore: () => Firestore): void {\n for (const reader of marketingFigureReaders(firestore)) {\n registerPluginFigureReader(reader, { pluginId: MARKETING_PLUGIN_ID })\n }\n}\n"],"names":["PLUGIN_FIGURE_MAX_ROWS","pluginFigureWindows","registerPluginFigureReader","campaignReport","EMAIL_CREATED_AT_FIELD","emailSendTimeMs","CAMPAIGN_SEND_HOST_FIELD","orgCampaignSends","compareVariants","summarizeVariantStats","MARKETING_PLUGIN_ID","CAMPAIGN_FIGURES_READ_LIMIT","EXPERIMENT_FIGURES_READ_LIMIT","WINDOWS","percent","rate","Math","round","value","pooled","rates","known","filter","denominator","reduce","sum","numerator","marketingFigureReaders","firestore","id","label","description","scope","windows","read","request","hostId","ok","status","error","includes","days","orgId","current","now","snapshot","where","orderBy","limit","get","sends","docs","map","doc","data","record","sentAt","Boolean","startMs","endMs","subject","String","trim","report","rows","campaign","delivered","every","send","openRate","open","clickRate","click","slice","table","title","source","path","period","from","to","columns","key","kind","omitted","max","length","notes","feature","experimentsRef","collection","experiment","byVariant","variants","stats","ref","Map","entry","control","forEach","variant","index","own","summary","comparison","push","test","name","shown","exposures","conversions","lift","confidence","registerMarketingFigureReaders","reader","pluginId"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/marketing-figures.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n PLUGIN_FIGURE_MAX_ROWS,\n pluginFigureWindows,\n registerPluginFigureReader,\n type PluginFigureReader,\n type PluginFigureRow,\n} from '@aglyn/aglyn/plugin-manager/plugin-figures'\nimport {\n type SendRate,\n type SendStats,\n} from '@aglyn/shared-ui-email-campaigns/model/send-report'\nimport { campaignReport } from '../model/campaign-report'\nimport {\n EMAIL_CREATED_AT_FIELD,\n emailSendTimeMs,\n} from '../model/email-record'\nimport { CAMPAIGN_SEND_HOST_FIELD } from '../model/campaign-container'\nimport { orgCampaignSends } from './campaign-org-refs'\nimport { compareVariants, summarizeVariantStats, type HostExperiment } from '../model/experiments'\nimport {\n CAMPAIGN_CONVERSION_KINDS,\n type CampaignConversionKind,\n type CampaignTouchChannel,\n} from '../model/campaign-conversions'\nimport {\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n type CampaignRevenueRollup,\n} from '../model/campaign-revenue'\nimport { CAMPAIGN_ATTRIBUTIONS_COLLECTION } from './campaign-attribution-store'\n\n/**\n * A site's campaign and A/B testing results as figure tables another plugin\n * reads by id (AGL-2915).\n *\n * The campaign table is what each send's report shows — delivered, and the\n * open and click rates over delivered — through `campaignReport`, the one\n * reader of a send's counters. The experiments table is what the A/B testing\n * section shows, through the same variant comparison. No recipient, address\n * or visitor reaches either: a campaign is named by its subject, a variant by\n * its name.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nconst MARKETING_PLUGIN_ID = 'marketing'\n\n/** Sends a campaign table reads, newest first, before it keeps those sent in the window. */\nexport const CAMPAIGN_FIGURES_READ_LIMIT = 100\n\n/** Experiments an experiments table reads. */\nexport const EXPERIMENT_FIGURES_READ_LIMIT = 20\n\n/**\n * The channels a conversion is counted by, in reading order. Each is an\n * aggregation count over the attribution records — `kind`, `channel` and the\n * window on `convertedAtMs` — so the table costs a read per thousand index\n * entries rather than a read per record, and never loads one.\n */\nexport const CONVERSION_FIGURE_CHANNELS: readonly CampaignTouchChannel[] = ['email', 'page', 'web', 'sequence']\n\n/** How a conversions table names each kind: the record it credits, in the console's words. */\nconst CONVERSION_KIND_LABELS: Readonly<Record<CampaignConversionKind, string>> = {\n form: 'Form submissions',\n lead: 'Leads',\n contact: 'Contacts',\n booking: 'Bookings',\n}\n\nconst WINDOWS: readonly number[] = [7, 14, 30, 90]\n\nconst percent = (rate: SendRate | null): number | null =>\n rate ? Math.round(rate.value * 1_000) / 10 : null\n\n/** Rates pooled across sends: every numerator over every denominator, never an average of rates. */\nfunction pooled(rates: ReadonlyArray<SendRate | null>): number | null {\n const known = rates.filter((rate): rate is SendRate => rate !== null)\n const denominator = known.reduce((sum, rate) => sum + rate.denominator, 0)\n if (!denominator) return null\n return Math.round((known.reduce((sum, rate) => sum + rate.numerator, 0) / denominator) * 1_000) / 10\n}\n\nexport function marketingFigureReaders(firestore: () => Firestore): PluginFigureReader[] {\n return [\n {\n id: 'marketing.campaigns',\n label: 'Campaigns',\n description:\n 'Each campaign email sent from the site in the window: how many were delivered and the share of delivered messages opened and clicked, with every campaign together on the first row.',\n scope: 'site',\n windows: WINDOWS,\n read: async (request) => {\n if (!request.hostId) return { ok: false, status: 400, error: 'Open a site to read its campaigns' }\n if (!WINDOWS.includes(request.days)) {\n return { ok: false, status: 400, error: 'That window is not one campaigns are read over' }\n }\n if (!request.orgId) return { ok: false, status: 400, error: 'This site is not part of an organization' }\n const { current } = pluginFigureWindows(request.now, request.days)\n /*\n * The organization's sends, narrowed to the ones sent as this site.\n * Served by the composite index on the site field and the creation\n * stamp — the equality and the ordering together are one index.\n */\n const snapshot = await orgCampaignSends(firestore(), request.orgId)\n .where(CAMPAIGN_SEND_HOST_FIELD, '==', request.hostId)\n .orderBy(EMAIL_CREATED_AT_FIELD, 'desc')\n .limit(CAMPAIGN_FIGURES_READ_LIMIT)\n .get()\n const sends = snapshot.docs\n .map((doc) => doc.data() ?? {})\n .filter((record) => {\n const sentAt = emailSendTimeMs(record)\n return Boolean(record['sentAt']) && sentAt >= current.startMs && sentAt < current.endMs\n })\n .map((record) => ({\n subject: String(record['subject'] ?? '').trim() || 'A campaign with no subject',\n report: campaignReport(record['stats'] as SendStats | undefined),\n }))\n const rows: PluginFigureRow[] = [\n {\n campaign: 'All campaigns',\n delivered: sends.every((send) => send.report.delivered === null)\n ? null\n : sends.reduce((sum, send) => sum + (send.report.delivered ?? 0), 0),\n openRate: pooled(sends.map((send) => send.report.rates.open)),\n clickRate: pooled(sends.map((send) => send.report.rates.click)),\n },\n ...sends.slice(0, PLUGIN_FIGURE_MAX_ROWS - 1).map((send) => ({\n campaign: send.subject,\n delivered: send.report.delivered,\n openRate: percent(send.report.rates.open),\n clickRate: percent(send.report.rates.click),\n })),\n ]\n return {\n ok: true,\n table: {\n title: 'Campaigns',\n source: { label: 'Campaigns', path: 'marketing/campaigns' },\n period: { from: current.from, to: current.to, days: request.days },\n columns: [\n { key: 'campaign', label: 'Campaign', kind: 'text' },\n { key: 'delivered', label: 'Delivered', kind: 'count' },\n { key: 'openRate', label: 'Opened', kind: 'percent' },\n { key: 'clickRate', label: 'Clicked', kind: 'percent' },\n ],\n rows,\n omitted: Math.max(0, sends.length - (PLUGIN_FIGURE_MAX_ROWS - 1)),\n notes: [\n 'Rates are over delivered messages, and a campaign with no delivery recorded has none.',\n ...(snapshot.docs.length === CAMPAIGN_FIGURES_READ_LIMIT\n ? [`Only the ${CAMPAIGN_FIGURES_READ_LIMIT} most recent emails were looked at.`]\n : []),\n ],\n },\n }\n },\n },\n {\n id: 'marketing.experiments',\n label: 'A/B tests',\n description:\n 'Each running or finished A/B test on the site: every variant’s visitors shown it, conversions and conversion rate, with the lift over the first variant and how confident that lift is.',\n scope: 'site',\n windows: [],\n feature: 'abTesting',\n read: async (request) => {\n if (!request.hostId) return { ok: false, status: 400, error: 'Open a site to read its A/B tests' }\n const experimentsRef = firestore().collection('hosts').doc(request.hostId).collection('experiments')\n const snapshot = await experimentsRef.limit(EXPERIMENT_FIGURES_READ_LIMIT).get()\n const rows: PluginFigureRow[] = []\n for (const doc of snapshot.docs) {\n const experiment = (doc.data() ?? {}) as Partial<HostExperiment>\n if (experiment.status !== 'running' && experiment.status !== 'done') continue\n const variants = (experiment.variants ?? []).slice(0, 4)\n if (!variants.length) continue\n const stats = await doc.ref.collection('stats').get()\n const byVariant = new Map(stats.docs.map((entry) => [entry.id, entry.data() ?? {}]))\n const control = byVariant.get(variants[0].id) ?? {}\n variants.forEach((variant, index) => {\n const own = byVariant.get(variant.id) ?? {}\n const summary = summarizeVariantStats(own)\n const comparison = index === 0 ? null : compareVariants(control, own)\n rows.push({\n test: String(experiment.name ?? '').trim() || doc.id,\n variant: String(variant.name ?? '').trim() || variant.id,\n shown: summary.exposures,\n conversions: summary.conversions,\n rate: summary.exposures ? Math.round(summary.rate * 1_000) / 10 : null,\n lift: comparison?.lift === null || comparison === null ? null : Math.round(comparison.lift * 1_000) / 10,\n confidence:\n comparison?.confidence === null || comparison === null\n ? null\n : Math.round(comparison.confidence * 1_000) / 10,\n })\n })\n }\n return {\n ok: true,\n table: {\n title: 'A/B tests',\n source: { label: 'A/B testing', path: 'marketing/experiments' },\n period: null,\n columns: [\n { key: 'test', label: 'Test', kind: 'text' },\n { key: 'variant', label: 'Variant', kind: 'text' },\n { key: 'shown', label: 'Shown', kind: 'count' },\n { key: 'conversions', label: 'Conversions', kind: 'count' },\n { key: 'rate', label: 'Conversion rate', kind: 'percent' },\n { key: 'lift', label: 'Lift over the first variant', kind: 'change' },\n { key: 'confidence', label: 'Confidence in the lift', kind: 'percent' },\n ],\n rows: rows.slice(0, PLUGIN_FIGURE_MAX_ROWS),\n omitted: Math.max(0, rows.length - PLUGIN_FIGURE_MAX_ROWS),\n notes: ['Figures are totals since each test started; the first variant of a test is its control.'],\n },\n }\n },\n },\n ]\n}\n\n/** A stored count as a non-negative integer. */\nconst whole = (raw: unknown): number => {\n const value = Number(raw)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * What the site's campaigns CAUSED and EARNED, as two more tables (AGL-3603):\n * the conversions credited to a campaign touch in the window, and the revenue\n * credited to the campaign emails sent in it. Same rules the Conversions\n * section and a campaign's report print — the kinds are never added together,\n * currencies are never added together, net is gross less refunded — and the\n * same honesty about what is NOT here: a conversion credited to nothing has no\n * record, so it is not counted, and the notes say so.\n */\nexport function marketingOutcomeFigureReaders(firestore: () => Firestore): PluginFigureReader[] {\n return [\n {\n id: 'marketing.conversions',\n label: 'Campaign conversions',\n description:\n 'Conversions credited to a campaign touch on the site in the window — form submissions, leads, contacts and bookings, one row each — counted by the touch that earned the credit: a campaign email, a page filed under a campaign, a link labeled for a campaign, or a sales sequence email.',\n scope: 'site',\n windows: WINDOWS,\n read: async (request) => {\n if (!request.hostId) return { ok: false, status: 400, error: 'Open a site to read its conversions' }\n if (!WINDOWS.includes(request.days)) {\n return { ok: false, status: 400, error: 'That window is not one conversions are read over' }\n }\n const { current } = pluginFigureWindows(request.now, request.days)\n /*\n * Aggregation counts, never the records: per kind, one over every\n * channel and one per channel, each an equality on `kind` (and\n * `channel`) with the window on `convertedAtMs`. Served by the two\n * composite indexes the index file declares for them.\n */\n const inWindow = (kind: CampaignConversionKind, channel: CampaignTouchChannel | null) => {\n let query = firestore()\n .collection('hosts')\n .doc(request.hostId as string)\n .collection(CAMPAIGN_ATTRIBUTIONS_COLLECTION)\n .where('kind', '==', kind)\n if (channel) query = query.where('channel', '==', channel)\n return query\n .where('convertedAtMs', '>=', current.startMs)\n .where('convertedAtMs', '<', current.endMs)\n .count()\n .get()\n .then((snapshot) => whole(snapshot.data().count))\n }\n const rows: PluginFigureRow[] = await Promise.all(\n CAMPAIGN_CONVERSION_KINDS.map(async (kind) => {\n const [credited, ...byChannel] = await Promise.all([\n inWindow(kind, null),\n ...CONVERSION_FIGURE_CHANNELS.map((channel) => inWindow(kind, channel)),\n ])\n const row: PluginFigureRow = { kind: CONVERSION_KIND_LABELS[kind], credited }\n CONVERSION_FIGURE_CHANNELS.forEach((channel, index) => {\n row[channel] = byChannel[index]\n })\n return row\n }),\n )\n return {\n ok: true,\n table: {\n title: 'Campaign conversions',\n source: { label: 'Conversions', path: 'marketing/conversions' },\n period: { from: current.from, to: current.to, days: request.days },\n columns: [\n { key: 'kind', label: 'Conversion', kind: 'text' },\n { key: 'credited', label: 'Credited to a campaign', kind: 'count' },\n { key: 'email', label: 'From a campaign email', kind: 'count' },\n { key: 'page', label: 'From a campaign page', kind: 'count' },\n { key: 'web', label: 'From a labeled link', kind: 'count' },\n { key: 'sequence', label: 'From a sequence email', kind: 'count' },\n ],\n rows,\n omitted: 0,\n notes: [\n 'Each row counts a different record of the same visits — one form submission can also make a lead and a contact — so the rows are never added together.',\n 'Only conversions credited to a campaign touch are counted; a conversion credited to nothing has no record and is not in this table.',\n ],\n },\n }\n },\n },\n {\n id: 'marketing.revenue',\n label: 'Campaign revenue',\n description:\n 'Revenue credited to each campaign email sent from the site in the window: orders credited, gross, refunded and net, one row per email and currency, with amounts in each row’s currency.',\n scope: 'site',\n windows: WINDOWS,\n read: async (request) => {\n if (!request.hostId) return { ok: false, status: 400, error: 'Open a site to read its campaign revenue' }\n if (!WINDOWS.includes(request.days)) {\n return { ok: false, status: 400, error: 'That window is not one campaign revenue is read over' }\n }\n if (!request.orgId) return { ok: false, status: 400, error: 'This site is not part of an organization' }\n const { current } = pluginFigureWindows(request.now, request.days)\n // The campaigns table's own read: the site's sends, newest first.\n const sendsRef = orgCampaignSends(firestore(), request.orgId)\n const snapshot = await sendsRef\n .where(CAMPAIGN_SEND_HOST_FIELD, '==', request.hostId)\n .orderBy(EMAIL_CREATED_AT_FIELD, 'desc')\n .limit(CAMPAIGN_FIGURES_READ_LIMIT)\n .get()\n const sends = snapshot.docs\n .filter((doc) => {\n const record = doc.data() ?? {}\n const sentAt = emailSendTimeMs(record)\n return Boolean(record['sentAt']) && sentAt >= current.startMs && sentAt < current.endMs\n })\n .slice(0, PLUGIN_FIGURE_MAX_ROWS)\n /*\n * One keyed read per send for its revenue rollup, bounded by the rows\n * a table may carry — the rollup is its own document so the history\n * list never pays for it, and this is the one place that does.\n */\n const rollups = sends.length\n ? await firestore().getAll(\n ...sends.map((doc) => sendsRef.doc(doc.id).collection('reports').doc('revenue')),\n )\n : []\n const rows: PluginFigureRow[] = []\n const currencies = new Set<string>()\n sends.forEach((doc, index) => {\n const rollup = (rollups[index]?.data() ?? {}) as CampaignRevenueRollup\n for (const [currency, totals] of Object.entries(rollup.byCurrency ?? {})) {\n const grossCents = whole(totals?.grossCents)\n const refundedCents = whole(totals?.refundedCents)\n const orders = whole(totals?.orders)\n if (!orders && !grossCents) continue\n currencies.add(currency.toUpperCase())\n rows.push({\n campaign: String(doc.get('subject') ?? '').trim() || 'A campaign with no subject',\n currency: currency.toUpperCase(),\n orders,\n gross: grossCents / 100,\n refunded: refundedCents / 100,\n // Clamped where it is shown, as the campaign report clamps it.\n net: Math.max(0, grossCents - refundedCents) / 100,\n })\n }\n })\n const [only] = currencies\n const money = currencies.size === 1 && /^[A-Z]{3}$/.test(only ?? '')\n const amount = (key: string, label: string) =>\n money\n ? { key, label, kind: 'money' as const, currency: only }\n : { key, label: `${label} (in the row’s currency)`, kind: 'number' as const }\n return {\n ok: true,\n table: {\n title: 'Campaign revenue',\n source: { label: 'Campaigns', path: 'marketing/campaigns' },\n period: { from: current.from, to: current.to, days: request.days },\n columns: [\n { key: 'campaign', label: 'Campaign email', kind: 'text' },\n { key: 'currency', label: 'Currency', kind: 'text' },\n { key: 'orders', label: 'Orders credited', kind: 'count' },\n amount('gross', 'Gross'),\n amount('refunded', 'Refunded'),\n amount('net', 'Net'),\n ],\n rows: rows.slice(0, PLUGIN_FIGURE_MAX_ROWS),\n omitted: Math.max(0, rows.length - PLUGIN_FIGURE_MAX_ROWS),\n notes: [\n `Revenue is credited to the last campaign email whose link the buyer clicked within ${EMAIL_ATTRIBUTION_WINDOW_DAYS} days, for the emails sent in the window; amounts in different currencies are never added together.`,\n ...(snapshot.docs.length === CAMPAIGN_FIGURES_READ_LIMIT\n ? [`Only the ${CAMPAIGN_FIGURES_READ_LIMIT} most recent emails were looked at.`]\n : []),\n ],\n },\n }\n },\n },\n ]\n}\n\n/** Registers the campaign and A/B testing readers from the console surface, where insight jobs run. */\nexport function registerMarketingFigureReaders(firestore: () => Firestore): void {\n for (const reader of [...marketingFigureReaders(firestore), ...marketingOutcomeFigureReaders(firestore)]) {\n registerPluginFigureReader(reader, { pluginId: MARKETING_PLUGIN_ID })\n }\n}\n"],"names":["PLUGIN_FIGURE_MAX_ROWS","pluginFigureWindows","registerPluginFigureReader","campaignReport","EMAIL_CREATED_AT_FIELD","emailSendTimeMs","CAMPAIGN_SEND_HOST_FIELD","orgCampaignSends","compareVariants","summarizeVariantStats","CAMPAIGN_CONVERSION_KINDS","EMAIL_ATTRIBUTION_WINDOW_DAYS","CAMPAIGN_ATTRIBUTIONS_COLLECTION","MARKETING_PLUGIN_ID","CAMPAIGN_FIGURES_READ_LIMIT","EXPERIMENT_FIGURES_READ_LIMIT","CONVERSION_FIGURE_CHANNELS","CONVERSION_KIND_LABELS","form","lead","contact","booking","WINDOWS","percent","rate","Math","round","value","pooled","rates","known","filter","denominator","reduce","sum","numerator","marketingFigureReaders","firestore","id","label","description","scope","windows","read","request","hostId","ok","status","error","includes","days","orgId","current","now","snapshot","where","orderBy","limit","get","sends","docs","map","doc","data","record","sentAt","Boolean","startMs","endMs","subject","String","trim","report","rows","campaign","delivered","every","send","openRate","open","clickRate","click","slice","table","title","source","path","period","from","to","columns","key","kind","omitted","max","length","notes","feature","experimentsRef","collection","experiment","byVariant","variants","stats","ref","Map","entry","control","forEach","variant","index","own","summary","comparison","push","test","name","shown","exposures","conversions","lift","confidence","whole","raw","Number","isFinite","floor","marketingOutcomeFigureReaders","inWindow","channel","query","count","then","Promise","all","credited","byChannel","row","sendsRef","rollups","getAll","currencies","Set","rollup","currency","totals","Object","entries","byCurrency","grossCents","refundedCents","orders","add","toUpperCase","gross","refunded","net","only","money","size","amount","registerMarketingFigureReaders","reader","pluginId"],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type PluginFigureReader } from '@aglyn/aglyn/plugin-manager/plugin-figures';
|
|
18
|
+
/**
|
|
19
|
+
* A site's announcement bars and popups as a figure table another plugin
|
|
20
|
+
* reads by id (AGL-3603) — the AI plugin's insights answer "which popup gets
|
|
21
|
+
* clicked?" from it.
|
|
22
|
+
*
|
|
23
|
+
* The figures are the lifetime counters the overlay beacon increments on each
|
|
24
|
+
* overlay (`stats.impressions`, `stats.clicks`, `stats.dismissals`, AGL-271)
|
|
25
|
+
* — the numbers the Overlays list shows — so the table has no window: the
|
|
26
|
+
* counters are totals since each overlay was made, never a period's. One read
|
|
27
|
+
* of the site's overlays, projected to the fields counted and capped. An
|
|
28
|
+
* overlay's copy and its pages never reach the table, only its name.
|
|
29
|
+
*/
|
|
30
|
+
type Firestore = FirebaseFirestore.Firestore;
|
|
31
|
+
/** Overlays one table reads; a site with more is told its table is partial. */
|
|
32
|
+
export declare const OVERLAY_FIGURES_READ_LIMIT = 100;
|
|
33
|
+
export declare function overlayFigureReader(firestore: () => Firestore): PluginFigureReader;
|
|
34
|
+
/** Registers the overlays reader from the console surface, where insight jobs run. */
|
|
35
|
+
export declare function registerOverlayFigureReader(firestore: () => Firestore): void;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { PLUGIN_FIGURE_MAX_ROWS, registerPluginFigureReader } from "@aglyn/aglyn/plugin-manager/plugin-figures";
|
|
17
|
+
import { compareOverlayPrecedence, overlayStatus } from "../model/overlays.js";
|
|
18
|
+
const MARKETING_PLUGIN_ID = 'marketing';
|
|
19
|
+
/** Overlays one table reads; a site with more is told its table is partial. */ export const OVERLAY_FIGURES_READ_LIMIT = 100;
|
|
20
|
+
const KIND_LABEL = {
|
|
21
|
+
bar: 'Announcement bar',
|
|
22
|
+
popup: 'Popup'
|
|
23
|
+
};
|
|
24
|
+
const STATUS_LABEL = {
|
|
25
|
+
off: 'Off',
|
|
26
|
+
scheduled: 'Scheduled',
|
|
27
|
+
live: 'Showing'
|
|
28
|
+
};
|
|
29
|
+
const count = (value)=>{
|
|
30
|
+
const number = Number(value);
|
|
31
|
+
return Number.isFinite(number) && number > 0 ? Math.round(number) : 0;
|
|
32
|
+
};
|
|
33
|
+
/** One counter over another as a percent to a tenth, or `null` with nothing to divide by. */ const rate = (numerator, denominator)=>denominator > 0 ? Math.round(numerator / denominator * 1000) / 10 : null;
|
|
34
|
+
export function overlayFigureReader(firestore) {
|
|
35
|
+
return {
|
|
36
|
+
id: 'marketing.overlays',
|
|
37
|
+
label: 'Announcement bars and popups',
|
|
38
|
+
description: 'Each announcement bar and popup on the site: whether it is showing, how many times it was seen, clicked and dismissed, and its click rate. Totals since each one was made, with no window.',
|
|
39
|
+
scope: 'site',
|
|
40
|
+
windows: [],
|
|
41
|
+
feature: 'marketingOverlays',
|
|
42
|
+
read: async (request)=>{
|
|
43
|
+
if (!request.hostId) return {
|
|
44
|
+
ok: false,
|
|
45
|
+
status: 400,
|
|
46
|
+
error: 'Open a site to read its overlays'
|
|
47
|
+
};
|
|
48
|
+
const snapshot = await firestore().collection('hosts').doc(request.hostId).collection('overlays').select('kind', 'name', 'enabled', 'startAtMs', 'endAtMs', 'order', 'stats').limit(OVERLAY_FIGURES_READ_LIMIT + 1).get();
|
|
49
|
+
const nowMs = request.now.getTime();
|
|
50
|
+
const overlays = snapshot.docs.slice(0, OVERLAY_FIGURES_READ_LIMIT).map((doc)=>{
|
|
51
|
+
var _doc_data;
|
|
52
|
+
return {
|
|
53
|
+
overlay: (_doc_data = doc.data()) != null ? _doc_data : {}
|
|
54
|
+
};
|
|
55
|
+
}).filter(({ overlay })=>overlay.kind === 'bar' || overlay.kind === 'popup')// The list's own order, so a row reads as the Overlays page lists it.
|
|
56
|
+
.sort((a, b)=>compareOverlayPrecedence(a.overlay, b.overlay));
|
|
57
|
+
let views = 0;
|
|
58
|
+
let clicks = 0;
|
|
59
|
+
let dismissals = 0;
|
|
60
|
+
const rows = overlays.map(({ overlay })=>{
|
|
61
|
+
var _overlay_name;
|
|
62
|
+
var _overlay_stats, _overlay_stats1, _overlay_stats2;
|
|
63
|
+
const own = {
|
|
64
|
+
views: count((_overlay_stats = overlay.stats) == null ? void 0 : _overlay_stats.impressions),
|
|
65
|
+
clicks: count((_overlay_stats1 = overlay.stats) == null ? void 0 : _overlay_stats1.clicks),
|
|
66
|
+
dismissals: count((_overlay_stats2 = overlay.stats) == null ? void 0 : _overlay_stats2.dismissals)
|
|
67
|
+
};
|
|
68
|
+
views += own.views;
|
|
69
|
+
clicks += own.clicks;
|
|
70
|
+
dismissals += own.dismissals;
|
|
71
|
+
return {
|
|
72
|
+
overlay: String((_overlay_name = overlay.name) != null ? _overlay_name : '').trim() || `Untitled ${KIND_LABEL[overlay.kind].toLowerCase()}`,
|
|
73
|
+
kind: KIND_LABEL[overlay.kind],
|
|
74
|
+
status: STATUS_LABEL[overlayStatus(overlay, nowMs)],
|
|
75
|
+
views: own.views,
|
|
76
|
+
clicks: own.clicks,
|
|
77
|
+
clickRate: rate(own.clicks, own.views),
|
|
78
|
+
dismissals: own.dismissals
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
const kept = rows.slice(0, PLUGIN_FIGURE_MAX_ROWS - 1);
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
table: {
|
|
85
|
+
title: 'Announcement bars and popups',
|
|
86
|
+
source: {
|
|
87
|
+
label: 'Overlays',
|
|
88
|
+
path: 'marketing/overlays'
|
|
89
|
+
},
|
|
90
|
+
period: null,
|
|
91
|
+
columns: [
|
|
92
|
+
{
|
|
93
|
+
key: 'overlay',
|
|
94
|
+
label: 'Overlay',
|
|
95
|
+
kind: 'text'
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
key: 'kind',
|
|
99
|
+
label: 'Kind',
|
|
100
|
+
kind: 'text'
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
key: 'status',
|
|
104
|
+
label: 'Status',
|
|
105
|
+
kind: 'text'
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
key: 'views',
|
|
109
|
+
label: 'Views',
|
|
110
|
+
kind: 'count'
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
key: 'clicks',
|
|
114
|
+
label: 'Clicks',
|
|
115
|
+
kind: 'count'
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
key: 'clickRate',
|
|
119
|
+
label: 'Click rate',
|
|
120
|
+
kind: 'percent'
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
key: 'dismissals',
|
|
124
|
+
label: 'Dismissed',
|
|
125
|
+
kind: 'count'
|
|
126
|
+
}
|
|
127
|
+
],
|
|
128
|
+
rows: [
|
|
129
|
+
{
|
|
130
|
+
overlay: 'All overlays',
|
|
131
|
+
kind: 'All kinds',
|
|
132
|
+
status: 'All statuses',
|
|
133
|
+
views,
|
|
134
|
+
clicks,
|
|
135
|
+
clickRate: rate(clicks, views),
|
|
136
|
+
dismissals
|
|
137
|
+
},
|
|
138
|
+
...kept
|
|
139
|
+
],
|
|
140
|
+
omitted: Math.max(0, rows.length - kept.length),
|
|
141
|
+
notes: [
|
|
142
|
+
'Totals since each overlay was made, not over a window.',
|
|
143
|
+
...snapshot.docs.length > OVERLAY_FIGURES_READ_LIMIT ? [
|
|
144
|
+
`The site has more than ${OVERLAY_FIGURES_READ_LIMIT} overlays; the table counts ${OVERLAY_FIGURES_READ_LIMIT} of them.`
|
|
145
|
+
] : []
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Registers the overlays reader from the console surface, where insight jobs run. */ export function registerOverlayFigureReader(firestore) {
|
|
153
|
+
registerPluginFigureReader(overlayFigureReader(firestore), {
|
|
154
|
+
pluginId: MARKETING_PLUGIN_ID
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
//# sourceMappingURL=overlay-figures.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/overlay-figures.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n PLUGIN_FIGURE_MAX_ROWS,\n registerPluginFigureReader,\n type PluginFigureReader,\n type PluginFigureRow,\n} from '@aglyn/aglyn/plugin-manager/plugin-figures'\nimport { compareOverlayPrecedence, overlayStatus, type HostOverlay } from '../model/overlays'\n\n/**\n * A site's announcement bars and popups as a figure table another plugin\n * reads by id (AGL-3603) — the AI plugin's insights answer \"which popup gets\n * clicked?\" from it.\n *\n * The figures are the lifetime counters the overlay beacon increments on each\n * overlay (`stats.impressions`, `stats.clicks`, `stats.dismissals`, AGL-271)\n * — the numbers the Overlays list shows — so the table has no window: the\n * counters are totals since each overlay was made, never a period's. One read\n * of the site's overlays, projected to the fields counted and capped. An\n * overlay's copy and its pages never reach the table, only its name.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nconst MARKETING_PLUGIN_ID = 'marketing'\n\n/** Overlays one table reads; a site with more is told its table is partial. */\nexport const OVERLAY_FIGURES_READ_LIMIT = 100\n\nconst KIND_LABEL: Readonly<Record<HostOverlay['kind'], string>> = {\n bar: 'Announcement bar',\n popup: 'Popup',\n}\n\nconst STATUS_LABEL: Readonly<Record<ReturnType<typeof overlayStatus>, string>> = {\n off: 'Off',\n scheduled: 'Scheduled',\n live: 'Showing',\n}\n\nconst count = (value: unknown): number => {\n const number = Number(value)\n return Number.isFinite(number) && number > 0 ? Math.round(number) : 0\n}\n\n/** One counter over another as a percent to a tenth, or `null` with nothing to divide by. */\nconst rate = (numerator: number, denominator: number): number | null =>\n denominator > 0 ? Math.round((numerator / denominator) * 1_000) / 10 : null\n\nexport function overlayFigureReader(firestore: () => Firestore): PluginFigureReader {\n return {\n id: 'marketing.overlays',\n label: 'Announcement bars and popups',\n description:\n 'Each announcement bar and popup on the site: whether it is showing, how many times it was seen, clicked and dismissed, and its click rate. Totals since each one was made, with no window.',\n scope: 'site',\n windows: [],\n feature: 'marketingOverlays',\n read: async (request) => {\n if (!request.hostId) return { ok: false, status: 400, error: 'Open a site to read its overlays' }\n const snapshot = await firestore()\n .collection('hosts')\n .doc(request.hostId)\n .collection('overlays')\n .select('kind', 'name', 'enabled', 'startAtMs', 'endAtMs', 'order', 'stats')\n .limit(OVERLAY_FIGURES_READ_LIMIT + 1)\n .get()\n const nowMs = request.now.getTime()\n const overlays = snapshot.docs\n .slice(0, OVERLAY_FIGURES_READ_LIMIT)\n .map((doc) => ({ overlay: (doc.data() ?? {}) as Partial<HostOverlay> }))\n .filter(({ overlay }) => overlay.kind === 'bar' || overlay.kind === 'popup')\n // The list's own order, so a row reads as the Overlays page lists it.\n .sort((a, b) => compareOverlayPrecedence(a.overlay, b.overlay))\n let views = 0\n let clicks = 0\n let dismissals = 0\n const rows: PluginFigureRow[] = overlays.map(({ overlay }) => {\n const own = {\n views: count(overlay.stats?.impressions),\n clicks: count(overlay.stats?.clicks),\n dismissals: count(overlay.stats?.dismissals),\n }\n views += own.views\n clicks += own.clicks\n dismissals += own.dismissals\n return {\n overlay: String(overlay.name ?? '').trim() || `Untitled ${KIND_LABEL[overlay.kind as HostOverlay['kind']].toLowerCase()}`,\n kind: KIND_LABEL[overlay.kind as HostOverlay['kind']],\n status: STATUS_LABEL[overlayStatus(overlay, nowMs)],\n views: own.views,\n clicks: own.clicks,\n clickRate: rate(own.clicks, own.views),\n dismissals: own.dismissals,\n }\n })\n const kept = rows.slice(0, PLUGIN_FIGURE_MAX_ROWS - 1)\n return {\n ok: true,\n table: {\n title: 'Announcement bars and popups',\n source: { label: 'Overlays', path: 'marketing/overlays' },\n period: null,\n columns: [\n { key: 'overlay', label: 'Overlay', kind: 'text' },\n { key: 'kind', label: 'Kind', kind: 'text' },\n { key: 'status', label: 'Status', kind: 'text' },\n { key: 'views', label: 'Views', kind: 'count' },\n { key: 'clicks', label: 'Clicks', kind: 'count' },\n { key: 'clickRate', label: 'Click rate', kind: 'percent' },\n { key: 'dismissals', label: 'Dismissed', kind: 'count' },\n ],\n rows: [\n {\n overlay: 'All overlays',\n kind: 'All kinds',\n status: 'All statuses',\n views,\n clicks,\n clickRate: rate(clicks, views),\n dismissals,\n },\n ...kept,\n ],\n omitted: Math.max(0, rows.length - kept.length),\n notes: [\n 'Totals since each overlay was made, not over a window.',\n ...(snapshot.docs.length > OVERLAY_FIGURES_READ_LIMIT\n ? [`The site has more than ${OVERLAY_FIGURES_READ_LIMIT} overlays; the table counts ${OVERLAY_FIGURES_READ_LIMIT} of them.`]\n : []),\n ],\n },\n }\n },\n }\n}\n\n/** Registers the overlays reader from the console surface, where insight jobs run. */\nexport function registerOverlayFigureReader(firestore: () => Firestore): void {\n registerPluginFigureReader(overlayFigureReader(firestore), { pluginId: MARKETING_PLUGIN_ID })\n}\n"],"names":["PLUGIN_FIGURE_MAX_ROWS","registerPluginFigureReader","compareOverlayPrecedence","overlayStatus","MARKETING_PLUGIN_ID","OVERLAY_FIGURES_READ_LIMIT","KIND_LABEL","bar","popup","STATUS_LABEL","off","scheduled","live","count","value","number","Number","isFinite","Math","round","rate","numerator","denominator","overlayFigureReader","firestore","id","label","description","scope","windows","feature","read","request","hostId","ok","status","error","snapshot","collection","doc","select","limit","get","nowMs","now","getTime","overlays","docs","slice","map","overlay","data","filter","kind","sort","a","b","views","clicks","dismissals","rows","own","stats","impressions","String","name","trim","toLowerCase","clickRate","kept","table","title","source","path","period","columns","key","omitted","max","length","notes","registerOverlayFigureReader","pluginId"],"mappings":"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"}
|