@aglyn/tenant-data-admin 1.0.0-beta.218 → 1.0.0-beta.220
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -8
- package/src/lib/server/api-http.js +5 -1
- package/src/lib/server/api-http.js.map +1 -1
- package/src/lib/server/api-v1-description.d.ts +112 -0
- package/src/lib/server/api-v1-description.js +126 -0
- package/src/lib/server/api-v1-description.js.map +1 -0
- package/src/lib/server/api-v1-kit.d.ts +163 -0
- package/src/lib/server/api-v1-kit.js +191 -0
- package/src/lib/server/api-v1-kit.js.map +1 -0
- package/src/lib/server/api-v1-resources.d.ts +143 -0
- package/src/lib/server/api-v1-resources.js +125 -0
- package/src/lib/server/api-v1-resources.js.map +1 -0
- package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
- package/src/lib/server/campaign-conversion-attribution.js +3 -3
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
- package/src/lib/server/card-payment-velocity.d.ts +9 -0
- package/src/lib/server/card-payment-velocity.js +28 -2
- package/src/lib/server/card-payment-velocity.js.map +1 -1
- package/src/lib/server/consent-group-carry.js +1 -1
- package/src/lib/server/consent-group-carry.js.map +1 -1
- package/src/lib/server/consent-group-change.d.ts +2 -1
- package/src/lib/server/consent-group-change.js +43 -18
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/contact-email-index.d.ts +22 -0
- package/src/lib/server/contact-email-index.js +108 -0
- package/src/lib/server/contact-email-index.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +1 -1
- package/src/lib/server/crm-email-activity.js +1 -1
- package/src/lib/server/crm-email-activity.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +9 -1
- package/src/lib/server/crm-records.js +19 -2
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/cron-auth.d.ts +44 -0
- package/src/lib/server/cron-auth.js +76 -0
- package/src/lib/server/cron-auth.js.map +1 -0
- package/src/lib/server/dataset-live-pages.js +6 -6
- package/src/lib/server/dataset-live-pages.js.map +1 -1
- package/src/lib/server/duplicate-resource.d.ts +26 -0
- package/src/lib/server/duplicate-resource.js +47 -25
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +6 -3
- package/src/lib/server/dynamic-list-materialize.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +2 -2
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +11 -0
- package/src/lib/server/email-suppression.js +37 -2
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
- package/src/lib/server/email-unsubscribe-link.js +7 -7
- package/src/lib/server/email-unsubscribe-link.js.map +1 -1
- package/src/lib/server/erase.d.ts +19 -33
- package/src/lib/server/erase.js +37 -162
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-email-tokens.js +1 -1
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-memberships.js +3 -3
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +32 -4
- package/src/lib/server/host-visitor-records.js +73 -14
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +27 -0
- package/src/lib/server/hosted-page-review.js +149 -12
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
- package/src/lib/server/media-cdn-rate-limit.js +6 -3
- package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
- package/src/lib/server/member-email-aliases.d.ts +10 -2
- package/src/lib/server/member-email-aliases.js +26 -2
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/new-account-announcement.d.ts +62 -0
- package/src/lib/server/new-account-announcement.js +77 -0
- package/src/lib/server/new-account-announcement.js.map +1 -0
- package/src/lib/server/notifications.d.ts +22 -2
- package/src/lib/server/notifications.js +40 -3
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/operator-alerts.d.ts +7 -1
- package/src/lib/server/operator-alerts.js +5 -3
- package/src/lib/server/operator-alerts.js.map +1 -1
- package/src/lib/server/org-containers.d.ts +66 -0
- package/src/lib/server/org-containers.js +95 -0
- package/src/lib/server/org-containers.js.map +1 -0
- package/src/lib/server/org-member-notice.js +3 -2
- package/src/lib/server/org-member-notice.js.map +1 -1
- package/src/lib/server/payment-fraud-signal.js +5 -1
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
- package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +36 -2
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
- package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
- package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
- package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
- package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
- package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
- package/src/lib/server/payment-provider.d.ts +173 -0
- package/src/lib/server/payment-provider.js +70 -0
- package/src/lib/server/payment-provider.js.map +1 -0
- package/src/lib/server/payment-risk-record.d.ts +1 -1
- package/src/lib/server/payment-risk-record.js +3 -1
- package/src/lib/server/payment-risk-record.js.map +1 -1
- package/src/lib/server/personal-data-export.js +1 -1
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/platform-marketing-consent.d.ts +1 -1
- package/src/lib/server/platform-marketing-consent.js +1 -1
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/rate-limit-store.js +13 -3
- package/src/lib/server/rate-limit-store.js.map +1 -1
- package/src/lib/server/realm-plugins.js +8 -3
- package/src/lib/server/realm-plugins.js.map +1 -1
- package/src/lib/server/retained-refusals.d.ts +142 -0
- package/src/lib/server/retained-refusals.js +325 -0
- package/src/lib/server/retained-refusals.js.map +1 -0
- package/src/lib/server/risk-notice.d.ts +32 -1
- package/src/lib/server/risk-notice.js +90 -28
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/sending-domains.d.ts +17 -0
- package/src/lib/server/sending-domains.js +28 -4
- package/src/lib/server/sending-domains.js.map +1 -1
- package/src/lib/server/sso-enforcement.js +7 -2
- package/src/lib/server/sso-enforcement.js.map +1 -1
- package/src/lib/server/staff-alert-email.js +4 -2
- package/src/lib/server/staff-alert-email.js.map +1 -1
- package/src/lib/server/test-firestore-queries.d.ts +7 -0
- package/src/lib/server/test-firestore-queries.js +70 -27
- package/src/lib/server/test-firestore-queries.js.map +1 -1
- package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
- package/src/lib/server/topic-subscriptions.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +46 -1
- package/src/lib/server/upsert-contact.js +87 -20
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +5 -8
- package/src/tenancy.js +12 -13
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/connect-account-status.js.map +0 -1
- package/src/lib/server/connect-payout-failure.js.map +0 -1
- package/src/lib/server/contact-email-engagement.d.ts +0 -32
- package/src/lib/server/contact-email-engagement.js +0 -145
- package/src/lib/server/contact-email-engagement.js.map +0 -1
- package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
- package/src/lib/server/contact-lifecycle-floor.js +0 -91
- package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
- package/src/lib/server/contact-merge.d.ts +0 -55
- package/src/lib/server/contact-merge.js +0 -288
- package/src/lib/server/contact-merge.js.map +0 -1
- package/src/lib/server/crm-booking-activity.d.ts +0 -86
- package/src/lib/server/crm-booking-activity.js +0 -257
- package/src/lib/server/crm-booking-activity.js.map +0 -1
- package/src/lib/server/crm-inbound-email.d.ts +0 -145
- package/src/lib/server/crm-inbound-email.js +0 -297
- package/src/lib/server/crm-inbound-email.js.map +0 -1
- package/src/lib/server/email-topic-confirmation.js.map +0 -1
- package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
- package/src/lib/server/form-dataset-binding-token.js +0 -107
- package/src/lib/server/form-dataset-binding-token.js.map +0 -1
- package/src/lib/server/stripe-account-mode.js.map +0 -1
- /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/campaign-conversion-attribution.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 { FieldValue, type DocumentReference, type Firestore } from 'firebase-admin/firestore'\nimport {\n parseCampaignTouch,\n type CampaignTouch,\n} from '@aglyn/aglyn/app-utils/campaign-touch'\n/*\n * Stamped from the REVENUE join's constants, aliased at the import.\n *\n * They are spelled `EMAIL_` because that is where both halves of that join\n * could reach them, not because the window is an email fact — it is the\n * platform's one answer to how long a touch may be credited. Reading them\n * from there is what makes a lead's record and an order's record carry the\n * same rule rather than two that happen to agree.\n */\nimport {\n EMAIL_ATTRIBUTION_MODEL as ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS as ATTRIBUTION_WINDOW_DAYS,\n emailTouchIsInWindow,\n} from '@aglyn/shared-util-email/email-revenue-window'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n CAMPAIGN_ATTRIBUTIONS_COLLECTION,\n eraseCampaignAttributionsForPersonKey,\n} from './campaign-attribution-store'\nimport { readEmailCampaignTouch } from './email-delivery-log'\nimport { isDocumentId } from './document-id'\nimport firebaseAdmin from './firebase-admin'\nimport { resolveOrgIdForHost } from './organizations'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/**\n * THE IDENTIFY-MOMENT JOIN — a form, a lead, a contact or a booking, credited\n * to the campaign that led to it.\n *\n * ## Why there is a second join at all, and why it is not a second SCHEME\n *\n * `email-revenue-attribution.ts` credits an ORDER by looking the buyer's\n * address up in the touch map a click wrote. That works because an order\n * names its buyer. Every other outcome a campaign causes is produced by\n * somebody who was anonymous until the instant they produced it: they arrive\n * from a campaign link, browse, and only become identifiable when they submit\n * a form, sign up, book or check out. There is no address to look up until\n * the very moment being attributed.\n *\n * So the touch is carried on the VISITOR (`campaign-touch.ts`) and attached\n * here, at each moment they become somebody. What is NOT rebuilt is the\n * model: the same seven-day window, the same last-click rule, the same\n * `model`/`windowDays` stamped onto every record so a report can say what it\n * counted. The email channel is not re-derived either — it is READ from the\n * touch the delivery webhook already wrote, so a campaign email and a\n * campaign ad are two inputs to one comparison rather than two schemes with\n * two answers.\n *\n * ## Last touch, across both channels\n *\n * At the identify moment there can be two candidate touches:\n *\n * - the WEB touch the visitor's device carried, from an ad, a partner link\n * or a social post; and\n * - the EMAIL touch on `emailDeliveries/{personKey}.campaignTouches[hostId]`,\n * written server-side when they clicked a campaign's mail.\n *\n * Both are window-checked and the LATER one wins. Splitting the outcome\n * between them is the multi-touch model the revenue work rejected for the\n * reason that applies here unchanged: a rule the merchant did not choose\n * produces a figure nobody can check. One outcome, one campaign, and a rule\n * stated on the record.\n *\n * ## No campaign touch means NO RECORD\n *\n * Direct traffic writes nothing at all, and there is deliberately no fallback\n * — no \"most recent campaign on this site\", no referrer inference, no\n * `utm_source=direct`. A conversion nobody can be credited with is a\n * conversion nobody is credited with, and the absence of a record is how the\n * report says so. The cost of a miss is one uncounted outcome; the cost of a\n * guess is a campaign that reads as effective because it was the last one to\n * run.\n *\n * ## Three reads at most, and usually one\n *\n * Resolving a touch costs ONE keyed document read, and only when the visitor\n * gave an address — no query, no index, nothing that can be truncated. A door\n * resolves once per request and hands the result to every writer beneath it,\n * so a form submission that creates a submission, a contact and a lead pays\n * for the lookup once rather than three times.\n *\n * ## Never throws\n *\n * The same contract as the revenue join and for the same reason: the visitor\n * has already done the thing being attributed and the record of it is already\n * written. A lost attribution understates a campaign; a thrown one loses a\n * lead.\n */\n\n/**\n * The collection and the erasure sweep, re-exported so a caller needs one\n * import for the whole join. They are DEFINED in a leaf module because the\n * sweep runs from `email-delivery-log.ts`, which this file reads — see\n * `campaign-attribution-store.ts` for why that cycle is not merely untidy.\n */\nexport {\n CAMPAIGN_ATTRIBUTIONS_COLLECTION,\n eraseCampaignAttributionsForPersonKey,\n}\n\n/** The single conversion rollup document under an email campaign. */\nexport const CAMPAIGN_CONVERSIONS_REPORT_DOC = 'conversions'\n\n/**\n * Which identify moment a record credits.\n *\n * Kept apart rather than summed, which is the whole reason the kind is on the\n * record. One form submission by a new person creates a submission, a contact\n * and possibly a lead — three true statements about one visitor action — and\n * a report that added them would treble every campaign's conversions. A\n * reader asks for one kind at a time, exactly as the revenue rollup buckets\n * by currency and never totals across them.\n */\nexport type CampaignConversionKind = 'form' | 'lead' | 'contact' | 'booking'\n\n/**\n * Which channel the credited touch arrived through.\n *\n * `sequence` (AGL-3254) is a click on, or the sending of, a one-to-one\n * email a rep's sequence sent. It is read off the same touch map as the\n * email channel — the Outreach click route stamps it there with the\n * sequence and the enrollment beside the campaign the sequence is in — so\n * a booking made from a sequence link is credited by the door that credits\n * every other booking, and nothing in this join has to know what a\n * sequence is.\n */\nexport type CampaignTouchChannel = 'email' | 'web' | 'sequence'\n\n/** The touch a conversion is credited to, once both channels have been asked. */\nexport interface ResolvedCampaignTouch {\n channel: CampaignTouchChannel\n /**\n * The campaign document, when the touch was a click on our own mail — or\n * the campaign CONTAINER (`emailCampaigns/{id}`) a sequence is in, for a\n * sequence touch.\n */\n campaignId?: string\n /** The sequence the touch came through, for a `sequence` touch. */\n sequenceId?: string\n /** The enrollment the touch came through, for a `sequence` touch. */\n enrollmentId?: string\n /** `utm_source`, when the touch was a link on the web. */\n source?: string\n /** `utm_medium`, when the touch was a link on the web. */\n medium?: string\n /** `utm_campaign`, when the touch was a link on the web. */\n campaign?: string\n /** When the visitor followed the campaign link, epoch ms. */\n touchedAtMs: number\n /**\n * `sha256` of the normalized address the touch was resolved against, when\n * the visitor gave one. The handle an erasure erases by — see\n * {@link eraseCampaignAttributionsForPersonKey}.\n */\n personKey?: string\n}\n\n/** What one conversion attribution record holds. */\nexport interface CampaignConversionRecord extends ResolvedCampaignTouch {\n kind: CampaignConversionKind\n /** The submission, lead, contact or booking this credits. */\n refId: string\n /** When the visitor became identifiable. */\n convertedAtMs: number\n /** The model this credit was decided under. */\n model: string\n /** The window, in days, it was decided inside. */\n windowDays: number\n}\n\n/**\n * The document id for one conversion.\n *\n * `{kind}:{refId}`, so the same submission credited twice is the same\n * document — which is what makes {@link attributeCampaignConversion}'s\n * `create()` idempotent. Scoped by kind because the ids come from different\n * collections and a booking id has no reason to be distinct from a form\n * submission's.\n */\nexport function campaignConversionId(\n kind: CampaignConversionKind,\n refId: string,\n): string {\n return `${kind}:${refId}`\n}\n\n/**\n * The campaign touch to credit a conversion happening now, or `null`.\n *\n * Called ONCE per conversion request, at the door, and the result handed to\n * every writer beneath it. See the module docblock for the cost argument.\n *\n * @returns the winning touch, or `null` when neither channel has one inside\n * the window — which is the ordinary case and writes nothing.\n */\nexport async function resolveCampaignTouch(\n options: {\n hostId: string\n /**\n * The wire form the conversion request carried, when the door reports\n * one. Re-parsed through the allowlist that wrote it rather than trusted:\n * it arrives from the visitor's browser, so it may claim no more than an\n * inbound URL could.\n */\n wire?: unknown\n /** The address the visitor identified with, raw. */\n email?: unknown\n /** When they identified themselves. Defaults to now. */\n atMs?: number\n },\n firestore?: any,\n): Promise<ResolvedCampaignTouch | null> {\n try {\n const hostId = String(options.hostId ?? '')\n if (!isDocumentId(hostId)) return null\n const convertedAtMs = Number(options.atMs ?? Date.now())\n if (!Number.isFinite(convertedAtMs) || convertedAtMs <= 0) return null\n\n // The window is enforced inside the parser, so an expired or\n // future-dated wire value answers null here exactly as it does in the\n // browser that decided whether to send it.\n const web: CampaignTouch | null = parseCampaignTouch(\n options.wire,\n convertedAtMs,\n )\n\n const key = personKey(options.email)\n /*\n * The email channel is only askable of somebody who named an address, and\n * that is the whole reason this join needs the web channel at all. No\n * address means no keyed read: the ordinary anonymous conversion costs\n * nothing here.\n */\n const emailTouch = key\n ? await readEmailCampaignTouch(\n String(options.email ?? ''),\n hostId,\n firestore ?? defaultFirestore(),\n )\n : null\n const emailInWindow =\n emailTouch &&\n isDocumentId(emailTouch.campaignId) &&\n emailTouchIsInWindow(emailTouch.clickedAtMs, convertedAtMs)\n ? emailTouch\n : null\n\n /*\n * LAST TOUCH decides, and a tie goes to email. A tie is only reachable\n * when the two instants are the same millisecond, and in that case the\n * email touch is the better evidence: it was recorded by the provider's\n * own click event on the server, while the web touch is a value the\n * visitor's device supplied.\n */\n const emailWins =\n emailInWindow && (!web || emailInWindow.clickedAtMs >= web.atMs)\n if (emailWins) {\n const viaSequence = emailInWindow.sequenceId && emailInWindow.enrollmentId\n return {\n channel: viaSequence ? 'sequence' : 'email',\n campaignId: emailInWindow.campaignId,\n ...(viaSequence\n ? { sequenceId: emailInWindow.sequenceId, enrollmentId: emailInWindow.enrollmentId }\n : {}),\n touchedAtMs: emailInWindow.clickedAtMs,\n ...(key ? { personKey: key } : {}),\n }\n }\n if (!web) return null\n return {\n channel: 'web',\n ...(web.source ? { source: web.source } : {}),\n ...(web.medium ? { medium: web.medium } : {}),\n ...(web.campaign ? { campaign: web.campaign } : {}),\n touchedAtMs: web.atMs,\n ...(key ? { personKey: key } : {}),\n }\n } catch (error) {\n console.error('resolveCampaignTouch failed', error)\n return null\n }\n}\n\n/**\n * Credits one conversion to the campaign the visitor last came from.\n *\n * ## Why the record is a document and not a field\n *\n * The revenue join's three reasons, and the first is decisive here too.\n * `create()` fails when the document already exists, which is exact\n * idempotency for free: a retried form POST, a redelivered booking webhook\n * and a re-run capture all land here a second time and must all leave the\n * rollup where they found it. Second, the four kinds live in four different\n * collections — a contact is org-scoped and shared across every site in the\n * org — so there is no one document a field could go on. Third, the\n * submission, the lead and the booking are read by the inbox, the export and\n * the fulfilment path, and none of them wants a field about marketing.\n *\n * ## The rollup exists for the EMAIL channel only\n *\n * A campaign document is a real entity with a real id, so its conversions\n * roll up under it beside the revenue the same join already credits it with.\n * A web campaign is a LABEL the marketer typed into a URL, with no document,\n * no id and no bound on how many distinct values exist — the same unbounded\n * key space the analytics collector caps its per-day label map against. A\n * rollup keyed on it would be a map anybody who can vary a query string can\n * grow. So the web channel's records stand on their own and a report reads\n * them; see the module's report seam note.\n *\n * @returns the record written, or `null` when nothing was credited.\n */\nexport async function attributeCampaignConversion(\n options: {\n hostId: string\n kind: CampaignConversionKind\n /** The submission, lead, contact or booking being credited. */\n refId: string\n /** The touch {@link resolveCampaignTouch} picked, or `null` for direct. */\n touch: ResolvedCampaignTouch | null | undefined\n /** When the visitor became identifiable. Defaults to now. */\n convertedAtMs?: number\n },\n firestore?: any,\n): Promise<CampaignConversionRecord | null> {\n try {\n const { touch } = options\n if (!touch) return null\n const hostId = String(options.hostId ?? '')\n const refId = String(options.refId ?? '')\n if (!isDocumentId(hostId) || !isDocumentId(refId)) return null\n const convertedAtMs = Number(options.convertedAtMs ?? Date.now())\n if (!Number.isFinite(convertedAtMs) || convertedAtMs <= 0) return null\n // Re-checked at the write rather than trusted from the resolve. The two\n // are separated by the writes that produced the thing being credited, and\n // a touch that has aged out between them is a touch outside the window.\n if (!emailTouchIsInWindow(touch.touchedAtMs, convertedAtMs)) return null\n\n const record: CampaignConversionRecord = {\n kind: options.kind,\n refId,\n channel: touch.channel,\n ...(touch.campaignId ? { campaignId: touch.campaignId } : {}),\n ...(touch.sequenceId ? { sequenceId: touch.sequenceId } : {}),\n ...(touch.enrollmentId ? { enrollmentId: touch.enrollmentId } : {}),\n ...(touch.source ? { source: touch.source } : {}),\n ...(touch.medium ? { medium: touch.medium } : {}),\n ...(touch.campaign ? { campaign: touch.campaign } : {}),\n touchedAtMs: touch.touchedAtMs,\n convertedAtMs,\n model: ATTRIBUTION_MODEL,\n windowDays: ATTRIBUTION_WINDOW_DAYS,\n ...(touch.personKey ? { personKey: touch.personKey } : {}),\n }\n\n const db = firestore ?? defaultFirestore()\n const hostRef = db.collection('hosts').doc(hostId)\n /*\n * `create()`, never `set()` — the revenue join's argument, unchanged. The\n * ALREADY_EXISTS failure IS the idempotency, and the rollup below is\n * reached only when the create succeeded, so the two can never disagree\n * about whether this conversion was counted.\n */\n try {\n await hostRef\n .collection(CAMPAIGN_ATTRIBUTIONS_COLLECTION)\n .doc(campaignConversionId(options.kind, refId))\n .create({ ...record, createdAt: FieldValue.serverTimestamp() })\n } catch {\n return null\n }\n\n /*\n * The send the touch names (`touch.campaignId` is a SEND id on the email\n * channel), wherever it is: the org's `campaigns` collection, or the\n * site's for a send the migration has not reached. A send in neither\n * place was discarded, and its rollup is skipped — the record above\n * already says what was credited.\n */\n const sendRef =\n touch.channel === 'email' && touch.campaignId && isDocumentId(touch.campaignId)\n ? await resolveCampaignSendRef({ hostId, sendId: touch.campaignId, firestore: db })\n : null\n if (sendRef) {\n /*\n * A merge-set that CREATES, for the reason `reports/revenue` gives: this\n * writes a document UNDER a campaign, so a campaign that no longer\n * exists gains an orphaned report rather than being resurrected as a\n * husk in the merchant's history — and the campaign was proven to exist\n * when its click wrote the touch.\n *\n * Every figure is an increment, so two conversions settling at once\n * both land.\n */\n await sendRef\n .collection('reports')\n .doc(CAMPAIGN_CONVERSIONS_REPORT_DOC)\n .set(\n {\n model: ATTRIBUTION_MODEL,\n windowDays: ATTRIBUTION_WINDOW_DAYS,\n byKind: { [options.kind]: FieldValue.increment(1) },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n }\n\n /*\n * A SEQUENCE touch rolls up under the campaign's sequences report, and\n * only a booking does: \"meetings booked from a sequence link\" is the one\n * identify moment a sequence's own outcomes do not already count. The\n * form, lead and contact kinds stand as records — visible on the record\n * they credit and on the site's conversions list — and never in\n * `byKind`, whose caveat promises the reader campaign emails only.\n */\n if (touch.channel === 'sequence' && touch.campaignId && options.kind === 'booking') {\n await creditCampaignSequenceOutcome(\n { hostId, campaignIds: [touch.campaignId], outcome: 'meetings', atMs: convertedAtMs },\n db,\n )\n }\n\n return record\n } catch (error) {\n console.error('attributeCampaignConversion failed', error)\n return null\n }\n}\n\n/*==========================================\n * WHAT A CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).\n *\n * A sequence joins a campaign the way a form does, and the Outreach runtime\n * credits what each enrollment produced — enrolled, first email sent, a\n * reply, a meeting booked from a sequence link, an enrolled lead converted\n * — to every campaign the sequence was in when the person was enrolled. The\n * counts live in `orgs/{orgId}/campaignSequenceReports/{campaignId}`, beside\n * the org's containers: one document per campaign, server-only like every\n * other report here, and never inside the campaign container, which the\n * history list reads.\n *\n * Idempotency is the CALLER's: a plugin credits an outcome once per\n * enrollment from a state change that happens once — the enrollment is\n * created once, its first email is the first, its status moves to replied\n * once, its lead converts once — so this writer is a plain increment and\n * keeps no per-enrollment record of its own.\n *=========================================*/\n\n/**\n * The per-org collection of sequence rollups. Restated from the reader\n * (`campaign-report.ts` in the campaigns UI library, which this package may\n * not import) and asserted equal by the reader's spec.\n */\nexport const CAMPAIGN_SEQUENCE_REPORTS_COLLECTION = 'campaignSequenceReports'\n\n/** The outcomes the runtime credits, in funnel order. */\nexport const CAMPAIGN_SEQUENCE_OUTCOMES = [\n 'enrolled',\n 'sent',\n 'replied',\n 'meetings',\n 'converted',\n] as const\n\nexport type CampaignSequenceOutcome = (typeof CAMPAIGN_SEQUENCE_OUTCOMES)[number]\n\n/**\n * Credits one outcome, for one enrollment, to every campaign named.\n *\n * Never throws, and never refuses the batch for one bad id: the outcome\n * has already happened, and a campaign that is gone gains an orphaned\n * report rather than blocking the credit to one that is not — the\n * conversions rollup's own argument for a merge-set that creates.\n *\n * @returns how many campaigns were credited.\n */\nexport async function creditCampaignSequenceOutcome(\n options: {\n /** The site the enrollment emails for; resolves the org when none is given. */\n hostId: string\n /**\n * The org whose containers the campaigns are, when the caller holds it —\n * an enrollment is an org document, so its routes do. Saves the\n * `hostIndex` read.\n */\n orgId?: string | null\n /** The campaigns the enrollment carries; unusable ids are skipped. */\n campaignIds: readonly string[]\n outcome: CampaignSequenceOutcome\n /** When the outcome happened. Defaults to now. */\n atMs?: number\n },\n firestore?: any,\n): Promise<number> {\n try {\n if (!(CAMPAIGN_SEQUENCE_OUTCOMES as readonly string[]).includes(options.outcome)) return 0\n const atMs = Number(options.atMs ?? Date.now())\n const ids = [...new Set(options.campaignIds.map((id) => String(id ?? '').trim()))].filter(\n (id) => isDocumentId(id),\n )\n if (!ids.length) return 0\n const hostId = String(options.hostId ?? '')\n const given = String(options.orgId ?? '')\n const orgId = isDocumentId(given)\n ? given\n : isDocumentId(hostId)\n ? await resolveOrgIdForHost(hostId)\n : null\n if (!orgId || !isDocumentId(orgId)) return 0\n const db = firestore ?? defaultFirestore()\n await Promise.all(\n ids.map((campaignId) =>\n orgCampaignSequenceReportRef(orgId, campaignId, db).set(\n {\n byOutcome: { [options.outcome]: FieldValue.increment(1) },\n ...(Number.isFinite(atMs) && atMs > 0 ? { updatedAtMs: atMs } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n ),\n ),\n )\n return ids.length\n } catch (error) {\n console.error('creditCampaignSequenceOutcome failed', error)\n return 0\n }\n}\n\n/*==========================================\n * WHERE A SEND IS (AGL-3273)\n *=========================================*/\n\n/**\n * WHERE A SEND IS, given only what a link or a delivery event carries.\n *\n * Campaign containers and sends belong to the organization\n * (`orgs/{orgId}/emailCampaigns`, `orgs/{orgId}/campaigns`, AGL-3273). Every\n * reader that starts from inside the product already holds the org id and\n * addresses those collections directly. This module is for the readers that\n * start OUTSIDE it — the unsubscribe link, the provider's delivery webhook,\n * the conversion join — which hold a site id and a send id and nothing else,\n * because that is what was signed into the link or tagged on the message\n * when it went out, possibly years ago.\n *\n * ## The fallback, and why it stays\n *\n * The site path (`hosts/{hostId}/campaigns/{sendId}`) is where every send\n * lived before the move, and the migration copies each one up under its\n * existing id. Between a deployment of this code and the migration reaching\n * an org, the send is still only at the site; and an interrupted migration\n * leaves some sends behind. A reader that looked only at the org would, in\n * that window, drop an unsubscribe's count or a bounce — and a bounce that\n * is dropped is an address that gets mailed again. So the org is asked\n * first and the site second. After the migration the second read never\n * happens, because the first one answers.\n *\n * The collection names are spelled here rather than imported from the\n * campaign model (`@aglyn/shared-ui-email-campaigns`), which this package\n * does not depend on. They are the same strings; `campaign-container.ts`\n * documents them. The join above is this lookup's first reader, and the two\n * move together when the campaign code leaves this package.\n */\n\nconst sendRefFirestore = (): Firestore => firebaseAdmin.app().firestore()\n\n/** `orgs/{orgId}/campaigns` — one document per email send. */\nconst ORG_SENDS = 'campaigns'\n\n/** `orgs/{orgId}/campaignSequenceReports` — keyed by the container id. */\nconst ORG_SEQUENCE_REPORTS = 'campaignSequenceReports'\n\n/** `orgs/{orgId}/campaigns/{sendId}`. */\nexport function orgCampaignSendRef(\n orgId: string,\n sendId: string,\n firestore: Firestore = sendRefFirestore(),\n): DocumentReference {\n return firestore.collection('orgs').doc(orgId).collection(ORG_SENDS).doc(sendId)\n}\n\n/** `orgs/{orgId}/campaignSequenceReports/{campaignId}`. */\nexport function orgCampaignSequenceReportRef(\n orgId: string,\n campaignId: string,\n firestore: Firestore = sendRefFirestore(),\n): DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(ORG_SEQUENCE_REPORTS)\n .doc(campaignId)\n}\n\n/** The pre-AGL-3273 location, read only as the fallback described above. */\nfunction legacySiteSendRef(\n hostId: string,\n sendId: string,\n firestore: Firestore,\n): DocumentReference {\n return firestore.collection('hosts').doc(hostId).collection(ORG_SENDS).doc(sendId)\n}\n\nexport interface ResolveCampaignSendRefInput {\n /** The site the link or the message tag names. */\n hostId: string\n /** The send id (`cid` on a link, `campaignId` on a message tag). */\n sendId: string\n /**\n * The org, when the caller already has it — a message tag minted since\n * AGL-3273 carries it. Saves the `hostIndex` read.\n */\n orgId?: string | null\n firestore?: Firestore\n}\n\n/**\n * The send's document, wherever it currently is. Null when neither location\n * holds it — a send id from a link whose send was discarded, or a forged one.\n *\n * Costs one read after the migration (the org document), plus the\n * `hostIndex` lookup when the caller has no org id; that lookup is\n * request-cached.\n */\nexport async function resolveCampaignSendRef(\n input: ResolveCampaignSendRefInput,\n): Promise<DocumentReference | null> {\n const { hostId, sendId } = input\n if (!hostId || !sendId || sendId.includes('/') || hostId.includes('/')) return null\n const firestore = input.firestore ?? sendRefFirestore()\n const orgId = input.orgId || (await resolveOrgIdForHost(hostId))\n if (orgId) {\n const orgRef = orgCampaignSendRef(orgId, sendId, firestore)\n if ((await orgRef.get()).exists) return orgRef\n }\n const siteRef = legacySiteSendRef(hostId, sendId, firestore)\n if ((await siteRef.get()).exists) return siteRef\n return null\n}\n"],"names":["FieldValue","parseCampaignTouch","EMAIL_ATTRIBUTION_MODEL","ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","ATTRIBUTION_WINDOW_DAYS","emailTouchIsInWindow","personKey","CAMPAIGN_ATTRIBUTIONS_COLLECTION","eraseCampaignAttributionsForPersonKey","readEmailCampaignTouch","isDocumentId","firebaseAdmin","resolveOrgIdForHost","defaultFirestore","app","firestore","CAMPAIGN_CONVERSIONS_REPORT_DOC","campaignConversionId","kind","refId","resolveCampaignTouch","options","hostId","String","convertedAtMs","Number","atMs","Date","now","isFinite","web","wire","key","email","emailTouch","emailInWindow","campaignId","clickedAtMs","emailWins","viaSequence","sequenceId","enrollmentId","channel","touchedAtMs","source","medium","campaign","error","console","attributeCampaignConversion","touch","record","model","windowDays","db","hostRef","collection","doc","create","createdAt","serverTimestamp","sendRef","resolveCampaignSendRef","sendId","set","byKind","increment","updatedAt","merge","creditCampaignSequenceOutcome","campaignIds","outcome","CAMPAIGN_SEQUENCE_REPORTS_COLLECTION","CAMPAIGN_SEQUENCE_OUTCOMES","includes","ids","Set","map","id","trim","filter","length","given","orgId","Promise","all","orgCampaignSequenceReportRef","byOutcome","updatedAtMs","sendRefFirestore","ORG_SENDS","ORG_SEQUENCE_REPORTS","orgCampaignSendRef","legacySiteSendRef","input","orgRef","get","exists","siteRef"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/campaign-conversion-attribution.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 { FieldValue, type DocumentReference, type Firestore } from 'firebase-admin/firestore'\nimport {\n parseUtmTouch,\n type UtmTouch,\n} from '@aglyn/aglyn/app-utils/utm-touch'\n/*\n * Stamped from the REVENUE join's constants, aliased at the import.\n *\n * They are spelled `EMAIL_` because that is where both halves of that join\n * could reach them, not because the window is an email fact — it is the\n * platform's one answer to how long a touch may be credited. Reading them\n * from there is what makes a lead's record and an order's record carry the\n * same rule rather than two that happen to agree.\n */\nimport {\n EMAIL_ATTRIBUTION_MODEL as ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS as ATTRIBUTION_WINDOW_DAYS,\n emailTouchIsInWindow,\n} from '@aglyn/shared-util-email/email-revenue-window'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n CAMPAIGN_ATTRIBUTIONS_COLLECTION,\n eraseCampaignAttributionsForPersonKey,\n} from './campaign-attribution-store'\nimport { readEmailCampaignTouch } from './email-delivery-log'\nimport { isDocumentId } from './document-id'\nimport firebaseAdmin from './firebase-admin'\nimport { resolveOrgIdForHost } from './organizations'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/**\n * THE IDENTIFY-MOMENT JOIN — a form, a lead, a contact or a booking, credited\n * to the campaign that led to it.\n *\n * ## Why there is a second join at all, and why it is not a second SCHEME\n *\n * `email-revenue-attribution.ts` credits an ORDER by looking the buyer's\n * address up in the touch map a click wrote. That works because an order\n * names its buyer. Every other outcome a campaign causes is produced by\n * somebody who was anonymous until the instant they produced it: they arrive\n * from a campaign link, browse, and only become identifiable when they submit\n * a form, sign up, book or check out. There is no address to look up until\n * the very moment being attributed.\n *\n * So the touch is carried on the VISITOR (`utm-touch.ts`) and attached\n * here, at each moment they become somebody. What is NOT rebuilt is the\n * model: the same seven-day window, the same last-click rule, the same\n * `model`/`windowDays` stamped onto every record so a report can say what it\n * counted. The email channel is not re-derived either — it is READ from the\n * touch the delivery webhook already wrote, so a campaign email and a\n * campaign ad are two inputs to one comparison rather than two schemes with\n * two answers.\n *\n * ## Last touch, across both channels\n *\n * At the identify moment there can be two candidate touches:\n *\n * - the WEB touch the visitor's device carried, from an ad, a partner link\n * or a social post; and\n * - the EMAIL touch on `emailDeliveries/{personKey}.campaignTouches[hostId]`,\n * written server-side when they clicked a campaign's mail.\n *\n * Both are window-checked and the LATER one wins. Splitting the outcome\n * between them is the multi-touch model the revenue work rejected for the\n * reason that applies here unchanged: a rule the merchant did not choose\n * produces a figure nobody can check. One outcome, one campaign, and a rule\n * stated on the record.\n *\n * ## No campaign touch means NO RECORD\n *\n * Direct traffic writes nothing at all, and there is deliberately no fallback\n * — no \"most recent campaign on this site\", no referrer inference, no\n * `utm_source=direct`. A conversion nobody can be credited with is a\n * conversion nobody is credited with, and the absence of a record is how the\n * report says so. The cost of a miss is one uncounted outcome; the cost of a\n * guess is a campaign that reads as effective because it was the last one to\n * run.\n *\n * ## Three reads at most, and usually one\n *\n * Resolving a touch costs ONE keyed document read, and only when the visitor\n * gave an address — no query, no index, nothing that can be truncated. A door\n * resolves once per request and hands the result to every writer beneath it,\n * so a form submission that creates a submission, a contact and a lead pays\n * for the lookup once rather than three times.\n *\n * ## Never throws\n *\n * The same contract as the revenue join and for the same reason: the visitor\n * has already done the thing being attributed and the record of it is already\n * written. A lost attribution understates a campaign; a thrown one loses a\n * lead.\n */\n\n/**\n * The collection and the erasure sweep, re-exported so a caller needs one\n * import for the whole join. They are DEFINED in a leaf module because the\n * sweep runs from `email-delivery-log.ts`, which this file reads — see\n * `campaign-attribution-store.ts` for why that cycle is not merely untidy.\n */\nexport {\n CAMPAIGN_ATTRIBUTIONS_COLLECTION,\n eraseCampaignAttributionsForPersonKey,\n}\n\n/** The single conversion rollup document under an email campaign. */\nexport const CAMPAIGN_CONVERSIONS_REPORT_DOC = 'conversions'\n\n/**\n * Which identify moment a record credits.\n *\n * Kept apart rather than summed, which is the whole reason the kind is on the\n * record. One form submission by a new person creates a submission, a contact\n * and possibly a lead — three true statements about one visitor action — and\n * a report that added them would treble every campaign's conversions. A\n * reader asks for one kind at a time, exactly as the revenue rollup buckets\n * by currency and never totals across them.\n */\nexport type CampaignConversionKind = 'form' | 'lead' | 'contact' | 'booking'\n\n/**\n * Which channel the credited touch arrived through.\n *\n * `sequence` (AGL-3254) is a click on, or the sending of, a one-to-one\n * email a rep's sequence sent. It is read off the same touch map as the\n * email channel — the Outreach click route stamps it there with the\n * sequence and the enrollment beside the campaign the sequence is in — so\n * a booking made from a sequence link is credited by the door that credits\n * every other booking, and nothing in this join has to know what a\n * sequence is.\n */\nexport type CampaignTouchChannel = 'email' | 'web' | 'sequence'\n\n/** The touch a conversion is credited to, once both channels have been asked. */\nexport interface ResolvedCampaignTouch {\n channel: CampaignTouchChannel\n /**\n * The campaign document, when the touch was a click on our own mail — or\n * the campaign CONTAINER (`emailCampaigns/{id}`) a sequence is in, for a\n * sequence touch.\n */\n campaignId?: string\n /** The sequence the touch came through, for a `sequence` touch. */\n sequenceId?: string\n /** The enrollment the touch came through, for a `sequence` touch. */\n enrollmentId?: string\n /** `utm_source`, when the touch was a link on the web. */\n source?: string\n /** `utm_medium`, when the touch was a link on the web. */\n medium?: string\n /** `utm_campaign`, when the touch was a link on the web. */\n campaign?: string\n /** When the visitor followed the campaign link, epoch ms. */\n touchedAtMs: number\n /**\n * `sha256` of the normalized address the touch was resolved against, when\n * the visitor gave one. The handle an erasure erases by — see\n * {@link eraseCampaignAttributionsForPersonKey}.\n */\n personKey?: string\n}\n\n/** What one conversion attribution record holds. */\nexport interface CampaignConversionRecord extends ResolvedCampaignTouch {\n kind: CampaignConversionKind\n /** The submission, lead, contact or booking this credits. */\n refId: string\n /** When the visitor became identifiable. */\n convertedAtMs: number\n /** The model this credit was decided under. */\n model: string\n /** The window, in days, it was decided inside. */\n windowDays: number\n}\n\n/**\n * The document id for one conversion.\n *\n * `{kind}:{refId}`, so the same submission credited twice is the same\n * document — which is what makes {@link attributeCampaignConversion}'s\n * `create()` idempotent. Scoped by kind because the ids come from different\n * collections and a booking id has no reason to be distinct from a form\n * submission's.\n */\nexport function campaignConversionId(\n kind: CampaignConversionKind,\n refId: string,\n): string {\n return `${kind}:${refId}`\n}\n\n/**\n * The campaign touch to credit a conversion happening now, or `null`.\n *\n * Called ONCE per conversion request, at the door, and the result handed to\n * every writer beneath it. See the module docblock for the cost argument.\n *\n * @returns the winning touch, or `null` when neither channel has one inside\n * the window — which is the ordinary case and writes nothing.\n */\nexport async function resolveCampaignTouch(\n options: {\n hostId: string\n /**\n * The wire form the conversion request carried, when the door reports\n * one. Re-parsed through the allowlist that wrote it rather than trusted:\n * it arrives from the visitor's browser, so it may claim no more than an\n * inbound URL could.\n */\n wire?: unknown\n /** The address the visitor identified with, raw. */\n email?: unknown\n /** When they identified themselves. Defaults to now. */\n atMs?: number\n },\n firestore?: any,\n): Promise<ResolvedCampaignTouch | null> {\n try {\n const hostId = String(options.hostId ?? '')\n if (!isDocumentId(hostId)) return null\n const convertedAtMs = Number(options.atMs ?? Date.now())\n if (!Number.isFinite(convertedAtMs) || convertedAtMs <= 0) return null\n\n // The window is enforced inside the parser, so an expired or\n // future-dated wire value answers null here exactly as it does in the\n // browser that decided whether to send it.\n const web: UtmTouch | null = parseUtmTouch(\n options.wire,\n convertedAtMs,\n )\n\n const key = personKey(options.email)\n /*\n * The email channel is only askable of somebody who named an address, and\n * that is the whole reason this join needs the web channel at all. No\n * address means no keyed read: the ordinary anonymous conversion costs\n * nothing here.\n */\n const emailTouch = key\n ? await readEmailCampaignTouch(\n String(options.email ?? ''),\n hostId,\n firestore ?? defaultFirestore(),\n )\n : null\n const emailInWindow =\n emailTouch &&\n isDocumentId(emailTouch.campaignId) &&\n emailTouchIsInWindow(emailTouch.clickedAtMs, convertedAtMs)\n ? emailTouch\n : null\n\n /*\n * LAST TOUCH decides, and a tie goes to email. A tie is only reachable\n * when the two instants are the same millisecond, and in that case the\n * email touch is the better evidence: it was recorded by the provider's\n * own click event on the server, while the web touch is a value the\n * visitor's device supplied.\n */\n const emailWins =\n emailInWindow && (!web || emailInWindow.clickedAtMs >= web.atMs)\n if (emailWins) {\n const viaSequence = emailInWindow.sequenceId && emailInWindow.enrollmentId\n return {\n channel: viaSequence ? 'sequence' : 'email',\n campaignId: emailInWindow.campaignId,\n ...(viaSequence\n ? { sequenceId: emailInWindow.sequenceId, enrollmentId: emailInWindow.enrollmentId }\n : {}),\n touchedAtMs: emailInWindow.clickedAtMs,\n ...(key ? { personKey: key } : {}),\n }\n }\n if (!web) return null\n return {\n channel: 'web',\n ...(web.source ? { source: web.source } : {}),\n ...(web.medium ? { medium: web.medium } : {}),\n ...(web.campaign ? { campaign: web.campaign } : {}),\n touchedAtMs: web.atMs,\n ...(key ? { personKey: key } : {}),\n }\n } catch (error) {\n console.error('resolveCampaignTouch failed', error)\n return null\n }\n}\n\n/**\n * Credits one conversion to the campaign the visitor last came from.\n *\n * ## Why the record is a document and not a field\n *\n * The revenue join's three reasons, and the first is decisive here too.\n * `create()` fails when the document already exists, which is exact\n * idempotency for free: a retried form POST, a redelivered booking webhook\n * and a re-run capture all land here a second time and must all leave the\n * rollup where they found it. Second, the four kinds live in four different\n * collections — a contact is org-scoped and shared across every site in the\n * org — so there is no one document a field could go on. Third, the\n * submission, the lead and the booking are read by the inbox, the export and\n * the fulfilment path, and none of them wants a field about marketing.\n *\n * ## The rollup exists for the EMAIL channel only\n *\n * A campaign document is a real entity with a real id, so its conversions\n * roll up under it beside the revenue the same join already credits it with.\n * A web campaign is a LABEL the marketer typed into a URL, with no document,\n * no id and no bound on how many distinct values exist — the same unbounded\n * key space the analytics collector caps its per-day label map against. A\n * rollup keyed on it would be a map anybody who can vary a query string can\n * grow. So the web channel's records stand on their own and a report reads\n * them; see the module's report seam note.\n *\n * @returns the record written, or `null` when nothing was credited.\n */\nexport async function attributeCampaignConversion(\n options: {\n hostId: string\n kind: CampaignConversionKind\n /** The submission, lead, contact or booking being credited. */\n refId: string\n /** The touch {@link resolveCampaignTouch} picked, or `null` for direct. */\n touch: ResolvedCampaignTouch | null | undefined\n /** When the visitor became identifiable. Defaults to now. */\n convertedAtMs?: number\n },\n firestore?: any,\n): Promise<CampaignConversionRecord | null> {\n try {\n const { touch } = options\n if (!touch) return null\n const hostId = String(options.hostId ?? '')\n const refId = String(options.refId ?? '')\n if (!isDocumentId(hostId) || !isDocumentId(refId)) return null\n const convertedAtMs = Number(options.convertedAtMs ?? Date.now())\n if (!Number.isFinite(convertedAtMs) || convertedAtMs <= 0) return null\n // Re-checked at the write rather than trusted from the resolve. The two\n // are separated by the writes that produced the thing being credited, and\n // a touch that has aged out between them is a touch outside the window.\n if (!emailTouchIsInWindow(touch.touchedAtMs, convertedAtMs)) return null\n\n const record: CampaignConversionRecord = {\n kind: options.kind,\n refId,\n channel: touch.channel,\n ...(touch.campaignId ? { campaignId: touch.campaignId } : {}),\n ...(touch.sequenceId ? { sequenceId: touch.sequenceId } : {}),\n ...(touch.enrollmentId ? { enrollmentId: touch.enrollmentId } : {}),\n ...(touch.source ? { source: touch.source } : {}),\n ...(touch.medium ? { medium: touch.medium } : {}),\n ...(touch.campaign ? { campaign: touch.campaign } : {}),\n touchedAtMs: touch.touchedAtMs,\n convertedAtMs,\n model: ATTRIBUTION_MODEL,\n windowDays: ATTRIBUTION_WINDOW_DAYS,\n ...(touch.personKey ? { personKey: touch.personKey } : {}),\n }\n\n const db = firestore ?? defaultFirestore()\n const hostRef = db.collection('hosts').doc(hostId)\n /*\n * `create()`, never `set()` — the revenue join's argument, unchanged. The\n * ALREADY_EXISTS failure IS the idempotency, and the rollup below is\n * reached only when the create succeeded, so the two can never disagree\n * about whether this conversion was counted.\n */\n try {\n await hostRef\n .collection(CAMPAIGN_ATTRIBUTIONS_COLLECTION)\n .doc(campaignConversionId(options.kind, refId))\n .create({ ...record, createdAt: FieldValue.serverTimestamp() })\n } catch {\n return null\n }\n\n /*\n * The send the touch names (`touch.campaignId` is a SEND id on the email\n * channel), wherever it is: the org's `campaigns` collection, or the\n * site's for a send the migration has not reached. A send in neither\n * place was discarded, and its rollup is skipped — the record above\n * already says what was credited.\n */\n const sendRef =\n touch.channel === 'email' && touch.campaignId && isDocumentId(touch.campaignId)\n ? await resolveCampaignSendRef({ hostId, sendId: touch.campaignId, firestore: db })\n : null\n if (sendRef) {\n /*\n * A merge-set that CREATES, for the reason `reports/revenue` gives: this\n * writes a document UNDER a campaign, so a campaign that no longer\n * exists gains an orphaned report rather than being resurrected as a\n * husk in the merchant's history — and the campaign was proven to exist\n * when its click wrote the touch.\n *\n * Every figure is an increment, so two conversions settling at once\n * both land.\n */\n await sendRef\n .collection('reports')\n .doc(CAMPAIGN_CONVERSIONS_REPORT_DOC)\n .set(\n {\n model: ATTRIBUTION_MODEL,\n windowDays: ATTRIBUTION_WINDOW_DAYS,\n byKind: { [options.kind]: FieldValue.increment(1) },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n }\n\n /*\n * A SEQUENCE touch rolls up under the campaign's sequences report, and\n * only a booking does: \"meetings booked from a sequence link\" is the one\n * identify moment a sequence's own outcomes do not already count. The\n * form, lead and contact kinds stand as records — visible on the record\n * they credit and on the site's conversions list — and never in\n * `byKind`, whose caveat promises the reader campaign emails only.\n */\n if (touch.channel === 'sequence' && touch.campaignId && options.kind === 'booking') {\n await creditCampaignSequenceOutcome(\n { hostId, campaignIds: [touch.campaignId], outcome: 'meetings', atMs: convertedAtMs },\n db,\n )\n }\n\n return record\n } catch (error) {\n console.error('attributeCampaignConversion failed', error)\n return null\n }\n}\n\n/*==========================================\n * WHAT A CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).\n *\n * A sequence joins a campaign the way a form does, and the Outreach runtime\n * credits what each enrollment produced — enrolled, first email sent, a\n * reply, a meeting booked from a sequence link, an enrolled lead converted\n * — to every campaign the sequence was in when the person was enrolled. The\n * counts live in `orgs/{orgId}/campaignSequenceReports/{campaignId}`, beside\n * the org's containers: one document per campaign, server-only like every\n * other report here, and never inside the campaign container, which the\n * history list reads.\n *\n * Idempotency is the CALLER's: a plugin credits an outcome once per\n * enrollment from a state change that happens once — the enrollment is\n * created once, its first email is the first, its status moves to replied\n * once, its lead converts once — so this writer is a plain increment and\n * keeps no per-enrollment record of its own.\n *=========================================*/\n\n/**\n * The per-org collection of sequence rollups. Restated from the reader\n * (`campaign-report.ts` in the campaigns UI library, which this package may\n * not import) and asserted equal by the reader's spec.\n */\nexport const CAMPAIGN_SEQUENCE_REPORTS_COLLECTION = 'campaignSequenceReports'\n\n/** The outcomes the runtime credits, in funnel order. */\nexport const CAMPAIGN_SEQUENCE_OUTCOMES = [\n 'enrolled',\n 'sent',\n 'replied',\n 'meetings',\n 'converted',\n] as const\n\nexport type CampaignSequenceOutcome = (typeof CAMPAIGN_SEQUENCE_OUTCOMES)[number]\n\n/**\n * Credits one outcome, for one enrollment, to every campaign named.\n *\n * Never throws, and never refuses the batch for one bad id: the outcome\n * has already happened, and a campaign that is gone gains an orphaned\n * report rather than blocking the credit to one that is not — the\n * conversions rollup's own argument for a merge-set that creates.\n *\n * @returns how many campaigns were credited.\n */\nexport async function creditCampaignSequenceOutcome(\n options: {\n /** The site the enrollment emails for; resolves the org when none is given. */\n hostId: string\n /**\n * The org whose containers the campaigns are, when the caller holds it —\n * an enrollment is an org document, so its routes do. Saves the\n * `hostIndex` read.\n */\n orgId?: string | null\n /** The campaigns the enrollment carries; unusable ids are skipped. */\n campaignIds: readonly string[]\n outcome: CampaignSequenceOutcome\n /** When the outcome happened. Defaults to now. */\n atMs?: number\n },\n firestore?: any,\n): Promise<number> {\n try {\n if (!(CAMPAIGN_SEQUENCE_OUTCOMES as readonly string[]).includes(options.outcome)) return 0\n const atMs = Number(options.atMs ?? Date.now())\n const ids = [...new Set(options.campaignIds.map((id) => String(id ?? '').trim()))].filter(\n (id) => isDocumentId(id),\n )\n if (!ids.length) return 0\n const hostId = String(options.hostId ?? '')\n const given = String(options.orgId ?? '')\n const orgId = isDocumentId(given)\n ? given\n : isDocumentId(hostId)\n ? await resolveOrgIdForHost(hostId)\n : null\n if (!orgId || !isDocumentId(orgId)) return 0\n const db = firestore ?? defaultFirestore()\n await Promise.all(\n ids.map((campaignId) =>\n orgCampaignSequenceReportRef(orgId, campaignId, db).set(\n {\n byOutcome: { [options.outcome]: FieldValue.increment(1) },\n ...(Number.isFinite(atMs) && atMs > 0 ? { updatedAtMs: atMs } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n ),\n ),\n )\n return ids.length\n } catch (error) {\n console.error('creditCampaignSequenceOutcome failed', error)\n return 0\n }\n}\n\n/*==========================================\n * WHERE A SEND IS (AGL-3273)\n *=========================================*/\n\n/**\n * WHERE A SEND IS, given only what a link or a delivery event carries.\n *\n * Campaign containers and sends belong to the organization\n * (`orgs/{orgId}/emailCampaigns`, `orgs/{orgId}/campaigns`, AGL-3273). Every\n * reader that starts from inside the product already holds the org id and\n * addresses those collections directly. This module is for the readers that\n * start OUTSIDE it — the unsubscribe link, the provider's delivery webhook,\n * the conversion join — which hold a site id and a send id and nothing else,\n * because that is what was signed into the link or tagged on the message\n * when it went out, possibly years ago.\n *\n * ## The fallback, and why it stays\n *\n * The site path (`hosts/{hostId}/campaigns/{sendId}`) is where every send\n * lived before the move, and the migration copies each one up under its\n * existing id. Between a deployment of this code and the migration reaching\n * an org, the send is still only at the site; and an interrupted migration\n * leaves some sends behind. A reader that looked only at the org would, in\n * that window, drop an unsubscribe's count or a bounce — and a bounce that\n * is dropped is an address that gets mailed again. So the org is asked\n * first and the site second. After the migration the second read never\n * happens, because the first one answers.\n *\n * The collection names are spelled here rather than imported from the\n * campaign model (`@aglyn/shared-ui-email-campaigns`), which this package\n * does not depend on. They are the same strings; `campaign-container.ts`\n * documents them. The join above is this lookup's first reader, and the two\n * move together when the campaign code leaves this package.\n */\n\nconst sendRefFirestore = (): Firestore => firebaseAdmin.app().firestore()\n\n/** `orgs/{orgId}/campaigns` — one document per email send. */\nconst ORG_SENDS = 'campaigns'\n\n/** `orgs/{orgId}/campaignSequenceReports` — keyed by the container id. */\nconst ORG_SEQUENCE_REPORTS = 'campaignSequenceReports'\n\n/** `orgs/{orgId}/campaigns/{sendId}`. */\nexport function orgCampaignSendRef(\n orgId: string,\n sendId: string,\n firestore: Firestore = sendRefFirestore(),\n): DocumentReference {\n return firestore.collection('orgs').doc(orgId).collection(ORG_SENDS).doc(sendId)\n}\n\n/** `orgs/{orgId}/campaignSequenceReports/{campaignId}`. */\nexport function orgCampaignSequenceReportRef(\n orgId: string,\n campaignId: string,\n firestore: Firestore = sendRefFirestore(),\n): DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(ORG_SEQUENCE_REPORTS)\n .doc(campaignId)\n}\n\n/** The pre-AGL-3273 location, read only as the fallback described above. */\nfunction legacySiteSendRef(\n hostId: string,\n sendId: string,\n firestore: Firestore,\n): DocumentReference {\n return firestore.collection('hosts').doc(hostId).collection(ORG_SENDS).doc(sendId)\n}\n\nexport interface ResolveCampaignSendRefInput {\n /** The site the link or the message tag names. */\n hostId: string\n /** The send id (`cid` on a link, `campaignId` on a message tag). */\n sendId: string\n /**\n * The org, when the caller already has it — a message tag minted since\n * AGL-3273 carries it. Saves the `hostIndex` read.\n */\n orgId?: string | null\n firestore?: Firestore\n}\n\n/**\n * The send's document, wherever it currently is. Null when neither location\n * holds it — a send id from a link whose send was discarded, or a forged one.\n *\n * Costs one read after the migration (the org document), plus the\n * `hostIndex` lookup when the caller has no org id; that lookup is\n * request-cached.\n */\nexport async function resolveCampaignSendRef(\n input: ResolveCampaignSendRefInput,\n): Promise<DocumentReference | null> {\n const { hostId, sendId } = input\n if (!hostId || !sendId || sendId.includes('/') || hostId.includes('/')) return null\n const firestore = input.firestore ?? sendRefFirestore()\n const orgId = input.orgId || (await resolveOrgIdForHost(hostId))\n if (orgId) {\n const orgRef = orgCampaignSendRef(orgId, sendId, firestore)\n if ((await orgRef.get()).exists) return orgRef\n }\n const siteRef = legacySiteSendRef(hostId, sendId, firestore)\n if ((await siteRef.get()).exists) return siteRef\n return null\n}\n"],"names":["FieldValue","parseUtmTouch","EMAIL_ATTRIBUTION_MODEL","ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","ATTRIBUTION_WINDOW_DAYS","emailTouchIsInWindow","personKey","CAMPAIGN_ATTRIBUTIONS_COLLECTION","eraseCampaignAttributionsForPersonKey","readEmailCampaignTouch","isDocumentId","firebaseAdmin","resolveOrgIdForHost","defaultFirestore","app","firestore","CAMPAIGN_CONVERSIONS_REPORT_DOC","campaignConversionId","kind","refId","resolveCampaignTouch","options","hostId","String","convertedAtMs","Number","atMs","Date","now","isFinite","web","wire","key","email","emailTouch","emailInWindow","campaignId","clickedAtMs","emailWins","viaSequence","sequenceId","enrollmentId","channel","touchedAtMs","source","medium","campaign","error","console","attributeCampaignConversion","touch","record","model","windowDays","db","hostRef","collection","doc","create","createdAt","serverTimestamp","sendRef","resolveCampaignSendRef","sendId","set","byKind","increment","updatedAt","merge","creditCampaignSequenceOutcome","campaignIds","outcome","CAMPAIGN_SEQUENCE_REPORTS_COLLECTION","CAMPAIGN_SEQUENCE_OUTCOMES","includes","ids","Set","map","id","trim","filter","length","given","orgId","Promise","all","orgCampaignSequenceReportRef","byOutcome","updatedAtMs","sendRefFirestore","ORG_SENDS","ORG_SEQUENCE_REPORTS","orgCampaignSendRef","legacySiteSendRef","input","orgRef","get","exists","siteRef"],"mappings":"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"}
|
|
@@ -38,6 +38,13 @@ export interface CardPaymentVelocityOptions {
|
|
|
38
38
|
}
|
|
39
39
|
/** The abuse-queue row id for a site's alarm on one day. Hex, like every row. */
|
|
40
40
|
export declare function cardPaymentAlarmReviewId(hostId: string, day: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* What the alarm is about, in the owner's words:
|
|
43
|
+
* `checkout on the site "Harbor View"`. The label rides on the row, so the
|
|
44
|
+
* review's closing notice and the staff alert name the site too — a
|
|
45
|
+
* workspace with several stores reads which orders to look at. Pure.
|
|
46
|
+
*/
|
|
47
|
+
export declare function cardTestingItemLabel(siteName: string | null | undefined): string;
|
|
41
48
|
/**
|
|
42
49
|
* Tell staff and the site's managers and the workspace's owners that a site
|
|
43
50
|
* crossed the card-payment window (AGL-3363), through the risk notice seam
|
|
@@ -50,6 +57,8 @@ export declare function notifyCardTestingVelocity(input: {
|
|
|
50
57
|
orgId: string | null;
|
|
51
58
|
reference: string;
|
|
52
59
|
reviewId?: string | null;
|
|
60
|
+
/** The site's display name, for the label. */
|
|
61
|
+
siteName?: string | null;
|
|
53
62
|
}, deps: {
|
|
54
63
|
notifyRisk: (input: RiskEventInput) => Promise<unknown>;
|
|
55
64
|
}): Promise<void>;
|
|
@@ -57,6 +57,31 @@ function refusal(resetMs, nowMs) {
|
|
|
57
57
|
/** The abuse-queue row id for a site's alarm on one day. Hex, like every row. */ export function cardPaymentAlarmReviewId(hostId, day) {
|
|
58
58
|
return createHash('sha256').update(`payment-velocity:${hostId}:${day}`).digest('hex').slice(0, 40);
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* What the alarm is about, in the owner's words:
|
|
62
|
+
* `checkout on the site "Harbor View"`. The label rides on the row, so the
|
|
63
|
+
* review's closing notice and the staff alert name the site too — a
|
|
64
|
+
* workspace with several stores reads which orders to look at. Pure.
|
|
65
|
+
*/ export function cardTestingItemLabel(siteName) {
|
|
66
|
+
const name = String(siteName != null ? siteName : '').trim();
|
|
67
|
+
return name ? `checkout on the site "${name.replace(/"/g, '”')}"` : 'checkout on your site';
|
|
68
|
+
}
|
|
69
|
+
/** The site's name for the label, from its document. Never throws. */ async function readSiteName(firestore, hostId) {
|
|
70
|
+
try {
|
|
71
|
+
var _host_get, _host_get1, _host_get2;
|
|
72
|
+
const host = await firestore.collection('hosts').doc(hostId).get();
|
|
73
|
+
for (const value of [
|
|
74
|
+
host == null ? void 0 : (_host_get = host.get) == null ? void 0 : _host_get.call(host, 'displayName'),
|
|
75
|
+
host == null ? void 0 : (_host_get1 = host.get) == null ? void 0 : _host_get1.call(host, 'name'),
|
|
76
|
+
host == null ? void 0 : (_host_get2 = host.get) == null ? void 0 : _host_get2.call(host, 'subdomain')
|
|
77
|
+
]){
|
|
78
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
79
|
+
}
|
|
80
|
+
} catch (unused) {
|
|
81
|
+
// The notice still goes, naming "your site".
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
60
85
|
/**
|
|
61
86
|
* Tell staff and the site's managers and the workspace's owners that a site
|
|
62
87
|
* crossed the card-payment window (AGL-3363), through the risk notice seam
|
|
@@ -72,7 +97,7 @@ function refusal(resetMs, nowMs) {
|
|
|
72
97
|
reviewId: (_input_reviewId = input.reviewId) != null ? _input_reviewId : null,
|
|
73
98
|
reference: input.reference,
|
|
74
99
|
item: {
|
|
75
|
-
label:
|
|
100
|
+
label: cardTestingItemLabel(input.siteName),
|
|
76
101
|
path: `/${input.hostId}/products/orders`
|
|
77
102
|
},
|
|
78
103
|
staffEvidence: 'An unusual number of card payments opened on one site.'
|
|
@@ -131,7 +156,8 @@ function refusal(resetMs, nowMs) {
|
|
|
131
156
|
hostId: input.hostId,
|
|
132
157
|
orgId: input.orgId,
|
|
133
158
|
reference,
|
|
134
|
-
reviewId
|
|
159
|
+
reviewId,
|
|
160
|
+
siteName: await readSiteName(input.firestore, input.hostId)
|
|
135
161
|
}, {
|
|
136
162
|
notifyRisk: input.notifyRisk
|
|
137
163
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/card-payment-velocity.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 * CARD-TESTING VELOCITY, THE DURABLE HALF (AGL-3363).\n *\n * The policy — the three counters, their numbers and why — is\n * `@aglyn/aglyn/app-utils/card-payment-velocity`. This counts them in the\n * shared durable store (`consumeRateLimit`) and answers the dispatcher the\n * way the visitor-write limiter beside it does: a `Response` to return, or\n * null to carry on. The tenant dispatcher calls it for a route registered\n * with `{ cardPayment: true }`, so every plugin's payment door meets it by\n * declaring itself, and none carries its own copy.\n *\n * The site counter refuses nothing. Crossing it files ONE urgent row per\n * site per day in the abuse queue (`source: 'payment-velocity'`), beside the\n * seller-pattern row, and tells staff once.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport {\n CARD_PAYMENT_VELOCITY,\n cardPaymentAddressKey,\n cardPaymentAlarmDay,\n cardPaymentSiteKey,\n cardPaymentVisitorKey,\n} from '@aglyn/aglyn/app-utils/card-payment-velocity'\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { consumeRateLimit } from './rate-limit-store'\nimport { notifyRiskEvent, type RiskEventInput } from './risk-notice'\n\nexport interface CardPaymentVelocityOptions {\n /** Dispatcher path, for the staff row. */\n path: string\n /** The site the payment is for; `''` when none resolved (still counted). */\n hostId: string\n /** The workspace owning it, when the dispatcher read one. */\n orgId?: string | null\n /** True for a workspace younger than the screen's young-workspace age. */\n young?: boolean\n /** Read for its client-address headers. */\n request: { headers?: { get?: (name: string) => unknown } }\n nowMs?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: unknown\n /** Injectable for tests; defaults to `notifyRiskEvent` (AGL-3368). */\n notifyRisk?: (input: RiskEventInput) => Promise<unknown>\n}\n\nfunction clientIp(request: CardPaymentVelocityOptions['request']): string {\n const headers = request?.headers\n if (typeof headers?.get !== 'function') return NO_CLIENT_ADDRESS_BUCKET\n return (\n readClientIp(headers as { get(name: string): string | null }) ??\n NO_CLIENT_ADDRESS_BUCKET\n )\n}\n\nfunction refusal(resetMs: number, nowMs: number): Response {\n // The same bare 429 as the visitor-write limiter: no remaining-count\n // headers for a stranger to pace a script by.\n return Response.json(\n { error: 'Too many payment attempts. Please wait a few minutes and try again.' },\n {\n status: 429,\n headers: {\n 'Retry-After': String(Math.max(1, Math.ceil((resetMs - nowMs) / 1000))),\n },\n },\n )\n}\n\n/** The abuse-queue row id for a site's alarm on one day. Hex, like every row. */\nexport function cardPaymentAlarmReviewId(hostId: string, day: string): string {\n return createHash('sha256')\n .update(`payment-velocity:${hostId}:${day}`)\n .digest('hex')\n .slice(0, 40)\n}\n\n/**\n * Tell staff and the site's managers and the workspace's owners that a site\n * crossed the card-payment window (AGL-3363), through the risk notice seam\n * (AGL-3368): the owners read the catalog's `card-testing` words — what was\n * seen and what to do, never the window or its numbers; staff get the row.\n * Never throws.\n */\nexport async function notifyCardTestingVelocity(\n input: { hostId: string; orgId: string | null; reference: string; reviewId?: string | null },\n deps: { notifyRisk: (input: RiskEventInput) => Promise<unknown> },\n): Promise<void> {\n await deps\n .notifyRisk({\n kind: 'card-testing',\n orgId: input.orgId,\n hostId: input.hostId,\n reviewId: input.reviewId ?? null,\n reference: input.reference,\n item: { label: 'your site', path: `/${input.hostId}/products/orders` },\n staffEvidence: 'An unusual number of card payments opened on one site.',\n })\n .catch(() => undefined)\n}\n\n/**\n * File (or count) the day's alarm row for a site whose payment doors crossed\n * the site counter, and tell staff the first time. Never throws: an alarm\n * that fails must not turn a shopper's checkout into a 500.\n */\nexport async function recordCardPaymentAlarm(input: {\n hostId: string\n orgId: string | null\n path: string\n young: boolean\n nowMs: number\n firestore: any\n notifyRisk: (input: RiskEventInput) => Promise<unknown>\n}): Promise<{ reviewId: string; first: boolean } | null> {\n try {\n const day = cardPaymentAlarmDay(input.nowMs)\n const reviewId = cardPaymentAlarmReviewId(input.hostId, day)\n const reference = `PV-${reviewId.slice(0, 10).toUpperCase()}`\n const ref = input.firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n const window = CARD_PAYMENT_VELOCITY.perSite\n const minutes = Math.round(window.windowMs / 60_000)\n await ref.set(\n {\n reference,\n // \"Phishing or fraud\" — the queue's urgent fraud category.\n category: 'phishing',\n severity: 'urgent',\n source: 'payment-velocity',\n url: null,\n reportedHostname: null,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n `Site ${input.hostId} opened more than ${window.limit} card payments in ${minutes} minutes (first overage through ${input.path}).`,\n input.young\n ? 'The workspace is young. Each address on this site is held to the tighter per-visitor limit.'\n : 'Each address is held to the per-visitor limit; nothing site-wide was refused.',\n 'A script testing stolen cards leaves this shape, from one address or many; so does a merchant testing cards through their own shop, and so does a busy launch.',\n 'Nothing has been refunded, canceled or paused. Look at the site and its orders; if it is card testing, lock the workspace and pause the connected account\\'s payouts in Stripe, then close this row with what you did.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n paymentVelocity: {\n hostId: input.hostId,\n orgId: input.orgId,\n day,\n threshold: window.limit,\n windowMinutes: minutes,\n young: input.young,\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n if (first) {\n await notifyCardTestingVelocity(\n { hostId: input.hostId, orgId: input.orgId, reference, reviewId },\n { notifyRisk: input.notifyRisk },\n )\n }\n return { reviewId, first }\n } catch (error) {\n console.error('[card-payment-velocity] alarm row failed', error)\n return null\n }\n}\n\n/** The (site, day) alarms this process has filed. */\nconst filedAlarms = new Set<string>()\n\n/** Forget which alarms this process filed. */\nexport function resetCardPaymentAlarmsForTests(): void {\n filedAlarms.clear()\n}\n\n/**\n * Count one opened card payment and return a 429 once a visitor's or an\n * address's window is spent. See the policy module for the three counters.\n */\nexport async function cardPaymentVelocityRefusal(\n options: CardPaymentVelocityOptions,\n): Promise<Response | null> {\n const nowMs = options.nowMs ?? Date.now()\n const ip = clientIp(options.request)\n const visitorWindow = options.young\n ? CARD_PAYMENT_VELOCITY.perVisitorYoung\n : CARD_PAYMENT_VELOCITY.perVisitor\n const count = (key: string, window: { limit: number; windowMs: number }) =>\n consumeRateLimit(key, {\n limit: window.limit,\n windowMs: window.windowMs,\n now: nowMs,\n firestore: options.firestore,\n })\n\n const siteKey = cardPaymentSiteKey(options.hostId)\n const [visitor, address, site] = await Promise.all([\n count(cardPaymentVisitorKey(options.hostId, ip), visitorWindow),\n count(cardPaymentAddressKey(ip), CARD_PAYMENT_VELOCITY.perAddress),\n siteKey ? count(siteKey, CARD_PAYMENT_VELOCITY.perSite) : Promise.resolve(null),\n ])\n\n // The site counter's overage files the day's alarm. Each process files a\n // (site, day) once — an attack is thousands of overages, and one row that\n // says so is the whole message; the row id makes a second process's filing\n // count on the same row rather than add one.\n const day = cardPaymentAlarmDay(nowMs)\n const filedKey = `${options.hostId}:${day}`\n if (site && !site.allowed && !site.contended && options.hostId && !filedAlarms.has(filedKey)) {\n filedAlarms.add(filedKey)\n const firestore =\n (options.firestore as any) ?? firebaseAdmin.app().firestore()\n await recordCardPaymentAlarm({\n hostId: options.hostId,\n orgId: options.orgId ?? null,\n path: options.path,\n young: options.young === true,\n nowMs,\n firestore,\n notifyRisk: options.notifyRisk ?? notifyRiskEvent,\n })\n }\n\n if (!visitor.allowed) return refusal(visitor.resetMs, nowMs)\n if (!address.allowed) return refusal(address.resetMs, nowMs)\n return null\n}\n\nexport default cardPaymentVelocityRefusal\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","CARD_PAYMENT_VELOCITY","cardPaymentAddressKey","cardPaymentAlarmDay","cardPaymentSiteKey","cardPaymentVisitorKey","NO_CLIENT_ADDRESS_BUCKET","readClientIp","FieldValue","firebaseAdmin","consumeRateLimit","notifyRiskEvent","clientIp","request","headers","get","refusal","resetMs","nowMs","Response","json","error","status","String","Math","max","ceil","cardPaymentAlarmReviewId","hostId","day","update","digest","slice","notifyCardTestingVelocity","input","deps","notifyRisk","kind","orgId","reviewId","reference","item","label","path","staffEvidence","catch","undefined","recordCardPaymentAlarm","toUpperCase","ref","firestore","collection","doc","first","exists","window","perSite","minutes","round","windowMs","set","category","severity","source","url","reportedHostname","details","limit","young","join","reporterEmail","reporterName","dmca","reportCount","increment","paymentVelocity","threshold","windowMinutes","updatedAt","serverTimestamp","createdAt","merge","console","filedAlarms","Set","resetCardPaymentAlarmsForTests","clear","cardPaymentVelocityRefusal","options","Date","now","ip","visitorWindow","perVisitorYoung","perVisitor","count","key","siteKey","visitor","address","site","Promise","all","perAddress","resolve","filedKey","allowed","contended","has","add","app"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/card-payment-velocity.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 * CARD-TESTING VELOCITY, THE DURABLE HALF (AGL-3363).\n *\n * The policy — the three counters, their numbers and why — is\n * `@aglyn/aglyn/app-utils/card-payment-velocity`. This counts them in the\n * shared durable store (`consumeRateLimit`) and answers the dispatcher the\n * way the visitor-write limiter beside it does: a `Response` to return, or\n * null to carry on. The tenant dispatcher calls it for a route registered\n * with `{ cardPayment: true }`, so every plugin's payment door meets it by\n * declaring itself, and none carries its own copy.\n *\n * The site counter refuses nothing. Crossing it files ONE urgent row per\n * site per day in the abuse queue (`source: 'payment-velocity'`), beside the\n * seller-pattern row, and tells staff once.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport {\n CARD_PAYMENT_VELOCITY,\n cardPaymentAddressKey,\n cardPaymentAlarmDay,\n cardPaymentSiteKey,\n cardPaymentVisitorKey,\n} from '@aglyn/aglyn/app-utils/card-payment-velocity'\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { consumeRateLimit } from './rate-limit-store'\nimport { notifyRiskEvent, type RiskEventInput } from './risk-notice'\n\nexport interface CardPaymentVelocityOptions {\n /** Dispatcher path, for the staff row. */\n path: string\n /** The site the payment is for; `''` when none resolved (still counted). */\n hostId: string\n /** The workspace owning it, when the dispatcher read one. */\n orgId?: string | null\n /** True for a workspace younger than the screen's young-workspace age. */\n young?: boolean\n /** Read for its client-address headers. */\n request: { headers?: { get?: (name: string) => unknown } }\n nowMs?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: unknown\n /** Injectable for tests; defaults to `notifyRiskEvent` (AGL-3368). */\n notifyRisk?: (input: RiskEventInput) => Promise<unknown>\n}\n\nfunction clientIp(request: CardPaymentVelocityOptions['request']): string {\n const headers = request?.headers\n if (typeof headers?.get !== 'function') return NO_CLIENT_ADDRESS_BUCKET\n return (\n readClientIp(headers as { get(name: string): string | null }) ??\n NO_CLIENT_ADDRESS_BUCKET\n )\n}\n\nfunction refusal(resetMs: number, nowMs: number): Response {\n // The same bare 429 as the visitor-write limiter: no remaining-count\n // headers for a stranger to pace a script by.\n return Response.json(\n { error: 'Too many payment attempts. Please wait a few minutes and try again.' },\n {\n status: 429,\n headers: {\n 'Retry-After': String(Math.max(1, Math.ceil((resetMs - nowMs) / 1000))),\n },\n },\n )\n}\n\n/** The abuse-queue row id for a site's alarm on one day. Hex, like every row. */\nexport function cardPaymentAlarmReviewId(hostId: string, day: string): string {\n return createHash('sha256')\n .update(`payment-velocity:${hostId}:${day}`)\n .digest('hex')\n .slice(0, 40)\n}\n\n/**\n * What the alarm is about, in the owner's words:\n * `checkout on the site \"Harbor View\"`. The label rides on the row, so the\n * review's closing notice and the staff alert name the site too — a\n * workspace with several stores reads which orders to look at. Pure.\n */\nexport function cardTestingItemLabel(siteName: string | null | undefined): string {\n const name = String(siteName ?? '').trim()\n return name ? `checkout on the site \"${name.replace(/\"/g, '”')}\"` : 'checkout on your site'\n}\n\n/** The site's name for the label, from its document. Never throws. */\nasync function readSiteName(firestore: any, hostId: string): Promise<string | null> {\n try {\n const host = await firestore.collection('hosts').doc(hostId).get()\n for (const value of [host?.get?.('displayName'), host?.get?.('name'), host?.get?.('subdomain')]) {\n if (typeof value === 'string' && value.trim()) return value.trim()\n }\n } catch {\n // The notice still goes, naming \"your site\".\n }\n return null\n}\n\n/**\n * Tell staff and the site's managers and the workspace's owners that a site\n * crossed the card-payment window (AGL-3363), through the risk notice seam\n * (AGL-3368): the owners read the catalog's `card-testing` words — what was\n * seen and what to do, never the window or its numbers; staff get the row.\n * Never throws.\n */\nexport async function notifyCardTestingVelocity(\n input: {\n hostId: string\n orgId: string | null\n reference: string\n reviewId?: string | null\n /** The site's display name, for the label. */\n siteName?: string | null\n },\n deps: { notifyRisk: (input: RiskEventInput) => Promise<unknown> },\n): Promise<void> {\n await deps\n .notifyRisk({\n kind: 'card-testing',\n orgId: input.orgId,\n hostId: input.hostId,\n reviewId: input.reviewId ?? null,\n reference: input.reference,\n item: { label: cardTestingItemLabel(input.siteName), path: `/${input.hostId}/products/orders` },\n staffEvidence: 'An unusual number of card payments opened on one site.',\n })\n .catch(() => undefined)\n}\n\n/**\n * File (or count) the day's alarm row for a site whose payment doors crossed\n * the site counter, and tell staff the first time. Never throws: an alarm\n * that fails must not turn a shopper's checkout into a 500.\n */\nexport async function recordCardPaymentAlarm(input: {\n hostId: string\n orgId: string | null\n path: string\n young: boolean\n nowMs: number\n firestore: any\n notifyRisk: (input: RiskEventInput) => Promise<unknown>\n}): Promise<{ reviewId: string; first: boolean } | null> {\n try {\n const day = cardPaymentAlarmDay(input.nowMs)\n const reviewId = cardPaymentAlarmReviewId(input.hostId, day)\n const reference = `PV-${reviewId.slice(0, 10).toUpperCase()}`\n const ref = input.firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n const window = CARD_PAYMENT_VELOCITY.perSite\n const minutes = Math.round(window.windowMs / 60_000)\n await ref.set(\n {\n reference,\n // \"Phishing or fraud\" — the queue's urgent fraud category.\n category: 'phishing',\n severity: 'urgent',\n source: 'payment-velocity',\n url: null,\n reportedHostname: null,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n `Site ${input.hostId} opened more than ${window.limit} card payments in ${minutes} minutes (first overage through ${input.path}).`,\n input.young\n ? 'The workspace is young. Each address on this site is held to the tighter per-visitor limit.'\n : 'Each address is held to the per-visitor limit; nothing site-wide was refused.',\n 'A script testing stolen cards leaves this shape, from one address or many; so does a merchant testing cards through their own shop, and so does a busy launch.',\n 'Nothing has been refunded, canceled or paused. Look at the site and its orders; if it is card testing, lock the workspace and pause the connected account\\'s payouts in Stripe, then close this row with what you did.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n paymentVelocity: {\n hostId: input.hostId,\n orgId: input.orgId,\n day,\n threshold: window.limit,\n windowMinutes: minutes,\n young: input.young,\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n if (first) {\n await notifyCardTestingVelocity(\n {\n hostId: input.hostId,\n orgId: input.orgId,\n reference,\n reviewId,\n siteName: await readSiteName(input.firestore, input.hostId),\n },\n { notifyRisk: input.notifyRisk },\n )\n }\n return { reviewId, first }\n } catch (error) {\n console.error('[card-payment-velocity] alarm row failed', error)\n return null\n }\n}\n\n/** The (site, day) alarms this process has filed. */\nconst filedAlarms = new Set<string>()\n\n/** Forget which alarms this process filed. */\nexport function resetCardPaymentAlarmsForTests(): void {\n filedAlarms.clear()\n}\n\n/**\n * Count one opened card payment and return a 429 once a visitor's or an\n * address's window is spent. See the policy module for the three counters.\n */\nexport async function cardPaymentVelocityRefusal(\n options: CardPaymentVelocityOptions,\n): Promise<Response | null> {\n const nowMs = options.nowMs ?? Date.now()\n const ip = clientIp(options.request)\n const visitorWindow = options.young\n ? CARD_PAYMENT_VELOCITY.perVisitorYoung\n : CARD_PAYMENT_VELOCITY.perVisitor\n const count = (key: string, window: { limit: number; windowMs: number }) =>\n consumeRateLimit(key, {\n limit: window.limit,\n windowMs: window.windowMs,\n now: nowMs,\n firestore: options.firestore,\n })\n\n const siteKey = cardPaymentSiteKey(options.hostId)\n const [visitor, address, site] = await Promise.all([\n count(cardPaymentVisitorKey(options.hostId, ip), visitorWindow),\n count(cardPaymentAddressKey(ip), CARD_PAYMENT_VELOCITY.perAddress),\n siteKey ? count(siteKey, CARD_PAYMENT_VELOCITY.perSite) : Promise.resolve(null),\n ])\n\n // The site counter's overage files the day's alarm. Each process files a\n // (site, day) once — an attack is thousands of overages, and one row that\n // says so is the whole message; the row id makes a second process's filing\n // count on the same row rather than add one.\n const day = cardPaymentAlarmDay(nowMs)\n const filedKey = `${options.hostId}:${day}`\n if (site && !site.allowed && !site.contended && options.hostId && !filedAlarms.has(filedKey)) {\n filedAlarms.add(filedKey)\n const firestore =\n (options.firestore as any) ?? firebaseAdmin.app().firestore()\n await recordCardPaymentAlarm({\n hostId: options.hostId,\n orgId: options.orgId ?? null,\n path: options.path,\n young: options.young === true,\n nowMs,\n firestore,\n notifyRisk: options.notifyRisk ?? notifyRiskEvent,\n })\n }\n\n if (!visitor.allowed) return refusal(visitor.resetMs, nowMs)\n if (!address.allowed) return refusal(address.resetMs, nowMs)\n return null\n}\n\nexport default cardPaymentVelocityRefusal\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","CARD_PAYMENT_VELOCITY","cardPaymentAddressKey","cardPaymentAlarmDay","cardPaymentSiteKey","cardPaymentVisitorKey","NO_CLIENT_ADDRESS_BUCKET","readClientIp","FieldValue","firebaseAdmin","consumeRateLimit","notifyRiskEvent","clientIp","request","headers","get","refusal","resetMs","nowMs","Response","json","error","status","String","Math","max","ceil","cardPaymentAlarmReviewId","hostId","day","update","digest","slice","cardTestingItemLabel","siteName","name","trim","replace","readSiteName","firestore","host","collection","doc","value","notifyCardTestingVelocity","input","deps","notifyRisk","kind","orgId","reviewId","reference","item","label","path","staffEvidence","catch","undefined","recordCardPaymentAlarm","toUpperCase","ref","first","exists","window","perSite","minutes","round","windowMs","set","category","severity","source","url","reportedHostname","details","limit","young","join","reporterEmail","reporterName","dmca","reportCount","increment","paymentVelocity","threshold","windowMinutes","updatedAt","serverTimestamp","createdAt","merge","console","filedAlarms","Set","resetCardPaymentAlarmsForTests","clear","cardPaymentVelocityRefusal","options","Date","now","ip","visitorWindow","perVisitorYoung","perVisitor","count","key","siteKey","visitor","address","site","Promise","all","perAddress","resolve","filedKey","allowed","contended","has","add","app"],"mappings":"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"}
|
|
@@ -52,7 +52,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
52
52
|
* `sinceMs` narrows a page to what changed since then — the catch-up, which
|
|
53
53
|
* reads by `suppressedAt`, `updatedAt` and `cadenceSetAtMs`. A row written
|
|
54
54
|
* without one of those is left to the sweep's full pass.
|
|
55
|
-
*/ import { readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/
|
|
55
|
+
*/ import { readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/subscription-topics";
|
|
56
56
|
import { FieldPath, FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
57
57
|
import { EMAIL_FREQUENCY_SUBCOLLECTION } from "./email-marketing-gate.js";
|
|
58
58
|
import { HOST_SUPPRESSIONS_SUBCOLLECTION } from "./email-suppression.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/consent-group-carry.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 * CARRYING A SITE'S REFUSALS ONTO A SITE THAT IS ABOUT TO STOP READING THEM\n * (AGL-3320).\n *\n * Three per-site stores are read across the CURRENT consent group by every\n * send path (`email-suppression.ts`, `email-marketing-gate.ts`), so a site\n * leaving a group stops seeing its former siblings' — and they stop seeing\n * its. Before the declaration flips, each carry `(to ← from)` of the plan\n * copies `from`'s refusals onto `to`:\n *\n * - **C1, the suppression list** (`hosts/{h}/suppressions/{key}`). A row\n * `to` does not have is CREATED with `from`'s fields and provenance; a row\n * `to` has is never touched. `reason` is kept, so only an unsubscribe stays\n * liftable by the preference page's resubscribe, and a group-wide\n * resubscribe lifts a carried row like an original.\n * - **C2, the topic opt-outs** (`hosts/{h}/topicOptOuts/{key}`). Every\n * stream `from` has LEFT, and `to` has not, becomes an opt-out on `to`\n * dated when the person left `from`. `to`'s other entries — its own\n * confirmations, its own opt-outs — are kept as they are.\n * - **C3, the pace** (`hosts/{h}/emailFrequency/{key}`). The choice made\n * most recently wins, as `groupPace` reads it within a group, and a site\n * keeping a slower pace learns when `from` last mailed the person, so the\n * separation cannot loosen the pace they asked for. The send counters are\n * never touched.\n *\n * ## One page per call, and every page idempotent\n *\n * The executor calls this until it answers `done`, handing back the cursor.\n * A page reads `from`'s documents in id order and `to`'s matching documents\n * in one `getAll`, and writes only what `to` lacks — so a page re-run after a\n * crash, a catch-up pass and the sweep all write nothing that was already\n * carried. A write that raced another writer (a `lastUpdateTime` precondition\n * or a create that found the document) is re-read and decided again once;\n * failing twice throws, and the executor retries the page.\n *\n * `sinceMs` narrows a page to what changed since then — the catch-up, which\n * reads by `suppressedAt`, `updatedAt` and `cadenceSetAtMs`. A row written\n * without one of those is left to the sweep's full pass.\n */\n\nimport {\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport type { ConsentGroupCarry } from '@aglyn/aglyn/app-utils/consent-group-change'\nimport { FieldPath, FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { EMAIL_FREQUENCY_SUBCOLLECTION } from './email-marketing-gate'\nimport { HOST_SUPPRESSIONS_SUBCOLLECTION } from './email-suppression'\n\n/** The three stores a carry copies, as the job counts them. */\nexport type ConsentGroupCarryStore = 'siteSuppressions' | 'topicOptOuts' | 'paces'\n\nexport const CONSENT_GROUP_CARRY_STORES: readonly ConsentGroupCarryStore[] = [\n 'siteSuppressions',\n 'topicOptOuts',\n 'paces',\n]\n\n/** Each store's subcollection under `hosts/{hostId}`. */\nexport const CONSENT_GROUP_CARRY_SUBCOLLECTIONS: Readonly<Record<ConsentGroupCarryStore, string>> = {\n siteSuppressions: HOST_SUPPRESSIONS_SUBCOLLECTION,\n topicOptOuts: TOPIC_OPT_OUTS_SUBCOLLECTION,\n paces: EMAIL_FREQUENCY_SUBCOLLECTION,\n}\n\n/** The field a catch-up reads each store by, and whether it is a timestamp. */\nconst CATCH_UP_FIELDS: Readonly<Record<ConsentGroupCarryStore, { field: string; timestamp: boolean }>> = {\n siteSuppressions: { field: 'suppressedAt', timestamp: true },\n topicOptOuts: { field: 'updatedAt', timestamp: true },\n paces: { field: 'cadenceSetAtMs', timestamp: false },\n}\n\n/**\n * The suppression row's fields a carry copies. Everything else a row could\n * carry is either the carry's own provenance or not a fact about the refusal.\n */\nconst SUPPRESSION_ROW_FIELDS = [\n 'reason',\n 'email',\n // The address's search tokens travel with it (AGL-3321).\n 'emailTokens',\n 'createdAt',\n 'suppressedAt',\n 'note',\n 'suppressedByUid',\n 'campaignId',\n 'topicId',\n] as const\n\n/** Documents one page reads from the source site. */\nexport const CONSENT_GROUP_CARRY_PAGE = 300\n\nexport interface ConsentGroupCarryPage {\n /** The source site has nothing after the cursor. */\n done: boolean\n /** Where the next page starts, or `null` once `done`. */\n cursor: string | null\n /** Source documents read. */\n read: number\n /** Documents written on the receiving site (counted, not written, on a dry run). */\n written: number\n}\n\nexport interface CarryConsentGroupRefusalsOptions {\n firestore: FirebaseFirestore.Firestore\n store: ConsentGroupCarryStore\n carry: ConsentGroupCarry\n changeId: string\n cursor: string | null\n /** Only documents changed at or after this instant — the catch-up. */\n sinceMs?: number | null\n pageSize?: number\n dryRun?: boolean\n}\n\ninterface Cursor {\n id: string\n /** The catch-up field's value on the last document read. */\n at?: { seconds: number; nanoseconds: number } | number\n}\n\nfunction readCursor(raw: string | null): Cursor | null {\n if (!raw) return null\n try {\n const parsed = JSON.parse(raw) as Cursor\n return typeof parsed?.id === 'string' ? parsed : null\n } catch {\n return null\n }\n}\n\n/** The error codes that mean \"somebody wrote this document since I read it\". */\nconst RACED = new Set([5, 6, 9])\nconst codeOf = (error: unknown): number | undefined => (error as { code?: number } | null)?.code\n\n/** Milliseconds from a stored number or timestamp, or `null`. */\nfunction storedMs(value: unknown): number | null {\n if (typeof value === 'number') return Number.isFinite(value) ? value : null\n const toMillis = (value as { toMillis?: () => number } | null)?.toMillis\n return typeof toMillis === 'function' ? toMillis.call(value) : null\n}\n\n/**\n * When the site last mailed the person, as the gate reads it: the stored\n * instant, or the newest entry of a window written before it existed.\n */\nfunction lastSentMs(data: Record<string, unknown> | undefined): number | null {\n if (!data) return null\n const stored = Number(data['lastSentAtMs'])\n if (Number.isFinite(stored) && stored > 0) return stored\n const window = Array.isArray(data['sentAtMs'])\n ? (data['sentAtMs'] as unknown[]).map(Number).filter(Number.isFinite)\n : []\n return window.length ? Math.max(...window) : null\n}\n\n/**\n * What `to` must become for one source document, or `null` when it already\n * honors everything `from` does. `create` says the document is new.\n */\ntype Decision = { create: boolean; data: Record<string, unknown> } | null\n\nfunction decideSuppression(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n changeId: string,\n): Decision {\n if (target.exists) return null\n const row = source.data() ?? {}\n const data: Record<string, unknown> = {}\n for (const field of SUPPRESSION_ROW_FIELDS) {\n if (field in row) data[field] = row[field]\n }\n data['carriedFromHostId'] = carry.fromHostId\n data['carriedByChangeId'] = changeId\n data['carriedAt'] = FieldValue.serverTimestamp()\n return { create: true, data }\n}\n\nfunction decideTopics(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n): Decision {\n const sourceTopics = (source.get('topics') ?? {}) as Record<string, TopicSubscriptionEntry>\n const targetTopics = ((target.exists ? target.get('topics') : null) ?? {}) as Record<\n string,\n TopicSubscriptionEntry & Record<string, unknown>\n >\n const next: Record<string, unknown> = { ...targetTopics }\n let changed = false\n for (const [topicId, entry] of Object.entries(sourceTopics)) {\n if (readTopicSubscriptionState(entry) !== 'opted-out') continue\n if (readTopicSubscriptionState(targetTopics[topicId]) === 'opted-out') continue\n next[topicId] = {\n ...(targetTopics[topicId] ?? {}),\n optedOutAt: entry.optedOutAt,\n resubscribedAt: null,\n carriedFromHostId: carry.fromHostId,\n }\n changed = true\n }\n if (!changed) return null\n const email = (target.exists ? target.get('email') : null) ?? source.get('email') ?? null\n return {\n create: !target.exists,\n data: {\n email,\n topics: next,\n updatedAt: FieldValue.serverTimestamp(),\n ...(target.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n }\n}\n\nfunction decidePace(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n): Decision {\n const from = source.data() ?? {}\n const to = target.exists ? (target.data() ?? {}) : undefined\n const patch: Record<string, unknown> = {}\n const sourceChoice = from['cadence']\n const targetChoice = to?.['cadence']\n const sourceSetAt = Number(from['cadenceSetAtMs'])\n const targetSetAt = Number(to?.['cadenceSetAtMs'])\n const carried =\n sourceChoice != null &&\n (targetChoice == null ||\n (Number.isFinite(targetSetAt) ? targetSetAt : 0) <\n (Number.isFinite(sourceSetAt) ? sourceSetAt : 0))\n if (carried) {\n patch['cadence'] = sourceChoice\n if (Number.isFinite(sourceSetAt)) patch['cadenceSetAtMs'] = sourceSetAt\n patch['cadenceCarriedFromHostId'] = carry.fromHostId\n }\n // The pace in force afterwards decides whether the last send matters: `all`\n // sends as they come and never asks when the last one went.\n const choice = carried ? sourceChoice : targetChoice\n if (choice != null && choice !== 'all') {\n const sourceLast = lastSentMs(from)\n const targetLast = lastSentMs(to)\n if (sourceLast !== null && (targetLast === null || sourceLast > targetLast)) {\n patch['lastSentAtMs'] = sourceLast\n }\n }\n if (!Object.keys(patch).length) return null\n return {\n create: !target.exists,\n data: target.exists ? patch : { email: from['email'] ?? null, ...patch },\n }\n}\n\nfunction decide(\n store: ConsentGroupCarryStore,\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n changeId: string,\n): Decision {\n if (store === 'siteSuppressions') return decideSuppression(source, target, carry, changeId)\n if (store === 'topicOptOuts') return decideTopics(source, target, carry)\n return decidePace(source, target, carry)\n}\n\n/**\n * One page of one store of one carry. See the module note for what each\n * store copies and why a re-run writes nothing.\n */\nexport async function carryConsentGroupRefusals(\n options: CarryConsentGroupRefusalsOptions,\n): Promise<ConsentGroupCarryPage> {\n const { firestore, store, carry, changeId } = options\n const subcollection = CONSENT_GROUP_CARRY_SUBCOLLECTIONS[store]\n const sourceList = firestore.collection('hosts').doc(carry.fromHostId).collection(subcollection)\n const targetList = firestore.collection('hosts').doc(carry.toHostId).collection(subcollection)\n const limit = options.pageSize ?? CONSENT_GROUP_CARRY_PAGE\n const cursor = readCursor(options.cursor)\n const catchUp = CATCH_UP_FIELDS[store]\n const since = options.sinceMs ?? null\n\n let query: FirebaseFirestore.Query = sourceList\n if (since !== null) {\n query = query\n .where(catchUp.field, '>=', catchUp.timestamp ? Timestamp.fromMillis(since) : since)\n .orderBy(catchUp.field)\n .orderBy(FieldPath.documentId())\n if (cursor && cursor.at !== undefined) {\n const at =\n typeof cursor.at === 'number' ? cursor.at : new Timestamp(cursor.at.seconds, cursor.at.nanoseconds)\n query = query.startAfter(at, cursor.id)\n }\n } else {\n query = query.orderBy(FieldPath.documentId())\n if (cursor) query = query.startAfter(cursor.id)\n }\n const page = await query.limit(limit).get()\n if (page.empty) return { done: true, cursor: null, read: 0, written: 0 }\n\n const targets = await firestore.getAll(...page.docs.map((doc) => targetList.doc(doc.id)))\n const decisions = page.docs.map((source, index) => ({\n source,\n target: targets[index],\n decision: decide(store, source, targets[index], carry, changeId),\n }))\n const due = decisions.filter((entry) => entry.decision !== null)\n const written = options.dryRun\n ? due.length\n : await writeDecisions(firestore, targetList, store, carry, changeId, due)\n\n const last = page.docs[page.docs.length - 1]\n const next: Cursor = { id: last.id }\n if (since !== null) {\n const value = last.get(catchUp.field)\n next.at =\n value instanceof Timestamp\n ? { seconds: value.seconds, nanoseconds: value.nanoseconds }\n : (storedMs(value) ?? 0)\n }\n const done = page.size < limit\n return { done, cursor: done ? null : JSON.stringify(next), read: page.size, written }\n}\n\n/**\n * Writes one page's decisions through a `BulkWriter`, each guarded against\n * the document having moved since it was read, and decides the ones that\n * raced once more from a fresh read.\n */\nasync function writeDecisions(\n firestore: FirebaseFirestore.Firestore,\n targetList: FirebaseFirestore.CollectionReference,\n store: ConsentGroupCarryStore,\n carry: ConsentGroupCarry,\n changeId: string,\n due: Array<{\n source: FirebaseFirestore.DocumentSnapshot\n target: FirebaseFirestore.DocumentSnapshot\n decision: Decision\n }>,\n): Promise<number> {\n let written = 0\n for (let round = 0; round < 2 && due.length; round += 1) {\n const writer = firestore.bulkWriter()\n const outcomes = due.map(({ source, target, decision }) => {\n const ref = targetList.doc(source.id)\n const write = (decision as NonNullable<Decision>).create\n ? writer.create(ref, (decision as NonNullable<Decision>).data)\n : writer.update(ref, (decision as NonNullable<Decision>).data, {\n lastUpdateTime: target.updateTime as FirebaseFirestore.Timestamp,\n })\n return write.then(\n () => ({ source, error: null as unknown }),\n (error: unknown) => ({ source, error: error ?? new Error('The write failed') }),\n )\n })\n await writer.close()\n const settled = await Promise.all(outcomes)\n const retry: FirebaseFirestore.DocumentSnapshot[] = []\n for (const { source, error } of settled) {\n if (error === null) {\n written += 1\n continue\n }\n const code = codeOf(error)\n if (code === undefined || !RACED.has(code)) throw error\n // The row a suppression carry would create exists now: `to` has its own.\n if (store === 'siteSuppressions' && code === 6) continue\n retry.push(source)\n }\n if (!retry.length) return written\n const fresh = await firestore.getAll(...retry.map((source) => targetList.doc(source.id)))\n due = retry\n .map((source, index) => ({\n source,\n target: fresh[index],\n decision: decide(store, source, fresh[index], carry, changeId),\n }))\n .filter((entry) => entry.decision !== null)\n }\n if (due.length) {\n throw new Error(\n `[consent-group-carry] ${due.length} document(s) on ${carry.toHostId} kept changing under the carry`,\n )\n }\n return written\n}\n"],"names":["readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","FieldPath","FieldValue","Timestamp","EMAIL_FREQUENCY_SUBCOLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","CONSENT_GROUP_CARRY_STORES","CONSENT_GROUP_CARRY_SUBCOLLECTIONS","siteSuppressions","topicOptOuts","paces","CATCH_UP_FIELDS","field","timestamp","SUPPRESSION_ROW_FIELDS","CONSENT_GROUP_CARRY_PAGE","readCursor","raw","parsed","JSON","parse","id","RACED","Set","codeOf","error","code","storedMs","value","Number","isFinite","toMillis","call","lastSentMs","data","stored","window","Array","isArray","map","filter","length","Math","max","decideSuppression","source","target","carry","changeId","exists","row","fromHostId","serverTimestamp","create","decideTopics","sourceTopics","get","targetTopics","next","changed","topicId","entry","Object","entries","optedOutAt","resubscribedAt","carriedFromHostId","email","topics","updatedAt","createdAt","decidePace","from","to","undefined","patch","sourceChoice","targetChoice","sourceSetAt","targetSetAt","carried","choice","sourceLast","targetLast","keys","decide","store","carryConsentGroupRefusals","options","firestore","subcollection","sourceList","collection","doc","targetList","toHostId","limit","pageSize","cursor","catchUp","since","sinceMs","query","where","fromMillis","orderBy","documentId","at","seconds","nanoseconds","startAfter","page","empty","done","read","written","targets","getAll","docs","decisions","index","decision","due","dryRun","writeDecisions","last","size","stringify","round","writer","bulkWriter","outcomes","ref","write","update","lastUpdateTime","updateTime","then","Error","close","settled","Promise","all","retry","has","push","fresh"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/consent-group-carry.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 * CARRYING A SITE'S REFUSALS ONTO A SITE THAT IS ABOUT TO STOP READING THEM\n * (AGL-3320).\n *\n * Three per-site stores are read across the CURRENT consent group by every\n * send path (`email-suppression.ts`, `email-marketing-gate.ts`), so a site\n * leaving a group stops seeing its former siblings' — and they stop seeing\n * its. Before the declaration flips, each carry `(to ← from)` of the plan\n * copies `from`'s refusals onto `to`:\n *\n * - **C1, the suppression list** (`hosts/{h}/suppressions/{key}`). A row\n * `to` does not have is CREATED with `from`'s fields and provenance; a row\n * `to` has is never touched. `reason` is kept, so only an unsubscribe stays\n * liftable by the preference page's resubscribe, and a group-wide\n * resubscribe lifts a carried row like an original.\n * - **C2, the topic opt-outs** (`hosts/{h}/topicOptOuts/{key}`). Every\n * stream `from` has LEFT, and `to` has not, becomes an opt-out on `to`\n * dated when the person left `from`. `to`'s other entries — its own\n * confirmations, its own opt-outs — are kept as they are.\n * - **C3, the pace** (`hosts/{h}/emailFrequency/{key}`). The choice made\n * most recently wins, as `groupPace` reads it within a group, and a site\n * keeping a slower pace learns when `from` last mailed the person, so the\n * separation cannot loosen the pace they asked for. The send counters are\n * never touched.\n *\n * ## One page per call, and every page idempotent\n *\n * The executor calls this until it answers `done`, handing back the cursor.\n * A page reads `from`'s documents in id order and `to`'s matching documents\n * in one `getAll`, and writes only what `to` lacks — so a page re-run after a\n * crash, a catch-up pass and the sweep all write nothing that was already\n * carried. A write that raced another writer (a `lastUpdateTime` precondition\n * or a create that found the document) is re-read and decided again once;\n * failing twice throws, and the executor retries the page.\n *\n * `sinceMs` narrows a page to what changed since then — the catch-up, which\n * reads by `suppressedAt`, `updatedAt` and `cadenceSetAtMs`. A row written\n * without one of those is left to the sweep's full pass.\n */\n\nimport {\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/subscription-topics'\nimport type { ConsentGroupCarry } from '@aglyn/aglyn/app-utils/consent-group-change'\nimport { FieldPath, FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { EMAIL_FREQUENCY_SUBCOLLECTION } from './email-marketing-gate'\nimport { HOST_SUPPRESSIONS_SUBCOLLECTION } from './email-suppression'\n\n/** The three stores a carry copies, as the job counts them. */\nexport type ConsentGroupCarryStore = 'siteSuppressions' | 'topicOptOuts' | 'paces'\n\nexport const CONSENT_GROUP_CARRY_STORES: readonly ConsentGroupCarryStore[] = [\n 'siteSuppressions',\n 'topicOptOuts',\n 'paces',\n]\n\n/** Each store's subcollection under `hosts/{hostId}`. */\nexport const CONSENT_GROUP_CARRY_SUBCOLLECTIONS: Readonly<Record<ConsentGroupCarryStore, string>> = {\n siteSuppressions: HOST_SUPPRESSIONS_SUBCOLLECTION,\n topicOptOuts: TOPIC_OPT_OUTS_SUBCOLLECTION,\n paces: EMAIL_FREQUENCY_SUBCOLLECTION,\n}\n\n/** The field a catch-up reads each store by, and whether it is a timestamp. */\nconst CATCH_UP_FIELDS: Readonly<Record<ConsentGroupCarryStore, { field: string; timestamp: boolean }>> = {\n siteSuppressions: { field: 'suppressedAt', timestamp: true },\n topicOptOuts: { field: 'updatedAt', timestamp: true },\n paces: { field: 'cadenceSetAtMs', timestamp: false },\n}\n\n/**\n * The suppression row's fields a carry copies. Everything else a row could\n * carry is either the carry's own provenance or not a fact about the refusal.\n */\nconst SUPPRESSION_ROW_FIELDS = [\n 'reason',\n 'email',\n // The address's search tokens travel with it (AGL-3321).\n 'emailTokens',\n 'createdAt',\n 'suppressedAt',\n 'note',\n 'suppressedByUid',\n 'campaignId',\n 'topicId',\n] as const\n\n/** Documents one page reads from the source site. */\nexport const CONSENT_GROUP_CARRY_PAGE = 300\n\nexport interface ConsentGroupCarryPage {\n /** The source site has nothing after the cursor. */\n done: boolean\n /** Where the next page starts, or `null` once `done`. */\n cursor: string | null\n /** Source documents read. */\n read: number\n /** Documents written on the receiving site (counted, not written, on a dry run). */\n written: number\n}\n\nexport interface CarryConsentGroupRefusalsOptions {\n firestore: FirebaseFirestore.Firestore\n store: ConsentGroupCarryStore\n carry: ConsentGroupCarry\n changeId: string\n cursor: string | null\n /** Only documents changed at or after this instant — the catch-up. */\n sinceMs?: number | null\n pageSize?: number\n dryRun?: boolean\n}\n\ninterface Cursor {\n id: string\n /** The catch-up field's value on the last document read. */\n at?: { seconds: number; nanoseconds: number } | number\n}\n\nfunction readCursor(raw: string | null): Cursor | null {\n if (!raw) return null\n try {\n const parsed = JSON.parse(raw) as Cursor\n return typeof parsed?.id === 'string' ? parsed : null\n } catch {\n return null\n }\n}\n\n/** The error codes that mean \"somebody wrote this document since I read it\". */\nconst RACED = new Set([5, 6, 9])\nconst codeOf = (error: unknown): number | undefined => (error as { code?: number } | null)?.code\n\n/** Milliseconds from a stored number or timestamp, or `null`. */\nfunction storedMs(value: unknown): number | null {\n if (typeof value === 'number') return Number.isFinite(value) ? value : null\n const toMillis = (value as { toMillis?: () => number } | null)?.toMillis\n return typeof toMillis === 'function' ? toMillis.call(value) : null\n}\n\n/**\n * When the site last mailed the person, as the gate reads it: the stored\n * instant, or the newest entry of a window written before it existed.\n */\nfunction lastSentMs(data: Record<string, unknown> | undefined): number | null {\n if (!data) return null\n const stored = Number(data['lastSentAtMs'])\n if (Number.isFinite(stored) && stored > 0) return stored\n const window = Array.isArray(data['sentAtMs'])\n ? (data['sentAtMs'] as unknown[]).map(Number).filter(Number.isFinite)\n : []\n return window.length ? Math.max(...window) : null\n}\n\n/**\n * What `to` must become for one source document, or `null` when it already\n * honors everything `from` does. `create` says the document is new.\n */\ntype Decision = { create: boolean; data: Record<string, unknown> } | null\n\nfunction decideSuppression(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n changeId: string,\n): Decision {\n if (target.exists) return null\n const row = source.data() ?? {}\n const data: Record<string, unknown> = {}\n for (const field of SUPPRESSION_ROW_FIELDS) {\n if (field in row) data[field] = row[field]\n }\n data['carriedFromHostId'] = carry.fromHostId\n data['carriedByChangeId'] = changeId\n data['carriedAt'] = FieldValue.serverTimestamp()\n return { create: true, data }\n}\n\nfunction decideTopics(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n): Decision {\n const sourceTopics = (source.get('topics') ?? {}) as Record<string, TopicSubscriptionEntry>\n const targetTopics = ((target.exists ? target.get('topics') : null) ?? {}) as Record<\n string,\n TopicSubscriptionEntry & Record<string, unknown>\n >\n const next: Record<string, unknown> = { ...targetTopics }\n let changed = false\n for (const [topicId, entry] of Object.entries(sourceTopics)) {\n if (readTopicSubscriptionState(entry) !== 'opted-out') continue\n if (readTopicSubscriptionState(targetTopics[topicId]) === 'opted-out') continue\n next[topicId] = {\n ...(targetTopics[topicId] ?? {}),\n optedOutAt: entry.optedOutAt,\n resubscribedAt: null,\n carriedFromHostId: carry.fromHostId,\n }\n changed = true\n }\n if (!changed) return null\n const email = (target.exists ? target.get('email') : null) ?? source.get('email') ?? null\n return {\n create: !target.exists,\n data: {\n email,\n topics: next,\n updatedAt: FieldValue.serverTimestamp(),\n ...(target.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n }\n}\n\nfunction decidePace(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n): Decision {\n const from = source.data() ?? {}\n const to = target.exists ? (target.data() ?? {}) : undefined\n const patch: Record<string, unknown> = {}\n const sourceChoice = from['cadence']\n const targetChoice = to?.['cadence']\n const sourceSetAt = Number(from['cadenceSetAtMs'])\n const targetSetAt = Number(to?.['cadenceSetAtMs'])\n const carried =\n sourceChoice != null &&\n (targetChoice == null ||\n (Number.isFinite(targetSetAt) ? targetSetAt : 0) <\n (Number.isFinite(sourceSetAt) ? sourceSetAt : 0))\n if (carried) {\n patch['cadence'] = sourceChoice\n if (Number.isFinite(sourceSetAt)) patch['cadenceSetAtMs'] = sourceSetAt\n patch['cadenceCarriedFromHostId'] = carry.fromHostId\n }\n // The pace in force afterwards decides whether the last send matters: `all`\n // sends as they come and never asks when the last one went.\n const choice = carried ? sourceChoice : targetChoice\n if (choice != null && choice !== 'all') {\n const sourceLast = lastSentMs(from)\n const targetLast = lastSentMs(to)\n if (sourceLast !== null && (targetLast === null || sourceLast > targetLast)) {\n patch['lastSentAtMs'] = sourceLast\n }\n }\n if (!Object.keys(patch).length) return null\n return {\n create: !target.exists,\n data: target.exists ? patch : { email: from['email'] ?? null, ...patch },\n }\n}\n\nfunction decide(\n store: ConsentGroupCarryStore,\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n changeId: string,\n): Decision {\n if (store === 'siteSuppressions') return decideSuppression(source, target, carry, changeId)\n if (store === 'topicOptOuts') return decideTopics(source, target, carry)\n return decidePace(source, target, carry)\n}\n\n/**\n * One page of one store of one carry. See the module note for what each\n * store copies and why a re-run writes nothing.\n */\nexport async function carryConsentGroupRefusals(\n options: CarryConsentGroupRefusalsOptions,\n): Promise<ConsentGroupCarryPage> {\n const { firestore, store, carry, changeId } = options\n const subcollection = CONSENT_GROUP_CARRY_SUBCOLLECTIONS[store]\n const sourceList = firestore.collection('hosts').doc(carry.fromHostId).collection(subcollection)\n const targetList = firestore.collection('hosts').doc(carry.toHostId).collection(subcollection)\n const limit = options.pageSize ?? CONSENT_GROUP_CARRY_PAGE\n const cursor = readCursor(options.cursor)\n const catchUp = CATCH_UP_FIELDS[store]\n const since = options.sinceMs ?? null\n\n let query: FirebaseFirestore.Query = sourceList\n if (since !== null) {\n query = query\n .where(catchUp.field, '>=', catchUp.timestamp ? Timestamp.fromMillis(since) : since)\n .orderBy(catchUp.field)\n .orderBy(FieldPath.documentId())\n if (cursor && cursor.at !== undefined) {\n const at =\n typeof cursor.at === 'number' ? cursor.at : new Timestamp(cursor.at.seconds, cursor.at.nanoseconds)\n query = query.startAfter(at, cursor.id)\n }\n } else {\n query = query.orderBy(FieldPath.documentId())\n if (cursor) query = query.startAfter(cursor.id)\n }\n const page = await query.limit(limit).get()\n if (page.empty) return { done: true, cursor: null, read: 0, written: 0 }\n\n const targets = await firestore.getAll(...page.docs.map((doc) => targetList.doc(doc.id)))\n const decisions = page.docs.map((source, index) => ({\n source,\n target: targets[index],\n decision: decide(store, source, targets[index], carry, changeId),\n }))\n const due = decisions.filter((entry) => entry.decision !== null)\n const written = options.dryRun\n ? due.length\n : await writeDecisions(firestore, targetList, store, carry, changeId, due)\n\n const last = page.docs[page.docs.length - 1]\n const next: Cursor = { id: last.id }\n if (since !== null) {\n const value = last.get(catchUp.field)\n next.at =\n value instanceof Timestamp\n ? { seconds: value.seconds, nanoseconds: value.nanoseconds }\n : (storedMs(value) ?? 0)\n }\n const done = page.size < limit\n return { done, cursor: done ? null : JSON.stringify(next), read: page.size, written }\n}\n\n/**\n * Writes one page's decisions through a `BulkWriter`, each guarded against\n * the document having moved since it was read, and decides the ones that\n * raced once more from a fresh read.\n */\nasync function writeDecisions(\n firestore: FirebaseFirestore.Firestore,\n targetList: FirebaseFirestore.CollectionReference,\n store: ConsentGroupCarryStore,\n carry: ConsentGroupCarry,\n changeId: string,\n due: Array<{\n source: FirebaseFirestore.DocumentSnapshot\n target: FirebaseFirestore.DocumentSnapshot\n decision: Decision\n }>,\n): Promise<number> {\n let written = 0\n for (let round = 0; round < 2 && due.length; round += 1) {\n const writer = firestore.bulkWriter()\n const outcomes = due.map(({ source, target, decision }) => {\n const ref = targetList.doc(source.id)\n const write = (decision as NonNullable<Decision>).create\n ? writer.create(ref, (decision as NonNullable<Decision>).data)\n : writer.update(ref, (decision as NonNullable<Decision>).data, {\n lastUpdateTime: target.updateTime as FirebaseFirestore.Timestamp,\n })\n return write.then(\n () => ({ source, error: null as unknown }),\n (error: unknown) => ({ source, error: error ?? new Error('The write failed') }),\n )\n })\n await writer.close()\n const settled = await Promise.all(outcomes)\n const retry: FirebaseFirestore.DocumentSnapshot[] = []\n for (const { source, error } of settled) {\n if (error === null) {\n written += 1\n continue\n }\n const code = codeOf(error)\n if (code === undefined || !RACED.has(code)) throw error\n // The row a suppression carry would create exists now: `to` has its own.\n if (store === 'siteSuppressions' && code === 6) continue\n retry.push(source)\n }\n if (!retry.length) return written\n const fresh = await firestore.getAll(...retry.map((source) => targetList.doc(source.id)))\n due = retry\n .map((source, index) => ({\n source,\n target: fresh[index],\n decision: decide(store, source, fresh[index], carry, changeId),\n }))\n .filter((entry) => entry.decision !== null)\n }\n if (due.length) {\n throw new Error(\n `[consent-group-carry] ${due.length} document(s) on ${carry.toHostId} kept changing under the carry`,\n )\n }\n return written\n}\n"],"names":["readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","FieldPath","FieldValue","Timestamp","EMAIL_FREQUENCY_SUBCOLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","CONSENT_GROUP_CARRY_STORES","CONSENT_GROUP_CARRY_SUBCOLLECTIONS","siteSuppressions","topicOptOuts","paces","CATCH_UP_FIELDS","field","timestamp","SUPPRESSION_ROW_FIELDS","CONSENT_GROUP_CARRY_PAGE","readCursor","raw","parsed","JSON","parse","id","RACED","Set","codeOf","error","code","storedMs","value","Number","isFinite","toMillis","call","lastSentMs","data","stored","window","Array","isArray","map","filter","length","Math","max","decideSuppression","source","target","carry","changeId","exists","row","fromHostId","serverTimestamp","create","decideTopics","sourceTopics","get","targetTopics","next","changed","topicId","entry","Object","entries","optedOutAt","resubscribedAt","carriedFromHostId","email","topics","updatedAt","createdAt","decidePace","from","to","undefined","patch","sourceChoice","targetChoice","sourceSetAt","targetSetAt","carried","choice","sourceLast","targetLast","keys","decide","store","carryConsentGroupRefusals","options","firestore","subcollection","sourceList","collection","doc","targetList","toHostId","limit","pageSize","cursor","catchUp","since","sinceMs","query","where","fromMillis","orderBy","documentId","at","seconds","nanoseconds","startAfter","page","empty","done","read","written","targets","getAll","docs","decisions","index","decision","due","dryRun","writeDecisions","last","size","stringify","round","writer","bulkWriter","outcomes","ref","write","update","lastUpdateTime","updateTime","then","Error","close","settled","Promise","all","retry","has","push","fresh"],"mappings":"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"}
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
* the one the editor rendered, and no other change is in flight. It sets
|
|
28
28
|
* the marker `consentGroupsChange` and creates the job document.
|
|
29
29
|
* 2. CARRY — every carry of the plan through the three platform stores
|
|
30
|
-
* (`consent-group-carry.ts`)
|
|
30
|
+
* (`consent-group-carry.ts`) and the organization's retained refusals
|
|
31
|
+
* (`retained-refusals.ts`), then every participant's carry phase.
|
|
31
32
|
* 3. CATCH UP — everything filed since the carry began, again.
|
|
32
33
|
* 4. DECLARE — one transaction: the marker still names this change in its
|
|
33
34
|
* carry phase and the stored declaration is still the one the job
|