@aglyn/plugins-marketing 1.0.0-beta.232 → 1.0.0-beta.233

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +16 -16
  2. package/src/lib/components/campaign-conversions-card.js +58 -15
  3. package/src/lib/components/campaign-conversions-card.js.map +1 -1
  4. package/src/lib/components/campaign-detail-card.js +50 -5
  5. package/src/lib/components/campaign-detail-card.js.map +1 -1
  6. package/src/lib/components/campaign-members-section.js +36 -8
  7. package/src/lib/components/campaign-members-section.js.map +1 -1
  8. package/src/lib/components/campaigns-card.js +64 -10
  9. package/src/lib/components/campaigns-card.js.map +1 -1
  10. package/src/lib/components/email-recipients-card.js +37 -2
  11. package/src/lib/components/email-recipients-card.js.map +1 -1
  12. package/src/lib/components/emails-list-card.js +59 -6
  13. package/src/lib/components/emails-list-card.js.map +1 -1
  14. package/src/lib/components/host-experiments-card.component.js +32 -6
  15. package/src/lib/components/host-experiments-card.component.js.map +1 -1
  16. package/src/lib/components/staff-org-email-card.component.js +82 -6
  17. package/src/lib/components/staff-org-email-card.component.js.map +1 -1
  18. package/src/lib/model/campaign-conversions.d.ts +2 -0
  19. package/src/lib/model/campaign-conversions.js +32 -0
  20. package/src/lib/model/campaign-conversions.js.map +1 -1
  21. package/src/lib/model/campaign-list-query.d.ts +6 -2
  22. package/src/lib/model/campaign-list-query.js +64 -9
  23. package/src/lib/model/campaign-list-query.js.map +1 -1
  24. package/src/lib/model/campaign-members-query.d.ts +2 -1
  25. package/src/lib/model/campaign-members-query.js +23 -1
  26. package/src/lib/model/campaign-members-query.js.map +1 -1
  27. package/src/lib/model/experiment-list-query.js +30 -2
  28. package/src/lib/model/experiment-list-query.js.map +1 -1
  29. package/src/lib/model/native-vocabulary.d.ts +26 -0
  30. package/src/lib/model/native-vocabulary.js +31 -0
  31. package/src/lib/model/native-vocabulary.js.map +1 -0
  32. package/src/lib/server/campaign-manage.d.ts +1 -1
  33. package/src/lib/server/campaign-manage.js +1 -0
  34. package/src/lib/server/campaign-manage.js.map +1 -1
@@ -304,5 +304,37 @@ export const CAMPAIGN_CONVERSION_KIND_COPY = {
304
304
  ].map((part)=>String(part != null ? part : '').trim()).filter(Boolean);
305
305
  return parts.join(' / ');
306
306
  }
307
+ /*
308
+ * THE CONVERSIONS LISTS' HEADER SORTS (AGL-3680).
309
+ *
310
+ * Both lists walk the document name by default. Converted and Record order
311
+ * the QUERY instead when their header is clicked: `convertedAtMs` and
312
+ * `refId` are on every attribution — the one writer
313
+ * (`campaign-conversion-attribution.ts`) refuses a record without either —
314
+ * so an `orderBy` on them drops nothing. Each order beside a list's two
315
+ * equalities (`kind` + `channel`, or `kind` + `campaignId`) is one whole-shape
316
+ * composite per list, pinned by `list-query-indexes.spec.ts`; Record sorts
317
+ * one way to keep that count down. Credited to is a label drawn from
318
+ * several fields and sorts the page.
319
+ */ export const CAMPAIGN_CONVERSION_SORTS = [
320
+ {
321
+ path: 'convertedAtMs',
322
+ direction: 'desc',
323
+ column: 'convertedAtMs',
324
+ label: 'Converted'
325
+ },
326
+ {
327
+ path: 'convertedAtMs',
328
+ direction: 'asc',
329
+ column: 'convertedAtMs',
330
+ label: 'Converted'
331
+ },
332
+ {
333
+ path: 'refId',
334
+ direction: 'asc',
335
+ column: 'refId',
336
+ label: 'Record'
337
+ }
338
+ ];
307
339
 
