@aglyn/aglyn 1.0.0-beta.225 → 1.0.0-beta.227

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 +11 -11
  2. package/src/lib/app-utils/account-acquisition.d.ts +7 -0
  3. package/src/lib/app-utils/account-acquisition.js +11 -3
  4. package/src/lib/app-utils/account-acquisition.js.map +1 -1
  5. package/src/lib/app-utils/after-response.d.ts +32 -0
  6. package/src/lib/app-utils/after-response.js +98 -0
  7. package/src/lib/app-utils/after-response.js.map +1 -0
  8. package/src/lib/app-utils/analytics-events.d.ts +7 -0
  9. package/src/lib/app-utils/analytics-events.js +1 -0
  10. package/src/lib/app-utils/analytics-events.js.map +1 -1
  11. package/src/lib/app-utils/api-adapter.d.ts +10 -0
  12. package/src/lib/app-utils/api-adapter.js +18 -0
  13. package/src/lib/app-utils/api-adapter.js.map +1 -1
  14. package/src/lib/app-utils/binding-token-catalog.js +5 -0
  15. package/src/lib/app-utils/binding-token-catalog.js.map +1 -1
  16. package/src/lib/app-utils/collection-entries.d.ts +21 -0
  17. package/src/lib/app-utils/collection-entries.js +20 -1
  18. package/src/lib/app-utils/collection-entries.js.map +1 -1
  19. package/src/lib/app-utils/docs-help.generated.d.ts +1 -1
  20. package/src/lib/app-utils/docs-help.generated.js +1 -0
  21. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  22. package/src/lib/app-utils/docs-index.generated.js +22 -15
  23. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  24. package/src/lib/app-utils/notifications.d.ts +3 -2
  25. package/src/lib/app-utils/notifications.js +11 -3
  26. package/src/lib/app-utils/notifications.js.map +1 -1
  27. package/src/lib/foundation/definitions/platform.types.d.ts +8 -0
  28. package/src/lib/foundation/definitions/platform.types.js.map +1 -1
  29. package/src/lib/plugin-manager/feature-plugins.d.ts +38 -5
  30. package/src/lib/plugin-manager/feature-plugins.js +11 -1
  31. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/aglyn",
3
- "version": "1.0.0-beta.225",
3
+ "version": "1.0.0-beta.227",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -37,16 +37,16 @@
37
37
  "./package.json": "./package.json"
38
38
  },
