@aglyn/tenant-data-admin 1.0.0-beta.221 → 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 (121) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/api-keys.js +3 -3
  3. package/src/lib/server/api-keys.js.map +1 -1
  4. package/src/lib/server/api-v1-resources.d.ts +44 -3
  5. package/src/lib/server/api-v1-resources.js +82 -5
  6. package/src/lib/server/api-v1-resources.js.map +1 -1
  7. package/src/lib/server/collection-live-pages.d.ts +2 -2
  8. package/src/lib/server/collection-live-pages.js +1 -1
  9. package/src/lib/server/collection-live-pages.js.map +1 -1
  10. package/src/lib/server/email-delivery-log.d.ts +17 -73
  11. package/src/lib/server/email-delivery-log.js +44 -222
  12. package/src/lib/server/email-delivery-log.js.map +1 -1
  13. package/src/lib/server/email-flow-gate.js +27 -38
  14. package/src/lib/server/email-flow-gate.js.map +1 -1
  15. package/src/lib/server/erase-person.d.ts +34 -54
  16. package/src/lib/server/erase-person.js +44 -239
  17. package/src/lib/server/erase-person.js.map +1 -1
  18. package/src/lib/server/ga4-measurement-protocol.js +41 -28
  19. package/src/lib/server/ga4-measurement-protocol.js.map +1 -1
  20. package/src/lib/server/host-visitor-records.d.ts +25 -4
  21. package/src/lib/server/host-visitor-records.js +39 -11
  22. package/src/lib/server/host-visitor-records.js.map +1 -1
  23. package/src/lib/server/hosted-page-review.js +52 -36
  24. package/src/lib/server/hosted-page-review.js.map +1 -1
  25. package/src/lib/server/link-reputation-review.d.ts +6 -2
  26. package/src/lib/server/link-reputation-review.js +61 -20
  27. package/src/lib/server/link-reputation-review.js.map +1 -1
  28. package/src/lib/server/live-page-drops.d.ts +67 -0
  29. package/src/lib/server/live-page-drops.js +57 -0
  30. package/src/lib/server/live-page-drops.js.map +1 -0
  31. package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
  32. package/src/lib/server/media-bandwidth-cap.js +180 -0
  33. package/src/lib/server/media-bandwidth-cap.js.map +1 -0
  34. package/src/lib/server/media-cdn-icon.d.ts +77 -0
  35. package/src/lib/server/media-cdn-icon.js +140 -0
  36. package/src/lib/server/media-cdn-icon.js.map +1 -0
  37. package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
  38. package/src/lib/server/media-delivery-regeneration.js +216 -0
  39. package/src/lib/server/media-delivery-regeneration.js.map +1 -0
  40. package/src/lib/server/media-delivery.d.ts +2 -0
  41. package/src/lib/server/media-delivery.js +2 -1
  42. package/src/lib/server/media-delivery.js.map +1 -1
  43. package/src/lib/server/media-serve-count.d.ts +50 -0
  44. package/src/lib/server/media-serve-count.js +62 -0
  45. package/src/lib/server/media-serve-count.js.map +1 -0
  46. package/src/lib/server/media-signing.d.ts +13 -2
  47. package/src/lib/server/media-signing.js +12 -8
  48. package/src/lib/server/media-signing.js.map +1 -1
  49. package/src/lib/server/media-tombstone.js +7 -5
  50. package/src/lib/server/media-tombstone.js.map +1 -1
  51. package/src/lib/server/media-variants.d.ts +151 -20
  52. package/src/lib/server/media-variants.js +204 -27
  53. package/src/lib/server/media-variants.js.map +1 -1
  54. package/src/lib/server/organizations.d.ts +102 -2
  55. package/src/lib/server/organizations.js +388 -58
  56. package/src/lib/server/organizations.js.map +1 -1
  57. package/src/lib/server/outbound-send-review.js +11 -7
  58. package/src/lib/server/outbound-send-review.js.map +1 -1
  59. package/src/lib/server/paged-sweeps.d.ts +51 -0
  60. package/src/lib/server/paged-sweeps.js +79 -0
  61. package/src/lib/server/paged-sweeps.js.map +1 -0
  62. package/src/lib/server/payment-fraud-signal.d.ts +49 -9
  63. package/src/lib/server/payment-fraud-signal.js +192 -48
  64. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  65. package/src/lib/server/platform-marketing-consent.d.ts +0 -49
  66. package/src/lib/server/platform-marketing-consent.js +27 -11
  67. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  68. package/src/lib/server/render-monitor.d.ts +166 -0
  69. package/src/lib/server/render-monitor.js +412 -0
  70. package/src/lib/server/render-monitor.js.map +1 -0
  71. package/src/lib/server/risk-notice.d.ts +7 -0
  72. package/src/lib/server/risk-notice.js +6 -0
  73. package/src/lib/server/risk-notice.js.map +1 -1
  74. package/src/lib/server/serve-media-cdn.d.ts +69 -1
  75. package/src/lib/server/serve-media-cdn.js +424 -70
  76. package/src/lib/server/serve-media-cdn.js.map +1 -1
  77. package/src/lib/server/site-icon-render.d.ts +68 -0
  78. package/src/lib/server/site-icon-render.js +157 -0
  79. package/src/lib/server/site-icon-render.js.map +1 -0
  80. package/src/lib/server/staff-seat.d.ts +50 -0
  81. package/src/lib/server/staff-seat.js +118 -0
  82. package/src/lib/server/staff-seat.js.map +1 -0
  83. package/src/lib/server/transfer-export.d.ts +46 -0
  84. package/src/lib/server/transfer-export.js +221 -0
  85. package/src/lib/server/transfer-export.js.map +1 -0
  86. package/src/lib/server/transfer-jobs.d.ts +385 -0
  87. package/src/lib/server/transfer-jobs.js +2368 -0
  88. package/src/lib/server/transfer-jobs.js.map +1 -0
  89. package/src/lib/server/transfer-packages.d.ts +127 -0
  90. package/src/lib/server/transfer-packages.js +1036 -0
  91. package/src/lib/server/transfer-packages.js.map +1 -0
  92. package/src/lib/server/upgrade-proposal.d.ts +97 -0
  93. package/src/lib/server/upgrade-proposal.js +312 -0
  94. package/src/lib/server/upgrade-proposal.js.map +1 -0
  95. package/src/lib/server/upsert-contact.d.ts +17 -8
  96. package/src/lib/server/upsert-contact.js +82 -16
  97. package/src/lib/server/upsert-contact.js.map +1 -1
  98. package/src/lib/server/web-risk.d.ts +50 -9
  99. package/src/lib/server/web-risk.js +290 -155
  100. package/src/lib/server/web-risk.js.map +1 -1
  101. package/src/tenancy.d.ts +3 -3
  102. package/src/tenancy.js +5 -3
  103. package/src/tenancy.js.map +1 -1
  104. package/src/lib/server/campaign-attribution-store.d.ts +0 -55
  105. package/src/lib/server/campaign-attribution-store.js +0 -95
  106. package/src/lib/server/campaign-attribution-store.js.map +0 -1
  107. package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
  108. package/src/lib/server/campaign-conversion-attribution.js +0 -450
  109. package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
  110. package/src/lib/server/crm-email-activity.d.ts +0 -118
  111. package/src/lib/server/crm-email-activity.js +0 -148
  112. package/src/lib/server/crm-email-activity.js.map +0 -1
  113. package/src/lib/server/crm-next-activity.d.ts +0 -74
  114. package/src/lib/server/crm-next-activity.js +0 -194
  115. package/src/lib/server/crm-next-activity.js.map +0 -1
  116. package/src/lib/server/dataset-live-pages.d.ts +0 -179
  117. package/src/lib/server/dataset-live-pages.js +0 -421
  118. package/src/lib/server/dataset-live-pages.js.map +0 -1
  119. package/src/lib/server/email-revenue-attribution.d.ts +0 -185
  120. package/src/lib/server/email-revenue-attribution.js +0 -321
  121. package/src/lib/server/email-revenue-attribution.js.map +0 -1