308
340
  //# sourceMappingURL=campaign-conversions.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-conversions.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\n/*\n * The window and the model name come from `@aglyn/shared-util-email`, for the\n * reason `campaign-revenue.ts` gives: the writer is in `tenant-data-admin`,\n * which may not import a feature plugin, and a rule defined on both sides of\n * the join drifts into a figure credited under one and printed under another.\n */\nimport {\n EMAIL_ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n} from '@aglyn/shared-util-email'\nimport type { CampaignCaveat } from './campaign-report'\n\n/**\n * WHAT A CAMPAIGN CAUSED — the read half of the identify-moment join.\n *\n * `campaign-revenue.ts` answers what a campaign EARNED, from orders that name\n * their buyer. This answers what it CAUSED among people who were anonymous\n * until the moment they became somebody: they arrive from a campaign link,\n * browse, and only become identifiable when they submit a form, sign up, book\n * or check out. Same window, same last-click rule, same `model`/`windowDays`\n * stamped on every record, so the two figures on one screen are two readings\n * of one rule rather than two rules that happen to agree.\n *\n * ## THE KINDS ARE NEVER SUMMED, and this module makes that structural\n *\n * One form submission by a new person writes a submission record, a contact\n * and a lead — three true statements about one visitor action. Adding them\n * would treble every campaign's conversions, and the reader would have no way\n * to see it had happened, because the sum looks like a bigger version of a\n * real number.\n *\n * So {@link CampaignConversionsReport} carries no total, no `all`, no\n * `conversions` scalar, and there is no function here that reduces the kinds.\n * A screen that wanted one would have to write the addition itself. The\n * report also raises {@link CampaignCaveat} `conversions-kinds-overlap` so\n * the reader is told WHY the four figures stand apart, rather than being left\n * to assume the product forgot to add them up.\n *\n * ## THE WEB CHANNEL HAS NO ROLLUP, and cannot be given one\n *\n * A conversion is credited to one of two touches. An EMAIL touch names a\n * campaign document — a real entity with a real id — so its conversions roll\n * up under it beside the revenue the same join credits it with. A WEB touch\n * is a `utm_` label a marketer typed into a URL: no document, no id, and no\n * bound on how many distinct values exist, because anybody who can vary a\n * query string can mint another one.\n *\n * A rollup keyed on that label is a map a stranger can grow, which is the\n * same unbounded key space the analytics collector caps its per-day label map\n * against. So the writer increments the rollup for the email channel ONLY,\n * this reader states that where the figure is drawn, and web-channel records\n * are read as records — see {@link campaignConversionsCoverage}.\n *\n * ## A PAGE OR A DECLARED LABEL NAMES THE CAMPAIGN ITSELF (AGL-3461)\n *\n * Two more touches name a campaign CONTAINER rather than a send: a page filed\n * under the campaign, and a `utm_campaign` label the campaign declares as its\n * own. Their records carry the container's id as `campaignId`, so the\n * campaign's page counts them with the same `kind` + `campaignId` aggregation\n * it counts its sends' conversions with — keyed by a document id, so bounded\n * by the campaigns that exist, and needing no rollup document of their own.\n *\n * ## A CONVERSION WITH NO TOUCH IS NOT IN HERE AT ALL\n *\n * Direct traffic writes no record. There is deliberately no\n * `utm_source=direct` placeholder, no referrer inference and no \"most recent\n * campaign on this site\" fallback: a conversion nobody can be credited with\n * is a conversion nobody is credited with.\n *\n * That makes the absence of a record load-bearing, and it makes a screen\n * showing only attributed conversions a lie by omission — it renders \"we\n * credited three of these\" as \"three of these happened\".\n * {@link campaignConversionsCoverage} exists so the unattributed count is a\n * figure on the page rather than an inference nobody makes.\n */\n\n/*\n * The constants are NOT re-exported here, though `campaign-revenue.ts`\n * re-exports the same two. Both modules sit behind one barrel, and a name\n * exported twice through it is a name a bundler has to disambiguate. Every\n * screen reads the rule off `model`/`windowDays` on the report anyway, which\n * is the stored value rather than today's constant — a campaign credited\n * under an older window has to print the window it was credited under.\n */\n\n/**\n * Which identify moment a record credits.\n *\n * An array first, because the ORDER is the reading order on every screen and\n * a second list of these is a second chance to leave one out. The union is\n * derived from it rather than written twice.\n */\nexport const CAMPAIGN_CONVERSION_KINDS = [\n 'form',\n 'lead',\n 'contact',\n 'booking',\n] as const\n\n/** One identify moment. Mirrors the writer's `CampaignConversionKind`. */\nexport type CampaignConversionKind = (typeof CAMPAIGN_CONVERSION_KINDS)[number]\n\n/**\n * Which channel the credited touch arrived through.\n *\n * `sequence` (AGL-3254) is a one-to-one email a rep's sequence sent: the\n * touch names the CAMPAIGN the sequence is in, like an email touch names\n * the campaign whose mail was clicked, plus the sequence and the enrollment\n * it came through, so a record can say which rep's outreach it credits.\n */\nexport type CampaignTouchChannel = 'email' | 'web' | 'sequence' | 'page'\n\n/**\n * THE RULE an identify moment is credited under (AGL-3461), stamped on every\n * record as `model` so a reader can print what was counted.\n *\n * `last-click` — the revenue join's rule, which an order still uses — named\n * the later of a campaign email click and a labeled link. A page filed under a\n * campaign is a touch too now, and it is not a click, so the identify moment\n * says so:\n *\n * - the later of a campaign email click and a link carrying `utm_` labels\n * wins, exactly as before;\n * - a page filed under a campaign competes with that winner. Within\n * {@link CAMPAIGN_VISIT_MINUTES} of it the two are one visit: a click that\n * names a campaign — its mail, or a label the campaign declares — keeps\n * the credit for the page it landed on, and a label that names none\n * yields to the page. Further apart, the later one wins;\n * - all of it inside the seven-day window, as before.\n */\nexport const CAMPAIGN_TOUCH_MODEL = 'last-touch'\n\n/** How close a page view and a click are to be one visit, in minutes. */\nexport const CAMPAIGN_VISIT_MINUTES = 30\n\n/** The same, in milliseconds. */\nexport const CAMPAIGN_VISIT_MS = CAMPAIGN_VISIT_MINUTES * 60 * 1000\n\n/**\n * The rule a record or a rollup was credited under, as one sentence a reader\n * can check the figure against. Keyed off the STORED model, so a conversion\n * credited under an older rule prints the rule it was credited under.\n */\nexport function campaignCreditRule(\n model: string | null | undefined,\n windowDays: number | null | undefined,\n): string {\n const days = Number(windowDays ?? 0) || EMAIL_ATTRIBUTION_WINDOW_DAYS\n if (model === CAMPAIGN_TOUCH_MODEL) {\n return (\n `Credited to the visitor’s latest campaign touch within ${days} days — a ` +\n 'campaign email they clicked, a link labeled for a campaign, or a page ' +\n `filed under one. A page viewed within ${CAMPAIGN_VISIT_MINUTES} minutes of ` +\n 'a click is that click’s visit, and the click keeps the credit when it ' +\n 'names a campaign.'\n )\n }\n if (model === EMAIL_ATTRIBUTION_MODEL) {\n return `Credited to the last campaign whose link they clicked, within ${days} days of that click.`\n }\n return `Credited under the ${String(model ?? 'recorded')} model, within ${days} days.`\n}\n\n/**\n * The record each conversion kind credits, as its owner publishes the kind\n * (`plugin-record-counts`, `plugin-record-lists`): a credited form names the\n * SUBMISSION it credits, the rest the record of their own name. The owners\n * count and read those records; this plugin only names the kind.\n */\nexport const CAMPAIGN_CONVERSION_RECORD_KINDS: Readonly<\n Record<CampaignConversionKind, string>\n> = {\n form: 'formSubmission',\n lead: 'lead',\n contact: 'contact',\n booking: 'booking',\n}\n\n/**\n * What a reader calls one kind, and what the count means.\n *\n * The note is the half that stops the addition. Each one names a DIFFERENT\n * population of the same visitors, and saying so under every figure is what\n * makes four numbers standing apart read as deliberate rather than as a\n * missing total.\n */\nexport interface CampaignConversionKindCopy {\n label: string\n note: string\n}\n\nexport const CAMPAIGN_CONVERSION_KIND_COPY: Readonly<\n Record<CampaignConversionKind, CampaignConversionKindCopy>\n> = {\n form: {\n label: 'Form submissions',\n note: 'submissions credited to this campaign',\n },\n lead: { label: 'Leads', note: 'new leads credited to this campaign' },\n contact: {\n label: 'Contacts',\n note: 'new contacts credited to this campaign',\n },\n booking: { label: 'Bookings', note: 'bookings credited to this campaign' },\n}\n\n/**\n * The stored shape of `campaigns/{campaignId}/reports/conversions`.\n *\n * Its own document beside `reports/revenue`, for the reason that one is: the\n * campaign document is read by the history list, the glance widget and the\n * send path, and a map that grows with what the campaign caused would enlarge\n * every one of those reads.\n */\nexport interface CampaignConversionsRollup {\n byKind?: Partial<Record<CampaignConversionKind, number>>\n /** The model these conversions were credited under. */\n model?: string\n /** The window, in days, they were credited inside. */\n windowDays?: number\n}\n\n/**\n * The stored shape of `hosts/{hostId}/campaignAttributions/{kind}:{refId}`.\n *\n * Read-side only, and every field optional: this is a document somebody\n * else's writer produced, and a reader that assumed a field was present would\n * throw on the first record written by an older version of it.\n */\nexport interface CampaignConversionRecord {\n kind?: CampaignConversionKind\n /** The submission, lead, contact or booking this credits. */\n refId?: string\n channel?: CampaignTouchChannel\n /**\n * The campaign document, when the touch was a click on our own mail — or,\n * for a `sequence` touch, the campaign container the sequence is in.\n */\n campaignId?: string\n /** The sequence, when the touch was a sequence email (AGL-3254). */\n sequenceId?: string\n /** The enrollment the sequence email went out under (AGL-3254). */\n enrollmentId?: string\n /** `utm_source`, when the touch was a link on the web. */\n source?: string\n /** `utm_medium`, when the touch was a link on the web. */\n medium?: string\n /** `utm_campaign`, when the touch was a link on the web. */\n campaign?: string\n /** When the visitor followed the campaign link, epoch ms. */\n touchedAtMs?: number\n /** When the visitor became identifiable, epoch ms. */\n convertedAtMs?: number\n /** The screen the visitor viewed, for a `page` touch (AGL-3461). */\n screenId?: string\n /** That page's path, for a `page` touch. */\n path?: string\n model?: string\n windowDays?: number\n}\n\n/**\n * The document id for one conversion — `{kind}:{refId}`.\n *\n * The reader's half of the writer's `campaignConversionId`. It is restated\n * here rather than imported because the writer lives in `tenant-data-admin`,\n * which a UI library may not import, and it is one line whose shape is\n * asserted in this module's spec.\n *\n * Answers `null` for an unusable pair rather than building `form:undefined`,\n * which would be a valid document path pointing at a record that can never\n * exist — a keyed read that silently reports \"not attributed\" for every\n * record on the screen.\n */\nexport function campaignConversionId(\n kind: string | null | undefined,\n refId: string | null | undefined,\n): string | null {\n const k = String(kind ?? '')\n const ref = String(refId ?? '')\n if (!k || !ref) return null\n if (!(CAMPAIGN_CONVERSION_KINDS as readonly string[]).includes(k)) return null\n // A slash would leave the collection; the id scheme already spends the\n // colon, and a ref carrying one would make the pair ambiguous.\n if (ref.includes('/') || ref.includes(':')) return null\n return `${k}:${ref}`\n}\n\n/** A stored count as a non-negative integer. */\nfunction count(raw: unknown): number {\n const value = Math.floor(Number(raw ?? 0))\n return Number.isFinite(value) && value > 0 ? value : 0\n}\n\n/** One kind's figure on screen. Independent of every other kind's. */\nexport interface CampaignConversionKindReport {\n kind: CampaignConversionKind\n label: string\n /**\n * Conversions of this kind, or `null` when the rollup holds no entry.\n *\n * `null` rather than 0 for {@link CampaignConversionsReport.recorded}'s\n * reason, applied per kind: a site with no booking form has never written a\n * booking conversion, and rendering that as a measured zero invites the\n * reader to conclude the campaign failed at something it never attempted.\n */\n value: number | null\n note: string\n}\n\n/** Everything the conversions section renders. */\nexport interface CampaignConversionsReport {\n /**\n * One entry per kind, always all four and always in\n * {@link CAMPAIGN_CONVERSION_KINDS} order.\n *\n * There is deliberately NO total beside this array, and no field anywhere\n * on this report that holds one. See the module docblock.\n */\n kinds: CampaignConversionKindReport[]\n /**\n * Whether the rollup document exists at all.\n *\n * `false` is NOT \"this campaign caused nothing\" — it is also every campaign\n * sent before the join existed, and every campaign whose conversions all\n * arrived through the web channel, which writes no rollup. The screen\n * renders the difference rather than printing a zero for all three.\n */\n recorded: boolean\n /** At least one kind holds a figure. */\n any: boolean\n /** The model these figures were credited under, as stored. */\n model: string\n /** The window they were credited inside, as stored. */\n windowDays: number\n caveats: CampaignCaveat[]\n}\n\n/**\n * Turns the stored rollup into the conversions section.\n *\n * Takes the rollup and nothing else. Unlike the revenue report there is no\n * denominator to hand in: a conversion RATE over delivered messages would be\n * the same defect the revenue section refuses for orders — one visitor can\n * submit two forms, so the quotient passes 100% without anything being wrong\n * — and counting distinct people would need a document per person per\n * campaign, the per-recipient read this whole surface exists to refuse.\n */\nexport function campaignConversionsReport(options: {\n rollup: CampaignConversionsRollup | undefined\n}): CampaignConversionsReport {\n const { rollup } = options\n const stored = rollup?.byKind ?? {}\n const caveats: CampaignCaveat[] = []\n\n const kinds: CampaignConversionKindReport[] = CAMPAIGN_CONVERSION_KINDS.map(\n (kind) => {\n const raw = stored[kind]\n const copy = CAMPAIGN_CONVERSION_KIND_COPY[kind]\n return {\n kind,\n label: copy.label,\n // `undefined` means the rollup has no entry for this kind, which is\n // not a measured zero. A stored 0 is impossible — the writer only\n // ever increments — but it is read as unrecorded for the same reason.\n value: raw === undefined || count(raw) === 0 ? null : count(raw),\n note: copy.note,\n }\n },\n )\n\n const any = kinds.some((entry) => entry.value !== null)\n\n if (any) {\n caveats.push({\n id: 'conversions-kinds-overlap',\n message:\n 'These figures count different things about the same visits and are ' +\n 'deliberately not added together. One person filling in one form can ' +\n 'appear as a submission, a contact and a lead, so a total would ' +\n 'count that visit three times.',\n })\n caveats.push({\n id: 'conversions-web-not-rolled-up',\n message:\n 'This email only. A conversion credited to a page filed under the ' +\n 'campaign, or to a link labeled for it, is counted on the ' +\n 'campaign’s own page; one credited to a utm_ label no campaign ' +\n 'declares is listed under Conversions in the marketing console.',\n })\n }\n\n return {\n kinds,\n recorded: rollup !== undefined,\n any,\n model: String(rollup?.model ?? EMAIL_ATTRIBUTION_MODEL),\n windowDays: count(rollup?.windowDays) || EMAIL_ATTRIBUTION_WINDOW_DAYS,\n caveats,\n }\n}\n\n/**\n * How much of one kind was credited to anything at all.\n *\n * ## Why this figure has to exist\n *\n * The join writes nothing for a conversion it cannot credit, so a list of\n * attribution records is a list of the SUCCESSES. Rendering only that turns\n * \"we credited four of these\" into \"four of these happened\", and the reader\n * draws a conclusion about their campaigns from a number that is mostly a\n * fact about how many visitors arrived without a campaign link.\n *\n * So the screen shows both halves and this computes the second. `attributed`\n * is counted over the attribution records; `total` over the records the\n * conversions themselves live in.\n *\n * ## Why the difference is a CEILING and says so\n *\n * The two counts come from different collections with different histories,\n * and the gap between them holds three things that are not the same:\n *\n * - conversions by visitors who arrived directly, which is what the figure\n * is meant to describe;\n * - conversions from before the join existed, which were never eligible; and\n * - for contacts, conversions on the org's OTHER sites, because contacts are\n * shared across an org while attributions are per host.\n *\n * None of those can be separated from the others without a field nobody\n * writes, so `unattributed` is reported as an upper bound and\n * {@link CampaignConversionsCoverage.exact} is `false` whenever the reasons\n * apply. A figure presented as exact when it is not is the failure this\n * whole surface is built to avoid.\n */\nexport interface CampaignConversionsCoverage {\n kind: CampaignConversionKind\n /** Conversions of this kind credited to some campaign or label. */\n attributed: number\n /** Conversions of this kind that exist, credited or not. */\n total: number\n /** `total - attributed`, clamped at zero. An upper bound, not a count. */\n unattributed: number\n /**\n * Whether `unattributed` may be read as \"arrived directly\".\n *\n * Always `false` today — the three reasons in the docblock all apply to\n * every host — and kept as a field rather than hardcoded in the copy so a\n * screen asks the model rather than restating its conclusion.\n */\n exact: boolean\n caveats: CampaignCaveat[]\n}\n\n/**\n * The attributed/unattributed split for one kind.\n *\n * `total` is `null` when it could not be counted — the aggregation failed, or\n * the collection is not readable from this surface — and the split is then\n * withheld entirely rather than defaulting `total` to `attributed`, which\n * would render every conversion as attributed and is the single most\n * flattering wrong answer available here.\n */\nexport function campaignConversionsCoverage(options: {\n kind: CampaignConversionKind\n attributed: number | null | undefined\n total: number | null | undefined\n /** The kind's records live outside this host, so `total` over-counts. */\n crossHostTotal?: boolean\n}): CampaignConversionsCoverage | null {\n const { kind, crossHostTotal } = options\n if (options.total == null || options.attributed == null) return null\n const attributed = count(options.attributed)\n const total = count(options.total)\n const caveats: CampaignCaveat[] = []\n\n /*\n * CLAMPED, and only here at the point of display. The two counts are taken\n * from two collections a moment apart, so a conversion recorded between\n * them makes `attributed` briefly exceed `total`; a negative count of\n * things that did not happen is not a sentence anybody can act on.\n */\n const unattributed = Math.max(0, total - attributed)\n\n caveats.push({\n id: 'conversions-unattributed-is-a-ceiling',\n message:\n `${unattributed.toLocaleString()} of these are not credited to any ` +\n 'campaign. Most arrived without following a campaign link and are ' +\n 'recorded as direct — nothing is guessed from a referrer, and no ' +\n 'campaign is credited for being the most recent one to run. The rest ' +\n 'are records from before campaign attribution was recorded at all, so ' +\n 'read this as an upper bound rather than a count of direct arrivals.',\n })\n if (crossHostTotal) {\n caveats.push({\n id: 'conversions-total-crosses-hosts',\n message:\n 'Contacts are shared across every site in this organization, while ' +\n 'attributions belong to one site. The uncredited figure therefore ' +\n 'includes contacts that were created on another site and could ' +\n 'never have been credited here.',\n })\n }\n\n return {\n kind,\n attributed,\n total,\n unattributed,\n // Never true while the reasons above stand. The field exists so a screen\n // reads the model rather than restating its conclusion in JSX.\n exact: false,\n caveats,\n }\n}\n\n/**\n * How a record names the thing it was credited to.\n *\n * The email channel names a campaign document, so the screen can link to it,\n * and so does a sequence touch: the campaign the sequence is in is a\n * container with a page — as does a page touch, the campaign the viewed page\n * is filed under (AGL-3461). The web channel names a label the marketer\n * typed, which is text unless a campaign declares the label as its own; then\n * the record carries that campaign's id too and the screen links it.\n *\n * The `utm_` triple is joined in the order a marketer set it, with the parts\n * that are absent left out rather than filled with a placeholder.\n * `google / cpc` and `google / cpc / (none)` describe the same link, and only\n * one of them invites the reader to look for a campaign called \"(none)\".\n */\nexport function campaignTouchLabel(\n record: CampaignConversionRecord | null | undefined,\n): string {\n if (!record) return ''\n if (\n record.channel === 'email' ||\n record.channel === 'sequence' ||\n record.channel === 'page'\n ) {\n return String(record.campaignId ?? '')\n }\n const parts = [record.source, record.medium, record.campaign]\n .map((part) => String(part ?? '').trim())\n .filter(Boolean)\n return parts.join(' / ')\n}\n"],"names":["EMAIL_ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","CAMPAIGN_CONVERSION_KINDS","CAMPAIGN_TOUCH_MODEL","CAMPAIGN_VISIT_MINUTES","CAMPAIGN_VISIT_MS","campaignCreditRule","model","windowDays","days","Number","String","CAMPAIGN_CONVERSION_RECORD_KINDS","form","lead","contact","booking","CAMPAIGN_CONVERSION_KIND_COPY","label","note","campaignConversionId","kind","refId","k","ref","includes","count","raw","value","Math","floor","isFinite","campaignConversionsReport","options","rollup","stored","byKind","caveats","kinds","map","copy","undefined","any","some","entry","push","id","message","recorded","campaignConversionsCoverage","crossHostTotal","total","attributed","unattributed","max","toLocaleString","exact","campaignTouchLabel","record","channel","campaignId","parts","source","medium","campaign","part","trim","filter","Boolean","join"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-conversions.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\n/*\n * The window and the model name come from `@aglyn/shared-util-email`, for the\n * reason `campaign-revenue.ts` gives: the writer is in `tenant-data-admin`,\n * which may not import a feature plugin, and a rule defined on both sides of\n * the join drifts into a figure credited under one and printed under another.\n */\nimport {\n EMAIL_ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n} from '@aglyn/shared-util-email'\nimport type { CampaignCaveat } from './campaign-report'\nimport type { ListQuerySort } from '@aglyn/shared-util-tools/list-query/list-query-plan'\n\n/**\n * WHAT A CAMPAIGN CAUSED — the read half of the identify-moment join.\n *\n * `campaign-revenue.ts` answers what a campaign EARNED, from orders that name\n * their buyer. This answers what it CAUSED among people who were anonymous\n * until the moment they became somebody: they arrive from a campaign link,\n * browse, and only become identifiable when they submit a form, sign up, book\n * or check out. Same window, same last-click rule, same `model`/`windowDays`\n * stamped on every record, so the two figures on one screen are two readings\n * of one rule rather than two rules that happen to agree.\n *\n * ## THE KINDS ARE NEVER SUMMED, and this module makes that structural\n *\n * One form submission by a new person writes a submission record, a contact\n * and a lead — three true statements about one visitor action. Adding them\n * would treble every campaign's conversions, and the reader would have no way\n * to see it had happened, because the sum looks like a bigger version of a\n * real number.\n *\n * So {@link CampaignConversionsReport} carries no total, no `all`, no\n * `conversions` scalar, and there is no function here that reduces the kinds.\n * A screen that wanted one would have to write the addition itself. The\n * report also raises {@link CampaignCaveat} `conversions-kinds-overlap` so\n * the reader is told WHY the four figures stand apart, rather than being left\n * to assume the product forgot to add them up.\n *\n * ## THE WEB CHANNEL HAS NO ROLLUP, and cannot be given one\n *\n * A conversion is credited to one of two touches. An EMAIL touch names a\n * campaign document — a real entity with a real id — so its conversions roll\n * up under it beside the revenue the same join credits it with. A WEB touch\n * is a `utm_` label a marketer typed into a URL: no document, no id, and no\n * bound on how many distinct values exist, because anybody who can vary a\n * query string can mint another one.\n *\n * A rollup keyed on that label is a map a stranger can grow, which is the\n * same unbounded key space the analytics collector caps its per-day label map\n * against. So the writer increments the rollup for the email channel ONLY,\n * this reader states that where the figure is drawn, and web-channel records\n * are read as records — see {@link campaignConversionsCoverage}.\n *\n * ## A PAGE OR A DECLARED LABEL NAMES THE CAMPAIGN ITSELF (AGL-3461)\n *\n * Two more touches name a campaign CONTAINER rather than a send: a page filed\n * under the campaign, and a `utm_campaign` label the campaign declares as its\n * own. Their records carry the container's id as `campaignId`, so the\n * campaign's page counts them with the same `kind` + `campaignId` aggregation\n * it counts its sends' conversions with — keyed by a document id, so bounded\n * by the campaigns that exist, and needing no rollup document of their own.\n *\n * ## A CONVERSION WITH NO TOUCH IS NOT IN HERE AT ALL\n *\n * Direct traffic writes no record. There is deliberately no\n * `utm_source=direct` placeholder, no referrer inference and no \"most recent\n * campaign on this site\" fallback: a conversion nobody can be credited with\n * is a conversion nobody is credited with.\n *\n * That makes the absence of a record load-bearing, and it makes a screen\n * showing only attributed conversions a lie by omission — it renders \"we\n * credited three of these\" as \"three of these happened\".\n * {@link campaignConversionsCoverage} exists so the unattributed count is a\n * figure on the page rather than an inference nobody makes.\n */\n\n/*\n * The constants are NOT re-exported here, though `campaign-revenue.ts`\n * re-exports the same two. Both modules sit behind one barrel, and a name\n * exported twice through it is a name a bundler has to disambiguate. Every\n * screen reads the rule off `model`/`windowDays` on the report anyway, which\n * is the stored value rather than today's constant — a campaign credited\n * under an older window has to print the window it was credited under.\n */\n\n/**\n * Which identify moment a record credits.\n *\n * An array first, because the ORDER is the reading order on every screen and\n * a second list of these is a second chance to leave one out. The union is\n * derived from it rather than written twice.\n */\nexport const CAMPAIGN_CONVERSION_KINDS = [\n 'form',\n 'lead',\n 'contact',\n 'booking',\n] as const\n\n/** One identify moment. Mirrors the writer's `CampaignConversionKind`. */\nexport type CampaignConversionKind = (typeof CAMPAIGN_CONVERSION_KINDS)[number]\n\n/**\n * Which channel the credited touch arrived through.\n *\n * `sequence` (AGL-3254) is a one-to-one email a rep's sequence sent: the\n * touch names the CAMPAIGN the sequence is in, like an email touch names\n * the campaign whose mail was clicked, plus the sequence and the enrollment\n * it came through, so a record can say which rep's outreach it credits.\n */\nexport type CampaignTouchChannel = 'email' | 'web' | 'sequence' | 'page'\n\n/**\n * THE RULE an identify moment is credited under (AGL-3461), stamped on every\n * record as `model` so a reader can print what was counted.\n *\n * `last-click` — the revenue join's rule, which an order still uses — named\n * the later of a campaign email click and a labeled link. A page filed under a\n * campaign is a touch too now, and it is not a click, so the identify moment\n * says so:\n *\n * - the later of a campaign email click and a link carrying `utm_` labels\n * wins, exactly as before;\n * - a page filed under a campaign competes with that winner. Within\n * {@link CAMPAIGN_VISIT_MINUTES} of it the two are one visit: a click that\n * names a campaign — its mail, or a label the campaign declares — keeps\n * the credit for the page it landed on, and a label that names none\n * yields to the page. Further apart, the later one wins;\n * - all of it inside the seven-day window, as before.\n */\nexport const CAMPAIGN_TOUCH_MODEL = 'last-touch'\n\n/** How close a page view and a click are to be one visit, in minutes. */\nexport const CAMPAIGN_VISIT_MINUTES = 30\n\n/** The same, in milliseconds. */\nexport const CAMPAIGN_VISIT_MS = CAMPAIGN_VISIT_MINUTES * 60 * 1000\n\n/**\n * The rule a record or a rollup was credited under, as one sentence a reader\n * can check the figure against. Keyed off the STORED model, so a conversion\n * credited under an older rule prints the rule it was credited under.\n */\nexport function campaignCreditRule(\n model: string | null | undefined,\n windowDays: number | null | undefined,\n): string {\n const days = Number(windowDays ?? 0) || EMAIL_ATTRIBUTION_WINDOW_DAYS\n if (model === CAMPAIGN_TOUCH_MODEL) {\n return (\n `Credited to the visitor’s latest campaign touch within ${days} days — a ` +\n 'campaign email they clicked, a link labeled for a campaign, or a page ' +\n `filed under one. A page viewed within ${CAMPAIGN_VISIT_MINUTES} minutes of ` +\n 'a click is that click’s visit, and the click keeps the credit when it ' +\n 'names a campaign.'\n )\n }\n if (model === EMAIL_ATTRIBUTION_MODEL) {\n return `Credited to the last campaign whose link they clicked, within ${days} days of that click.`\n }\n return `Credited under the ${String(model ?? 'recorded')} model, within ${days} days.`\n}\n\n/**\n * The record each conversion kind credits, as its owner publishes the kind\n * (`plugin-record-counts`, `plugin-record-lists`): a credited form names the\n * SUBMISSION it credits, the rest the record of their own name. The owners\n * count and read those records; this plugin only names the kind.\n */\nexport const CAMPAIGN_CONVERSION_RECORD_KINDS: Readonly<\n Record<CampaignConversionKind, string>\n> = {\n form: 'formSubmission',\n lead: 'lead',\n contact: 'contact',\n booking: 'booking',\n}\n\n/**\n * What a reader calls one kind, and what the count means.\n *\n * The note is the half that stops the addition. Each one names a DIFFERENT\n * population of the same visitors, and saying so under every figure is what\n * makes four numbers standing apart read as deliberate rather than as a\n * missing total.\n */\nexport interface CampaignConversionKindCopy {\n label: string\n note: string\n}\n\nexport const CAMPAIGN_CONVERSION_KIND_COPY: Readonly<\n Record<CampaignConversionKind, CampaignConversionKindCopy>\n> = {\n form: {\n label: 'Form submissions',\n note: 'submissions credited to this campaign',\n },\n lead: { label: 'Leads', note: 'new leads credited to this campaign' },\n contact: {\n label: 'Contacts',\n note: 'new contacts credited to this campaign',\n },\n booking: { label: 'Bookings', note: 'bookings credited to this campaign' },\n}\n\n/**\n * The stored shape of `campaigns/{campaignId}/reports/conversions`.\n *\n * Its own document beside `reports/revenue`, for the reason that one is: the\n * campaign document is read by the history list, the glance widget and the\n * send path, and a map that grows with what the campaign caused would enlarge\n * every one of those reads.\n */\nexport interface CampaignConversionsRollup {\n byKind?: Partial<Record<CampaignConversionKind, number>>\n /** The model these conversions were credited under. */\n model?: string\n /** The window, in days, they were credited inside. */\n windowDays?: number\n}\n\n/**\n * The stored shape of `hosts/{hostId}/campaignAttributions/{kind}:{refId}`.\n *\n * Read-side only, and every field optional: this is a document somebody\n * else's writer produced, and a reader that assumed a field was present would\n * throw on the first record written by an older version of it.\n */\nexport interface CampaignConversionRecord {\n kind?: CampaignConversionKind\n /** The submission, lead, contact or booking this credits. */\n refId?: string\n channel?: CampaignTouchChannel\n /**\n * The campaign document, when the touch was a click on our own mail — or,\n * for a `sequence` touch, the campaign container the sequence is in.\n */\n campaignId?: string\n /** The sequence, when the touch was a sequence email (AGL-3254). */\n sequenceId?: string\n /** The enrollment the sequence email went out under (AGL-3254). */\n enrollmentId?: string\n /** `utm_source`, when the touch was a link on the web. */\n source?: string\n /** `utm_medium`, when the touch was a link on the web. */\n medium?: string\n /** `utm_campaign`, when the touch was a link on the web. */\n campaign?: string\n /** When the visitor followed the campaign link, epoch ms. */\n touchedAtMs?: number\n /** When the visitor became identifiable, epoch ms. */\n convertedAtMs?: number\n /** The screen the visitor viewed, for a `page` touch (AGL-3461). */\n screenId?: string\n /** That page's path, for a `page` touch. */\n path?: string\n model?: string\n windowDays?: number\n}\n\n/**\n * The document id for one conversion — `{kind}:{refId}`.\n *\n * The reader's half of the writer's `campaignConversionId`. It is restated\n * here rather than imported because the writer lives in `tenant-data-admin`,\n * which a UI library may not import, and it is one line whose shape is\n * asserted in this module's spec.\n *\n * Answers `null` for an unusable pair rather than building `form:undefined`,\n * which would be a valid document path pointing at a record that can never\n * exist — a keyed read that silently reports \"not attributed\" for every\n * record on the screen.\n */\nexport function campaignConversionId(\n kind: string | null | undefined,\n refId: string | null | undefined,\n): string | null {\n const k = String(kind ?? '')\n const ref = String(refId ?? '')\n if (!k || !ref) return null\n if (!(CAMPAIGN_CONVERSION_KINDS as readonly string[]).includes(k)) return null\n // A slash would leave the collection; the id scheme already spends the\n // colon, and a ref carrying one would make the pair ambiguous.\n if (ref.includes('/') || ref.includes(':')) return null\n return `${k}:${ref}`\n}\n\n/** A stored count as a non-negative integer. */\nfunction count(raw: unknown): number {\n const value = Math.floor(Number(raw ?? 0))\n return Number.isFinite(value) && value > 0 ? value : 0\n}\n\n/** One kind's figure on screen. Independent of every other kind's. */\nexport interface CampaignConversionKindReport {\n kind: CampaignConversionKind\n label: string\n /**\n * Conversions of this kind, or `null` when the rollup holds no entry.\n *\n * `null` rather than 0 for {@link CampaignConversionsReport.recorded}'s\n * reason, applied per kind: a site with no booking form has never written a\n * booking conversion, and rendering that as a measured zero invites the\n * reader to conclude the campaign failed at something it never attempted.\n */\n value: number | null\n note: string\n}\n\n/** Everything the conversions section renders. */\nexport interface CampaignConversionsReport {\n /**\n * One entry per kind, always all four and always in\n * {@link CAMPAIGN_CONVERSION_KINDS} order.\n *\n * There is deliberately NO total beside this array, and no field anywhere\n * on this report that holds one. See the module docblock.\n */\n kinds: CampaignConversionKindReport[]\n /**\n * Whether the rollup document exists at all.\n *\n * `false` is NOT \"this campaign caused nothing\" — it is also every campaign\n * sent before the join existed, and every campaign whose conversions all\n * arrived through the web channel, which writes no rollup. The screen\n * renders the difference rather than printing a zero for all three.\n */\n recorded: boolean\n /** At least one kind holds a figure. */\n any: boolean\n /** The model these figures were credited under, as stored. */\n model: string\n /** The window they were credited inside, as stored. */\n windowDays: number\n caveats: CampaignCaveat[]\n}\n\n/**\n * Turns the stored rollup into the conversions section.\n *\n * Takes the rollup and nothing else. Unlike the revenue report there is no\n * denominator to hand in: a conversion RATE over delivered messages would be\n * the same defect the revenue section refuses for orders — one visitor can\n * submit two forms, so the quotient passes 100% without anything being wrong\n * — and counting distinct people would need a document per person per\n * campaign, the per-recipient read this whole surface exists to refuse.\n */\nexport function campaignConversionsReport(options: {\n rollup: CampaignConversionsRollup | undefined\n}): CampaignConversionsReport {\n const { rollup } = options\n const stored = rollup?.byKind ?? {}\n const caveats: CampaignCaveat[] = []\n\n const kinds: CampaignConversionKindReport[] = CAMPAIGN_CONVERSION_KINDS.map(\n (kind) => {\n const raw = stored[kind]\n const copy = CAMPAIGN_CONVERSION_KIND_COPY[kind]\n return {\n kind,\n label: copy.label,\n // `undefined` means the rollup has no entry for this kind, which is\n // not a measured zero. A stored 0 is impossible — the writer only\n // ever increments — but it is read as unrecorded for the same reason.\n value: raw === undefined || count(raw) === 0 ? null : count(raw),\n note: copy.note,\n }\n },\n )\n\n const any = kinds.some((entry) => entry.value !== null)\n\n if (any) {\n caveats.push({\n id: 'conversions-kinds-overlap',\n message:\n 'These figures count different things about the same visits and are ' +\n 'deliberately not added together. One person filling in one form can ' +\n 'appear as a submission, a contact and a lead, so a total would ' +\n 'count that visit three times.',\n })\n caveats.push({\n id: 'conversions-web-not-rolled-up',\n message:\n 'This email only. A conversion credited to a page filed under the ' +\n 'campaign, or to a link labeled for it, is counted on the ' +\n 'campaign’s own page; one credited to a utm_ label no campaign ' +\n 'declares is listed under Conversions in the marketing console.',\n })\n }\n\n return {\n kinds,\n recorded: rollup !== undefined,\n any,\n model: String(rollup?.model ?? EMAIL_ATTRIBUTION_MODEL),\n windowDays: count(rollup?.windowDays) || EMAIL_ATTRIBUTION_WINDOW_DAYS,\n caveats,\n }\n}\n\n/**\n * How much of one kind was credited to anything at all.\n *\n * ## Why this figure has to exist\n *\n * The join writes nothing for a conversion it cannot credit, so a list of\n * attribution records is a list of the SUCCESSES. Rendering only that turns\n * \"we credited four of these\" into \"four of these happened\", and the reader\n * draws a conclusion about their campaigns from a number that is mostly a\n * fact about how many visitors arrived without a campaign link.\n *\n * So the screen shows both halves and this computes the second. `attributed`\n * is counted over the attribution records; `total` over the records the\n * conversions themselves live in.\n *\n * ## Why the difference is a CEILING and says so\n *\n * The two counts come from different collections with different histories,\n * and the gap between them holds three things that are not the same:\n *\n * - conversions by visitors who arrived directly, which is what the figure\n * is meant to describe;\n * - conversions from before the join existed, which were never eligible; and\n * - for contacts, conversions on the org's OTHER sites, because contacts are\n * shared across an org while attributions are per host.\n *\n * None of those can be separated from the others without a field nobody\n * writes, so `unattributed` is reported as an upper bound and\n * {@link CampaignConversionsCoverage.exact} is `false` whenever the reasons\n * apply. A figure presented as exact when it is not is the failure this\n * whole surface is built to avoid.\n */\nexport interface CampaignConversionsCoverage {\n kind: CampaignConversionKind\n /** Conversions of this kind credited to some campaign or label. */\n attributed: number\n /** Conversions of this kind that exist, credited or not. */\n total: number\n /** `total - attributed`, clamped at zero. An upper bound, not a count. */\n unattributed: number\n /**\n * Whether `unattributed` may be read as \"arrived directly\".\n *\n * Always `false` today — the three reasons in the docblock all apply to\n * every host — and kept as a field rather than hardcoded in the copy so a\n * screen asks the model rather than restating its conclusion.\n */\n exact: boolean\n caveats: CampaignCaveat[]\n}\n\n/**\n * The attributed/unattributed split for one kind.\n *\n * `total` is `null` when it could not be counted — the aggregation failed, or\n * the collection is not readable from this surface — and the split is then\n * withheld entirely rather than defaulting `total` to `attributed`, which\n * would render every conversion as attributed and is the single most\n * flattering wrong answer available here.\n */\nexport function campaignConversionsCoverage(options: {\n kind: CampaignConversionKind\n attributed: number | null | undefined\n total: number | null | undefined\n /** The kind's records live outside this host, so `total` over-counts. */\n crossHostTotal?: boolean\n}): CampaignConversionsCoverage | null {\n const { kind, crossHostTotal } = options\n if (options.total == null || options.attributed == null) return null\n const attributed = count(options.attributed)\n const total = count(options.total)\n const caveats: CampaignCaveat[] = []\n\n /*\n * CLAMPED, and only here at the point of display. The two counts are taken\n * from two collections a moment apart, so a conversion recorded between\n * them makes `attributed` briefly exceed `total`; a negative count of\n * things that did not happen is not a sentence anybody can act on.\n */\n const unattributed = Math.max(0, total - attributed)\n\n caveats.push({\n id: 'conversions-unattributed-is-a-ceiling',\n message:\n `${unattributed.toLocaleString()} of these are not credited to any ` +\n 'campaign. Most arrived without following a campaign link and are ' +\n 'recorded as direct — nothing is guessed from a referrer, and no ' +\n 'campaign is credited for being the most recent one to run. The rest ' +\n 'are records from before campaign attribution was recorded at all, so ' +\n 'read this as an upper bound rather than a count of direct arrivals.',\n })\n if (crossHostTotal) {\n caveats.push({\n id: 'conversions-total-crosses-hosts',\n message:\n 'Contacts are shared across every site in this organization, while ' +\n 'attributions belong to one site. The uncredited figure therefore ' +\n 'includes contacts that were created on another site and could ' +\n 'never have been credited here.',\n })\n }\n\n return {\n kind,\n attributed,\n total,\n unattributed,\n // Never true while the reasons above stand. The field exists so a screen\n // reads the model rather than restating its conclusion in JSX.\n exact: false,\n caveats,\n }\n}\n\n/**\n * How a record names the thing it was credited to.\n *\n * The email channel names a campaign document, so the screen can link to it,\n * and so does a sequence touch: the campaign the sequence is in is a\n * container with a page — as does a page touch, the campaign the viewed page\n * is filed under (AGL-3461). The web channel names a label the marketer\n * typed, which is text unless a campaign declares the label as its own; then\n * the record carries that campaign's id too and the screen links it.\n *\n * The `utm_` triple is joined in the order a marketer set it, with the parts\n * that are absent left out rather than filled with a placeholder.\n * `google / cpc` and `google / cpc / (none)` describe the same link, and only\n * one of them invites the reader to look for a campaign called \"(none)\".\n */\nexport function campaignTouchLabel(\n record: CampaignConversionRecord | null | undefined,\n): string {\n if (!record) return ''\n if (\n record.channel === 'email' ||\n record.channel === 'sequence' ||\n record.channel === 'page'\n ) {\n return String(record.campaignId ?? '')\n }\n const parts = [record.source, record.medium, record.campaign]\n .map((part) => String(part ?? '').trim())\n .filter(Boolean)\n return parts.join(' / ')\n}\n\n/*\n * THE CONVERSIONS LISTS' HEADER SORTS (AGL-3680).\n *\n * Both lists walk the document name by default. Converted and Record order\n * the QUERY instead when their header is clicked: `convertedAtMs` and\n * `refId` are on every attribution — the one writer\n * (`campaign-conversion-attribution.ts`) refuses a record without either —\n * so an `orderBy` on them drops nothing. Each order beside a list's two\n * equalities (`kind` + `channel`, or `kind` + `campaignId`) is one whole-shape\n * composite per list, pinned by `list-query-indexes.spec.ts`; Record sorts\n * one way to keep that count down. Credited to is a label drawn from\n * several fields and sorts the page.\n */\nexport const CAMPAIGN_CONVERSION_SORTS: readonly ListQuerySort[] = [\n { path: 'convertedAtMs', direction: 'desc', column: 'convertedAtMs', label: 'Converted' },\n { path: 'convertedAtMs', direction: 'asc', column: 'convertedAtMs', label: 'Converted' },\n { path: 'refId', direction: 'asc', column: 'refId', label: 'Record' },\n]\n"],"names":["EMAIL_ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","CAMPAIGN_CONVERSION_KINDS","CAMPAIGN_TOUCH_MODEL","CAMPAIGN_VISIT_MINUTES","CAMPAIGN_VISIT_MS","campaignCreditRule","model","windowDays","days","Number","String","CAMPAIGN_CONVERSION_RECORD_KINDS","form","lead","contact","booking","CAMPAIGN_CONVERSION_KIND_COPY","label","note","campaignConversionId","kind","refId","k","ref","includes","count","raw","value","Math","floor","isFinite","campaignConversionsReport","options","rollup","stored","byKind","caveats","kinds","map","copy","undefined","any","some","entry","push","id","message","recorded","campaignConversionsCoverage","crossHostTotal","total","attributed","unattributed","max","toLocaleString","exact","campaignTouchLabel","record","channel","campaignId","parts","source","medium","campaign","part","trim","filter","Boolean","join","CAMPAIGN_CONVERSION_SORTS","path","direction","column"],"mappings":"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"}