39
39
  "dependencies": {
40
- "@aglyn/shared-data-enums": "1.0.0-beta.225",
41
- "@aglyn/shared-data-mdi": "1.0.0-beta.225",
42
- "@aglyn/shared-data-types": "1.0.0-beta.225",
43
- "@aglyn/shared-util-email": "1.0.0-beta.225",
44
- "@aglyn/shared-util-first-touch": "1.0.0-beta.225",
45
- "@aglyn/shared-util-http": "1.0.0-beta.225",
46
- "@aglyn/shared-util-logger": "1.0.0-beta.225",
47
- "@aglyn/shared-util-timestamp": "1.0.0-beta.225",
48
- "@aglyn/shared-util-tools": "1.0.0-beta.225",
49
- "@aglyn/shared-util-vendor": "1.0.0-beta.225",
40
+ "@aglyn/shared-data-enums": "1.0.0-beta.227",
41
+ "@aglyn/shared-data-mdi": "1.0.0-beta.227",
42
+ "@aglyn/shared-data-types": "1.0.0-beta.227",
43
+ "@aglyn/shared-util-email": "1.0.0-beta.227",
44
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.227",
45
+ "@aglyn/shared-util-http": "1.0.0-beta.227",
46
+ "@aglyn/shared-util-logger": "1.0.0-beta.227",
47
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.227",
48
+ "@aglyn/shared-util-tools": "1.0.0-beta.227",
49
+ "@aglyn/shared-util-vendor": "1.0.0-beta.227",
50
50
  "@data-driven-forms/react-form-renderer": "^4.2.0",
51
51
  "@msgpack/msgpack": "^3.1.3",
52
52
  "@types/unist": "^3.0.3",
@@ -89,6 +89,11 @@ export interface AccountAcquisition {
89
89
  utm: FirstTouchUtm | null;
90
90
  /** Which ad click ids the landing URL carried — presence only. */
91
91
  clickIds: FirstTouchClickId[];
92
+ /**
93
+ * The Google Ads campaign the click came from (`gad_campaignid`), which
94
+ * auto-tagging appends whether or not the ad carries a `utm_campaign`.
95
+ */
96
+ adCampaignId: string | null;
92
97
  /** How the account was created. */
93
98
  door: AcquisitionDoor;
94
99
  /** The sign-in provider it was created with: `password`, `google.com`, `saml.…`. */
@@ -150,6 +155,8 @@ export declare function organizationAcquisition(creatorRecord: unknown, creatorU
150
155
  * record at all.
151
156
  */
152
157
  export declare function readAccountAcquisition(value: unknown): AccountAcquisition | null;
158
+ /** The Google Ads page for a campaign id, opened in whichever account is signed in. */
159
+ export declare function googleAdsCampaignUrl(campaignId: string): string;
153
160
  /** A sign-in provider id as a person reads it. */
154
161
  export declare function providerLabel(provider: string | null | undefined): string | null;
155
162
  /**
@@ -98,7 +98,7 @@ function uidOf(value) {
98
98
  * a fact about the account too, and a card that renders blank would read as
99
99
  * "never asked".
100
100
  */ export function buildAccountAcquisition(input) {
101
- var _ref, _touch_via, _touch_utm, _touch_click;
101
+ var _ref, _touch_via, _touch_utm, _touch_click, _touch_adCampaign;
102
102
  var _touch_utm1;
103
103
  const touch = sanitizeFirstTouch(input.touch, input.nowMs);
104
104
  const base = {
@@ -126,7 +126,8 @@ function uidOf(value) {
126
126
  referrerHost: touch.ref,
127
127
  viaHost: (_touch_via = touch.via) != null ? _touch_via : null,
128
128
  utm: (_touch_utm = touch.utm) != null ? _touch_utm : null,
129
- clickIds: (_touch_click = touch.click) != null ? _touch_click : []
129
+ clickIds: (_touch_click = touch.click) != null ? _touch_click : [],
130
+ adCampaignId: (_touch_adCampaign = touch.adCampaign) != null ? _touch_adCampaign : null
130
131
  }, base);
131
132
  }
132
133
  /** A record for an account nothing was captured for. */ export function unknownAccountAcquisition(input) {
@@ -143,6 +144,7 @@ function uidOf(value) {
143
144
  viaHost: null,
144
145
  utm: null,
145
146
  clickIds: [],
147
+ adCampaignId: null,
146
148
  door: (_input_door = input.door) != null ? _input_door : 'unknown',
147
149
  provider: (_input_provider = input.provider) != null ? _input_provider : null,
148
150
  invitedToOrgId: (_input_invitedToOrgId = input.invitedToOrgId) != null ? _input_invitedToOrgId : null,
@@ -221,6 +223,7 @@ function uidOf(value) {
221
223
  viaHost: label(raw['viaHost'], 253),
222
224
  utm: Object.keys(utm).length ? utm : null,
223
225
  clickIds,
226
+ adCampaignId: typeof raw['adCampaignId'] === 'string' && /^\d{1,20}$/.test(raw['adCampaignId']) ? raw['adCampaignId'] : null,
224
227
  door: doorOf(raw['door']),
225
228
  provider: providerOf(raw['provider']),
226
229
  invitedToOrgId: uidOf(raw['invitedToOrgId']),
@@ -233,6 +236,9 @@ function uidOf(value) {
233
236
  if (copiedFrom) record.copiedFromUid = copiedFrom;
234
237
  return record;
235
238
  }
239
+ /** The Google Ads page for a campaign id, opened in whichever account is signed in. */ export function googleAdsCampaignUrl(campaignId) {
240
+ return `https://ads.google.com/aw/campaigns?campaignId=${encodeURIComponent(campaignId)}`;
241
+ }
236
242
  /** A sign-in provider id as a person reads it. */ export function providerLabel(provider) {
237
243
  if (!provider) return null;
238
244
  if (provider === 'password') return 'password';
@@ -244,8 +250,10 @@ function uidOf(value) {
244
250
  return provider;
245
251
  }
246
252
  function channelPhrase(record) {
253
+ var _record_campaign;
247
254
  const from = displayHost(record.referrerHost) || record.source;
248
- const campaign = record.campaign ? `, ${record.campaign}` : '';
255
+ const named = (_record_campaign = record.campaign) != null ? _record_campaign : record.adCampaignId ? `campaign ${record.adCampaignId}` : null;
256
+ const campaign = named ? `, ${named}` : '';
249
257
  switch(record.channel){
250
258
  case 'referral':
251
259
  return `Referral from ${from}${campaign}`;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/account-acquisition.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n classifyChannel,\n displayHost,\n sanitizeFirstTouch,\n sourceAndMedium,\n type AcquisitionChannel,\n type FirstTouchClickId,\n type FirstTouchUtm,\n} from '@aglyn/shared-util-first-touch'\n\n/**\n * Where an account came from, written ONCE on the account when it is created\n * (AGL-3289) — `users/{uid}.acquisition`, and on the organization created in\n * the same flow as `orgs/{orgId}.acquisition`.\n *\n * ## Why this exists\n *\n * Answering \"where did this person come from?\" for one new workspace took a\n * sales session twenty-five minutes across five tools: the staff user page\n * had no source, Firestore held nothing beyond names, and the answer — a\n * review site's referral — was finally found in an analytics property and in\n * the marketing host's daily referrer counts. The platform never stored the\n * fact on the account it was about. This record is that fact, read from one\n * card on the staff user and organization pages.\n *\n * ## What writes it, and what never may\n *\n * The capture library keeps the first touch on the visitor's device; the\n * platform turns it into this record at account creation, server-side, and\n * the rules refuse the field to every client — owner and staff alike. A\n * record the account holder could restate would be a record nobody could\n * trust. It is never overwritten: a later touch, if one is ever kept, goes\n * BESIDE it as its own field.\n *\n * Every field here is built from the untrusted touch by re-scrubbing it, the\n * same way the capture scrubbed it on the device, and nothing the record says\n * grants anything: it is a label for a staff reader.\n */\n\n/** The field on `users/{uid}` and `orgs/{orgId}`. */\nexport const ACCOUNT_ACQUISITION_FIELD = 'acquisition'\n\n/**\n * How an account was created: the signup form with a password, the signup\n * form through Google, accepting an invitation, or single sign-on.\n */\nexport const ACQUISITION_DOORS = [\n 'signup-password',\n 'signup-google',\n 'invite',\n 'sso',\n] as const\nexport type AcquisitionDoor = (typeof ACQUISITION_DOORS)[number] | 'unknown'\n\n/** What wrote a record: a door at creation, an organization copying its creator's, or the backfill. */\nexport type AcquisitionRecordedBy = 'signup' | 'sso' | 'org-creation' | 'backfill'\n\n/** Where the account-creating request came from, as the edge reported it. */\nexport interface AcquisitionGeo {\n country: string | null\n region: string | null\n city: string | null\n}\n\n/** The stored record. */\nexport interface AccountAcquisition {\n v: 1\n /**\n * Where the visit came from, spelled the way reports spell it — a\n * `utm_source`, a referring host, `(direct)` — or `unknown` when nothing was\n * captured.\n */\n source: string\n /** `utm_medium`, or what the channel implies; null when unknown. */\n medium: string | null\n /** `utm_campaign`, when the landing URL named one. */\n campaign: string | null\n /** The channel, from the platform's own rule table. */\n channel: AcquisitionChannel\n /** When the first visit landed; null when it was never captured. */\n capturedAt: number | null\n /** The first page the visitor landed on. */\n landing: { host: string; path: string } | null\n /** The external host that sent them. */\n referrerHost: string | null\n /** One of the platform's own hosts they came from before anything captured them. */\n viaHost: string | null\n /** Every `utm_*` the landing URL carried. */\n utm: FirstTouchUtm | null\n /** Which ad click ids the landing URL carried — presence only. */\n clickIds: FirstTouchClickId[]\n /** How the account was created. */\n door: AcquisitionDoor\n /** The sign-in provider it was created with: `password`, `google.com`, `saml.…`. */\n provider: string | null\n /** The organization whose invitation the person signed up to accept. */\n invitedToOrgId: string | null\n /** Where the account-creating request came from. */\n geo: AcquisitionGeo | null\n /** When the auth record says the account was created. */\n accountCreatedAt: number | null\n /** When this record was written. */\n recordedAt: number\n recordedBy: AcquisitionRecordedBy\n /** On an organization's copy: the account it was copied from. */\n copiedFromUid?: string\n}\n\nconst MAX_LABEL = 100\nconst PROVIDER_SHAPE = /^[a-z0-9][a-z0-9._-]{0,63}$/i\nconst RECORDED_BY: readonly AcquisitionRecordedBy[] = [\n 'signup',\n 'sso',\n 'org-creation',\n 'backfill',\n]\n\nfunction label(value: unknown, max = MAX_LABEL): string | null {\n if (typeof value !== 'string') return null\n const clean = value.replace(/[\\u0000-\\u001f\\u007f]/g, '').trim() // eslint-disable-line no-control-regex\n return clean ? clean.slice(0, max) : null\n}\n\nfunction doorOf(value: unknown): AcquisitionDoor {\n return (ACQUISITION_DOORS as readonly string[]).includes(value as string)\n ? (value as AcquisitionDoor)\n : 'unknown'\n}\n\nfunction providerOf(value: unknown): string | null {\n return typeof value === 'string' && PROVIDER_SHAPE.test(value.trim())\n ? value.trim().toLowerCase()\n : null\n}\n\nfunction geoOf(value: unknown): AcquisitionGeo | null {\n if (!value || typeof value !== 'object') return null\n const raw = value as Record<string, unknown>\n const geo: AcquisitionGeo = {\n country: label(raw['country'], 8)?.toUpperCase() ?? null,\n region: label(raw['region'], 64),\n city: label(raw['city'], 64),\n }\n return geo.country || geo.region || geo.city ? geo : null\n}\n\nfunction millisOf(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null\n}\n\nfunction uidOf(value: unknown): string | null {\n return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value) ? value : null\n}\n\n/** What a door knows when it records an account. */\nexport interface AccountAcquisitionInput {\n /** The first touch the capture kept — untrusted, from the device. */\n touch: unknown\n door: unknown\n provider?: unknown\n geo?: unknown\n invitedToOrgId?: unknown\n /** When the auth record says the account was created. */\n accountCreatedAtMs?: number | null\n recordedBy: AcquisitionRecordedBy\n nowMs: number\n}\n\n/**\n * The record for an account being created now.\n *\n * A missing or unusable touch still produces a record — `source: 'unknown'`\n * with whatever the door knows — because \"we asked and nothing came back\" is\n * a fact about the account too, and a card that renders blank would read as\n * \"never asked\".\n */\nexport function buildAccountAcquisition(input: AccountAcquisitionInput): AccountAcquisition {\n const touch = sanitizeFirstTouch(input.touch, input.nowMs)\n const base = {\n door: doorOf(input.door),\n provider: providerOf(input.provider),\n invitedToOrgId: uidOf(input.invitedToOrgId),\n geo: geoOf(input.geo),\n accountCreatedAt: millisOf(input.accountCreatedAtMs),\n recordedAt: input.nowMs,\n recordedBy: input.recordedBy,\n }\n if (!touch) return unknownAccountAcquisition(base)\n const { source, medium } = sourceAndMedium(touch)\n return {\n v: 1,\n source,\n medium,\n campaign: touch.utm?.campaign ?? null,\n channel: classifyChannel(touch),\n capturedAt: touch.at,\n landing: { host: touch.host, path: touch.path },\n referrerHost: touch.ref,\n viaHost: touch.via ?? null,\n utm: touch.utm ?? null,\n clickIds: touch.click ?? [],\n ...base,\n }\n}\n\n/** A record for an account nothing was captured for. */\nexport function unknownAccountAcquisition(input: {\n recordedAt: number\n recordedBy: AcquisitionRecordedBy\n door?: AcquisitionDoor\n provider?: string | null\n invitedToOrgId?: string | null\n geo?: AcquisitionGeo | null\n accountCreatedAt?: number | null\n}): AccountAcquisition {\n return {\n v: 1,\n source: 'unknown',\n medium: null,\n campaign: null,\n channel: 'unknown',\n capturedAt: null,\n landing: null,\n referrerHost: null,\n viaHost: null,\n utm: null,\n clickIds: [],\n door: input.door ?? 'unknown',\n provider: input.provider ?? null,\n invitedToOrgId: input.invitedToOrgId ?? null,\n geo: input.geo ?? null,\n accountCreatedAt: input.accountCreatedAt ?? null,\n recordedAt: input.recordedAt,\n recordedBy: input.recordedBy,\n }\n}\n\n/**\n * The organization's copy of its creator's record, or an `unknown` one naming\n * the creator when the account has none — so an organization's card is never\n * blank either, and always says whose record it is.\n */\nexport function organizationAcquisition(\n creatorRecord: unknown,\n creatorUid: string,\n nowMs: number,\n): AccountAcquisition {\n const record = readAccountAcquisition(creatorRecord)\n const copied = record\n ? { ...record }\n : unknownAccountAcquisition({ recordedAt: nowMs, recordedBy: 'org-creation' })\n delete copied.copiedFromUid\n return { ...copied, copiedFromUid: creatorUid }\n}\n\n/**\n * A stored record as a reader should trust it: re-validated field by field,\n * so a document written by an older version, a backfill or a hand edit still\n * renders, and renders nothing it should not. Null when the value is not a\n * record at all.\n */\nexport function readAccountAcquisition(value: unknown): AccountAcquisition | null {\n if (!value || typeof value !== 'object') return null\n const raw = value as Record<string, unknown>\n const source = label(raw['source'])\n if (!source) return null\n const channel = raw['channel']\n const landing = raw['landing'] as Record<string, unknown> | null\n const utmRaw = raw['utm'] as Record<string, unknown> | null\n const utm: FirstTouchUtm = {}\n for (const key of ['source', 'medium', 'campaign', 'content', 'term'] as const) {\n const cleaned = label(utmRaw?.[key])\n if (cleaned) utm[key] = cleaned\n }\n const clickIds = Array.isArray(raw['clickIds'])\n ? (['gclid', 'fbclid', 'msclkid'] as const).filter((id) =>\n (raw['clickIds'] as unknown[]).includes(id),\n )\n : []\n const recordedBy = RECORDED_BY.includes(raw['recordedBy'] as AcquisitionRecordedBy)\n ? (raw['recordedBy'] as AcquisitionRecordedBy)\n : 'backfill'\n const record: AccountAcquisition = {\n v: 1,\n source,\n medium: label(raw['medium']),\n campaign: label(raw['campaign']),\n channel:\n typeof channel === 'string' &&\n [\n 'organic-search',\n 'paid-search',\n 'social',\n 'referral',\n 'email',\n 'direct',\n 'unknown',\n ].includes(channel)\n ? (channel as AcquisitionChannel)\n : 'unknown',\n capturedAt:\n typeof raw['capturedAt'] === 'number' && raw['capturedAt'] > 0\n ? (raw['capturedAt'] as number)\n : null,\n landing:\n landing && label(landing['host'], 253)\n ? { host: label(landing['host'], 253) as string, path: label(landing['path'], 200) ?? '/' }\n : null,\n referrerHost: label(raw['referrerHost'], 253),\n viaHost: label(raw['viaHost'], 253),\n utm: Object.keys(utm).length ? utm : null,\n clickIds,\n door: doorOf(raw['door']),\n provider: providerOf(raw['provider']),\n invitedToOrgId: uidOf(raw['invitedToOrgId']),\n geo: geoOf(raw['geo']),\n accountCreatedAt: millisOf(raw['accountCreatedAt']),\n recordedAt:\n typeof raw['recordedAt'] === 'number' ? (raw['recordedAt'] as number) : 0,\n recordedBy,\n }\n const copiedFrom = uidOf(raw['copiedFromUid'])\n if (copiedFrom) record.copiedFromUid = copiedFrom\n return record\n}\n\n/** A sign-in provider id as a person reads it. */\nexport function providerLabel(provider: string | null | undefined): string | null {\n if (!provider) return null\n if (provider === 'password') return 'password'\n if (provider === 'google.com') return 'Google'\n if (provider === 'apple.com') return 'Apple'\n if (provider === 'microsoft.com') return 'Microsoft'\n if (provider === 'github.com') return 'GitHub'\n if (provider.startsWith('saml.') || provider.startsWith('oidc.')) return 'single sign-on'\n return provider\n}\n\nfunction channelPhrase(record: AccountAcquisition): string {\n const from = displayHost(record.referrerHost) || record.source\n const campaign = record.campaign ? `, ${record.campaign}` : ''\n switch (record.channel) {\n case 'referral':\n return `Referral from ${from}${campaign}`\n case 'organic-search':\n return `Organic search from ${from}`\n case 'paid-search':\n return `Paid search (${record.source}${campaign})`\n case 'social':\n return `Social from ${from}${campaign}`\n case 'email':\n return `Email (${record.source}${campaign})`\n case 'direct':\n return record.viaHost\n ? `Direct, first seen arriving from ${record.viaHost}`\n : 'Direct'\n default:\n return record.recordedBy === 'backfill'\n ? 'Source unknown — the account predates capture'\n : 'Source unknown — nothing was captured'\n }\n}\n\nfunction doorPhrase(record: AccountAcquisition): string {\n const provider = providerLabel(record.provider)\n switch (record.door) {\n case 'signup-password':\n return 'signed up with password'\n case 'signup-google':\n return 'signed up with Google'\n case 'invite':\n return provider\n ? `signed up from an invitation, with ${provider}`\n : 'signed up from an invitation'\n case 'sso':\n return 'signed in with single sign-on'\n default:\n return provider ? `created with ${provider}` : 'created'\n }\n}\n\n/**\n * The record as one sentence a staff member reads at a glance:\n * \"Referral from g2.com → /pricing → signed up with password\".\n *\n * The landing is a bare path when it was on `primaryHost` — the platform's own\n * home — and host-and-path anywhere else, so a docs landing says so.\n */\nexport function describeAccountAcquisition(\n record: AccountAcquisition | null | undefined,\n options: { primaryHost?: string | null } = {},\n): string {\n if (!record) return 'No acquisition record'\n const parts = [channelPhrase(record)]\n if (record.landing) {\n const onPrimary =\n options.primaryHost &&\n displayHost(record.landing.host) === displayHost(options.primaryHost)\n parts.push(\n onPrimary ? record.landing.path : `${displayHost(record.landing.host)}${record.landing.path}`,\n )\n }\n parts.push(doorPhrase(record))\n return parts.join(' → ')\n}\n"],"names":["classifyChannel","displayHost","sanitizeFirstTouch","sourceAndMedium","ACCOUNT_ACQUISITION_FIELD","ACQUISITION_DOORS","MAX_LABEL","PROVIDER_SHAPE","RECORDED_BY","label","value","max","clean","replace","trim","slice","doorOf","includes","providerOf","test","toLowerCase","geoOf","raw","geo","country","toUpperCase","region","city","millisOf","Number","isFinite","uidOf","buildAccountAcquisition","input","touch","nowMs","base","door","provider","invitedToOrgId","accountCreatedAt","accountCreatedAtMs","recordedAt","recordedBy","unknownAccountAcquisition","source","medium","v","campaign","utm","channel","capturedAt","at","landing","host","path","referrerHost","ref","viaHost","via","clickIds","click","organizationAcquisition","creatorRecord","creatorUid","record","readAccountAcquisition","copied","copiedFromUid","utmRaw","key","cleaned","Array","isArray","filter","id","Object","keys","length","copiedFrom","providerLabel","startsWith","channelPhrase","from","doorPhrase","describeAccountAcquisition","options","parts","onPrimary","primaryHost","push","join"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/account-acquisition.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n classifyChannel,\n displayHost,\n sanitizeFirstTouch,\n sourceAndMedium,\n type AcquisitionChannel,\n type FirstTouchClickId,\n type FirstTouchUtm,\n} from '@aglyn/shared-util-first-touch'\n\n/**\n * Where an account came from, written ONCE on the account when it is created\n * (AGL-3289) — `users/{uid}.acquisition`, and on the organization created in\n * the same flow as `orgs/{orgId}.acquisition`.\n *\n * ## Why this exists\n *\n * Answering \"where did this person come from?\" for one new workspace took a\n * sales session twenty-five minutes across five tools: the staff user page\n * had no source, Firestore held nothing beyond names, and the answer — a\n * review site's referral — was finally found in an analytics property and in\n * the marketing host's daily referrer counts. The platform never stored the\n * fact on the account it was about. This record is that fact, read from one\n * card on the staff user and organization pages.\n *\n * ## What writes it, and what never may\n *\n * The capture library keeps the first touch on the visitor's device; the\n * platform turns it into this record at account creation, server-side, and\n * the rules refuse the field to every client — owner and staff alike. A\n * record the account holder could restate would be a record nobody could\n * trust. It is never overwritten: a later touch, if one is ever kept, goes\n * BESIDE it as its own field.\n *\n * Every field here is built from the untrusted touch by re-scrubbing it, the\n * same way the capture scrubbed it on the device, and nothing the record says\n * grants anything: it is a label for a staff reader.\n */\n\n/** The field on `users/{uid}` and `orgs/{orgId}`. */\nexport const ACCOUNT_ACQUISITION_FIELD = 'acquisition'\n\n/**\n * How an account was created: the signup form with a password, the signup\n * form through Google, accepting an invitation, or single sign-on.\n */\nexport const ACQUISITION_DOORS = [\n 'signup-password',\n 'signup-google',\n 'invite',\n 'sso',\n] as const\nexport type AcquisitionDoor = (typeof ACQUISITION_DOORS)[number] | 'unknown'\n\n/** What wrote a record: a door at creation, an organization copying its creator's, or the backfill. */\nexport type AcquisitionRecordedBy = 'signup' | 'sso' | 'org-creation' | 'backfill'\n\n/** Where the account-creating request came from, as the edge reported it. */\nexport interface AcquisitionGeo {\n country: string | null\n region: string | null\n city: string | null\n}\n\n/** The stored record. */\nexport interface AccountAcquisition {\n v: 1\n /**\n * Where the visit came from, spelled the way reports spell it — a\n * `utm_source`, a referring host, `(direct)` — or `unknown` when nothing was\n * captured.\n */\n source: string\n /** `utm_medium`, or what the channel implies; null when unknown. */\n medium: string | null\n /** `utm_campaign`, when the landing URL named one. */\n campaign: string | null\n /** The channel, from the platform's own rule table. */\n channel: AcquisitionChannel\n /** When the first visit landed; null when it was never captured. */\n capturedAt: number | null\n /** The first page the visitor landed on. */\n landing: { host: string; path: string } | null\n /** The external host that sent them. */\n referrerHost: string | null\n /** One of the platform's own hosts they came from before anything captured them. */\n viaHost: string | null\n /** Every `utm_*` the landing URL carried. */\n utm: FirstTouchUtm | null\n /** Which ad click ids the landing URL carried — presence only. */\n clickIds: FirstTouchClickId[]\n /**\n * The Google Ads campaign the click came from (`gad_campaignid`), which\n * auto-tagging appends whether or not the ad carries a `utm_campaign`.\n */\n adCampaignId: string | null\n /** How the account was created. */\n door: AcquisitionDoor\n /** The sign-in provider it was created with: `password`, `google.com`, `saml.…`. */\n provider: string | null\n /** The organization whose invitation the person signed up to accept. */\n invitedToOrgId: string | null\n /** Where the account-creating request came from. */\n geo: AcquisitionGeo | null\n /** When the auth record says the account was created. */\n accountCreatedAt: number | null\n /** When this record was written. */\n recordedAt: number\n recordedBy: AcquisitionRecordedBy\n /** On an organization's copy: the account it was copied from. */\n copiedFromUid?: string\n}\n\nconst MAX_LABEL = 100\nconst PROVIDER_SHAPE = /^[a-z0-9][a-z0-9._-]{0,63}$/i\nconst RECORDED_BY: readonly AcquisitionRecordedBy[] = [\n 'signup',\n 'sso',\n 'org-creation',\n 'backfill',\n]\n\nfunction label(value: unknown, max = MAX_LABEL): string | null {\n if (typeof value !== 'string') return null\n const clean = value.replace(/[\\u0000-\\u001f\\u007f]/g, '').trim() // eslint-disable-line no-control-regex\n return clean ? clean.slice(0, max) : null\n}\n\nfunction doorOf(value: unknown): AcquisitionDoor {\n return (ACQUISITION_DOORS as readonly string[]).includes(value as string)\n ? (value as AcquisitionDoor)\n : 'unknown'\n}\n\nfunction providerOf(value: unknown): string | null {\n return typeof value === 'string' && PROVIDER_SHAPE.test(value.trim())\n ? value.trim().toLowerCase()\n : null\n}\n\nfunction geoOf(value: unknown): AcquisitionGeo | null {\n if (!value || typeof value !== 'object') return null\n const raw = value as Record<string, unknown>\n const geo: AcquisitionGeo = {\n country: label(raw['country'], 8)?.toUpperCase() ?? null,\n region: label(raw['region'], 64),\n city: label(raw['city'], 64),\n }\n return geo.country || geo.region || geo.city ? geo : null\n}\n\nfunction millisOf(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null\n}\n\nfunction uidOf(value: unknown): string | null {\n return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value) ? value : null\n}\n\n/** What a door knows when it records an account. */\nexport interface AccountAcquisitionInput {\n /** The first touch the capture kept — untrusted, from the device. */\n touch: unknown\n door: unknown\n provider?: unknown\n geo?: unknown\n invitedToOrgId?: unknown\n /** When the auth record says the account was created. */\n accountCreatedAtMs?: number | null\n recordedBy: AcquisitionRecordedBy\n nowMs: number\n}\n\n/**\n * The record for an account being created now.\n *\n * A missing or unusable touch still produces a record — `source: 'unknown'`\n * with whatever the door knows — because \"we asked and nothing came back\" is\n * a fact about the account too, and a card that renders blank would read as\n * \"never asked\".\n */\nexport function buildAccountAcquisition(input: AccountAcquisitionInput): AccountAcquisition {\n const touch = sanitizeFirstTouch(input.touch, input.nowMs)\n const base = {\n door: doorOf(input.door),\n provider: providerOf(input.provider),\n invitedToOrgId: uidOf(input.invitedToOrgId),\n geo: geoOf(input.geo),\n accountCreatedAt: millisOf(input.accountCreatedAtMs),\n recordedAt: input.nowMs,\n recordedBy: input.recordedBy,\n }\n if (!touch) return unknownAccountAcquisition(base)\n const { source, medium } = sourceAndMedium(touch)\n return {\n v: 1,\n source,\n medium,\n campaign: touch.utm?.campaign ?? null,\n channel: classifyChannel(touch),\n capturedAt: touch.at,\n landing: { host: touch.host, path: touch.path },\n referrerHost: touch.ref,\n viaHost: touch.via ?? null,\n utm: touch.utm ?? null,\n clickIds: touch.click ?? [],\n adCampaignId: touch.adCampaign ?? null,\n ...base,\n }\n}\n\n/** A record for an account nothing was captured for. */\nexport function unknownAccountAcquisition(input: {\n recordedAt: number\n recordedBy: AcquisitionRecordedBy\n door?: AcquisitionDoor\n provider?: string | null\n invitedToOrgId?: string | null\n geo?: AcquisitionGeo | null\n accountCreatedAt?: number | null\n}): AccountAcquisition {\n return {\n v: 1,\n source: 'unknown',\n medium: null,\n campaign: null,\n channel: 'unknown',\n capturedAt: null,\n landing: null,\n referrerHost: null,\n viaHost: null,\n utm: null,\n clickIds: [],\n adCampaignId: null,\n door: input.door ?? 'unknown',\n provider: input.provider ?? null,\n invitedToOrgId: input.invitedToOrgId ?? null,\n geo: input.geo ?? null,\n accountCreatedAt: input.accountCreatedAt ?? null,\n recordedAt: input.recordedAt,\n recordedBy: input.recordedBy,\n }\n}\n\n/**\n * The organization's copy of its creator's record, or an `unknown` one naming\n * the creator when the account has none — so an organization's card is never\n * blank either, and always says whose record it is.\n */\nexport function organizationAcquisition(\n creatorRecord: unknown,\n creatorUid: string,\n nowMs: number,\n): AccountAcquisition {\n const record = readAccountAcquisition(creatorRecord)\n const copied = record\n ? { ...record }\n : unknownAccountAcquisition({ recordedAt: nowMs, recordedBy: 'org-creation' })\n delete copied.copiedFromUid\n return { ...copied, copiedFromUid: creatorUid }\n}\n\n/**\n * A stored record as a reader should trust it: re-validated field by field,\n * so a document written by an older version, a backfill or a hand edit still\n * renders, and renders nothing it should not. Null when the value is not a\n * record at all.\n */\nexport function readAccountAcquisition(value: unknown): AccountAcquisition | null {\n if (!value || typeof value !== 'object') return null\n const raw = value as Record<string, unknown>\n const source = label(raw['source'])\n if (!source) return null\n const channel = raw['channel']\n const landing = raw['landing'] as Record<string, unknown> | null\n const utmRaw = raw['utm'] as Record<string, unknown> | null\n const utm: FirstTouchUtm = {}\n for (const key of ['source', 'medium', 'campaign', 'content', 'term'] as const) {\n const cleaned = label(utmRaw?.[key])\n if (cleaned) utm[key] = cleaned\n }\n const clickIds = Array.isArray(raw['clickIds'])\n ? (['gclid', 'fbclid', 'msclkid'] as const).filter((id) =>\n (raw['clickIds'] as unknown[]).includes(id),\n )\n : []\n const recordedBy = RECORDED_BY.includes(raw['recordedBy'] as AcquisitionRecordedBy)\n ? (raw['recordedBy'] as AcquisitionRecordedBy)\n : 'backfill'\n const record: AccountAcquisition = {\n v: 1,\n source,\n medium: label(raw['medium']),\n campaign: label(raw['campaign']),\n channel:\n typeof channel === 'string' &&\n [\n 'organic-search',\n 'paid-search',\n 'social',\n 'referral',\n 'email',\n 'direct',\n 'unknown',\n ].includes(channel)\n ? (channel as AcquisitionChannel)\n : 'unknown',\n capturedAt:\n typeof raw['capturedAt'] === 'number' && raw['capturedAt'] > 0\n ? (raw['capturedAt'] as number)\n : null,\n landing:\n landing && label(landing['host'], 253)\n ? { host: label(landing['host'], 253) as string, path: label(landing['path'], 200) ?? '/' }\n : null,\n referrerHost: label(raw['referrerHost'], 253),\n viaHost: label(raw['viaHost'], 253),\n utm: Object.keys(utm).length ? utm : null,\n clickIds,\n adCampaignId:\n typeof raw['adCampaignId'] === 'string' && /^\\d{1,20}$/.test(raw['adCampaignId'])\n ? raw['adCampaignId']\n : null,\n door: doorOf(raw['door']),\n provider: providerOf(raw['provider']),\n invitedToOrgId: uidOf(raw['invitedToOrgId']),\n geo: geoOf(raw['geo']),\n accountCreatedAt: millisOf(raw['accountCreatedAt']),\n recordedAt:\n typeof raw['recordedAt'] === 'number' ? (raw['recordedAt'] as number) : 0,\n recordedBy,\n }\n const copiedFrom = uidOf(raw['copiedFromUid'])\n if (copiedFrom) record.copiedFromUid = copiedFrom\n return record\n}\n\n/** The Google Ads page for a campaign id, opened in whichever account is signed in. */\nexport function googleAdsCampaignUrl(campaignId: string): string {\n return `https://ads.google.com/aw/campaigns?campaignId=${encodeURIComponent(campaignId)}`\n}\n\n/** A sign-in provider id as a person reads it. */\nexport function providerLabel(provider: string | null | undefined): string | null {\n if (!provider) return null\n if (provider === 'password') return 'password'\n if (provider === 'google.com') return 'Google'\n if (provider === 'apple.com') return 'Apple'\n if (provider === 'microsoft.com') return 'Microsoft'\n if (provider === 'github.com') return 'GitHub'\n if (provider.startsWith('saml.') || provider.startsWith('oidc.')) return 'single sign-on'\n return provider\n}\n\nfunction channelPhrase(record: AccountAcquisition): string {\n const from = displayHost(record.referrerHost) || record.source\n const named = record.campaign ?? (record.adCampaignId ? `campaign ${record.adCampaignId}` : null)\n const campaign = named ? `, ${named}` : ''\n switch (record.channel) {\n case 'referral':\n return `Referral from ${from}${campaign}`\n case 'organic-search':\n return `Organic search from ${from}`\n case 'paid-search':\n return `Paid search (${record.source}${campaign})`\n case 'social':\n return `Social from ${from}${campaign}`\n case 'email':\n return `Email (${record.source}${campaign})`\n case 'direct':\n return record.viaHost\n ? `Direct, first seen arriving from ${record.viaHost}`\n : 'Direct'\n default:\n return record.recordedBy === 'backfill'\n ? 'Source unknown — the account predates capture'\n : 'Source unknown — nothing was captured'\n }\n}\n\nfunction doorPhrase(record: AccountAcquisition): string {\n const provider = providerLabel(record.provider)\n switch (record.door) {\n case 'signup-password':\n return 'signed up with password'\n case 'signup-google':\n return 'signed up with Google'\n case 'invite':\n return provider\n ? `signed up from an invitation, with ${provider}`\n : 'signed up from an invitation'\n case 'sso':\n return 'signed in with single sign-on'\n default:\n return provider ? `created with ${provider}` : 'created'\n }\n}\n\n/**\n * The record as one sentence a staff member reads at a glance:\n * \"Referral from g2.com → /pricing → signed up with password\".\n *\n * The landing is a bare path when it was on `primaryHost` — the platform's own\n * home — and host-and-path anywhere else, so a docs landing says so.\n */\nexport function describeAccountAcquisition(\n record: AccountAcquisition | null | undefined,\n options: { primaryHost?: string | null } = {},\n): string {\n if (!record) return 'No acquisition record'\n const parts = [channelPhrase(record)]\n if (record.landing) {\n const onPrimary =\n options.primaryHost &&\n displayHost(record.landing.host) === displayHost(options.primaryHost)\n parts.push(\n onPrimary ? record.landing.path : `${displayHost(record.landing.host)}${record.landing.path}`,\n )\n }\n parts.push(doorPhrase(record))\n return parts.join(' → ')\n}\n"],"names":["classifyChannel","displayHost","sanitizeFirstTouch","sourceAndMedium","ACCOUNT_ACQUISITION_FIELD","ACQUISITION_DOORS","MAX_LABEL","PROVIDER_SHAPE","RECORDED_BY","label","value","max","clean","replace","trim","slice","doorOf","includes","providerOf","test","toLowerCase","geoOf","raw","geo","country","toUpperCase","region","city","millisOf","Number","isFinite","uidOf","buildAccountAcquisition","input","touch","nowMs","base","door","provider","invitedToOrgId","accountCreatedAt","accountCreatedAtMs","recordedAt","recordedBy","unknownAccountAcquisition","source","medium","v","campaign","utm","channel","capturedAt","at","landing","host","path","referrerHost","ref","viaHost","via","clickIds","click","adCampaignId","adCampaign","organizationAcquisition","creatorRecord","creatorUid","record","readAccountAcquisition","copied","copiedFromUid","utmRaw","key","cleaned","Array","isArray","filter","id","Object","keys","length","copiedFrom","googleAdsCampaignUrl","campaignId","encodeURIComponent","providerLabel","startsWith","channelPhrase","from","named","doorPhrase","describeAccountAcquisition","options","parts","onPrimary","primaryHost","push","join"],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** Next's `after()`, as the callers use it. */
18
+ export type AfterResponse = (task: () => Promise<void>) => void;
19
+ /**
20
+ * Next's `after()`, loaded the first time it is asked for; `null` where
21
+ * `next/server` cannot be loaded or has no `after`. One load per process.
22
+ */
23
+ export declare function loadAfterResponse(label?: string): Promise<AfterResponse | null>;
24
+ /**
25
+ * Runs `task` once the response has been sent, through Next's `after()`.
26
+ * Resolves `false` where there is no request to run after — a script, a
27
+ * spec — and the task is then not run at all. Never rejects. `label`
28
+ * prefixes what is logged, the caller's own (`[media-cdn]`).
29
+ */
30
+ export declare function scheduleAfterResponse(task: () => Promise<void>, label: string): Promise<boolean>;
31
+ /** Test seam: forget the loaded `after()` and what was logged. */
32
+ export declare function resetAfterResponseForTests(): void;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /*==========================================
17
+ * WORK RUN AFTER THE RESPONSE: NEXT'S `after()`, LOADED LAZILY.
18
+ *
19
+ * A serverless invocation is frozen the moment its response is sent, and
20
+ * work scheduled any other way does not run (AGL-2327). `after()` is the
21
+ * one way to keep it. Three callers use it: the media CDN's lazy
22
+ * regeneration, the deliverability check at capture, and
23
+ * `runLegacyHandler` (`api-adapter.ts`), which hands it whatever a
24
+ * streaming handler is still awaiting when its body closes.
25
+ *
26
+ * ## Loaded, not imported
27
+ *
28
+ * `next/server` evaluates web `Request` classes at load, which a jsdom spec
29
+ * reaching one of `tenant-data-admin`'s writers cannot, and the modules
30
+ * that schedule work ride every writer's import. So it is loaded the first time
31
+ * work is scheduled.
32
+ *
33
+ * ## Through `import()`, never `require()`
34
+ *
35
+ * This package is an ES module. Next 16's Turbopack build compiled the
36
+ * `const loaded = require('next/server')` both callers once used into a
37
+ * hoisted `.after` read and left the body naming `loaded`, a binding that
38
+ * no longer existed: every call threw `ReferenceError: loaded is not
39
+ * defined`, the `catch` around it answered "no request", and neither the
40
+ * media CDN's regeneration (AGL-3486) nor the deliverability check at
41
+ * capture (AGL-3328) ever ran in production. A spec cannot see this:
42
+ * `jest.mock('next/server')` answers `require` and `import()` alike.
43
+ * `after-response.spec.ts` holds this package and `tenant-data-admin`
44
+ * to `import()`.
45
+ *
46
+ * ## Every drop is said once
47
+ *
48
+ * A task that could not be scheduled is work that silently did not
49
+ * happen. The first time each caller's task is dropped for a given reason
50
+ * it is logged; after that the same reason stays quiet, so a script
51
+ * writing five hundred rows outside a request says it once, not five
52
+ * hundred times. A task that runs and fails is logged every time.
53
+ *==========================================*/ /** Next's `after()`, as the callers use it. */ let afterResponseLoad = null;
54
+ const logged = new Set();
55
+ function logOnce(label, reason, message, error) {
56
+ const key = `${label}|${reason}`;
57
+ if (logged.has(key)) return;
58
+ logged.add(key);
59
+ if (error === undefined) console.error(`${label} ${message}`);
60
+ else console.error(`${label} ${message}`, error);
61
+ }
62
+ /**
63
+ * Next's `after()`, loaded the first time it is asked for; `null` where
64
+ * `next/server` cannot be loaded or has no `after`. One load per process.
65
+ */ export function loadAfterResponse(label = '[after-response]') {
66
+ afterResponseLoad != null ? afterResponseLoad : afterResponseLoad = import("next/server").then((loaded)=>typeof loaded.after === 'function' ? loaded.after : null, (error)=>{
67
+ logOnce(label, 'load', 'next/server could not be loaded', error);
68
+ return null;
69
+ });
70
+ return afterResponseLoad;
71
+ }
72
+ /**
73
+ * Runs `task` once the response has been sent, through Next's `after()`.
74
+ * Resolves `false` where there is no request to run after — a script, a
75
+ * spec — and the task is then not run at all. Never rejects. `label`
76
+ * prefixes what is logged, the caller's own (`[media-cdn]`).
77
+ */ export async function scheduleAfterResponse(task, label) {
78
+ const after = await loadAfterResponse(label);
79
+ if (!after) {
80
+ logOnce(label, 'unavailable', 'after() is unavailable; the task was not scheduled');
81
+ return false;
82
+ }
83
+ try {
84
+ after(()=>task().catch((error)=>{
85
+ console.error(`${label} after-response task failed`, error);
86
+ }));
87
+ return true;
88
+ } catch (error) {
89
+ logOnce(label, 'refused', 'after() refused the task; it was not scheduled', error);
90
+ return false;
91
+ }
92
+ }
93
+ /** Test seam: forget the loaded `after()` and what was logged. */ export function resetAfterResponseForTests() {
94
+ afterResponseLoad = null;
95
+ logged.clear();
96
+ }
97
+
98
+ //# sourceMappingURL=after-response.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/after-response.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 * WORK RUN AFTER THE RESPONSE: NEXT'S `after()`, LOADED LAZILY.\n *\n * A serverless invocation is frozen the moment its response is sent, and\n * work scheduled any other way does not run (AGL-2327). `after()` is the\n * one way to keep it. Three callers use it: the media CDN's lazy\n * regeneration, the deliverability check at capture, and\n * `runLegacyHandler` (`api-adapter.ts`), which hands it whatever a\n * streaming handler is still awaiting when its body closes.\n *\n * ## Loaded, not imported\n *\n * `next/server` evaluates web `Request` classes at load, which a jsdom spec\n * reaching one of `tenant-data-admin`'s writers cannot, and the modules\n * that schedule work ride every writer's import. So it is loaded the first time\n * work is scheduled.\n *\n * ## Through `import()`, never `require()`\n *\n * This package is an ES module. Next 16's Turbopack build compiled the\n * `const loaded = require('next/server')` both callers once used into a\n * hoisted `.after` read and left the body naming `loaded`, a binding that\n * no longer existed: every call threw `ReferenceError: loaded is not\n * defined`, the `catch` around it answered \"no request\", and neither the\n * media CDN's regeneration (AGL-3486) nor the deliverability check at\n * capture (AGL-3328) ever ran in production. A spec cannot see this:\n * `jest.mock('next/server')` answers `require` and `import()` alike.\n * `after-response.spec.ts` holds this package and `tenant-data-admin`\n * to `import()`.\n *\n * ## Every drop is said once\n *\n * A task that could not be scheduled is work that silently did not\n * happen. The first time each caller's task is dropped for a given reason\n * it is logged; after that the same reason stays quiet, so a script\n * writing five hundred rows outside a request says it once, not five\n * hundred times. A task that runs and fails is logged every time.\n *==========================================*/\n\n/** Next's `after()`, as the callers use it. */\nexport type AfterResponse = (task: () => Promise<void>) => void\n\nlet afterResponseLoad: Promise<AfterResponse | null> | null = null\n\nconst logged = new Set<string>()\n\nfunction logOnce(label: string, reason: string, message: string, error?: unknown): void {\n const key = `${label}|${reason}`\n if (logged.has(key)) return\n logged.add(key)\n if (error === undefined) console.error(`${label} ${message}`)\n else console.error(`${label} ${message}`, error)\n}\n\n/**\n * Next's `after()`, loaded the first time it is asked for; `null` where\n * `next/server` cannot be loaded or has no `after`. One load per process.\n */\nexport function loadAfterResponse(label = '[after-response]'): Promise<AfterResponse | null> {\n afterResponseLoad ??= import('next/server').then(\n (loaded: unknown): AfterResponse | null =>\n typeof (loaded as { after?: unknown }).after === 'function'\n ? (loaded as { after: AfterResponse }).after\n : null,\n (error: unknown): null => {\n logOnce(label, 'load', 'next/server could not be loaded', error)\n return null\n },\n )\n return afterResponseLoad\n}\n\n/**\n * Runs `task` once the response has been sent, through Next's `after()`.\n * Resolves `false` where there is no request to run after — a script, a\n * spec — and the task is then not run at all. Never rejects. `label`\n * prefixes what is logged, the caller's own (`[media-cdn]`).\n */\nexport async function scheduleAfterResponse(task: () => Promise<void>, label: string): Promise<boolean> {\n const after = await loadAfterResponse(label)\n if (!after) {\n logOnce(label, 'unavailable', 'after() is unavailable; the task was not scheduled')\n return false\n }\n try {\n after(() =>\n task().catch((error: unknown) => {\n console.error(`${label} after-response task failed`, error)\n }),\n )\n return true\n } catch (error) {\n logOnce(label, 'refused', 'after() refused the task; it was not scheduled', error)\n return false\n }\n}\n\n/** Test seam: forget the loaded `after()` and what was logged. */\nexport function resetAfterResponseForTests(): void {\n afterResponseLoad = null\n logged.clear()\n}\n"],"names":["afterResponseLoad","logged","Set","logOnce","label","reason","message","error","key","has","add","undefined","console","loadAfterResponse","then","loaded","after","scheduleAfterResponse","task","catch","resetAfterResponseForTests","clear"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4CAqC4C,GAE5C,6CAA6C,GAG7C,IAAIA,oBAA0D;AAE9D,MAAMC,SAAS,IAAIC;AAEnB,SAASC,QAAQC,KAAa,EAAEC,MAAc,EAAEC,OAAe,EAAEC,KAAe;IAC9E,MAAMC,MAAM,GAAGJ,MAAM,CAAC,EAAEC,QAAQ;IAChC,IAAIJ,OAAOQ,GAAG,CAACD,MAAM;IACrBP,OAAOS,GAAG,CAACF;IACX,IAAID,UAAUI,WAAWC,QAAQL,KAAK,CAAC,GAAGH,MAAM,CAAC,EAAEE,SAAS;SACvDM,QAAQL,KAAK,CAAC,GAAGH,MAAM,CAAC,EAAEE,SAAS,EAAEC;AAC5C;AAEA;;;CAGC,GACD,OAAO,SAASM,kBAAkBT,QAAQ,kBAAkB;IAC1DJ,4BAAAA,oBAAAA,oBAAsB,MAAM,CAAC,eAAec,IAAI,CAC9C,CAACC,SACC,OAAO,AAACA,OAA+BC,KAAK,KAAK,aAC7C,AAACD,OAAoCC,KAAK,GAC1C,MACN,CAACT;QACCJ,QAAQC,OAAO,QAAQ,mCAAmCG;QAC1D,OAAO;IACT;IAEF,OAAOP;AACT;AAEA;;;;;CAKC,GACD,OAAO,eAAeiB,sBAAsBC,IAAyB,EAAEd,KAAa;IAClF,MAAMY,QAAQ,MAAMH,kBAAkBT;IACtC,IAAI,CAACY,OAAO;QACVb,QAAQC,OAAO,eAAe;QAC9B,OAAO;IACT;IACA,IAAI;QACFY,MAAM,IACJE,OAAOC,KAAK,CAAC,CAACZ;gBACZK,QAAQL,KAAK,CAAC,GAAGH,MAAM,2BAA2B,CAAC,EAAEG;YACvD;QAEF,OAAO;IACT,EAAE,OAAOA,OAAO;QACdJ,QAAQC,OAAO,WAAW,kDAAkDG;QAC5E,OAAO;IACT;AACF;AAEA,gEAAgE,GAChE,OAAO,SAASa;IACdpB,oBAAoB;IACpBC,OAAOoB,KAAK;AACd"}
@@ -195,6 +195,13 @@ export interface AnalyticsEventParams {
195
195
  site_published: {
196
196
  first_publish?: boolean;
197
197
  };
198
+ /**
199
+ * Custom: no GA4 equivalent (AGL-3594). How a person chose to start a new
200
+ * site in the guided start: the ready-made starter, or AI.
201
+ */
202
+ site_start_choice: {
203
+ choice: 'starter' | 'ai';
204
+ };
198
205
  /** Custom: no GA4 equivalent. Stripe Connect onboarding completed. */
199
206
  stripe_connected: Record<string, never>;
200
207
  /** GA4 recommended. A plan checkout started. */
@@ -478,6 +478,7 @@ function scrubValue(value) {
478
478
  org_created: true,
479
479
  host_created: true,
480
480
  site_published: true,
481
+ site_start_choice: true,
481
482
  stripe_connected: true,
482
483
  begin_checkout: true,
483
484
  purchase: true,