@@ -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":"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"}
@@ -24,7 +24,7 @@
24
24
  * split as `visitorWriteRateLimitRefusal`, and for the same reason.
25
25
  */
26
26
  import { type VisitorRecordKind } from '@aglyn/aglyn/server';
27
- import { type ResolvedCampaignTouch } from './campaign-conversion-attribution';
27
+ import { type PluginConversionTouch } from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit';
28
28
  import type { ConsentGroup, ScopeToken } from '@aglyn/aglyn/server';
29
29
  /**
30
30
  * A lead's one home: `orgs/{orgId}/leads/{personKey}` (AGL-3275).
@@ -142,7 +142,7 @@ export declare function leadForWrite(hostId: string, key: string): Promise<{
142
142
  * count as shipped.
143
143
  *
144
144
  * Two audiences, one call, in exactly `recordAbuseCeilingTrip`'s shape
145
- * (`apps/tenant/app/api/forms/submit/route.ts`, AGL-1655):
145
+ * (`libs/plugins/forms/src/lib/server/form-submit.ts`, AGL-1655):
146
146
  *
147
147
  * - A durable per-month refusal count at
148
148
  * `hosts/{id}/counters/{siteMembers|leads}Refused`. Counters are excluded
@@ -219,6 +219,25 @@ 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[];
235
+ /**
236
+ * A phone the capture learned (AGL-3493), E.164 as the door normalized
237
+ * it. Written only when the lead holds none: a number a rep typed on the
238
+ * lead, or an earlier capture brought, is not replaced by this one.
239
+ */
240
+ phoneFill?: string;
222
241
  }