@@ -30,11 +30,15 @@ export declare const CAMPAIGN_LIST_ORDER_FIELD = "createdAtMs";
30
30
  * its own; the query asks `emailCampaignId == null` for it.
31
31
  */
32
32
  export declare const SINGLE_SEND_FILTER_VALUE = "__single__";
33
+ /** The send's subject, lower-cased whole, for the Subject header sort. */
34
+ export declare const CAMPAIGN_SEND_SUBJECT_LOWER = "subjectLower";
33
35
  /**
34
- * The search field a send carries beside its `subject`, stamped by every
35
- * write that sets the subject.
36
+ * The list fields a send carries beside its `subject`, stamped by every
37
+ * write that sets the subject: the search tokens, and the lower-cased key
38
+ * the Subject header orders by (AGL-3680).
36
39
  */
37
40
  export declare function campaignSendSearchFields(subject: unknown): {
41
+ subjectLower: string;
38
42
  subjectTokens: string[];
39
43
  };
40
44
  /**
@@ -83,12 +83,16 @@ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
83
83
  * empty value reads as "no value" to the panel, so the choice needs one of
84
84
  * its own; the query asks `emailCampaignId == null` for it.
85
85
  */ export const SINGLE_SEND_FILTER_VALUE = '__single__';
86
+ /** The send's subject, lower-cased whole, for the Subject header sort. */ export const CAMPAIGN_SEND_SUBJECT_LOWER = 'subjectLower';
86
87
  /**
87
- * The search field a send carries beside its `subject`, stamped by every
88
- * write that sets the subject.
88
+ * The list fields a send carries beside its `subject`, stamped by every
89
+ * write that sets the subject: the search tokens, and the lower-cased key
90
+ * the Subject header orders by (AGL-3680).
89
91
  */ export function campaignSendSearchFields(subject) {
92
+ const text = typeof subject === 'string' ? subject : '';
90
93
  return {
91
- subjectTokens: nameSearchTokens(typeof subject === 'string' ? subject : '')
94
+ subjectLower: nameSearchKey(text),
95
+ subjectTokens: nameSearchTokens(text)
92
96
  };
93
97
  }
