@aglyn/tenant-data-admin 1.0.0-beta.222 → 1.0.0-beta.223

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 (31) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/ga4-measurement-protocol.js +41 -28
  3. package/src/lib/server/ga4-measurement-protocol.js.map +1 -1
  4. package/src/lib/server/host-visitor-records.d.ts +13 -0
  5. package/src/lib/server/host-visitor-records.js +22 -4
  6. package/src/lib/server/host-visitor-records.js.map +1 -1
  7. package/src/lib/server/hosted-page-review.js +6 -3
  8. package/src/lib/server/hosted-page-review.js.map +1 -1
  9. package/src/lib/server/outbound-send-review.js +6 -3
  10. package/src/lib/server/outbound-send-review.js.map +1 -1
  11. package/src/lib/server/payment-fraud-signal.d.ts +49 -9
  12. package/src/lib/server/payment-fraud-signal.js +192 -48
  13. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  14. package/src/lib/server/render-monitor.d.ts +166 -0
  15. package/src/lib/server/render-monitor.js +412 -0
  16. package/src/lib/server/render-monitor.js.map +1 -0
  17. package/src/lib/server/risk-notice.d.ts +7 -0
  18. package/src/lib/server/risk-notice.js +6 -0
  19. package/src/lib/server/risk-notice.js.map +1 -1
  20. package/src/lib/server/web-risk.d.ts +2 -0
  21. package/src/lib/server/web-risk.js +18 -21
  22. package/src/lib/server/web-risk.js.map +1 -1
  23. package/src/tenancy.d.ts +0 -2
  24. package/src/tenancy.js +0 -2
  25. package/src/tenancy.js.map +1 -1
  26. package/src/lib/server/crm-email-activity.d.ts +0 -118
  27. package/src/lib/server/crm-email-activity.js +0 -148
  28. package/src/lib/server/crm-email-activity.js.map +0 -1
  29. package/src/lib/server/crm-next-activity.d.ts +0 -74
  30. package/src/lib/server/crm-next-activity.js +0 -194
  31. package/src/lib/server/crm-next-activity.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-data-admin",
3
- "version": "1.0.0-beta.222",
3
+ "version": "1.0.0-beta.223",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,12 +25,12 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.222",
29
- "@aglyn/shared-util-email": "1.0.0-beta.222",
30
- "@aglyn/shared-util-fbserver": "1.0.0-beta.222",
31
- "@aglyn/shared-util-first-touch": "1.0.0-beta.222",
32
- "@aglyn/shared-util-http": "1.0.0-beta.222",
33
- "@aglyn/shared-util-tools": "1.0.0-beta.222",
28
+ "@aglyn/aglyn": "1.0.0-beta.223",
29
+ "@aglyn/shared-util-email": "1.0.0-beta.223",
30
+ "@aglyn/shared-util-fbserver": "1.0.0-beta.223",
31
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.223",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.223",
33
+ "@aglyn/shared-util-tools": "1.0.0-beta.223",
34
34
  "@msgpack/msgpack": "^3.1.3",
35
35
  "@swc/helpers": "0.5.23",
36
36
  "sharp": "^0.35.5",
@@ -15,6 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { sanitizeEventParams } from "@aglyn/aglyn/app-utils/analytics-events";
18
+ import { boundedAwait } from "@aglyn/shared-util-http/bounded-await";
18
19
  // One definition for a pair of strings that has to agree with a setting in the
19
20
  // GA UI nothing here can typecheck against (AGL-1582/AGL-2064). Four surfaces
20
21
  // stamp them now — the console, the tenant runtime, the docs site, and this
@@ -165,34 +166,46 @@ const SEND_TIMEOUT_MS = 3000;
165
166
  [INTERNAL_TRAFFIC_PARAM]: INTERNAL_TRAFFIC_VALUE
166
167
  }) : params;