223
242
  /**
224
243
  * Record one lead at `hosts/{hostId}/leads/{personKey}`, bounded by
@@ -285,7 +304,9 @@ export declare function addHostLeadOutcome(options: {
285
304
  */
286
305
  ceiling?: number;
287
306
  /**
288
- * The campaign this person came from, already resolved by the door.
307
+ * Where this person arrived from, already resolved by the door through the
308
+ * plugin that credits outcomes (`plugin-conversion-credit`), and opaque
309
+ * here.
289
310
  *
290
311
  * Resolved rather than raw, and passed rather than looked up, because one
291
312
  * visitor action reaches several writers: a form submission that creates a
@@ -293,7 +314,7 @@ export declare function addHostLeadOutcome(options: {
293
314
  * door that hands none — every order path, every import — attributes
294
315
  * nothing, which is how a lead that no campaign caused stays uncredited.
295
316
  */
296
- touch?: ResolvedCampaignTouch | null;
317
+ touch?: PluginConversionTouch | null;
297
318
  }): Promise<HostLeadOutcome>;
298
319
  /** What {@link addHostLeadOutcome} did with one capture. */
299
320
  export interface HostLeadOutcome {
@@ -22,14 +22,14 @@ 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, submissionMonthKey, 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
- import { attributeCampaignConversion } from "./campaign-conversion-attribution.js";
27
+ import { creditConversion } from "@aglyn/aglyn/plugin-manager/plugin-conversion-credit";
28
28
  import { notifyHostManagers } from "./notifications.js";
29
29
  import { scheduleCapturedEmailCheck } from "./capture-email-check.js";
30
30
  import firebaseAdmin from "./firebase-admin.js";
31
31
  import { consentGroupForSite, orgDataCollectionForHost, scopedToHost } from "./organizations.js";
32
- import { crmLeadListFields, crmReadTokens, crmScopeTokens } from "@aglyn/aglyn/server";
32
+ import { composeContactName, crmLeadListFields, crmReadTokens, crmScopeTokens } from "@aglyn/aglyn/server";
33
33
  // The module path rather than the barrel, as `upsert-contact.ts` does: a spec
34
34
  // that substitutes the barrel keeps the real rule for which group a grant is.
35
35
  import { consentGroupForGrant } from "@aglyn/aglyn/app-utils/consent-groups";
@@ -159,7 +159,7 @@ const firestore = ()=>firebaseAdmin.app().firestore();
159
159
  * count as shipped.
160
160
  *
161
161
  * Two audiences, one call, in exactly `recordAbuseCeilingTrip`'s shape
162
- * (`apps/tenant/app/api/forms/submit/route.ts`, AGL-1655):
162
+ * (`libs/plugins/forms/src/lib/server/form-submit.ts`, AGL-1655):
163
163
  *
164
164
  * - A durable per-month refusal count at
165
165
  * `hosts/{id}/counters/{siteMembers|leads}Refused`. Counters are excluded
@@ -186,7 +186,7 @@ const firestore = ()=>firebaseAdmin.app().firestore();
186
186
  */ export async function recordVisitorRecordCeilingTrip(options) {
187
187
  var _options_monthKey;
188
188
  const { hostRef, hostId, kind, ceiling } = options;
189
- const monthKey = (_options_monthKey = options.monthKey) != null ? _options_monthKey : submissionMonthKey();
189
+ const monthKey = (_options_monthKey = options.monthKey) != null ? _options_monthKey : utcMonthKey();
190
190
  try {
191
191
  const refusedRef = hostRef.collection('counters').doc(visitorRecordRefusedCounterId(kind));
192
192
  const refusedSnapshot = await refusedRef.get();
@@ -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;
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.
@@ -447,11 +448,32 @@ const firestore = ()=>firebaseAdmin.app().firestore();
447
448
  * `status: 'new'`, so the Open view's `status in [new, working]`
448
449
  * finds a lead nobody has touched — an absent field matches no query.
449
450
  */ const stored = (_existing_data3 = existing.data()) != null ? _existing_data3 : {};
451
+ const phoneFill = lead.phoneFill && !String((_stored_phone = stored['phone']) != null ? _stored_phone : '').trim() ? {
452
+ phone: lead.phoneFill
453
+ } : {};
450
454
  const storedScope = Array.isArray(stored['visibleTo']) ? stored['visibleTo'] : [];
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
+ /*
469
+ * A lead that keeps a first or last name keeps the name they make
470
+ * (AGL-3513): a capture's typed name does not replace it.
471
+ */ const composedName = composeContactName(stored['firstName'], stored['lastName']);
472
+ const name = composedName || lead.name;
451
473
  const listFields = crmLeadListFields(_extends({}, stored, {
452
474
  email: lead.email
453
- }, lead.name ? {
454
- name: lead.name
475
+ }, name ? {
476
+ name
455
477
  } : {}, {
456
478
  visibleTo: [
457
479
  ...new Set([
@@ -459,10 +481,16 @@ const firestore = ()=>firebaseAdmin.app().firestore();
459
481
  ...scope
460
482
  ])
461
483
  ]
462
- }));
484
+ }, campaignsField && campaigns ? {
485
+ [campaignsField]: campaigns
486
+ } : {}));
463
487
  tx.set(leadRef, _extends({
464
488
  email: lead.email
465
- }, seen, listFields, {
489
+ }, seen, composedName ? {
490
+ name: composedName
491
+ } : {}, phoneFill, listFields, campaignsField && campaigns ? {
492
+ [campaignsField]: campaigns
493
+ } : {}, {
466
494
  /*
467
495
  * WIDENED BY THE CAPTURE, NEVER BY THE LOOKUP (AGL-3275).
468
496
  *
@@ -533,7 +561,7 @@ const firestore = ()=>firebaseAdmin.app().firestore();
533
561
  predicted
534
562
  });
535
563
  if (created && options.touch) {
536
- await attributeCampaignConversion({
564
+ await creditConversion({
537
565
  hostId,
538
566
  kind: 'lead',
539
567
  refId: leadRef.id,