@aglyn/tenant-data-admin 1.0.0-beta.198 → 1.0.0-beta.200
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 +7 -7
- package/src/lib/server/admin-audit-write.d.ts +28 -0
- package/src/lib/server/admin-audit-write.js +46 -0
- package/src/lib/server/admin-audit-write.js.map +1 -0
- package/src/lib/server/admin-audit.d.ts +4 -9
- package/src/lib/server/admin-audit.js +12 -31
- package/src/lib/server/admin-audit.js.map +1 -1
- package/src/lib/server/collection-live-pages.d.ts +83 -0
- package/src/lib/server/collection-live-pages.js +198 -0
- package/src/lib/server/collection-live-pages.js.map +1 -0
- package/src/lib/server/consent-group-carry.js +2 -0
- package/src/lib/server/consent-group-carry.js.map +1 -1
- package/src/lib/server/contact-company-link.js +3 -0
- package/src/lib/server/contact-company-link.js.map +1 -1
- package/src/lib/server/contact-lifecycle-floor.js +3 -0
- package/src/lib/server/contact-lifecycle-floor.js.map +1 -1
- package/src/lib/server/contact-merge.js +4 -0
- package/src/lib/server/contact-merge.js.map +1 -1
- package/src/lib/server/crm-booking-activity.js +3 -2
- package/src/lib/server/crm-booking-activity.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +15 -0
- package/src/lib/server/crm-records.js +52 -0
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/csp-aggregate.d.ts +16 -0
- package/src/lib/server/csp-aggregate.js +36 -0
- package/src/lib/server/csp-aggregate.js.map +1 -1
- package/src/lib/server/duplicate-resource.js +12 -5
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/email-delivery-log.d.ts +33 -0
- package/src/lib/server/email-delivery-log.js +43 -2
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +11 -14
- package/src/lib/server/email-suppression.js +14 -31
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/erase-person.js +22 -3
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/erase.js +4 -3
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-memberships.d.ts +34 -0
- package/src/lib/server/host-memberships.js +89 -1
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/host-visitor-records.js +23 -3
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/list-members.d.ts +11 -0
- package/src/lib/server/list-members.js +28 -1
- package/src/lib/server/list-members.js.map +1 -1
- package/src/lib/server/list-query.d.ts +36 -0
- package/src/lib/server/list-query.js +49 -0
- package/src/lib/server/list-query.js.map +1 -0
- package/src/lib/server/live-page-usage.d.ts +33 -0
- package/src/lib/server/live-page-usage.js +61 -1
- package/src/lib/server/live-page-usage.js.map +1 -1
- package/src/lib/server/media-tombstone.js +8 -2
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/org-member-list-fields.d.ts +31 -0
- package/src/lib/server/org-member-list-fields.js +38 -0
- package/src/lib/server/org-member-list-fields.js.map +1 -0
- package/src/lib/server/organizations.js +37 -7
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/replay-erasures.js +2 -1
- package/src/lib/server/replay-erasures.js.map +1 -1
- package/src/lib/server/sso-enforcement.js +2 -1
- package/src/lib/server/sso-enforcement.js.map +1 -1
- package/src/lib/server/upsert-contact.js +8 -4
- package/src/lib/server/upsert-contact.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/list-members.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The only writer of `orgs/{orgId}/lists/{listId}/members` (AGL-2499).\n *\n * Two enrollment routes reach this collection — the commerce newsletter\n * handler and the workflow `enrollList` step — and they used to derive the\n * document id two incompatible ways: a full `sha256(email)` on one side, a\n * `hmac('aglyn-list-member', email)` truncated to 20 hex on the other. The\n * same person subscribing by both routes became two members of one list.\n *\n * The id is now `personKey` on both, which is the derivation both\n * `docs/specs/email-overhaul.md` §3d and `docs/specs/reusable-forms.md` §4\n * specify, and it normalizes before hashing so casing cannot fork it either.\n *\n * ## Why a helper and not two corrected call sites\n *\n * Two call sites that merely agree today are what produced the split: nothing\n * about either one said the other existed. A third route — the reusable-forms\n * capture path is already specified — would have been written the same way.\n * Enrolling goes through this function so that the id has one definition and\n * no caller is offered the chance to derive its own.\n */\n\nimport type { DocumentReference } from 'firebase-admin/firestore'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n normalizeContactEmail,\n personKey,\n CAPTURED_BY_HOST_FIELD,\n MARKETING_CONSENT_BASIS_FIELD,\n MARKETING_CONSENT_BY_HOST_FIELD,\n MARKETING_CONSENT_FIELD,\n marketingConsentFieldsForGroup,\n readMarketingBasis,\n type ConsentGroup,\n} from '@aglyn/aglyn/server'\nimport { consentGroupForGrant } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { createHash, createHmac } from 'node:crypto'\n\n/**\n * The two ids this collection was written under before `personKey`.\n *\n * Read-only, and never written for a new row: they exist so an address\n * already enrolled under a legacy id is *found* rather than duplicated. See\n * `enrollListMember` for why that lookup is the migration.\n *\n * Both take the normalized address, which is what the two original call sites\n * happened to pass — each lowercased at its own entry point before hashing, so\n * every row already on the collection was keyed from a lowercased address even\n * though neither derivation enforced it.\n */\nfunction legacyListMemberIds(normalizedEmail: string): string[] {\n return [\n createHash('sha256').update(normalizedEmail).digest('hex'),\n createHmac('sha256', 'aglyn-list-member')\n .update(normalizedEmail)\n .digest('hex')\n .slice(0, 20),\n ]\n}\n\n/**\n * Every document id one address may be filed under on a list's `members`:\n * the canonical key first, then the two ids earlier writers minted. A\n * reader that must find the person wherever they were enrolled — the\n * erasure sweep (AGL-2623) — asks for all of them with one `getAll`, the\n * same set `enrollListMember` reads before it writes, so a member enrolled\n * under an old id is neither missed nor duplicated.\n */\nexport function listMemberDocIds(email: unknown): string[] {\n const normalized = normalizeContactEmail(email)\n if (!normalized) return []\n const key = personKey(normalized)\n if (!key) return []\n return [key, ...legacyListMemberIds(normalized).filter((id) => id !== key)]\n}\n\n/**\n * What makes an enrollment mailable, and who is answerable for it.\n *\n * Two values and no third. They are not interchangeable and the difference is\n * the whole reason the basis is stored rather than implied:\n *\n * - `contact-opt-in` — the person ticked a box. The enrollment carries a\n * decision they made, and `atMs` is when THEY made it, not when the row was\n * written. Nobody is answerable because nobody asserted anything.\n * - `operator-attested` — a console account stated that they have this\n * person's permission. That statement is itself the basis, so it is only\n * worth storing with the account that made it and the moment they did.\n *\n * Rendering the second as though it were the first is what a support or\n * compliance question cannot recover from, which is why the basis rides\n * beside `marketingConsent` on every row that has one.\n */\nexport type ListMemberConsentBasis = 'contact-opt-in' | 'operator-attested'\n\n/** A basis, with the attribution that makes it answerable. */\nexport interface ListMemberConsent {\n basis: ListMemberConsentBasis\n /**\n * When the basis was recorded.\n *\n * For a pass-through this is the ORIGINAL opt-in moment carried over from\n * the person's own record, because the question a consent record answers is\n * when the person said yes — not when somebody copied their answer onto a\n * list.\n */\n atMs: number\n /** The console account that attested. Meaningless for a pass-through. */\n byUid?: string | null\n /**\n * Why, in the operator's own terms, for whoever audits this later.\n *\n * Written for an attestation and ignored for a pass-through, which needs no\n * explanation beyond the person's own record. An import fills it from what\n * the FILE declared — the opt-in source and date the merchant supplied per\n * row — because \"a declared basis per address, not one checkbox over the\n * file\" is only worth asking for if the declaration survives onto the row\n * it was made about.\n */\n reason?: string\n}\n\nexport interface EnrollListMemberInput {\n /** `orgs/{orgId}/lists/{listId}` — the caller has already proved it exists. */\n listRef: DocumentReference\n /**\n * The consent group the enrollment is being made IN THE NAME OF.\n *\n * Lists live on the org and every site in it can mail one, so a membership\n * carries a basis that no path could attribute to a controller. A GROUP\n * rather than a site, because a business running three sites under one name\n * legitimately enrolls somebody into all three at once — and an agency's\n * client, which declared no group, gets exactly itself.\n *\n * Required and undefaulted: a caller that has not resolved a group has not\n * decided who this person agreed to hear from.\n */\n group: ConsentGroup\n email: string\n name?: string\n /** Free-form provenance: `'newsletter'`, `'action:{actionId}'`. */\n source: string\n /**\n * Explicit marketing opt-in, with a consent timestamp — the same shape\n * `upsertHostContact` and `addHostLead` already carry.\n *\n * A list membership had NO consent field of any kind, so `audience: 'list'`\n * had nothing for the send-time join to read\n * (`docs/specs/email-overhaul.md` §1d/§3f). This is the field that closes\n * it, and it is the caller's captured checkbox: enrolling is an act, and\n * `false`/omitted therefore writes nothing rather than a basis.\n */\n marketingConsent?: boolean\n /**\n * The basis in full, for a caller that knows more than \"a box was ticked\".\n *\n * `marketingConsent: true` is the shorthand a capture surface uses: it means\n * `contact-opt-in`, recorded now, asserted by nobody. Anything else — a\n * pass-through carrying the person's ORIGINAL opt-in date, or an\n * attestation carrying the account that made it — has to say so, and this\n * is where it says it. The two inputs are normalized to one value before\n * anything is written, so there is a single writer of the consent fields.\n */\n consent?: ListMemberConsent\n /**\n * The person's own grants, as `grantEntriesAsRecorded` read them off their\n * record — what a PASS-THROUGH carries (AGL-3320).\n *\n * A `contact-opt-in` consent with these writes exactly these sites, with\n * each grant's own moment and the disclosure it was given under: an opt-in\n * given to one site before the group existed stays that site's on the list,\n * and one given under the group's sentence reaches the sites it named.\n * Re-recording the pass-through over {@link group} instead would widen the\n * first to every site the group names today.\n */\n grantEntries?: Readonly<Record<string, Readonly<Record<string, unknown>>>>\n /**\n * The `consentGroupDisclosureKey` a capture surface rendered beside its\n * checkbox, for a {@link marketingConsent} captured just now. The grant\n * pools across {@link group} only when it is the group's current key.\n *\n * Every other basis without {@link grantEntries} — an attestation, a\n * capture that rendered no disclosure — is recorded for the enrolling site\n * alone: an operator's word about one site's list is not a disclosure\n * anybody read about the rest.\n */\n disclosedConsentGroup?: string | null\n /**\n * How the person got here: enrolled by hand or by an automation\n * (`'manual'`), or selected by a dynamic list's rule (`'rule'`).\n *\n * The materializer needs to tell its own rows apart from a manual one on\n * the same list, because a person who stops matching the rule leaves and a\n * person somebody added by hand does not.\n */\n via?: 'manual' | 'rule'\n}\n\nexport interface EnrolledListMember {\n /** The document actually written — a legacy id when one was adopted. */\n memberId: string\n /** True when an existing legacy-keyed row was written instead of a new one. */\n adopted: boolean\n /** False when the row already existed under some id. */\n created: boolean\n}\n\n/** Why nothing was written. */\nexport type EnrollListMemberRefusal =\n /** Not an address this collection can key. Nothing to write, nothing to fix. */\n | 'unusable-address'\n /**\n * The person's membership records a REFUSAL. Not overridable by any caller\n * and not reported as a failure to fix, because the fix would be to ignore\n * it.\n */\n | 'declined'\n\n/**\n * Enrolled, or refused and why.\n *\n * A union rather than `EnrolledListMember | null`, because the two refusals\n * are not the same event and a caller that cannot tell them apart is a caller\n * that reports a person's recorded refusal as a malformed address.\n */\nexport type EnrollListMemberResult =\n | ({ enrolled: true } & EnrolledListMember)\n | { enrolled: false; refusal: EnrollListMemberRefusal }\n\n/**\n * Enrolls one address into a list, at one document per person.\n *\n * ## The legacy lookup is the migration\n *\n * Changing the derivation without one would strand every row written under the\n * old ids: the next enrollment of an address already on the list would key a\n * *new* document beside the old one, so a defect that produced duplicates only\n * when two routes met would start producing them on a single route. Rather\n * than rewrite those ids — a bulk operation that has to delete the row it\n * replaces, and deleting an enrollment destroys the consent record that says\n * the person asked to be there — the write resolves the person's existing\n * document and keeps using it.\n *\n * The cost is one `getAll` of three refs, on a human-triggered signup, in\n * place of the blind `set` this replaced. It is one round trip, not three.\n *\n * `addedAt` is stamped only when the document is created, so re-enrolling\n * keeps the date the person actually joined — the same \"earliest wins\" collapse\n * `docs/specs/email-overhaul.md` §3d asks a backfill to preserve.\n *\n * ## A recorded refusal stops every route through this function\n *\n * This is the only writer of the collection, so the refusal check belongs\n * here and not at the four call sites: a guard on the button that adds\n * somebody by hand leaves the newsletter handler, the workflow `enrollList`\n * step and the dynamic-list materializer free to put the same person back.\n * The membership snapshot is already in hand from the id resolution above, so\n * the check costs no read.\n *\n * It answers the question about the LIST ROW. A refusal recorded on the\n * person's CRM record is a different document this function does not fetch —\n * a caller that can see one is expected to consult it, and the send-time\n * consent join reads the row this function writes either way.\n */\nexport async function enrollListMember(\n input: EnrollListMemberInput,\n): Promise<EnrollListMemberResult> {\n const email = normalizeContactEmail(input.email)\n if (!email) return { enrolled: false, refusal: 'unusable-address' }\n const key = personKey(email)\n if (!key) return { enrolled: false, refusal: 'unusable-address' }\n\n const members = input.listRef.collection('members')\n const canonicalRef = members.doc(key)\n const legacyRefs = legacyListMemberIds(email)\n // A legacy derivation that happens to agree with `personKey` — `sha256` of\n // the same normalized address does — must not be fetched twice: `getAll`\n // rejects duplicate references.\n .filter((id) => id !== key)\n .map((id) => members.doc(id))\n\n const snapshots = await input.listRef.firestore.getAll(\n canonicalRef,\n ...legacyRefs,\n )\n const existing = snapshots.find((snapshot) => snapshot.exists)\n const target = existing?.ref ?? canonicalRef\n\n if (\n existing &&\n readMarketingBasis(existing.data() as Record<string, unknown>, input.group)\n .basis === 'declined'\n ) {\n return { enrolled: false, refusal: 'declined' }\n }\n\n /*\n * ONE value from the two ways a caller may state a basis.\n *\n * `marketingConsent: true` is a capture surface saying \"a box was ticked\n * just now\", which is `contact-opt-in` recorded at this moment with nobody\n * asserting it. Normalizing here rather than branching at the write is what\n * keeps the consent fields to a single writer — two branches stamping\n * overlapping subsets of them is how a row comes to carry a basis with no\n * timestamp, or a timestamp with no basis.\n */\n const consent: ListMemberConsent | null =\n input.consent ??\n (input.marketingConsent\n ? { basis: 'contact-opt-in', atMs: Date.now() }\n : null)\n\n await target.set(\n {\n email,\n ...(input.name ? { name: input.name } : {}),\n source: input.source,\n ...(input.via ? { via: input.via } : {}),\n /*\n * Written only when the caller has a basis, and never unwritten.\n *\n * A merge that stamped `marketingConsent: false` on the omitted case\n * would erase a basis this person gave on an earlier enrollment, and a\n * withdrawn consent is not the same event as a re-enrollment that\n * happened to carry no checkbox. Withdrawal has its own path — the\n * unsubscribe link and the suppression list.\n *\n * `marketingConsentAtMs` moves with each fresh basis on purpose: the\n * question a consent record answers is when the person last said yes.\n *\n * The `marketingConsent` family rather than the `consent*` names\n * `docs/specs/email-overhaul.md` §3d sketched: `readMarketingBasis` is\n * the shipped reader and it reads these, so a second vocabulary here\n * would be a basis the send-time join cannot see. The two new fields\n * are attribution ON that field — they say why the person is mailable,\n * never whether — so nothing about the send decision moves into them.\n *\n * Written UNDER the enrolling GROUP, through the one writer helper. A\n * list lives on the org and every site in it can mail one, so a basis\n * written at the top of this row would enroll a person into one\n * client's newsletter and make them mailable by all of them. A group of\n * one — the default — is that client and nobody else.\n *\n * `marketingConsentByUid` is written on EVERY basis, `null` for a\n * pass-through. Leaving it absent would let a real opt-in that\n * supersedes an earlier attestation inherit the attesting account, and\n * a row reading \"this account vouched for them\" when the person ticked\n * a box themselves is a false attribution in the one direction a\n * compliance answer cannot afford.\n *\n * `marketingConsentReason` follows the same rule for the same reason:\n * always written, `''` for a pass-through, so a later real opt-in\n * cannot inherit the sentence an earlier import wrote about where the\n * merchant said the address came from.\n *\n * WHICH sites the basis covers is `membershipConsentFields`' to say,\n * and it never re-derives a grant from the group as it stands now.\n */\n ...(consent ? membershipConsentFields(input, consent) : {}),\n ...(existing\n ? {}\n : {\n addedAt: FieldValue.serverTimestamp(),\n [CAPTURED_BY_HOST_FIELD]: [input.group.hostId],\n }),\n },\n { merge: true },\n )\n\n return {\n enrolled: true,\n memberId: target.id,\n adopted: target.id !== key,\n created: !existing,\n }\n}\n\n/**\n * The consent fields one enrollment writes: the basis, its attribution, and\n * the sites it covers (AGL-3320).\n *\n * Three ways in, and the sites follow from which one it is:\n *\n * - a PASS-THROUGH with the person's {@link EnrollListMemberInput.grantEntries}\n * writes those sites and no others, each grant with its own moment and\n * the disclosure it was given under;\n * - a capture's checkbox pools over the group only when its surface sent\n * back the group's current disclosure key — `consentGroupForGrant`;\n * - everything else, an attestation among them, is the enrolling site's\n * alone.\n *\n * The attribution is the membership's own on every entry, the pass-through's\n * included, for the reason the write explains: an entry that kept an earlier\n * attestation's account under a person's own opt-in would be a false claim\n * about who vouched for them.\n */\nfunction membershipConsentFields(\n input: EnrollListMemberInput,\n consent: ListMemberConsent,\n): Record<string, unknown> {\n const attribution = {\n [MARKETING_CONSENT_BASIS_FIELD]: consent.basis,\n marketingConsentByUid: consent.byUid ?? null,\n marketingConsentReason: consent.reason ?? '',\n }\n const carried =\n consent.basis === 'contact-opt-in'\n ? Object.entries(input.grantEntries ?? {})\n : []\n if (carried.length) {\n return {\n [MARKETING_CONSENT_BY_HOST_FIELD]: Object.fromEntries(\n carried.map(([hostId, entry]) => [\n hostId,\n {\n ...entry,\n [MARKETING_CONSENT_FIELD]: true,\n // The grant's own moment; the pass-through's only when the\n // person's record never stamped one, as the basis itself falls\n // back to now.\n marketingConsentAtMs: entry['marketingConsentAtMs'] ?? consent.atMs,\n ...attribution,\n },\n ]),\n ),\n }\n }\n return marketingConsentFieldsForGroup(\n consentGroupForGrant(input.group, input.disclosedConsentGroup),\n consent.atMs,\n attribution,\n )\n}\n"],"names":["FieldValue","normalizeContactEmail","personKey","CAPTURED_BY_HOST_FIELD","MARKETING_CONSENT_BASIS_FIELD","MARKETING_CONSENT_BY_HOST_FIELD","MARKETING_CONSENT_FIELD","marketingConsentFieldsForGroup","readMarketingBasis","consentGroupForGrant","createHash","createHmac","legacyListMemberIds","normalizedEmail","update","digest","slice","listMemberDocIds","email","normalized","key","filter","id","enrollListMember","input","enrolled","refusal","members","listRef","collection","canonicalRef","doc","legacyRefs","map","snapshots","firestore","getAll","existing","find","snapshot","exists","target","ref","data","group","basis","consent","marketingConsent","atMs","Date","now","set","name","source","via","membershipConsentFields","addedAt","serverTimestamp","hostId","merge","memberId","adopted","created","attribution","marketingConsentByUid","byUid","marketingConsentReason","reason","carried","Object","entries","grantEntries","length","fromEntries","entry","marketingConsentAtMs","disclosedConsentGroup"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/list-members.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The only writer of `orgs/{orgId}/lists/{listId}/members` (AGL-2499).\n *\n * Two enrollment routes reach this collection — the commerce newsletter\n * handler and the workflow `enrollList` step — and they used to derive the\n * document id two incompatible ways: a full `sha256(email)` on one side, a\n * `hmac('aglyn-list-member', email)` truncated to 20 hex on the other. The\n * same person subscribing by both routes became two members of one list.\n *\n * The id is now `personKey` on both, which is the derivation both\n * `docs/specs/email-overhaul.md` §3d and `docs/specs/reusable-forms.md` §4\n * specify, and it normalizes before hashing so casing cannot fork it either.\n *\n * ## Why a helper and not two corrected call sites\n *\n * Two call sites that merely agree today are what produced the split: nothing\n * about either one said the other existed. A third route — the reusable-forms\n * capture path is already specified — would have been written the same way.\n * Enrolling goes through this function so that the id has one definition and\n * no caller is offered the chance to derive its own.\n */\n\nimport type { DocumentReference } from 'firebase-admin/firestore'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n normalizeContactEmail,\n personKey,\n CAPTURED_BY_HOST_FIELD,\n MARKETING_CONSENT_BASIS_FIELD,\n MARKETING_CONSENT_BY_HOST_FIELD,\n MARKETING_CONSENT_FIELD,\n marketingConsentFieldsForGroup,\n readMarketingBasis,\n type ConsentGroup,\n} from '@aglyn/aglyn/server'\nimport { consentGroupForGrant } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { createHash, createHmac } from 'node:crypto'\nimport { emailSearchTokens } from './email-suppression'\n\n/**\n * What a list's membership table searches a member by (AGL-3321): the word\n * prefixes of their name and of their address (`emailSearchTokens`), one\n * array, so one `array-contains` finds \"dana\" in either.\n *\n * Mirrored by `tools/scripts/backfill-email-list-filters.mjs`, which stamps\n * the rows written before the field; the two are held to the same fixtures\n * (`tools/scripts/lib/email-search-tokens.fixtures.json`, which the\n * script-side twin in `tools/scripts/lib/email-search-tokens.mjs` answers too).\n */\nexport function listMemberSearchTokens(\n email: string | null | undefined,\n name: string | null | undefined,\n): string[] {\n return [...new Set([...emailSearchTokens(email), ...nameSearchTokens(name)])]\n}\n\n/**\n * The two ids this collection was written under before `personKey`.\n *\n * Read-only, and never written for a new row: they exist so an address\n * already enrolled under a legacy id is *found* rather than duplicated. See\n * `enrollListMember` for why that lookup is the migration.\n *\n * Both take the normalized address, which is what the two original call sites\n * happened to pass — each lowercased at its own entry point before hashing, so\n * every row already on the collection was keyed from a lowercased address even\n * though neither derivation enforced it.\n */\nfunction legacyListMemberIds(normalizedEmail: string): string[] {\n return [\n createHash('sha256').update(normalizedEmail).digest('hex'),\n createHmac('sha256', 'aglyn-list-member')\n .update(normalizedEmail)\n .digest('hex')\n .slice(0, 20),\n ]\n}\n\n/**\n * Every document id one address may be filed under on a list's `members`:\n * the canonical key first, then the two ids earlier writers minted. A\n * reader that must find the person wherever they were enrolled — the\n * erasure sweep (AGL-2623) — asks for all of them with one `getAll`, the\n * same set `enrollListMember` reads before it writes, so a member enrolled\n * under an old id is neither missed nor duplicated.\n */\nexport function listMemberDocIds(email: unknown): string[] {\n const normalized = normalizeContactEmail(email)\n if (!normalized) return []\n const key = personKey(normalized)\n if (!key) return []\n return [key, ...legacyListMemberIds(normalized).filter((id) => id !== key)]\n}\n\n/**\n * What makes an enrollment mailable, and who is answerable for it.\n *\n * Two values and no third. They are not interchangeable and the difference is\n * the whole reason the basis is stored rather than implied:\n *\n * - `contact-opt-in` — the person ticked a box. The enrollment carries a\n * decision they made, and `atMs` is when THEY made it, not when the row was\n * written. Nobody is answerable because nobody asserted anything.\n * - `operator-attested` — a console account stated that they have this\n * person's permission. That statement is itself the basis, so it is only\n * worth storing with the account that made it and the moment they did.\n *\n * Rendering the second as though it were the first is what a support or\n * compliance question cannot recover from, which is why the basis rides\n * beside `marketingConsent` on every row that has one.\n */\nexport type ListMemberConsentBasis = 'contact-opt-in' | 'operator-attested'\n\n/** A basis, with the attribution that makes it answerable. */\nexport interface ListMemberConsent {\n basis: ListMemberConsentBasis\n /**\n * When the basis was recorded.\n *\n * For a pass-through this is the ORIGINAL opt-in moment carried over from\n * the person's own record, because the question a consent record answers is\n * when the person said yes — not when somebody copied their answer onto a\n * list.\n */\n atMs: number\n /** The console account that attested. Meaningless for a pass-through. */\n byUid?: string | null\n /**\n * Why, in the operator's own terms, for whoever audits this later.\n *\n * Written for an attestation and ignored for a pass-through, which needs no\n * explanation beyond the person's own record. An import fills it from what\n * the FILE declared — the opt-in source and date the merchant supplied per\n * row — because \"a declared basis per address, not one checkbox over the\n * file\" is only worth asking for if the declaration survives onto the row\n * it was made about.\n */\n reason?: string\n}\n\nexport interface EnrollListMemberInput {\n /** `orgs/{orgId}/lists/{listId}` — the caller has already proved it exists. */\n listRef: DocumentReference\n /**\n * The consent group the enrollment is being made IN THE NAME OF.\n *\n * Lists live on the org and every site in it can mail one, so a membership\n * carries a basis that no path could attribute to a controller. A GROUP\n * rather than a site, because a business running three sites under one name\n * legitimately enrolls somebody into all three at once — and an agency's\n * client, which declared no group, gets exactly itself.\n *\n * Required and undefaulted: a caller that has not resolved a group has not\n * decided who this person agreed to hear from.\n */\n group: ConsentGroup\n email: string\n name?: string\n /** Free-form provenance: `'newsletter'`, `'action:{actionId}'`. */\n source: string\n /**\n * Explicit marketing opt-in, with a consent timestamp — the same shape\n * `upsertHostContact` and `addHostLead` already carry.\n *\n * A list membership had NO consent field of any kind, so `audience: 'list'`\n * had nothing for the send-time join to read\n * (`docs/specs/email-overhaul.md` §1d/§3f). This is the field that closes\n * it, and it is the caller's captured checkbox: enrolling is an act, and\n * `false`/omitted therefore writes nothing rather than a basis.\n */\n marketingConsent?: boolean\n /**\n * The basis in full, for a caller that knows more than \"a box was ticked\".\n *\n * `marketingConsent: true` is the shorthand a capture surface uses: it means\n * `contact-opt-in`, recorded now, asserted by nobody. Anything else — a\n * pass-through carrying the person's ORIGINAL opt-in date, or an\n * attestation carrying the account that made it — has to say so, and this\n * is where it says it. The two inputs are normalized to one value before\n * anything is written, so there is a single writer of the consent fields.\n */\n consent?: ListMemberConsent\n /**\n * The person's own grants, as `grantEntriesAsRecorded` read them off their\n * record — what a PASS-THROUGH carries (AGL-3320).\n *\n * A `contact-opt-in` consent with these writes exactly these sites, with\n * each grant's own moment and the disclosure it was given under: an opt-in\n * given to one site before the group existed stays that site's on the list,\n * and one given under the group's sentence reaches the sites it named.\n * Re-recording the pass-through over {@link group} instead would widen the\n * first to every site the group names today.\n */\n grantEntries?: Readonly<Record<string, Readonly<Record<string, unknown>>>>\n /**\n * The `consentGroupDisclosureKey` a capture surface rendered beside its\n * checkbox, for a {@link marketingConsent} captured just now. The grant\n * pools across {@link group} only when it is the group's current key.\n *\n * Every other basis without {@link grantEntries} — an attestation, a\n * capture that rendered no disclosure — is recorded for the enrolling site\n * alone: an operator's word about one site's list is not a disclosure\n * anybody read about the rest.\n */\n disclosedConsentGroup?: string | null\n /**\n * How the person got here: enrolled by hand or by an automation\n * (`'manual'`), or selected by a dynamic list's rule (`'rule'`).\n *\n * The materializer needs to tell its own rows apart from a manual one on\n * the same list, because a person who stops matching the rule leaves and a\n * person somebody added by hand does not.\n */\n via?: 'manual' | 'rule'\n}\n\nexport interface EnrolledListMember {\n /** The document actually written — a legacy id when one was adopted. */\n memberId: string\n /** True when an existing legacy-keyed row was written instead of a new one. */\n adopted: boolean\n /** False when the row already existed under some id. */\n created: boolean\n}\n\n/** Why nothing was written. */\nexport type EnrollListMemberRefusal =\n /** Not an address this collection can key. Nothing to write, nothing to fix. */\n | 'unusable-address'\n /**\n * The person's membership records a REFUSAL. Not overridable by any caller\n * and not reported as a failure to fix, because the fix would be to ignore\n * it.\n */\n | 'declined'\n\n/**\n * Enrolled, or refused and why.\n *\n * A union rather than `EnrolledListMember | null`, because the two refusals\n * are not the same event and a caller that cannot tell them apart is a caller\n * that reports a person's recorded refusal as a malformed address.\n */\nexport type EnrollListMemberResult =\n | ({ enrolled: true } & EnrolledListMember)\n | { enrolled: false; refusal: EnrollListMemberRefusal }\n\n/**\n * Enrolls one address into a list, at one document per person.\n *\n * ## The legacy lookup is the migration\n *\n * Changing the derivation without one would strand every row written under the\n * old ids: the next enrollment of an address already on the list would key a\n * *new* document beside the old one, so a defect that produced duplicates only\n * when two routes met would start producing them on a single route. Rather\n * than rewrite those ids — a bulk operation that has to delete the row it\n * replaces, and deleting an enrollment destroys the consent record that says\n * the person asked to be there — the write resolves the person's existing\n * document and keeps using it.\n *\n * The cost is one `getAll` of three refs, on a human-triggered signup, in\n * place of the blind `set` this replaced. It is one round trip, not three.\n *\n * `addedAt` is stamped only when the document is created, so re-enrolling\n * keeps the date the person actually joined — the same \"earliest wins\" collapse\n * `docs/specs/email-overhaul.md` §3d asks a backfill to preserve.\n *\n * ## A recorded refusal stops every route through this function\n *\n * This is the only writer of the collection, so the refusal check belongs\n * here and not at the four call sites: a guard on the button that adds\n * somebody by hand leaves the newsletter handler, the workflow `enrollList`\n * step and the dynamic-list materializer free to put the same person back.\n * The membership snapshot is already in hand from the id resolution above, so\n * the check costs no read.\n *\n * It answers the question about the LIST ROW. A refusal recorded on the\n * person's CRM record is a different document this function does not fetch —\n * a caller that can see one is expected to consult it, and the send-time\n * consent join reads the row this function writes either way.\n */\nexport async function enrollListMember(\n input: EnrollListMemberInput,\n): Promise<EnrollListMemberResult> {\n const email = normalizeContactEmail(input.email)\n if (!email) return { enrolled: false, refusal: 'unusable-address' }\n const key = personKey(email)\n if (!key) return { enrolled: false, refusal: 'unusable-address' }\n\n const members = input.listRef.collection('members')\n const canonicalRef = members.doc(key)\n const legacyRefs = legacyListMemberIds(email)\n // A legacy derivation that happens to agree with `personKey` — `sha256` of\n // the same normalized address does — must not be fetched twice: `getAll`\n // rejects duplicate references.\n .filter((id) => id !== key)\n .map((id) => members.doc(id))\n\n const snapshots = await input.listRef.firestore.getAll(\n canonicalRef,\n ...legacyRefs,\n )\n const existing = snapshots.find((snapshot) => snapshot.exists)\n const target = existing?.ref ?? canonicalRef\n\n if (\n existing &&\n readMarketingBasis(existing.data() as Record<string, unknown>, input.group)\n .basis === 'declined'\n ) {\n return { enrolled: false, refusal: 'declined' }\n }\n\n /*\n * ONE value from the two ways a caller may state a basis.\n *\n * `marketingConsent: true` is a capture surface saying \"a box was ticked\n * just now\", which is `contact-opt-in` recorded at this moment with nobody\n * asserting it. Normalizing here rather than branching at the write is what\n * keeps the consent fields to a single writer — two branches stamping\n * overlapping subsets of them is how a row comes to carry a basis with no\n * timestamp, or a timestamp with no basis.\n */\n const consent: ListMemberConsent | null =\n input.consent ??\n (input.marketingConsent\n ? { basis: 'contact-opt-in', atMs: Date.now() }\n : null)\n\n /*\n * The name the row will hold after this write — the caller's, else the one\n * it already carries — because the search tokens are the address's and\n * that name's, and a re-enrollment without a name keeps the stored one.\n */\n const name = input.name || (existing?.get('name') as string | undefined) || null\n await target.set(\n {\n email,\n ...(input.name ? { name: input.name } : {}),\n searchTokens: listMemberSearchTokens(email, name),\n source: input.source,\n /*\n * `via` is on every row, so the table's How filter can ask for it\n * (AGL-3321): the caller's, else `manual` on a row that has none — what\n * an absent `via` has always meant. A caller that says nothing never\n * moves a stored one.\n */\n ...(input.via\n ? { via: input.via }\n : existing?.get('via')\n ? {}\n : { via: 'manual' }),\n /*\n * Written only when the caller has a basis, and never unwritten.\n *\n * A merge that stamped `marketingConsent: false` on the omitted case\n * would erase a basis this person gave on an earlier enrollment, and a\n * withdrawn consent is not the same event as a re-enrollment that\n * happened to carry no checkbox. Withdrawal has its own path — the\n * unsubscribe link and the suppression list.\n *\n * `marketingConsentAtMs` moves with each fresh basis on purpose: the\n * question a consent record answers is when the person last said yes.\n *\n * The `marketingConsent` family rather than the `consent*` names\n * `docs/specs/email-overhaul.md` §3d sketched: `readMarketingBasis` is\n * the shipped reader and it reads these, so a second vocabulary here\n * would be a basis the send-time join cannot see. The two new fields\n * are attribution ON that field — they say why the person is mailable,\n * never whether — so nothing about the send decision moves into them.\n *\n * Written UNDER the enrolling GROUP, through the one writer helper. A\n * list lives on the org and every site in it can mail one, so a basis\n * written at the top of this row would enroll a person into one\n * client's newsletter and make them mailable by all of them. A group of\n * one — the default — is that client and nobody else.\n *\n * `marketingConsentByUid` is written on EVERY basis, `null` for a\n * pass-through. Leaving it absent would let a real opt-in that\n * supersedes an earlier attestation inherit the attesting account, and\n * a row reading \"this account vouched for them\" when the person ticked\n * a box themselves is a false attribution in the one direction a\n * compliance answer cannot afford.\n *\n * `marketingConsentReason` follows the same rule for the same reason:\n * always written, `''` for a pass-through, so a later real opt-in\n * cannot inherit the sentence an earlier import wrote about where the\n * merchant said the address came from.\n *\n * WHICH sites the basis covers is `membershipConsentFields`' to say,\n * and it never re-derives a grant from the group as it stands now.\n */\n ...(consent ? membershipConsentFields(input, consent) : {}),\n ...(existing\n ? {}\n : {\n addedAt: FieldValue.serverTimestamp(),\n [CAPTURED_BY_HOST_FIELD]: [input.group.hostId],\n }),\n },\n { merge: true },\n )\n\n return {\n enrolled: true,\n memberId: target.id,\n adopted: target.id !== key,\n created: !existing,\n }\n}\n\n/**\n * The consent fields one enrollment writes: the basis, its attribution, and\n * the sites it covers (AGL-3320).\n *\n * Three ways in, and the sites follow from which one it is:\n *\n * - a PASS-THROUGH with the person's {@link EnrollListMemberInput.grantEntries}\n * writes those sites and no others, each grant with its own moment and\n * the disclosure it was given under;\n * - a capture's checkbox pools over the group only when its surface sent\n * back the group's current disclosure key — `consentGroupForGrant`;\n * - everything else, an attestation among them, is the enrolling site's\n * alone.\n *\n * The attribution is the membership's own on every entry, the pass-through's\n * included, for the reason the write explains: an entry that kept an earlier\n * attestation's account under a person's own opt-in would be a false claim\n * about who vouched for them.\n */\nfunction membershipConsentFields(\n input: EnrollListMemberInput,\n consent: ListMemberConsent,\n): Record<string, unknown> {\n const attribution = {\n [MARKETING_CONSENT_BASIS_FIELD]: consent.basis,\n marketingConsentByUid: consent.byUid ?? null,\n marketingConsentReason: consent.reason ?? '',\n }\n const carried =\n consent.basis === 'contact-opt-in'\n ? Object.entries(input.grantEntries ?? {})\n : []\n if (carried.length) {\n return {\n [MARKETING_CONSENT_BY_HOST_FIELD]: Object.fromEntries(\n carried.map(([hostId, entry]) => [\n hostId,\n {\n ...entry,\n [MARKETING_CONSENT_FIELD]: true,\n // The grant's own moment; the pass-through's only when the\n // person's record never stamped one, as the basis itself falls\n // back to now.\n marketingConsentAtMs: entry['marketingConsentAtMs'] ?? consent.atMs,\n ...attribution,\n },\n ]),\n ),\n }\n }\n return marketingConsentFieldsForGroup(\n consentGroupForGrant(input.group, input.disclosedConsentGroup),\n consent.atMs,\n attribution,\n )\n}\n"],"names":["FieldValue","normalizeContactEmail","personKey","CAPTURED_BY_HOST_FIELD","MARKETING_CONSENT_BASIS_FIELD","MARKETING_CONSENT_BY_HOST_FIELD","MARKETING_CONSENT_FIELD","marketingConsentFieldsForGroup","readMarketingBasis","consentGroupForGrant","nameSearchTokens","createHash","createHmac","emailSearchTokens","listMemberSearchTokens","email","name","Set","legacyListMemberIds","normalizedEmail","update","digest","slice","listMemberDocIds","normalized","key","filter","id","enrollListMember","input","enrolled","refusal","members","listRef","collection","canonicalRef","doc","legacyRefs","map","snapshots","firestore","getAll","existing","find","snapshot","exists","target","ref","data","group","basis","consent","marketingConsent","atMs","Date","now","get","set","searchTokens","source","via","membershipConsentFields","addedAt","serverTimestamp","hostId","merge","memberId","adopted","created","attribution","marketingConsentByUid","byUid","marketingConsentReason","reason","carried","Object","entries","grantEntries","length","fromEntries","entry","marketingConsentAtMs","disclosedConsentGroup"],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
/** The part of a list query plan the query is built from. */
|
|
18
|
+
export interface AdminListQueryPlan {
|
|
19
|
+
filters: ReadonlyArray<{
|
|
20
|
+
path: string;
|
|
21
|
+
op: FirebaseFirestore.WhereFilterOp;
|
|
22
|
+
value: unknown;
|
|
23
|
+
}>;
|
|
24
|
+
orderBy: {
|
|
25
|
+
path: string;
|
|
26
|
+
direction: 'asc' | 'desc';
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Every predicate the plan holds, then its one order.
|
|
31
|
+
*
|
|
32
|
+
* The caller pages it with its own cursor (`startAfter` a document in the
|
|
33
|
+
* plan's order) and matches nothing afterwards: what the plan refused is not
|
|
34
|
+
* applied, and the route says so in its response (`plan.refused`).
|
|
35
|
+
*/
|
|
36
|
+
export declare function applyListQuery(ref: FirebaseFirestore.Query, plan: AdminListQueryPlan): FirebaseFirestore.Query;
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
*/ import { FieldPath, Timestamp } from "firebase-admin/firestore";
|
|
17
|
+
/*
|
|
18
|
+
* THE ADMIN TWIN OF THE LIST QUERY PLAN (AGL-3321).
|
|
19
|
+
*
|
|
20
|
+
* `planListQuery` (`@aglyn/shared-ui-jsx/const/list-query-plan`) composes a
|
|
21
|
+
* list's clauses and search into one SDK-free plan; this puts that plan on
|
|
22
|
+
* an Admin query, for a route that reads a list on the server — a console
|
|
23
|
+
* route or a plugin's API handler alike, which is why it lives here and not
|
|
24
|
+
* in the console. The web twin is `listQueryConstraints` in the instance
|
|
25
|
+
* library.
|
|
26
|
+
*
|
|
27
|
+
* The plan's shape is restated structurally rather than imported, so this
|
|
28
|
+
* data library stays free of the UI library that plans it: any object with
|
|
29
|
+
* these fields is a plan, and `ListQueryPlan` is one.
|
|
30
|
+
*/ /** The document id, as the plan spells it. */ const ID_PATH = '__name__';
|
|
31
|
+
const planValue = (value)=>value instanceof Date ? Timestamp.fromDate(value) : Array.isArray(value) ? [
|
|
32
|
+
...value
|
|
33
|
+
] : value;
|
|
34
|
+
/**
|
|
35
|
+
* Every predicate the plan holds, then its one order.
|
|
36
|
+
*
|
|
37
|
+
* The caller pages it with its own cursor (`startAfter` a document in the
|
|
38
|
+
* plan's order) and matches nothing afterwards: what the plan refused is not
|
|
39
|
+
* applied, and the route says so in its response (`plan.refused`).
|
|
40
|
+
*/ export function applyListQuery(ref, plan) {
|
|
41
|
+
const path = (value)=>value === ID_PATH ? FieldPath.documentId() : value;
|
|
42
|
+
let query = ref;
|
|
43
|
+
for (const filter of plan.filters){
|
|
44
|
+
query = query.where(path(filter.path), filter.op, planValue(filter.value));
|
|
45
|
+
}
|
|
46
|
+
return query.orderBy(path(plan.orderBy.path), plan.orderBy.direction);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
//# sourceMappingURL=list-query.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/list-query.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { FieldPath, Timestamp } from 'firebase-admin/firestore'\n\n/*\n * THE ADMIN TWIN OF THE LIST QUERY PLAN (AGL-3321).\n *\n * `planListQuery` (`@aglyn/shared-ui-jsx/const/list-query-plan`) composes a\n * list's clauses and search into one SDK-free plan; this puts that plan on\n * an Admin query, for a route that reads a list on the server — a console\n * route or a plugin's API handler alike, which is why it lives here and not\n * in the console. The web twin is `listQueryConstraints` in the instance\n * library.\n *\n * The plan's shape is restated structurally rather than imported, so this\n * data library stays free of the UI library that plans it: any object with\n * these fields is a plan, and `ListQueryPlan` is one.\n */\n\n/** The document id, as the plan spells it. */\nconst ID_PATH = '__name__'\n\n/** The part of a list query plan the query is built from. */\nexport interface AdminListQueryPlan {\n filters: ReadonlyArray<{\n path: string\n op: FirebaseFirestore.WhereFilterOp\n value: unknown\n }>\n orderBy: { path: string; direction: 'asc' | 'desc' }\n}\n\nconst planValue = (value: unknown): unknown =>\n value instanceof Date ? Timestamp.fromDate(value) : Array.isArray(value) ? [...value] : value\n\n/**\n * Every predicate the plan holds, then its one order.\n *\n * The caller pages it with its own cursor (`startAfter` a document in the\n * plan's order) and matches nothing afterwards: what the plan refused is not\n * applied, and the route says so in its response (`plan.refused`).\n */\nexport function applyListQuery(\n ref: FirebaseFirestore.Query,\n plan: AdminListQueryPlan,\n): FirebaseFirestore.Query {\n const path = (value: string) => (value === ID_PATH ? FieldPath.documentId() : value)\n let query = ref\n for (const filter of plan.filters) {\n query = query.where(path(filter.path), filter.op, planValue(filter.value))\n }\n return query.orderBy(path(plan.orderBy.path), plan.orderBy.direction)\n}\n"],"names":["FieldPath","Timestamp","ID_PATH","planValue","value","Date","fromDate","Array","isArray","applyListQuery","ref","plan","path","documentId","query","filter","filters","where","op","orderBy","direction"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,SAAS,EAAEC,SAAS,QAAQ,2BAA0B;AAE/D;;;;;;;;;;;;;CAaC,GAED,4CAA4C,GAC5C,MAAMC,UAAU;AAYhB,MAAMC,YAAY,CAACC,QACjBA,iBAAiBC,OAAOJ,UAAUK,QAAQ,CAACF,SAASG,MAAMC,OAAO,CAACJ,SAAS;WAAIA;KAAM,GAAGA;AAE1F;;;;;;CAMC,GACD,OAAO,SAASK,eACdC,GAA4B,EAC5BC,IAAwB;IAExB,MAAMC,OAAO,CAACR,QAAmBA,UAAUF,UAAUF,UAAUa,UAAU,KAAKT;IAC9E,IAAIU,QAAQJ;IACZ,KAAK,MAAMK,UAAUJ,KAAKK,OAAO,CAAE;QACjCF,QAAQA,MAAMG,KAAK,CAACL,KAAKG,OAAOH,IAAI,GAAGG,OAAOG,EAAE,EAAEf,UAAUY,OAAOX,KAAK;IAC1E;IACA,OAAOU,MAAMK,OAAO,CAACP,KAAKD,KAAKQ,OAAO,CAACP,IAAI,GAAGD,KAAKQ,OAAO,CAACC,SAAS;AACtE"}
|
|
@@ -203,6 +203,39 @@ export declare function screenIdsUsingLayoutDeep(layoutId: string, screens: Usag
|
|
|
203
203
|
* allows, and a cycle here would hang a publish rather than surface anything.
|
|
204
204
|
*/
|
|
205
205
|
export declare function screenIdsUsingComponentDeep(componentId: string, sources: UsageSources): string[];
|
|
206
|
+
/**
|
|
207
|
+
* Every live screen whose rendered output renders the collection
|
|
208
|
+
* `collectionSlug`, however indirectly.
|
|
209
|
+
*
|
|
210
|
+
* The content twin of the console's `screenIdsUsingFormDeep`, and the same
|
|
211
|
+
* walk for the same reason. Saving, publishing or deleting an ENTRY changes
|
|
212
|
+
* every page that renders the collection it belongs to, and only some of those
|
|
213
|
+
* pages are derivable from the collection's slug. `/blog`, `/blog/my-post` and
|
|
214
|
+
* `/blog/category/guides` are; the "Latest posts" rail on the home page, the
|
|
215
|
+
* category pills in a layout's chrome and the search box in a header are not
|
|
216
|
+
* — they are found by searching node trees, and they can sit on a screen,
|
|
217
|
+
* inside page chrome, or inside a reusable component that a third component
|
|
218
|
+
* nests.
|
|
219
|
+
*
|
|
220
|
+
* Miss them and an entry edit reports success while the pages that advertise
|
|
221
|
+
* it keep serving the old set for the whole revalidate window — with the rail
|
|
222
|
+
* on the home page, the single most visible place the change was supposed to
|
|
223
|
+
* appear.
|
|
224
|
+
*
|
|
225
|
+
* The first level is the only thing that differs from the form walk: a
|
|
226
|
+
* document uses a collection by BINDING a collection block to its slug. After
|
|
227
|
+
* that the fan-out is identical, so it is delegated rather than restated.
|
|
228
|
+
*
|
|
229
|
+
* The collection's OWN routes are deliberately not here. They are addresses,
|
|
230
|
+
* not screens — a fallback listing has no screen document at all, and a
|
|
231
|
+
* template screen serves the collection's paths rather than its own — so the
|
|
232
|
+
* caller derives them from the slug and sends them beside these.
|
|
233
|
+
*
|
|
234
|
+
* Pure, and separate from the Firestore read, so the closure is testable
|
|
235
|
+
* without a database. Cycle-safe by delegation: both walks it defers to bound
|
|
236
|
+
* themselves.
|
|
237
|
+
*/
|
|
238
|
+
export declare function screenIdsUsingCollectionDeep(collectionSlug: string, sources: UsageSources): string[];
|
|
206
239
|
export interface UsageCandidateRead {
|
|
207
240
|
candidates: UsageCandidate[];
|
|
208
241
|
/**
|
|
@@ -30,7 +30,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
30
30
|
* Nothing here is dataset-specific, and nothing is console-specific. The
|
|
31
31
|
* console's `scan-artifact-usage` re-exports these and keeps the parts that
|
|
32
32
|
* are about the console's own "what would I break" copy.
|
|
33
|
-
*/ import { nodesReferenceComponent, decodeStoredNodes } from "@aglyn/aglyn/server";
|
|
33
|
+
*/ import { nodesReferenceComponent, nodesRenderCollection, decodeStoredNodes } from "@aglyn/aglyn/server";
|
|
34
34
|
import { getSystemEmailTemplate, SYSTEM_EMAIL_COLLECTION } from "@aglyn/shared-util-email/system-email-catalog";
|
|
35
35
|
import { getTenantEmail, TENANT_EMAIL_COLLECTION } from "@aglyn/shared-util-email/tenant-email-catalog";
|
|
36
36
|
/** `displayName`, falling back to a legacy `name`, then the raw id. */ export function usageCandidateLabel(candidate) {
|
|
@@ -226,6 +226,66 @@ import { getTenantEmail, TENANT_EMAIL_COLLECTION } from "@aglyn/shared-util-emai
|
|
|
226
226
|
...screenIds
|
|
227
227
|
];
|
|
228
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Every live screen whose rendered output renders the collection
|
|
231
|
+
* `collectionSlug`, however indirectly.
|
|
232
|
+
*
|
|
233
|
+
* The content twin of the console's `screenIdsUsingFormDeep`, and the same
|
|
234
|
+
* walk for the same reason. Saving, publishing or deleting an ENTRY changes
|
|
235
|
+
* every page that renders the collection it belongs to, and only some of those
|
|
236
|
+
* pages are derivable from the collection's slug. `/blog`, `/blog/my-post` and
|
|
237
|
+
* `/blog/category/guides` are; the "Latest posts" rail on the home page, the
|
|
238
|
+
* category pills in a layout's chrome and the search box in a header are not
|
|
239
|
+
* — they are found by searching node trees, and they can sit on a screen,
|
|
240
|
+
* inside page chrome, or inside a reusable component that a third component
|
|
241
|
+
* nests.
|
|
242
|
+
*
|
|
243
|
+
* Miss them and an entry edit reports success while the pages that advertise
|
|
244
|
+
* it keep serving the old set for the whole revalidate window — with the rail
|
|
245
|
+
* on the home page, the single most visible place the change was supposed to
|
|
246
|
+
* appear.
|
|
247
|
+
*
|
|
248
|
+
* The first level is the only thing that differs from the form walk: a
|
|
249
|
+
* document uses a collection by BINDING a collection block to its slug. After
|
|
250
|
+
* that the fan-out is identical, so it is delegated rather than restated.
|
|
251
|
+
*
|
|
252
|
+
* The collection's OWN routes are deliberately not here. They are addresses,
|
|
253
|
+
* not screens — a fallback listing has no screen document at all, and a
|
|
254
|
+
* template screen serves the collection's paths rather than its own — so the
|
|
255
|
+
* caller derives them from the slug and sends them beside these.
|
|
256
|
+
*
|
|
257
|
+
* Pure, and separate from the Firestore read, so the closure is testable
|
|
258
|
+
* without a database. Cycle-safe by delegation: both walks it defers to bound
|
|
259
|
+
* themselves.
|
|
260
|
+
*/ export function screenIdsUsingCollectionDeep(collectionSlug, sources) {
|
|
261
|
+
if (!collectionSlug) return [];
|
|
262
|
+
const screenIds = new Set();
|
|
263
|
+
for (const candidate of sources.screens){
|
|
264
|
+
if (!isLiveUsageCandidate(candidate)) continue;
|
|
265
|
+
if (nodesRenderCollection(candidate.nodes, collectionSlug)) {
|
|
266
|
+
screenIds.add(candidate.id);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
for (const candidate of sources.layouts){
|
|
270
|
+
if (!isLiveUsageCandidate(candidate)) continue;
|
|
271
|
+
if (!nodesRenderCollection(candidate.nodes, collectionSlug)) continue;
|
|
272
|
+
// The layout itself renders no URL; the screens beneath it do.
|
|
273
|
+
for (const screenId of screenIdsUsingLayoutDeep(candidate.id, sources.screens, sources.layouts)){
|
|
274
|
+
screenIds.add(screenId);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const candidate of sources.components){
|
|
278
|
+
if (!isLiveUsageCandidate(candidate)) continue;
|
|
279
|
+
if (!nodesRenderCollection(candidate.nodes, collectionSlug)) continue;
|
|
280
|
+
// And a component renders wherever it is placed, however deeply nested.
|
|
281
|
+
for (const screenId of screenIdsUsingComponentDeep(candidate.id, sources)){
|
|
282
|
+
screenIds.add(screenId);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return [
|
|
286
|
+
...screenIds
|
|
287
|
+
];
|
|
288
|
+
}
|
|
229
289
|
/**
|
|
230
290
|
* One collection's documents, with published nodes attached when the scan
|
|
231
291
|
* needs to search them.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/live-page-usage.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 * Which live pages a change reaches, for a site's screens, layouts and\n * components.\n *\n * The corpus read and the two closures over it, held here rather than in the\n * console because the console is no longer the only side that needs them. A\n * dataset record is written from the tenant as well — a form submission, an\n * automation step — and those writes make the same pages stale as a console\n * edit does. Two implementations of \"which screens render this\" is the shape\n * that put AGL-1223 in the tree: one reader handled a node tree's second\n * storage form and the other did not, so half the corpus answered \"used\n * nowhere\" and a publish dropped no cache.\n *\n * Nothing here is dataset-specific, and nothing is console-specific. The\n * console's `scan-artifact-usage` re-exports these and keeps the parts that\n * are about the console's own \"what would I break\" copy.\n */\n\nimport {\n nodesReferenceComponent,\n decodeStoredNodes,\n type ReusableComponentProp,\n} from '@aglyn/aglyn/server'\nimport {\n getSystemEmailTemplate,\n SYSTEM_EMAIL_COLLECTION,\n} from '@aglyn/shared-util-email/system-email-catalog'\nimport {\n getTenantEmail,\n TENANT_EMAIL_COLLECTION,\n} from '@aglyn/shared-util-email/tenant-email-catalog'\n\n/** A screen/layout/component reduced to what a usage scan needs. */\nexport interface UsageCandidate {\n id: string\n displayName?: string\n /** Legacy field some older documents used instead of `displayName`. */\n name?: string\n deletedAt?: unknown\n /**\n * Node tree to search. For screens and layouts this is the PUBLISHED\n * version's nodes (what visitors see); for components it is the definition\n * tree off the component document, which is what the runtime reads.\n */\n nodes?: Record<string, any> | null\n /** Published version, carried through so the caller can deep-link. */\n versionId?: string\n /** Screens only: the layout they render inside. */\n layoutId?: string\n /** Screens only: the screen they nest under, which is part of their path. */\n parentId?: string\n /**\n * Screens only: `'email'` for a campaign email design (AGL-3287), which\n * a scan reports as an `emailDesign` rather than a page.\n */\n kind?: string\n /**\n * Components only: the properties the definition declares (AGL-1247). A\n * Link property's default renders as a link wherever an instance leaves the\n * property unset, and it is stored here, not in `nodes` (AGL-2846).\n */\n props?: ReadonlyArray<ReusableComponentProp | null | undefined> | null\n}\n\n/** One document that depends on the artifact a scan was asked about. */\nexport interface UsageDependent {\n /**\n * `emailTemplate` is one of the site's own transactional emails\n * (`hosts/{h}/emailTemplates/{key}`), `id` its catalog key (AGL-3287).\n *\n * `emailDesign` is an email designed for a campaign: a `kind: 'email'`\n * screen document, `id` its screen id. It is its own type rather than a\n * `screen` because it is never a page. A caller that took it for one sent\n * the reader to the page settings — a slug, SEO and a Publish button that\n * would serve the email as a route — and a cache drop has no page to drop.\n *\n * `systemEmail` is one of the platform's own emails\n * (`systemEmailTemplates/{key}`, at the root, belonging to no site), `id`\n * its catalog key (AGL-3318). Only the platform marketing site's blocks\n * have one: that is the site those emails read their header and footer\n * from.\n */\n type:\n | 'screen'\n | 'layout'\n | 'component'\n | 'collection'\n | 'emailTemplate'\n | 'emailDesign'\n | 'systemEmail'\n id: string\n name: string\n via: Array<'id' | 'name'>\n versionId?: string\n /**\n * HOW the dependent references the artifact — screens and collection\n * listings (AGL-703, AGL-2806).\n *\n * A component or a layout has exactly one kind of dependent and the noun\n * says everything: an instance, or a binding. A screen has three, and they\n * break in three different ways — a link goes dead, a child moves, a\n * collection loses the page it renders through. Copy that could not tell\n * them apart would have to describe the worst case every time. A collection\n * listing's dependents are all links, and say so.\n */\n relation?: 'link' | 'child' | 'template'\n}\n\n/** The three corpora every closure below walks, and a fourth for components. */\nexport interface UsageSources {\n screens: UsageCandidate[]\n layouts: UsageCandidate[]\n components: UsageCandidate[]\n /**\n * The site's transactional email designs, each on its published version\n * (AGL-3287). Read only where a scan asks \"what places this component\" —\n * {@link scanComponentUsage} lists them — because an email is not a page:\n * nothing is cached for it, so no closure that decides which pages to drop\n * has any use for them, and none reads them.\n */\n emailTemplates?: UsageCandidate[]\n /**\n * The platform's own emails, each on its published version (AGL-3318).\n * Read only when the site scanned is the platform marketing site, whose\n * email blocks those emails place, and listed by {@link scanComponentUsage}\n * alone for the reason `emailTemplates` is: an email is not a page.\n */\n systemEmails?: UsageCandidate[]\n}\n\n/** `displayName`, falling back to a legacy `name`, then the raw id. */\nexport function usageCandidateLabel(candidate: UsageCandidate): string {\n return String(candidate.displayName ?? candidate.name ?? candidate.id)\n}\n\n/** A soft-deleted document renders nothing, so it depends on nothing. */\nexport const isLiveUsageCandidate = (candidate: UsageCandidate): boolean =>\n !candidate.deletedAt\n\n/**\n * Everything that references a reusable component (AGL-703).\n *\n * Every place the renderer expands an instance: published screen versions\n * (email designs among them — a campaign email is a screen), published layout\n * versions, OTHER component definitions — `composeReusableComponentNodes`\n * grafts nested instances, so a component used only inside another component\n * is genuinely used — and, when the caller read them, the site's transactional\n * emails, which graft a placed header or footer at send time (AGL-3287), and\n * the platform's own emails, which graft the platform marketing site's header\n * and footer (AGL-3318). Omitting any of them would report \"used nowhere\" for\n * something that renders and invite a confident deletion, which is worse than\n * showing nothing at all.\n */\nexport function scanComponentUsage(\n componentId: string,\n sources: UsageSources,\n): UsageDependent[] {\n if (!componentId) return []\n const dependents: UsageDependent[] = []\n const collect = (\n candidates: UsageCandidate[],\n type: UsageDependent['type'],\n ) => {\n for (const candidate of candidates) {\n if (!isLiveUsageCandidate(candidate)) continue\n // A component never counts as using itself, however it nests.\n if (type === 'component' && candidate.id === componentId) continue\n if (!nodesReferenceComponent(candidate.nodes, componentId)) continue\n dependents.push({\n type:\n type === 'screen' && candidate.kind === 'email'\n ? 'emailDesign'\n : type,\n id: candidate.id,\n name: usageCandidateLabel(candidate),\n // Instances reference by id, so a rename can never break them.\n via: ['id'],\n ...(candidate.versionId ? { versionId: candidate.versionId } : {}),\n })\n }\n }\n collect(sources.screens, 'screen')\n collect(sources.layouts, 'layout')\n collect(sources.components, 'component')\n collect(sources.emailTemplates ?? [], 'emailTemplate')\n collect(sources.systemEmails ?? [], 'systemEmail')\n return dependents\n}\n\n/**\n * Everything rendering inside a layout (AGL-703).\n *\n * Two kinds of dependent, both expressed by the same `layoutId` pointer:\n *\n * - **screens**, which name the layout they render inside;\n * - **other layouts**, since a layout can itself sit inside one. A nested\n * layout is a real dependent — deleting its parent unwraps every screen\n * underneath it — so leaving layouts out would report a parent layout as\n * used only by the screens that name it directly, and none of the ones\n * that reach it through a child.\n *\n * A layout never counts as its own dependent; `canNestLayout` refuses that,\n * and this refuses to report it even if stored data holds one.\n */\nexport function scanLayoutUsage(\n layoutId: string,\n screens: UsageCandidate[],\n layouts: UsageCandidate[] = [],\n): UsageDependent[] {\n if (!layoutId) return []\n const dependentsOf = (\n candidates: UsageCandidate[],\n type: 'screen' | 'layout',\n ) =>\n candidates\n .filter(\n (candidate) =>\n isLiveUsageCandidate(candidate) &&\n candidate.layoutId === layoutId &&\n candidate.id !== layoutId,\n )\n .map((candidate) => ({\n type,\n id: candidate.id,\n name: usageCandidateLabel(candidate),\n via: ['id' as const],\n ...(candidate.versionId ? { versionId: candidate.versionId } : {}),\n }))\n return [\n ...dependentsOf(screens, 'screen'),\n ...dependentsOf(layouts, 'layout'),\n ]\n}\n\n/**\n * Every live screen rendered inside `layoutId`, at ANY nesting depth\n * (AGL-1150).\n *\n * `scanLayoutUsage` answers one level. Layouts nest — a screen points at a\n * layout, which can point at a parent layout, and `compose-screen-nodes` walks\n * that whole chain when composing a page. So publishing a layout changes every\n * screen below it, not just the ones bound to it directly, and a cache drop\n * that only handles the direct level leaves the rest showing stale chrome for\n * the full revalidate window.\n *\n * Pure, and separate from the Firestore read, so the nesting behaviour is\n * testable without a database.\n *\n * Cycle-safe. `canNestLayout` refuses to create a cycle, but a document written\n * straight to Firestore is not bound by that, and a cycle here would hang a\n * publish request rather than surface anything.\n */\nexport function screenIdsUsingLayoutDeep(\n layoutId: string,\n screens: UsageCandidate[],\n layouts: UsageCandidate[] = [],\n): string[] {\n if (!layoutId) return []\n const screenIds = new Set<string>()\n const seenLayouts = new Set<string>([layoutId])\n let frontier = [layoutId]\n\n while (frontier.length) {\n const next: string[] = []\n for (const id of frontier) {\n for (const dependent of scanLayoutUsage(id, screens, layouts)) {\n if (dependent.type === 'screen') {\n screenIds.add(dependent.id)\n } else if (!seenLayouts.has(dependent.id)) {\n seenLayouts.add(dependent.id)\n next.push(dependent.id)\n }\n }\n }\n frontier = next\n }\n\n return [...screenIds]\n}\n\n/**\n * Every live screen whose rendered output contains `componentId`, however\n * indirectly (AGL-1161).\n *\n * `scanComponentUsage` answers one level and returns three kinds of dependent.\n * Only one of them is a screen, and the other two both reach screens by routes\n * a single-level scan cannot see:\n *\n * - a **component** dependent nests the target inside itself, and that outer\n * component may itself only be used inside a third — so component→component\n * edges have to be followed to a fixed point;\n * - a **layout** dependent puts the component in page chrome, which every\n * screen under that layout renders. Layouts nest, so that is\n * `screenIdsUsingLayoutDeep`, not a direct `layoutId` match.\n *\n * Miss either and a publish reports success while some pages keep serving the\n * old component for the full revalidate window — the failure this whole arc\n * exists to remove, and the one that is hardest to notice because the pages\n * that ARE dropped update instantly.\n *\n * Pure, and separate from the Firestore read, so the closure is testable\n * without a database — the same split `screenIdsUsingLayoutDeep` uses.\n *\n * Cycle-safe. `composeReusableComponentNodes` would not survive a cycle, but a\n * document written straight to Firestore is not bound by what the editor\n * allows, and a cycle here would hang a publish rather than surface anything.\n */\nexport function screenIdsUsingComponentDeep(\n componentId: string,\n sources: UsageSources,\n): string[] {\n if (!componentId) return []\n const screenIds = new Set<string>()\n const seenComponents = new Set<string>([componentId])\n // Layouts are resolved through their own deep walk, so remember which ones\n // have already been expanded: two components in the same layout would\n // otherwise re-walk the whole layout tree once each.\n const seenLayouts = new Set<string>()\n let frontier = [componentId]\n\n while (frontier.length) {\n const next: string[] = []\n for (const id of frontier) {\n for (const dependent of scanComponentUsage(id, sources)) {\n if (dependent.type === 'screen') {\n screenIds.add(dependent.id)\n } else if (dependent.type === 'layout') {\n if (seenLayouts.has(dependent.id)) continue\n seenLayouts.add(dependent.id)\n // The layout itself renders no URL; the screens beneath it do.\n for (const screenId of screenIdsUsingLayoutDeep(\n dependent.id,\n sources.screens,\n sources.layouts,\n )) {\n screenIds.add(screenId)\n }\n } else if (\n dependent.type === 'component' &&\n !seenComponents.has(dependent.id)\n ) {\n seenComponents.add(dependent.id)\n next.push(dependent.id)\n }\n // An email — transactional, designed for a campaign, or one of the\n // platform's own — is not a page and nothing caches it, so it\n // contributes no screen and is not followed (AGL-3287, AGL-3318). A\n // transactional or platform email's id is a catalog key, and walking\n // it as a component would read a key as a definition that does not\n // exist.\n }\n }\n frontier = next\n }\n\n return [...screenIds]\n}\n\nexport interface UsageCandidateRead {\n candidates: UsageCandidate[]\n /**\n * The collection held more documents than `limit` allowed, so the scan below\n * it is INCOMPLETE.\n *\n * Returned rather than logged, because the two callers owe the user\n * different things: an advisory \"what would I break\" can show a partial\n * answer and say so, while a cache drop that silently scans a prefix reports\n * a successful publish and leaves real pages stale.\n */\n truncated: boolean\n}\n\n/**\n * One collection's documents, with published nodes attached when the scan\n * needs to search them.\n *\n * `limit` is a real bound, not a guess: it is fetched with one extra document\n * so exceeding it is DETECTED rather than assumed away. A caller that ignores\n * `truncated` is choosing to be wrong quietly.\n *\n * `emailTemplates` — the site's transactional email designs (AGL-3287) — has\n * the screens' shape: a parent holding the published `versionId`, the tree on\n * that version. The catalog is code and fixed, and a template document only\n * exists once somebody pressed Design, so most sites have none and the read\n * costs one empty query. Its documents carry no name, so each is labelled with\n * the catalog's.\n */\nexport async function readUsageCandidates(\n hostRef: FirebaseFirestore.DocumentReference,\n collectionName:\n | 'screens'\n | 'layouts'\n | 'components'\n | typeof TENANT_EMAIL_COLLECTION,\n options: { withNodes: boolean; limit: number },\n): Promise<UsageCandidateRead> {\n return readCandidatesIn(\n hostRef.collection(collectionName),\n collectionName,\n options,\n )\n}\n\n/**\n * The platform's own emails as a usage corpus (AGL-3318).\n *\n * They live at the ROOT, `systemEmailTemplates/{key}`, because they belong to\n * no site, with the shape a site's own emails have: the published `versionId`\n * on the template, the tree on that version, and no name but the catalog's.\n * Only a scan of the platform marketing site asks for them, since its email\n * blocks are the ones they place. Read under the same bound, and reporting\n * the same `truncated`, as a site's collections; the catalog is code, so a\n * document exists only for an email somebody designed, and the read is a\n * handful of documents.\n */\nexport async function readSystemEmailUsageCandidates(\n firestore: Pick<FirebaseFirestore.Firestore, 'collection'>,\n options: { limit: number },\n): Promise<UsageCandidateRead> {\n return readCandidatesIn(\n firestore.collection(SYSTEM_EMAIL_COLLECTION),\n SYSTEM_EMAIL_COLLECTION,\n { withNodes: true, limit: options.limit },\n )\n}\n\n/** The read both of the above make, over the collection each one names. */\nasync function readCandidatesIn(\n collection: FirebaseFirestore.CollectionReference,\n collectionName:\n | 'screens'\n | 'layouts'\n | 'components'\n | typeof TENANT_EMAIL_COLLECTION\n | typeof SYSTEM_EMAIL_COLLECTION,\n options: { withNodes: boolean; limit: number },\n): Promise<UsageCandidateRead> {\n const { withNodes, limit } = options\n // One over the limit: if the extra document comes back, there was more than\n // we are about to look at. Cheaper than a count() and exact.\n const docs = await collection.limit(limit + 1).get()\n const truncated = docs.size > limit\n const inScope = truncated ? docs.docs.slice(0, limit) : docs.docs\n\n const candidates = await Promise.all(\n inScope.map(async (docSnapshot) => {\n const versionId = docSnapshot.get('versionId')\n // Components keep their tree on the document; screens and layouts keep\n // it on the published version.\n //\n // BOTH reads are decoded (AGL-1223). A component document is msgpack\n // for anything promoted since components were compressed and a plain\n // map for everything older, and `decodeStoredNodes` returns a map\n // unchanged — so the branch below cannot be simplified back into a raw\n // read on either side.\n //\n // What rides on this: every consumer walks the value with\n // `Object.values`, which over a `Buffer` yields byte NUMBERS and\n // matches nothing. The answer that produces is \"used nowhere\" — for\n // `/api/hosts/where-used` an invitation to delete something a live page\n // renders, and for `/api/screens/revalidate` a publish that drops no\n // cache and leaves the old page served.\n const nodes =\n collectionName === 'components'\n ? decodeStoredNodes(docSnapshot.get('nodes'))\n : withNodes && versionId\n ? await docSnapshot.ref\n .collection('versions')\n .doc(String(versionId))\n .get()\n .then((version) => decodeStoredNodes(version.get('nodes')))\n .catch(() => null)\n : null\n return {\n id: docSnapshot.id,\n displayName:\n collectionName === TENANT_EMAIL_COLLECTION\n ? (getTenantEmail(docSnapshot.id)?.name ?? docSnapshot.id)\n : collectionName === SYSTEM_EMAIL_COLLECTION\n ? (getSystemEmailTemplate(docSnapshot.id)?.name ??\n docSnapshot.id)\n : docSnapshot.get('displayName'),\n name: docSnapshot.get('name'),\n deletedAt: docSnapshot.get('deletedAt'),\n nodes,\n ...(versionId ? { versionId: String(versionId) } : {}),\n ...(docSnapshot.get('layoutId')\n ? { layoutId: String(docSnapshot.get('layoutId')) }\n : {}),\n // Screens only, and only the screen scan reads it: a child screen is\n // a dependent of its parent, because its PATH is built from it\n // (AGL-703).\n ...(docSnapshot.get('parentId')\n ? { parentId: String(docSnapshot.get('parentId')) }\n : {}),\n // Screens only: a campaign email design is a screen document that is\n // never a page (AGL-3287).\n ...(collectionName === 'screens' && docSnapshot.get('kind')\n ? { kind: String(docSnapshot.get('kind')) }\n : {}),\n // Components only: the declared properties, whose Link defaults render\n // as links wherever an instance leaves one unset (AGL-2846). The same\n // document the tree came from, so it costs no read.\n ...(collectionName === 'components' &&\n Array.isArray(docSnapshot.get('props'))\n ? { props: docSnapshot.get('props') }\n : {}),\n } satisfies UsageCandidate\n }),\n )\n\n return { candidates, truncated }\n}\n\n/**\n * Every screen, layout and component of a site, with their node trees — the\n * corpus every tree-searching closure above walks.\n *\n * Each collection ONCE, in memory: a query per level would multiply round\n * trips by the nesting depth of the graph. Shared so two scans cannot read\n * different corpora under different bounds, and a change to one cannot quietly\n * narrow the other.\n */\nexport async function readUsageSources(\n hostRef: FirebaseFirestore.DocumentReference,\n limit: number,\n): Promise<{ candidates: UsageSources; truncated: boolean }> {\n const [screens, layouts, components] = await Promise.all([\n readUsageCandidates(hostRef, 'screens', { withNodes: true, limit }),\n readUsageCandidates(hostRef, 'layouts', { withNodes: true, limit }),\n readUsageCandidates(hostRef, 'components', { withNodes: true, limit }),\n ])\n return {\n candidates: {\n screens: screens.candidates,\n layouts: layouts.candidates,\n components: components.candidates,\n },\n truncated: screens.truncated || layouts.truncated || components.truncated,\n }\n}\n"],"names":["nodesReferenceComponent","decodeStoredNodes","getSystemEmailTemplate","SYSTEM_EMAIL_COLLECTION","getTenantEmail","TENANT_EMAIL_COLLECTION","usageCandidateLabel","candidate","String","displayName","name","id","isLiveUsageCandidate","deletedAt","scanComponentUsage","componentId","sources","dependents","collect","candidates","type","nodes","push","kind","via","versionId","screens","layouts","components","emailTemplates","systemEmails","scanLayoutUsage","layoutId","dependentsOf","filter","map","screenIdsUsingLayoutDeep","screenIds","Set","seenLayouts","frontier","length","next","dependent","add","has","screenIdsUsingComponentDeep","seenComponents","screenId","readUsageCandidates","hostRef","collectionName","options","readCandidatesIn","collection","readSystemEmailUsageCandidates","firestore","withNodes","limit","docs","get","truncated","size","inScope","slice","Promise","all","docSnapshot","ref","doc","then","version","catch","parentId","Array","isArray","props","readUsageSources"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/live-page-usage.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 * Which live pages a change reaches, for a site's screens, layouts and\n * components.\n *\n * The corpus read and the two closures over it, held here rather than in the\n * console because the console is no longer the only side that needs them. A\n * dataset record is written from the tenant as well — a form submission, an\n * automation step — and those writes make the same pages stale as a console\n * edit does. Two implementations of \"which screens render this\" is the shape\n * that put AGL-1223 in the tree: one reader handled a node tree's second\n * storage form and the other did not, so half the corpus answered \"used\n * nowhere\" and a publish dropped no cache.\n *\n * Nothing here is dataset-specific, and nothing is console-specific. The\n * console's `scan-artifact-usage` re-exports these and keeps the parts that\n * are about the console's own \"what would I break\" copy.\n */\n\nimport {\n nodesReferenceComponent,\n nodesRenderCollection,\n decodeStoredNodes,\n type ReusableComponentProp,\n} from '@aglyn/aglyn/server'\nimport {\n getSystemEmailTemplate,\n SYSTEM_EMAIL_COLLECTION,\n} from '@aglyn/shared-util-email/system-email-catalog'\nimport {\n getTenantEmail,\n TENANT_EMAIL_COLLECTION,\n} from '@aglyn/shared-util-email/tenant-email-catalog'\n\n/** A screen/layout/component reduced to what a usage scan needs. */\nexport interface UsageCandidate {\n id: string\n displayName?: string\n /** Legacy field some older documents used instead of `displayName`. */\n name?: string\n deletedAt?: unknown\n /**\n * Node tree to search. For screens and layouts this is the PUBLISHED\n * version's nodes (what visitors see); for components it is the definition\n * tree off the component document, which is what the runtime reads.\n */\n nodes?: Record<string, any> | null\n /** Published version, carried through so the caller can deep-link. */\n versionId?: string\n /** Screens only: the layout they render inside. */\n layoutId?: string\n /** Screens only: the screen they nest under, which is part of their path. */\n parentId?: string\n /**\n * Screens only: `'email'` for a campaign email design (AGL-3287), which\n * a scan reports as an `emailDesign` rather than a page.\n */\n kind?: string\n /**\n * Components only: the properties the definition declares (AGL-1247). A\n * Link property's default renders as a link wherever an instance leaves the\n * property unset, and it is stored here, not in `nodes` (AGL-2846).\n */\n props?: ReadonlyArray<ReusableComponentProp | null | undefined> | null\n}\n\n/** One document that depends on the artifact a scan was asked about. */\nexport interface UsageDependent {\n /**\n * `emailTemplate` is one of the site's own transactional emails\n * (`hosts/{h}/emailTemplates/{key}`), `id` its catalog key (AGL-3287).\n *\n * `emailDesign` is an email designed for a campaign: a `kind: 'email'`\n * screen document, `id` its screen id. It is its own type rather than a\n * `screen` because it is never a page. A caller that took it for one sent\n * the reader to the page settings — a slug, SEO and a Publish button that\n * would serve the email as a route — and a cache drop has no page to drop.\n *\n * `systemEmail` is one of the platform's own emails\n * (`systemEmailTemplates/{key}`, at the root, belonging to no site), `id`\n * its catalog key (AGL-3318). Only the platform marketing site's blocks\n * have one: that is the site those emails read their header and footer\n * from.\n */\n type:\n | 'screen'\n | 'layout'\n | 'component'\n | 'collection'\n | 'emailTemplate'\n | 'emailDesign'\n | 'systemEmail'\n id: string\n name: string\n via: Array<'id' | 'name'>\n versionId?: string\n /**\n * HOW the dependent references the artifact — screens and collection\n * listings (AGL-703, AGL-2806).\n *\n * A component or a layout has exactly one kind of dependent and the noun\n * says everything: an instance, or a binding. A screen has three, and they\n * break in three different ways — a link goes dead, a child moves, a\n * collection loses the page it renders through. Copy that could not tell\n * them apart would have to describe the worst case every time. A collection\n * listing's dependents are all links, and say so.\n */\n relation?: 'link' | 'child' | 'template'\n}\n\n/** The three corpora every closure below walks, and a fourth for components. */\nexport interface UsageSources {\n screens: UsageCandidate[]\n layouts: UsageCandidate[]\n components: UsageCandidate[]\n /**\n * The site's transactional email designs, each on its published version\n * (AGL-3287). Read only where a scan asks \"what places this component\" —\n * {@link scanComponentUsage} lists them — because an email is not a page:\n * nothing is cached for it, so no closure that decides which pages to drop\n * has any use for them, and none reads them.\n */\n emailTemplates?: UsageCandidate[]\n /**\n * The platform's own emails, each on its published version (AGL-3318).\n * Read only when the site scanned is the platform marketing site, whose\n * email blocks those emails place, and listed by {@link scanComponentUsage}\n * alone for the reason `emailTemplates` is: an email is not a page.\n */\n systemEmails?: UsageCandidate[]\n}\n\n/** `displayName`, falling back to a legacy `name`, then the raw id. */\nexport function usageCandidateLabel(candidate: UsageCandidate): string {\n return String(candidate.displayName ?? candidate.name ?? candidate.id)\n}\n\n/** A soft-deleted document renders nothing, so it depends on nothing. */\nexport const isLiveUsageCandidate = (candidate: UsageCandidate): boolean =>\n !candidate.deletedAt\n\n/**\n * Everything that references a reusable component (AGL-703).\n *\n * Every place the renderer expands an instance: published screen versions\n * (email designs among them — a campaign email is a screen), published layout\n * versions, OTHER component definitions — `composeReusableComponentNodes`\n * grafts nested instances, so a component used only inside another component\n * is genuinely used — and, when the caller read them, the site's transactional\n * emails, which graft a placed header or footer at send time (AGL-3287), and\n * the platform's own emails, which graft the platform marketing site's header\n * and footer (AGL-3318). Omitting any of them would report \"used nowhere\" for\n * something that renders and invite a confident deletion, which is worse than\n * showing nothing at all.\n */\nexport function scanComponentUsage(\n componentId: string,\n sources: UsageSources,\n): UsageDependent[] {\n if (!componentId) return []\n const dependents: UsageDependent[] = []\n const collect = (\n candidates: UsageCandidate[],\n type: UsageDependent['type'],\n ) => {\n for (const candidate of candidates) {\n if (!isLiveUsageCandidate(candidate)) continue\n // A component never counts as using itself, however it nests.\n if (type === 'component' && candidate.id === componentId) continue\n if (!nodesReferenceComponent(candidate.nodes, componentId)) continue\n dependents.push({\n type:\n type === 'screen' && candidate.kind === 'email'\n ? 'emailDesign'\n : type,\n id: candidate.id,\n name: usageCandidateLabel(candidate),\n // Instances reference by id, so a rename can never break them.\n via: ['id'],\n ...(candidate.versionId ? { versionId: candidate.versionId } : {}),\n })\n }\n }\n collect(sources.screens, 'screen')\n collect(sources.layouts, 'layout')\n collect(sources.components, 'component')\n collect(sources.emailTemplates ?? [], 'emailTemplate')\n collect(sources.systemEmails ?? [], 'systemEmail')\n return dependents\n}\n\n/**\n * Everything rendering inside a layout (AGL-703).\n *\n * Two kinds of dependent, both expressed by the same `layoutId` pointer:\n *\n * - **screens**, which name the layout they render inside;\n * - **other layouts**, since a layout can itself sit inside one. A nested\n * layout is a real dependent — deleting its parent unwraps every screen\n * underneath it — so leaving layouts out would report a parent layout as\n * used only by the screens that name it directly, and none of the ones\n * that reach it through a child.\n *\n * A layout never counts as its own dependent; `canNestLayout` refuses that,\n * and this refuses to report it even if stored data holds one.\n */\nexport function scanLayoutUsage(\n layoutId: string,\n screens: UsageCandidate[],\n layouts: UsageCandidate[] = [],\n): UsageDependent[] {\n if (!layoutId) return []\n const dependentsOf = (\n candidates: UsageCandidate[],\n type: 'screen' | 'layout',\n ) =>\n candidates\n .filter(\n (candidate) =>\n isLiveUsageCandidate(candidate) &&\n candidate.layoutId === layoutId &&\n candidate.id !== layoutId,\n )\n .map((candidate) => ({\n type,\n id: candidate.id,\n name: usageCandidateLabel(candidate),\n via: ['id' as const],\n ...(candidate.versionId ? { versionId: candidate.versionId } : {}),\n }))\n return [\n ...dependentsOf(screens, 'screen'),\n ...dependentsOf(layouts, 'layout'),\n ]\n}\n\n/**\n * Every live screen rendered inside `layoutId`, at ANY nesting depth\n * (AGL-1150).\n *\n * `scanLayoutUsage` answers one level. Layouts nest — a screen points at a\n * layout, which can point at a parent layout, and `compose-screen-nodes` walks\n * that whole chain when composing a page. So publishing a layout changes every\n * screen below it, not just the ones bound to it directly, and a cache drop\n * that only handles the direct level leaves the rest showing stale chrome for\n * the full revalidate window.\n *\n * Pure, and separate from the Firestore read, so the nesting behaviour is\n * testable without a database.\n *\n * Cycle-safe. `canNestLayout` refuses to create a cycle, but a document written\n * straight to Firestore is not bound by that, and a cycle here would hang a\n * publish request rather than surface anything.\n */\nexport function screenIdsUsingLayoutDeep(\n layoutId: string,\n screens: UsageCandidate[],\n layouts: UsageCandidate[] = [],\n): string[] {\n if (!layoutId) return []\n const screenIds = new Set<string>()\n const seenLayouts = new Set<string>([layoutId])\n let frontier = [layoutId]\n\n while (frontier.length) {\n const next: string[] = []\n for (const id of frontier) {\n for (const dependent of scanLayoutUsage(id, screens, layouts)) {\n if (dependent.type === 'screen') {\n screenIds.add(dependent.id)\n } else if (!seenLayouts.has(dependent.id)) {\n seenLayouts.add(dependent.id)\n next.push(dependent.id)\n }\n }\n }\n frontier = next\n }\n\n return [...screenIds]\n}\n\n/**\n * Every live screen whose rendered output contains `componentId`, however\n * indirectly (AGL-1161).\n *\n * `scanComponentUsage` answers one level and returns three kinds of dependent.\n * Only one of them is a screen, and the other two both reach screens by routes\n * a single-level scan cannot see:\n *\n * - a **component** dependent nests the target inside itself, and that outer\n * component may itself only be used inside a third — so component→component\n * edges have to be followed to a fixed point;\n * - a **layout** dependent puts the component in page chrome, which every\n * screen under that layout renders. Layouts nest, so that is\n * `screenIdsUsingLayoutDeep`, not a direct `layoutId` match.\n *\n * Miss either and a publish reports success while some pages keep serving the\n * old component for the full revalidate window — the failure this whole arc\n * exists to remove, and the one that is hardest to notice because the pages\n * that ARE dropped update instantly.\n *\n * Pure, and separate from the Firestore read, so the closure is testable\n * without a database — the same split `screenIdsUsingLayoutDeep` uses.\n *\n * Cycle-safe. `composeReusableComponentNodes` would not survive a cycle, but a\n * document written straight to Firestore is not bound by what the editor\n * allows, and a cycle here would hang a publish rather than surface anything.\n */\nexport function screenIdsUsingComponentDeep(\n componentId: string,\n sources: UsageSources,\n): string[] {\n if (!componentId) return []\n const screenIds = new Set<string>()\n const seenComponents = new Set<string>([componentId])\n // Layouts are resolved through their own deep walk, so remember which ones\n // have already been expanded: two components in the same layout would\n // otherwise re-walk the whole layout tree once each.\n const seenLayouts = new Set<string>()\n let frontier = [componentId]\n\n while (frontier.length) {\n const next: string[] = []\n for (const id of frontier) {\n for (const dependent of scanComponentUsage(id, sources)) {\n if (dependent.type === 'screen') {\n screenIds.add(dependent.id)\n } else if (dependent.type === 'layout') {\n if (seenLayouts.has(dependent.id)) continue\n seenLayouts.add(dependent.id)\n // The layout itself renders no URL; the screens beneath it do.\n for (const screenId of screenIdsUsingLayoutDeep(\n dependent.id,\n sources.screens,\n sources.layouts,\n )) {\n screenIds.add(screenId)\n }\n } else if (\n dependent.type === 'component' &&\n !seenComponents.has(dependent.id)\n ) {\n seenComponents.add(dependent.id)\n next.push(dependent.id)\n }\n // An email — transactional, designed for a campaign, or one of the\n // platform's own — is not a page and nothing caches it, so it\n // contributes no screen and is not followed (AGL-3287, AGL-3318). A\n // transactional or platform email's id is a catalog key, and walking\n // it as a component would read a key as a definition that does not\n // exist.\n }\n }\n frontier = next\n }\n\n return [...screenIds]\n}\n\n/**\n * Every live screen whose rendered output renders the collection\n * `collectionSlug`, however indirectly.\n *\n * The content twin of the console's `screenIdsUsingFormDeep`, and the same\n * walk for the same reason. Saving, publishing or deleting an ENTRY changes\n * every page that renders the collection it belongs to, and only some of those\n * pages are derivable from the collection's slug. `/blog`, `/blog/my-post` and\n * `/blog/category/guides` are; the \"Latest posts\" rail on the home page, the\n * category pills in a layout's chrome and the search box in a header are not\n * — they are found by searching node trees, and they can sit on a screen,\n * inside page chrome, or inside a reusable component that a third component\n * nests.\n *\n * Miss them and an entry edit reports success while the pages that advertise\n * it keep serving the old set for the whole revalidate window — with the rail\n * on the home page, the single most visible place the change was supposed to\n * appear.\n *\n * The first level is the only thing that differs from the form walk: a\n * document uses a collection by BINDING a collection block to its slug. After\n * that the fan-out is identical, so it is delegated rather than restated.\n *\n * The collection's OWN routes are deliberately not here. They are addresses,\n * not screens — a fallback listing has no screen document at all, and a\n * template screen serves the collection's paths rather than its own — so the\n * caller derives them from the slug and sends them beside these.\n *\n * Pure, and separate from the Firestore read, so the closure is testable\n * without a database. Cycle-safe by delegation: both walks it defers to bound\n * themselves.\n */\nexport function screenIdsUsingCollectionDeep(\n collectionSlug: string,\n sources: UsageSources,\n): string[] {\n if (!collectionSlug) return []\n const screenIds = new Set<string>()\n\n for (const candidate of sources.screens) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (nodesRenderCollection(candidate.nodes, collectionSlug)) {\n screenIds.add(candidate.id)\n }\n }\n for (const candidate of sources.layouts) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (!nodesRenderCollection(candidate.nodes, collectionSlug)) continue\n // The layout itself renders no URL; the screens beneath it do.\n for (const screenId of screenIdsUsingLayoutDeep(\n candidate.id,\n sources.screens,\n sources.layouts,\n )) {\n screenIds.add(screenId)\n }\n }\n for (const candidate of sources.components) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (!nodesRenderCollection(candidate.nodes, collectionSlug)) continue\n // And a component renders wherever it is placed, however deeply nested.\n for (const screenId of screenIdsUsingComponentDeep(candidate.id, sources)) {\n screenIds.add(screenId)\n }\n }\n\n return [...screenIds]\n}\n\nexport interface UsageCandidateRead {\n candidates: UsageCandidate[]\n /**\n * The collection held more documents than `limit` allowed, so the scan below\n * it is INCOMPLETE.\n *\n * Returned rather than logged, because the two callers owe the user\n * different things: an advisory \"what would I break\" can show a partial\n * answer and say so, while a cache drop that silently scans a prefix reports\n * a successful publish and leaves real pages stale.\n */\n truncated: boolean\n}\n\n/**\n * One collection's documents, with published nodes attached when the scan\n * needs to search them.\n *\n * `limit` is a real bound, not a guess: it is fetched with one extra document\n * so exceeding it is DETECTED rather than assumed away. A caller that ignores\n * `truncated` is choosing to be wrong quietly.\n *\n * `emailTemplates` — the site's transactional email designs (AGL-3287) — has\n * the screens' shape: a parent holding the published `versionId`, the tree on\n * that version. The catalog is code and fixed, and a template document only\n * exists once somebody pressed Design, so most sites have none and the read\n * costs one empty query. Its documents carry no name, so each is labelled with\n * the catalog's.\n */\nexport async function readUsageCandidates(\n hostRef: FirebaseFirestore.DocumentReference,\n collectionName:\n | 'screens'\n | 'layouts'\n | 'components'\n | typeof TENANT_EMAIL_COLLECTION,\n options: { withNodes: boolean; limit: number },\n): Promise<UsageCandidateRead> {\n return readCandidatesIn(\n hostRef.collection(collectionName),\n collectionName,\n options,\n )\n}\n\n/**\n * The platform's own emails as a usage corpus (AGL-3318).\n *\n * They live at the ROOT, `systemEmailTemplates/{key}`, because they belong to\n * no site, with the shape a site's own emails have: the published `versionId`\n * on the template, the tree on that version, and no name but the catalog's.\n * Only a scan of the platform marketing site asks for them, since its email\n * blocks are the ones they place. Read under the same bound, and reporting\n * the same `truncated`, as a site's collections; the catalog is code, so a\n * document exists only for an email somebody designed, and the read is a\n * handful of documents.\n */\nexport async function readSystemEmailUsageCandidates(\n firestore: Pick<FirebaseFirestore.Firestore, 'collection'>,\n options: { limit: number },\n): Promise<UsageCandidateRead> {\n return readCandidatesIn(\n firestore.collection(SYSTEM_EMAIL_COLLECTION),\n SYSTEM_EMAIL_COLLECTION,\n { withNodes: true, limit: options.limit },\n )\n}\n\n/** The read both of the above make, over the collection each one names. */\nasync function readCandidatesIn(\n collection: FirebaseFirestore.CollectionReference,\n collectionName:\n | 'screens'\n | 'layouts'\n | 'components'\n | typeof TENANT_EMAIL_COLLECTION\n | typeof SYSTEM_EMAIL_COLLECTION,\n options: { withNodes: boolean; limit: number },\n): Promise<UsageCandidateRead> {\n const { withNodes, limit } = options\n // One over the limit: if the extra document comes back, there was more than\n // we are about to look at. Cheaper than a count() and exact.\n const docs = await collection.limit(limit + 1).get()\n const truncated = docs.size > limit\n const inScope = truncated ? docs.docs.slice(0, limit) : docs.docs\n\n const candidates = await Promise.all(\n inScope.map(async (docSnapshot) => {\n const versionId = docSnapshot.get('versionId')\n // Components keep their tree on the document; screens and layouts keep\n // it on the published version.\n //\n // BOTH reads are decoded (AGL-1223). A component document is msgpack\n // for anything promoted since components were compressed and a plain\n // map for everything older, and `decodeStoredNodes` returns a map\n // unchanged — so the branch below cannot be simplified back into a raw\n // read on either side.\n //\n // What rides on this: every consumer walks the value with\n // `Object.values`, which over a `Buffer` yields byte NUMBERS and\n // matches nothing. The answer that produces is \"used nowhere\" — for\n // `/api/hosts/where-used` an invitation to delete something a live page\n // renders, and for `/api/screens/revalidate` a publish that drops no\n // cache and leaves the old page served.\n const nodes =\n collectionName === 'components'\n ? decodeStoredNodes(docSnapshot.get('nodes'))\n : withNodes && versionId\n ? await docSnapshot.ref\n .collection('versions')\n .doc(String(versionId))\n .get()\n .then((version) => decodeStoredNodes(version.get('nodes')))\n .catch(() => null)\n : null\n return {\n id: docSnapshot.id,\n displayName:\n collectionName === TENANT_EMAIL_COLLECTION\n ? (getTenantEmail(docSnapshot.id)?.name ?? docSnapshot.id)\n : collectionName === SYSTEM_EMAIL_COLLECTION\n ? (getSystemEmailTemplate(docSnapshot.id)?.name ??\n docSnapshot.id)\n : docSnapshot.get('displayName'),\n name: docSnapshot.get('name'),\n deletedAt: docSnapshot.get('deletedAt'),\n nodes,\n ...(versionId ? { versionId: String(versionId) } : {}),\n ...(docSnapshot.get('layoutId')\n ? { layoutId: String(docSnapshot.get('layoutId')) }\n : {}),\n // Screens only, and only the screen scan reads it: a child screen is\n // a dependent of its parent, because its PATH is built from it\n // (AGL-703).\n ...(docSnapshot.get('parentId')\n ? { parentId: String(docSnapshot.get('parentId')) }\n : {}),\n // Screens only: a campaign email design is a screen document that is\n // never a page (AGL-3287).\n ...(collectionName === 'screens' && docSnapshot.get('kind')\n ? { kind: String(docSnapshot.get('kind')) }\n : {}),\n // Components only: the declared properties, whose Link defaults render\n // as links wherever an instance leaves one unset (AGL-2846). The same\n // document the tree came from, so it costs no read.\n ...(collectionName === 'components' &&\n Array.isArray(docSnapshot.get('props'))\n ? { props: docSnapshot.get('props') }\n : {}),\n } satisfies UsageCandidate\n }),\n )\n\n return { candidates, truncated }\n}\n\n/**\n * Every screen, layout and component of a site, with their node trees — the\n * corpus every tree-searching closure above walks.\n *\n * Each collection ONCE, in memory: a query per level would multiply round\n * trips by the nesting depth of the graph. Shared so two scans cannot read\n * different corpora under different bounds, and a change to one cannot quietly\n * narrow the other.\n */\nexport async function readUsageSources(\n hostRef: FirebaseFirestore.DocumentReference,\n limit: number,\n): Promise<{ candidates: UsageSources; truncated: boolean }> {\n const [screens, layouts, components] = await Promise.all([\n readUsageCandidates(hostRef, 'screens', { withNodes: true, limit }),\n readUsageCandidates(hostRef, 'layouts', { withNodes: true, limit }),\n readUsageCandidates(hostRef, 'components', { withNodes: true, limit }),\n ])\n return {\n candidates: {\n screens: screens.candidates,\n layouts: layouts.candidates,\n components: components.candidates,\n },\n truncated: screens.truncated || layouts.truncated || components.truncated,\n }\n}\n"],"names":["nodesReferenceComponent","nodesRenderCollection","decodeStoredNodes","getSystemEmailTemplate","SYSTEM_EMAIL_COLLECTION","getTenantEmail","TENANT_EMAIL_COLLECTION","usageCandidateLabel","candidate","String","displayName","name","id","isLiveUsageCandidate","deletedAt","scanComponentUsage","componentId","sources","dependents","collect","candidates","type","nodes","push","kind","via","versionId","screens","layouts","components","emailTemplates","systemEmails","scanLayoutUsage","layoutId","dependentsOf","filter","map","screenIdsUsingLayoutDeep","screenIds","Set","seenLayouts","frontier","length","next","dependent","add","has","screenIdsUsingComponentDeep","seenComponents","screenId","screenIdsUsingCollectionDeep","collectionSlug","readUsageCandidates","hostRef","collectionName","options","readCandidatesIn","collection","readSystemEmailUsageCandidates","firestore","withNodes","limit","docs","get","truncated","size","inScope","slice","Promise","all","docSnapshot","ref","doc","then","version","catch","parentId","Array","isArray","props","readUsageSources"],"mappings":"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"}
|
|
@@ -16,6 +16,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
18
18
|
import { checkQuota } from "@aglyn/aglyn/server";
|
|
19
|
+
import { mediaFilterKeys } from "@aglyn/aglyn/app-utils/media-metadata";
|
|
19
20
|
import { removeMediaDeliveryCopies, withoutMediaDeliveryCopies } from "./media-delivery.js";
|
|
20
21
|
/**
|
|
21
22
|
* What a DAM delete has to leave behind for an undo to be possible at all
|
|
@@ -394,13 +395,18 @@ import { removeMediaDeliveryCopies, withoutMediaDeliveryCopies } from "./media-d
|
|
|
394
395
|
});
|
|
395
396
|
}
|
|
396
397
|
const restored = await scopeRef.firestore.runTransaction(async (transaction)=>{
|
|
397
|
-
var _record_sizeBytes;
|
|
398
|
+
var _media_folderId, _record_sizeBytes;
|
|
398
399
|
const fresh = await transaction.get(tombstoneRef);
|
|
399
400
|
if (!fresh.exists) return false;
|
|
400
401
|
const record = fresh.data();
|
|
401
402
|
// Without its delivery copy record: the delete removed those copies,
|
|
402
403
|
// and a record of them would name objects that are gone (AGL-2824).
|
|
403
|
-
|
|
404
|
+
// With the library's filter keys re-derived, so a file deleted before
|
|
405
|
+
// they existed comes back findable by them (AGL-3327).
|
|
406
|
+
const media = withoutMediaDeliveryCopies(record.media);
|
|
407
|
+
transaction.set(mediaRef, _extends({}, media, mediaFilterKeys(media), {
|
|
408
|
+
folderId: (_media_folderId = media.folderId) != null ? _media_folderId : null
|
|
409
|
+
}));
|
|
404
410
|
transaction.delete(tombstoneRef);
|
|
405
411
|
transaction.set(counterRef, {
|
|
406
412
|
bytes: FieldValue.increment(Number((_record_sizeBytes = record.sizeBytes) != null ? _record_sizeBytes : 0)),
|