@aglyn/tenant-data-admin 1.0.0-beta.218 → 1.0.0-beta.220
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -8
- package/src/lib/server/api-http.js +5 -1
- package/src/lib/server/api-http.js.map +1 -1
- package/src/lib/server/api-v1-description.d.ts +112 -0
- package/src/lib/server/api-v1-description.js +126 -0
- package/src/lib/server/api-v1-description.js.map +1 -0
- package/src/lib/server/api-v1-kit.d.ts +163 -0
- package/src/lib/server/api-v1-kit.js +191 -0
- package/src/lib/server/api-v1-kit.js.map +1 -0
- package/src/lib/server/api-v1-resources.d.ts +143 -0
- package/src/lib/server/api-v1-resources.js +125 -0
- package/src/lib/server/api-v1-resources.js.map +1 -0
- package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
- package/src/lib/server/campaign-conversion-attribution.js +3 -3
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
- package/src/lib/server/card-payment-velocity.d.ts +9 -0
- package/src/lib/server/card-payment-velocity.js +28 -2
- package/src/lib/server/card-payment-velocity.js.map +1 -1
- package/src/lib/server/consent-group-carry.js +1 -1
- package/src/lib/server/consent-group-carry.js.map +1 -1
- package/src/lib/server/consent-group-change.d.ts +2 -1
- package/src/lib/server/consent-group-change.js +43 -18
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/contact-email-index.d.ts +22 -0
- package/src/lib/server/contact-email-index.js +108 -0
- package/src/lib/server/contact-email-index.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +1 -1
- package/src/lib/server/crm-email-activity.js +1 -1
- package/src/lib/server/crm-email-activity.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +9 -1
- package/src/lib/server/crm-records.js +19 -2
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/cron-auth.d.ts +44 -0
- package/src/lib/server/cron-auth.js +76 -0
- package/src/lib/server/cron-auth.js.map +1 -0
- package/src/lib/server/dataset-live-pages.js +6 -6
- package/src/lib/server/dataset-live-pages.js.map +1 -1
- package/src/lib/server/duplicate-resource.d.ts +26 -0
- package/src/lib/server/duplicate-resource.js +47 -25
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +6 -3
- package/src/lib/server/dynamic-list-materialize.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +2 -2
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +11 -0
- package/src/lib/server/email-suppression.js +37 -2
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
- package/src/lib/server/email-unsubscribe-link.js +7 -7
- package/src/lib/server/email-unsubscribe-link.js.map +1 -1
- package/src/lib/server/erase.d.ts +19 -33
- package/src/lib/server/erase.js +37 -162
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-email-tokens.js +1 -1
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-memberships.js +3 -3
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +32 -4
- package/src/lib/server/host-visitor-records.js +73 -14
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +27 -0
- package/src/lib/server/hosted-page-review.js +149 -12
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
- package/src/lib/server/media-cdn-rate-limit.js +6 -3
- package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
- package/src/lib/server/member-email-aliases.d.ts +10 -2
- package/src/lib/server/member-email-aliases.js +26 -2
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/new-account-announcement.d.ts +62 -0
- package/src/lib/server/new-account-announcement.js +77 -0
- package/src/lib/server/new-account-announcement.js.map +1 -0
- package/src/lib/server/notifications.d.ts +22 -2
- package/src/lib/server/notifications.js +40 -3
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/operator-alerts.d.ts +7 -1
- package/src/lib/server/operator-alerts.js +5 -3
- package/src/lib/server/operator-alerts.js.map +1 -1
- package/src/lib/server/org-containers.d.ts +66 -0
- package/src/lib/server/org-containers.js +95 -0
- package/src/lib/server/org-containers.js.map +1 -0
- package/src/lib/server/org-member-notice.js +3 -2
- package/src/lib/server/org-member-notice.js.map +1 -1
- package/src/lib/server/payment-fraud-signal.js +5 -1
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
- package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +36 -2
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
- package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
- package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
- package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
- package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
- package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
- package/src/lib/server/payment-provider.d.ts +173 -0
- package/src/lib/server/payment-provider.js +70 -0
- package/src/lib/server/payment-provider.js.map +1 -0
- package/src/lib/server/payment-risk-record.d.ts +1 -1
- package/src/lib/server/payment-risk-record.js +3 -1
- package/src/lib/server/payment-risk-record.js.map +1 -1
- package/src/lib/server/personal-data-export.js +1 -1
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/platform-marketing-consent.d.ts +1 -1
- package/src/lib/server/platform-marketing-consent.js +1 -1
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/rate-limit-store.js +13 -3
- package/src/lib/server/rate-limit-store.js.map +1 -1
- package/src/lib/server/realm-plugins.js +8 -3
- package/src/lib/server/realm-plugins.js.map +1 -1
- package/src/lib/server/retained-refusals.d.ts +142 -0
- package/src/lib/server/retained-refusals.js +325 -0
- package/src/lib/server/retained-refusals.js.map +1 -0
- package/src/lib/server/risk-notice.d.ts +32 -1
- package/src/lib/server/risk-notice.js +90 -28
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/sending-domains.d.ts +17 -0
- package/src/lib/server/sending-domains.js +28 -4
- package/src/lib/server/sending-domains.js.map +1 -1
- package/src/lib/server/sso-enforcement.js +7 -2
- package/src/lib/server/sso-enforcement.js.map +1 -1
- package/src/lib/server/staff-alert-email.js +4 -2
- package/src/lib/server/staff-alert-email.js.map +1 -1
- package/src/lib/server/test-firestore-queries.d.ts +7 -0
- package/src/lib/server/test-firestore-queries.js +70 -27
- package/src/lib/server/test-firestore-queries.js.map +1 -1
- package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
- package/src/lib/server/topic-subscriptions.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +46 -1
- package/src/lib/server/upsert-contact.js +87 -20
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +5 -8
- package/src/tenancy.js +12 -13
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/connect-account-status.js.map +0 -1
- package/src/lib/server/connect-payout-failure.js.map +0 -1
- package/src/lib/server/contact-email-engagement.d.ts +0 -32
- package/src/lib/server/contact-email-engagement.js +0 -145
- package/src/lib/server/contact-email-engagement.js.map +0 -1
- package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
- package/src/lib/server/contact-lifecycle-floor.js +0 -91
- package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
- package/src/lib/server/contact-merge.d.ts +0 -55
- package/src/lib/server/contact-merge.js +0 -288
- package/src/lib/server/contact-merge.js.map +0 -1
- package/src/lib/server/crm-booking-activity.d.ts +0 -86
- package/src/lib/server/crm-booking-activity.js +0 -257
- package/src/lib/server/crm-booking-activity.js.map +0 -1
- package/src/lib/server/crm-inbound-email.d.ts +0 -145
- package/src/lib/server/crm-inbound-email.js +0 -297
- package/src/lib/server/crm-inbound-email.js.map +0 -1
- package/src/lib/server/email-topic-confirmation.js.map +0 -1
- package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
- package/src/lib/server/form-dataset-binding-token.js +0 -107
- package/src/lib/server/form-dataset-binding-token.js.map +0 -1
- package/src/lib/server/stripe-account-mode.js.map +0 -1
- /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-email-tokens.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 resolveSiteTheme,\n type ThemeHostDocument,\n} from '@aglyn/aglyn/app-utils/marketplace-theme'\nimport { sanitizeAuthorHtml } from '@aglyn/aglyn/app-utils/author-html'\nimport {\n hostTokenMerge,\n type HostTokenSource,\n} from '@aglyn/aglyn/app-utils/host-tokens'\nimport { composeHostComponentNodes } from '@aglyn/aglyn/app-utils/load-referenced-components'\nimport { buildSiteEmailChrome } from '@aglyn/aglyn/app-utils/site-email-chrome'\nimport {\n buildEmailPalette,\n emailPaletteBaseForHost,\n getTenantEmail,\n hostEmailOrigin,\n loadHostEmail,\n renderFramedTextEmail,\n renderHostEmail,\n renderLoadedHostEmail,\n type EmailChrome,\n type EmailTheme,\n type LoadedHostEmail,\n type RenderedHostEmail,\n} from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * `renderHostEmail` with `host.*` tokens resolved (AGL-1022).\n *\n * The whole point of host variables is that a publisher's email template can\n * say \"write to {{host.supportEmail}}\" and have the installing site fill it in.\n * That only happens if something resolves the tokens at SEND time, and the\n * email library cannot: `@aglyn/shared-util-email` is `scope:shared` and may\n * only depend on `scope:shared`, while the token registry is aglyn-scoped.\n *\n * So the join happens here, in the one lib that already sits above both. Three\n * alternatives were weighed and this is the one that cannot fail quietly:\n *\n * * A registration hook inside the email library would mean a provider that is\n * never registered resolves every token to nothing, silently, on a path whose\n * output has already been sent to a customer. This repo has shipped a\n * flawless security control with zero callers before; that is the shape.\n * * Threading the map through all eighteen call sites puts the burden on\n * whoever adds the nineteenth, and they will not know.\n * * Moving the registry into a `scope:shared` lib is the cleanest end state and\n * the right thing if a second shared consumer ever appears — but the only\n * candidate today is `shared/util/tools`, which is generic helpers, and\n * domain logic does not belong there.\n *\n * Costs one host read per send. Emails are not a hot path, and the read buys\n * the guarantee that a template's branding is right by construction.\n */\nexport async function renderHostEmailWithTokens(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n merge: Record<string, string> = {},\n options: { origin?: string } = {},\n): Promise<RenderedHostEmail | null> {\n const host = await readHostTokenSource(firestore, hostId)\n return renderHostEmail(\n firestore as never,\n hostId,\n templateKey,\n // Host tokens first: an explicit caller value WINS. A sender passing\n // `host.businessName` is naming a specific thing on purpose (a white-label\n // send, a preview with sample data), and this is not the place to overrule\n // it.\n { ...hostTokenMerge(host), ...merge },\n // The HTML policy joins here for the same reason the token map does, and\n // it is the same reason: `renderEmailHtml` requires one, `scope:shared`\n // cannot import one, and this lib already sits above both. Supplying it\n // here rather than at each of the nine senders keeps their signatures\n // untouched and gives the tenth one the right policy without knowing it\n // needed to ask.\n //\n // The component graft joins on the same argument (AGL-3287): a header or\n // footer the site owner placed is a reference the shared renderer cannot\n // resolve, so every sender behind this function mails it expanded without\n // having to know it exists. Style overrides do not survive into mail —\n // they land in `sx`, which `renderEmailHtml` never reads — but property\n // values and attribute overrides do.\n //\n // And the site's header and footer (AGL-3370), which the built-in copy is\n // drawn inside; a design the owner published goes out as they built it.\n {\n ...options,\n sanitize: sanitizeAuthorHtml,\n compose: composeHostComponentNodes,\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n },\n )\n}\n\n/**\n * The site's host document as the token and chrome readers see it, or null.\n *\n * A failed read must not stop the send. Every token then resolves to its\n * empty behaviour, which is the same outcome as a site that set nothing —\n * an email with a gap where the address would be, rather than no email.\n */\nasync function readHostTokenSource(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n): Promise<SiteEmailHost | null> {\n try {\n const snapshot = await firestore.collection('hosts').doc(hostId).get()\n return snapshot.exists ? (snapshot.data() as SiteEmailHost) : null\n } catch {\n return null\n }\n}\n\n/** What a site email reads off the host document: its tokens and its theme. */\ntype SiteEmailHost = HostTokenSource & {\n theme?: {\n colorSchemes?: { light?: Record<string, unknown> | null }\n shape?: { borderRadius?: unknown }\n } | null\n themeOverride?: unknown\n}\n\n/**\n * The site's theme as its email draws it (AGL-3370): the palette the\n * published site renders with, light scheme, since an inbox has one, and its\n * corner radius for buttons. A site with no theme of its own resolves the\n * same base the site itself does.\n */\nfunction siteThemeFor(host: SiteEmailHost | null): EmailTheme {\n // Resolved, not read off `theme`: a site's edits are an override on the\n // theme it picked (AGL-3404), so the stored theme alone is the design\n // before anything the site changed.\n const theme = resolveSiteTheme(host as ThemeHostDocument | null)\n const radius = theme?.shape?.borderRadius\n return {\n palette: buildEmailPalette({\n base: emailPaletteBaseForHost(host),\n colors: (theme?.colorSchemes?.light ?? null) as Record<string, unknown> | null,\n }),\n ...(typeof radius === 'number' ? { buttonRadius: radius } : {}),\n }\n}\n\n/** The site's chrome for one kind of email, with that kind's footer reason. */\nfunction siteChromeFor(\n host: HostTokenSource | null,\n templateKey: string,\n): EmailChrome {\n return buildSiteEmailChrome({\n host,\n reason: getTenantEmail(templateKey)?.footerReason,\n })\n}\n\n/** A site email loaded once for a batch, with the site's tokens beside it. */\nexport interface LoadedHostEmailWithTokens {\n loaded: LoadedHostEmail\n hostMerge: Record<string, string>\n}\n\n/**\n * {@link loadHostEmail} for a batch (AGL-3370): the template, or the built-in\n * copy in the site's header and footer, read ONCE, with the site's `host.*`\n * tokens beside it for {@link renderLoadedHostEmailWithTokens}. `null` only\n * for a key with neither, so the batch keeps its own copy.\n */\nexport async function loadHostEmailWithTokens(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n options: { origin?: string } = {},\n): Promise<LoadedHostEmailWithTokens | null> {\n const host = await readHostTokenSource(firestore, hostId)\n const loaded = await loadHostEmail(firestore as never, hostId, templateKey, {\n ...options,\n compose: composeHostComponentNodes,\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n })\n return loaded ? { loaded, hostMerge: hostTokenMerge(host) } : null\n}\n\n/** One recipient's copy of a batch-loaded site email; no reads. */\nexport function renderLoadedHostEmailWithTokens(\n bundle: LoadedHostEmailWithTokens,\n merge: Record<string, string> = {},\n): RenderedHostEmail | null {\n return renderLoadedHostEmail(\n bundle.loaded,\n { ...bundle.hostMerge, ...merge },\n sanitizeAuthorHtml,\n )\n}\n\n/**\n * The site's header and footer for one kind of email, its theme, and the\n * origin and host a stored logo reference resolves against (AGL-3370): for a sender\n * that renders per recipient itself, such as a typed campaign.\n */\nexport async function siteEmailFrame(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n): Promise<{\n chrome: EmailChrome\n theme: EmailTheme\n mediaOrigin?: string\n mediaHostId: string\n}> {\n const host = await readHostTokenSource(firestore, hostId)\n const origin = host\n ? hostEmailOrigin({ cname: host.cname, subdomain: host.subdomain })\n : undefined\n return {\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n ...(origin ? { mediaOrigin: origin } : {}),\n mediaHostId: hostId,\n }\n}\n\n/**\n * A text-only site email in the site's header and footer (AGL-3370): a\n * workflow's email step, or a message a site sends in words nobody designed.\n * The text arrives resolved; the footer's reason is the kind's, by key.\n */\nexport async function renderSiteTextEmail(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n message: { subject: string; text: string; preheader?: string },\n): Promise<{ subject: string; html: string; text: string }> {\n const frame = await siteEmailFrame(firestore, hostId, templateKey)\n const rendered = renderFramedTextEmail({ ...message, ...frame })\n return { subject: message.subject, html: rendered.html, text: rendered.text }\n}\n\nexport default renderHostEmailWithTokens\n"],"names":["resolveSiteTheme","sanitizeAuthorHtml","hostTokenMerge","composeHostComponentNodes","buildSiteEmailChrome","buildEmailPalette","emailPaletteBaseForHost","getTenantEmail","hostEmailOrigin","loadHostEmail","renderFramedTextEmail","renderHostEmail","renderLoadedHostEmail","renderHostEmailWithTokens","firestore","hostId","templateKey","merge","options","host","readHostTokenSource","sanitize","compose","chrome","siteChromeFor","theme","siteThemeFor","snapshot","collection","doc","get","exists","data","radius","shape","borderRadius","palette","base","colors","colorSchemes","light","buttonRadius","reason","footerReason","loadHostEmailWithTokens","loaded","hostMerge","renderLoadedHostEmailWithTokens","bundle","siteEmailFrame","origin","cname","subdomain","undefined","mediaOrigin","mediaHostId","renderSiteTextEmail","message","frame","rendered","subject","html","text"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,gBAAgB,QAEX,2CAA0C;AACjD,SAASC,kBAAkB,QAAQ,qCAAoC;AACvE,SACEC,cAAc,QAET,qCAAoC;AAC3C,SAASC,yBAAyB,QAAQ,oDAAmD;AAC7F,SAASC,oBAAoB,QAAQ,2CAA0C;AAC/E,SACEC,iBAAiB,EACjBC,uBAAuB,EACvBC,cAAc,EACdC,eAAe,EACfC,aAAa,EACbC,qBAAqB,EACrBC,eAAe,EACfC,qBAAqB,QAKhB,2BAA0B;AAGjC;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,eAAeC,0BACpBC,SAAsC,EACtCC,MAAc,EACdC,WAAmB,EACnBC,QAAgC,CAAC,CAAC,EAClCC,UAA+B,CAAC,CAAC;IAEjC,MAAMC,OAAO,MAAMC,oBAAoBN,WAAWC;IAClD,OAAOJ,gBACLG,WACAC,QACAC,aACA,qEAAqE;IACrE,2EAA2E;IAC3E,2EAA2E;IAC3E,MAAM;IACN,aAAKd,eAAeiB,OAAUF,QAC9B,yEAAyE;IACzE,wEAAwE;IACxE,wEAAwE;IACxE,sEAAsE;IACtE,wEAAwE;IACxE,iBAAiB;IACjB,EAAE;IACF,yEAAyE;IACzE,yEAAyE;IACzE,0EAA0E;IAC1E,uEAAuE;IACvE,wEAAwE;IACxE,qCAAqC;IACrC,EAAE;IACF,0EAA0E;IAC1E,wEAAwE;IACxE,aACKC;QACHG,UAAUpB;QACVqB,SAASnB;QACToB,QAAQC,cAAcL,MAAMH;QAC5BS,OAAOC,aAAaP;;AAG1B;AAEA;;;;;;CAMC,GACD,eAAeC,oBACbN,SAAsC,EACtCC,MAAc;IAEd,IAAI;QACF,MAAMY,WAAW,MAAMb,UAAUc,UAAU,CAAC,SAASC,GAAG,CAACd,QAAQe,GAAG;QACpE,OAAOH,SAASI,MAAM,GAAIJ,SAASK,IAAI,KAAuB;IAChE,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAWA;;;;;CAKC,GACD,SAASN,aAAaP,IAA0B;;QAK/BM,cAIFA;IARb,wEAAwE;IACxE,sEAAsE;IACtE,oCAAoC;IACpC,MAAMA,QAAQzB,iBAAiBmB;IAC/B,MAAMc,SAASR,0BAAAA,eAAAA,MAAOS,KAAK,qBAAZT,aAAcU,YAAY;IACzC,OAAO;QACLC,SAAS/B,kBAAkB;YACzBgC,MAAM/B,wBAAwBa;YAC9BmB,MAAM,UAAGb,0BAAAA,sBAAAA,MAAOc,YAAY,qBAAnBd,oBAAqBe,KAAK,mBAAI;QACzC;OACI,OAAOP,WAAW,WAAW;QAAEQ,cAAcR;IAAO,IAAI,CAAC;AAEjE;AAEA,6EAA6E,GAC7E,SAAST,cACPL,IAA4B,EAC5BH,WAAmB;QAITT;IAFV,OAAOH,qBAAqB;QAC1Be;QACAuB,MAAM,GAAEnC,kBAAAA,eAAeS,iCAAfT,gBAA6BoC,YAAY;IACnD;AACF;AAQA;;;;;CAKC,GACD,OAAO,eAAeC,wBACpB9B,SAAsC,EACtCC,MAAc,EACdC,WAAmB,EACnBE,UAA+B,CAAC,CAAC;IAEjC,MAAMC,OAAO,MAAMC,oBAAoBN,WAAWC;IAClD,MAAM8B,SAAS,MAAMpC,cAAcK,WAAoBC,QAAQC,aAAa,aACvEE;QACHI,SAASnB;QACToB,QAAQC,cAAcL,MAAMH;QAC5BS,OAAOC,aAAaP;;IAEtB,OAAO0B,SAAS;QAAEA;QAAQC,WAAW5C,eAAeiB;IAAM,IAAI;AAChE;AAEA,iEAAiE,GACjE,OAAO,SAAS4B,gCACdC,MAAiC,EACjC/B,QAAgC,CAAC,CAAC;IAElC,OAAOL,sBACLoC,OAAOH,MAAM,EACb,aAAKG,OAAOF,SAAS,EAAK7B,QAC1BhB;AAEJ;AAEA;;;;CAIC,GACD,OAAO,eAAegD,eACpBnC,SAAsC,EACtCC,MAAc,EACdC,WAAmB;IAOnB,MAAMG,OAAO,MAAMC,oBAAoBN,WAAWC;IAClD,MAAMmC,SAAS/B,OACXX,gBAAgB;QAAE2C,OAAOhC,KAAKgC,KAAK;QAAEC,WAAWjC,KAAKiC,SAAS;IAAC,KAC/DC;IACJ,OAAO;QACL9B,QAAQC,cAAcL,MAAMH;QAC5BS,OAAOC,aAAaP;OAChB+B,SAAS;QAAEI,aAAaJ;IAAO,IAAI,CAAC;QACxCK,aAAaxC;;AAEjB;AAEA;;;;CAIC,GACD,OAAO,eAAeyC,oBACpB1C,SAAsC,EACtCC,MAAc,EACdC,WAAmB,EACnByC,OAA8D;IAE9D,MAAMC,QAAQ,MAAMT,eAAenC,WAAWC,QAAQC;IACtD,MAAM2C,WAAWjD,sBAAsB,aAAK+C,SAAYC;IACxD,OAAO;QAAEE,SAASH,QAAQG,OAAO;QAAEC,MAAMF,SAASE,IAAI;QAAEC,MAAMH,SAASG,IAAI;IAAC;AAC9E;AAEA,eAAejD,0BAAyB"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-email-tokens.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 resolveSiteTheme,\n type ThemeHostDocument,\n} from '@aglyn/aglyn/app-utils/site-theme'\nimport { sanitizeAuthorHtml } from '@aglyn/aglyn/app-utils/author-html'\nimport {\n hostTokenMerge,\n type HostTokenSource,\n} from '@aglyn/aglyn/app-utils/host-tokens'\nimport { composeHostComponentNodes } from '@aglyn/aglyn/app-utils/load-referenced-components'\nimport { buildSiteEmailChrome } from '@aglyn/aglyn/app-utils/site-email-chrome'\nimport {\n buildEmailPalette,\n emailPaletteBaseForHost,\n getTenantEmail,\n hostEmailOrigin,\n loadHostEmail,\n renderFramedTextEmail,\n renderHostEmail,\n renderLoadedHostEmail,\n type EmailChrome,\n type EmailTheme,\n type LoadedHostEmail,\n type RenderedHostEmail,\n} from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * `renderHostEmail` with `host.*` tokens resolved (AGL-1022).\n *\n * The whole point of host variables is that a publisher's email template can\n * say \"write to {{host.supportEmail}}\" and have the installing site fill it in.\n * That only happens if something resolves the tokens at SEND time, and the\n * email library cannot: `@aglyn/shared-util-email` is `scope:shared` and may\n * only depend on `scope:shared`, while the token registry is aglyn-scoped.\n *\n * So the join happens here, in the one lib that already sits above both. Three\n * alternatives were weighed and this is the one that cannot fail quietly:\n *\n * * A registration hook inside the email library would mean a provider that is\n * never registered resolves every token to nothing, silently, on a path whose\n * output has already been sent to a customer. This repo has shipped a\n * flawless security control with zero callers before; that is the shape.\n * * Threading the map through all eighteen call sites puts the burden on\n * whoever adds the nineteenth, and they will not know.\n * * Moving the registry into a `scope:shared` lib is the cleanest end state and\n * the right thing if a second shared consumer ever appears — but the only\n * candidate today is `shared/util/tools`, which is generic helpers, and\n * domain logic does not belong there.\n *\n * Costs one host read per send. Emails are not a hot path, and the read buys\n * the guarantee that a template's branding is right by construction.\n */\nexport async function renderHostEmailWithTokens(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n merge: Record<string, string> = {},\n options: { origin?: string } = {},\n): Promise<RenderedHostEmail | null> {\n const host = await readHostTokenSource(firestore, hostId)\n return renderHostEmail(\n firestore as never,\n hostId,\n templateKey,\n // Host tokens first: an explicit caller value WINS. A sender passing\n // `host.businessName` is naming a specific thing on purpose (a white-label\n // send, a preview with sample data), and this is not the place to overrule\n // it.\n { ...hostTokenMerge(host), ...merge },\n // The HTML policy joins here for the same reason the token map does, and\n // it is the same reason: `renderEmailHtml` requires one, `scope:shared`\n // cannot import one, and this lib already sits above both. Supplying it\n // here rather than at each of the nine senders keeps their signatures\n // untouched and gives the tenth one the right policy without knowing it\n // needed to ask.\n //\n // The component graft joins on the same argument (AGL-3287): a header or\n // footer the site owner placed is a reference the shared renderer cannot\n // resolve, so every sender behind this function mails it expanded without\n // having to know it exists. Style overrides do not survive into mail —\n // they land in `sx`, which `renderEmailHtml` never reads — but property\n // values and attribute overrides do.\n //\n // And the site's header and footer (AGL-3370), which the built-in copy is\n // drawn inside; a design the owner published goes out as they built it.\n {\n ...options,\n sanitize: sanitizeAuthorHtml,\n compose: composeHostComponentNodes,\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n },\n )\n}\n\n/**\n * The site's host document as the token and chrome readers see it, or null.\n *\n * A failed read must not stop the send. Every token then resolves to its\n * empty behaviour, which is the same outcome as a site that set nothing —\n * an email with a gap where the address would be, rather than no email.\n */\nasync function readHostTokenSource(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n): Promise<SiteEmailHost | null> {\n try {\n const snapshot = await firestore.collection('hosts').doc(hostId).get()\n return snapshot.exists ? (snapshot.data() as SiteEmailHost) : null\n } catch {\n return null\n }\n}\n\n/** What a site email reads off the host document: its tokens and its theme. */\ntype SiteEmailHost = HostTokenSource & {\n theme?: {\n colorSchemes?: { light?: Record<string, unknown> | null }\n shape?: { borderRadius?: unknown }\n } | null\n themeOverride?: unknown\n}\n\n/**\n * The site's theme as its email draws it (AGL-3370): the palette the\n * published site renders with, light scheme, since an inbox has one, and its\n * corner radius for buttons. A site with no theme of its own resolves the\n * same base the site itself does.\n */\nfunction siteThemeFor(host: SiteEmailHost | null): EmailTheme {\n // Resolved, not read off `theme`: a site's edits are an override on the\n // theme it picked (AGL-3404), so the stored theme alone is the design\n // before anything the site changed.\n const theme = resolveSiteTheme(host as ThemeHostDocument | null)\n const radius = theme?.shape?.borderRadius\n return {\n palette: buildEmailPalette({\n base: emailPaletteBaseForHost(host),\n colors: (theme?.colorSchemes?.light ?? null) as Record<string, unknown> | null,\n }),\n ...(typeof radius === 'number' ? { buttonRadius: radius } : {}),\n }\n}\n\n/** The site's chrome for one kind of email, with that kind's footer reason. */\nfunction siteChromeFor(\n host: HostTokenSource | null,\n templateKey: string,\n): EmailChrome {\n return buildSiteEmailChrome({\n host,\n reason: getTenantEmail(templateKey)?.footerReason,\n })\n}\n\n/** A site email loaded once for a batch, with the site's tokens beside it. */\nexport interface LoadedHostEmailWithTokens {\n loaded: LoadedHostEmail\n hostMerge: Record<string, string>\n}\n\n/**\n * {@link loadHostEmail} for a batch (AGL-3370): the template, or the built-in\n * copy in the site's header and footer, read ONCE, with the site's `host.*`\n * tokens beside it for {@link renderLoadedHostEmailWithTokens}. `null` only\n * for a key with neither, so the batch keeps its own copy.\n */\nexport async function loadHostEmailWithTokens(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n options: { origin?: string } = {},\n): Promise<LoadedHostEmailWithTokens | null> {\n const host = await readHostTokenSource(firestore, hostId)\n const loaded = await loadHostEmail(firestore as never, hostId, templateKey, {\n ...options,\n compose: composeHostComponentNodes,\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n })\n return loaded ? { loaded, hostMerge: hostTokenMerge(host) } : null\n}\n\n/** One recipient's copy of a batch-loaded site email; no reads. */\nexport function renderLoadedHostEmailWithTokens(\n bundle: LoadedHostEmailWithTokens,\n merge: Record<string, string> = {},\n): RenderedHostEmail | null {\n return renderLoadedHostEmail(\n bundle.loaded,\n { ...bundle.hostMerge, ...merge },\n sanitizeAuthorHtml,\n )\n}\n\n/**\n * The site's header and footer for one kind of email, its theme, and the\n * origin and host a stored logo reference resolves against (AGL-3370): for a sender\n * that renders per recipient itself, such as a typed campaign.\n */\nexport async function siteEmailFrame(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n): Promise<{\n chrome: EmailChrome\n theme: EmailTheme\n mediaOrigin?: string\n mediaHostId: string\n}> {\n const host = await readHostTokenSource(firestore, hostId)\n const origin = host\n ? hostEmailOrigin({ cname: host.cname, subdomain: host.subdomain })\n : undefined\n return {\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n ...(origin ? { mediaOrigin: origin } : {}),\n mediaHostId: hostId,\n }\n}\n\n/**\n * A text-only site email in the site's header and footer (AGL-3370): a\n * workflow's email step, or a message a site sends in words nobody designed.\n * The text arrives resolved; the footer's reason is the kind's, by key.\n */\nexport async function renderSiteTextEmail(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n message: { subject: string; text: string; preheader?: string },\n): Promise<{ subject: string; html: string; text: string }> {\n const frame = await siteEmailFrame(firestore, hostId, templateKey)\n const rendered = renderFramedTextEmail({ ...message, ...frame })\n return { subject: message.subject, html: rendered.html, text: rendered.text }\n}\n\nexport default renderHostEmailWithTokens\n"],"names":["resolveSiteTheme","sanitizeAuthorHtml","hostTokenMerge","composeHostComponentNodes","buildSiteEmailChrome","buildEmailPalette","emailPaletteBaseForHost","getTenantEmail","hostEmailOrigin","loadHostEmail","renderFramedTextEmail","renderHostEmail","renderLoadedHostEmail","renderHostEmailWithTokens","firestore","hostId","templateKey","merge","options","host","readHostTokenSource","sanitize","compose","chrome","siteChromeFor","theme","siteThemeFor","snapshot","collection","doc","get","exists","data","radius","shape","borderRadius","palette","base","colors","colorSchemes","light","buttonRadius","reason","footerReason","loadHostEmailWithTokens","loaded","hostMerge","renderLoadedHostEmailWithTokens","bundle","siteEmailFrame","origin","cname","subdomain","undefined","mediaOrigin","mediaHostId","renderSiteTextEmail","message","frame","rendered","subject","html","text"],"mappings":"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"}
|
|
@@ -86,7 +86,7 @@ const readHostMeta = async (db, hostIds)=>{
|
|
|
86
86
|
];
|
|
87
87
|
}));
|
|
88
88
|
};
|
|
89
|
-
const
|
|
89
|
+
const readOrgMembers = async (db, orgId)=>{
|
|
90
90
|
const snap = await db.collection('orgs').doc(orgId).collection('members').get();
|
|
91
91
|
return snap.docs.map((d)=>_extends({
|
|
92
92
|
$id: d.id
|
|
@@ -269,7 +269,7 @@ const membershipRef = (db, uid, hostId)=>db.collection('users').doc(uid).collect
|
|
|
269
269
|
*/ export async function syncHostProjectionForMembers(orgId, hostId) {
|
|
270
270
|
const db = firestore();
|
|
271
271
|
const [members, meta] = await Promise.all([
|
|
272
|
-
|
|
272
|
+
readOrgMembers(db, orgId),
|
|
273
273
|
readHostMeta(db, [
|
|
274
274
|
hostId
|
|
275
275
|
])
|
|
@@ -301,7 +301,7 @@ const membershipRef = (db, uid, hostId)=>db.collection('users').doc(uid).collect
|
|
|
301
301
|
}
|
|
302
302
|
/** Delete every member's projection row for one host (host erased). */ export async function deleteHostProjectionForAllMembers(orgId, hostId) {
|
|
303
303
|
const db = firestore();
|
|
304
|
-
const members = await
|
|
304
|
+
const members = await readOrgMembers(db, orgId);
|
|
305
305
|
await commitChunked(db, members.map((member)=>(batch)=>{
|
|
306
306
|
batch.delete(membershipRef(db, member.$id, hostId));
|
|
307
307
|
}));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-memberships.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 * Per-user host membership projection (AGL-844): a reverse index at\n * `users/{uid}/hostMemberships/{hostId}` mirroring `hosts/{hostId}.memberRoles`\n * — the hosts a user can reach in an org, denormalized with the name so the\n * site switcher and subdomain→id routing can query + order + name-prefix-search\n * a user's own sites without loading the whole `hosts` collection (which the\n * rules can only serve unordered under a per-user `memberRoles.{uid}` filter).\n *\n * Because it lives under the user's own doc it is owner-readable and already\n * membership-filtered, so `where(orgId==…) orderBy(nameLower|updatedAt)` is a\n * plain composite index (no dynamic map path). Admin-SDK-written only.\n *\n * It is a best-effort convenience index, NOT an authorization source: real\n * access stays gated by `hosts/{hostId}.memberRoles` in the rules, so a stale\n * row at worst shows a site that then 404s on read — never grants access.\n *\n * Writers are scoped, mirroring the `users/{uid}/orgs` reverse index: a member\n * change re-syncs THAT member across the org's hosts; a host change re-syncs\n * THAT host across members. Kept in sync at the same points as `memberRoles`\n * (see `syncHostMemberRoles` callers) plus the erase/displayName gaps (AGL-855).\n *\n * ## The Sites list is a query over these rows (AGL-3321)\n *\n * The organization's Sites list pages, filters and searches THIS collection —\n * `where(orgId ==)`, plus the reader's clauses, ordered by `nameLower` or\n * `createdAt` — and joins each row to the host document by id for display.\n * It cannot query `hosts` itself: the rules serve a non-staff LIST of `hosts`\n * only under a `memberRoles.{uid}` clause, a composite on that per-user map\n * path can exist for no order but the document id, and a predicated listen\n * over the mutable `memberRoles`/`orgId` is what tombstoned host documents\n * for every other reader (AGL-1190, `useOrgHosts`).\n *\n * So a row carries what that list asks of a site: `searchTokens` (the quick\n * search, over the name, the platform subdomain and the custom domain),\n * `hasCustomDomain` (the Custom domain filter) and the host's `createdAt`\n * (immutable, set once at create). Every field is derived from the host\n * document in `membershipRow`, so a re-sync from any writer re-derives all of\n * them; the writers of their source fields — create, the owner and staff\n * renames, the Setup name save, and the domain attach and detach routes (the\n * only writers of `cname`) — each re-sync. Rows written before AGL-3321 are\n * stamped by `tools/scripts/backfill-host-memberships-list-fields.mjs`.\n */\n\nimport {\n hostRoleFor,\n isOrgWideMember,\n nameSearchKey,\n type AglynOrganization,\n type AglynOrgMember,\n} from '@aglyn/aglyn/server'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** Firestore caps a batch at 500 writes; commit in chunks below that. */\nconst CHUNK = 400\n\ninterface HostMeta {\n displayName?: string\n subdomain?: string\n favicon?: string\n /** The custom domain, which the Sites list's search finds a site by. */\n cname?: string\n /** When the site was created: set once, by `claimHostForOrg`, and never moved. */\n createdAt?: unknown\n /** Whether the site document was there to read. */\n exists?: boolean\n}\n\nconst orgHostIds = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> => {\n const org = (await db.collection('orgs').doc(orgId).get()).data() as\n | AglynOrganization\n | undefined\n return Object.keys(org?.hosts ?? {})\n}\n\nconst readHostMeta = async (\n db: FirebaseFirestore.Firestore,\n hostIds: string[],\n): Promise<Map<string, HostMeta>> => {\n if (hostIds.length === 0) return new Map()\n const snaps = await db.getAll(\n ...hostIds.map((id) => db.collection('hosts').doc(id)),\n )\n return new Map(\n snaps.map((snap) => [\n snap.id,\n {\n displayName: snap.get('displayName'),\n subdomain: snap.get('subdomain'),\n // The switcher renders from the projection, not the host doc, so the\n // favicon has to travel with it (AGL-1071).\n favicon: snap.get('seo')?.favicon,\n cname: snap.get('cname'),\n createdAt: snap.get('createdAt'),\n exists: snap.exists,\n },\n ]),\n )\n}\n\nconst listMembers = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<AglynOrgMember[]> => {\n const snap = await db\n .collection('orgs')\n .doc(orgId)\n .collection('members')\n .get()\n return snap.docs.map((d) => ({ $id: d.id, ...d.data() }) as AglynOrgMember)\n}\n\nconst membershipRef = (\n db: FirebaseFirestore.Firestore,\n uid: string,\n hostId: string,\n) => db.collection('users').doc(uid).collection('hostMemberships').doc(hostId)\n\n/** A tail of a word begins after any character that is not a letter or a digit. */\nconst WORD_SEPARATOR = /[^\\p{L}\\p{N}]/u\n\n/**\n * A word, and every tail of it that begins after a separator:\n * `shop.harbor-bakery.com` is itself, `harbor-bakery.com`, `bakery.com` and\n * `com`. Walked by codepoint, so a tail never starts inside a surrogate pair.\n */\nconst wordTails = (word: string): string[] => {\n const chars = [...word]\n const tails = [word]\n for (let at = 1; at < chars.length; at += 1) {\n if (WORD_SEPARATOR.test(chars[at - 1]) && !WORD_SEPARATOR.test(chars[at])) {\n tails.push(chars.slice(at).join(''))\n }\n }\n return tails\n}\n\n/**\n * The words a site is FOUND by in the Sites list's quick search (AGL-3321):\n * its name, its platform subdomain and its custom domain, as the word-prefix\n * tokens `nameSearchTokens` writes, which the list's query asks for with one\n * `array-contains` on the first typed word (`nameSearchNormalizers.token`).\n *\n * A subdomain or a domain is one \"word\" to `nameSearchTokens`, so the prefixes\n * of `harbor-bakery` alone would find it by `harbor` and never by `bakery`.\n * Every tail that begins after a separator is tokenized too, which is what\n * lets `bakery`, `bakery.com` and `shop.harbor` all find\n * `shop.harbor-bakery.com` — the words a reader remembers of an address.\n *\n * The platform apex is not a word here: it is the same on every site, so it\n * narrows nothing, and the subdomain in front of it is.\n *\n * Its script-side twin, for the backfill and the seeds that write these rows,\n * is `tools/scripts/lib/host-membership-search-tokens.mjs`; both answer\n * `tools/scripts/lib/host-membership-search-tokens.fixtures.json`, which this\n * library's spec asserts too.\n */\nexport function hostMembershipSearchTokens(\n meta: Pick<HostMeta, 'displayName' | 'subdomain' | 'cname'> | undefined,\n): string[] {\n const words = [meta?.displayName, meta?.subdomain, meta?.cname]\n .map((value) => nameSearchKey(typeof value === 'string' ? value : ''))\n .flatMap((key) => (key ? key.split(' ') : []))\n .flatMap(wordTails)\n return nameSearchTokens(words.join(' '))\n}\n\n/**\n * Whether a site has a custom domain connected, as the Sites card lists it:\n * a non-blank `cname`, which only the domain attach route writes and only the\n * detach route removes.\n */\nexport const hasCustomDomain = (meta: Pick<HostMeta, 'cname'> | undefined): boolean =>\n typeof meta?.cname === 'string' && meta.cname.trim() !== ''\n\n/**\n * The keys the staff Sites list (`/admin/sites`) queries on the SITE document\n * itself: the same name key, search tokens and custom-domain flag the\n * membership rows carry, so staff find a site by its name, subdomain or\n * domain across every organization without walking anyone's projection.\n *\n * Stamped by `syncHostProjectionForMembers` — the funnel every create,\n * rename, domain attach and domain release already calls — and by\n * `tools/scripts/backfill-host-search-fields.mjs` on the sites written before\n * it. `hasCustomDomain` is stored as `false`, never left out, for the reason\n * the membership row gives: a query cannot find a document by a field it\n * lacks.\n */\nexport const hostSearchFields = (\n meta: Pick<HostMeta, 'displayName' | 'subdomain' | 'cname'> | undefined,\n) => ({\n nameLower: nameSearchKey(meta?.displayName ?? ''),\n searchTokens: hostMembershipSearchTokens(meta),\n hasCustomDomain: hasCustomDomain(meta),\n})\n\n/** A Firestore timestamp, as opposed to a legacy number or string. */\nconst isTimestamp = (value: unknown): boolean =>\n typeof (value as { toMillis?: unknown } | null | undefined)?.toMillis === 'function'\n\n/**\n * Shape one projection row. Exported for tests: the favicon rule below is a\n * delete-vs-omit decision that is invisible in review and silent in\n * production when wrong (AGL-1071).\n */\nexport const membershipRow = (\n orgId: string,\n meta: HostMeta | undefined,\n role: string,\n) => {\n const displayName = meta?.displayName ?? ''\n return {\n orgId,\n ...(meta?.subdomain ? { subdomain: meta.subdomain } : {}),\n displayName,\n nameLower: nameSearchKey(displayName),\n // What the Sites list searches (AGL-3321). Re-derived on every sync, so a\n // rename, a domain attach or a domain release moves it with the host.\n searchTokens: hostMembershipSearchTokens(meta),\n // The Custom domain filter (AGL-3321): an equality, which every order the\n // list takes can carry. Stored as `false`, never left out, because a query\n // cannot find a document by a field it lacks.\n hasCustomDomain: hasCustomDomain(meta),\n // What the Sites list's Created filter and order read. NULL rather than\n // absent for a site that never recorded one: `orderBy('createdAt')` drops\n // a document MISSING the field, and a site must not vanish from the list\n // because it is sorted by a date it does not have.\n createdAt: isTimestamp(meta?.createdAt) ? meta?.createdAt : null,\n // DELETE rather than omit when the site has no favicon (AGL-1071). These\n // rows are written with `{ merge: true }`, so omitting the key leaves\n // whatever was there — clearing a favicon would keep showing the old one\n // in the switcher until something unrelated rewrote the row. The clear\n // path writes `seo.favicon: ''`, so test truthiness, not nullishness.\n favicon: meta?.favicon ? meta.favicon : FieldValue.delete(),\n role,\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/** Runs a set of write operations in ≤CHUNK-sized batches. */\nconst commitChunked = async (\n db: FirebaseFirestore.Firestore,\n ops: Array<(batch: FirebaseFirestore.WriteBatch) => void>,\n): Promise<void> => {\n for (let i = 0; i < ops.length; i += CHUNK) {\n const batch = db.batch()\n for (const op of ops.slice(i, i + CHUNK)) op(batch)\n await batch.commit()\n }\n}\n\n/**\n * Mirror the member's org-wide reach onto their `users/{uid}/orgs/{orgId}`\n * row (AGL-1032), so the console can tell a site collaborator from an\n * org-wide viewer without a second read on every org route.\n *\n * Rides the projection pass rather than being a call the membership APIs\n * each have to remember: every path that changes a member's reach — add,\n * role change, grant, revoke, ownership transfer — already re-syncs their\n * host rows, so one funnel keeps the mirror true and self-heals a row an\n * earlier failure left stale.\n *\n * Only ever UPDATES an existing row. The reverse index is created with the\n * membership and deleted with it; a `set`/merge here on a removed member\n * would resurrect a half-row with no `orgName` or `slug`, and the console\n * renders that as a nameless workspace nobody can open.\n */\nasync function syncMemberOrgReach(\n db: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n member: AglynOrgMember | null,\n): Promise<void> {\n if (!member) return\n const ref = db.collection('users').doc(uid).collection('orgs').doc(orgId)\n const snap = await ref.get()\n if (!snap.exists) return\n const orgWide = isOrgWideMember(member)\n if (snap.get('orgWide') === orgWide) return\n await ref.set({ orgWide }, { merge: true })\n}\n\n/**\n * Re-sync ONE member's projection rows across all the org's hosts: set a row\n * for every host the member can reach, delete the row for every host they\n * cannot (a role downgrade silently drops hosts). Call after any change to a\n * single member (add / role change / grant / revoke / ownership step).\n */\nexport async function syncMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const [memberSnap, hostIds] = await Promise.all([\n db.collection('orgs').doc(orgId).collection('members').doc(uid).get(),\n orgHostIds(db, orgId),\n ])\n const member = memberSnap.exists\n ? ({ $id: uid, ...memberSnap.data() } as AglynOrgMember)\n : null\n await syncMemberOrgReach(db, orgId, uid, member)\n const meta = await readHostMeta(db, hostIds)\n const ops = hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, uid, hostId)\n if (role) batch.set(ref, membershipRow(orgId, meta.get(hostId), role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n}\n\n/**\n * Re-sync ONE host's projection rows across all org members: set a row for\n * every member who can reach it, delete for those who cannot. Call after a\n * host is created, renamed, or its displayName changes.\n */\nexport async function syncHostProjectionForMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const [members, meta] = await Promise.all([\n listMembers(db, orgId),\n readHostMeta(db, [hostId]),\n ])\n const hostMeta = meta.get(hostId)\n const ops = members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, member.$id, hostId)\n if (role) batch.set(ref, membershipRow(orgId, hostMeta, role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n // The staff Sites list's keys, on the site itself. `update`, never a\n // merge-set: a site erased since the read must not be minted back as a\n // phantom carrying only these three keys (the AGL-1765 shape). Outside the\n // batch so a site gone missing cannot fail its members' rows.\n if (hostMeta?.exists) {\n await db\n .collection('hosts')\n .doc(hostId)\n .update(hostSearchFields(hostMeta))\n .catch(() => undefined)\n }\n}\n\n/** Delete every projection row for a member across the org's hosts (member removed). */\nexport async function deleteMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const hostIds = await orgHostIds(db, orgId)\n await commitChunked(\n db,\n hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, uid, hostId))\n }),\n )\n}\n\n/** Delete every member's projection row for one host (host erased). */\nexport async function deleteHostProjectionForAllMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const members = await listMembers(db, orgId)\n await commitChunked(\n db,\n members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, member.$id, hostId))\n }),\n )\n}\n"],"names":["hostRoleFor","isOrgWideMember","nameSearchKey","nameSearchTokens","FieldValue","firebaseAdmin","firestore","app","CHUNK","orgHostIds","db","orgId","org","collection","doc","get","data","Object","keys","hosts","readHostMeta","hostIds","length","Map","snaps","getAll","map","id","snap","displayName","subdomain","favicon","cname","createdAt","exists","listMembers","docs","d","$id","membershipRef","uid","hostId","WORD_SEPARATOR","wordTails","word","chars","tails","at","test","push","slice","join","hostMembershipSearchTokens","meta","words","value","flatMap","key","split","hasCustomDomain","trim","hostSearchFields","nameLower","searchTokens","isTimestamp","toMillis","membershipRow","role","delete","updatedAt","serverTimestamp","commitChunked","ops","i","batch","op","commit","syncMemberOrgReach","member","ref","orgWide","set","merge","syncMemberHostProjections","memberSnap","Promise","all","syncHostProjectionForMembers","members","hostMeta","update","catch","undefined","deleteMemberHostProjections","deleteHostProjectionForAllMembers"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-memberships.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 * Per-user host membership projection (AGL-844): a reverse index at\n * `users/{uid}/hostMemberships/{hostId}` mirroring `hosts/{hostId}.memberRoles`\n * — the hosts a user can reach in an org, denormalized with the name so the\n * site switcher and subdomain→id routing can query + order + name-prefix-search\n * a user's own sites without loading the whole `hosts` collection (which the\n * rules can only serve unordered under a per-user `memberRoles.{uid}` filter).\n *\n * Because it lives under the user's own doc it is owner-readable and already\n * membership-filtered, so `where(orgId==…) orderBy(nameLower|updatedAt)` is a\n * plain composite index (no dynamic map path). Admin-SDK-written only.\n *\n * It is a best-effort convenience index, NOT an authorization source: real\n * access stays gated by `hosts/{hostId}.memberRoles` in the rules, so a stale\n * row at worst shows a site that then 404s on read — never grants access.\n *\n * Writers are scoped, mirroring the `users/{uid}/orgs` reverse index: a member\n * change re-syncs THAT member across the org's hosts; a host change re-syncs\n * THAT host across members. Kept in sync at the same points as `memberRoles`\n * (see `syncHostMemberRoles` callers) plus the erase/displayName gaps (AGL-855).\n *\n * ## The Sites list is a query over these rows (AGL-3321)\n *\n * The organization's Sites list pages, filters and searches THIS collection —\n * `where(orgId ==)`, plus the reader's clauses, ordered by `nameLower` or\n * `createdAt` — and joins each row to the host document by id for display.\n * It cannot query `hosts` itself: the rules serve a non-staff LIST of `hosts`\n * only under a `memberRoles.{uid}` clause, a composite on that per-user map\n * path can exist for no order but the document id, and a predicated listen\n * over the mutable `memberRoles`/`orgId` is what tombstoned host documents\n * for every other reader (AGL-1190, `useOrgHosts`).\n *\n * So a row carries what that list asks of a site: `searchTokens` (the quick\n * search, over the name, the platform subdomain and the custom domain),\n * `hasCustomDomain` (the Custom domain filter) and the host's `createdAt`\n * (immutable, set once at create). Every field is derived from the host\n * document in `membershipRow`, so a re-sync from any writer re-derives all of\n * them; the writers of their source fields — create, the owner and staff\n * renames, the Setup name save, and the domain attach and detach routes (the\n * only writers of `cname`) — each re-sync. Rows written before AGL-3321 are\n * stamped by `tools/scripts/backfill-host-memberships-list-fields.mjs`.\n */\n\nimport {\n hostRoleFor,\n isOrgWideMember,\n nameSearchKey,\n type AglynOrganization,\n type AglynOrgMember,\n} from '@aglyn/aglyn/server'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** Firestore caps a batch at 500 writes; commit in chunks below that. */\nconst CHUNK = 400\n\ninterface HostMeta {\n displayName?: string\n subdomain?: string\n favicon?: string\n /** The custom domain, which the Sites list's search finds a site by. */\n cname?: string\n /** When the site was created: set once, by `claimHostForOrg`, and never moved. */\n createdAt?: unknown\n /** Whether the site document was there to read. */\n exists?: boolean\n}\n\nconst orgHostIds = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> => {\n const org = (await db.collection('orgs').doc(orgId).get()).data() as\n | AglynOrganization\n | undefined\n return Object.keys(org?.hosts ?? {})\n}\n\nconst readHostMeta = async (\n db: FirebaseFirestore.Firestore,\n hostIds: string[],\n): Promise<Map<string, HostMeta>> => {\n if (hostIds.length === 0) return new Map()\n const snaps = await db.getAll(\n ...hostIds.map((id) => db.collection('hosts').doc(id)),\n )\n return new Map(\n snaps.map((snap) => [\n snap.id,\n {\n displayName: snap.get('displayName'),\n subdomain: snap.get('subdomain'),\n // The switcher renders from the projection, not the host doc, so the\n // favicon has to travel with it (AGL-1071).\n favicon: snap.get('seo')?.favicon,\n cname: snap.get('cname'),\n createdAt: snap.get('createdAt'),\n exists: snap.exists,\n },\n ]),\n )\n}\n\nconst readOrgMembers = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<AglynOrgMember[]> => {\n const snap = await db\n .collection('orgs')\n .doc(orgId)\n .collection('members')\n .get()\n return snap.docs.map((d) => ({ $id: d.id, ...d.data() }) as AglynOrgMember)\n}\n\nconst membershipRef = (\n db: FirebaseFirestore.Firestore,\n uid: string,\n hostId: string,\n) => db.collection('users').doc(uid).collection('hostMemberships').doc(hostId)\n\n/** A tail of a word begins after any character that is not a letter or a digit. */\nconst WORD_SEPARATOR = /[^\\p{L}\\p{N}]/u\n\n/**\n * A word, and every tail of it that begins after a separator:\n * `shop.harbor-bakery.com` is itself, `harbor-bakery.com`, `bakery.com` and\n * `com`. Walked by codepoint, so a tail never starts inside a surrogate pair.\n */\nconst wordTails = (word: string): string[] => {\n const chars = [...word]\n const tails = [word]\n for (let at = 1; at < chars.length; at += 1) {\n if (WORD_SEPARATOR.test(chars[at - 1]) && !WORD_SEPARATOR.test(chars[at])) {\n tails.push(chars.slice(at).join(''))\n }\n }\n return tails\n}\n\n/**\n * The words a site is FOUND by in the Sites list's quick search (AGL-3321):\n * its name, its platform subdomain and its custom domain, as the word-prefix\n * tokens `nameSearchTokens` writes, which the list's query asks for with one\n * `array-contains` on the first typed word (`nameSearchNormalizers.token`).\n *\n * A subdomain or a domain is one \"word\" to `nameSearchTokens`, so the prefixes\n * of `harbor-bakery` alone would find it by `harbor` and never by `bakery`.\n * Every tail that begins after a separator is tokenized too, which is what\n * lets `bakery`, `bakery.com` and `shop.harbor` all find\n * `shop.harbor-bakery.com` — the words a reader remembers of an address.\n *\n * The platform apex is not a word here: it is the same on every site, so it\n * narrows nothing, and the subdomain in front of it is.\n *\n * Its script-side twin, for the backfill and the seeds that write these rows,\n * is `tools/scripts/lib/host-membership-search-tokens.mjs`; both answer\n * `tools/scripts/lib/host-membership-search-tokens.fixtures.json`, which this\n * library's spec asserts too.\n */\nexport function hostMembershipSearchTokens(\n meta: Pick<HostMeta, 'displayName' | 'subdomain' | 'cname'> | undefined,\n): string[] {\n const words = [meta?.displayName, meta?.subdomain, meta?.cname]\n .map((value) => nameSearchKey(typeof value === 'string' ? value : ''))\n .flatMap((key) => (key ? key.split(' ') : []))\n .flatMap(wordTails)\n return nameSearchTokens(words.join(' '))\n}\n\n/**\n * Whether a site has a custom domain connected, as the Sites card lists it:\n * a non-blank `cname`, which only the domain attach route writes and only the\n * detach route removes.\n */\nexport const hasCustomDomain = (meta: Pick<HostMeta, 'cname'> | undefined): boolean =>\n typeof meta?.cname === 'string' && meta.cname.trim() !== ''\n\n/**\n * The keys the staff Sites list (`/admin/sites`) queries on the SITE document\n * itself: the same name key, search tokens and custom-domain flag the\n * membership rows carry, so staff find a site by its name, subdomain or\n * domain across every organization without walking anyone's projection.\n *\n * Stamped by `syncHostProjectionForMembers` — the funnel every create,\n * rename, domain attach and domain release already calls — and by\n * `tools/scripts/backfill-host-search-fields.mjs` on the sites written before\n * it. `hasCustomDomain` is stored as `false`, never left out, for the reason\n * the membership row gives: a query cannot find a document by a field it\n * lacks.\n */\nexport const hostSearchFields = (\n meta: Pick<HostMeta, 'displayName' | 'subdomain' | 'cname'> | undefined,\n) => ({\n nameLower: nameSearchKey(meta?.displayName ?? ''),\n searchTokens: hostMembershipSearchTokens(meta),\n hasCustomDomain: hasCustomDomain(meta),\n})\n\n/** A Firestore timestamp, as opposed to a legacy number or string. */\nconst isTimestamp = (value: unknown): boolean =>\n typeof (value as { toMillis?: unknown } | null | undefined)?.toMillis === 'function'\n\n/**\n * Shape one projection row. Exported for tests: the favicon rule below is a\n * delete-vs-omit decision that is invisible in review and silent in\n * production when wrong (AGL-1071).\n */\nexport const membershipRow = (\n orgId: string,\n meta: HostMeta | undefined,\n role: string,\n) => {\n const displayName = meta?.displayName ?? ''\n return {\n orgId,\n ...(meta?.subdomain ? { subdomain: meta.subdomain } : {}),\n displayName,\n nameLower: nameSearchKey(displayName),\n // What the Sites list searches (AGL-3321). Re-derived on every sync, so a\n // rename, a domain attach or a domain release moves it with the host.\n searchTokens: hostMembershipSearchTokens(meta),\n // The Custom domain filter (AGL-3321): an equality, which every order the\n // list takes can carry. Stored as `false`, never left out, because a query\n // cannot find a document by a field it lacks.\n hasCustomDomain: hasCustomDomain(meta),\n // What the Sites list's Created filter and order read. NULL rather than\n // absent for a site that never recorded one: `orderBy('createdAt')` drops\n // a document MISSING the field, and a site must not vanish from the list\n // because it is sorted by a date it does not have.\n createdAt: isTimestamp(meta?.createdAt) ? meta?.createdAt : null,\n // DELETE rather than omit when the site has no favicon (AGL-1071). These\n // rows are written with `{ merge: true }`, so omitting the key leaves\n // whatever was there — clearing a favicon would keep showing the old one\n // in the switcher until something unrelated rewrote the row. The clear\n // path writes `seo.favicon: ''`, so test truthiness, not nullishness.\n favicon: meta?.favicon ? meta.favicon : FieldValue.delete(),\n role,\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/** Runs a set of write operations in ≤CHUNK-sized batches. */\nconst commitChunked = async (\n db: FirebaseFirestore.Firestore,\n ops: Array<(batch: FirebaseFirestore.WriteBatch) => void>,\n): Promise<void> => {\n for (let i = 0; i < ops.length; i += CHUNK) {\n const batch = db.batch()\n for (const op of ops.slice(i, i + CHUNK)) op(batch)\n await batch.commit()\n }\n}\n\n/**\n * Mirror the member's org-wide reach onto their `users/{uid}/orgs/{orgId}`\n * row (AGL-1032), so the console can tell a site collaborator from an\n * org-wide viewer without a second read on every org route.\n *\n * Rides the projection pass rather than being a call the membership APIs\n * each have to remember: every path that changes a member's reach — add,\n * role change, grant, revoke, ownership transfer — already re-syncs their\n * host rows, so one funnel keeps the mirror true and self-heals a row an\n * earlier failure left stale.\n *\n * Only ever UPDATES an existing row. The reverse index is created with the\n * membership and deleted with it; a `set`/merge here on a removed member\n * would resurrect a half-row with no `orgName` or `slug`, and the console\n * renders that as a nameless workspace nobody can open.\n */\nasync function syncMemberOrgReach(\n db: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n member: AglynOrgMember | null,\n): Promise<void> {\n if (!member) return\n const ref = db.collection('users').doc(uid).collection('orgs').doc(orgId)\n const snap = await ref.get()\n if (!snap.exists) return\n const orgWide = isOrgWideMember(member)\n if (snap.get('orgWide') === orgWide) return\n await ref.set({ orgWide }, { merge: true })\n}\n\n/**\n * Re-sync ONE member's projection rows across all the org's hosts: set a row\n * for every host the member can reach, delete the row for every host they\n * cannot (a role downgrade silently drops hosts). Call after any change to a\n * single member (add / role change / grant / revoke / ownership step).\n */\nexport async function syncMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const [memberSnap, hostIds] = await Promise.all([\n db.collection('orgs').doc(orgId).collection('members').doc(uid).get(),\n orgHostIds(db, orgId),\n ])\n const member = memberSnap.exists\n ? ({ $id: uid, ...memberSnap.data() } as AglynOrgMember)\n : null\n await syncMemberOrgReach(db, orgId, uid, member)\n const meta = await readHostMeta(db, hostIds)\n const ops = hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, uid, hostId)\n if (role) batch.set(ref, membershipRow(orgId, meta.get(hostId), role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n}\n\n/**\n * Re-sync ONE host's projection rows across all org members: set a row for\n * every member who can reach it, delete for those who cannot. Call after a\n * host is created, renamed, or its displayName changes.\n */\nexport async function syncHostProjectionForMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const [members, meta] = await Promise.all([\n readOrgMembers(db, orgId),\n readHostMeta(db, [hostId]),\n ])\n const hostMeta = meta.get(hostId)\n const ops = members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, member.$id, hostId)\n if (role) batch.set(ref, membershipRow(orgId, hostMeta, role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n // The staff Sites list's keys, on the site itself. `update`, never a\n // merge-set: a site erased since the read must not be minted back as a\n // phantom carrying only these three keys (the AGL-1765 shape). Outside the\n // batch so a site gone missing cannot fail its members' rows.\n if (hostMeta?.exists) {\n await db\n .collection('hosts')\n .doc(hostId)\n .update(hostSearchFields(hostMeta))\n .catch(() => undefined)\n }\n}\n\n/** Delete every projection row for a member across the org's hosts (member removed). */\nexport async function deleteMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const hostIds = await orgHostIds(db, orgId)\n await commitChunked(\n db,\n hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, uid, hostId))\n }),\n )\n}\n\n/** Delete every member's projection row for one host (host erased). */\nexport async function deleteHostProjectionForAllMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const members = await readOrgMembers(db, orgId)\n await commitChunked(\n db,\n members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, member.$id, hostId))\n }),\n )\n}\n"],"names":["hostRoleFor","isOrgWideMember","nameSearchKey","nameSearchTokens","FieldValue","firebaseAdmin","firestore","app","CHUNK","orgHostIds","db","orgId","org","collection","doc","get","data","Object","keys","hosts","readHostMeta","hostIds","length","Map","snaps","getAll","map","id","snap","displayName","subdomain","favicon","cname","createdAt","exists","readOrgMembers","docs","d","$id","membershipRef","uid","hostId","WORD_SEPARATOR","wordTails","word","chars","tails","at","test","push","slice","join","hostMembershipSearchTokens","meta","words","value","flatMap","key","split","hasCustomDomain","trim","hostSearchFields","nameLower","searchTokens","isTimestamp","toMillis","membershipRow","role","delete","updatedAt","serverTimestamp","commitChunked","ops","i","batch","op","commit","syncMemberOrgReach","member","ref","orgWide","set","merge","syncMemberHostProjections","memberSnap","Promise","all","syncHostProjectionForMembers","members","hostMeta","update","catch","undefined","deleteMemberHostProjections","deleteHostProjectionForAllMembers"],"mappings":"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"}
|
|
@@ -150,13 +150,19 @@ export declare function leadForWrite(hostId: string, key: string): Promise<{
|
|
|
150
150
|
* so the record cannot be edited away by the site it describes, and host
|
|
151
151
|
* admins can already READ it — which is what lets the inbox console page
|
|
152
152
|
* render it with no rules change.
|
|
153
|
-
* - One
|
|
154
|
-
*
|
|
155
|
-
*
|
|
153
|
+
* - One notification to the site's managers per CROSSING (AGL-3442): on the
|
|
154
|
+
* first refusal after the site reaches the ceiling, and not again while it
|
|
155
|
+
* stays there. A notification per refused bot request would be the flood
|
|
156
|
+
* again, delivered, and one a month would repeat an alarm about a state a
|
|
157
|
+
* site can sit in for months. {@link rearmVisitorRecordCeilingNotice}
|
|
158
|
+
* re-arms it once the site is seen below the ceiling, so reaching it again
|
|
159
|
+
* is announced again.
|
|
156
160
|
*
|
|
157
161
|
* The counter is month-keyed even though the CEILING is a total, because the
|
|
158
162
|
* two facts are different: the ceiling is "how many records exist" and this is
|
|
159
|
-
* "how many were turned away recently".
|
|
163
|
+
* "how many were turned away recently". The inbox reports the second; the
|
|
164
|
+
* notice announces the first, which is why it follows the crossing and not
|
|
165
|
+
* the month.
|
|
160
166
|
*
|
|
161
167
|
* Best-effort throughout: bookkeeping that failed must never turn a contained
|
|
162
168
|
* refusal into a 500, because a 500 is an invitation to retry.
|
|
@@ -168,6 +174,28 @@ export declare function recordVisitorRecordCeilingTrip(options: {
|
|
|
168
174
|
ceiling: number;
|
|
169
175
|
monthKey?: string;
|
|
170
176
|
}): Promise<void>;
|
|
177
|
+
/**
|
|
178
|
+
* Re-arm the ceiling notice when an accepted record shows the site was below
|
|
179
|
+
* the ceiling (AGL-3442).
|
|
180
|
+
*
|
|
181
|
+
* Called by every writer that judges a new record against the ceiling, with
|
|
182
|
+
* the count it judged. It does nothing unless the record it just accepted took
|
|
183
|
+
* the site's LAST free slot: a site that dropped below the ceiling can reach
|
|
184
|
+
* it again only through that accept, so it is where the re-arm is observed,
|
|
185
|
+
* and every other accept pays nothing for it. That accept reads the refusal
|
|
186
|
+
* counter, and clears the notice marker only when the previous crossing was
|
|
187
|
+
* announced, so a site that never reached the ceiling gains no document.
|
|
188
|
+
*
|
|
189
|
+
* Best-effort and never throws, for the reason the trip's bookkeeping is: the
|
|
190
|
+
* record is already stored, and a failure here only costs a notice.
|
|
191
|
+
*/
|
|
192
|
+
export declare function rearmVisitorRecordCeilingNotice(options: {
|
|
193
|
+
hostRef: FirebaseFirestore.DocumentReference;
|
|
194
|
+
kind: VisitorRecordKind;
|
|
195
|
+
/** The live count the accept was judged against, before its own record. */
|
|
196
|
+
used: number;
|
|
197
|
+
ceiling: number;
|
|
198
|
+
}): Promise<void>;
|
|
171
199
|
/** What a caller wants stored on the lead. `createdAt` is stamped here. */
|
|
172
200
|
export interface HostLeadInput {
|
|
173
201
|
email: string;
|
|
@@ -22,7 +22,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
22
22
|
* The policy is pure and lives in `@aglyn/aglyn/server`
|
|
23
23
|
* (`visitor-record-ceiling.ts`); this is the part that needs Firestore. Same
|
|
24
24
|
* split as `visitorWriteRateLimitRefusal`, and for the same reason.
|
|
25
|
-
*/ import { CAPTURED_BY_HOST_FIELD, checkVisitorRecordCeiling, LEADS_MAX_PER_HOST, marketingConsentFieldsForGroup, personKey, readMarketingBasis, submissionMonthKey, visitorRecordRefusedCounterId } from "@aglyn/aglyn/server";
|
|
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";
|
|
26
26
|
import { FieldValue } from "firebase-admin/firestore";
|
|
27
27
|
import { attributeCampaignConversion } from "./campaign-conversion-attribution.js";
|
|
28
28
|
import { notifyHostManagers } from "./notifications.js";
|
|
@@ -167,13 +167,19 @@ const firestore = ()=>firebaseAdmin.app().firestore();
|
|
|
167
167
|
* so the record cannot be edited away by the site it describes, and host
|
|
168
168
|
* admins can already READ it — which is what lets the inbox console page
|
|
169
169
|
* render it with no rules change.
|
|
170
|
-
* - One
|
|
171
|
-
*
|
|
172
|
-
*
|
|
170
|
+
* - One notification to the site's managers per CROSSING (AGL-3442): on the
|
|
171
|
+
* first refusal after the site reaches the ceiling, and not again while it
|
|
172
|
+
* stays there. A notification per refused bot request would be the flood
|
|
173
|
+
* again, delivered, and one a month would repeat an alarm about a state a
|
|
174
|
+
* site can sit in for months. {@link rearmVisitorRecordCeilingNotice}
|
|
175
|
+
* re-arms it once the site is seen below the ceiling, so reaching it again
|
|
176
|
+
* is announced again.
|
|
173
177
|
*
|
|
174
178
|
* The counter is month-keyed even though the CEILING is a total, because the
|
|
175
179
|
* two facts are different: the ceiling is "how many records exist" and this is
|
|
176
|
-
* "how many were turned away recently".
|
|
180
|
+
* "how many were turned away recently". The inbox reports the second; the
|
|
181
|
+
* notice announces the first, which is why it follows the crossing and not
|
|
182
|
+
* the month.
|
|
177
183
|
*
|
|
178
184
|
* Best-effort throughout: bookkeeping that failed must never turn a contained
|
|
179
185
|
* refusal into a 500, because a 500 is an invitation to retry.
|
|
@@ -182,24 +188,35 @@ const firestore = ()=>firebaseAdmin.app().firestore();
|
|
|
182
188
|
const { hostRef, hostId, kind, ceiling } = options;
|
|
183
189
|
const monthKey = (_options_monthKey = options.monthKey) != null ? _options_monthKey : submissionMonthKey();
|
|
184
190
|
try {
|
|
185
|
-
var _refusedSnapshot_get;
|
|
186
191
|
const refusedRef = hostRef.collection('counters').doc(visitorRecordRefusedCounterId(kind));
|
|
187
192
|
const refusedSnapshot = await refusedRef.get();
|
|
188
|
-
|
|
189
|
-
|
|
193
|
+
// Announced already for this crossing, by this writer or by the
|
|
194
|
+
// month-scoped one before it — see `visitorRecordCeilingAnnounced`.
|
|
195
|
+
const announced = visitorRecordCeilingAnnounced(refusedSnapshot.data());
|
|
196
|
+
const now = Date.now();
|
|
197
|
+
await refusedRef.set(_extends({
|
|
190
198
|
[monthKey]: FieldValue.increment(1),
|
|
191
199
|
// Explicit values only — Firestore rejects `undefined`.
|
|
192
200
|
ceiling,
|
|
193
|
-
lastRefusedAtMs:
|
|
194
|
-
}, {
|
|
201
|
+
lastRefusedAtMs: now
|
|
202
|
+
}, announced ? {} : {
|
|
203
|
+
[VISITOR_RECORD_NOTICE_SENT_FIELD]: now
|
|
204
|
+
}), {
|
|
195
205
|
merge: true
|
|
196
206
|
});
|
|
197
|
-
if (
|
|
207
|
+
if (!announced) {
|
|
198
208
|
const leads = kind === 'leads';
|
|
199
|
-
|
|
209
|
+
/*
|
|
210
|
+
* What is refused, said exactly (AGL-3432). A lead door — a form
|
|
211
|
+
* routed to leads, a booking — stores its own record BEFORE it files
|
|
212
|
+
* the lead, so at the ceiling the visitor's submission or booking is
|
|
213
|
+
* still kept and only the new lead is not. A member sign-up is refused
|
|
214
|
+
* whole: no account is created. `{site}` is the site's name, filled by
|
|
215
|
+
* `notifyHostManagers` from the host doc it already reads.
|
|
216
|
+
*/ await notifyHostManagers(hostId, {
|
|
200
217
|
type: 'system.visitorRecordsPaused',
|
|
201
|
-
title: leads ? 'Lead capture paused
|
|
202
|
-
body: `
|
|
218
|
+
title: leads ? 'Lead capture paused on {site} — platform limit reached' : 'Sign-ups paused on {site} — platform limit reached',
|
|
219
|
+
body: leads ? `{site} holds ${ceiling.toLocaleString()} leads, the platform ` + 'safety limit, so new people are no longer added as leads. What ' + 'they send, such as a form submission or a booking, is still ' + 'saved; only the new lead is not. This limit is not part of your ' + 'plan — every plan includes unlimited leads — and nothing is ' + 'charged. Remove some leads, or contact support if this is real ' + 'traffic.' : `{site} has ${ceiling.toLocaleString()} member accounts, the ` + 'platform safety limit, so new sign-ups are refused and no ' + 'account is created. This limit is not part of your plan — every ' + 'plan includes unlimited member accounts — and nothing is ' + 'charged. Remove some members, or contact support if this is ' + 'real traffic.',
|
|
203
220
|
link: `/${hostId}/inbox`
|
|
204
221
|
});
|
|
205
222
|
}
|
|
@@ -207,6 +224,36 @@ const firestore = ()=>firebaseAdmin.app().firestore();
|
|
|
207
224
|
console.error('visitor record ceiling bookkeeping failed', error);
|
|
208
225
|
}
|
|
209
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* Re-arm the ceiling notice when an accepted record shows the site was below
|
|
229
|
+
* the ceiling (AGL-3442).
|
|
230
|
+
*
|
|
231
|
+
* Called by every writer that judges a new record against the ceiling, with
|
|
232
|
+
* the count it judged. It does nothing unless the record it just accepted took
|
|
233
|
+
* the site's LAST free slot: a site that dropped below the ceiling can reach
|
|
234
|
+
* it again only through that accept, so it is where the re-arm is observed,
|
|
235
|
+
* and every other accept pays nothing for it. That accept reads the refusal
|
|
236
|
+
* counter, and clears the notice marker only when the previous crossing was
|
|
237
|
+
* announced, so a site that never reached the ceiling gains no document.
|
|
238
|
+
*
|
|
239
|
+
* Best-effort and never throws, for the reason the trip's bookkeeping is: the
|
|
240
|
+
* record is already stored, and a failure here only costs a notice.
|
|
241
|
+
*/ export async function rearmVisitorRecordCeilingNotice(options) {
|
|
242
|
+
const { hostRef, kind, used, ceiling } = options;
|
|
243
|
+
if (!visitorRecordAcceptFillsCeiling(used, ceiling)) return;
|
|
244
|
+
try {
|
|
245
|
+
const refusedRef = hostRef.collection('counters').doc(visitorRecordRefusedCounterId(kind));
|
|
246
|
+
const refusedSnapshot = await refusedRef.get();
|
|
247
|
+
if (!visitorRecordCeilingAnnounced(refusedSnapshot.data())) return;
|
|
248
|
+
await refusedRef.set({
|
|
249
|
+
[VISITOR_RECORD_NOTICE_SENT_FIELD]: null
|
|
250
|
+
}, {
|
|
251
|
+
merge: true
|
|
252
|
+
});
|
|
253
|
+
} catch (error) {
|
|
254
|
+
console.error('visitor record ceiling re-arm failed', error);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
210
257
|
/**
|
|
211
258
|
* Record one lead at `hosts/{hostId}/leads/{personKey}`, bounded by
|
|
212
259
|
* `LEADS_MAX_PER_HOST` (AGL-1529).
|
|
@@ -302,6 +349,8 @@ const firestore = ()=>firebaseAdmin.app().firestore();
|
|
|
302
349
|
let created = false;
|
|
303
350
|
let sourceAdded = false;
|
|
304
351
|
let predicted = false;
|
|
352
|
+
// The count a new person was judged against, for the notice re-arm.
|
|
353
|
+
let usedBeforeCreate = null;
|
|
305
354
|
const refused = await firestore.runTransaction(async (tx)=>{
|
|
306
355
|
var _existing_data, _existing_data1, _existing_data2, _existing_data3;
|
|
307
356
|
// Reset per attempt: a contended transaction re-runs its body, and a
|
|
@@ -309,6 +358,7 @@ const firestore = ()=>firebaseAdmin.app().firestore();
|
|
|
309
358
|
// with a person who turned out to exist already.
|
|
310
359
|
created = false;
|
|
311
360
|
sourceAdded = false;
|
|
361
|
+
usedBeforeCreate = null;
|
|
312
362
|
// ALL READS BEFORE THE WRITE, which Firestore requires.
|
|
313
363
|
const existing = await tx.get(leadRef);
|
|
314
364
|
// Whether the lead already reads "Would bounce", for the check below.
|
|
@@ -341,6 +391,7 @@ const firestore = ()=>firebaseAdmin.app().firestore();
|
|
|
341
391
|
* both, which is the honest answer: each of them holds it.
|
|
342
392
|
*/ const used = (await tx.get(scopedToHost(leadsRef, hostId).count())).data().count;
|
|
343
393
|
if (checkVisitorRecordCeiling(used, maxPerHost).exceeded) return true;
|
|
394
|
+
usedBeforeCreate = used;
|
|
344
395
|
}
|
|
345
396
|
/*
|
|
346
397
|
* Consent is carried forward and never cleared.
|
|
@@ -450,6 +501,14 @@ const firestore = ()=>firebaseAdmin.app().firestore();
|
|
|
450
501
|
});
|
|
451
502
|
return NOT_STORED;
|
|
452
503
|
}
|
|
504
|
+
if (created && usedBeforeCreate !== null) {
|
|
505
|
+
await rearmVisitorRecordCeilingNotice({
|
|
506
|
+
hostRef,
|
|
507
|
+
kind: 'leads',
|
|
508
|
+
used: usedBeforeCreate,
|
|
509
|
+
ceiling: maxPerHost
|
|
510
|
+
});
|
|
511
|
+
}
|
|
453
512
|
/*
|
|
454
513
|
* ATTRIBUTED ON CREATION ONLY.
|
|
455
514
|
*
|