167
168
  try {
168
- const response = await fetch(`${GA4_ENDPOINT}?measurement_id=${encodeURIComponent(credentials.measurementId)}&api_secret=${encodeURIComponent(credentials.apiSecret)}`, {
169
- method: 'POST',
170
- headers: {
171
- 'Content-Type': 'application/json'
172
- },
173
- body: JSON.stringify(_extends({
174
- client_id: clientId
175
- }, userId ? {
176
- user_id: userId
177
- } : {}, {
178
- // Keeps THIS path out of ads personalization, and it is the only
179
- // thing that does. The property runs Google Signals and ads
180
- // personalization ON in every region, so a server event sent without
181
- // this flag would join the advertising audiences the browser tags
182
- // build — carrying a `client_id` that may be synthesized from a
183
- // Stripe customer id, which is a stronger identifier than any cookie
184
- // the browser path has. A per-hit assertion rather than a property
185
- // setting, so no dashboard change can quietly opt these events in.
186
- non_personalized_ads: true,
187
- events: [
188
- {
189
- name: eventName,
190
- params: stamped
191
- }
192
- ]
193
- })),
194
- signal: AbortSignal.timeout(SEND_TIMEOUT_MS)
195
- });
169
+ // A real deadline, not only the request's signal (AGL-3565): this is
170
+ // awaited inside a page render when a scheduled publish lands, and a
171
+ // signal did not bound Next's patched fetch there.
172
+ const response = await boundedAwait((signal)=>fetch(`${GA4_ENDPOINT}?measurement_id=${encodeURIComponent(credentials.measurementId)}&api_secret=${encodeURIComponent(credentials.apiSecret)}`, {
173
+ method: 'POST',
174
+ headers: {
175
+ 'Content-Type': 'application/json'
176
+ },
177
+ body: JSON.stringify(_extends({
178
+ client_id: clientId
179
+ }, userId ? {
180
+ user_id: userId
181
+ } : {}, {
182
+ // Keeps THIS path out of ads personalization, and it is the only
183
+ // thing that does. The property runs Google Signals and ads
184
+ // personalization ON in every region, so a server event sent without
185
+ // this flag would join the advertising audiences the browser tags
186
+ // build — carrying a `client_id` that may be synthesized from a
187
+ // Stripe customer id, which is a stronger identifier than any cookie
188
+ // the browser path has. A per-hit assertion rather than a property
189
+ // setting, so no dashboard change can quietly opt these events in.
190
+ non_personalized_ads: true,
191
+ events: [
192
+ {
193
+ name: eventName,
194
+ params: stamped
195
+ }
196
+ ]
197
+ })),
198
+ signal
199
+ }), SEND_TIMEOUT_MS, null, 'ga4.measurement-protocol');
200
+ if (!response) {
201
+ console.warn(JSON.stringify(_extends({}, log, {
202
+ error: 'timeout'
203
+ })));
204
+ return {
205
+ sent: false,
206
+ reason: 'timeout'
207
+ };
208
+ }
196
209
  if (!response.ok) {
197
210
  console.warn(JSON.stringify(_extends({}, log, {
198
211
  status: response.status
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/ga4-measurement-protocol.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 { sanitizeEventParams } from '@aglyn/aglyn/app-utils/analytics-events'\n// One definition for a pair of strings that has to agree with a setting in the\n// GA UI nothing here can typecheck against (AGL-1582/AGL-2064). Four surfaces\n// stamp them now — the console, the tenant runtime, the docs site, and this\n// one — and a second spelling would read as a different dimension in GA, so\n// the filter would catch three of them and look like it was working.\nimport {\n INTERNAL_TRAFFIC_PARAM,\n INTERNAL_TRAFFIC_VALUE,\n} from '@aglyn/aglyn/app-utils/internal-traffic'\n\n/**\n * Server-to-server GA4 delivery via the Measurement Protocol (AGL-1561) —\n * `purchase`, and the one activation event no browser can witness.\n *\n * ## Why revenue is sent from the server and nothing else is\n *\n * The obvious alternative is a client-side `purchase` on the post-checkout\n * return page. It is simpler, and it is wrong for money:\n *\n * - **The return page is not reached reliably.** Stripe redirects back after\n * payment, and a customer who closes the tab, loses signal, or gets a bank\n * 3DS interstitial that lands somewhere else has still paid us. Their\n * revenue would simply never appear.\n * - **Ad blockers drop it**, and they are over-represented in exactly the\n * developer audience Aglyn sells to. Under-reported revenue is worse than\n * no revenue reporting, because it looks like data.\n * - **`?status=success` carries no amount and no session id** (verified: the\n * hosted checkout return URL sets only `status`, and nothing in the app\n * reads it), so a client event could not state what was actually charged.\n * It would also re-fire on a refresh or a back-navigation.\n * - The authoritative amount, currency and transaction id already exist,\n * server-side, in the Stripe webhook — the same place the subscription\n * mirror is written. Reporting revenue from anywhere else means reporting\n * a second, weaker version of a number we already hold.\n *\n * `begin_checkout` stays client-side, because intent genuinely is a browser\n * event and its loss to an ad blocker costs a funnel step, not a dollar.\n *\n * ## The second sender: `site_published` from a scheduled publish (AGL-1589)\n *\n * Everything above argues for keeping the server-side surface as small as\n * possible, and {@link sendGa4SitePublished} is the one other event that has\n * no browser to fire it: a SCHEDULED publish is applied by a cron beat (or by\n * an ISR regeneration) at a moment when the author is asleep. Until AGL-1589\n * that path could not make a page reachable at all, so it was not an\n * activation and nothing was sent; now that it registers the routing entry, a\n * scheduled FIRST publish is a route going live and `publishScreenRoute`'s\n * client-side `site_published` — the headline activation metric — would miss\n * it. It is sent HERE for the same reason revenue is: the alternative is not\n * a weaker event, it is no event.\n *\n * The rest of the activation funnel stays client-side. This is not a general\n * server-side analytics channel and should not become one.\n *\n * ## The `client_id` problem, stated plainly\n *\n * The Measurement Protocol REQUIRES a `client_id`, and a server has no way to\n * know the browser's. Whatever is supplied decides whether the purchase joins\n * the user's existing GA session — and therefore whether revenue can be\n * attributed to the campaign that produced it, which is the entire point of\n * the exercise.\n *\n * So the browser's real `client_id` is captured when checkout STARTS and\n * carried on the Stripe object's metadata (`ga_client_id`), and read back\n * here. When it is present, the purchase lands on the same GA user and\n * session as the marketing click that produced it.\n *\n * When it is absent — an older checkout session, a customer whose consent or\n * ad blocker meant gtag never ran, or a subscription renewal months later\n * with no browser involved at all — {@link sendGa4Purchase} falls back to a\n * synthesized id derived from the Stripe customer. That records the revenue\n * correctly but attributes it to a synthetic, sessionless user: the money is\n * right, the channel is unknown. This is a deliberate trade (revenue truth\n * beats attribution completeness) and NOT a silent one — the fallback is\n * counted in the returned result so it can be alarmed on if it ever becomes\n * the common case.\n *\n * ## Failure posture\n *\n * Fire-and-forget, swallowing everything. The webhook route claims each\n * Stripe event in a `stripeEvents` doc BEFORE running handlers and deletes\n * the claim on any throw, so a thrown error here would un-claim the event and\n * make Stripe redeliver it — turning a missing analytics hit into a repeated\n * billing side effect. Analytics must never be able to do that.\n */\n\nconst GA4_ENDPOINT = 'https://www.google-analytics.com/mp/collect'\nconst SEND_TIMEOUT_MS = 3000\n\nexport interface Ga4PurchaseItem {\n item_id: string\n item_name: string\n item_category?: string\n price?: number\n quantity?: number\n}\n\nexport interface Ga4PurchaseInput {\n /** Stripe object id — invoice or checkout session. GA de-duplicates on it. */\n transactionId: string\n /** Whole currency units, NOT cents. */\n value: number\n /** ISO 4217, upper-cased by the caller or here. */\n currency: string\n items: Ga4PurchaseItem[]\n billingInterval?: 'monthly' | 'annual'\n /**\n * The browser's GA `client_id`, captured at checkout start and carried on\n * Stripe metadata. Absent for renewals and for customers whose gtag never\n * ran; see the module comment.\n */\n clientId?: string | null\n /** Opaque Firebase uid. The one identifier GA is allowed to hold. */\n userId?: string | null\n /** Stripe customer id — used ONLY to synthesize a fallback client id. */\n stripeCustomerId?: string | null\n\n /**\n * Whether this transaction is OURS — a staff or rehearsal purchase — rather\n * than a customer's (AGL-1582). Carried from the browser that started\n * checkout on Stripe metadata, the same way `clientId` is.\n *\n * Opt-in only, never inferred: wrongly flagging a real customer erases their\n * revenue from every report, and a GA4 data filter is not retroactive.\n */\n internal?: boolean\n}\n\nexport interface Ga4SendResult {\n sent: boolean\n /** True when no real browser client id was available. */\n synthesizedClientId: boolean\n reason?: string\n}\n\n/**\n * A stable, opaque stand-in for a browser client id.\n *\n * GA expects the `<random>.<timestamp>` shape. Determinism matters more than\n * realism: the same `seed` must map to the same synthetic user every time, or\n * one paying customer becomes a crowd of one-purchase strangers and ARPA is\n * nonsense. Derived by a cheap non-cryptographic hash — this is a bucketing\n * key, not a secret, and the seed it comes from never leaves this function.\n *\n * The seed is whatever identifies the actor the events belong to on the\n * surface doing the sending: the Stripe customer for revenue, the host id for\n * a scheduled publish (AGL-1589). Both are opaque to GA — only the hash is\n * ever transmitted.\n */\nexport function synthesizeClientId(seed: string): string {\n let hash = 0\n for (let i = 0; i < seed.length; i += 1) {\n hash = (hash * 31 + seed.charCodeAt(i)) >>> 0\n }\n // Fixed second component: a real timestamp would make the id move between\n // renewals, which is precisely what determinism is here to prevent.\n return `${hash}.1000000000`\n}\n\ninterface Ga4Credentials {\n measurementId: string\n apiSecret: string\n}\n\n/**\n * Absent config is the normal state on self-hosted deployments and in\n * development — not an error, and not worth a log line per event.\n *\n * ⚠️ It is NO LONGER the state of our own production deployments, and this\n * comment saying otherwise is how that fact got lost twice (AGL-2327).\n * `GA4_MEASUREMENT_ID` and `GA4_API_SECRET` are present on the production\n * deployments of `aglyn-console`, `aglyn-tenant` AND `aglyn-docs` since\n * **2026-08-17 12:15 UTC** — verified against `GET /v13/deployments/{id}`,\n * which returns the env key list a running lambda actually has, with an older\n * deployment as a negative control. `vercel env ls` and the project's own env\n * list cannot answer this question; only the deployment can.\n *\n * So a server-side event missing from GA4 today is **not** explained by\n * credentials, and must not be written off as \"the transport is dead\". A\n * 2026-08-19 smoke pass reached exactly that conclusion off the stale version\n * of this note, four days after the flip landed. Look at dimension\n * registration (AGL-1637 item 3) and at how the beacon is SCHEDULED\n * (AGL-2346/AGL-2327 — a bare `void` in a route handler never runs) instead.\n *\n * See docs/ANALYTICS.md §\"Environment variables\".\n */\nfunction ga4Credentials(): Ga4Credentials | null {\n const measurementId = process.env.GA4_MEASUREMENT_ID || ''\n const apiSecret = process.env.GA4_API_SECRET || ''\n if (!measurementId || !apiSecret) return null\n return { measurementId, apiSecret }\n}\n\n/**\n * POST one already-sanitized event. Never throws — see the module comment on\n * failure posture; the callers run inside a Stripe webhook claim and inside a\n * publish executor, and in both a throw would have a side effect far worse\n * than a missing analytics hit.\n */\nasync function postGa4Event(options: {\n credentials: Ga4Credentials\n eventName: string\n params: Record<string, unknown>\n clientId: string\n userId?: string | null\n /** Structured-log fields for the two failure lines (tag + subject). */\n log: Record<string, unknown>\n /**\n * Whether this hit is OURS rather than a customer's (AGL-1582).\n *\n * Applied here, centrally, rather than by each sender: a fifth sender added\n * later would otherwise be unstamped by default, and \"unstamped by default\"\n * is how our own rehearsal revenue ends up in the real reports.\n */\n internal?: boolean\n}): Promise<{ sent: boolean; reason?: string }> {\n const { credentials, eventName, params, clientId, userId, log } = options\n // Only ever ADDED, never set to `false`. GA4's internal-traffic filter\n // matches on the parameter being present with this value; a `traffic_type:\n // 'external'` on every real hit would be a second dimension nobody filters\n // on, and one bad `internal` computation away from erasing paying customers\n // from every report — which a data filter cannot undo.\n const stamped = options.internal\n ? { ...params, [INTERNAL_TRAFFIC_PARAM]: INTERNAL_TRAFFIC_VALUE }\n : params\n try {\n const response = await fetch(\n `${GA4_ENDPOINT}?measurement_id=${encodeURIComponent(\n credentials.measurementId,\n )}&api_secret=${encodeURIComponent(credentials.apiSecret)}`,\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n client_id: clientId,\n // Opaque uid only — it is what stitches this event to the\n // console's client-side events, which set the same user_id.\n ...(userId ? { user_id: userId } : {}),\n // Keeps THIS path out of ads personalization, and it is the only\n // thing that does. The property runs Google Signals and ads\n // personalization ON in every region, so a server event sent without\n // this flag would join the advertising audiences the browser tags\n // build — carrying a `client_id` that may be synthesized from a\n // Stripe customer id, which is a stronger identifier than any cookie\n // the browser path has. A per-hit assertion rather than a property\n // setting, so no dashboard change can quietly opt these events in.\n non_personalized_ads: true,\n events: [{ name: eventName, params: stamped }],\n }),\n signal: AbortSignal.timeout(SEND_TIMEOUT_MS),\n },\n )\n if (!response.ok) {\n console.warn(JSON.stringify({ ...log, status: response.status }))\n return { sent: false, reason: `http-${response.status}` }\n }\n return { sent: true }\n } catch (error) {\n console.warn(\n JSON.stringify({\n ...log,\n error: error instanceof Error ? error.message : 'unknown',\n }),\n )\n return { sent: false, reason: 'network' }\n }\n}\n\n/**\n * Send one `purchase` to GA4. Never throws.\n *\n * Returns a result rather than void so the caller can log the fallback rate;\n * nothing about the return value should change billing behaviour.\n */\nexport async function sendGa4Purchase(\n input: Ga4PurchaseInput,\n): Promise<Ga4SendResult> {\n const credentials = ga4Credentials()\n if (!credentials) {\n return { sent: false, synthesizedClientId: false, reason: 'not-configured' }\n }\n const clientId =\n input.clientId ||\n (input.stripeCustomerId ? synthesizeClientId(input.stripeCustomerId) : '')\n if (!clientId) {\n return { sent: false, synthesizedClientId: false, reason: 'no-client-id' }\n }\n const synthesizedClientId = !input.clientId\n\n // The same sanitizer the browser path uses, applied again here. The inputs\n // are Stripe-derived rather than hand-written, and a Stripe product name is\n // one `metadata` edit away from carrying a customer's name.\n const params = sanitizeEventParams({\n transaction_id: input.transactionId,\n currency: String(input.currency || 'USD').toUpperCase(),\n value: input.value,\n ...(input.billingInterval\n ? { billing_interval: input.billingInterval }\n : {}),\n items: input.items,\n })\n\n // Swallows everything — see the module comment: a throw here would un-claim\n // the Stripe event and cause a redelivery.\n const result = await postGa4Event({\n credentials,\n eventName: 'purchase',\n params,\n clientId,\n userId: input.userId,\n log: {\n tag: 'AGL-1561:ga4-purchase',\n transactionId: input.transactionId,\n },\n internal: input.internal,\n })\n return { ...result, synthesizedClientId }\n}\n\n/**\n * Send one GA4 `refund` — revenue that came back (AGL-1850). Never throws.\n *\n * Both `purchase` senders were one-directional, so GA revenue could only\n * ever go UP: a refunded invoice or marketplace sale stayed on the books\n * forever, drifting GA away from Stripe by exactly the refund volume. GA4's\n * native `refund` event is built for this — sent with the ORIGINAL\n * purchase's `transaction_id`, GA nets the two against each other in every\n * revenue report.\n *\n * `transaction_id` is therefore the one field that carries the whole\n * mechanism: the invoice id for a subscription refund, the checkout session\n * id for a marketplace one — whatever the matching `purchase` reported,\n * never the refund's own charge or event id.\n *\n * `value` is what actually came back, in the SAME accounting the purchase\n * used. For a subscription that is `amount_refunded` (the purchase reported\n * `amount_paid`, all ours); for a marketplace sale it is the platform-net\n * share (the purchase reported our net per AGL-1639, so refunding the gross\n * would net MORE than the sale ever reported). The caller owns that\n * arithmetic because the caller holds the ledger; this sender ships whatever\n * number it is handed.\n *\n * Same claiming rules as `purchase` (the caller discriminates, this module\n * transmits), same client-id capture-or-synthesize fallback, same\n * fire-and-forget posture — a throw would un-claim the Stripe event.\n */\nexport async function sendGa4Refund(\n input: Ga4PurchaseInput,\n): Promise<Ga4SendResult> {\n const credentials = ga4Credentials()\n if (!credentials) {\n return { sent: false, synthesizedClientId: false, reason: 'not-configured' }\n }\n const clientId =\n input.clientId ||\n (input.stripeCustomerId ? synthesizeClientId(input.stripeCustomerId) : '')\n if (!clientId) {\n return { sent: false, synthesizedClientId: false, reason: 'no-client-id' }\n }\n const synthesizedClientId = !input.clientId\n const params = sanitizeEventParams({\n transaction_id: input.transactionId,\n currency: String(input.currency || 'USD').toUpperCase(),\n value: input.value,\n ...(input.billingInterval\n ? { billing_interval: input.billingInterval }\n : {}),\n items: input.items,\n })\n const result = await postGa4Event({\n credentials,\n eventName: 'refund',\n params,\n clientId,\n userId: input.userId,\n log: {\n tag: 'AGL-1850:ga4-refund',\n transactionId: input.transactionId,\n },\n internal: input.internal,\n })\n return { ...result, synthesizedClientId }\n}\n\n/**\n * Send one `subscription_cancelled` — the churn half of the funnel\n * (AGL-1851). Never throws.\n *\n * Every event instruments the way INTO revenue and nothing the way out, so\n * GA could not answer churn rate, plan-tier churn mix or tenure at\n * cancellation. Sent server-side for the same reason `purchase` is: a\n * subscription ends with no browser present — portal cancellations at\n * period end, dunning exhaustion, staff action — so a client event is not a\n * weaker option, it is no option.\n *\n * A CUSTOM name, deliberately. GA4's only subscription-cancel vocabulary\n * (`app_store_subscription_cancel`) is reserved for app-store data sources\n * and a web hit wearing it is dropped, which is silence, not pollution —\n * the worse failure. The params need dimension registration to report on\n * (AGL-1637): `plan` — the plan being LEFT, never the `free` the org mirror\n * writes; `billing_interval` — the churn half of the AGL-1640 annual-mix\n * metric; `tenure_days` — whole days from the subscription's `created` to\n * when it actually ended, the \"how long do they stay\" number.\n */\nexport async function sendGa4SubscriptionCancelled(input: {\n /** The plan tier the org is LEAVING (metadata/price-derived, not `free`). */\n plan: string\n /** `monthly` | `annual`, when the subscription's price interval said. */\n billingInterval?: 'monthly' | 'annual'\n /** Whole days the subscription lived; omitted when timestamps are absent. */\n tenureDays?: number\n /** Same capture-or-synthesize contract as `purchase`. */\n clientId?: string | null\n userId?: string | null\n stripeCustomerId?: string | null\n /** Ours rather than a customer's (AGL-1582) — see `Ga4PurchaseInput`. */\n internal?: boolean\n}): Promise<Ga4SendResult> {\n const credentials = ga4Credentials()\n if (!credentials) {\n return { sent: false, synthesizedClientId: false, reason: 'not-configured' }\n }\n const clientId =\n input.clientId ||\n (input.stripeCustomerId ? synthesizeClientId(input.stripeCustomerId) : '')\n if (!clientId) {\n return { sent: false, synthesizedClientId: false, reason: 'no-client-id' }\n }\n const synthesizedClientId = !input.clientId\n const params = sanitizeEventParams({\n plan: input.plan,\n ...(input.billingInterval\n ? { billing_interval: input.billingInterval }\n : {}),\n // The sanitizer drops `undefined`, which keeps \"not determined\" distinct\n // from a real zero-day tenure.\n tenure_days: input.tenureDays,\n })\n const result = await postGa4Event({\n credentials,\n eventName: 'subscription_cancelled',\n params,\n clientId,\n userId: input.userId,\n log: { tag: 'AGL-1851:ga4-subscription-cancelled' },\n internal: input.internal,\n })\n return { ...result, synthesizedClientId }\n}\n\n/**\n * Send one `site_published` for a route that went live with no browser\n * involved (AGL-1589). Never throws.\n *\n * ## Why this is allowed to be a synthetic user\n *\n * The client id is derived from the HOST — the site whose route went live —\n * and not from the author, because the author is not present: a scheduled\n * publish is applied by the cron beat minutes or hours after anybody clicked\n * anything, and the browser that wrote the schedule is long gone. There is no\n * session to join and nothing to attribute.\n *\n * Deriving it from the host rather than randomly is what keeps the metric\n * honest in the one direction it can be dishonest. \"% who publish a site\"\n * counts USERS, so a random id per publish would let one site scheduling ten\n * publishes look like ten activated customers — the same inflation\n * `publishScreenRoute` avoids by firing only when a route goes live. One host\n * is one synthetic user, forever, however many of its screens go live on a\n * timer.\n *\n * ## One param, deliberately\n *\n * `first_publish` and nothing else, matching the browser event (AGL-1588).\n * The host id is a resource identifier bought for nothing, and the rest of the\n * metric is answered by the event alone; the id is used only as the hash seed\n * and never transmitted.\n *\n * The caller decides `first_publish` from the routing map it read before\n * registering the entry, through the same `isFirstPublishedRoute` the console\n * uses — a dimension is only worth registering if every sender means the same\n * thing by it, and this sender is the one nobody can watch happen.\n */\nexport async function sendGa4SitePublished(input: {\n /** Host uid the route went live on — hashed into a client id, never sent. */\n hostId: string\n /**\n * Whether the host had no live route at all before this one. Omit when it\n * genuinely cannot be determined — the param is optional and an absent\n * breakdown value beats an invented one.\n */\n firstPublish?: boolean\n /**\n * Ours rather than a customer's (AGL-1582). A scheduled publish of our own\n * marketing site or a demo host is an activation event for nobody, and\n * `site_published` is the headline activation metric.\n */\n internal?: boolean\n}): Promise<Ga4SendResult> {\n const credentials = ga4Credentials()\n if (!credentials) {\n return { sent: false, synthesizedClientId: false, reason: 'not-configured' }\n }\n if (!input.hostId) {\n return { sent: false, synthesizedClientId: false, reason: 'no-client-id' }\n }\n const result = await postGa4Event({\n credentials,\n eventName: 'site_published',\n // Sanitized like every other payload even though it carries one boolean —\n // the guarantee should hold because the sanitizer ran, not because the\n // author of this call happened to pass nothing identifying. It also drops\n // `first_publish` when it is undefined, which is how \"not determined\"\n // stays distinct from `false`.\n params: sanitizeEventParams({ first_publish: input.firstPublish }),\n clientId: synthesizeClientId(input.hostId),\n log: { tag: 'AGL-1589:ga4-site-published' },\n internal: input.internal,\n })\n // Always synthetic here: there is no browser client id to have missed.\n return { ...result, synthesizedClientId: true }\n}\n\n/**\n * Send one `stripe_connected` for an account that became able to take\n * payments with no browser present (AGL-1580). Never throws.\n *\n * ## Why this needs a server sender at all\n *\n * There are two client-side emitters for this event — the commerce payments\n * card and the marketplace seller panel — and both are gated the same way:\n *\n * if (!chargesEnabled) trackEvent('stripe_connected')\n *\n * where `chargesEnabled` is the state read from the merchant's profile BEFORE\n * the connect request. The gate is right in intent: the connect handler is a\n * re-entrant create-or-resume that answers `chargesEnabled: true` on every\n * later visit, so a bare event there would count one merchant once per click\n * forever.\n *\n * What defeats it is AGL-1997. Stripe emits `account.updated` the moment\n * hosted onboarding completes — while the merchant is still on Stripe's\n * domain, before the `return_url` bounces them back — and\n * {@link syncConnectAccountStatus} mirrors `charges_enabled` onto the profile\n * from that webhook. So by the time the merchant lands back in the console\n * the flag they are supposed to transition FROM already reads `true`, the\n * client guard is false, and the event never fires. It is not a race that\n * usually goes the wrong way; the webhook wins by an entire page load.\n *\n * ## The two senders cannot double-count\n *\n * Both key off the SAME stored flag, from opposite sides of it:\n *\n * - merchant clicks first → the connect route writes `true` → the later\n * `account.updated` sees no transition → only the CLIENT event fires;\n * - webhook lands first → the transition is here → the merchant's later click\n * reads a pre-state of `true` → only the SERVER event fires.\n *\n * Exactly one of the two guards can be open for a given account, which is why\n * this is added alongside the client emitters rather than replacing them —\n * a deployment with no Connect webhook destination (the self-host default)\n * still reports through the browser.\n *\n * ## One synthetic user per connected account\n *\n * The client id is hashed from the Stripe account id for the reason\n * {@link sendGa4SitePublished} hashes the host: \"% who connect Stripe\" counts\n * MERCHANTS, and a random id per hit would let one account that reconnects\n * look like several activated customers. The account id is the hash seed and\n * is never transmitted.\n *\n * ## No parameters, deliberately\n *\n * `stripe_connected` is declared as `Record<string, never>` in the client\n * event contract, and a server sender that invented a param would create a\n * dimension only half the hits carry.\n */\nexport async function sendGa4StripeConnected(input: {\n /** Stripe account id — hashed into a client id, never sent. */\n accountId: string\n /**\n * Ours rather than a customer's (AGL-1582). Omitted by the webhook caller\n * for the same reason the publish executor omits it: no server-side signal\n * distinguishes our own connected account from a customer's, and guessing\n * would stamp real merchants as internal — which a data filter then\n * permanently discards.\n */\n internal?: boolean\n}): Promise<Ga4SendResult> {\n const credentials = ga4Credentials()\n if (!credentials) {\n return { sent: false, synthesizedClientId: false, reason: 'not-configured' }\n }\n if (!input.accountId) {\n return { sent: false, synthesizedClientId: false, reason: 'no-client-id' }\n }\n const result = await postGa4Event({\n credentials,\n eventName: 'stripe_connected',\n // Sanitized despite being empty, so the guarantee holds because the\n // sanitizer ran rather than because this call site happened to pass\n // nothing.\n params: sanitizeEventParams({}),\n clientId: synthesizeClientId(input.accountId),\n log: { tag: 'AGL-1580:ga4-stripe-connected' },\n internal: input.internal,\n })\n // Always synthetic here: there is no browser client id to have missed.\n return { ...result, synthesizedClientId: true }\n}\n"],"names":["sanitizeEventParams","INTERNAL_TRAFFIC_PARAM","INTERNAL_TRAFFIC_VALUE","GA4_ENDPOINT","SEND_TIMEOUT_MS","synthesizeClientId","seed","hash","i","length","charCodeAt","ga4Credentials","measurementId","process","env","GA4_MEASUREMENT_ID","apiSecret","GA4_API_SECRET","postGa4Event","options","credentials","eventName","params","clientId","userId","log","stamped","internal","response","fetch","encodeURIComponent","method","headers","body","JSON","stringify","client_id","user_id","non_personalized_ads","events","name","signal","AbortSignal","timeout","ok","console","warn","status","sent","reason","error","Error","message","sendGa4Purchase","input","synthesizedClientId","stripeCustomerId","transaction_id","transactionId","currency","String","toUpperCase","value","billingInterval","billing_interval","items","result","tag","sendGa4Refund","sendGa4SubscriptionCancelled","plan","tenure_days","tenureDays","sendGa4SitePublished","hostId","first_publish","firstPublish","sendGa4StripeConnected","accountId"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/ga4-measurement-protocol.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 { sanitizeEventParams } from '@aglyn/aglyn/app-utils/analytics-events'\nimport { boundedAwait } from '@aglyn/shared-util-http/bounded-await'\n// One definition for a pair of strings that has to agree with a setting in the\n// GA UI nothing here can typecheck against (AGL-1582/AGL-2064). Four surfaces\n// stamp them now — the console, the tenant runtime, the docs site, and this\n// one — and a second spelling would read as a different dimension in GA, so\n// the filter would catch three of them and look like it was working.\nimport {\n INTERNAL_TRAFFIC_PARAM,\n INTERNAL_TRAFFIC_VALUE,\n} from '@aglyn/aglyn/app-utils/internal-traffic'\n\n/**\n * Server-to-server GA4 delivery via the Measurement Protocol (AGL-1561) —\n * `purchase`, and the one activation event no browser can witness.\n *\n * ## Why revenue is sent from the server and nothing else is\n *\n * The obvious alternative is a client-side `purchase` on the post-checkout\n * return page. It is simpler, and it is wrong for money:\n *\n * - **The return page is not reached reliably.** Stripe redirects back after\n * payment, and a customer who closes the tab, loses signal, or gets a bank\n * 3DS interstitial that lands somewhere else has still paid us. Their\n * revenue would simply never appear.\n * - **Ad blockers drop it**, and they are over-represented in exactly the\n * developer audience Aglyn sells to. Under-reported revenue is worse than\n * no revenue reporting, because it looks like data.\n * - **`?status=success` carries no amount and no session id** (verified: the\n * hosted checkout return URL sets only `status`, and nothing in the app\n * reads it), so a client event could not state what was actually charged.\n * It would also re-fire on a refresh or a back-navigation.\n * - The authoritative amount, currency and transaction id already exist,\n * server-side, in the Stripe webhook — the same place the subscription\n * mirror is written. Reporting revenue from anywhere else means reporting\n * a second, weaker version of a number we already hold.\n *\n * `begin_checkout` stays client-side, because intent genuinely is a browser\n * event and its loss to an ad blocker costs a funnel step, not a dollar.\n *\n * ## The second sender: `site_published` from a scheduled publish (AGL-1589)\n *\n * Everything above argues for keeping the server-side surface as small as\n * possible, and {@link sendGa4SitePublished} is the one other event that has\n * no browser to fire it: a SCHEDULED publish is applied by a cron beat (or by\n * an ISR regeneration) at a moment when the author is asleep. Until AGL-1589\n * that path could not make a page reachable at all, so it was not an\n * activation and nothing was sent; now that it registers the routing entry, a\n * scheduled FIRST publish is a route going live and `publishScreenRoute`'s\n * client-side `site_published` — the headline activation metric — would miss\n * it. It is sent HERE for the same reason revenue is: the alternative is not\n * a weaker event, it is no event.\n *\n * The rest of the activation funnel stays client-side. This is not a general\n * server-side analytics channel and should not become one.\n *\n * ## The `client_id` problem, stated plainly\n *\n * The Measurement Protocol REQUIRES a `client_id`, and a server has no way to\n * know the browser's. Whatever is supplied decides whether the purchase joins\n * the user's existing GA session — and therefore whether revenue can be\n * attributed to the campaign that produced it, which is the entire point of\n * the exercise.\n *\n * So the browser's real `client_id` is captured when checkout STARTS and\n * carried on the Stripe object's metadata (`ga_client_id`), and read back\n * here. When it is present, the purchase lands on the same GA user and\n * session as the marketing click that produced it.\n *\n * When it is absent — an older checkout session, a customer whose consent or\n * ad blocker meant gtag never ran, or a subscription renewal months later\n * with no browser involved at all — {@link sendGa4Purchase} falls back to a\n * synthesized id derived from the Stripe customer. That records the revenue\n * correctly but attributes it to a synthetic, sessionless user: the money is\n * right, the channel is unknown. This is a deliberate trade (revenue truth\n * beats attribution completeness) and NOT a silent one — the fallback is\n * counted in the returned result so it can be alarmed on if it ever becomes\n * the common case.\n *\n * ## Failure posture\n *\n * Fire-and-forget, swallowing everything. The webhook route claims each\n * Stripe event in a `stripeEvents` doc BEFORE running handlers and deletes\n * the claim on any throw, so a thrown error here would un-claim the event and\n * make Stripe redeliver it — turning a missing analytics hit into a repeated\n * billing side effect. Analytics must never be able to do that.\n */\n\nconst GA4_ENDPOINT = 'https://www.google-analytics.com/mp/collect'\nconst SEND_TIMEOUT_MS = 3000\n\nexport interface Ga4PurchaseItem {\n item_id: string\n item_name: string\n item_category?: string\n price?: number\n quantity?: number\n}\n\nexport interface Ga4PurchaseInput {\n /** Stripe object id — invoice or checkout session. GA de-duplicates on it. */\n transactionId: string\n /** Whole currency units, NOT cents. */\n value: number\n /** ISO 4217, upper-cased by the caller or here. */\n currency: string\n items: Ga4PurchaseItem[]\n billingInterval?: 'monthly' | 'annual'\n /**\n * The browser's GA `client_id`, captured at checkout start and carried on\n * Stripe metadata. Absent for renewals and for customers whose gtag never\n * ran; see the module comment.\n */\n clientId?: string | null\n /** Opaque Firebase uid. The one identifier GA is allowed to hold. */\n userId?: string | null\n /** Stripe customer id — used ONLY to synthesize a fallback client id. */\n stripeCustomerId?: string | null\n\n /**\n * Whether this transaction is OURS — a staff or rehearsal purchase — rather\n * than a customer's (AGL-1582). Carried from the browser that started\n * checkout on Stripe metadata, the same way `clientId` is.\n *\n * Opt-in only, never inferred: wrongly flagging a real customer erases their\n * revenue from every report, and a GA4 data filter is not retroactive.\n */\n internal?: boolean\n}\n\nexport interface Ga4SendResult {\n sent: boolean\n /** True when no real browser client id was available. */\n synthesizedClientId: boolean\n reason?: string\n}\n\n/**\n * A stable, opaque stand-in for a browser client id.\n *\n * GA expects the `<random>.<timestamp>` shape. Determinism matters more than\n * realism: the same `seed` must map to the same synthetic user every time, or\n * one paying customer becomes a crowd of one-purchase strangers and ARPA is\n * nonsense. Derived by a cheap non-cryptographic hash — this is a bucketing\n * key, not a secret, and the seed it comes from never leaves this function.\n *\n * The seed is whatever identifies the actor the events belong to on the\n * surface doing the sending: the Stripe customer for revenue, the host id for\n * a scheduled publish (AGL-1589). Both are opaque to GA — only the hash is\n * ever transmitted.\n */\nexport function synthesizeClientId(seed: string): string {\n let hash = 0\n for (let i = 0; i < seed.length; i += 1) {\n hash = (hash * 31 + seed.charCodeAt(i)) >>> 0\n }\n // Fixed second component: a real timestamp would make the id move between\n // renewals, which is precisely what determinism is here to prevent.\n return `${hash}.1000000000`\n}\n\ninterface Ga4Credentials {\n measurementId: string\n apiSecret: string\n}\n\n/**\n * Absent config is the normal state on self-hosted deployments and in\n * development — not an error, and not worth a log line per event.\n *\n * ⚠️ It is NO LONGER the state of our own production deployments, and this\n * comment saying otherwise is how that fact got lost twice (AGL-2327).\n * `GA4_MEASUREMENT_ID` and `GA4_API_SECRET` are present on the production\n * deployments of `aglyn-console`, `aglyn-tenant` AND `aglyn-docs` since\n * **2026-08-17 12:15 UTC** — verified against `GET /v13/deployments/{id}`,\n * which returns the env key list a running lambda actually has, with an older\n * deployment as a negative control. `vercel env ls` and the project's own env\n * list cannot answer this question; only the deployment can.\n *\n * So a server-side event missing from GA4 today is **not** explained by\n * credentials, and must not be written off as \"the transport is dead\". A\n * 2026-08-19 smoke pass reached exactly that conclusion off the stale version\n * of this note, four days after the flip landed. Look at dimension\n * registration (AGL-1637 item 3) and at how the beacon is SCHEDULED\n * (AGL-2346/AGL-2327 — a bare `void` in a route handler never runs) instead.\n *\n * See docs/ANALYTICS.md §\"Environment variables\".\n */\nfunction ga4Credentials(): Ga4Credentials | null {\n const measurementId = process.env.GA4_MEASUREMENT_ID || ''\n const apiSecret = process.env.GA4_API_SECRET || ''\n if (!measurementId || !apiSecret) return null\n return { measurementId, apiSecret }\n}\n\n/**\n * POST one already-sanitized event. Never throws — see the module comment on\n * failure posture; the callers run inside a Stripe webhook claim and inside a\n * publish executor, and in both a throw would have a side effect far worse\n * than a missing analytics hit.\n */\nasync function postGa4Event(options: {\n credentials: Ga4Credentials\n eventName: string\n params: Record<string, unknown>\n clientId: string\n userId?: string | null\n /** Structured-log fields for the two failure lines (tag + subject). */\n log: Record<string, unknown>\n /**\n * Whether this hit is OURS rather than a customer's (AGL-1582).\n *\n * Applied here, centrally, rather than by each sender: a fifth sender added\n * later would otherwise be unstamped by default, and \"unstamped by default\"\n * is how our own rehearsal revenue ends up in the real reports.\n */\n internal?: boolean\n}): Promise<{ sent: boolean; reason?: string }> {\n const { credentials, eventName, params, clientId, userId, log } = options\n // Only ever ADDED, never set to `false`. GA4's internal-traffic filter\n // matches on the parameter being present with this value; a `traffic_type:\n // 'external'` on every real hit would be a second dimension nobody filters\n // on, and one bad `internal` computation away from erasing paying customers\n // from every report — which a data filter cannot undo.\n const stamped = options.internal\n ? { ...params, [INTERNAL_TRAFFIC_PARAM]: INTERNAL_TRAFFIC_VALUE }\n : params\n try {\n // A real deadline, not only the request's signal (AGL-3565): this is\n // awaited inside a page render when a scheduled publish lands, and a\n // signal did not bound Next's patched fetch there.\n const response = await boundedAwait(\n (signal) =>\n fetch(\n `${GA4_ENDPOINT}?measurement_id=${encodeURIComponent(\n credentials.measurementId,\n )}&api_secret=${encodeURIComponent(credentials.apiSecret)}`,\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n client_id: clientId,\n // Opaque uid only — it is what stitches this event to the\n // console's client-side events, which set the same user_id.\n ...(userId ? { user_id: userId } : {}),\n // Keeps THIS path out of ads personalization, and it is the only\n // thing that does. The property runs Google Signals and ads\n // personalization ON in every region, so a server event sent without\n // this flag would join the advertising audiences the browser tags\n // build — carrying a `client_id` that may be synthesized from a\n // Stripe customer id, which is a stronger identifier than any cookie\n // the browser path has. A per-hit assertion rather than a property\n // setting, so no dashboard change can quietly opt these events in.\n non_personalized_ads: true,\n events: [{ name: eventName, params: stamped }],\n }),\n signal,\n },\n ),\n SEND_TIMEOUT_MS,\n null,\n 'ga4.measurement-protocol',\n )\n if (!response) {\n console.warn(JSON.stringify({ ...log, error: 'timeout' }))\n return { sent: false, reason: 'timeout' }\n }\n if (!response.ok) {\n console.warn(JSON.stringify({ ...log, status: response.status }))\n return { sent: false, reason: `http-${response.status}` }\n }\n return { sent: true }\n } catch (error) {\n console.warn(\n JSON.stringify({\n ...log,\n error: error instanceof Error ? error.message : 'unknown',\n }),\n )\n return { sent: false, reason: 'network' }\n }\n}\n\n/**\n * Send one `purchase` to GA4. Never throws.\n *\n * Returns a result rather than void so the caller can log the fallback rate;\n * nothing about the return value should change billing behaviour.\n */\nexport async function sendGa4Purchase(\n input: Ga4PurchaseInput,\n): Promise<Ga4SendResult> {\n const credentials = ga4Credentials()\n if (!credentials) {\n return { sent: false, synthesizedClientId: false, reason: 'not-configured' }\n }\n const clientId =\n input.clientId ||\n (input.stripeCustomerId ? synthesizeClientId(input.stripeCustomerId) : '')\n if (!clientId) {\n return { sent: false, synthesizedClientId: false, reason: 'no-client-id' }\n }\n const synthesizedClientId = !input.clientId\n\n // The same sanitizer the browser path uses, applied again here. The inputs\n // are Stripe-derived rather than hand-written, and a Stripe product name is\n // one `metadata` edit away from carrying a customer's name.\n const params = sanitizeEventParams({\n transaction_id: input.transactionId,\n currency: String(input.currency || 'USD').toUpperCase(),\n value: input.value,\n ...(input.billingInterval\n ? { billing_interval: input.billingInterval }\n : {}),\n items: input.items,\n })\n\n // Swallows everything — see the module comment: a throw here would un-claim\n // the Stripe event and cause a redelivery.\n const result = await postGa4Event({\n credentials,\n eventName: 'purchase',\n params,\n clientId,\n userId: input.userId,\n log: {\n tag: 'AGL-1561:ga4-purchase',\n transactionId: input.transactionId,\n },\n internal: input.internal,\n })\n return { ...result, synthesizedClientId }\n}\n\n/**\n * Send one GA4 `refund` — revenue that came back (AGL-1850). Never throws.\n *\n * Both `purchase` senders were one-directional, so GA revenue could only\n * ever go UP: a refunded invoice or marketplace sale stayed on the books\n * forever, drifting GA away from Stripe by exactly the refund volume. GA4's\n * native `refund` event is built for this — sent with the ORIGINAL\n * purchase's `transaction_id`, GA nets the two against each other in every\n * revenue report.\n *\n * `transaction_id` is therefore the one field that carries the whole\n * mechanism: the invoice id for a subscription refund, the checkout session\n * id for a marketplace one — whatever the matching `purchase` reported,\n * never the refund's own charge or event id.\n *\n * `value` is what actually came back, in the SAME accounting the purchase\n * used. For a subscription that is `amount_refunded` (the purchase reported\n * `amount_paid`, all ours); for a marketplace sale it is the platform-net\n * share (the purchase reported our net per AGL-1639, so refunding the gross\n * would net MORE than the sale ever reported). The caller owns that\n * arithmetic because the caller holds the ledger; this sender ships whatever\n * number it is handed.\n *\n * Same claiming rules as `purchase` (the caller discriminates, this module\n * transmits), same client-id capture-or-synthesize fallback, same\n * fire-and-forget posture — a throw would un-claim the Stripe event.\n */\nexport async function sendGa4Refund(\n input: Ga4PurchaseInput,\n): Promise<Ga4SendResult> {\n const credentials = ga4Credentials()\n if (!credentials) {\n return { sent: false, synthesizedClientId: false, reason: 'not-configured' }\n }\n const clientId =\n input.clientId ||\n (input.stripeCustomerId ? synthesizeClientId(input.stripeCustomerId) : '')\n if (!clientId) {\n return { sent: false, synthesizedClientId: false, reason: 'no-client-id' }\n }\n const synthesizedClientId = !input.clientId\n const params = sanitizeEventParams({\n transaction_id: input.transactionId,\n currency: String(input.currency || 'USD').toUpperCase(),\n value: input.value,\n ...(input.billingInterval\n ? { billing_interval: input.billingInterval }\n : {}),\n items: input.items,\n })\n const result = await postGa4Event({\n credentials,\n eventName: 'refund',\n params,\n clientId,\n userId: input.userId,\n log: {\n tag: 'AGL-1850:ga4-refund',\n transactionId: input.transactionId,\n },\n internal: input.internal,\n })\n return { ...result, synthesizedClientId }\n}\n\n/**\n * Send one `subscription_cancelled` — the churn half of the funnel\n * (AGL-1851). Never throws.\n *\n * Every event instruments the way INTO revenue and nothing the way out, so\n * GA could not answer churn rate, plan-tier churn mix or tenure at\n * cancellation. Sent server-side for the same reason `purchase` is: a\n * subscription ends with no browser present — portal cancellations at\n * period end, dunning exhaustion, staff action — so a client event is not a\n * weaker option, it is no option.\n *\n * A CUSTOM name, deliberately. GA4's only subscription-cancel vocabulary\n * (`app_store_subscription_cancel`) is reserved for app-store data sources\n * and a web hit wearing it is dropped, which is silence, not pollution —\n * the worse failure. The params need dimension registration to report on\n * (AGL-1637): `plan` — the plan being LEFT, never the `free` the org mirror\n * writes; `billing_interval` — the churn half of the AGL-1640 annual-mix\n * metric; `tenure_days` — whole days from the subscription's `created` to\n * when it actually ended, the \"how long do they stay\" number.\n */\nexport async function sendGa4SubscriptionCancelled(input: {\n /** The plan tier the org is LEAVING (metadata/price-derived, not `free`). */\n plan: string\n /** `monthly` | `annual`, when the subscription's price interval said. */\n billingInterval?: 'monthly' | 'annual'\n /** Whole days the subscription lived; omitted when timestamps are absent. */\n tenureDays?: number\n /** Same capture-or-synthesize contract as `purchase`. */\n clientId?: string | null\n userId?: string | null\n stripeCustomerId?: string | null\n /** Ours rather than a customer's (AGL-1582) — see `Ga4PurchaseInput`. */\n internal?: boolean\n}): Promise<Ga4SendResult> {\n const credentials = ga4Credentials()\n if (!credentials) {\n return { sent: false, synthesizedClientId: false, reason: 'not-configured' }\n }\n const clientId =\n input.clientId ||\n (input.stripeCustomerId ? synthesizeClientId(input.stripeCustomerId) : '')\n if (!clientId) {\n return { sent: false, synthesizedClientId: false, reason: 'no-client-id' }\n }\n const synthesizedClientId = !input.clientId\n const params = sanitizeEventParams({\n plan: input.plan,\n ...(input.billingInterval\n ? { billing_interval: input.billingInterval }\n : {}),\n // The sanitizer drops `undefined`, which keeps \"not determined\" distinct\n // from a real zero-day tenure.\n tenure_days: input.tenureDays,\n })\n const result = await postGa4Event({\n credentials,\n eventName: 'subscription_cancelled',\n params,\n clientId,\n userId: input.userId,\n log: { tag: 'AGL-1851:ga4-subscription-cancelled' },\n internal: input.internal,\n })\n return { ...result, synthesizedClientId }\n}\n\n/**\n * Send one `site_published` for a route that went live with no browser\n * involved (AGL-1589). Never throws.\n *\n * ## Why this is allowed to be a synthetic user\n *\n * The client id is derived from the HOST — the site whose route went live —\n * and not from the author, because the author is not present: a scheduled\n * publish is applied by the cron beat minutes or hours after anybody clicked\n * anything, and the browser that wrote the schedule is long gone. There is no\n * session to join and nothing to attribute.\n *\n * Deriving it from the host rather than randomly is what keeps the metric\n * honest in the one direction it can be dishonest. \"% who publish a site\"\n * counts USERS, so a random id per publish would let one site scheduling ten\n * publishes look like ten activated customers — the same inflation\n * `publishScreenRoute` avoids by firing only when a route goes live. One host\n * is one synthetic user, forever, however many of its screens go live on a\n * timer.\n *\n * ## One param, deliberately\n *\n * `first_publish` and nothing else, matching the browser event (AGL-1588).\n * The host id is a resource identifier bought for nothing, and the rest of the\n * metric is answered by the event alone; the id is used only as the hash seed\n * and never transmitted.\n *\n * The caller decides `first_publish` from the routing map it read before\n * registering the entry, through the same `isFirstPublishedRoute` the console\n * uses — a dimension is only worth registering if every sender means the same\n * thing by it, and this sender is the one nobody can watch happen.\n */\nexport async function sendGa4SitePublished(input: {\n /** Host uid the route went live on — hashed into a client id, never sent. */\n hostId: string\n /**\n * Whether the host had no live route at all before this one. Omit when it\n * genuinely cannot be determined — the param is optional and an absent\n * breakdown value beats an invented one.\n */\n firstPublish?: boolean\n /**\n * Ours rather than a customer's (AGL-1582). A scheduled publish of our own\n * marketing site or a demo host is an activation event for nobody, and\n * `site_published` is the headline activation metric.\n */\n internal?: boolean\n}): Promise<Ga4SendResult> {\n const credentials = ga4Credentials()\n if (!credentials) {\n return { sent: false, synthesizedClientId: false, reason: 'not-configured' }\n }\n if (!input.hostId) {\n return { sent: false, synthesizedClientId: false, reason: 'no-client-id' }\n }\n const result = await postGa4Event({\n credentials,\n eventName: 'site_published',\n // Sanitized like every other payload even though it carries one boolean —\n // the guarantee should hold because the sanitizer ran, not because the\n // author of this call happened to pass nothing identifying. It also drops\n // `first_publish` when it is undefined, which is how \"not determined\"\n // stays distinct from `false`.\n params: sanitizeEventParams({ first_publish: input.firstPublish }),\n clientId: synthesizeClientId(input.hostId),\n log: { tag: 'AGL-1589:ga4-site-published' },\n internal: input.internal,\n })\n // Always synthetic here: there is no browser client id to have missed.\n return { ...result, synthesizedClientId: true }\n}\n\n/**\n * Send one `stripe_connected` for an account that became able to take\n * payments with no browser present (AGL-1580). Never throws.\n *\n * ## Why this needs a server sender at all\n *\n * There are two client-side emitters for this event — the commerce payments\n * card and the marketplace seller panel — and both are gated the same way:\n *\n * if (!chargesEnabled) trackEvent('stripe_connected')\n *\n * where `chargesEnabled` is the state read from the merchant's profile BEFORE\n * the connect request. The gate is right in intent: the connect handler is a\n * re-entrant create-or-resume that answers `chargesEnabled: true` on every\n * later visit, so a bare event there would count one merchant once per click\n * forever.\n *\n * What defeats it is AGL-1997. Stripe emits `account.updated` the moment\n * hosted onboarding completes — while the merchant is still on Stripe's\n * domain, before the `return_url` bounces them back — and\n * {@link syncConnectAccountStatus} mirrors `charges_enabled` onto the profile\n * from that webhook. So by the time the merchant lands back in the console\n * the flag they are supposed to transition FROM already reads `true`, the\n * client guard is false, and the event never fires. It is not a race that\n * usually goes the wrong way; the webhook wins by an entire page load.\n *\n * ## The two senders cannot double-count\n *\n * Both key off the SAME stored flag, from opposite sides of it:\n *\n * - merchant clicks first → the connect route writes `true` → the later\n * `account.updated` sees no transition → only the CLIENT event fires;\n * - webhook lands first → the transition is here → the merchant's later click\n * reads a pre-state of `true` → only the SERVER event fires.\n *\n * Exactly one of the two guards can be open for a given account, which is why\n * this is added alongside the client emitters rather than replacing them —\n * a deployment with no Connect webhook destination (the self-host default)\n * still reports through the browser.\n *\n * ## One synthetic user per connected account\n *\n * The client id is hashed from the Stripe account id for the reason\n * {@link sendGa4SitePublished} hashes the host: \"% who connect Stripe\" counts\n * MERCHANTS, and a random id per hit would let one account that reconnects\n * look like several activated customers. The account id is the hash seed and\n * is never transmitted.\n *\n * ## No parameters, deliberately\n *\n * `stripe_connected` is declared as `Record<string, never>` in the client\n * event contract, and a server sender that invented a param would create a\n * dimension only half the hits carry.\n */\nexport async function sendGa4StripeConnected(input: {\n /** Stripe account id — hashed into a client id, never sent. */\n accountId: string\n /**\n * Ours rather than a customer's (AGL-1582). Omitted by the webhook caller\n * for the same reason the publish executor omits it: no server-side signal\n * distinguishes our own connected account from a customer's, and guessing\n * would stamp real merchants as internal — which a data filter then\n * permanently discards.\n */\n internal?: boolean\n}): Promise<Ga4SendResult> {\n const credentials = ga4Credentials()\n if (!credentials) {\n return { sent: false, synthesizedClientId: false, reason: 'not-configured' }\n }\n if (!input.accountId) {\n return { sent: false, synthesizedClientId: false, reason: 'no-client-id' }\n }\n const result = await postGa4Event({\n credentials,\n eventName: 'stripe_connected',\n // Sanitized despite being empty, so the guarantee holds because the\n // sanitizer ran rather than because this call site happened to pass\n // nothing.\n params: sanitizeEventParams({}),\n clientId: synthesizeClientId(input.accountId),\n log: { tag: 'AGL-1580:ga4-stripe-connected' },\n internal: input.internal,\n })\n // Always synthetic here: there is no browser client id to have missed.\n return { ...result, synthesizedClientId: true }\n}\n"],"names":["sanitizeEventParams","boundedAwait","INTERNAL_TRAFFIC_PARAM","INTERNAL_TRAFFIC_VALUE","GA4_ENDPOINT","SEND_TIMEOUT_MS","synthesizeClientId","seed","hash","i","length","charCodeAt","ga4Credentials","measurementId","process","env","GA4_MEASUREMENT_ID","apiSecret","GA4_API_SECRET","postGa4Event","options","credentials","eventName","params","clientId","userId","log","stamped","internal","response","signal","fetch","encodeURIComponent","method","headers","body","JSON","stringify","client_id","user_id","non_personalized_ads","events","name","console","warn","error","sent","reason","ok","status","Error","message","sendGa4Purchase","input","synthesizedClientId","stripeCustomerId","transaction_id","transactionId","currency","String","toUpperCase","value","billingInterval","billing_interval","items","result","tag","sendGa4Refund","sendGa4SubscriptionCancelled","plan","tenure_days","tenureDays","sendGa4SitePublished","hostId","first_publish","firstPublish","sendGa4StripeConnected","accountId"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,mBAAmB,QAAQ,0CAAyC;AAC7E,SAASC,YAAY,QAAQ,wCAAuC;AACpE,+EAA+E;AAC/E,8EAA8E;AAC9E,4EAA4E;AAC5E,4EAA4E;AAC5E,qEAAqE;AACrE,SACEC,sBAAsB,EACtBC,sBAAsB,QACjB,0CAAyC;AAEhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0EC,GAED,MAAMC,eAAe;AACrB,MAAMC,kBAAkB;AAgDxB;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASC,mBAAmBC,IAAY;IAC7C,IAAIC,OAAO;IACX,IAAK,IAAIC,IAAI,GAAGA,IAAIF,KAAKG,MAAM,EAAED,KAAK,EAAG;QACvCD,OAAO,AAACA,OAAO,KAAKD,KAAKI,UAAU,CAACF,OAAQ;IAC9C;IACA,0EAA0E;IAC1E,oEAAoE;IACpE,OAAO,GAAGD,KAAK,WAAW,CAAC;AAC7B;AAOA;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,SAASI;IACP,MAAMC,gBAAgBC,QAAQC,GAAG,CAACC,kBAAkB,IAAI;IACxD,MAAMC,YAAYH,QAAQC,GAAG,CAACG,cAAc,IAAI;IAChD,IAAI,CAACL,iBAAiB,CAACI,WAAW,OAAO;IACzC,OAAO;QAAEJ;QAAeI;IAAU;AACpC;AAEA;;;;;CAKC,GACD,eAAeE,aAAaC,OAgB3B;IACC,MAAM,EAAEC,WAAW,EAAEC,SAAS,EAAEC,MAAM,EAAEC,QAAQ,EAAEC,MAAM,EAAEC,GAAG,EAAE,GAAGN;IAClE,uEAAuE;IACvE,2EAA2E;IAC3E,2EAA2E;IAC3E,4EAA4E;IAC5E,uDAAuD;IACvD,MAAMO,UAAUP,QAAQQ,QAAQ,GAC5B,aAAKL;QAAQ,CAACrB,uBAAuB,EAAEC;SACvCoB;IACJ,IAAI;QACF,qEAAqE;QACrE,qEAAqE;QACrE,mDAAmD;QACnD,MAAMM,WAAW,MAAM5B,aACrB,CAAC6B,SACCC,MACE,GAAG3B,aAAa,gBAAgB,EAAE4B,mBAChCX,YAAYR,aAAa,EACzB,YAAY,EAAEmB,mBAAmBX,YAAYJ,SAAS,GAAG,EAC3D;gBACEgB,QAAQ;gBACRC,SAAS;oBAAE,gBAAgB;gBAAmB;gBAC9CC,MAAMC,KAAKC,SAAS,CAAC;oBACnBC,WAAWd;mBAGPC,SAAS;oBAAEc,SAASd;gBAAO,IAAI,CAAC;oBACpC,iEAAiE;oBACjE,4DAA4D;oBAC5D,qEAAqE;oBACrE,kEAAkE;oBAClE,gEAAgE;oBAChE,qEAAqE;oBACrE,mEAAmE;oBACnE,mEAAmE;oBACnEe,sBAAsB;oBACtBC,QAAQ;wBAAC;4BAAEC,MAAMpB;4BAAWC,QAAQI;wBAAQ;qBAAE;;gBAEhDG;YACF,IAEJzB,iBACA,MACA;QAEF,IAAI,CAACwB,UAAU;YACbc,QAAQC,IAAI,CAACR,KAAKC,SAAS,CAAC,aAAKX;gBAAKmB,OAAO;;YAC7C,OAAO;gBAAEC,MAAM;gBAAOC,QAAQ;YAAU;QAC1C;QACA,IAAI,CAAClB,SAASmB,EAAE,EAAE;YAChBL,QAAQC,IAAI,CAACR,KAAKC,SAAS,CAAC,aAAKX;gBAAKuB,QAAQpB,SAASoB,MAAM;;YAC7D,OAAO;gBAAEH,MAAM;gBAAOC,QAAQ,CAAC,KAAK,EAAElB,SAASoB,MAAM,EAAE;YAAC;QAC1D;QACA,OAAO;YAAEH,MAAM;QAAK;IACtB,EAAE,OAAOD,OAAO;QACdF,QAAQC,IAAI,CACVR,KAAKC,SAAS,CAAC,aACVX;YACHmB,OAAOA,iBAAiBK,QAAQL,MAAMM,OAAO,GAAG;;QAGpD,OAAO;YAAEL,MAAM;YAAOC,QAAQ;QAAU;IAC1C;AACF;AAEA;;;;;CAKC,GACD,OAAO,eAAeK,gBACpBC,KAAuB;IAEvB,MAAMhC,cAAcT;IACpB,IAAI,CAACS,aAAa;QAChB,OAAO;YAAEyB,MAAM;YAAOQ,qBAAqB;YAAOP,QAAQ;QAAiB;IAC7E;IACA,MAAMvB,WACJ6B,MAAM7B,QAAQ,IACb6B,CAAAA,MAAME,gBAAgB,GAAGjD,mBAAmB+C,MAAME,gBAAgB,IAAI,EAAC;IAC1E,IAAI,CAAC/B,UAAU;QACb,OAAO;YAAEsB,MAAM;YAAOQ,qBAAqB;YAAOP,QAAQ;QAAe;IAC3E;IACA,MAAMO,sBAAsB,CAACD,MAAM7B,QAAQ;IAE3C,2EAA2E;IAC3E,4EAA4E;IAC5E,4DAA4D;IAC5D,MAAMD,SAASvB,oBAAoB;QACjCwD,gBAAgBH,MAAMI,aAAa;QACnCC,UAAUC,OAAON,MAAMK,QAAQ,IAAI,OAAOE,WAAW;QACrDC,OAAOR,MAAMQ,KAAK;OACdR,MAAMS,eAAe,GACrB;QAAEC,kBAAkBV,MAAMS,eAAe;IAAC,IAC1C,CAAC;QACLE,OAAOX,MAAMW,KAAK;;IAGpB,4EAA4E;IAC5E,2CAA2C;IAC3C,MAAMC,SAAS,MAAM9C,aAAa;QAChCE;QACAC,WAAW;QACXC;QACAC;QACAC,QAAQ4B,MAAM5B,MAAM;QACpBC,KAAK;YACHwC,KAAK;YACLT,eAAeJ,MAAMI,aAAa;QACpC;QACA7B,UAAUyB,MAAMzB,QAAQ;IAC1B;IACA,OAAO,aAAKqC;QAAQX;;AACtB;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,eAAea,cACpBd,KAAuB;IAEvB,MAAMhC,cAAcT;IACpB,IAAI,CAACS,aAAa;QAChB,OAAO;YAAEyB,MAAM;YAAOQ,qBAAqB;YAAOP,QAAQ;QAAiB;IAC7E;IACA,MAAMvB,WACJ6B,MAAM7B,QAAQ,IACb6B,CAAAA,MAAME,gBAAgB,GAAGjD,mBAAmB+C,MAAME,gBAAgB,IAAI,EAAC;IAC1E,IAAI,CAAC/B,UAAU;QACb,OAAO;YAAEsB,MAAM;YAAOQ,qBAAqB;YAAOP,QAAQ;QAAe;IAC3E;IACA,MAAMO,sBAAsB,CAACD,MAAM7B,QAAQ;IAC3C,MAAMD,SAASvB,oBAAoB;QACjCwD,gBAAgBH,MAAMI,aAAa;QACnCC,UAAUC,OAAON,MAAMK,QAAQ,IAAI,OAAOE,WAAW;QACrDC,OAAOR,MAAMQ,KAAK;OACdR,MAAMS,eAAe,GACrB;QAAEC,kBAAkBV,MAAMS,eAAe;IAAC,IAC1C,CAAC;QACLE,OAAOX,MAAMW,KAAK;;IAEpB,MAAMC,SAAS,MAAM9C,aAAa;QAChCE;QACAC,WAAW;QACXC;QACAC;QACAC,QAAQ4B,MAAM5B,MAAM;QACpBC,KAAK;YACHwC,KAAK;YACLT,eAAeJ,MAAMI,aAAa;QACpC;QACA7B,UAAUyB,MAAMzB,QAAQ;IAC1B;IACA,OAAO,aAAKqC;QAAQX;;AACtB;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,eAAec,6BAA6Bf,KAalD;IACC,MAAMhC,cAAcT;IACpB,IAAI,CAACS,aAAa;QAChB,OAAO;YAAEyB,MAAM;YAAOQ,qBAAqB;YAAOP,QAAQ;QAAiB;IAC7E;IACA,MAAMvB,WACJ6B,MAAM7B,QAAQ,IACb6B,CAAAA,MAAME,gBAAgB,GAAGjD,mBAAmB+C,MAAME,gBAAgB,IAAI,EAAC;IAC1E,IAAI,CAAC/B,UAAU;QACb,OAAO;YAAEsB,MAAM;YAAOQ,qBAAqB;YAAOP,QAAQ;QAAe;IAC3E;IACA,MAAMO,sBAAsB,CAACD,MAAM7B,QAAQ;IAC3C,MAAMD,SAASvB,oBAAoB;QACjCqE,MAAMhB,MAAMgB,IAAI;OACZhB,MAAMS,eAAe,GACrB;QAAEC,kBAAkBV,MAAMS,eAAe;IAAC,IAC1C,CAAC;QACL,yEAAyE;QACzE,+BAA+B;QAC/BQ,aAAajB,MAAMkB,UAAU;;IAE/B,MAAMN,SAAS,MAAM9C,aAAa;QAChCE;QACAC,WAAW;QACXC;QACAC;QACAC,QAAQ4B,MAAM5B,MAAM;QACpBC,KAAK;YAAEwC,KAAK;QAAsC;QAClDtC,UAAUyB,MAAMzB,QAAQ;IAC1B;IACA,OAAO,aAAKqC;QAAQX;;AACtB;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+BC,GACD,OAAO,eAAekB,qBAAqBnB,KAe1C;IACC,MAAMhC,cAAcT;IACpB,IAAI,CAACS,aAAa;QAChB,OAAO;YAAEyB,MAAM;YAAOQ,qBAAqB;YAAOP,QAAQ;QAAiB;IAC7E;IACA,IAAI,CAACM,MAAMoB,MAAM,EAAE;QACjB,OAAO;YAAE3B,MAAM;YAAOQ,qBAAqB;YAAOP,QAAQ;QAAe;IAC3E;IACA,MAAMkB,SAAS,MAAM9C,aAAa;QAChCE;QACAC,WAAW;QACX,0EAA0E;QAC1E,uEAAuE;QACvE,0EAA0E;QAC1E,sEAAsE;QACtE,+BAA+B;QAC/BC,QAAQvB,oBAAoB;YAAE0E,eAAerB,MAAMsB,YAAY;QAAC;QAChEnD,UAAUlB,mBAAmB+C,MAAMoB,MAAM;QACzC/C,KAAK;YAAEwC,KAAK;QAA8B;QAC1CtC,UAAUyB,MAAMzB,QAAQ;IAC1B;IACA,uEAAuE;IACvE,OAAO,aAAKqC;QAAQX,qBAAqB;;AAC3C;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqDC,GACD,OAAO,eAAesB,uBAAuBvB,KAW5C;IACC,MAAMhC,cAAcT;IACpB,IAAI,CAACS,aAAa;QAChB,OAAO;YAAEyB,MAAM;YAAOQ,qBAAqB;YAAOP,QAAQ;QAAiB;IAC7E;IACA,IAAI,CAACM,MAAMwB,SAAS,EAAE;QACpB,OAAO;YAAE/B,MAAM;YAAOQ,qBAAqB;YAAOP,QAAQ;QAAe;IAC3E;IACA,MAAMkB,SAAS,MAAM9C,aAAa;QAChCE;QACAC,WAAW;QACX,oEAAoE;QACpE,oEAAoE;QACpE,WAAW;QACXC,QAAQvB,oBAAoB,CAAC;QAC7BwB,UAAUlB,mBAAmB+C,MAAMwB,SAAS;QAC5CnD,KAAK;YAAEwC,KAAK;QAAgC;QAC5CtC,UAAUyB,MAAMzB,QAAQ;IAC1B;IACA,uEAAuE;IACvE,OAAO,aAAKqC;QAAQX,qBAAqB;;AAC3C"}
@@ -219,6 +219,19 @@ export interface HostLeadInput {
219
219
  * the same rule `upsertHostContact` applies.
220
220
  */
221
221
  disclosedConsentGroup?: string | null;
222
+ /**
223
+ * The campaigns the capture SURFACE is filed under (AGL-3458) — a form's
224
+ * `campaignIds`, read by the door off the verified form document. Never the
225
+ * campaign touch, which is where the visitor came from: this is which
226
+ * campaigns the merchant put the form in, and it is true of everybody who
227
+ * fills that form in.
228
+ *
229
+ * Unioned into the lead's own `campaignIds` — the field the enroll route
230
+ * and the bulk bar write — so a campaign the lead joined by hand or by a
231
+ * sequence is kept, and the Leads list's Campaign filter
232
+ * (`scopedCampaignIds`) is restamped from the union in the same write.
233
+ */
234
+ campaignIds?: readonly string[];
222
235
  /**
223
236
  * A phone the capture learned (AGL-3493), E.164 as the door normalized
224
237
  * it. Written only when the lead holds none: a number a rep typed on the
@@ -22,7 +22,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
22
22
  * The policy is pure and lives in `@aglyn/aglyn/server`
23
23
  * (`visitor-record-ceiling.ts`); this is the part that needs Firestore. Same
24
24
  * split as `visitorWriteRateLimitRefusal`, and for the same reason.
25
- */ import { CAPTURED_BY_HOST_FIELD, checkVisitorRecordCeiling, LEADS_MAX_PER_HOST, marketingConsentFieldsForGroup, personKey, readMarketingBasis, utcMonthKey, VISITOR_RECORD_NOTICE_SENT_FIELD, visitorRecordAcceptFillsCeiling, visitorRecordCeilingAnnounced, visitorRecordRefusedCounterId } from "@aglyn/aglyn/server";
25
+ */ import { CAPTURED_BY_HOST_FIELD, checkVisitorRecordCeiling, containerMembershipField, LEADS_MAX_PER_HOST, marketingConsentFieldsForGroup, normalizeContainerIds, personKey, readMarketingBasis, utcMonthKey, VISITOR_RECORD_NOTICE_SENT_FIELD, visitorRecordAcceptFillsCeiling, visitorRecordCeilingAnnounced, visitorRecordRefusedCounterId } from "@aglyn/aglyn/server";
26
26
  import { FieldValue } from "firebase-admin/firestore";
27
27
  import { creditConversion } from "@aglyn/aglyn/plugin-manager/plugin-conversion-credit";
28
28
  import { notifyHostManagers } from "./notifications.js";
@@ -352,7 +352,8 @@ const firestore = ()=>firebaseAdmin.app().firestore();
352
352
  // The count a new person was judged against, for the notice re-arm.
353
353
  let usedBeforeCreate = null;
354
354
  const refused = await firestore.runTransaction(async (tx)=>{
355
- var _existing_data, _existing_data1, _existing_data2, _existing_data3, _stored_phone;
355
+ var _existing_data, _existing_data1, _existing_data2, _existing_data3, _stored_phone, _lead_campaignIds;
356
+ var _lead_campaignIds1;
356
357
  // Reset per attempt: a contended transaction re-runs its body, and a
357
358
  // flag left standing from an aborted attempt would credit a campaign
358
359
  // with a person who turned out to exist already.
@@ -452,6 +453,19 @@ const firestore = ()=>firebaseAdmin.app().firestore();
452
453
  } : {};
453
454
  const storedScope = Array.isArray(stored['visibleTo']) ? stored['visibleTo'] : [];
454
455
  /*
456
+ * THE CAMPAIGNS, unioned (AGL-3458). Computed from what the transaction
457
+ * read rather than written as an `arrayUnion`, so the Campaign filter's
458
+ * scoped keys below are derived from the same list the write stores,
459
+ * and the union is held to the membership cap every reader applies.
460
+ * Written only when the capture names one, so a door with no campaign
461
+ * never rewrites the field a person or a sequence filled.
462
+ */ // The field a lead holds its campaigns in, at the top of its document.
463
+ const campaignsField = ((_lead_campaignIds1 = lead.campaignIds) == null ? void 0 : _lead_campaignIds1.length) ? containerMembershipField('campaign') : null;
464
+ const campaigns = campaignsField ? normalizeContainerIds([
465
+ ...normalizeContainerIds(stored[campaignsField]),
466
+ ...(_lead_campaignIds = lead.campaignIds) != null ? _lead_campaignIds : []
467
+ ]) : null;
468
+ /*
455
469
  * A lead that keeps a first or last name keeps the name they make
456
470
  * (AGL-3513): a capture's typed name does not replace it.
457
471
  */ const composedName = composeContactName(stored['firstName'], stored['lastName']);
@@ -467,12 +481,16 @@ const firestore = ()=>firebaseAdmin.app().firestore();
467
481
  ...scope
468
482
  ])
469
483
  ]
470
- }));
484
+ }, campaignsField && campaigns ? {
485
+ [campaignsField]: campaigns
486
+ } : {}));
471
487
  tx.set(leadRef, _extends({
472
488
  email: lead.email
473
489
  }, seen, composedName ? {
474
490
  name: composedName
475
- } : {}, phoneFill, listFields, {
491
+ } : {}, phoneFill, listFields, campaignsField && campaigns ? {
492
+ [campaignsField]: campaigns
493
+ } : {}, {
476
494
  /*
477
495
  * WIDENED BY THE CAPTURE, NEVER BY THE LOOKUP (AGL-3275).
478
496
  *
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-visitor-records.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 durable half of the visitor-record ceilings (AGL-1529) — the lead\n * writer that enforces `LEADS_MAX_PER_HOST`, and the bookkeeping that makes a\n * trip of EITHER ceiling something a site's owner can see.\n *\n * The policy is pure and lives in `@aglyn/aglyn/server`\n * (`visitor-record-ceiling.ts`); this is the part that needs Firestore. Same\n * split as `visitorWriteRateLimitRefusal`, and for the same reason.\n */\n\nimport {\n CAPTURED_BY_HOST_FIELD,\n checkVisitorRecordCeiling,\n LEADS_MAX_PER_HOST,\n marketingConsentFieldsForGroup,\n personKey,\n readMarketingBasis,\n utcMonthKey,\n VISITOR_RECORD_NOTICE_SENT_FIELD,\n visitorRecordAcceptFillsCeiling,\n visitorRecordCeilingAnnounced,\n visitorRecordRefusedCounterId,\n type VisitorRecordKind,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n creditConversion,\n type PluginConversionTouch,\n} from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\nimport { notifyHostManagers } from './notifications'\nimport { scheduleCapturedEmailCheck } from './capture-email-check'\nimport firebaseAdmin from './firebase-admin'\nimport {\n consentGroupForSite,\n orgDataCollectionForHost,\n resolveOrgIdForHost,\n scopedToHost,\n} from './organizations'\nimport {\n composeContactName,\n crmLeadListFields,\n crmReadTokens,\n crmScopeTokens,\n} from '@aglyn/aglyn/server'\nimport type { ConsentGroup, ScopeToken } from '@aglyn/aglyn/server'\n// The module path rather than the barrel, as `upsert-contact.ts` does: a spec\n// that substitutes the barrel keeps the real rule for which group a grant is.\nimport { consentGroupForGrant } from '@aglyn/aglyn/app-utils/consent-groups'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/**\n * A lead's one home: `orgs/{orgId}/leads/{personKey}` (AGL-3275).\n *\n * ## Why leads moved\n *\n * A lead used to live at `hosts/{hostId}/leads/{personKey}` while the\n * contact for the same human lived on the org with a `visibleTo` array. The\n * path was doing the job `visibleTo` does, and doing it worse:\n *\n * - An AGENCY was isolated by the path. It is isolated by `visibleTo` too,\n * with no configuration — `consentGroupScope` on a group of one is\n * `['host:{id}']`.\n * - A MULTI-BRAND org could not be served at all. `personKey` is derived\n * from the address, so the same person captured on two sibling brands\n * produced two documents with byte-identical ids under different parents.\n * One person was two records by construction, which is the thing AGL-3232\n * had just finished eliminating for contacts.\n * - The two answers could disagree. A person's contact was visible to every\n * brand in the consent group they actually consented to — the disclosure\n * names those brands — while their lead was visible to whichever brand's\n * form they happened to land on. The lead was scoped more narrowly than\n * what the person was told, which is the safe direction to be wrong in and\n * still the wrong answer.\n *\n * So a lead is stamped with `crmScopeTokens` and read with `crmReadTokens`,\n * the same pair the contacts surface uses. Nothing here is a new mechanism;\n * this is leads arriving at the one the rest of the CRM already had.\n *\n * ## The fallback is READ-ONLY, and temporary\n *\n * This repo has run this migration once already — AGL-237 moved datasets,\n * contacts and media to the org, AGL-1040 backfilled, AGL-1050 deleted the\n * fallback — and `orgDataCollectionForHost` carries what it learned:\n *\n * \"a second storage path that can still be WRITTEN is a second boundary to\n * enforce forever, which undoes the premise of scoped sharing: one home\n * per resource plus an explicit scope.\"\n *\n * So the host path is never written here. It is read, once, to carry a\n * not-yet-migrated lead onto the org as a side effect of the next write that\n * touches it ({@link leadForWrite}) — which shrinks what AGL-3276's backfill\n * has left to fold, and means no read can be answered by a stale host row\n * after a write has moved on. AGL-3277 deletes the fallback and the rules\n * block behind it once that backfill reports nothing left to plan.\n */\n\n\n/**\n * The org collection a lead is written to. Always the org — see the\n * read-only note above.\n */\nexport async function orgLeadsForHost(\n hostId: string,\n): Promise<FirebaseFirestore.CollectionReference> {\n return orgDataCollectionForHost(hostId, 'leads')\n}\n\n/**\n * The leads one site may LIST, narrowed by `visibleTo`.\n *\n * The Admin SDK does not evaluate rules, so an unnarrowed read here would\n * serve one agency client's leads on another's site — the same reason\n * `scopedToHost` exists for every other org-owned collection.\n */\nexport async function orgLeadsQueryForHost(hostId: string): Promise<{\n ref: FirebaseFirestore.CollectionReference\n query: FirebaseFirestore.Query\n}> {\n const ref = await orgLeadsForHost(hostId)\n return { ref, query: scopedToHost(ref, hostId) }\n}\n\n/**\n * The tokens a reader holding this group may ask `array-contains-any` for.\n * Capped at 30 by `crmReadTokens`, as the contacts list is.\n */\nexport async function leadReadTokensForHost(\n hostId: string,\n group?: ConsentGroup,\n): Promise<ScopeToken[]> {\n return crmReadTokens(group ?? (await consentGroupForSite(hostId)))\n}\n\n/**\n * The `visibleTo` a capture on this site stamps: the consent group's sites,\n * or `['org']` where the org set `defaultResourceScope`.\n *\n * Widened by the capture, never by the lookup — a site that has never\n * captured this person gains nothing by finding them, which is what keeps an\n * agency's clients apart on a record they share. Callers UPDATING an\n * existing lead union these in rather than replacing, exactly as\n * `upsert-contact` does.\n */\nexport async function leadScopeForHost(\n hostId: string,\n org?: Record<string, unknown> | null,\n): Promise<ScopeToken[]> {\n const group = await consentGroupForSite(hostId, org)\n return crmScopeTokens(org ?? null, group)\n}\n\n/**\n * The lead for an address, wherever it currently lives.\n *\n * ## The lookup is UNSCOPED, and has to be\n *\n * One human who touched two sibling brands is one person. Narrowing this to\n * what the capturing site may already see would let a second submission on a\n * sibling brand mint a SECOND record for the same address — the duplication\n * this migration exists to end, reintroduced one layer up. Recognizing\n * somebody and being allowed to read their row are different acts:\n * this finds the person, {@link leadScopeForHost} decides who may see them.\n *\n * `upsert-contact` states the same rule at its own dedupe lookup, and for the\n * same reason. A caller that wants only what a site may SEE wants\n * {@link orgLeadsQueryForHost}.\n *\n * @returns the org row; else the not-yet-migrated host row; else `null`.\n */\nexport async function readLeadForHost(\n hostId: string,\n key: string,\n): Promise<FirebaseFirestore.DocumentSnapshot | null> {\n const orgRow = await (await orgLeadsForHost(hostId)).doc(key).get()\n return orgRow.exists ? orgRow : null\n}\n\n/**\n * The ref a write should target.\n *\n * This carried a not-yet-backfilled host row onto the org first, and reported\n * whether it had (`carried`). AGL-3276 emptied the host path and AGL-3277\n * removed it, so there is nothing left to carry: the org row is the only row.\n * `carried` stays on the shape, always `false`, because several callers\n * destructure it and a lie is cheaper to read than a signature change that\n * says nothing.\n */\nexport async function leadForWrite(\n hostId: string,\n key: string,\n): Promise<{\n ref: FirebaseFirestore.DocumentReference\n existed: boolean\n carried: boolean\n}> {\n const ref = (await orgLeadsForHost(hostId)).doc(key)\n return { ref, existed: (await ref.get()).exists, carried: false }\n}\n\n\n/**\n * Make a tripped ceiling OBSERVABLE rather than a silent drop — the * standing rule that a control which is not visible in the console does not\n * count as shipped.\n *\n * Two audiences, one call, in exactly `recordAbuseCeilingTrip`'s shape\n * (`libs/plugins/forms/src/lib/server/form-submit.ts`, AGL-1655):\n *\n * - A durable per-month refusal count at\n * `hosts/{id}/counters/{siteMembers|leads}Refused`. Counters are excluded\n * from every client write in `cloud/firebase-firestore.rules` (AGL-1367),\n * so the record cannot be edited away by the site it describes, and host\n * admins can already READ it — which is what lets the inbox console page\n * render it with no rules change.\n * - One notification to the site's managers per CROSSING (AGL-3442): on the\n * first refusal after the site reaches the ceiling, and not again while it\n * stays there. A notification per refused bot request would be the flood\n * again, delivered, and one a month would repeat an alarm about a state a\n * site can sit in for months. {@link rearmVisitorRecordCeilingNotice}\n * re-arms it once the site is seen below the ceiling, so reaching it again\n * is announced again.\n *\n * The counter is month-keyed even though the CEILING is a total, because the\n * two facts are different: the ceiling is \"how many records exist\" and this is\n * \"how many were turned away recently\". The inbox reports the second; the\n * notice announces the first, which is why it follows the crossing and not\n * the month.\n *\n * Best-effort throughout: bookkeeping that failed must never turn a contained\n * refusal into a 500, because a 500 is an invitation to retry.\n */\nexport async function recordVisitorRecordCeilingTrip(options: {\n hostRef: FirebaseFirestore.DocumentReference\n hostId: string\n kind: VisitorRecordKind\n ceiling: number\n monthKey?: string\n}): Promise<void> {\n const { hostRef, hostId, kind, ceiling } = options\n const monthKey = options.monthKey ?? utcMonthKey()\n try {\n const refusedRef = hostRef\n .collection('counters')\n .doc(visitorRecordRefusedCounterId(kind))\n const refusedSnapshot = await refusedRef.get()\n // Announced already for this crossing, by this writer or by the\n // month-scoped one before it — see `visitorRecordCeilingAnnounced`.\n const announced = visitorRecordCeilingAnnounced(refusedSnapshot.data())\n const now = Date.now()\n await refusedRef.set(\n {\n [monthKey]: FieldValue.increment(1),\n // Explicit values only — Firestore rejects `undefined`.\n ceiling,\n lastRefusedAtMs: now,\n // Marked in the same write as the refusal, so the next refusal reads\n // the crossing as announced without a write of its own.\n ...(announced ? {} : { [VISITOR_RECORD_NOTICE_SENT_FIELD]: now }),\n },\n { merge: true },\n )\n if (!announced) {\n const leads = kind === 'leads'\n /*\n * What is refused, said exactly (AGL-3432). A lead door — a form\n * routed to leads, a booking — stores its own record BEFORE it files\n * the lead, so at the ceiling the visitor's submission or booking is\n * still kept and only the new lead is not. A member sign-up is refused\n * whole: no account is created. `{site}` is the site's name, filled by\n * `notifyHostManagers` from the host doc it already reads.\n */\n await notifyHostManagers(hostId, {\n type: 'system.visitorRecordsPaused',\n title: leads\n ? 'Lead capture paused on {site} — platform limit reached'\n : 'Sign-ups paused on {site} — platform limit reached',\n body: leads\n ? `{site} holds ${ceiling.toLocaleString()} leads, the platform ` +\n 'safety limit, so new people are no longer added as leads. What ' +\n 'they send, such as a form submission or a booking, is still ' +\n 'saved; only the new lead is not. This limit is not part of your ' +\n 'plan — every plan includes unlimited leads — and nothing is ' +\n 'charged. Remove some leads, or contact support if this is real ' +\n 'traffic.'\n : `{site} has ${ceiling.toLocaleString()} member accounts, the ` +\n 'platform safety limit, so new sign-ups are refused and no ' +\n 'account is created. This limit is not part of your plan — every ' +\n 'plan includes unlimited member accounts — and nothing is ' +\n 'charged. Remove some members, or contact support if this is ' +\n 'real traffic.',\n link: `/${hostId}/inbox`,\n })\n }\n } catch (error) {\n console.error('visitor record ceiling bookkeeping failed', error)\n }\n}\n\n/**\n * Re-arm the ceiling notice when an accepted record shows the site was below\n * the ceiling (AGL-3442).\n *\n * Called by every writer that judges a new record against the ceiling, with\n * the count it judged. It does nothing unless the record it just accepted took\n * the site's LAST free slot: a site that dropped below the ceiling can reach\n * it again only through that accept, so it is where the re-arm is observed,\n * and every other accept pays nothing for it. That accept reads the refusal\n * counter, and clears the notice marker only when the previous crossing was\n * announced, so a site that never reached the ceiling gains no document.\n *\n * Best-effort and never throws, for the reason the trip's bookkeeping is: the\n * record is already stored, and a failure here only costs a notice.\n */\nexport async function rearmVisitorRecordCeilingNotice(options: {\n hostRef: FirebaseFirestore.DocumentReference\n kind: VisitorRecordKind\n /** The live count the accept was judged against, before its own record. */\n used: number\n ceiling: number\n}): Promise<void> {\n const { hostRef, kind, used, ceiling } = options\n if (!visitorRecordAcceptFillsCeiling(used, ceiling)) return\n try {\n const refusedRef = hostRef\n .collection('counters')\n .doc(visitorRecordRefusedCounterId(kind))\n const refusedSnapshot = await refusedRef.get()\n if (!visitorRecordCeilingAnnounced(refusedSnapshot.data())) return\n await refusedRef.set(\n { [VISITOR_RECORD_NOTICE_SENT_FIELD]: null },\n { merge: true },\n )\n } catch (error) {\n console.error('visitor record ceiling re-arm failed', error)\n }\n}\n\n/** What a caller wants stored on the lead. `createdAt` is stamped here. */\nexport interface HostLeadInput {\n email: string\n /** The name the person typed, when they typed one (AGL-2303). */\n name?: string\n /** `signup`, `booking`, `form:{formId}` — the surface that produced it. */\n source: string\n /**\n * Explicit marketing opt-in, with a consent timestamp — the same shape\n * `upsertHostContact` already carries (AGL-301). Omitted or `false` writes\n * nothing: a lead is a side effect of an action the visitor DID take\n * (sign up, book), which is not by itself consent to be emailed\n * marketing, so this is only set when the caller captured an explicit\n * checkbox.\n */\n marketingConsent?: boolean\n /**\n * The `consentGroupDisclosureKey` the capture surface rendered beside that\n * checkbox (AGL-3320). The grant pools across the site's consent group only\n * when it is the group's current key, and is this site's alone otherwise —\n * the same rule `upsertHostContact` applies.\n */\n disclosedConsentGroup?: string | null\n /**\n * A phone the capture learned (AGL-3493), E.164 as the door normalized\n * it. Written only when the lead holds none: a number a rep typed on the\n * lead, or an earlier capture brought, is not replaced by this one.\n */\n phoneFill?: string\n}\n\n/**\n * Record one lead at `hosts/{hostId}/leads/{personKey}`, bounded by\n * `LEADS_MAX_PER_HOST` (AGL-1529).\n *\n * ## One person is one document\n *\n * `docs/specs/reusable-forms.md` §4b. This used to be `tx.create(ref.doc())`\n * — an auto-id per capture event — so one returning customer who signed up\n * and booked twice was three \"leads\". The Members & leads tab presented a\n * list of events as a list of people, and the only thing holding two rows for\n * one person together was string equality on the address at render time.\n *\n * The events are not lost, they are just no longer the record: `sources`\n * carries every surface that produced a capture, `submissionCount` counts\n * them, and `firstSeenAtMs`/`lastSeenAtMs` bracket them. The submissions, the\n * bookings and the member document are still there and still one row each.\n *\n * The id is {@link personKey} — `sha256(normalizeContactEmail(email))`, the\n * SAME derivation a list membership's `memberKey` uses, imported rather than\n * restated. Two specs named this function and the rule both stated is that\n * whichever ships second imports the first's helper: a second copy is how\n * `emailSuppressionKey` and `suppressionId` came to disagree.\n *\n * ## Why every lead writer goes through here\n *\n * There are three lead writes in the repo — the sign-up handler and the two\n * bookings paths — and all three were `hostRef.collection('leads').add(…)`\n * with a `.catch()` on the end. A cap enforced at two of three call sites is\n * not a cap, and the fourth writer somebody adds next month would not have\n * one either. One function is the only shape that survives that.\n *\n * ## The count is evaluated INSIDE the transaction that writes\n *\n * A create-time quota can be laundered by WHEN it is evaluated, not by the\n * counting rule (AGL-2231/2265/2266). Read-then-decide-then-`add()` lets N\n * concurrent visitors each read the same pre-count, each find room, and each\n * land — and nothing re-counts afterwards, so the extra rows are permanent.\n * `Transaction.get(AggregateQuery)` serialises the count against a concurrent\n * create into the same collection: the loser retries, re-reads the higher\n * count, and is refused. The count is of LIVE documents, so triaging leads in\n * the inbox frees the slots.\n *\n * ## Refusing a lead never fails the visitor's action\n *\n * A lead is a SIDE EFFECT — of a sign-up, or of a booking. The visitor did\n * not ask for it and cannot see it, so refusing one must not refuse the thing\n * they did ask for. This returns a boolean and never throws; the trip is\n * recorded and the caller carries on. That is the honest split, and it is why\n * `SITE_MEMBERS_MAX_PER_HOST` (which governs an action a visitor DID take) is\n * enforced with a 429 in the sign-up handler and this is not.\n */\nexport async function addHostLeadOutcome(options: {\n hostRef: FirebaseFirestore.DocumentReference\n hostId: string\n lead: HostLeadInput\n /**\n * The ceiling to compare against. Defaults to `LEADS_MAX_PER_HOST` and is\n * passed by NOTHING in production — it exists so the suite can re-drive the\n * same count against a ceiling one higher and require the write to succeed.\n * A refusal that survives its own ceiling being raised was never that\n * ceiling's refusal, and there is exactly ONE comparison below, so knifing\n * it cannot be absorbed by a fallback branch.\n */\n ceiling?: number\n /**\n * Where this person arrived from, already resolved by the door through the\n * plugin that credits outcomes (`plugin-conversion-credit`), and opaque\n * here.\n *\n * Resolved rather than raw, and passed rather than looked up, because one\n * visitor action reaches several writers: a form submission that creates a\n * submission, a contact AND a lead must pay for the touch lookup once. A\n * door that hands none — every order path, every import — attributes\n * nothing, which is how a lead that no campaign caused stays uncredited.\n */\n touch?: PluginConversionTouch | null\n}): Promise<HostLeadOutcome> {\n const { hostRef, hostId, lead } = options\n const maxPerHost = options.ceiling ?? LEADS_MAX_PER_HOST\n try {\n const leadsRef = await orgLeadsForHost(hostId)\n const firestore = hostRef.firestore\n /*\n * `null` for anything that is not a usable address — a lead captured\n * against a malformed one keeps an auto-id and stays its own row. Keying\n * several unusable addresses under one guessed id would merge two\n * different people, which is worse than two rows for one.\n */\n const key = personKey(lead.email)\n /*\n * A lead the org has not taken over yet is carried across BEFORE the\n * transaction opens (AGL-3275). It has to happen outside: the carry is\n * itself a read-then-write, and a transaction that also counted the\n * collection would be reading a row it was about to create. Two doors\n * carrying the same person in the same second both write the legacy\n * fields under `merge`, so the race is a no-op rather than a conflict.\n */\n const leadRef = key\n ? (await leadForWrite(hostId, key)).ref\n : leadsRef.doc()\n /*\n * The scope this capture stamps, and the group whose terms its consent is\n * recorded under. Both resolved HERE rather than in the transaction body:\n * a contended transaction re-runs that body, and neither of these can\n * change between attempts, so resolving them inside would pay for the org\n * read again on every retry.\n */\n const scope = await leadScopeForHost(hostId)\n const group = await consentGroupForSite(hostId)\n const now = Date.now()\n const seen = {\n // `arrayUnion`, so a person who books twice has `['booking']` and one\n // who signed up and then submitted a form has both. Bounded by the\n // number of surfaces, not by the number of captures.\n sources: FieldValue.arrayUnion(lead.source),\n lastSeenAtMs: now,\n submissionCount: FieldValue.increment(1),\n ...(lead.name ? { name: lead.name } : {}),\n }\n let created = false\n let sourceAdded = false\n let predicted = false\n // The count a new person was judged against, for the notice re-arm.\n let usedBeforeCreate: number | null = null\n const refused = await firestore.runTransaction(async (tx) => {\n // Reset per attempt: a contended transaction re-runs its body, and a\n // flag left standing from an aborted attempt would credit a campaign\n // with a person who turned out to exist already.\n created = false\n sourceAdded = false\n usedBeforeCreate = null\n // ALL READS BEFORE THE WRITE, which Firestore requires.\n const existing = await tx.get(leadRef)\n // Whether the lead already reads \"Would bounce\", for the check below.\n predicted = (existing.data() ?? {})['emailStatus'] === 'undeliverable'\n /*\n * ⛔ THE CEILING GATES A NEW PERSON, NEVER AN EXISTING ONE.\n *\n * A returning visitor's capture is an UPDATE — it does not grow the\n * collection, so refusing it buys no capacity and costs the customer\n * the source and the timestamp they would have learned. That is the\n * enforcement-at-use shape the capacity rule exists to forbid: a limit\n * must refuse the addition, never a person already recorded or the\n * data attached to them.\n *\n * It also means the count is only paid on a genuinely new person,\n * which is the case that can move it.\n */\n if (!existing.exists) {\n /*\n * COUNTED OVER WHAT THIS SITE MAY SEE, not over the org (AGL-3275).\n *\n * The collection is org-wide now, and `LEADS_MAX_PER_HOST` is a\n * per-SITE ceiling. An unfiltered count would charge every brand in\n * an agency's account for every other brand's leads, and the first\n * client to fill its allowance would refuse captures on sites that\n * had taken none. `scopedToHost` is the same narrowing every other\n * org-owned read uses, so a single-site org counts exactly what it\n * counted before this moved.\n *\n * A lead shared by two brands in one consent group is counted by\n * both, which is the honest answer: each of them holds it.\n */\n const used = (\n await tx.get(scopedToHost(leadsRef, hostId).count())\n ).data().count\n if (checkVisitorRecordCeiling(used, maxPerHost).exceeded) return true\n usedBeforeCreate = used\n }\n /*\n * Consent is carried forward and never cleared.\n *\n * A basis is written only when this capture carried an explicit\n * opt-in, so a later booking by someone who did not tick the box\n * leaves an earlier grant standing — absent-or-granted, the shape\n * every other writer uses. The TIMESTAMP is carried over rather than\n * restamped, for the reason given at the read below.\n */\n /*\n * The EARLIEST grant is the one that happened, so a later capture\n * carrying the same checkbox keeps the original date rather than\n * restamping when this person opted in. Read back through the shared\n * reader so \"already consented\" means the same thing here as it does\n * at send time.\n */\n /*\n * THE SITE'S REAL GROUP, because the silo that justified a group of one\n * is gone (AGL-3275).\n *\n * This read `soloConsentGroup(hostId)` for as long as a lead lived at\n * `hosts/{hostId}/leads`, and the reason was the path: private by\n * construction, so pooling a lead's basis would have recorded a\n * disclosure that reached nothing, while the contact written by the\n * same capture door was org-shared and pooled.\n *\n * A lead is now org-shared on exactly the terms the contact is, so the\n * premise is false and keeping the group of one would leave a\n * multi-brand org holding a lead its sibling brand can SEE but may not\n * MAIL — a narrower basis than the one `consentGroupDisclosure` named\n * beside the checkbox this person ticked. Pooling here records what\n * they were actually told, and only that: the grant reaches the group\n * when the capture surface sent back the group's current disclosure\n * key (AGL-3320), and this site alone otherwise. An undeclared group is\n * still a group of one, so an agency is unchanged and configures\n * nothing.\n *\n * Resolved above the transaction — see `group`'s declaration.\n */\n const prior = readMarketingBasis(existing.data() ?? null, group)\n const consentAtMs =\n prior.basis === 'granted' && prior.basisAtMs !== null\n ? prior.basisAtMs\n : now\n created = !existing.exists\n /*\n * Whether this capture puts its surface on the person for the FIRST\n * time: a new lead, or one first met through another door. A form\n * counts its leads by this (AGL-3330) — the people it filed, which is\n * what a recount of `sources` finds — so a returning visitor's second\n * submission moves no form's lead count.\n */\n const storedSources = (existing.data() ?? {})['sources']\n sourceAdded =\n created || !(Array.isArray(storedSources) && storedSources.includes(lead.source))\n /*\n * THE FIELDS THE LEADS LIST QUERIES (AGL-3321), from the lead as it\n * will stand: what the transaction read, the address and name this\n * capture brings and the scope it widens. A new lead is written\n * `status: 'new'`, so the Open view's `status in [new, working]`\n * finds a lead nobody has touched — an absent field matches no query.\n */\n const stored = existing.data() ?? {}\n const phoneFill =\n lead.phoneFill && !String(stored['phone'] ?? '').trim()\n ? { phone: lead.phoneFill }\n : {}\n const storedScope = Array.isArray(stored['visibleTo']) ? (stored['visibleTo'] as unknown[]) : []\n /*\n * A lead that keeps a first or last name keeps the name they make\n * (AGL-3513): a capture's typed name does not replace it.\n */\n const composedName = composeContactName(stored['firstName'], stored['lastName'])\n const name = composedName || lead.name\n const listFields = crmLeadListFields({\n ...stored,\n email: lead.email,\n ...(name ? { name } : {}),\n visibleTo: [...new Set([...storedScope, ...scope])],\n })\n tx.set(\n leadRef,\n {\n email: lead.email,\n ...seen,\n ...(composedName ? { name: composedName } : {}),\n ...phoneFill,\n ...listFields,\n /*\n * WIDENED BY THE CAPTURE, NEVER BY THE LOOKUP (AGL-3275).\n *\n * `arrayUnion` rather than a replace, exactly as `upsert-contact`\n * stamps a contact: this site just collected this person, so it may\n * see the row. A site that merely FOUND them — the unscoped dedupe\n * lookup in `readLeadForHost` — gains nothing, which is what keeps\n * an agency's clients apart on a record they share.\n */\n visibleTo: FieldValue.arrayUnion(...scope),\n /*\n * EVERY SITE THAT CAPTURED THIS PERSON, not just the first\n * (AGL-3275).\n *\n * This was written once, on create, and that was sound while the\n * collection sat under one host and could hold only that host's\n * name. On the org the row is shared, so a sibling brand capturing\n * a person the first brand already holds has to be recorded here or\n * the \"Known by\" answer silently omits it — the same `arrayUnion`\n * the contact door has always used for this field, now that a lead\n * has the same question to answer.\n */\n [CAPTURED_BY_HOST_FIELD]: FieldValue.arrayUnion(hostId),\n ...(existing.exists\n ? {}\n : {\n firstSeenAtMs: now,\n createdAt: FieldValue.serverTimestamp(),\n }),\n /*\n * The basis is recorded under the GROUP, keyed by its host, because\n * {@link readMarketingBasis} is one function over four silos and a\n * silo whose basis lived somewhere else would need the reader to\n * know which collection it was handed. A reader that has to be told\n * the shape is a reader that can be told the wrong one.\n */\n ...(lead.marketingConsent\n ? marketingConsentFieldsForGroup(\n consentGroupForGrant(group, lead.disclosedConsentGroup),\n consentAtMs,\n )\n : {}),\n },\n { merge: true },\n )\n return false\n })\n if (refused) {\n await recordVisitorRecordCeilingTrip({\n hostRef,\n hostId,\n kind: 'leads',\n ceiling: maxPerHost,\n })\n return NOT_STORED\n }\n if (created && usedBeforeCreate !== null) {\n await rearmVisitorRecordCeilingNotice({\n hostRef,\n kind: 'leads',\n used: usedBeforeCreate,\n ceiling: maxPerHost,\n })\n }\n /*\n * ATTRIBUTED ON CREATION ONLY.\n *\n * A returning visitor's capture is an update — the campaign did not\n * produce a lead, it produced another visit by a person the site already\n * held — and crediting it would let whichever campaign ran most recently\n * re-earn every lead on the list. `created` is set inside the transaction\n * that decides it, so the attribution and the write agree about whether\n * this person is new.\n *\n * Awaited rather than fired off: `addHostLead` already returns only after\n * its own write, and a caller that `void`s it (every one of them) is\n * unaffected. Never throws, so a failure here cannot cost the lead.\n */\n /*\n * THE ADDRESS'S DOMAIN, checked after the response (AGL-3328): a lead\n * whose domain takes no mail reads \"Would bounce\". Queued, never\n * awaited — the write above is the capture, and this costs it nothing.\n */\n scheduleCapturedEmailCheck({\n orgId: leadsRef.parent?.id ?? null,\n hostId,\n email: lead.email,\n predicted,\n })\n if (created && options.touch) {\n await creditConversion({\n hostId,\n kind: 'lead',\n refId: leadRef.id,\n touch: options.touch,\n convertedAtMs: now,\n })\n }\n return { stored: true, created, sourceAdded }\n } catch (error) {\n // Same posture the three original call sites had (`.catch(() => undefined)`\n // / `.catch(console.error)`): a lead that failed to store must not fail\n // the sign-up or the booking that produced it.\n console.error('lead write failed', error)\n return NOT_STORED\n }\n}\n\n/** What {@link addHostLeadOutcome} did with one capture. */\nexport interface HostLeadOutcome {\n /** Whether the lead was written — false when refused or failed. */\n stored: boolean\n /** Whether it was a new person. */\n created: boolean\n /** Whether this capture's `source` was new on the lead — see the write. */\n sourceAdded: boolean\n}\n\nconst NOT_STORED: HostLeadOutcome = { stored: false, created: false, sourceAdded: false }\n\n/**\n * {@link addHostLeadOutcome}, answering only whether the lead was stored —\n * the question every door but a form's asks.\n */\nexport async function addHostLead(\n options: Parameters<typeof addHostLeadOutcome>[0],\n): Promise<boolean> {\n return (await addHostLeadOutcome(options)).stored\n}\n"],"names":["CAPTURED_BY_HOST_FIELD","checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","marketingConsentFieldsForGroup","personKey","readMarketingBasis","utcMonthKey","VISITOR_RECORD_NOTICE_SENT_FIELD","visitorRecordAcceptFillsCeiling","visitorRecordCeilingAnnounced","visitorRecordRefusedCounterId","FieldValue","creditConversion","notifyHostManagers","scheduleCapturedEmailCheck","firebaseAdmin","consentGroupForSite","orgDataCollectionForHost","scopedToHost","composeContactName","crmLeadListFields","crmReadTokens","crmScopeTokens","consentGroupForGrant","firestore","app","orgLeadsForHost","hostId","orgLeadsQueryForHost","ref","query","leadReadTokensForHost","group","leadScopeForHost","org","readLeadForHost","key","orgRow","doc","get","exists","leadForWrite","existed","carried","recordVisitorRecordCeilingTrip","options","hostRef","kind","ceiling","monthKey","refusedRef","collection","refusedSnapshot","announced","data","now","Date","set","increment","lastRefusedAtMs","merge","leads","type","title","body","toLocaleString","link","error","console","rearmVisitorRecordCeilingNotice","used","addHostLeadOutcome","lead","maxPerHost","leadsRef","email","leadRef","scope","seen","sources","arrayUnion","source","lastSeenAtMs","submissionCount","name","created","sourceAdded","predicted","usedBeforeCreate","refused","runTransaction","tx","existing","stored","count","exceeded","prior","consentAtMs","basis","basisAtMs","storedSources","Array","isArray","includes","phoneFill","String","trim","phone","storedScope","composedName","listFields","visibleTo","Set","firstSeenAtMs","createdAt","serverTimestamp","marketingConsent","disclosedConsentGroup","NOT_STORED","orgId","parent","id","touch","refId","convertedAtMs","addHostLead"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-visitor-records.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 durable half of the visitor-record ceilings (AGL-1529) — the lead\n * writer that enforces `LEADS_MAX_PER_HOST`, and the bookkeeping that makes a\n * trip of EITHER ceiling something a site's owner can see.\n *\n * The policy is pure and lives in `@aglyn/aglyn/server`\n * (`visitor-record-ceiling.ts`); this is the part that needs Firestore. Same\n * split as `visitorWriteRateLimitRefusal`, and for the same reason.\n */\n\nimport {\n CAPTURED_BY_HOST_FIELD,\n checkVisitorRecordCeiling,\n containerMembershipField,\n LEADS_MAX_PER_HOST,\n marketingConsentFieldsForGroup,\n normalizeContainerIds,\n personKey,\n readMarketingBasis,\n utcMonthKey,\n VISITOR_RECORD_NOTICE_SENT_FIELD,\n visitorRecordAcceptFillsCeiling,\n visitorRecordCeilingAnnounced,\n visitorRecordRefusedCounterId,\n type VisitorRecordKind,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n creditConversion,\n type PluginConversionTouch,\n} from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\nimport { notifyHostManagers } from './notifications'\nimport { scheduleCapturedEmailCheck } from './capture-email-check'\nimport firebaseAdmin from './firebase-admin'\nimport {\n consentGroupForSite,\n orgDataCollectionForHost,\n resolveOrgIdForHost,\n scopedToHost,\n} from './organizations'\nimport {\n composeContactName,\n crmLeadListFields,\n crmReadTokens,\n crmScopeTokens,\n} from '@aglyn/aglyn/server'\nimport type { ConsentGroup, ScopeToken } from '@aglyn/aglyn/server'\n// The module path rather than the barrel, as `upsert-contact.ts` does: a spec\n// that substitutes the barrel keeps the real rule for which group a grant is.\nimport { consentGroupForGrant } from '@aglyn/aglyn/app-utils/consent-groups'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/**\n * A lead's one home: `orgs/{orgId}/leads/{personKey}` (AGL-3275).\n *\n * ## Why leads moved\n *\n * A lead used to live at `hosts/{hostId}/leads/{personKey}` while the\n * contact for the same human lived on the org with a `visibleTo` array. The\n * path was doing the job `visibleTo` does, and doing it worse:\n *\n * - An AGENCY was isolated by the path. It is isolated by `visibleTo` too,\n * with no configuration — `consentGroupScope` on a group of one is\n * `['host:{id}']`.\n * - A MULTI-BRAND org could not be served at all. `personKey` is derived\n * from the address, so the same person captured on two sibling brands\n * produced two documents with byte-identical ids under different parents.\n * One person was two records by construction, which is the thing AGL-3232\n * had just finished eliminating for contacts.\n * - The two answers could disagree. A person's contact was visible to every\n * brand in the consent group they actually consented to — the disclosure\n * names those brands — while their lead was visible to whichever brand's\n * form they happened to land on. The lead was scoped more narrowly than\n * what the person was told, which is the safe direction to be wrong in and\n * still the wrong answer.\n *\n * So a lead is stamped with `crmScopeTokens` and read with `crmReadTokens`,\n * the same pair the contacts surface uses. Nothing here is a new mechanism;\n * this is leads arriving at the one the rest of the CRM already had.\n *\n * ## The fallback is READ-ONLY, and temporary\n *\n * This repo has run this migration once already — AGL-237 moved datasets,\n * contacts and media to the org, AGL-1040 backfilled, AGL-1050 deleted the\n * fallback — and `orgDataCollectionForHost` carries what it learned:\n *\n * \"a second storage path that can still be WRITTEN is a second boundary to\n * enforce forever, which undoes the premise of scoped sharing: one home\n * per resource plus an explicit scope.\"\n *\n * So the host path is never written here. It is read, once, to carry a\n * not-yet-migrated lead onto the org as a side effect of the next write that\n * touches it ({@link leadForWrite}) — which shrinks what AGL-3276's backfill\n * has left to fold, and means no read can be answered by a stale host row\n * after a write has moved on. AGL-3277 deletes the fallback and the rules\n * block behind it once that backfill reports nothing left to plan.\n */\n\n\n/**\n * The org collection a lead is written to. Always the org — see the\n * read-only note above.\n */\nexport async function orgLeadsForHost(\n hostId: string,\n): Promise<FirebaseFirestore.CollectionReference> {\n return orgDataCollectionForHost(hostId, 'leads')\n}\n\n/**\n * The leads one site may LIST, narrowed by `visibleTo`.\n *\n * The Admin SDK does not evaluate rules, so an unnarrowed read here would\n * serve one agency client's leads on another's site — the same reason\n * `scopedToHost` exists for every other org-owned collection.\n */\nexport async function orgLeadsQueryForHost(hostId: string): Promise<{\n ref: FirebaseFirestore.CollectionReference\n query: FirebaseFirestore.Query\n}> {\n const ref = await orgLeadsForHost(hostId)\n return { ref, query: scopedToHost(ref, hostId) }\n}\n\n/**\n * The tokens a reader holding this group may ask `array-contains-any` for.\n * Capped at 30 by `crmReadTokens`, as the contacts list is.\n */\nexport async function leadReadTokensForHost(\n hostId: string,\n group?: ConsentGroup,\n): Promise<ScopeToken[]> {\n return crmReadTokens(group ?? (await consentGroupForSite(hostId)))\n}\n\n/**\n * The `visibleTo` a capture on this site stamps: the consent group's sites,\n * or `['org']` where the org set `defaultResourceScope`.\n *\n * Widened by the capture, never by the lookup — a site that has never\n * captured this person gains nothing by finding them, which is what keeps an\n * agency's clients apart on a record they share. Callers UPDATING an\n * existing lead union these in rather than replacing, exactly as\n * `upsert-contact` does.\n */\nexport async function leadScopeForHost(\n hostId: string,\n org?: Record<string, unknown> | null,\n): Promise<ScopeToken[]> {\n const group = await consentGroupForSite(hostId, org)\n return crmScopeTokens(org ?? null, group)\n}\n\n/**\n * The lead for an address, wherever it currently lives.\n *\n * ## The lookup is UNSCOPED, and has to be\n *\n * One human who touched two sibling brands is one person. Narrowing this to\n * what the capturing site may already see would let a second submission on a\n * sibling brand mint a SECOND record for the same address — the duplication\n * this migration exists to end, reintroduced one layer up. Recognizing\n * somebody and being allowed to read their row are different acts:\n * this finds the person, {@link leadScopeForHost} decides who may see them.\n *\n * `upsert-contact` states the same rule at its own dedupe lookup, and for the\n * same reason. A caller that wants only what a site may SEE wants\n * {@link orgLeadsQueryForHost}.\n *\n * @returns the org row; else the not-yet-migrated host row; else `null`.\n */\nexport async function readLeadForHost(\n hostId: string,\n key: string,\n): Promise<FirebaseFirestore.DocumentSnapshot | null> {\n const orgRow = await (await orgLeadsForHost(hostId)).doc(key).get()\n return orgRow.exists ? orgRow : null\n}\n\n/**\n * The ref a write should target.\n *\n * This carried a not-yet-backfilled host row onto the org first, and reported\n * whether it had (`carried`). AGL-3276 emptied the host path and AGL-3277\n * removed it, so there is nothing left to carry: the org row is the only row.\n * `carried` stays on the shape, always `false`, because several callers\n * destructure it and a lie is cheaper to read than a signature change that\n * says nothing.\n */\nexport async function leadForWrite(\n hostId: string,\n key: string,\n): Promise<{\n ref: FirebaseFirestore.DocumentReference\n existed: boolean\n carried: boolean\n}> {\n const ref = (await orgLeadsForHost(hostId)).doc(key)\n return { ref, existed: (await ref.get()).exists, carried: false }\n}\n\n\n/**\n * Make a tripped ceiling OBSERVABLE rather than a silent drop — the * standing rule that a control which is not visible in the console does not\n * count as shipped.\n *\n * Two audiences, one call, in exactly `recordAbuseCeilingTrip`'s shape\n * (`libs/plugins/forms/src/lib/server/form-submit.ts`, AGL-1655):\n *\n * - A durable per-month refusal count at\n * `hosts/{id}/counters/{siteMembers|leads}Refused`. Counters are excluded\n * from every client write in `cloud/firebase-firestore.rules` (AGL-1367),\n * so the record cannot be edited away by the site it describes, and host\n * admins can already READ it — which is what lets the inbox console page\n * render it with no rules change.\n * - One notification to the site's managers per CROSSING (AGL-3442): on the\n * first refusal after the site reaches the ceiling, and not again while it\n * stays there. A notification per refused bot request would be the flood\n * again, delivered, and one a month would repeat an alarm about a state a\n * site can sit in for months. {@link rearmVisitorRecordCeilingNotice}\n * re-arms it once the site is seen below the ceiling, so reaching it again\n * is announced again.\n *\n * The counter is month-keyed even though the CEILING is a total, because the\n * two facts are different: the ceiling is \"how many records exist\" and this is\n * \"how many were turned away recently\". The inbox reports the second; the\n * notice announces the first, which is why it follows the crossing and not\n * the month.\n *\n * Best-effort throughout: bookkeeping that failed must never turn a contained\n * refusal into a 500, because a 500 is an invitation to retry.\n */\nexport async function recordVisitorRecordCeilingTrip(options: {\n hostRef: FirebaseFirestore.DocumentReference\n hostId: string\n kind: VisitorRecordKind\n ceiling: number\n monthKey?: string\n}): Promise<void> {\n const { hostRef, hostId, kind, ceiling } = options\n const monthKey = options.monthKey ?? utcMonthKey()\n try {\n const refusedRef = hostRef\n .collection('counters')\n .doc(visitorRecordRefusedCounterId(kind))\n const refusedSnapshot = await refusedRef.get()\n // Announced already for this crossing, by this writer or by the\n // month-scoped one before it — see `visitorRecordCeilingAnnounced`.\n const announced = visitorRecordCeilingAnnounced(refusedSnapshot.data())\n const now = Date.now()\n await refusedRef.set(\n {\n [monthKey]: FieldValue.increment(1),\n // Explicit values only — Firestore rejects `undefined`.\n ceiling,\n lastRefusedAtMs: now,\n // Marked in the same write as the refusal, so the next refusal reads\n // the crossing as announced without a write of its own.\n ...(announced ? {} : { [VISITOR_RECORD_NOTICE_SENT_FIELD]: now }),\n },\n { merge: true },\n )\n if (!announced) {\n const leads = kind === 'leads'\n /*\n * What is refused, said exactly (AGL-3432). A lead door — a form\n * routed to leads, a booking — stores its own record BEFORE it files\n * the lead, so at the ceiling the visitor's submission or booking is\n * still kept and only the new lead is not. A member sign-up is refused\n * whole: no account is created. `{site}` is the site's name, filled by\n * `notifyHostManagers` from the host doc it already reads.\n */\n await notifyHostManagers(hostId, {\n type: 'system.visitorRecordsPaused',\n title: leads\n ? 'Lead capture paused on {site} — platform limit reached'\n : 'Sign-ups paused on {site} — platform limit reached',\n body: leads\n ? `{site} holds ${ceiling.toLocaleString()} leads, the platform ` +\n 'safety limit, so new people are no longer added as leads. What ' +\n 'they send, such as a form submission or a booking, is still ' +\n 'saved; only the new lead is not. This limit is not part of your ' +\n 'plan — every plan includes unlimited leads — and nothing is ' +\n 'charged. Remove some leads, or contact support if this is real ' +\n 'traffic.'\n : `{site} has ${ceiling.toLocaleString()} member accounts, the ` +\n 'platform safety limit, so new sign-ups are refused and no ' +\n 'account is created. This limit is not part of your plan — every ' +\n 'plan includes unlimited member accounts — and nothing is ' +\n 'charged. Remove some members, or contact support if this is ' +\n 'real traffic.',\n link: `/${hostId}/inbox`,\n })\n }\n } catch (error) {\n console.error('visitor record ceiling bookkeeping failed', error)\n }\n}\n\n/**\n * Re-arm the ceiling notice when an accepted record shows the site was below\n * the ceiling (AGL-3442).\n *\n * Called by every writer that judges a new record against the ceiling, with\n * the count it judged. It does nothing unless the record it just accepted took\n * the site's LAST free slot: a site that dropped below the ceiling can reach\n * it again only through that accept, so it is where the re-arm is observed,\n * and every other accept pays nothing for it. That accept reads the refusal\n * counter, and clears the notice marker only when the previous crossing was\n * announced, so a site that never reached the ceiling gains no document.\n *\n * Best-effort and never throws, for the reason the trip's bookkeeping is: the\n * record is already stored, and a failure here only costs a notice.\n */\nexport async function rearmVisitorRecordCeilingNotice(options: {\n hostRef: FirebaseFirestore.DocumentReference\n kind: VisitorRecordKind\n /** The live count the accept was judged against, before its own record. */\n used: number\n ceiling: number\n}): Promise<void> {\n const { hostRef, kind, used, ceiling } = options\n if (!visitorRecordAcceptFillsCeiling(used, ceiling)) return\n try {\n const refusedRef = hostRef\n .collection('counters')\n .doc(visitorRecordRefusedCounterId(kind))\n const refusedSnapshot = await refusedRef.get()\n if (!visitorRecordCeilingAnnounced(refusedSnapshot.data())) return\n await refusedRef.set(\n { [VISITOR_RECORD_NOTICE_SENT_FIELD]: null },\n { merge: true },\n )\n } catch (error) {\n console.error('visitor record ceiling re-arm failed', error)\n }\n}\n\n/** What a caller wants stored on the lead. `createdAt` is stamped here. */\nexport interface HostLeadInput {\n email: string\n /** The name the person typed, when they typed one (AGL-2303). */\n name?: string\n /** `signup`, `booking`, `form:{formId}` — the surface that produced it. */\n source: string\n /**\n * Explicit marketing opt-in, with a consent timestamp — the same shape\n * `upsertHostContact` already carries (AGL-301). Omitted or `false` writes\n * nothing: a lead is a side effect of an action the visitor DID take\n * (sign up, book), which is not by itself consent to be emailed\n * marketing, so this is only set when the caller captured an explicit\n * checkbox.\n */\n marketingConsent?: boolean\n /**\n * The `consentGroupDisclosureKey` the capture surface rendered beside that\n * checkbox (AGL-3320). The grant pools across the site's consent group only\n * when it is the group's current key, and is this site's alone otherwise —\n * the same rule `upsertHostContact` applies.\n */\n disclosedConsentGroup?: string | null\n /**\n * The campaigns the capture SURFACE is filed under (AGL-3458) — a form's\n * `campaignIds`, read by the door off the verified form document. Never the\n * campaign touch, which is where the visitor came from: this is which\n * campaigns the merchant put the form in, and it is true of everybody who\n * fills that form in.\n *\n * Unioned into the lead's own `campaignIds` — the field the enroll route\n * and the bulk bar write — so a campaign the lead joined by hand or by a\n * sequence is kept, and the Leads list's Campaign filter\n * (`scopedCampaignIds`) is restamped from the union in the same write.\n */\n campaignIds?: readonly string[]\n /**\n * A phone the capture learned (AGL-3493), E.164 as the door normalized\n * it. Written only when the lead holds none: a number a rep typed on the\n * lead, or an earlier capture brought, is not replaced by this one.\n */\n phoneFill?: string\n}\n\n/**\n * Record one lead at `hosts/{hostId}/leads/{personKey}`, bounded by\n * `LEADS_MAX_PER_HOST` (AGL-1529).\n *\n * ## One person is one document\n *\n * `docs/specs/reusable-forms.md` §4b. This used to be `tx.create(ref.doc())`\n * — an auto-id per capture event — so one returning customer who signed up\n * and booked twice was three \"leads\". The Members & leads tab presented a\n * list of events as a list of people, and the only thing holding two rows for\n * one person together was string equality on the address at render time.\n *\n * The events are not lost, they are just no longer the record: `sources`\n * carries every surface that produced a capture, `submissionCount` counts\n * them, and `firstSeenAtMs`/`lastSeenAtMs` bracket them. The submissions, the\n * bookings and the member document are still there and still one row each.\n *\n * The id is {@link personKey} — `sha256(normalizeContactEmail(email))`, the\n * SAME derivation a list membership's `memberKey` uses, imported rather than\n * restated. Two specs named this function and the rule both stated is that\n * whichever ships second imports the first's helper: a second copy is how\n * `emailSuppressionKey` and `suppressionId` came to disagree.\n *\n * ## Why every lead writer goes through here\n *\n * There are three lead writes in the repo — the sign-up handler and the two\n * bookings paths — and all three were `hostRef.collection('leads').add(…)`\n * with a `.catch()` on the end. A cap enforced at two of three call sites is\n * not a cap, and the fourth writer somebody adds next month would not have\n * one either. One function is the only shape that survives that.\n *\n * ## The count is evaluated INSIDE the transaction that writes\n *\n * A create-time quota can be laundered by WHEN it is evaluated, not by the\n * counting rule (AGL-2231/2265/2266). Read-then-decide-then-`add()` lets N\n * concurrent visitors each read the same pre-count, each find room, and each\n * land — and nothing re-counts afterwards, so the extra rows are permanent.\n * `Transaction.get(AggregateQuery)` serialises the count against a concurrent\n * create into the same collection: the loser retries, re-reads the higher\n * count, and is refused. The count is of LIVE documents, so triaging leads in\n * the inbox frees the slots.\n *\n * ## Refusing a lead never fails the visitor's action\n *\n * A lead is a SIDE EFFECT — of a sign-up, or of a booking. The visitor did\n * not ask for it and cannot see it, so refusing one must not refuse the thing\n * they did ask for. This returns a boolean and never throws; the trip is\n * recorded and the caller carries on. That is the honest split, and it is why\n * `SITE_MEMBERS_MAX_PER_HOST` (which governs an action a visitor DID take) is\n * enforced with a 429 in the sign-up handler and this is not.\n */\nexport async function addHostLeadOutcome(options: {\n hostRef: FirebaseFirestore.DocumentReference\n hostId: string\n lead: HostLeadInput\n /**\n * The ceiling to compare against. Defaults to `LEADS_MAX_PER_HOST` and is\n * passed by NOTHING in production — it exists so the suite can re-drive the\n * same count against a ceiling one higher and require the write to succeed.\n * A refusal that survives its own ceiling being raised was never that\n * ceiling's refusal, and there is exactly ONE comparison below, so knifing\n * it cannot be absorbed by a fallback branch.\n */\n ceiling?: number\n /**\n * Where this person arrived from, already resolved by the door through the\n * plugin that credits outcomes (`plugin-conversion-credit`), and opaque\n * here.\n *\n * Resolved rather than raw, and passed rather than looked up, because one\n * visitor action reaches several writers: a form submission that creates a\n * submission, a contact AND a lead must pay for the touch lookup once. A\n * door that hands none — every order path, every import — attributes\n * nothing, which is how a lead that no campaign caused stays uncredited.\n */\n touch?: PluginConversionTouch | null\n}): Promise<HostLeadOutcome> {\n const { hostRef, hostId, lead } = options\n const maxPerHost = options.ceiling ?? LEADS_MAX_PER_HOST\n try {\n const leadsRef = await orgLeadsForHost(hostId)\n const firestore = hostRef.firestore\n /*\n * `null` for anything that is not a usable address — a lead captured\n * against a malformed one keeps an auto-id and stays its own row. Keying\n * several unusable addresses under one guessed id would merge two\n * different people, which is worse than two rows for one.\n */\n const key = personKey(lead.email)\n /*\n * A lead the org has not taken over yet is carried across BEFORE the\n * transaction opens (AGL-3275). It has to happen outside: the carry is\n * itself a read-then-write, and a transaction that also counted the\n * collection would be reading a row it was about to create. Two doors\n * carrying the same person in the same second both write the legacy\n * fields under `merge`, so the race is a no-op rather than a conflict.\n */\n const leadRef = key\n ? (await leadForWrite(hostId, key)).ref\n : leadsRef.doc()\n /*\n * The scope this capture stamps, and the group whose terms its consent is\n * recorded under. Both resolved HERE rather than in the transaction body:\n * a contended transaction re-runs that body, and neither of these can\n * change between attempts, so resolving them inside would pay for the org\n * read again on every retry.\n */\n const scope = await leadScopeForHost(hostId)\n const group = await consentGroupForSite(hostId)\n const now = Date.now()\n const seen = {\n // `arrayUnion`, so a person who books twice has `['booking']` and one\n // who signed up and then submitted a form has both. Bounded by the\n // number of surfaces, not by the number of captures.\n sources: FieldValue.arrayUnion(lead.source),\n lastSeenAtMs: now,\n submissionCount: FieldValue.increment(1),\n ...(lead.name ? { name: lead.name } : {}),\n }\n let created = false\n let sourceAdded = false\n let predicted = false\n // The count a new person was judged against, for the notice re-arm.\n let usedBeforeCreate: number | null = null\n const refused = await firestore.runTransaction(async (tx) => {\n // Reset per attempt: a contended transaction re-runs its body, and a\n // flag left standing from an aborted attempt would credit a campaign\n // with a person who turned out to exist already.\n created = false\n sourceAdded = false\n usedBeforeCreate = null\n // ALL READS BEFORE THE WRITE, which Firestore requires.\n const existing = await tx.get(leadRef)\n // Whether the lead already reads \"Would bounce\", for the check below.\n predicted = (existing.data() ?? {})['emailStatus'] === 'undeliverable'\n /*\n * ⛔ THE CEILING GATES A NEW PERSON, NEVER AN EXISTING ONE.\n *\n * A returning visitor's capture is an UPDATE — it does not grow the\n * collection, so refusing it buys no capacity and costs the customer\n * the source and the timestamp they would have learned. That is the\n * enforcement-at-use shape the capacity rule exists to forbid: a limit\n * must refuse the addition, never a person already recorded or the\n * data attached to them.\n *\n * It also means the count is only paid on a genuinely new person,\n * which is the case that can move it.\n */\n if (!existing.exists) {\n /*\n * COUNTED OVER WHAT THIS SITE MAY SEE, not over the org (AGL-3275).\n *\n * The collection is org-wide now, and `LEADS_MAX_PER_HOST` is a\n * per-SITE ceiling. An unfiltered count would charge every brand in\n * an agency's account for every other brand's leads, and the first\n * client to fill its allowance would refuse captures on sites that\n * had taken none. `scopedToHost` is the same narrowing every other\n * org-owned read uses, so a single-site org counts exactly what it\n * counted before this moved.\n *\n * A lead shared by two brands in one consent group is counted by\n * both, which is the honest answer: each of them holds it.\n */\n const used = (\n await tx.get(scopedToHost(leadsRef, hostId).count())\n ).data().count\n if (checkVisitorRecordCeiling(used, maxPerHost).exceeded) return true\n usedBeforeCreate = used\n }\n /*\n * Consent is carried forward and never cleared.\n *\n * A basis is written only when this capture carried an explicit\n * opt-in, so a later booking by someone who did not tick the box\n * leaves an earlier grant standing — absent-or-granted, the shape\n * every other writer uses. The TIMESTAMP is carried over rather than\n * restamped, for the reason given at the read below.\n */\n /*\n * The EARLIEST grant is the one that happened, so a later capture\n * carrying the same checkbox keeps the original date rather than\n * restamping when this person opted in. Read back through the shared\n * reader so \"already consented\" means the same thing here as it does\n * at send time.\n */\n /*\n * THE SITE'S REAL GROUP, because the silo that justified a group of one\n * is gone (AGL-3275).\n *\n * This read `soloConsentGroup(hostId)` for as long as a lead lived at\n * `hosts/{hostId}/leads`, and the reason was the path: private by\n * construction, so pooling a lead's basis would have recorded a\n * disclosure that reached nothing, while the contact written by the\n * same capture door was org-shared and pooled.\n *\n * A lead is now org-shared on exactly the terms the contact is, so the\n * premise is false and keeping the group of one would leave a\n * multi-brand org holding a lead its sibling brand can SEE but may not\n * MAIL — a narrower basis than the one `consentGroupDisclosure` named\n * beside the checkbox this person ticked. Pooling here records what\n * they were actually told, and only that: the grant reaches the group\n * when the capture surface sent back the group's current disclosure\n * key (AGL-3320), and this site alone otherwise. An undeclared group is\n * still a group of one, so an agency is unchanged and configures\n * nothing.\n *\n * Resolved above the transaction — see `group`'s declaration.\n */\n const prior = readMarketingBasis(existing.data() ?? null, group)\n const consentAtMs =\n prior.basis === 'granted' && prior.basisAtMs !== null\n ? prior.basisAtMs\n : now\n created = !existing.exists\n /*\n * Whether this capture puts its surface on the person for the FIRST\n * time: a new lead, or one first met through another door. A form\n * counts its leads by this (AGL-3330) — the people it filed, which is\n * what a recount of `sources` finds — so a returning visitor's second\n * submission moves no form's lead count.\n */\n const storedSources = (existing.data() ?? {})['sources']\n sourceAdded =\n created || !(Array.isArray(storedSources) && storedSources.includes(lead.source))\n /*\n * THE FIELDS THE LEADS LIST QUERIES (AGL-3321), from the lead as it\n * will stand: what the transaction read, the address and name this\n * capture brings and the scope it widens. A new lead is written\n * `status: 'new'`, so the Open view's `status in [new, working]`\n * finds a lead nobody has touched — an absent field matches no query.\n */\n const stored = existing.data() ?? {}\n const phoneFill =\n lead.phoneFill && !String(stored['phone'] ?? '').trim()\n ? { phone: lead.phoneFill }\n : {}\n const storedScope = Array.isArray(stored['visibleTo']) ? (stored['visibleTo'] as unknown[]) : []\n /*\n * THE CAMPAIGNS, unioned (AGL-3458). Computed from what the transaction\n * read rather than written as an `arrayUnion`, so the Campaign filter's\n * scoped keys below are derived from the same list the write stores,\n * and the union is held to the membership cap every reader applies.\n * Written only when the capture names one, so a door with no campaign\n * never rewrites the field a person or a sequence filled.\n */\n // The field a lead holds its campaigns in, at the top of its document.\n const campaignsField = lead.campaignIds?.length ? containerMembershipField('campaign') : null\n const campaigns = campaignsField\n ? normalizeContainerIds([\n ...normalizeContainerIds(stored[campaignsField]),\n ...(lead.campaignIds ?? []),\n ])\n : null\n /*\n * A lead that keeps a first or last name keeps the name they make\n * (AGL-3513): a capture's typed name does not replace it.\n */\n const composedName = composeContactName(stored['firstName'], stored['lastName'])\n const name = composedName || lead.name\n const listFields = crmLeadListFields({\n ...stored,\n email: lead.email,\n ...(name ? { name } : {}),\n visibleTo: [...new Set([...storedScope, ...scope])],\n ...(campaignsField && campaigns ? { [campaignsField]: campaigns } : {}),\n })\n tx.set(\n leadRef,\n {\n email: lead.email,\n ...seen,\n ...(composedName ? { name: composedName } : {}),\n ...phoneFill,\n ...listFields,\n ...(campaignsField && campaigns ? { [campaignsField]: campaigns } : {}),\n /*\n * WIDENED BY THE CAPTURE, NEVER BY THE LOOKUP (AGL-3275).\n *\n * `arrayUnion` rather than a replace, exactly as `upsert-contact`\n * stamps a contact: this site just collected this person, so it may\n * see the row. A site that merely FOUND them — the unscoped dedupe\n * lookup in `readLeadForHost` — gains nothing, which is what keeps\n * an agency's clients apart on a record they share.\n */\n visibleTo: FieldValue.arrayUnion(...scope),\n /*\n * EVERY SITE THAT CAPTURED THIS PERSON, not just the first\n * (AGL-3275).\n *\n * This was written once, on create, and that was sound while the\n * collection sat under one host and could hold only that host's\n * name. On the org the row is shared, so a sibling brand capturing\n * a person the first brand already holds has to be recorded here or\n * the \"Known by\" answer silently omits it — the same `arrayUnion`\n * the contact door has always used for this field, now that a lead\n * has the same question to answer.\n */\n [CAPTURED_BY_HOST_FIELD]: FieldValue.arrayUnion(hostId),\n ...(existing.exists\n ? {}\n : {\n firstSeenAtMs: now,\n createdAt: FieldValue.serverTimestamp(),\n }),\n /*\n * The basis is recorded under the GROUP, keyed by its host, because\n * {@link readMarketingBasis} is one function over four silos and a\n * silo whose basis lived somewhere else would need the reader to\n * know which collection it was handed. A reader that has to be told\n * the shape is a reader that can be told the wrong one.\n */\n ...(lead.marketingConsent\n ? marketingConsentFieldsForGroup(\n consentGroupForGrant(group, lead.disclosedConsentGroup),\n consentAtMs,\n )\n : {}),\n },\n { merge: true },\n )\n return false\n })\n if (refused) {\n await recordVisitorRecordCeilingTrip({\n hostRef,\n hostId,\n kind: 'leads',\n ceiling: maxPerHost,\n })\n return NOT_STORED\n }\n if (created && usedBeforeCreate !== null) {\n await rearmVisitorRecordCeilingNotice({\n hostRef,\n kind: 'leads',\n used: usedBeforeCreate,\n ceiling: maxPerHost,\n })\n }\n /*\n * ATTRIBUTED ON CREATION ONLY.\n *\n * A returning visitor's capture is an update — the campaign did not\n * produce a lead, it produced another visit by a person the site already\n * held — and crediting it would let whichever campaign ran most recently\n * re-earn every lead on the list. `created` is set inside the transaction\n * that decides it, so the attribution and the write agree about whether\n * this person is new.\n *\n * Awaited rather than fired off: `addHostLead` already returns only after\n * its own write, and a caller that `void`s it (every one of them) is\n * unaffected. Never throws, so a failure here cannot cost the lead.\n */\n /*\n * THE ADDRESS'S DOMAIN, checked after the response (AGL-3328): a lead\n * whose domain takes no mail reads \"Would bounce\". Queued, never\n * awaited — the write above is the capture, and this costs it nothing.\n */\n scheduleCapturedEmailCheck({\n orgId: leadsRef.parent?.id ?? null,\n hostId,\n email: lead.email,\n predicted,\n })\n if (created && options.touch) {\n await creditConversion({\n hostId,\n kind: 'lead',\n refId: leadRef.id,\n touch: options.touch,\n convertedAtMs: now,\n })\n }\n return { stored: true, created, sourceAdded }\n } catch (error) {\n // Same posture the three original call sites had (`.catch(() => undefined)`\n // / `.catch(console.error)`): a lead that failed to store must not fail\n // the sign-up or the booking that produced it.\n console.error('lead write failed', error)\n return NOT_STORED\n }\n}\n\n/** What {@link addHostLeadOutcome} did with one capture. */\nexport interface HostLeadOutcome {\n /** Whether the lead was written — false when refused or failed. */\n stored: boolean\n /** Whether it was a new person. */\n created: boolean\n /** Whether this capture's `source` was new on the lead — see the write. */\n sourceAdded: boolean\n}\n\nconst NOT_STORED: HostLeadOutcome = { stored: false, created: false, sourceAdded: false }\n\n/**\n * {@link addHostLeadOutcome}, answering only whether the lead was stored —\n * the question every door but a form's asks.\n */\nexport async function addHostLead(\n options: Parameters<typeof addHostLeadOutcome>[0],\n): Promise<boolean> {\n return (await addHostLeadOutcome(options)).stored\n}\n"],"names":["CAPTURED_BY_HOST_FIELD","checkVisitorRecordCeiling","containerMembershipField","LEADS_MAX_PER_HOST","marketingConsentFieldsForGroup","normalizeContainerIds","personKey","readMarketingBasis","utcMonthKey","VISITOR_RECORD_NOTICE_SENT_FIELD","visitorRecordAcceptFillsCeiling","visitorRecordCeilingAnnounced","visitorRecordRefusedCounterId","FieldValue","creditConversion","notifyHostManagers","scheduleCapturedEmailCheck","firebaseAdmin","consentGroupForSite","orgDataCollectionForHost","scopedToHost","composeContactName","crmLeadListFields","crmReadTokens","crmScopeTokens","consentGroupForGrant","firestore","app","orgLeadsForHost","hostId","orgLeadsQueryForHost","ref","query","leadReadTokensForHost","group","leadScopeForHost","org","readLeadForHost","key","orgRow","doc","get","exists","leadForWrite","existed","carried","recordVisitorRecordCeilingTrip","options","hostRef","kind","ceiling","monthKey","refusedRef","collection","refusedSnapshot","announced","data","now","Date","set","increment","lastRefusedAtMs","merge","leads","type","title","body","toLocaleString","link","error","console","rearmVisitorRecordCeilingNotice","used","addHostLeadOutcome","lead","maxPerHost","leadsRef","email","leadRef","scope","seen","sources","arrayUnion","source","lastSeenAtMs","submissionCount","name","created","sourceAdded","predicted","usedBeforeCreate","refused","runTransaction","tx","existing","stored","count","exceeded","prior","consentAtMs","basis","basisAtMs","storedSources","Array","isArray","includes","phoneFill","String","trim","phone","storedScope","campaignsField","campaignIds","length","campaigns","composedName","listFields","visibleTo","Set","firstSeenAtMs","createdAt","serverTimestamp","marketingConsent","disclosedConsentGroup","NOT_STORED","orgId","parent","id","touch","refId","convertedAtMs","addHostLead"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;CAQC,GAED,SACEA,sBAAsB,EACtBC,yBAAyB,EACzBC,wBAAwB,EACxBC,kBAAkB,EAClBC,8BAA8B,EAC9BC,qBAAqB,EACrBC,SAAS,EACTC,kBAAkB,EAClBC,WAAW,EACXC,gCAAgC,EAChCC,+BAA+B,EAC/BC,6BAA6B,EAC7BC,6BAA6B,QAExB,sBAAqB;AAC5B,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SACEC,gBAAgB,QAEX,uDAAsD;AAC7D,SAASC,kBAAkB,QAAQ,qBAAiB;AACpD,SAASC,0BAA0B,QAAQ,2BAAuB;AAClE,OAAOC,mBAAmB,sBAAkB;AAC5C,SACEC,mBAAmB,EACnBC,wBAAwB,EAExBC,YAAY,QACP,qBAAiB;AACxB,SACEC,kBAAkB,EAClBC,iBAAiB,EACjBC,aAAa,EACbC,cAAc,QACT,sBAAqB;AAE5B,8EAA8E;AAC9E,8EAA8E;AAC9E,SAASC,oBAAoB,QAAQ,wCAAuC;AAE5E,MAAMC,YAAY,IAAMT,cAAcU,GAAG,GAAGD,SAAS;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4CC,GAGD;;;CAGC,GACD,OAAO,eAAeE,gBACpBC,MAAc;IAEd,OAAOV,yBAAyBU,QAAQ;AAC1C;AAEA;;;;;;CAMC,GACD,OAAO,eAAeC,qBAAqBD,MAAc;IAIvD,MAAME,MAAM,MAAMH,gBAAgBC;IAClC,OAAO;QAAEE;QAAKC,OAAOZ,aAAaW,KAAKF;IAAQ;AACjD;AAEA;;;CAGC,GACD,OAAO,eAAeI,sBACpBJ,MAAc,EACdK,KAAoB;IAEpB,OAAOX,cAAcW,gBAAAA,QAAU,MAAMhB,oBAAoBW;AAC3D;AAEA;;;;;;;;;CASC,GACD,OAAO,eAAeM,iBACpBN,MAAc,EACdO,GAAoC;IAEpC,MAAMF,QAAQ,MAAMhB,oBAAoBW,QAAQO;IAChD,OAAOZ,eAAeY,cAAAA,MAAO,MAAMF;AACrC;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,eAAeG,gBACpBR,MAAc,EACdS,GAAW;IAEX,MAAMC,SAAS,MAAM,AAAC,CAAA,MAAMX,gBAAgBC,OAAM,EAAGW,GAAG,CAACF,KAAKG,GAAG;IACjE,OAAOF,OAAOG,MAAM,GAAGH,SAAS;AAClC;AAEA;;;;;;;;;CASC,GACD,OAAO,eAAeI,aACpBd,MAAc,EACdS,GAAW;IAMX,MAAMP,MAAM,AAAC,CAAA,MAAMH,gBAAgBC,OAAM,EAAGW,GAAG,CAACF;IAChD,OAAO;QAAEP;QAAKa,SAAS,AAAC,CAAA,MAAMb,IAAIU,GAAG,EAAC,EAAGC,MAAM;QAAEG,SAAS;IAAM;AAClE;AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6BC,GACD,OAAO,eAAeC,+BAA+BC,OAMpD;QAEkBA;IADjB,MAAM,EAAEC,OAAO,EAAEnB,MAAM,EAAEoB,IAAI,EAAEC,OAAO,EAAE,GAAGH;IAC3C,MAAMI,YAAWJ,oBAAAA,QAAQI,QAAQ,YAAhBJ,oBAAoBvC;IACrC,IAAI;QACF,MAAM4C,aAAaJ,QAChBK,UAAU,CAAC,YACXb,GAAG,CAAC5B,8BAA8BqC;QACrC,MAAMK,kBAAkB,MAAMF,WAAWX,GAAG;QAC5C,gEAAgE;QAChE,oEAAoE;QACpE,MAAMc,YAAY5C,8BAA8B2C,gBAAgBE,IAAI;QACpE,MAAMC,MAAMC,KAAKD,GAAG;QACpB,MAAML,WAAWO,GAAG,CAClB;YACE,CAACR,SAAS,EAAEtC,WAAW+C,SAAS,CAAC;YACjC,wDAAwD;YACxDV;YACAW,iBAAiBJ;WAGbF,YAAY,CAAC,IAAI;YAAE,CAAC9C,iCAAiC,EAAEgD;QAAI,IAEjE;YAAEK,OAAO;QAAK;QAEhB,IAAI,CAACP,WAAW;YACd,MAAMQ,QAAQd,SAAS;YACvB;;;;;;;OAOC,GACD,MAAMlC,mBAAmBc,QAAQ;gBAC/BmC,MAAM;gBACNC,OAAOF,QACH,2DACA;gBACJG,MAAMH,QACF,CAAC,aAAa,EAAEb,QAAQiB,cAAc,GAAG,qBAAqB,CAAC,GAC/D,oEACA,iEACA,qEACA,iEACA,oEACA,aACA,CAAC,WAAW,EAAEjB,QAAQiB,cAAc,GAAG,sBAAsB,CAAC,GAC9D,+DACA,qEACA,8DACA,iEACA;gBACJC,MAAM,CAAC,CAAC,EAAEvC,OAAO,MAAM,CAAC;YAC1B;QACF;IACF,EAAE,OAAOwC,OAAO;QACdC,QAAQD,KAAK,CAAC,6CAA6CA;IAC7D;AACF;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,eAAeE,gCAAgCxB,OAMrD;IACC,MAAM,EAAEC,OAAO,EAAEC,IAAI,EAAEuB,IAAI,EAAEtB,OAAO,EAAE,GAAGH;IACzC,IAAI,CAACrC,gCAAgC8D,MAAMtB,UAAU;IACrD,IAAI;QACF,MAAME,aAAaJ,QAChBK,UAAU,CAAC,YACXb,GAAG,CAAC5B,8BAA8BqC;QACrC,MAAMK,kBAAkB,MAAMF,WAAWX,GAAG;QAC5C,IAAI,CAAC9B,8BAA8B2C,gBAAgBE,IAAI,KAAK;QAC5D,MAAMJ,WAAWO,GAAG,CAClB;YAAE,CAAClD,iCAAiC,EAAE;QAAK,GAC3C;YAAEqD,OAAO;QAAK;IAElB,EAAE,OAAOO,OAAO;QACdC,QAAQD,KAAK,CAAC,wCAAwCA;IACxD;AACF;AA8CA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkDC,GACD,OAAO,eAAeI,mBAAmB1B,OAyBxC;QAEoBA;IADnB,MAAM,EAAEC,OAAO,EAAEnB,MAAM,EAAE6C,IAAI,EAAE,GAAG3B;IAClC,MAAM4B,cAAa5B,mBAAAA,QAAQG,OAAO,YAAfH,mBAAmB5C;IACtC,IAAI;;YAwROyE;QAvRT,MAAMA,WAAW,MAAMhD,gBAAgBC;QACvC,MAAMH,YAAYsB,QAAQtB,SAAS;QACnC;;;;;KAKC,GACD,MAAMY,MAAMhC,UAAUoE,KAAKG,KAAK;QAChC;;;;;;;KAOC,GACD,MAAMC,UAAUxC,MACZ,AAAC,CAAA,MAAMK,aAAad,QAAQS,IAAG,EAAGP,GAAG,GACrC6C,SAASpC,GAAG;QAChB;;;;;;KAMC,GACD,MAAMuC,QAAQ,MAAM5C,iBAAiBN;QACrC,MAAMK,QAAQ,MAAMhB,oBAAoBW;QACxC,MAAM4B,MAAMC,KAAKD,GAAG;QACpB,MAAMuB,OAAO;YACX,sEAAsE;YACtE,mEAAmE;YACnE,qDAAqD;YACrDC,SAASpE,WAAWqE,UAAU,CAACR,KAAKS,MAAM;YAC1CC,cAAc3B;YACd4B,iBAAiBxE,WAAW+C,SAAS,CAAC;WAClCc,KAAKY,IAAI,GAAG;YAAEA,MAAMZ,KAAKY,IAAI;QAAC,IAAI,CAAC;QAEzC,IAAIC,UAAU;QACd,IAAIC,cAAc;QAClB,IAAIC,YAAY;QAChB,oEAAoE;QACpE,IAAIC,mBAAkC;QACtC,MAAMC,UAAU,MAAMjE,UAAUkE,cAAc,CAAC,OAAOC;gBAUvCC,gBA0EoBA,iBAaVA,iBAURA,iBAEaC,eAiBlBrB;gBAJaA;YAzHvB,qEAAqE;YACrE,qEAAqE;YACrE,iDAAiD;YACjDa,UAAU;YACVC,cAAc;YACdE,mBAAmB;YACnB,wDAAwD;YACxD,MAAMI,WAAW,MAAMD,GAAGpD,GAAG,CAACqC;YAC9B,sEAAsE;YACtEW,YAAY,EAACK,iBAAAA,SAAStC,IAAI,cAAbsC,iBAAmB,CAAC,EAAE,CAAC,cAAc,KAAK;YACvD;;;;;;;;;;;;OAYC,GACD,IAAI,CAACA,SAASpD,MAAM,EAAE;gBACpB;;;;;;;;;;;;;SAaC,GACD,MAAM8B,OAAO,AACX,CAAA,MAAMqB,GAAGpD,GAAG,CAACrB,aAAawD,UAAU/C,QAAQmE,KAAK,GAAE,EACnDxC,IAAI,GAAGwC,KAAK;gBACd,IAAI/F,0BAA0BuE,MAAMG,YAAYsB,QAAQ,EAAE,OAAO;gBACjEP,mBAAmBlB;YACrB;YACA;;;;;;;;OAQC,GACD;;;;;;OAMC,GACD;;;;;;;;;;;;;;;;;;;;;;OAsBC,GACD,MAAM0B,QAAQ3F,oBAAmBuF,kBAAAA,SAAStC,IAAI,cAAbsC,kBAAmB,MAAM5D;YAC1D,MAAMiE,cACJD,MAAME,KAAK,KAAK,aAAaF,MAAMG,SAAS,KAAK,OAC7CH,MAAMG,SAAS,GACf5C;YACN8B,UAAU,CAACO,SAASpD,MAAM;YAC1B;;;;;;OAMC,GACD,MAAM4D,gBAAgB,EAACR,kBAAAA,SAAStC,IAAI,cAAbsC,kBAAmB,CAAC,EAAE,CAAC,UAAU;YACxDN,cACED,WAAW,CAAEgB,CAAAA,MAAMC,OAAO,CAACF,kBAAkBA,cAAcG,QAAQ,CAAC/B,KAAKS,MAAM,CAAA;YACjF;;;;;;OAMC,GACD,MAAMY,UAASD,kBAAAA,SAAStC,IAAI,cAAbsC,kBAAmB,CAAC;YACnC,MAAMY,YACJhC,KAAKgC,SAAS,IAAI,CAACC,QAAOZ,gBAAAA,MAAM,CAAC,QAAQ,YAAfA,gBAAmB,IAAIa,IAAI,KACjD;gBAAEC,OAAOnC,KAAKgC,SAAS;YAAC,IACxB,CAAC;YACP,MAAMI,cAAcP,MAAMC,OAAO,CAACT,MAAM,CAAC,YAAY,IAAKA,MAAM,CAAC,YAAY,GAAiB,EAAE;YAChG;;;;;;;OAOC,GACD,uEAAuE;YACvE,MAAMgB,iBAAiBrC,EAAAA,qBAAAA,KAAKsC,WAAW,qBAAhBtC,mBAAkBuC,MAAM,IAAG/G,yBAAyB,cAAc;YACzF,MAAMgH,YAAYH,iBACd1G,sBAAsB;mBACjBA,sBAAsB0F,MAAM,CAACgB,eAAe;oBAC3CrC,oBAAAA,KAAKsC,WAAW,YAAhBtC,oBAAoB,EAAE;aAC3B,IACD;YACJ;;;OAGC,GACD,MAAMyC,eAAe9F,mBAAmB0E,MAAM,CAAC,YAAY,EAAEA,MAAM,CAAC,WAAW;YAC/E,MAAMT,OAAO6B,gBAAgBzC,KAAKY,IAAI;YACtC,MAAM8B,aAAa9F,kBAAkB,aAChCyE;gBACHlB,OAAOH,KAAKG,KAAK;eACbS,OAAO;gBAAEA;YAAK,IAAI,CAAC;gBACvB+B,WAAW;uBAAI,IAAIC,IAAI;2BAAIR;2BAAgB/B;qBAAM;iBAAE;eAC/CgC,kBAAkBG,YAAY;gBAAE,CAACH,eAAe,EAAEG;YAAU,IAAI,CAAC;YAEvErB,GAAGlC,GAAG,CACJmB,SACA;gBACED,OAAOH,KAAKG,KAAK;eACdG,MACCmC,eAAe;gBAAE7B,MAAM6B;YAAa,IAAI,CAAC,GAC1CT,WACAU,YACCL,kBAAkBG,YAAY;gBAAE,CAACH,eAAe,EAAEG;YAAU,IAAI,CAAC;gBACrE;;;;;;;;WAQC,GACDG,WAAWxG,WAAWqE,UAAU,IAAIH;gBACpC;;;;;;;;;;;WAWC,GACD,CAAC/E,uBAAuB,EAAEa,WAAWqE,UAAU,CAACrD;eAC5CiE,SAASpD,MAAM,GACf,CAAC,IACD;gBACE6E,eAAe9D;gBACf+D,WAAW3G,WAAW4G,eAAe;YACvC,GAQA/C,KAAKgD,gBAAgB,GACrBtH,+BACEqB,qBAAqBS,OAAOwC,KAAKiD,qBAAqB,GACtDxB,eAEF,CAAC,IAEP;gBAAErC,OAAO;YAAK;YAEhB,OAAO;QACT;QACA,IAAI6B,SAAS;YACX,MAAM7C,+BAA+B;gBACnCE;gBACAnB;gBACAoB,MAAM;gBACNC,SAASyB;YACX;YACA,OAAOiD;QACT;QACA,IAAIrC,WAAWG,qBAAqB,MAAM;YACxC,MAAMnB,gCAAgC;gBACpCvB;gBACAC,MAAM;gBACNuB,MAAMkB;gBACNxC,SAASyB;YACX;QACF;QACA;;;;;;;;;;;;;KAaC,GACD;;;;KAIC,GACD3D,2BAA2B;YACzB6G,KAAK,WAAEjD,mBAAAA,SAASkD,MAAM,qBAAflD,iBAAiBmD,EAAE,mBAAI;YAC9BlG;YACAgD,OAAOH,KAAKG,KAAK;YACjBY;QACF;QACA,IAAIF,WAAWxC,QAAQiF,KAAK,EAAE;YAC5B,MAAMlH,iBAAiB;gBACrBe;gBACAoB,MAAM;gBACNgF,OAAOnD,QAAQiD,EAAE;gBACjBC,OAAOjF,QAAQiF,KAAK;gBACpBE,eAAezE;YACjB;QACF;QACA,OAAO;YAAEsC,QAAQ;YAAMR;YAASC;QAAY;IAC9C,EAAE,OAAOnB,OAAO;QACd,4EAA4E;QAC5E,wEAAwE;QACxE,+CAA+C;QAC/CC,QAAQD,KAAK,CAAC,qBAAqBA;QACnC,OAAOuD;IACT;AACF;AAYA,MAAMA,aAA8B;IAAE7B,QAAQ;IAAOR,SAAS;IAAOC,aAAa;AAAM;AAExF;;;CAGC,GACD,OAAO,eAAe2C,YACpBpF,OAAiD;IAEjD,OAAO,AAAC,CAAA,MAAM0B,mBAAmB1B,QAAO,EAAGgD,MAAM;AACnD"}