94
98
  /**
@@ -104,9 +108,47 @@ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
104
108
  const ORDER = [
105
109
  {
106
110
  path: CAMPAIGN_LIST_ORDER_FIELD,
107
- direction: 'desc'
111
+ direction: 'desc',
112
+ label: 'newest first'
108
113
  }
109
114
  ];
115
+ /*
116
+ * ## The header sorts (AGL-3680)
117
+ *
118
+ * Every one is `alone`: served while nothing narrows the list past its
119
+ * scope, falling back to newest first (with a notice) under a filter or the
120
+ * search. On the org hub, which has no scope, that costs no composite; under
121
+ * a site (`hostId ==`) or a campaign (`emailCampaignId ==`) it costs one per
122
+ * order and scope field.
123
+ *
124
+ * Subject `subjectLower`, stamped beside the tokens
125
+ * (`backfill-campaign-list-fields.mjs` completes older sends)
126
+ * Campaign `nameLower`, stamped on every container (AGL-3321)
127
+ *
128
+ * The FIGURES sort the page on screen. A send's `stats.*` counters are left
129
+ * ABSENT until the send or the delivery webhook moves them, on purpose: the
130
+ * report reads an absent counter as "not recorded yet" and a 0 as "recorded:
131
+ * none" (`campaign-send-consent.spec`, `campaign-send-ids.spec`), so they
132
+ * cannot be stamped 0 for an `orderBy` without making every report claim a
133
+ * measurement nobody took. So do a row's State, When, Site name and a
134
+ * container's summed figures.
135
+ */ const textSort = (path, column, label)=>[
136
+ {
137
+ path,
138
+ direction: 'asc',
139
+ column,
140
+ label,
141
+ alone: true
142
+ },
143
+ {
144
+ path,
145
+ direction: 'desc',
146
+ column,
147
+ label,
148
+ alone: true
149
+ }
150
+ ];
151
+ const SUBJECT_SORTS = textSort(CAMPAIGN_SEND_SUBJECT_LOWER, 'subject', 'Subject');
110
152
  const CREATED = {
111
153
  column: CAMPAIGN_LIST_ORDER_FIELD,
112
154
  kind: 'date',
@@ -195,7 +237,10 @@ const EMAILS_SITE = {
195
237
  EMAIL_CAMPAIGN,
196
238
  CREATED
197
239
  ],
198
- sorts: ORDER,
240
+ sorts: [
241
+ ...ORDER,
242
+ ...SUBJECT_SORTS
243
+ ],
199
244
  search: {
200
245
  tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS
201
246
  }
@@ -220,7 +265,10 @@ const EMAILS_ORG = _extends({}, EMAILS_SITE, {
220
265
  STATUS,
221
266
  CREATED
222
267
  ],
223
- sorts: ORDER,
268
+ sorts: [
269
+ ...ORDER,
270
+ ...SUBJECT_SORTS
271
+ ],
224
272
  search: {
225
273
  tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS
226
274
  }
@@ -251,15 +299,19 @@ const CONTAINER_NAME = {
251
299
  'startsWith'
252
300
  ]
253
301
  };
302
+ const CONTAINER_SORTS = [
303
+ ...ORDER,
304
+ ...textSort(CAMPAIGN_NAME_LOWER, 'name', 'Campaign')
305
+ ];
254
306
  const CONTAINERS_SITE = {
255
307
  fields: [
256
308
  CONTAINER_NAME_PREFIX,
257
309
  CREATED
258
310
  ],
259
- sorts: ORDER
311
+ sorts: CONTAINER_SORTS
260
312
  };
261
313
  const CONTAINERS_ORG = {
262
- sorts: ORDER,
314
+ sorts: CONTAINER_SORTS,
263
315
  search: {
264
316
  tokensPath: CAMPAIGN_NAME_TOKENS
265
317
  },
@@ -302,7 +354,10 @@ const SINGLE_SITE = {
302
354
  SINGLE_NAME,
303
355
  CREATED
304
356
  ],
305
- sorts: ORDER,
357
+ sorts: [
358
+ ...ORDER,
359
+ ...textSort(CAMPAIGN_SEND_SUBJECT_LOWER, 'name', 'Campaign')
360
+ ],
306
361
  search: {
307
362
  tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS
308
363
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-list-query.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 { nameSearchKey, nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport type { ListFilterField } from '@aglyn/shared-util-tools/list-query/list-filter'\nimport type { ListFilterClause } from '@aglyn/shared-util-tools/list-query/list-filter-codecs'\nimport type {\n ListQueryDeclaration,\n ListQueryFilter,\n} from '@aglyn/shared-util-tools/list-query/list-query-plan'\n\n/*\n * WHAT THE MARKETING LISTS ASK FIRESTORE (AGL-3321).\n *\n * Three tables read the org's campaign collections — the Campaigns list, a\n * campaign's own emails, and the Emails list — and every clause their\n * Filters panels offer, and their search boxes, is a predicate on ONE query,\n * newest first, paged by that query. Nothing is matched over the rows a read\n * happened to hold. Each field is here because a query can serve it.\n *\n * ## The fields the writers stamp\n *\n * A SEND (`orgs/{orgId}/campaigns`) carries:\n *\n * `createdAtMs` when the record was minted — the one date on every\n * send (a sent one has `sentAt`, a scheduled one\n * `sendAtMs`, a draft neither), so the order;\n * `subjectTokens` the subject's word prefixes, for search and\n * \"Subject contains\";\n * `emailCampaignId` the container it is filed under, or `null` for a\n * single send — stored null rather than left out,\n * because a query can ask for `== null` and can never\n * ask for a field that is missing.\n *\n * A CONTAINER (`orgs/{orgId}/emailCampaigns`) carries `createdAtMs`,\n * `nameTokens` (the org hub's search) and `nameLower` (a site hub's search,\n * as a prefix range — below).\n *\n * Every writer stamps them through the two helpers below, and\n * `tools/scripts/backfill-campaign-list-fields.mjs` stamps the records\n * written before them.\n *\n * ## What a site hub asks\n *\n * A send is sent AS one site, so a site hub's sends are `hostId == {site}` —\n * an equality, which merges with every other clause on single-field\n * indexes and leaves the query's one array clause for search. The security\n * rules admit that read for the site's collaborators (a send's `hostId` is\n * the site its `visibleTo` names). A container is placed on SEVERAL sites,\n * so its scope stays `visibleTo array-contains-any [org, host:{site}]` —\n * the query's one array clause, and the only one the rules can prove for a\n * site collaborator. So a site hub's campaign search is not a token lookup:\n * it is a PREFIX RANGE on `nameLower` beside the scope\n * (`campaignSiteSearchClause`), which orders the page by name while it\n * applies, and the list says search matches the start of a name. Folding\n * the word into the scope clause through scoped tokens would be refused by\n * the rules for such a reader: that query proves nothing about `visibleTo`.\n *\n * ## What is not offered\n *\n * - A send's STATE (Sending, Stopped, …) is derived from its counters at\n * read time (`campaignSendDisplay`); no query can ask it. Its stored\n * `status` is offered instead.\n * - A campaign's WINDOW (Upcoming, Running, Ended) is derived from its dates\n * against the clock; \"running\" is a range on two fields. Not offered.\n * - The site NAME a row shows is not stored on the record; the Site filter\n * asks by the site's id, and search reads the subject or the name only.\n * - Ranges: ONE, on `createdAtMs`, the field every list is ordered by — so\n * it needs no order of its own and no index beyond the ones above.\n */\n\n/** The send's subject, as word prefixes. */\nexport const CAMPAIGN_SEND_SUBJECT_TOKENS = 'subjectTokens'\n/** The container's name, as word prefixes. */\nexport const CAMPAIGN_NAME_TOKENS = 'nameTokens'\n/** The container's name, lower-cased whole, for a site hub's prefix search. */\nexport const CAMPAIGN_NAME_LOWER = 'nameLower'\n/** The one date on every send and container, and every list's order. */\nexport const CAMPAIGN_LIST_ORDER_FIELD = 'createdAtMs'\n\n/**\n * The Campaign filter's value for an email filed under no campaign. An\n * empty value reads as \"no value\" to the panel, so the choice needs one of\n * its own; the query asks `emailCampaignId == null` for it.\n */\nexport const SINGLE_SEND_FILTER_VALUE = '__single__'\n\n/**\n * The search field a send carries beside its `subject`, stamped by every\n * write that sets the subject.\n */\nexport function campaignSendSearchFields(subject: unknown): {\n subjectTokens: string[]\n} {\n return {\n subjectTokens: nameSearchTokens(typeof subject === 'string' ? subject : ''),\n }\n}\n\n/**\n * The search fields a container carries beside its `name`, stamped by every\n * write that sets the name.\n */\nexport function campaignContainerSearchFields(name: unknown): {\n nameLower: string\n nameTokens: string[]\n} {\n const text = typeof name === 'string' ? name : ''\n return { nameLower: nameSearchKey(text), nameTokens: nameSearchTokens(text) }\n}\n\nconst ORDER = [{ path: CAMPAIGN_LIST_ORDER_FIELD, direction: 'desc' as const }]\n\nconst CREATED: ListFilterField = {\n column: CAMPAIGN_LIST_ORDER_FIELD,\n kind: 'date',\n path: CAMPAIGN_LIST_ORDER_FIELD,\n storedAs: 'millis',\n operators: ['is', 'after', 'onOrAfter', 'before', 'onOrBefore'],\n}\n\nconst SUBJECT: ListFilterField = {\n column: 'subject',\n kind: 'text',\n path: 'subject',\n tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS,\n operators: ['contains'],\n}\n\nconst STATUS: ListFilterField = {\n column: 'status',\n kind: 'exact',\n path: 'status',\n operators: ['equals', 'isAnyOf'],\n}\n\n/**\n * The stored statuses a send can hold, for the Status filter. Stored\n * strings, not the State column's display states: an email between batches\n * is stored `scheduled` and SHOWN as Sending, which is why the filter is\n * headed Status and not State.\n */\nexport const CAMPAIGN_SEND_STATUS_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [\n { value: 'draft', label: 'Draft' },\n { value: 'scheduled', label: 'Scheduled' },\n { value: 'sending', label: 'Sending' },\n { value: 'sent', label: 'Sent' },\n { value: 'canceled', label: 'Canceled' },\n { value: 'failed', label: 'Failed' },\n]\n\n/**\n * The Emails list: every send, under a site or across the org.\n *\n * `site` is the org hub's only — under a site every row is that site's.\n */\nexport function campaignEmailsListQuery(orgHub: boolean): ListQueryDeclaration {\n return orgHub ? EMAILS_ORG : EMAILS_SITE\n}\n\nconst EMAIL_CAMPAIGN: ListFilterField = {\n column: 'emailCampaignId',\n kind: 'exact',\n path: 'emailCampaignId',\n presence: 'nullable',\n // `is` only: \"Single send\" is `== null`, which cannot sit in an `in`\n // beside campaign ids.\n operators: ['equals', 'isEmpty'],\n}\n\nconst EMAILS_SITE: ListQueryDeclaration = {\n fields: [SUBJECT, STATUS, EMAIL_CAMPAIGN, CREATED],\n sorts: ORDER,\n search: { tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS },\n}\nconst EMAILS_ORG: ListQueryDeclaration = {\n ...EMAILS_SITE,\n fields: [\n ...EMAILS_SITE.fields,\n { column: 'site', kind: 'exact', path: 'hostId', operators: ['equals', 'isAnyOf'] },\n ],\n}\n\n/** A campaign's own emails: the container is the query's base. */\nexport const CAMPAIGN_EMAILS_QUERY: ListQueryDeclaration = {\n fields: [SUBJECT, STATUS, CREATED],\n sorts: ORDER,\n search: { tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS },\n}\n\n/**\n * The Campaigns list's CAMPAIGNS: the containers. The columns are the\n * table's (`name`, `listIds`, `sitesLabel`); Lists and Sites are the org\n * hub's only — under a site the scope clause is the query's one array\n * clause, so an any-of on another array could never be served beside it.\n */\nexport function campaignContainersListQuery(orgHub: boolean): ListQueryDeclaration {\n return orgHub ? CONTAINERS_ORG : CONTAINERS_SITE\n}\n\nconst CONTAINER_NAME: ListFilterField = {\n column: 'name',\n kind: 'text',\n path: 'name',\n tokensPath: CAMPAIGN_NAME_TOKENS,\n operators: ['contains'],\n}\n/** A site hub's Campaign field: the name's start, a range beside the scope. */\nconst CONTAINER_NAME_PREFIX: ListFilterField = {\n column: 'name',\n kind: 'text',\n path: 'name',\n lowerPath: CAMPAIGN_NAME_LOWER,\n operators: ['startsWith'],\n}\nconst CONTAINERS_SITE: ListQueryDeclaration = {\n fields: [CONTAINER_NAME_PREFIX, CREATED],\n sorts: ORDER,\n}\nconst CONTAINERS_ORG: ListQueryDeclaration = {\n sorts: ORDER,\n search: { tokensPath: CAMPAIGN_NAME_TOKENS },\n fields: [\n CONTAINER_NAME,\n { column: 'listIds', kind: 'exact', path: 'listIds', tokensPath: 'listIds', operators: ['isAnyOf'] },\n // Asked by scope token — `org` is \"every site\", `host:{id}` one site.\n { column: 'sitesLabel', kind: 'exact', path: 'visibleTo', tokensPath: 'visibleTo', operators: ['isAnyOf'] },\n CREATED,\n ],\n}\n\n/**\n * The Campaigns list's SINGLE SENDS: sends filed under no campaign, drawn\n * in the same table. The `name` column is the send's subject; `sitesLabel`\n * is the site it was sent as, on the org hub.\n */\nexport function campaignSingleSendsListQuery(orgHub: boolean): ListQueryDeclaration {\n return orgHub ? SINGLE_ORG : SINGLE_SITE\n}\n\nconst SINGLE_NAME: ListFilterField = { ...SUBJECT, column: 'name' }\nconst SINGLE_SITE: ListQueryDeclaration = {\n fields: [SINGLE_NAME, CREATED],\n sorts: ORDER,\n search: { tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS },\n}\nconst SINGLE_ORG: ListQueryDeclaration = {\n ...SINGLE_SITE,\n fields: [\n SINGLE_NAME,\n { column: 'sitesLabel', kind: 'exact', path: 'hostId', operators: ['equals', 'isAnyOf'] },\n CREATED,\n ],\n}\n\n/** A site hub's sends: the ones sent as it. Nothing on the org hub. */\nexport function campaignSendsScope(hostId: string | null): ListQueryFilter[] {\n return hostId ? [{ path: 'hostId', op: '==', value: hostId }] : []\n}\n\n/** Said above a site hub's campaigns while its search is applied. */\nexport const CAMPAIGN_SITE_SEARCH_NOTICE =\n 'Search on a site’s campaigns matches the start of a campaign’s name.'\n\n/**\n * A site hub's campaign search, as the clause its query can serve: the typed\n * words as \"Campaign starts with\", or null when nothing was typed. The list\n * passes it among its clauses and no search words — see the file header for\n * why a site's search is a prefix range and not a token lookup.\n */\nexport function campaignSiteSearchClause(\n words: readonly string[],\n): ListFilterClause | null {\n const typed = words.map((word) => word.trim()).filter(Boolean).join(' ')\n return typed ? { field: 'name', op: 'startsWith', value: typed } : null\n}\n\n/** A site hub's containers: the ones placed on every site or on it. */\nexport function campaignContainersScope(hostId: string | null): ListQueryFilter[] {\n return hostId\n ? [{ path: 'visibleTo', op: 'array-contains-any', value: scopeTokensForHost(hostId) }]\n : []\n}\n\n/** The sends filed under no campaign. */\nexport const SINGLE_SENDS_BASE: ListQueryFilter = {\n path: 'emailCampaignId',\n op: '==',\n value: null,\n}\n\n/**\n * The Campaign filter's clauses as the query asks them: \"Single send\" is\n * `emailCampaignId == null`, which the grammar spells `isEmpty`. The chips\n * keep reading the clause as the reader set it.\n */\nexport function emailCampaignQueryClauses(\n clauses: readonly ListFilterClause[],\n): ListFilterClause[] {\n return clauses.map((clause) =>\n clause.field === 'emailCampaignId' &&\n clause.op === 'equals' &&\n clause.value === SINGLE_SEND_FILTER_VALUE\n ? { ...clause, op: 'isEmpty', value: '' }\n : clause,\n )\n}\n"],"names":["nameSearchKey","nameSearchTokens","scopeTokensForHost","CAMPAIGN_SEND_SUBJECT_TOKENS","CAMPAIGN_NAME_TOKENS","CAMPAIGN_NAME_LOWER","CAMPAIGN_LIST_ORDER_FIELD","SINGLE_SEND_FILTER_VALUE","campaignSendSearchFields","subject","subjectTokens","campaignContainerSearchFields","name","text","nameLower","nameTokens","ORDER","path","direction","CREATED","column","kind","storedAs","operators","SUBJECT","tokensPath","STATUS","CAMPAIGN_SEND_STATUS_OPTIONS","value","label","campaignEmailsListQuery","orgHub","EMAILS_ORG","EMAILS_SITE","EMAIL_CAMPAIGN","presence","fields","sorts","search","CAMPAIGN_EMAILS_QUERY","campaignContainersListQuery","CONTAINERS_ORG","CONTAINERS_SITE","CONTAINER_NAME","CONTAINER_NAME_PREFIX","lowerPath","campaignSingleSendsListQuery","SINGLE_ORG","SINGLE_SITE","SINGLE_NAME","campaignSendsScope","hostId","op","CAMPAIGN_SITE_SEARCH_NOTICE","campaignSiteSearchClause","words","typed","map","word","trim","filter","Boolean","join","field","campaignContainersScope","SINGLE_SENDS_BASE","emailCampaignQueryClauses","clauses","clause"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-list-query.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 { nameSearchKey, nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport type { ListFilterField } from '@aglyn/shared-util-tools/list-query/list-filter'\nimport type { ListFilterClause } from '@aglyn/shared-util-tools/list-query/list-filter-codecs'\nimport type {\n ListQueryDeclaration,\n ListQueryFilter,\n ListQuerySort,\n} from '@aglyn/shared-util-tools/list-query/list-query-plan'\n\n/*\n * WHAT THE MARKETING LISTS ASK FIRESTORE (AGL-3321).\n *\n * Three tables read the org's campaign collections — the Campaigns list, a\n * campaign's own emails, and the Emails list — and every clause their\n * Filters panels offer, and their search boxes, is a predicate on ONE query,\n * newest first, paged by that query. Nothing is matched over the rows a read\n * happened to hold. Each field is here because a query can serve it.\n *\n * ## The fields the writers stamp\n *\n * A SEND (`orgs/{orgId}/campaigns`) carries:\n *\n * `createdAtMs` when the record was minted — the one date on every\n * send (a sent one has `sentAt`, a scheduled one\n * `sendAtMs`, a draft neither), so the order;\n * `subjectTokens` the subject's word prefixes, for search and\n * \"Subject contains\";\n * `emailCampaignId` the container it is filed under, or `null` for a\n * single send — stored null rather than left out,\n * because a query can ask for `== null` and can never\n * ask for a field that is missing.\n *\n * A CONTAINER (`orgs/{orgId}/emailCampaigns`) carries `createdAtMs`,\n * `nameTokens` (the org hub's search) and `nameLower` (a site hub's search,\n * as a prefix range — below).\n *\n * Every writer stamps them through the two helpers below, and\n * `tools/scripts/backfill-campaign-list-fields.mjs` stamps the records\n * written before them.\n *\n * ## What a site hub asks\n *\n * A send is sent AS one site, so a site hub's sends are `hostId == {site}` —\n * an equality, which merges with every other clause on single-field\n * indexes and leaves the query's one array clause for search. The security\n * rules admit that read for the site's collaborators (a send's `hostId` is\n * the site its `visibleTo` names). A container is placed on SEVERAL sites,\n * so its scope stays `visibleTo array-contains-any [org, host:{site}]` —\n * the query's one array clause, and the only one the rules can prove for a\n * site collaborator. So a site hub's campaign search is not a token lookup:\n * it is a PREFIX RANGE on `nameLower` beside the scope\n * (`campaignSiteSearchClause`), which orders the page by name while it\n * applies, and the list says search matches the start of a name. Folding\n * the word into the scope clause through scoped tokens would be refused by\n * the rules for such a reader: that query proves nothing about `visibleTo`.\n *\n * ## What is not offered\n *\n * - A send's STATE (Sending, Stopped, …) is derived from its counters at\n * read time (`campaignSendDisplay`); no query can ask it. Its stored\n * `status` is offered instead.\n * - A campaign's WINDOW (Upcoming, Running, Ended) is derived from its dates\n * against the clock; \"running\" is a range on two fields. Not offered.\n * - The site NAME a row shows is not stored on the record; the Site filter\n * asks by the site's id, and search reads the subject or the name only.\n * - Ranges: ONE, on `createdAtMs`, the field every list is ordered by — so\n * it needs no order of its own and no index beyond the ones above.\n */\n\n/** The send's subject, as word prefixes. */\nexport const CAMPAIGN_SEND_SUBJECT_TOKENS = 'subjectTokens'\n/** The container's name, as word prefixes. */\nexport const CAMPAIGN_NAME_TOKENS = 'nameTokens'\n/** The container's name, lower-cased whole, for a site hub's prefix search. */\nexport const CAMPAIGN_NAME_LOWER = 'nameLower'\n/** The one date on every send and container, and every list's order. */\nexport const CAMPAIGN_LIST_ORDER_FIELD = 'createdAtMs'\n\n/**\n * The Campaign filter's value for an email filed under no campaign. An\n * empty value reads as \"no value\" to the panel, so the choice needs one of\n * its own; the query asks `emailCampaignId == null` for it.\n */\nexport const SINGLE_SEND_FILTER_VALUE = '__single__'\n\n/** The send's subject, lower-cased whole, for the Subject header sort. */\nexport const CAMPAIGN_SEND_SUBJECT_LOWER = 'subjectLower'\n\n/**\n * The list fields a send carries beside its `subject`, stamped by every\n * write that sets the subject: the search tokens, and the lower-cased key\n * the Subject header orders by (AGL-3680).\n */\nexport function campaignSendSearchFields(subject: unknown): {\n subjectLower: string\n subjectTokens: string[]\n} {\n const text = typeof subject === 'string' ? subject : ''\n return { subjectLower: nameSearchKey(text), subjectTokens: nameSearchTokens(text) }\n}\n\n/**\n * The search fields a container carries beside its `name`, stamped by every\n * write that sets the name.\n */\nexport function campaignContainerSearchFields(name: unknown): {\n nameLower: string\n nameTokens: string[]\n} {\n const text = typeof name === 'string' ? name : ''\n return { nameLower: nameSearchKey(text), nameTokens: nameSearchTokens(text) }\n}\n\nconst ORDER = [{ path: CAMPAIGN_LIST_ORDER_FIELD, direction: 'desc' as const, label: 'newest first' }]\n\n/*\n * ## The header sorts (AGL-3680)\n *\n * Every one is `alone`: served while nothing narrows the list past its\n * scope, falling back to newest first (with a notice) under a filter or the\n * search. On the org hub, which has no scope, that costs no composite; under\n * a site (`hostId ==`) or a campaign (`emailCampaignId ==`) it costs one per\n * order and scope field.\n *\n * Subject `subjectLower`, stamped beside the tokens\n * (`backfill-campaign-list-fields.mjs` completes older sends)\n * Campaign `nameLower`, stamped on every container (AGL-3321)\n *\n * The FIGURES sort the page on screen. A send's `stats.*` counters are left\n * ABSENT until the send or the delivery webhook moves them, on purpose: the\n * report reads an absent counter as \"not recorded yet\" and a 0 as \"recorded:\n * none\" (`campaign-send-consent.spec`, `campaign-send-ids.spec`), so they\n * cannot be stamped 0 for an `orderBy` without making every report claim a\n * measurement nobody took. So do a row's State, When, Site name and a\n * container's summed figures.\n */\nconst textSort = (path: string, column: string, label: string): ListQuerySort[] => [\n { path, direction: 'asc', column, label, alone: true },\n { path, direction: 'desc', column, label, alone: true },\n]\nconst SUBJECT_SORTS = textSort(CAMPAIGN_SEND_SUBJECT_LOWER, 'subject', 'Subject')\n\nconst CREATED: ListFilterField = {\n column: CAMPAIGN_LIST_ORDER_FIELD,\n kind: 'date',\n path: CAMPAIGN_LIST_ORDER_FIELD,\n storedAs: 'millis',\n operators: ['is', 'after', 'onOrAfter', 'before', 'onOrBefore'],\n}\n\nconst SUBJECT: ListFilterField = {\n column: 'subject',\n kind: 'text',\n path: 'subject',\n tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS,\n operators: ['contains'],\n}\n\nconst STATUS: ListFilterField = {\n column: 'status',\n kind: 'exact',\n path: 'status',\n operators: ['equals', 'isAnyOf'],\n}\n\n/**\n * The stored statuses a send can hold, for the Status filter. Stored\n * strings, not the State column's display states: an email between batches\n * is stored `scheduled` and SHOWN as Sending, which is why the filter is\n * headed Status and not State.\n */\nexport const CAMPAIGN_SEND_STATUS_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [\n { value: 'draft', label: 'Draft' },\n { value: 'scheduled', label: 'Scheduled' },\n { value: 'sending', label: 'Sending' },\n { value: 'sent', label: 'Sent' },\n { value: 'canceled', label: 'Canceled' },\n { value: 'failed', label: 'Failed' },\n]\n\n/**\n * The Emails list: every send, under a site or across the org.\n *\n * `site` is the org hub's only — under a site every row is that site's.\n */\nexport function campaignEmailsListQuery(orgHub: boolean): ListQueryDeclaration {\n return orgHub ? EMAILS_ORG : EMAILS_SITE\n}\n\nconst EMAIL_CAMPAIGN: ListFilterField = {\n column: 'emailCampaignId',\n kind: 'exact',\n path: 'emailCampaignId',\n presence: 'nullable',\n // `is` only: \"Single send\" is `== null`, which cannot sit in an `in`\n // beside campaign ids.\n operators: ['equals', 'isEmpty'],\n}\n\nconst EMAILS_SITE: ListQueryDeclaration = {\n fields: [SUBJECT, STATUS, EMAIL_CAMPAIGN, CREATED],\n sorts: [\n ...ORDER,\n ...SUBJECT_SORTS,\n ],\n search: { tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS },\n}\nconst EMAILS_ORG: ListQueryDeclaration = {\n ...EMAILS_SITE,\n fields: [\n ...EMAILS_SITE.fields,\n { column: 'site', kind: 'exact', path: 'hostId', operators: ['equals', 'isAnyOf'] },\n ],\n}\n\n/** A campaign's own emails: the container is the query's base. */\nexport const CAMPAIGN_EMAILS_QUERY: ListQueryDeclaration = {\n fields: [SUBJECT, STATUS, CREATED],\n sorts: [\n ...ORDER,\n ...SUBJECT_SORTS,\n ],\n search: { tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS },\n}\n\n/**\n * The Campaigns list's CAMPAIGNS: the containers. The columns are the\n * table's (`name`, `listIds`, `sitesLabel`); Lists and Sites are the org\n * hub's only — under a site the scope clause is the query's one array\n * clause, so an any-of on another array could never be served beside it.\n */\nexport function campaignContainersListQuery(orgHub: boolean): ListQueryDeclaration {\n return orgHub ? CONTAINERS_ORG : CONTAINERS_SITE\n}\n\nconst CONTAINER_NAME: ListFilterField = {\n column: 'name',\n kind: 'text',\n path: 'name',\n tokensPath: CAMPAIGN_NAME_TOKENS,\n operators: ['contains'],\n}\n/** A site hub's Campaign field: the name's start, a range beside the scope. */\nconst CONTAINER_NAME_PREFIX: ListFilterField = {\n column: 'name',\n kind: 'text',\n path: 'name',\n lowerPath: CAMPAIGN_NAME_LOWER,\n operators: ['startsWith'],\n}\nconst CONTAINER_SORTS = [...ORDER, ...textSort(CAMPAIGN_NAME_LOWER, 'name', 'Campaign')]\nconst CONTAINERS_SITE: ListQueryDeclaration = {\n fields: [CONTAINER_NAME_PREFIX, CREATED],\n sorts: CONTAINER_SORTS,\n}\nconst CONTAINERS_ORG: ListQueryDeclaration = {\n sorts: CONTAINER_SORTS,\n search: { tokensPath: CAMPAIGN_NAME_TOKENS },\n fields: [\n CONTAINER_NAME,\n { column: 'listIds', kind: 'exact', path: 'listIds', tokensPath: 'listIds', operators: ['isAnyOf'] },\n // Asked by scope token — `org` is \"every site\", `host:{id}` one site.\n { column: 'sitesLabel', kind: 'exact', path: 'visibleTo', tokensPath: 'visibleTo', operators: ['isAnyOf'] },\n CREATED,\n ],\n}\n\n/**\n * The Campaigns list's SINGLE SENDS: sends filed under no campaign, drawn\n * in the same table. The `name` column is the send's subject; `sitesLabel`\n * is the site it was sent as, on the org hub.\n */\nexport function campaignSingleSendsListQuery(orgHub: boolean): ListQueryDeclaration {\n return orgHub ? SINGLE_ORG : SINGLE_SITE\n}\n\nconst SINGLE_NAME: ListFilterField = { ...SUBJECT, column: 'name' }\nconst SINGLE_SITE: ListQueryDeclaration = {\n fields: [SINGLE_NAME, CREATED],\n sorts: [\n ...ORDER,\n ...textSort(CAMPAIGN_SEND_SUBJECT_LOWER, 'name', 'Campaign'),\n ],\n search: { tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS },\n}\nconst SINGLE_ORG: ListQueryDeclaration = {\n ...SINGLE_SITE,\n fields: [\n SINGLE_NAME,\n { column: 'sitesLabel', kind: 'exact', path: 'hostId', operators: ['equals', 'isAnyOf'] },\n CREATED,\n ],\n}\n\n/** A site hub's sends: the ones sent as it. Nothing on the org hub. */\nexport function campaignSendsScope(hostId: string | null): ListQueryFilter[] {\n return hostId ? [{ path: 'hostId', op: '==', value: hostId }] : []\n}\n\n/** Said above a site hub's campaigns while its search is applied. */\nexport const CAMPAIGN_SITE_SEARCH_NOTICE =\n 'Search on a site’s campaigns matches the start of a campaign’s name.'\n\n/**\n * A site hub's campaign search, as the clause its query can serve: the typed\n * words as \"Campaign starts with\", or null when nothing was typed. The list\n * passes it among its clauses and no search words — see the file header for\n * why a site's search is a prefix range and not a token lookup.\n */\nexport function campaignSiteSearchClause(\n words: readonly string[],\n): ListFilterClause | null {\n const typed = words.map((word) => word.trim()).filter(Boolean).join(' ')\n return typed ? { field: 'name', op: 'startsWith', value: typed } : null\n}\n\n/** A site hub's containers: the ones placed on every site or on it. */\nexport function campaignContainersScope(hostId: string | null): ListQueryFilter[] {\n return hostId\n ? [{ path: 'visibleTo', op: 'array-contains-any', value: scopeTokensForHost(hostId) }]\n : []\n}\n\n/** The sends filed under no campaign. */\nexport const SINGLE_SENDS_BASE: ListQueryFilter = {\n path: 'emailCampaignId',\n op: '==',\n value: null,\n}\n\n/**\n * The Campaign filter's clauses as the query asks them: \"Single send\" is\n * `emailCampaignId == null`, which the grammar spells `isEmpty`. The chips\n * keep reading the clause as the reader set it.\n */\nexport function emailCampaignQueryClauses(\n clauses: readonly ListFilterClause[],\n): ListFilterClause[] {\n return clauses.map((clause) =>\n clause.field === 'emailCampaignId' &&\n clause.op === 'equals' &&\n clause.value === SINGLE_SEND_FILTER_VALUE\n ? { ...clause, op: 'isEmpty', value: '' }\n : clause,\n )\n}\n"],"names":["nameSearchKey","nameSearchTokens","scopeTokensForHost","CAMPAIGN_SEND_SUBJECT_TOKENS","CAMPAIGN_NAME_TOKENS","CAMPAIGN_NAME_LOWER","CAMPAIGN_LIST_ORDER_FIELD","SINGLE_SEND_FILTER_VALUE","CAMPAIGN_SEND_SUBJECT_LOWER","campaignSendSearchFields","subject","text","subjectLower","subjectTokens","campaignContainerSearchFields","name","nameLower","nameTokens","ORDER","path","direction","label","textSort","column","alone","SUBJECT_SORTS","CREATED","kind","storedAs","operators","SUBJECT","tokensPath","STATUS","CAMPAIGN_SEND_STATUS_OPTIONS","value","campaignEmailsListQuery","orgHub","EMAILS_ORG","EMAILS_SITE","EMAIL_CAMPAIGN","presence","fields","sorts","search","CAMPAIGN_EMAILS_QUERY","campaignContainersListQuery","CONTAINERS_ORG","CONTAINERS_SITE","CONTAINER_NAME","CONTAINER_NAME_PREFIX","lowerPath","CONTAINER_SORTS","campaignSingleSendsListQuery","SINGLE_ORG","SINGLE_SITE","SINGLE_NAME","campaignSendsScope","hostId","op","CAMPAIGN_SITE_SEARCH_NOTICE","campaignSiteSearchClause","words","typed","map","word","trim","filter","Boolean","join","field","campaignContainersScope","SINGLE_SENDS_BASE","emailCampaignQueryClauses","clauses","clause"],"mappings":"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"}
@@ -15,7 +15,8 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import type { ListFilterClause } from '@aglyn/shared-ui-jsx/const/list-grid-filter';
18
- import { type ListQueryDeclaration, type ListQueryFilter } from '@aglyn/shared-ui-jsx/const/list-query-plan';
18
+ import { type ListQueryDeclaration, type ListQueryFilter, type ListQuerySort } from '@aglyn/shared-ui-jsx/const/list-query-plan';
19
+ export declare const CAMPAIGN_MEMBERS_NAME_SORTS: readonly ListQuerySort[];
19
20
  /** A campaign's screens or forms: the name's start, walked by document name. */
20
21
  export declare const CAMPAIGN_MEMBERS_QUERY: ListQueryDeclaration;
21
22
  /** The collections a campaign's members are read from. */
@@ -72,6 +72,27 @@ import { LIST_QUERY_ID_PATH } from "@aglyn/shared-ui-jsx/const/list-query-plan";
72
72
  'startsWith'
73
73
  ]
74
74
  };
75
+ /*
76
+ * The Name header (AGL-3680) orders by `nameLower` both ways — the field
77
+ * the search ranges over, stamped on every screen and form — and holds under
78
+ * the search, whose range leads with that same field. Each direction is the
79
+ * one whole-shape composite per collection named above, ascending (which
80
+ * the search already had) and descending. The figures are computed from the
81
+ * campaign's visits and sort the page.
82
+ */ export const CAMPAIGN_MEMBERS_NAME_SORTS = [
83
+ {
84
+ path: 'nameLower',
85
+ direction: 'asc',
86
+ column: 'name',
87
+ label: 'Name'
88
+ },
89
+ {
90
+ path: 'nameLower',
91
+ direction: 'desc',
92
+ column: 'name',
93
+ label: 'Name'
94
+ }
95
+ ];
75
96
  /** A campaign's screens or forms: the name's start, walked by document name. */ export const CAMPAIGN_MEMBERS_QUERY = {
76
97
  fields: [
77
98
  MEMBER_NAME
@@ -80,7 +101,8 @@ import { LIST_QUERY_ID_PATH } from "@aglyn/shared-ui-jsx/const/list-query-plan";
80
101
  {
81
102
  path: LIST_QUERY_ID_PATH,
82
103
  direction: 'asc'
83
- }
104
+ },
105
+ ...CAMPAIGN_MEMBERS_NAME_SORTS
84
106
  ]
85
107
  };
86
108
  /** A screen that has not been deleted: `deletedAt` stored as `null`. */ export const SCREEN_NOT_DELETED = {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-members-query.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 { CAMPAIGN_MEMBERSHIP_FIELD } from './campaign-kind'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport type { ListFilterClause } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport {\n LIST_QUERY_ID_PATH,\n type ListQueryDeclaration,\n type ListQueryFilter,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\n\n/*\n * WHAT A CAMPAIGN'S SCREENS AND FORMS LISTS ASK FIRESTORE (AGL-3321).\n *\n * A campaign's page lists the screens and the forms filed under it, each a\n * table paged by its own query over `hosts/{hostId}/screens` or `/forms`.\n * The search box and the soft delete are predicates on that query, so a\n * member on the fourth page is found and a deleted one is never a row.\n *\n * ## The base: the membership, and (for screens) not deleted\n *\n * `campaignIds array-contains {campaign}` is the join — the member's own\n * field, read from the other end — and it is the query's ONE array clause.\n *\n * A screen is soft-deleted: a delete stamps `deletedAt` and leaves the\n * document, with its campaigns, in place. So the screens query also asks\n * `deletedAt == null`, which matches only a document that HOLDS the field:\n * every screen create stamps `deletedAt: null` (`artifactCreateListKeys`),\n * and `tools/scripts/backfill-artifacts-list-keys.mjs` stamps the screens\n * written before it. A form has no soft delete — it is deleted outright —\n * so its base is the membership alone, and a retired form stays a member.\n *\n * Unsearched, both lists walk the document name, which every record has:\n * the equality and the array clause merge on Firestore's single-field\n * indexes, so the default read needs no composite.\n *\n * ## The search is a PREFIX RANGE on `nameLower`\n *\n * A word search is an `array-contains` on `nameTokens`, and the membership\n * has already spent the query's one array clause. So the search matches the\n * START of the name — `nameLower` from the typed text to the text followed\n * by the highest character — which is a range, orders the page by name while\n * it applies, and is said above the table (`CAMPAIGN_MEMBERS_SEARCH_NOTICE`),\n * as a site's campaigns list says it.\n *\n * ## The composites: two\n *\n * A range beside equalities needs a composite. Under index merging this\n * shape would take one `(predicate, nameLower)` pair per predicate — three\n * across the two collections — so each collection instead carries the ONE\n * composite that serves its whole search shape:\n *\n * screens campaignIds CONTAINS, deletedAt ASC, nameLower ASC\n * forms campaignIds CONTAINS, nameLower ASC\n *\n * Pinned by `list-query-indexes.spec.ts`. Nothing else is offered: a column\n * filter would be one more predicate beside the range, and so one more\n * composite per collection, for lists that hold a campaign's handful of\n * pages and forms.\n */\n\n/** The name field the search ranges over, as the planner reads it. */\nconst MEMBER_NAME: ListFilterField = {\n column: 'name',\n kind: 'text',\n path: 'displayName',\n lowerPath: 'nameLower',\n operators: ['startsWith'],\n}\n\n/** A campaign's screens or forms: the name's start, walked by document name. */\nexport const CAMPAIGN_MEMBERS_QUERY: ListQueryDeclaration = {\n fields: [MEMBER_NAME],\n sorts: [{ path: LIST_QUERY_ID_PATH, direction: 'asc' }],\n}\n\n/** The collections a campaign's members are read from. */\nexport type CampaignMemberCollection = 'screens' | 'forms'\n\n/** A screen that has not been deleted: `deletedAt` stored as `null`. */\nexport const SCREEN_NOT_DELETED: ListQueryFilter = {\n path: 'deletedAt',\n op: '==',\n value: null,\n}\n\n/**\n * The predicates every read of a campaign's members carries: the\n * membership, and for screens the soft delete.\n */\nexport function campaignMembersBase(\n collection: CampaignMemberCollection,\n campaignId: string,\n): ListQueryFilter[] {\n return [\n { path: CAMPAIGN_MEMBERSHIP_FIELD, op: 'array-contains', value: campaignId },\n ...(collection === 'screens' ? [SCREEN_NOT_DELETED] : []),\n ]\n}\n\n/** Said above a member table while its search applies. */\nexport const CAMPAIGN_MEMBERS_SEARCH_NOTICE =\n 'Search here matches the start of a name.'\n\n/**\n * The search box's words as the clause the query serves: \"Name starts\n * with\", or null when nothing was typed. The table passes it among its\n * clauses and no search words — see the file header for why.\n */\nexport function campaignMembersSearchClause(\n words: readonly string[],\n): ListFilterClause | null {\n const typed = words\n .map((word) => word.trim())\n .filter(Boolean)\n .join(' ')\n return typed ? { field: 'name', op: 'startsWith', value: typed } : null\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_FIELD","LIST_QUERY_ID_PATH","MEMBER_NAME","column","kind","path","lowerPath","operators","CAMPAIGN_MEMBERS_QUERY","fields","sorts","direction","SCREEN_NOT_DELETED","op","value","campaignMembersBase","collection","campaignId","CAMPAIGN_MEMBERS_SEARCH_NOTICE","campaignMembersSearchClause","words","typed","map","word","trim","filter","Boolean","join","field"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,yBAAyB,QAAQ,qBAAiB;AAG3D,SACEC,kBAAkB,QAGb,6CAA4C;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgDC,GAED,oEAAoE,GACpE,MAAMC,cAA+B;IACnCC,QAAQ;IACRC,MAAM;IACNC,MAAM;IACNC,WAAW;IACXC,WAAW;QAAC;KAAa;AAC3B;AAEA,8EAA8E,GAC9E,OAAO,MAAMC,yBAA+C;IAC1DC,QAAQ;QAACP;KAAY;IACrBQ,OAAO;QAAC;YAAEL,MAAMJ;YAAoBU,WAAW;QAAM;KAAE;AACzD,EAAC;AAKD,sEAAsE,GACtE,OAAO,MAAMC,qBAAsC;IACjDP,MAAM;IACNQ,IAAI;IACJC,OAAO;AACT,EAAC;AAED;;;CAGC,GACD,OAAO,SAASC,oBACdC,UAAoC,EACpCC,UAAkB;IAElB,OAAO;QACL;YAAEZ,MAAML;YAA2Ba,IAAI;YAAkBC,OAAOG;QAAW;WACvED,eAAe,YAAY;YAACJ;SAAmB,GAAG,EAAE;KACzD;AACH;AAEA,wDAAwD,GACxD,OAAO,MAAMM,iCACX,2CAA0C;AAE5C;;;;CAIC,GACD,OAAO,SAASC,4BACdC,KAAwB;IAExB,MAAMC,QAAQD,MACXE,GAAG,CAAC,CAACC,OAASA,KAAKC,IAAI,IACvBC,MAAM,CAACC,SACPC,IAAI,CAAC;IACR,OAAON,QAAQ;QAAEO,OAAO;QAAQf,IAAI;QAAcC,OAAOO;IAAM,IAAI;AACrE"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-members-query.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 { CAMPAIGN_MEMBERSHIP_FIELD } from './campaign-kind'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport type { ListFilterClause } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport {\n LIST_QUERY_ID_PATH,\n type ListQueryDeclaration,\n type ListQueryFilter,\n type ListQuerySort,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\n\n/*\n * WHAT A CAMPAIGN'S SCREENS AND FORMS LISTS ASK FIRESTORE (AGL-3321).\n *\n * A campaign's page lists the screens and the forms filed under it, each a\n * table paged by its own query over `hosts/{hostId}/screens` or `/forms`.\n * The search box and the soft delete are predicates on that query, so a\n * member on the fourth page is found and a deleted one is never a row.\n *\n * ## The base: the membership, and (for screens) not deleted\n *\n * `campaignIds array-contains {campaign}` is the join — the member's own\n * field, read from the other end — and it is the query's ONE array clause.\n *\n * A screen is soft-deleted: a delete stamps `deletedAt` and leaves the\n * document, with its campaigns, in place. So the screens query also asks\n * `deletedAt == null`, which matches only a document that HOLDS the field:\n * every screen create stamps `deletedAt: null` (`artifactCreateListKeys`),\n * and `tools/scripts/backfill-artifacts-list-keys.mjs` stamps the screens\n * written before it. A form has no soft delete — it is deleted outright —\n * so its base is the membership alone, and a retired form stays a member.\n *\n * Unsearched, both lists walk the document name, which every record has:\n * the equality and the array clause merge on Firestore's single-field\n * indexes, so the default read needs no composite.\n *\n * ## The search is a PREFIX RANGE on `nameLower`\n *\n * A word search is an `array-contains` on `nameTokens`, and the membership\n * has already spent the query's one array clause. So the search matches the\n * START of the name — `nameLower` from the typed text to the text followed\n * by the highest character — which is a range, orders the page by name while\n * it applies, and is said above the table (`CAMPAIGN_MEMBERS_SEARCH_NOTICE`),\n * as a site's campaigns list says it.\n *\n * ## The composites: two\n *\n * A range beside equalities needs a composite. Under index merging this\n * shape would take one `(predicate, nameLower)` pair per predicate — three\n * across the two collections — so each collection instead carries the ONE\n * composite that serves its whole search shape:\n *\n * screens campaignIds CONTAINS, deletedAt ASC, nameLower ASC\n * forms campaignIds CONTAINS, nameLower ASC\n *\n * Pinned by `list-query-indexes.spec.ts`. Nothing else is offered: a column\n * filter would be one more predicate beside the range, and so one more\n * composite per collection, for lists that hold a campaign's handful of\n * pages and forms.\n */\n\n/** The name field the search ranges over, as the planner reads it. */\nconst MEMBER_NAME: ListFilterField = {\n column: 'name',\n kind: 'text',\n path: 'displayName',\n lowerPath: 'nameLower',\n operators: ['startsWith'],\n}\n\n/*\n * The Name header (AGL-3680) orders by `nameLower` both ways — the field\n * the search ranges over, stamped on every screen and form — and holds under\n * the search, whose range leads with that same field. Each direction is the\n * one whole-shape composite per collection named above, ascending (which\n * the search already had) and descending. The figures are computed from the\n * campaign's visits and sort the page.\n */\nexport const CAMPAIGN_MEMBERS_NAME_SORTS: readonly ListQuerySort[] = [\n { path: 'nameLower', direction: 'asc', column: 'name', label: 'Name' },\n { path: 'nameLower', direction: 'desc', column: 'name', label: 'Name' },\n]\n\n/** A campaign's screens or forms: the name's start, walked by document name. */\nexport const CAMPAIGN_MEMBERS_QUERY: ListQueryDeclaration = {\n fields: [MEMBER_NAME],\n sorts: [{ path: LIST_QUERY_ID_PATH, direction: 'asc' }, ...CAMPAIGN_MEMBERS_NAME_SORTS],\n}\n\n/** The collections a campaign's members are read from. */\nexport type CampaignMemberCollection = 'screens' | 'forms'\n\n/** A screen that has not been deleted: `deletedAt` stored as `null`. */\nexport const SCREEN_NOT_DELETED: ListQueryFilter = {\n path: 'deletedAt',\n op: '==',\n value: null,\n}\n\n/**\n * The predicates every read of a campaign's members carries: the\n * membership, and for screens the soft delete.\n */\nexport function campaignMembersBase(\n collection: CampaignMemberCollection,\n campaignId: string,\n): ListQueryFilter[] {\n return [\n { path: CAMPAIGN_MEMBERSHIP_FIELD, op: 'array-contains', value: campaignId },\n ...(collection === 'screens' ? [SCREEN_NOT_DELETED] : []),\n ]\n}\n\n/** Said above a member table while its search applies. */\nexport const CAMPAIGN_MEMBERS_SEARCH_NOTICE =\n 'Search here matches the start of a name.'\n\n/**\n * The search box's words as the clause the query serves: \"Name starts\n * with\", or null when nothing was typed. The table passes it among its\n * clauses and no search words — see the file header for why.\n */\nexport function campaignMembersSearchClause(\n words: readonly string[],\n): ListFilterClause | null {\n const typed = words\n .map((word) => word.trim())\n .filter(Boolean)\n .join(' ')\n return typed ? { field: 'name', op: 'startsWith', value: typed } : null\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_FIELD","LIST_QUERY_ID_PATH","MEMBER_NAME","column","kind","path","lowerPath","operators","CAMPAIGN_MEMBERS_NAME_SORTS","direction","label","CAMPAIGN_MEMBERS_QUERY","fields","sorts","SCREEN_NOT_DELETED","op","value","campaignMembersBase","collection","campaignId","CAMPAIGN_MEMBERS_SEARCH_NOTICE","campaignMembersSearchClause","words","typed","map","word","trim","filter","Boolean","join","field"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,yBAAyB,QAAQ,qBAAiB;AAG3D,SACEC,kBAAkB,QAIb,6CAA4C;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgDC,GAED,oEAAoE,GACpE,MAAMC,cAA+B;IACnCC,QAAQ;IACRC,MAAM;IACNC,MAAM;IACNC,WAAW;IACXC,WAAW;QAAC;KAAa;AAC3B;AAEA;;;;;;;CAOC,GACD,OAAO,MAAMC,8BAAwD;IACnE;QAAEH,MAAM;QAAaI,WAAW;QAAON,QAAQ;QAAQO,OAAO;IAAO;IACrE;QAAEL,MAAM;QAAaI,WAAW;QAAQN,QAAQ;QAAQO,OAAO;IAAO;CACvE,CAAA;AAED,8EAA8E,GAC9E,OAAO,MAAMC,yBAA+C;IAC1DC,QAAQ;QAACV;KAAY;IACrBW,OAAO;QAAC;YAAER,MAAMJ;YAAoBQ,WAAW;QAAM;WAAMD;KAA4B;AACzF,EAAC;AAKD,sEAAsE,GACtE,OAAO,MAAMM,qBAAsC;IACjDT,MAAM;IACNU,IAAI;IACJC,OAAO;AACT,EAAC;AAED;;;CAGC,GACD,OAAO,SAASC,oBACdC,UAAoC,EACpCC,UAAkB;IAElB,OAAO;QACL;YAAEd,MAAML;YAA2Be,IAAI;YAAkBC,OAAOG;QAAW;WACvED,eAAe,YAAY;YAACJ;SAAmB,GAAG,EAAE;KACzD;AACH;AAEA,wDAAwD,GACxD,OAAO,MAAMM,iCACX,2CAA0C;AAE5C;;;;CAIC,GACD,OAAO,SAASC,4BACdC,KAAwB;IAExB,MAAMC,QAAQD,MACXE,GAAG,CAAC,CAACC,OAASA,KAAKC,IAAI,IACvBC,MAAM,CAACC,SACPC,IAAI,CAAC;IACR,OAAON,QAAQ;QAAEO,OAAO;QAAQf,IAAI;QAAcC,OAAOO;IAAM,IAAI;AACrE"}
@@ -71,10 +71,38 @@ export const EXPERIMENT_LIST_QUERY = {
71
71
  ]
72
72
  }
73
73
  ],
74
- sorts: [
74
+ /*
75
+ * The headers (AGL-3680): Experiment orders the query both ways — `name`
76
+ * is on every experiment (see the card) — and Status alone; Tests is
77
+ * drawn from the target and the variant count and sorts the page. `alone`
78
+ * on this unscoped collection costs no composite.
79
+ */ sorts: [
75
80
  {
76
81
  path: 'name',
77
- direction: 'asc'
82
+ direction: 'asc',
83
+ column: 'name',
84
+ label: 'Experiment'
85
+ },
86
+ {
87
+ path: 'name',
88
+ direction: 'desc',
89
+ column: 'name',
90
+ label: 'Experiment',
91
+ alone: true
92
+ },
93
+ {
94
+ path: 'status',
95
+ direction: 'asc',
96
+ column: 'status',
97
+ label: 'Status',
98
+ alone: true
99
+ },
100
+ {
101
+ path: 'status',
102
+ direction: 'desc',
103
+ column: 'status',
104
+ label: 'Status',
105
+ alone: true
78
106
  }
79
107
  ],
80
108
  search: {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/experiment-list-query.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 type { ListQueryDeclaration } from '@aglyn/shared-ui-jsx/const/list-query-plan'\n\n/*\n * WHAT A SITE'S A/B TESTING LIST ASKS FIRESTORE (AGL-3321).\n *\n * Every clause the list's Filters panel offers, and its search box, is a\n * predicate on ONE query over `hosts/{hostId}/experiments`, ordered by name,\n * paged by that query — never a match over the experiments a read happened\n * to hold. Each field is here because a query can serve it:\n *\n * name `contains` a word (the `nameTokens` array) or `equals` the whole\n * name (`nameLower`). Every writer of an experiment stamps both\n * through `nameSearchFields` — the console's editor, the besigner's\n * \"test this section\" shortcut and the seeds — and\n * `tools/scripts/backfill-experiments-name-search.mjs` stamps the\n * ones saved before;\n * target what the test splits (screen, section, email), which every\n * writer sets;\n * status draft, running, paused or done, which every writer sets.\n *\n * The one order is `name` ascending, as the list has always read. No range\n * is offered: a range orders the list by the field it ranges over, and would\n * cost one composite per field above under that order (four), for a list a\n * site keeps a handful of tests in.\n */\n\n/** The array `contains` and the search box read: every word's prefixes. */\nexport const EXPERIMENT_NAME_TOKENS = 'nameTokens'\n/** The lower-cased whole name `equals` reads. */\nexport const EXPERIMENT_NAME_LOWER = 'nameLower'\n\nexport const EXPERIMENT_LIST_QUERY: ListQueryDeclaration = {\n fields: [\n {\n column: 'name',\n kind: 'text',\n path: 'name',\n lowerPath: EXPERIMENT_NAME_LOWER,\n tokensPath: EXPERIMENT_NAME_TOKENS,\n operators: ['contains', 'equals'],\n },\n { column: 'target', kind: 'exact', path: 'target', presence: 'always', operators: ['equals', 'isAnyOf'] },\n { column: 'status', kind: 'exact', path: 'status', presence: 'always', operators: ['equals', 'isAnyOf'] },\n ],\n sorts: [{ path: 'name', direction: 'asc' }],\n search: { tokensPath: EXPERIMENT_NAME_TOKENS },\n}\n"],"names":["EXPERIMENT_NAME_TOKENS","EXPERIMENT_NAME_LOWER","EXPERIMENT_LIST_QUERY","fields","column","kind","path","lowerPath","tokensPath","operators","presence","sorts","direction","search"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAID;;;;;;;;;;;;;;;;;;;;;;CAsBC,GAED,yEAAyE,GACzE,OAAO,MAAMA,yBAAyB,aAAY;AAClD,+CAA+C,GAC/C,OAAO,MAAMC,wBAAwB,YAAW;AAEhD,OAAO,MAAMC,wBAA8C;IACzDC,QAAQ;QACN;YACEC,QAAQ;YACRC,MAAM;YACNC,MAAM;YACNC,WAAWN;YACXO,YAAYR;YACZS,WAAW;gBAAC;gBAAY;aAAS;QACnC;QACA;YAAEL,QAAQ;YAAUC,MAAM;YAASC,MAAM;YAAUI,UAAU;YAAUD,WAAW;gBAAC;gBAAU;aAAU;QAAC;QACxG;YAAEL,QAAQ;YAAUC,MAAM;YAASC,MAAM;YAAUI,UAAU;YAAUD,WAAW;gBAAC;gBAAU;aAAU;QAAC;KACzG;IACDE,OAAO;QAAC;YAAEL,MAAM;YAAQM,WAAW;QAAM;KAAE;IAC3CC,QAAQ;QAAEL,YAAYR;IAAuB;AAC/C,EAAC"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/experiment-list-query.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 type { ListQueryDeclaration } from '@aglyn/shared-ui-jsx/const/list-query-plan'\n\n/*\n * WHAT A SITE'S A/B TESTING LIST ASKS FIRESTORE (AGL-3321).\n *\n * Every clause the list's Filters panel offers, and its search box, is a\n * predicate on ONE query over `hosts/{hostId}/experiments`, ordered by name,\n * paged by that query — never a match over the experiments a read happened\n * to hold. Each field is here because a query can serve it:\n *\n * name `contains` a word (the `nameTokens` array) or `equals` the whole\n * name (`nameLower`). Every writer of an experiment stamps both\n * through `nameSearchFields` — the console's editor, the besigner's\n * \"test this section\" shortcut and the seeds — and\n * `tools/scripts/backfill-experiments-name-search.mjs` stamps the\n * ones saved before;\n * target what the test splits (screen, section, email), which every\n * writer sets;\n * status draft, running, paused or done, which every writer sets.\n *\n * The one order is `name` ascending, as the list has always read. No range\n * is offered: a range orders the list by the field it ranges over, and would\n * cost one composite per field above under that order (four), for a list a\n * site keeps a handful of tests in.\n */\n\n/** The array `contains` and the search box read: every word's prefixes. */\nexport const EXPERIMENT_NAME_TOKENS = 'nameTokens'\n/** The lower-cased whole name `equals` reads. */\nexport const EXPERIMENT_NAME_LOWER = 'nameLower'\n\nexport const EXPERIMENT_LIST_QUERY: ListQueryDeclaration = {\n fields: [\n {\n column: 'name',\n kind: 'text',\n path: 'name',\n lowerPath: EXPERIMENT_NAME_LOWER,\n tokensPath: EXPERIMENT_NAME_TOKENS,\n operators: ['contains', 'equals'],\n },\n { column: 'target', kind: 'exact', path: 'target', presence: 'always', operators: ['equals', 'isAnyOf'] },\n { column: 'status', kind: 'exact', path: 'status', presence: 'always', operators: ['equals', 'isAnyOf'] },\n ],\n /*\n * The headers (AGL-3680): Experiment orders the query both ways — `name`\n * is on every experiment (see the card) — and Status alone; Tests is\n * drawn from the target and the variant count and sorts the page. `alone`\n * on this unscoped collection costs no composite.\n */\n sorts: [\n { path: 'name', direction: 'asc', column: 'name', label: 'Experiment' },\n { path: 'name', direction: 'desc', column: 'name', label: 'Experiment', alone: true },\n { path: 'status', direction: 'asc', column: 'status', label: 'Status', alone: true },\n { path: 'status', direction: 'desc', column: 'status', label: 'Status', alone: true },\n ],\n search: { tokensPath: EXPERIMENT_NAME_TOKENS },\n}\n"],"names":["EXPERIMENT_NAME_TOKENS","EXPERIMENT_NAME_LOWER","EXPERIMENT_LIST_QUERY","fields","column","kind","path","lowerPath","tokensPath","operators","presence","sorts","direction","label","alone","search"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAID;;;;;;;;;;;;;;;;;;;;;;CAsBC,GAED,yEAAyE,GACzE,OAAO,MAAMA,yBAAyB,aAAY;AAClD,+CAA+C,GAC/C,OAAO,MAAMC,wBAAwB,YAAW;AAEhD,OAAO,MAAMC,wBAA8C;IACzDC,QAAQ;QACN;YACEC,QAAQ;YACRC,MAAM;YACNC,MAAM;YACNC,WAAWN;YACXO,YAAYR;YACZS,WAAW;gBAAC;gBAAY;aAAS;QACnC;QACA;YAAEL,QAAQ;YAAUC,MAAM;YAASC,MAAM;YAAUI,UAAU;YAAUD,WAAW;gBAAC;gBAAU;aAAU;QAAC;QACxG;YAAEL,QAAQ;YAAUC,MAAM;YAASC,MAAM;YAAUI,UAAU;YAAUD,WAAW;gBAAC;gBAAU;aAAU;QAAC;KACzG;IACD;;;;;GAKC,GACDE,OAAO;QACL;YAAEL,MAAM;YAAQM,WAAW;YAAOR,QAAQ;YAAQS,OAAO;QAAa;QACtE;YAAEP,MAAM;YAAQM,WAAW;YAAQR,QAAQ;YAAQS,OAAO;YAAcC,OAAO;QAAK;QACpF;YAAER,MAAM;YAAUM,WAAW;YAAOR,QAAQ;YAAUS,OAAO;YAAUC,OAAO;QAAK;QACnF;YAAER,MAAM;YAAUM,WAAW;YAAQR,QAAQ;YAAUS,OAAO;YAAUC,OAAO;QAAK;KACrF;IACDC,QAAQ;QAAEP,YAAYR;IAAuB;AAC/C,EAAC"}
@@ -0,0 +1,26 @@
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 { ListQueryDeclaration } from '@aglyn/shared-util-tools/list-query/list-query-plan';
18
+ /** A site's Emails list: every send under the site, newest first. */
19
+ export declare const NATIVE_SITE_EMAILS_QUERY: ListQueryDeclaration;
20
+ /** A site's Campaigns list: the containers the site's scope sees. */
21
+ export declare const NATIVE_SITE_CAMPAIGNS_QUERY: ListQueryDeclaration;
22
+ /** The stored statuses a send can hold, for the Status chips. */
23
+ export declare const NATIVE_CAMPAIGN_SEND_STATUSES: {
24
+ value: string;
25
+ label: string;
26
+ }[];