@aglyn/tenant-data-admin 1.0.0-beta.219 → 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-kit.d.ts +7 -0
- package/src/lib/server/api-v1-kit.js.map +1 -1
- package/src/lib/server/api-v1-resources.d.ts +1 -2
- package/src/lib/server/api-v1-resources.js +0 -1
- package/src/lib/server/api-v1-resources.js.map +1 -1
- 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.js +2 -2
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +3 -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.js +1 -1
- package/src/lib/server/email-suppression.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 +9 -1
- package/src/lib/server/member-email-aliases.js +25 -1
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/notifications.d.ts +14 -0
- package/src/lib/server/notifications.js +29 -0
- 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/payment-provider-stripe-connect-payouts.js +35 -1
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -1
- 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/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/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/{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.js +2 -2
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +1 -3
- package/src/tenancy.js +1 -7
- package/src/tenancy.js.map +1 -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/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 -168
- package/src/lib/server/crm-inbound-email.js +0 -282
- 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/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
* verified account asks the plugin that keeps the site's preferences to
|
|
64
64
|
* reopen that list and nothing else (`plugin-email-streams`).
|
|
65
65
|
*/
|
|
66
|
-
import { type TopicSubscriptionState } from '@aglyn/aglyn/app-utils/
|
|
66
|
+
import { type TopicSubscriptionState } from '@aglyn/aglyn/app-utils/subscription-topics';
|
|
67
67
|
import { type PlatformMarketingConsentDecision, type PlatformMarketingConsoleSourceKind, type PlatformMarketingConsentState } from '@aglyn/aglyn/app-utils/platform-marketing-consent';
|
|
68
68
|
import { type MarketingBasis, type MarketingConsentAssertedBy, type MarketingConsentReason, type MarketingConsentVerdict } from '@aglyn/aglyn/app-utils/marketing-consent';
|
|
69
69
|
import { type EmailStreamRejoinOutcome, rejoinEmailStream } from '@aglyn/aglyn/plugin-manager/plugin-email-streams';
|
|
@@ -62,7 +62,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
62
62
|
* The console's switch undoes it the same way, one list wide: a Yes from a
|
|
63
63
|
* verified account asks the plugin that keeps the site's preferences to
|
|
64
64
|
* reopen that list and nothing else (`plugin-email-streams`).
|
|
65
|
-
*/ import { readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/
|
|
65
|
+
*/ import { readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/subscription-topics";
|
|
66
66
|
import { isPlatformMarketingConsentDecision, isPlatformMarketingConsoleSourceKind, isPlatformMarketingEmailSourceKind, platformMarketingConsentSource, platformMarketingEmailSource, PLATFORM_MARKETING_TOPIC_ID, platformMarketingUserFields, readPlatformMarketingConsent, USER_MARKETING_PROMPT_DISMISSED_AT_FIELD } from "@aglyn/aglyn/app-utils/platform-marketing-consent";
|
|
67
67
|
import { marketingConsentDecision, readMarketingBasis, resolveMarketingConsentPolicy } from "@aglyn/aglyn/app-utils/marketing-consent";
|
|
68
68
|
import { rejoinEmailStream } from "@aglyn/aglyn/plugin-manager/plugin-email-streams";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/platform-marketing-consent.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The record a console door writes when a person decides about the\n * platform's own product email (AGL-3185).\n *\n * ## Two documents, one decision\n *\n * The decision goes onto the person's own `users/{uid}` first — the console\n * shows the preference from there, and it must not depend on the operator's\n * CRM accepting a row — and then onto the operator's marketing contact for\n * the same address through `upsertHostContact`, which is every other capture\n * door's path and the only one the send-time consent join reads. The contact\n * half carries the decision AND its provenance, so a campaign's audience\n * count reports this basis as the person's own click and never as something\n * asserted on their behalf.\n *\n * ## Server-side, because the client cannot and should not\n *\n * The sign-up page is a client of the person's own project; the operator's\n * marketing org is somebody else's data. Only the Admin SDK writes there, and\n * only after the person's ID token has been verified by the route in front\n * of this module. The user-document half could be a client write — the rules\n * make it owner-writable — but a mirror the client writes and the server\n * reads back is two writers of one fact, so both halves are written here.\n *\n * ## Which host, and why it is a setting\n *\n * The marketing host is the deployment's, not the code's: this repository is\n * what a self-hosted install runs, and an install with no product audience of\n * its own has nowhere for a contact to go. `PLATFORM_MARKETING_HOST_ID` names\n * it, and when it is unset the decision is recorded on the person's document\n * alone. No hostname or document id is hardcoded here for the same reason\n * `platform-marketing-host.ts` gives: nothing in the code knows which site\n * is the operator's.\n *\n * ## The site's own pages answer the same question, for one list (AGL-3305)\n *\n * The marketing site's emails carry their own way out, and it writes the\n * site's lists, not either consent record. An unsubscribe that was about the\n * list the console's question stands for — `PLATFORM_MARKETING_TOPIC_ID`, or\n * everything the site sends — is mirrored onto the ACCOUNT here, through an\n * email door; an unsubscribe from any other list is not, because the person\n * said nothing about product updates. The contact is left alone on purpose:\n * its basis covers every list the site sends, and declining it would stop the\n * newsletter for somebody who only left product updates.\n *\n * The console's switch undoes it the same way, one list wide: a Yes from a\n * verified account asks the plugin that keeps the site's preferences to\n * reopen that list and nothing else (`plugin-email-streams`).\n */\n\nimport {\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionEntry,\n type TopicSubscriptionState,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport {\n isPlatformMarketingConsentDecision,\n isPlatformMarketingConsoleSourceKind,\n isPlatformMarketingEmailSourceKind,\n type PlatformMarketingConsentDecision,\n type PlatformMarketingConsoleSourceKind,\n type PlatformMarketingConsentState,\n platformMarketingConsentSource,\n platformMarketingEmailSource,\n PLATFORM_MARKETING_TOPIC_ID,\n platformMarketingUserFields,\n readPlatformMarketingConsent,\n USER_MARKETING_PROMPT_DISMISSED_AT_FIELD,\n} from '@aglyn/aglyn/app-utils/platform-marketing-consent'\nimport {\n marketingConsentDecision,\n type MarketingBasis,\n type MarketingConsentAssertedBy,\n type MarketingConsentReason,\n type MarketingConsentVerdict,\n readMarketingBasis,\n resolveMarketingConsentPolicy,\n} from '@aglyn/aglyn/app-utils/marketing-consent'\nimport {\n type EmailStreamRejoinOutcome,\n rejoinEmailStream,\n} from '@aglyn/aglyn/plugin-manager/plugin-email-streams'\nimport { attributableAccountForAddress } from './account-addresses'\nimport { findContactByEmail } from './contact-email-index'\nimport {\n EMAIL_SUPPRESSIONS_COLLECTION,\n emailSuppressionKey,\n HOST_SUPPRESSIONS_SUBCOLLECTION,\n} from './email-suppression'\nimport firebaseAdmin from './firebase-admin'\nimport {\n consentGroupForSite,\n getOrgForHost,\n orgDataCollectionForHost,\n} from './organizations'\nimport { upsertHostContact } from './upsert-contact'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/**\n * The variable naming the host whose contacts receive product-marketing\n * consent — the operator's own marketing site. Unset on a self-hosted\n * install, and honored as \"record the preference, capture no contact\".\n */\nexport const PLATFORM_MARKETING_HOST_ID_ENV = 'PLATFORM_MARKETING_HOST_ID'\n\n/**\n * The configured marketing host, or `null` when the deployment names none.\n *\n * Read at call time rather than at module load, so a test can set it and a\n * process that started without it picks up a later value the same way every\n * other runtime setting here does.\n */\nexport function platformMarketingHostId(): string | null {\n const value = String(process.env.PLATFORM_MARKETING_HOST_ID ?? '').trim()\n return value || null\n}\n\n/** What became of the contact half of the record. */\nexport type PlatformMarketingContactOutcome =\n | { status: 'recorded'; contactId: string; created: boolean }\n /** No marketing host is configured — a self-hosted install. */\n | { status: 'unconfigured' }\n /** The account carries no address a contact could be keyed on. */\n | { status: 'no-email' }\n /** The capture door refused the row — its band, an erasure, or an error. */\n | { status: 'refused'; reason: string }\n\nexport interface RecordPlatformMarketingConsentInput {\n uid: string\n /** The account's address, from the verified token. */\n email: string | null | undefined\n /** The name the token carries, for an account whose document holds none. */\n name?: string | null\n decision: PlatformMarketingConsentDecision\n /** A console door. An email door is recorded by the site's own pages. */\n source: PlatformMarketingConsoleSourceKind\n /** The wording version the door showed, stamped by the route. */\n textVersion: string\n /**\n * Whether the token proves the caller holds the mailbox — its email is\n * verified (AGL-3305). Only then does a Yes reopen the list on the\n * marketing site, because reopening it reverses what somebody did from\n * that mailbox; an unverified account could name a stranger's address.\n */\n mailboxVerified?: boolean\n /** Injectable for tests; defaults to the clock. */\n now?: number\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n /** Injectable for tests; defaults to {@link platformMarketingHostId}. */\n hostId?: string | null\n /** Injectable for tests; defaults to the real capture door. */\n upsert?: typeof upsertHostContact\n /** Injectable for tests; defaults to the `plugin-email-streams` slot. */\n rejoin?: typeof rejoinEmailStream\n}\n\n/**\n * What a Yes did to the marketing site's own lists (AGL-3305). Absent for a\n * No, and whenever there is no site or no address to reopen a list for.\n */\nexport type PlatformMarketingStreamOutcome =\n | EmailStreamRejoinOutcome\n /** The token does not prove the mailbox, so nothing was reopened. */\n | { status: 'unverified' }\n\nexport interface RecordPlatformMarketingConsentResult {\n /** The timestamp both documents carry. */\n atMs: number\n contact: PlatformMarketingContactOutcome\n stream?: PlatformMarketingStreamOutcome\n}\n\n/**\n * The name the person's own document holds, else the one the caller has.\n *\n * The sign-up form writes `firstName`/`lastName` onto the document; a Google\n * account carries its name on the token instead. Either is the name the\n * contact row should show, and a blank is left blank rather than invented.\n */\nfunction accountName(\n snapshot: { get?: (field: string) => unknown } | null | undefined,\n fallback: string | null | undefined,\n): string | undefined {\n const first = String(snapshot?.get?.('firstName') ?? '').trim()\n const last = String(snapshot?.get?.('lastName') ?? '').trim()\n const stored = [first, last].filter(Boolean).join(' ')\n return stored || String(fallback ?? '').trim() || undefined\n}\n\n/**\n * Records a decision on both documents.\n *\n * Loud on bad input, like `recordLegalAcceptance`: a decision that cannot be\n * attributed to an account, a door and a wording is not a record of anything.\n * Quiet on the contact half — a refused row is returned, not thrown — because\n * the person's own preference has already been recorded by then, and the\n * operator's CRM band is not a reason to tell them their choice failed.\n */\nexport async function recordPlatformMarketingConsent(\n input: RecordPlatformMarketingConsentInput,\n): Promise<RecordPlatformMarketingConsentResult> {\n const uid = String(input.uid ?? '').trim()\n if (!uid) throw new Error('recordPlatformMarketingConsent: uid is required')\n if (!isPlatformMarketingConsentDecision(input.decision)) {\n throw new Error('recordPlatformMarketingConsent: decision is required')\n }\n if (!isPlatformMarketingConsoleSourceKind(input.source)) {\n throw new Error('recordPlatformMarketingConsent: a console source is required')\n }\n const textVersion = String(input.textVersion ?? '').trim()\n if (!textVersion) {\n throw new Error('recordPlatformMarketingConsent: textVersion is required')\n }\n\n const atMs = input.now ?? Date.now()\n const source = platformMarketingConsentSource({\n kind: input.source,\n decision: input.decision,\n uid,\n atMs,\n textVersion,\n })\n\n const userRef = (input.firestore ?? firestore()).collection('users').doc(uid)\n const snapshot = await userRef.get()\n await userRef.set(\n platformMarketingUserFields({ decision: input.decision, atMs, source }),\n { merge: true },\n )\n\n const hostId =\n input.hostId === undefined ? platformMarketingHostId() : input.hostId\n if (!hostId) return { atMs, contact: { status: 'unconfigured' } }\n const email = String(input.email ?? '').trim()\n if (!email) return { atMs, contact: { status: 'no-email' } }\n\n const name = accountName(snapshot, input.name)\n const verdict = await (input.upsert ?? upsertHostContact)({\n hostId,\n email,\n ...(name ? { name } : {}),\n source: 'account',\n // The sentence the provenance carries is also the timeline row's: what\n // the person did, in the words an auditor reads.\n interaction: { atMs, summary: source.reason },\n ...(input.decision === 'granted'\n ? { marketingConsent: true }\n : { declineMarketingConsent: true }),\n marketingConsentSource: source,\n // An account on the platform is at least a lead for the platform's own\n // CRM, whichever way the marketing decision went; the floor never moves\n // a customer back.\n initialLifecycleStage: 'lead',\n })\n const contact: PlatformMarketingContactOutcome =\n 'refused' in verdict\n ? { status: 'refused', reason: verdict.refused }\n : { status: 'recorded', contactId: verdict.contactId, created: verdict.created }\n if (input.decision !== 'granted') return { atMs, contact }\n\n /*\n * A Yes reopens the one list it stands for (AGL-3305).\n *\n * After the contact, whatever the contact's outcome: an erasure that\n * refused the row also left a suppression the plugin will not lift, and it\n * says so. One list wide, never \"resubscribe to everything\" — somebody who\n * left every list and now asks for product updates has asked for product\n * updates.\n */\n const stream: PlatformMarketingStreamOutcome = input.mailboxVerified\n ? await (input.rejoin ?? rejoinEmailStream)({\n hostId,\n email,\n topicId: PLATFORM_MARKETING_TOPIC_ID,\n })\n : { status: 'unverified' }\n return { atMs, contact, stream }\n}\n\n/**\n * Stamps a prompt dismissal — no decision — so the console stays quiet for\n * the snooze window and asks once more after it.\n */\nexport async function snoozePlatformMarketingPrompt(\n uid: string,\n options: { now?: number; firestore?: any } = {},\n): Promise<{ atMs: number }> {\n const trimmed = String(uid ?? '').trim()\n if (!trimmed) throw new Error('snoozePlatformMarketingPrompt: uid is required')\n const atMs = options.now ?? Date.now()\n await (options.firestore ?? firestore())\n .collection('users')\n .doc(trimmed)\n .set({ [USER_MARKETING_PROMPT_DISMISSED_AT_FIELD]: atMs }, { merge: true })\n return { atMs }\n}\n\n/** The decision the person's own document holds, for the console to show. */\nexport async function readPlatformMarketingConsentForUser(\n uid: string,\n options: { firestore?: any } = {},\n): Promise<PlatformMarketingConsentState> {\n const trimmed = String(uid ?? '').trim()\n if (!trimmed) throw new Error('readPlatformMarketingConsentForUser: uid is required')\n const snapshot = await (options.firestore ?? firestore())\n .collection('users')\n .doc(trimmed)\n .get()\n return readPlatformMarketingConsent(\n (snapshot?.data?.() as Record<string, unknown> | undefined) ?? null,\n )\n}\n\n/** An entry on one of the two lists every campaign send passes. */\nexport interface PlatformMarketingSuppression {\n /** `site`: the marketing site's own list. `platform`: every sender's. */\n list: 'site' | 'platform'\n /** `unsubscribe` on the site list; `bounce`, `complaint` or `staff` platform-wide. */\n reason: string\n}\n\n/**\n * What the operator's own product email would decide about one address\n * (AGL-3292), read the way a campaign from the marketing site reads it.\n *\n * The decision the person made lives on `users/{uid}`, and nothing a send\n * does looks there. A campaign from the configured marketing site reads the\n * CONTACT `recordPlatformMarketingConsent` wrote into that site's\n * organization — its basis for the site, under the organization's policy —\n * and then drops anybody on either suppression list, and a campaign on the\n * product-updates list anybody who left it. An unsubscribe writes those\n * lists; since AGL-3305 one about product updates also turns the account's\n * answer to No, but the contact's basis stays as it was, because it covers\n * every list the site sends. So a staff reader asking \"may we email them?\"\n * needs this half, and needs it from the sender's own readers — the same\n * basis read, consent group, policy and lists — or it answers a question no\n * send asks.\n */\nexport type PlatformMarketingReach =\n /** No marketing site is configured — a self-hosted install. */\n | { status: 'unconfigured' }\n /** The account carries no address a contact could be keyed on. */\n | { status: 'no-email' }\n /** A read failed. Nothing about the person follows from it. */\n | { status: 'unreadable'; hostId: string }\n | {\n status: 'read'\n hostId: string\n orgId: string\n orgSlug: string | null\n /** `null` when the operator's CRM holds no contact for the address. */\n contactId: string | null\n basis: MarketingBasis\n basisAtMs: number | null\n /** Whose act the basis is — a person's own, or an operator's assertion. */\n assertedBy: MarketingConsentAssertedBy | null\n /** Which door recorded the basis (`console-signup`, a form, a backfill). */\n basisKind: string | null\n /** The consent rule's answer alone, before either list is consulted. */\n verdict: MarketingConsentVerdict\n reason: MarketingConsentReason\n /** The list that stops every send to the address, when one does. */\n suppression: PlatformMarketingSuppression | null\n /**\n * The address on the list the account's answer stands for (AGL-3305):\n * `opted-out` when they left product updates, `pending` while a\n * confirmation is unanswered. A campaign on that list drops both.\n */\n topic: PlatformMarketingTopicState\n }\n\n/** One address on {@link PLATFORM_MARKETING_TOPIC_ID}, as the site stores it. */\nexport interface PlatformMarketingTopicState {\n id: string\n state: TopicSubscriptionState\n /** When they left it, for an `opted-out` state; else `null`. */\n leftAtMs: number | null\n}\n\n/**\n * The entry that suppresses `email` for sends from `hostId`, if any.\n *\n * The site's own list first, because it is the one a person puts themselves\n * on from this sender's footer; the platform list second. The same two reads\n * `filterSendableForHost` makes: a site entry suppresses by existing, and a\n * platform entry until it is released.\n */\nasync function readPlatformMarketingSuppression(\n db: any,\n hostId: string,\n email: string,\n): Promise<PlatformMarketingSuppression | null> {\n const key = emailSuppressionKey(email)\n if (!key) return null\n const [site, platform] = await Promise.all([\n db\n .collection('hosts')\n .doc(hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n .doc(key)\n .get(),\n db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key).get(),\n ])\n if (site?.exists) {\n return { list: 'site', reason: String(site.get('reason') ?? 'unsubscribe') }\n }\n if (platform?.exists && !platform.get('releasedAt')) {\n return { list: 'platform', reason: String(platform.get('reason') ?? 'staff') }\n }\n return null\n}\n\n/** Milliseconds from a stored Firestore timestamp or a number, else `null`. */\nfunction storedMs(value: unknown): number | null {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n const toMillis = (value as { toMillis?: () => number } | null)?.toMillis\n return typeof toMillis === 'function' ? toMillis.call(value) : null\n}\n\n/**\n * Where the address stands on {@link PLATFORM_MARKETING_TOPIC_ID}.\n *\n * The site's per-topic record, read by key and through the one function that\n * knows its three states — the same read `filterTopicSendable` makes before\n * a campaign on that list goes out. No record is `subscribed`: nobody has\n * left a list they were never taken off.\n */\nasync function readPlatformMarketingTopic(\n db: any,\n hostId: string,\n email: string,\n): Promise<PlatformMarketingTopicState> {\n const key = emailSuppressionKey(email)\n const unrecorded = { id: PLATFORM_MARKETING_TOPIC_ID, state: 'subscribed' as const, leftAtMs: null }\n if (!key) return unrecorded\n const snapshot = await db\n .collection('hosts')\n .doc(hostId)\n .collection(TOPIC_OPT_OUTS_SUBCOLLECTION)\n .doc(key)\n .get()\n if (!snapshot?.exists) return unrecorded\n const entry = ((snapshot.get('topics') ?? {}) as Record<string, TopicSubscriptionEntry | undefined>)[\n PLATFORM_MARKETING_TOPIC_ID\n ]\n const state = readTopicSubscriptionState(entry)\n return {\n id: PLATFORM_MARKETING_TOPIC_ID,\n state,\n leftAtMs: state === 'opted-out' ? storedMs(entry?.optedOutAt) : null,\n }\n}\n\n/**\n * Reads {@link PlatformMarketingReach} for an address.\n *\n * Never throws: a failed read is `unreadable`, which the staff page shows as\n * unknown — never as \"no consent\", which would be a claim about the person.\n */\nexport async function readPlatformMarketingReach(input: {\n email: string | null | undefined\n /** Injectable for tests; defaults to {@link platformMarketingHostId}. */\n hostId?: string | null\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n}): Promise<PlatformMarketingReach> {\n const hostId =\n input.hostId === undefined ? platformMarketingHostId() : input.hostId\n if (!hostId) return { status: 'unconfigured' }\n const email = String(input.email ?? '').trim().toLowerCase()\n if (!email) return { status: 'no-email' }\n const db = input.firestore ?? firestore()\n try {\n const owner = await getOrgForHost(hostId)\n if (!owner) return { status: 'unreadable', hostId }\n const org = owner.org as Record<string, unknown>\n const [contact, group, suppression, topic] = await Promise.all([\n // Through the org-data seam, as the capture door finds the same row.\n orgDataCollectionForHost(hostId, 'contacts').then((contacts) =>\n findContactByEmail(contacts, email),\n ),\n consentGroupForSite(hostId, org),\n readPlatformMarketingSuppression(db, hostId, email),\n readPlatformMarketingTopic(db, hostId, email),\n ])\n const record = readMarketingBasis(\n (contact?.data?.() as Record<string, unknown> | undefined) ?? null,\n group,\n )\n const decision = marketingConsentDecision(\n record,\n resolveMarketingConsentPolicy(org['marketingConsentPolicy']),\n )\n return {\n status: 'read',\n hostId,\n orgId: owner.orgId,\n orgSlug: typeof org['slug'] === 'string' ? org['slug'] : null,\n contactId: contact?.id ?? null,\n basis: record.basis,\n basisAtMs: record.basisAtMs,\n assertedBy: record.assertedBy,\n basisKind: record.source?.kind ?? null,\n verdict: decision.verdict,\n reason: decision.reason,\n suppression,\n topic,\n }\n } catch (error) {\n console.error('[platform-marketing-consent] reach read failed', error)\n return { status: 'unreadable', hostId }\n }\n}\n\n/**\n * Why somebody who said Yes will not receive product updates, in the terms\n * of what they can do about it (AGL-3305) — or `null` when nothing on the\n * marketing site's lists stands in the way.\n *\n * - `unsubscribed`: they left everything, or left product updates, from an\n * email. Theirs to undo, and a Yes from a verified account does.\n * - `unconfirmed`: a confirmation for the list was asked and not answered.\n * - `paused`: a bounce, a complaint, an erasure or a staff hold. Not a\n * preference, so no switch of theirs lifts it.\n *\n * The consent basis is not judged here: the Yes this is asked about is the\n * basis. Only the lists a Yes cannot overrule are.\n */\nexport type PlatformMarketingHold =\n | { kind: 'unsubscribed' }\n | { kind: 'unconfirmed' }\n | { kind: 'paused'; reason: string }\n\nexport function platformMarketingHold(\n reach: PlatformMarketingReach,\n): PlatformMarketingHold | null {\n if (reach.status !== 'read') return null\n if (reach.suppression) {\n return reach.suppression.list === 'site' && reach.suppression.reason === 'unsubscribe'\n ? { kind: 'unsubscribed' }\n : { kind: 'paused', reason: reach.suppression.reason }\n }\n if (reach.topic.state === 'opted-out') return { kind: 'unsubscribed' }\n if (reach.topic.state === 'pending') return { kind: 'unconfirmed' }\n return null\n}\n\n/** What mirroring an email-side change onto the account did (AGL-3305). */\nexport type PlatformMarketingMirrorOutcome =\n /** Another site's list. An account is never touched from one. */\n | { status: 'not-platform' }\n /** A list the account's answer is not about. */\n | { status: 'other-list' }\n /** No account holds the address — or more than one does. */\n | { status: 'no-account' }\n /** The account already says so, or the rule leaves it where it is. */\n | { status: 'unchanged' }\n | { status: 'recorded'; uid: string; decision: PlatformMarketingConsentDecision }\n /** Logged; the site's own write, which is what stops the mail, stands. */\n | { status: 'failed' }\n\nexport interface PlatformMarketingMirrorInput {\n /** The site whose page the person used. */\n hostId: string\n /** The address the signed link was for. */\n email: string\n /** Injectable for tests; defaults to the clock. */\n now?: number\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n /** Injectable for tests; defaults to {@link platformMarketingHostId}. */\n platformHostId?: string | null\n /**\n * Injectable for tests; defaults to `attributableAccountForAddress`, which\n * answers `null` for nobody AND for more than one account. Changing the\n * wrong person's answer is worse than changing nobody's.\n */\n findAccount?: (email: string) => Promise<string | null>\n}\n\n/** The account a mirror would write, or the reason there is none. */\nasync function mirrorTarget(\n input: PlatformMarketingMirrorInput,\n): Promise<{ uid: string; ref: any; state: PlatformMarketingConsentState } | PlatformMarketingMirrorOutcome> {\n const platformHost =\n input.platformHostId === undefined ? platformMarketingHostId() : input.platformHostId\n if (!platformHost || input.hostId !== platformHost) return { status: 'not-platform' }\n const uid = await (input.findAccount ?? ((email: string) => attributableAccountForAddress(email)))(\n input.email,\n )\n if (!uid) return { status: 'no-account' }\n const ref = (input.firestore ?? firestore()).collection('users').doc(uid)\n const snapshot = await ref.get()\n return {\n uid,\n ref,\n state: readPlatformMarketingConsent(\n (snapshot?.data?.() as Record<string, unknown> | undefined) ?? null,\n ),\n }\n}\n\n/**\n * Mirrors an unsubscribe from the marketing site onto the account's answer.\n *\n * Called by the site's own pages AFTER their write, with what the person\n * left: `'everything'` for the site's list, or the topics left on the\n * preference page. Only the platform's marketing site counts, and only a\n * list the answer is about — {@link PLATFORM_MARKETING_TOPIC_ID}, which\n * everything includes. The first No keeps its date and its door: a second\n * unsubscribe is not a later decision.\n *\n * Never throws. The page has already stopped the mail; this is the account\n * catching up with it.\n */\nexport async function mirrorPlatformUnsubscribe(\n input: PlatformMarketingMirrorInput & { left: 'everything' | readonly string[] },\n): Promise<PlatformMarketingMirrorOutcome> {\n try {\n const everything = input.left === 'everything'\n if (!everything && !input.left.includes(PLATFORM_MARKETING_TOPIC_ID)) {\n // Cheaper to answer before the account lookup, and the answer does not\n // depend on the site: no other list is this one on any site.\n const platformHost =\n input.platformHostId === undefined ? platformMarketingHostId() : input.platformHostId\n return input.hostId === platformHost ? { status: 'other-list' } : { status: 'not-platform' }\n }\n const target = await mirrorTarget(input)\n if ('status' in target) return target\n if (target.state.decision === 'declined') return { status: 'unchanged' }\n const atMs = input.now ?? Date.now()\n const source = platformMarketingEmailSource(\n everything\n ? { kind: 'email-unsubscribe', decision: 'declined', uid: target.uid, atMs }\n : { kind: 'email-preferences', decision: 'declined', uid: target.uid, atMs },\n )\n await target.ref.set(\n platformMarketingUserFields({ decision: 'declined', atMs, source }),\n { merge: true },\n )\n return { status: 'recorded', uid: target.uid, decision: 'declined' }\n } catch (error) {\n console.error('[platform-marketing-consent] could not mirror an unsubscribe onto the account', error)\n return { status: 'failed' }\n }\n}\n\n/**\n * Mirrors a way back in from the marketing site: the resubscribe link, or\n * the list ticked again on the preference page.\n *\n * Narrower than the unsubscribe on purpose. It restores only a No that an\n * email door recorded — a No given in the console is the person's answer in\n * the console, and a link in an email does not overturn it — and only once\n * product updates would really reach them: nothing on either suppression\n * list, and the list itself not left. A resubscribe that lifted the site's\n * list while product updates stay unticked restores nothing.\n */\nexport async function mirrorPlatformResubscribe(\n input: PlatformMarketingMirrorInput & { via: 'email-resubscribe' | 'email-preferences' },\n): Promise<PlatformMarketingMirrorOutcome> {\n try {\n const target = await mirrorTarget(input)\n if ('status' in target) return target\n if (\n target.state.decision !== 'declined' ||\n !isPlatformMarketingEmailSourceKind(target.state.sourceKind)\n ) {\n return { status: 'unchanged' }\n }\n const db = input.firestore ?? firestore()\n const [suppression, topic] = await Promise.all([\n readPlatformMarketingSuppression(db, input.hostId, input.email),\n readPlatformMarketingTopic(db, input.hostId, input.email),\n ])\n if (suppression || topic.state !== 'subscribed') return { status: 'unchanged' }\n const atMs = input.now ?? Date.now()\n const source = platformMarketingEmailSource({\n kind: input.via,\n decision: 'granted',\n uid: target.uid,\n atMs,\n })\n await target.ref.set(\n platformMarketingUserFields({ decision: 'granted', atMs, source }),\n { merge: true },\n )\n return { status: 'recorded', uid: target.uid, decision: 'granted' }\n } catch (error) {\n console.error('[platform-marketing-consent] could not mirror a resubscribe onto the account', error)\n return { status: 'failed' }\n }\n}\n"],"names":["readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","isPlatformMarketingConsentDecision","isPlatformMarketingConsoleSourceKind","isPlatformMarketingEmailSourceKind","platformMarketingConsentSource","platformMarketingEmailSource","PLATFORM_MARKETING_TOPIC_ID","platformMarketingUserFields","readPlatformMarketingConsent","USER_MARKETING_PROMPT_DISMISSED_AT_FIELD","marketingConsentDecision","readMarketingBasis","resolveMarketingConsentPolicy","rejoinEmailStream","attributableAccountForAddress","findContactByEmail","EMAIL_SUPPRESSIONS_COLLECTION","emailSuppressionKey","HOST_SUPPRESSIONS_SUBCOLLECTION","firebaseAdmin","consentGroupForSite","getOrgForHost","orgDataCollectionForHost","upsertHostContact","firestore","app","PLATFORM_MARKETING_HOST_ID_ENV","platformMarketingHostId","process","value","String","env","PLATFORM_MARKETING_HOST_ID","trim","accountName","snapshot","fallback","first","get","last","stored","filter","Boolean","join","undefined","recordPlatformMarketingConsent","input","uid","Error","decision","source","textVersion","atMs","now","Date","kind","userRef","collection","doc","set","merge","hostId","contact","status","email","name","verdict","upsert","interaction","summary","reason","marketingConsent","declineMarketingConsent","marketingConsentSource","initialLifecycleStage","refused","contactId","created","stream","mailboxVerified","rejoin","topicId","snoozePlatformMarketingPrompt","options","trimmed","readPlatformMarketingConsentForUser","data","readPlatformMarketingSuppression","db","key","site","platform","Promise","all","exists","list","storedMs","Number","isFinite","toMillis","call","readPlatformMarketingTopic","unrecorded","id","state","leftAtMs","entry","optedOutAt","readPlatformMarketingReach","toLowerCase","record","owner","org","group","suppression","topic","then","contacts","orgId","orgSlug","basis","basisAtMs","assertedBy","basisKind","error","console","platformMarketingHold","reach","mirrorTarget","platformHost","platformHostId","findAccount","ref","mirrorPlatformUnsubscribe","everything","left","includes","target","mirrorPlatformResubscribe","sourceKind","via"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/platform-marketing-consent.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The record a console door writes when a person decides about the\n * platform's own product email (AGL-3185).\n *\n * ## Two documents, one decision\n *\n * The decision goes onto the person's own `users/{uid}` first — the console\n * shows the preference from there, and it must not depend on the operator's\n * CRM accepting a row — and then onto the operator's marketing contact for\n * the same address through `upsertHostContact`, which is every other capture\n * door's path and the only one the send-time consent join reads. The contact\n * half carries the decision AND its provenance, so a campaign's audience\n * count reports this basis as the person's own click and never as something\n * asserted on their behalf.\n *\n * ## Server-side, because the client cannot and should not\n *\n * The sign-up page is a client of the person's own project; the operator's\n * marketing org is somebody else's data. Only the Admin SDK writes there, and\n * only after the person's ID token has been verified by the route in front\n * of this module. The user-document half could be a client write — the rules\n * make it owner-writable — but a mirror the client writes and the server\n * reads back is two writers of one fact, so both halves are written here.\n *\n * ## Which host, and why it is a setting\n *\n * The marketing host is the deployment's, not the code's: this repository is\n * what a self-hosted install runs, and an install with no product audience of\n * its own has nowhere for a contact to go. `PLATFORM_MARKETING_HOST_ID` names\n * it, and when it is unset the decision is recorded on the person's document\n * alone. No hostname or document id is hardcoded here for the same reason\n * `platform-marketing-host.ts` gives: nothing in the code knows which site\n * is the operator's.\n *\n * ## The site's own pages answer the same question, for one list (AGL-3305)\n *\n * The marketing site's emails carry their own way out, and it writes the\n * site's lists, not either consent record. An unsubscribe that was about the\n * list the console's question stands for — `PLATFORM_MARKETING_TOPIC_ID`, or\n * everything the site sends — is mirrored onto the ACCOUNT here, through an\n * email door; an unsubscribe from any other list is not, because the person\n * said nothing about product updates. The contact is left alone on purpose:\n * its basis covers every list the site sends, and declining it would stop the\n * newsletter for somebody who only left product updates.\n *\n * The console's switch undoes it the same way, one list wide: a Yes from a\n * verified account asks the plugin that keeps the site's preferences to\n * reopen that list and nothing else (`plugin-email-streams`).\n */\n\nimport {\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionEntry,\n type TopicSubscriptionState,\n} from '@aglyn/aglyn/app-utils/subscription-topics'\nimport {\n isPlatformMarketingConsentDecision,\n isPlatformMarketingConsoleSourceKind,\n isPlatformMarketingEmailSourceKind,\n type PlatformMarketingConsentDecision,\n type PlatformMarketingConsoleSourceKind,\n type PlatformMarketingConsentState,\n platformMarketingConsentSource,\n platformMarketingEmailSource,\n PLATFORM_MARKETING_TOPIC_ID,\n platformMarketingUserFields,\n readPlatformMarketingConsent,\n USER_MARKETING_PROMPT_DISMISSED_AT_FIELD,\n} from '@aglyn/aglyn/app-utils/platform-marketing-consent'\nimport {\n marketingConsentDecision,\n type MarketingBasis,\n type MarketingConsentAssertedBy,\n type MarketingConsentReason,\n type MarketingConsentVerdict,\n readMarketingBasis,\n resolveMarketingConsentPolicy,\n} from '@aglyn/aglyn/app-utils/marketing-consent'\nimport {\n type EmailStreamRejoinOutcome,\n rejoinEmailStream,\n} from '@aglyn/aglyn/plugin-manager/plugin-email-streams'\nimport { attributableAccountForAddress } from './account-addresses'\nimport { findContactByEmail } from './contact-email-index'\nimport {\n EMAIL_SUPPRESSIONS_COLLECTION,\n emailSuppressionKey,\n HOST_SUPPRESSIONS_SUBCOLLECTION,\n} from './email-suppression'\nimport firebaseAdmin from './firebase-admin'\nimport {\n consentGroupForSite,\n getOrgForHost,\n orgDataCollectionForHost,\n} from './organizations'\nimport { upsertHostContact } from './upsert-contact'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/**\n * The variable naming the host whose contacts receive product-marketing\n * consent — the operator's own marketing site. Unset on a self-hosted\n * install, and honored as \"record the preference, capture no contact\".\n */\nexport const PLATFORM_MARKETING_HOST_ID_ENV = 'PLATFORM_MARKETING_HOST_ID'\n\n/**\n * The configured marketing host, or `null` when the deployment names none.\n *\n * Read at call time rather than at module load, so a test can set it and a\n * process that started without it picks up a later value the same way every\n * other runtime setting here does.\n */\nexport function platformMarketingHostId(): string | null {\n const value = String(process.env.PLATFORM_MARKETING_HOST_ID ?? '').trim()\n return value || null\n}\n\n/** What became of the contact half of the record. */\nexport type PlatformMarketingContactOutcome =\n | { status: 'recorded'; contactId: string; created: boolean }\n /** No marketing host is configured — a self-hosted install. */\n | { status: 'unconfigured' }\n /** The account carries no address a contact could be keyed on. */\n | { status: 'no-email' }\n /** The capture door refused the row — its band, an erasure, or an error. */\n | { status: 'refused'; reason: string }\n\nexport interface RecordPlatformMarketingConsentInput {\n uid: string\n /** The account's address, from the verified token. */\n email: string | null | undefined\n /** The name the token carries, for an account whose document holds none. */\n name?: string | null\n decision: PlatformMarketingConsentDecision\n /** A console door. An email door is recorded by the site's own pages. */\n source: PlatformMarketingConsoleSourceKind\n /** The wording version the door showed, stamped by the route. */\n textVersion: string\n /**\n * Whether the token proves the caller holds the mailbox — its email is\n * verified (AGL-3305). Only then does a Yes reopen the list on the\n * marketing site, because reopening it reverses what somebody did from\n * that mailbox; an unverified account could name a stranger's address.\n */\n mailboxVerified?: boolean\n /** Injectable for tests; defaults to the clock. */\n now?: number\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n /** Injectable for tests; defaults to {@link platformMarketingHostId}. */\n hostId?: string | null\n /** Injectable for tests; defaults to the real capture door. */\n upsert?: typeof upsertHostContact\n /** Injectable for tests; defaults to the `plugin-email-streams` slot. */\n rejoin?: typeof rejoinEmailStream\n}\n\n/**\n * What a Yes did to the marketing site's own lists (AGL-3305). Absent for a\n * No, and whenever there is no site or no address to reopen a list for.\n */\nexport type PlatformMarketingStreamOutcome =\n | EmailStreamRejoinOutcome\n /** The token does not prove the mailbox, so nothing was reopened. */\n | { status: 'unverified' }\n\nexport interface RecordPlatformMarketingConsentResult {\n /** The timestamp both documents carry. */\n atMs: number\n contact: PlatformMarketingContactOutcome\n stream?: PlatformMarketingStreamOutcome\n}\n\n/**\n * The name the person's own document holds, else the one the caller has.\n *\n * The sign-up form writes `firstName`/`lastName` onto the document; a Google\n * account carries its name on the token instead. Either is the name the\n * contact row should show, and a blank is left blank rather than invented.\n */\nfunction accountName(\n snapshot: { get?: (field: string) => unknown } | null | undefined,\n fallback: string | null | undefined,\n): string | undefined {\n const first = String(snapshot?.get?.('firstName') ?? '').trim()\n const last = String(snapshot?.get?.('lastName') ?? '').trim()\n const stored = [first, last].filter(Boolean).join(' ')\n return stored || String(fallback ?? '').trim() || undefined\n}\n\n/**\n * Records a decision on both documents.\n *\n * Loud on bad input, like `recordLegalAcceptance`: a decision that cannot be\n * attributed to an account, a door and a wording is not a record of anything.\n * Quiet on the contact half — a refused row is returned, not thrown — because\n * the person's own preference has already been recorded by then, and the\n * operator's CRM band is not a reason to tell them their choice failed.\n */\nexport async function recordPlatformMarketingConsent(\n input: RecordPlatformMarketingConsentInput,\n): Promise<RecordPlatformMarketingConsentResult> {\n const uid = String(input.uid ?? '').trim()\n if (!uid) throw new Error('recordPlatformMarketingConsent: uid is required')\n if (!isPlatformMarketingConsentDecision(input.decision)) {\n throw new Error('recordPlatformMarketingConsent: decision is required')\n }\n if (!isPlatformMarketingConsoleSourceKind(input.source)) {\n throw new Error('recordPlatformMarketingConsent: a console source is required')\n }\n const textVersion = String(input.textVersion ?? '').trim()\n if (!textVersion) {\n throw new Error('recordPlatformMarketingConsent: textVersion is required')\n }\n\n const atMs = input.now ?? Date.now()\n const source = platformMarketingConsentSource({\n kind: input.source,\n decision: input.decision,\n uid,\n atMs,\n textVersion,\n })\n\n const userRef = (input.firestore ?? firestore()).collection('users').doc(uid)\n const snapshot = await userRef.get()\n await userRef.set(\n platformMarketingUserFields({ decision: input.decision, atMs, source }),\n { merge: true },\n )\n\n const hostId =\n input.hostId === undefined ? platformMarketingHostId() : input.hostId\n if (!hostId) return { atMs, contact: { status: 'unconfigured' } }\n const email = String(input.email ?? '').trim()\n if (!email) return { atMs, contact: { status: 'no-email' } }\n\n const name = accountName(snapshot, input.name)\n const verdict = await (input.upsert ?? upsertHostContact)({\n hostId,\n email,\n ...(name ? { name } : {}),\n source: 'account',\n // The sentence the provenance carries is also the timeline row's: what\n // the person did, in the words an auditor reads.\n interaction: { atMs, summary: source.reason },\n ...(input.decision === 'granted'\n ? { marketingConsent: true }\n : { declineMarketingConsent: true }),\n marketingConsentSource: source,\n // An account on the platform is at least a lead for the platform's own\n // CRM, whichever way the marketing decision went; the floor never moves\n // a customer back.\n initialLifecycleStage: 'lead',\n })\n const contact: PlatformMarketingContactOutcome =\n 'refused' in verdict\n ? { status: 'refused', reason: verdict.refused }\n : { status: 'recorded', contactId: verdict.contactId, created: verdict.created }\n if (input.decision !== 'granted') return { atMs, contact }\n\n /*\n * A Yes reopens the one list it stands for (AGL-3305).\n *\n * After the contact, whatever the contact's outcome: an erasure that\n * refused the row also left a suppression the plugin will not lift, and it\n * says so. One list wide, never \"resubscribe to everything\" — somebody who\n * left every list and now asks for product updates has asked for product\n * updates.\n */\n const stream: PlatformMarketingStreamOutcome = input.mailboxVerified\n ? await (input.rejoin ?? rejoinEmailStream)({\n hostId,\n email,\n topicId: PLATFORM_MARKETING_TOPIC_ID,\n })\n : { status: 'unverified' }\n return { atMs, contact, stream }\n}\n\n/**\n * Stamps a prompt dismissal — no decision — so the console stays quiet for\n * the snooze window and asks once more after it.\n */\nexport async function snoozePlatformMarketingPrompt(\n uid: string,\n options: { now?: number; firestore?: any } = {},\n): Promise<{ atMs: number }> {\n const trimmed = String(uid ?? '').trim()\n if (!trimmed) throw new Error('snoozePlatformMarketingPrompt: uid is required')\n const atMs = options.now ?? Date.now()\n await (options.firestore ?? firestore())\n .collection('users')\n .doc(trimmed)\n .set({ [USER_MARKETING_PROMPT_DISMISSED_AT_FIELD]: atMs }, { merge: true })\n return { atMs }\n}\n\n/** The decision the person's own document holds, for the console to show. */\nexport async function readPlatformMarketingConsentForUser(\n uid: string,\n options: { firestore?: any } = {},\n): Promise<PlatformMarketingConsentState> {\n const trimmed = String(uid ?? '').trim()\n if (!trimmed) throw new Error('readPlatformMarketingConsentForUser: uid is required')\n const snapshot = await (options.firestore ?? firestore())\n .collection('users')\n .doc(trimmed)\n .get()\n return readPlatformMarketingConsent(\n (snapshot?.data?.() as Record<string, unknown> | undefined) ?? null,\n )\n}\n\n/** An entry on one of the two lists every campaign send passes. */\nexport interface PlatformMarketingSuppression {\n /** `site`: the marketing site's own list. `platform`: every sender's. */\n list: 'site' | 'platform'\n /** `unsubscribe` on the site list; `bounce`, `complaint` or `staff` platform-wide. */\n reason: string\n}\n\n/**\n * What the operator's own product email would decide about one address\n * (AGL-3292), read the way a campaign from the marketing site reads it.\n *\n * The decision the person made lives on `users/{uid}`, and nothing a send\n * does looks there. A campaign from the configured marketing site reads the\n * CONTACT `recordPlatformMarketingConsent` wrote into that site's\n * organization — its basis for the site, under the organization's policy —\n * and then drops anybody on either suppression list, and a campaign on the\n * product-updates list anybody who left it. An unsubscribe writes those\n * lists; since AGL-3305 one about product updates also turns the account's\n * answer to No, but the contact's basis stays as it was, because it covers\n * every list the site sends. So a staff reader asking \"may we email them?\"\n * needs this half, and needs it from the sender's own readers — the same\n * basis read, consent group, policy and lists — or it answers a question no\n * send asks.\n */\nexport type PlatformMarketingReach =\n /** No marketing site is configured — a self-hosted install. */\n | { status: 'unconfigured' }\n /** The account carries no address a contact could be keyed on. */\n | { status: 'no-email' }\n /** A read failed. Nothing about the person follows from it. */\n | { status: 'unreadable'; hostId: string }\n | {\n status: 'read'\n hostId: string\n orgId: string\n orgSlug: string | null\n /** `null` when the operator's CRM holds no contact for the address. */\n contactId: string | null\n basis: MarketingBasis\n basisAtMs: number | null\n /** Whose act the basis is — a person's own, or an operator's assertion. */\n assertedBy: MarketingConsentAssertedBy | null\n /** Which door recorded the basis (`console-signup`, a form, a backfill). */\n basisKind: string | null\n /** The consent rule's answer alone, before either list is consulted. */\n verdict: MarketingConsentVerdict\n reason: MarketingConsentReason\n /** The list that stops every send to the address, when one does. */\n suppression: PlatformMarketingSuppression | null\n /**\n * The address on the list the account's answer stands for (AGL-3305):\n * `opted-out` when they left product updates, `pending` while a\n * confirmation is unanswered. A campaign on that list drops both.\n */\n topic: PlatformMarketingTopicState\n }\n\n/** One address on {@link PLATFORM_MARKETING_TOPIC_ID}, as the site stores it. */\nexport interface PlatformMarketingTopicState {\n id: string\n state: TopicSubscriptionState\n /** When they left it, for an `opted-out` state; else `null`. */\n leftAtMs: number | null\n}\n\n/**\n * The entry that suppresses `email` for sends from `hostId`, if any.\n *\n * The site's own list first, because it is the one a person puts themselves\n * on from this sender's footer; the platform list second. The same two reads\n * `filterSendableForHost` makes: a site entry suppresses by existing, and a\n * platform entry until it is released.\n */\nasync function readPlatformMarketingSuppression(\n db: any,\n hostId: string,\n email: string,\n): Promise<PlatformMarketingSuppression | null> {\n const key = emailSuppressionKey(email)\n if (!key) return null\n const [site, platform] = await Promise.all([\n db\n .collection('hosts')\n .doc(hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n .doc(key)\n .get(),\n db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key).get(),\n ])\n if (site?.exists) {\n return { list: 'site', reason: String(site.get('reason') ?? 'unsubscribe') }\n }\n if (platform?.exists && !platform.get('releasedAt')) {\n return { list: 'platform', reason: String(platform.get('reason') ?? 'staff') }\n }\n return null\n}\n\n/** Milliseconds from a stored Firestore timestamp or a number, else `null`. */\nfunction storedMs(value: unknown): number | null {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n const toMillis = (value as { toMillis?: () => number } | null)?.toMillis\n return typeof toMillis === 'function' ? toMillis.call(value) : null\n}\n\n/**\n * Where the address stands on {@link PLATFORM_MARKETING_TOPIC_ID}.\n *\n * The site's per-topic record, read by key and through the one function that\n * knows its three states — the same read `filterTopicSendable` makes before\n * a campaign on that list goes out. No record is `subscribed`: nobody has\n * left a list they were never taken off.\n */\nasync function readPlatformMarketingTopic(\n db: any,\n hostId: string,\n email: string,\n): Promise<PlatformMarketingTopicState> {\n const key = emailSuppressionKey(email)\n const unrecorded = { id: PLATFORM_MARKETING_TOPIC_ID, state: 'subscribed' as const, leftAtMs: null }\n if (!key) return unrecorded\n const snapshot = await db\n .collection('hosts')\n .doc(hostId)\n .collection(TOPIC_OPT_OUTS_SUBCOLLECTION)\n .doc(key)\n .get()\n if (!snapshot?.exists) return unrecorded\n const entry = ((snapshot.get('topics') ?? {}) as Record<string, TopicSubscriptionEntry | undefined>)[\n PLATFORM_MARKETING_TOPIC_ID\n ]\n const state = readTopicSubscriptionState(entry)\n return {\n id: PLATFORM_MARKETING_TOPIC_ID,\n state,\n leftAtMs: state === 'opted-out' ? storedMs(entry?.optedOutAt) : null,\n }\n}\n\n/**\n * Reads {@link PlatformMarketingReach} for an address.\n *\n * Never throws: a failed read is `unreadable`, which the staff page shows as\n * unknown — never as \"no consent\", which would be a claim about the person.\n */\nexport async function readPlatformMarketingReach(input: {\n email: string | null | undefined\n /** Injectable for tests; defaults to {@link platformMarketingHostId}. */\n hostId?: string | null\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n}): Promise<PlatformMarketingReach> {\n const hostId =\n input.hostId === undefined ? platformMarketingHostId() : input.hostId\n if (!hostId) return { status: 'unconfigured' }\n const email = String(input.email ?? '').trim().toLowerCase()\n if (!email) return { status: 'no-email' }\n const db = input.firestore ?? firestore()\n try {\n const owner = await getOrgForHost(hostId)\n if (!owner) return { status: 'unreadable', hostId }\n const org = owner.org as Record<string, unknown>\n const [contact, group, suppression, topic] = await Promise.all([\n // Through the org-data seam, as the capture door finds the same row.\n orgDataCollectionForHost(hostId, 'contacts').then((contacts) =>\n findContactByEmail(contacts, email),\n ),\n consentGroupForSite(hostId, org),\n readPlatformMarketingSuppression(db, hostId, email),\n readPlatformMarketingTopic(db, hostId, email),\n ])\n const record = readMarketingBasis(\n (contact?.data?.() as Record<string, unknown> | undefined) ?? null,\n group,\n )\n const decision = marketingConsentDecision(\n record,\n resolveMarketingConsentPolicy(org['marketingConsentPolicy']),\n )\n return {\n status: 'read',\n hostId,\n orgId: owner.orgId,\n orgSlug: typeof org['slug'] === 'string' ? org['slug'] : null,\n contactId: contact?.id ?? null,\n basis: record.basis,\n basisAtMs: record.basisAtMs,\n assertedBy: record.assertedBy,\n basisKind: record.source?.kind ?? null,\n verdict: decision.verdict,\n reason: decision.reason,\n suppression,\n topic,\n }\n } catch (error) {\n console.error('[platform-marketing-consent] reach read failed', error)\n return { status: 'unreadable', hostId }\n }\n}\n\n/**\n * Why somebody who said Yes will not receive product updates, in the terms\n * of what they can do about it (AGL-3305) — or `null` when nothing on the\n * marketing site's lists stands in the way.\n *\n * - `unsubscribed`: they left everything, or left product updates, from an\n * email. Theirs to undo, and a Yes from a verified account does.\n * - `unconfirmed`: a confirmation for the list was asked and not answered.\n * - `paused`: a bounce, a complaint, an erasure or a staff hold. Not a\n * preference, so no switch of theirs lifts it.\n *\n * The consent basis is not judged here: the Yes this is asked about is the\n * basis. Only the lists a Yes cannot overrule are.\n */\nexport type PlatformMarketingHold =\n | { kind: 'unsubscribed' }\n | { kind: 'unconfirmed' }\n | { kind: 'paused'; reason: string }\n\nexport function platformMarketingHold(\n reach: PlatformMarketingReach,\n): PlatformMarketingHold | null {\n if (reach.status !== 'read') return null\n if (reach.suppression) {\n return reach.suppression.list === 'site' && reach.suppression.reason === 'unsubscribe'\n ? { kind: 'unsubscribed' }\n : { kind: 'paused', reason: reach.suppression.reason }\n }\n if (reach.topic.state === 'opted-out') return { kind: 'unsubscribed' }\n if (reach.topic.state === 'pending') return { kind: 'unconfirmed' }\n return null\n}\n\n/** What mirroring an email-side change onto the account did (AGL-3305). */\nexport type PlatformMarketingMirrorOutcome =\n /** Another site's list. An account is never touched from one. */\n | { status: 'not-platform' }\n /** A list the account's answer is not about. */\n | { status: 'other-list' }\n /** No account holds the address — or more than one does. */\n | { status: 'no-account' }\n /** The account already says so, or the rule leaves it where it is. */\n | { status: 'unchanged' }\n | { status: 'recorded'; uid: string; decision: PlatformMarketingConsentDecision }\n /** Logged; the site's own write, which is what stops the mail, stands. */\n | { status: 'failed' }\n\nexport interface PlatformMarketingMirrorInput {\n /** The site whose page the person used. */\n hostId: string\n /** The address the signed link was for. */\n email: string\n /** Injectable for tests; defaults to the clock. */\n now?: number\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n /** Injectable for tests; defaults to {@link platformMarketingHostId}. */\n platformHostId?: string | null\n /**\n * Injectable for tests; defaults to `attributableAccountForAddress`, which\n * answers `null` for nobody AND for more than one account. Changing the\n * wrong person's answer is worse than changing nobody's.\n */\n findAccount?: (email: string) => Promise<string | null>\n}\n\n/** The account a mirror would write, or the reason there is none. */\nasync function mirrorTarget(\n input: PlatformMarketingMirrorInput,\n): Promise<{ uid: string; ref: any; state: PlatformMarketingConsentState } | PlatformMarketingMirrorOutcome> {\n const platformHost =\n input.platformHostId === undefined ? platformMarketingHostId() : input.platformHostId\n if (!platformHost || input.hostId !== platformHost) return { status: 'not-platform' }\n const uid = await (input.findAccount ?? ((email: string) => attributableAccountForAddress(email)))(\n input.email,\n )\n if (!uid) return { status: 'no-account' }\n const ref = (input.firestore ?? firestore()).collection('users').doc(uid)\n const snapshot = await ref.get()\n return {\n uid,\n ref,\n state: readPlatformMarketingConsent(\n (snapshot?.data?.() as Record<string, unknown> | undefined) ?? null,\n ),\n }\n}\n\n/**\n * Mirrors an unsubscribe from the marketing site onto the account's answer.\n *\n * Called by the site's own pages AFTER their write, with what the person\n * left: `'everything'` for the site's list, or the topics left on the\n * preference page. Only the platform's marketing site counts, and only a\n * list the answer is about — {@link PLATFORM_MARKETING_TOPIC_ID}, which\n * everything includes. The first No keeps its date and its door: a second\n * unsubscribe is not a later decision.\n *\n * Never throws. The page has already stopped the mail; this is the account\n * catching up with it.\n */\nexport async function mirrorPlatformUnsubscribe(\n input: PlatformMarketingMirrorInput & { left: 'everything' | readonly string[] },\n): Promise<PlatformMarketingMirrorOutcome> {\n try {\n const everything = input.left === 'everything'\n if (!everything && !input.left.includes(PLATFORM_MARKETING_TOPIC_ID)) {\n // Cheaper to answer before the account lookup, and the answer does not\n // depend on the site: no other list is this one on any site.\n const platformHost =\n input.platformHostId === undefined ? platformMarketingHostId() : input.platformHostId\n return input.hostId === platformHost ? { status: 'other-list' } : { status: 'not-platform' }\n }\n const target = await mirrorTarget(input)\n if ('status' in target) return target\n if (target.state.decision === 'declined') return { status: 'unchanged' }\n const atMs = input.now ?? Date.now()\n const source = platformMarketingEmailSource(\n everything\n ? { kind: 'email-unsubscribe', decision: 'declined', uid: target.uid, atMs }\n : { kind: 'email-preferences', decision: 'declined', uid: target.uid, atMs },\n )\n await target.ref.set(\n platformMarketingUserFields({ decision: 'declined', atMs, source }),\n { merge: true },\n )\n return { status: 'recorded', uid: target.uid, decision: 'declined' }\n } catch (error) {\n console.error('[platform-marketing-consent] could not mirror an unsubscribe onto the account', error)\n return { status: 'failed' }\n }\n}\n\n/**\n * Mirrors a way back in from the marketing site: the resubscribe link, or\n * the list ticked again on the preference page.\n *\n * Narrower than the unsubscribe on purpose. It restores only a No that an\n * email door recorded — a No given in the console is the person's answer in\n * the console, and a link in an email does not overturn it — and only once\n * product updates would really reach them: nothing on either suppression\n * list, and the list itself not left. A resubscribe that lifted the site's\n * list while product updates stay unticked restores nothing.\n */\nexport async function mirrorPlatformResubscribe(\n input: PlatformMarketingMirrorInput & { via: 'email-resubscribe' | 'email-preferences' },\n): Promise<PlatformMarketingMirrorOutcome> {\n try {\n const target = await mirrorTarget(input)\n if ('status' in target) return target\n if (\n target.state.decision !== 'declined' ||\n !isPlatformMarketingEmailSourceKind(target.state.sourceKind)\n ) {\n return { status: 'unchanged' }\n }\n const db = input.firestore ?? firestore()\n const [suppression, topic] = await Promise.all([\n readPlatformMarketingSuppression(db, input.hostId, input.email),\n readPlatformMarketingTopic(db, input.hostId, input.email),\n ])\n if (suppression || topic.state !== 'subscribed') return { status: 'unchanged' }\n const atMs = input.now ?? Date.now()\n const source = platformMarketingEmailSource({\n kind: input.via,\n decision: 'granted',\n uid: target.uid,\n atMs,\n })\n await target.ref.set(\n platformMarketingUserFields({ decision: 'granted', atMs, source }),\n { merge: true },\n )\n return { status: 'recorded', uid: target.uid, decision: 'granted' }\n } catch (error) {\n console.error('[platform-marketing-consent] could not mirror a resubscribe onto the account', error)\n return { status: 'failed' }\n }\n}\n"],"names":["readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","isPlatformMarketingConsentDecision","isPlatformMarketingConsoleSourceKind","isPlatformMarketingEmailSourceKind","platformMarketingConsentSource","platformMarketingEmailSource","PLATFORM_MARKETING_TOPIC_ID","platformMarketingUserFields","readPlatformMarketingConsent","USER_MARKETING_PROMPT_DISMISSED_AT_FIELD","marketingConsentDecision","readMarketingBasis","resolveMarketingConsentPolicy","rejoinEmailStream","attributableAccountForAddress","findContactByEmail","EMAIL_SUPPRESSIONS_COLLECTION","emailSuppressionKey","HOST_SUPPRESSIONS_SUBCOLLECTION","firebaseAdmin","consentGroupForSite","getOrgForHost","orgDataCollectionForHost","upsertHostContact","firestore","app","PLATFORM_MARKETING_HOST_ID_ENV","platformMarketingHostId","process","value","String","env","PLATFORM_MARKETING_HOST_ID","trim","accountName","snapshot","fallback","first","get","last","stored","filter","Boolean","join","undefined","recordPlatformMarketingConsent","input","uid","Error","decision","source","textVersion","atMs","now","Date","kind","userRef","collection","doc","set","merge","hostId","contact","status","email","name","verdict","upsert","interaction","summary","reason","marketingConsent","declineMarketingConsent","marketingConsentSource","initialLifecycleStage","refused","contactId","created","stream","mailboxVerified","rejoin","topicId","snoozePlatformMarketingPrompt","options","trimmed","readPlatformMarketingConsentForUser","data","readPlatformMarketingSuppression","db","key","site","platform","Promise","all","exists","list","storedMs","Number","isFinite","toMillis","call","readPlatformMarketingTopic","unrecorded","id","state","leftAtMs","entry","optedOutAt","readPlatformMarketingReach","toLowerCase","record","owner","org","group","suppression","topic","then","contacts","orgId","orgSlug","basis","basisAtMs","assertedBy","basisKind","error","console","platformMarketingHold","reach","mirrorTarget","platformHost","platformHostId","findAccount","ref","mirrorPlatformUnsubscribe","everything","left","includes","target","mirrorPlatformResubscribe","sourceKind","via"],"mappings":"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"}
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
-
import { type ResolvedRiskAction, type RiskEventKind } from '@aglyn/shared-util-email/risk-notice-catalog';
|
|
17
|
+
import { type ResolvedRiskAction, type RiskEventKind, type RiskNoticeSeverity } from '@aglyn/shared-util-email/risk-notice-catalog';
|
|
18
|
+
import type { NotificationLevel } from '@aglyn/aglyn/server';
|
|
18
19
|
import { type SendEmailOptions, type SendEmailResult } from '@aglyn/shared-util-email';
|
|
19
20
|
import { type HeldPageSubject, type HeldPageTarget, type PageHoldNoticeKind } from '@aglyn/shared-util-email/held-page';
|
|
20
21
|
import { type NotificationPayload, type NotifyUsersOptions } from './notifications';
|
|
@@ -25,6 +26,11 @@ export declare const RISK_NOTICE_COLLECTION = "riskNotices";
|
|
|
25
26
|
export declare const RISK_NOTICE_LEDGER_COLLECTION = "riskNoticeLedger";
|
|
26
27
|
/** The in-app notification type every owner notice is written as. */
|
|
27
28
|
export declare const RISK_NOTICE_NOTIFICATION_TYPE: "system.riskNotice";
|
|
29
|
+
/**
|
|
30
|
+
* The level an owner notice is drawn at (AGL-3437), from its kind's
|
|
31
|
+
* severity: a release or a lift is `info`, not the red of the hold it ends.
|
|
32
|
+
*/
|
|
33
|
+
export declare const RISK_NOTICE_LEVELS: Record<RiskNoticeSeverity, NotificationLevel>;
|
|
28
34
|
/**
|
|
29
35
|
* The burst allowance: how many notices one workspace's owners, and staff
|
|
30
36
|
* about one workspace, receive one by one in {@link RISK_NOTICE_BURST.windowMs}
|
|
@@ -66,7 +72,20 @@ export interface RiskEventInput {
|
|
|
66
72
|
occurredAtMs?: number;
|
|
67
73
|
/** `$56.00 USD`. */
|
|
68
74
|
amount?: string | null;
|
|
75
|
+
/**
|
|
76
|
+
* What happened to the payment, finishing "the payment was …"
|
|
77
|
+
* (`RISK_PAYMENT_EVENTS`): a fraud report, a review, a dispute.
|
|
78
|
+
*/
|
|
79
|
+
paymentEvent?: string | null;
|
|
69
80
|
evidenceDueByMs?: number | null;
|
|
81
|
+
/**
|
|
82
|
+
* A new publisher's payout schedule: how many days each payout waits, and
|
|
83
|
+
* the workspace age it waits until.
|
|
84
|
+
*/
|
|
85
|
+
payout?: {
|
|
86
|
+
delayDays: number;
|
|
87
|
+
untilWorkspaceAgeDays: number;
|
|
88
|
+
} | null;
|
|
70
89
|
/** Staff only: the Stripe Dashboard page for the charge, review or dispute. */
|
|
71
90
|
stripeUrl?: string | null;
|
|
72
91
|
lock?: {
|
|
@@ -87,6 +106,11 @@ export interface RiskEventInput {
|
|
|
87
106
|
staffEvidence?: string | null;
|
|
88
107
|
/** Overrides the default dedupe key. */
|
|
89
108
|
dedupeKey?: string | null;
|
|
109
|
+
/**
|
|
110
|
+
* A closing notice's opening kind. The closing goes to the people the
|
|
111
|
+
* opening went to: the site's managers too, when the opening told them.
|
|
112
|
+
*/
|
|
113
|
+
openedAs?: RiskEventKind | null;
|
|
90
114
|
/**
|
|
91
115
|
* `false` to send no owner EMAIL for this event (a lock placed under a
|
|
92
116
|
* legal hold). The in-app notice and the record are still written.
|
|
@@ -178,6 +202,13 @@ export interface RenderedRiskNoticeEmail {
|
|
|
178
202
|
* platform does (holds, locks, lifts, cancellations, reviews).
|
|
179
203
|
*/
|
|
180
204
|
export declare function riskNoticeEmailBrand(kind: RiskEventKind | null, orgId: string | null): Promise<SystemEmailBrand>;
|
|
205
|
+
/**
|
|
206
|
+
* The product name a notice's own words use (`your Aglyn account`): the one
|
|
207
|
+
* the email it rides in is branded with, so the sentence and the header
|
|
208
|
+
* never disagree — the platform's for platform mail, a white-label
|
|
209
|
+
* workspace's own for the notices sent in its brand.
|
|
210
|
+
*/
|
|
211
|
+
export declare function riskNoticeProductName(brand: SystemEmailBrand): string;
|
|
181
212
|
/**
|
|
182
213
|
* Renders one notice email through the System emails seam (AGL-3367): the
|
|
183
214
|
* published design for the kind's own `risk-…` key, or its built-in copy,
|
|
@@ -57,8 +57,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
57
57
|
* short "more are arriving" note in-app; when the hour closes,
|
|
58
58
|
* {@link flushRiskNoticeDigests} (hourly cron, and the next notice after the
|
|
59
59
|
* window) sends ONE summary. A workspace under attack gets a digest, not
|
|
60
|
-
* five hundred emails. Locks, lifts
|
|
61
|
-
* go out on their own.
|
|
60
|
+
* five hundred emails. Locks, lifts, cancellations, disputes and card-testing
|
|
61
|
+
* warnings (`neverDigest`) always go out on their own.
|
|
62
62
|
*
|
|
63
63
|
* ## Never throws
|
|
64
64
|
*
|
|
@@ -68,7 +68,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
68
68
|
*=========================================*/ import { createHash } from "crypto";
|
|
69
69
|
import { ABUSE_REPORT_COLLECTION, abuseReportContactEmail } from "@aglyn/aglyn/app-utils/abuse-report";
|
|
70
70
|
import { DOCS_BASE_URL } from "@aglyn/aglyn/app-utils/docs-help";
|
|
71
|
-
import {
|
|
71
|
+
import { PLATFORM_BRAND_MERGE_TOKENS } from "@aglyn/aglyn/app-utils/system-email-chrome";
|
|
72
|
+
import { isRiskEventKind, renderOwnerRiskNotice, renderRiskNoticeText, renderStaffRiskNotice, resolveOwnerRiskActions, RISK_NOTICE_CATALOG, RISK_NOTICE_CLOSING_KINDS, RISK_NOTICE_DIGEST_EMAIL_KEY, RISK_NOTICE_HELP_PATH, RISK_NOTICE_WORKSPACE_BRANDED, riskNoticeEmailKey, riskPayoutDelayText } from "@aglyn/shared-util-email/risk-notice-catalog";
|
|
72
73
|
import { sendEmail as sendEmailImpl } from "@aglyn/shared-util-email";
|
|
73
74
|
import { heldPageDetails, heldPageLabel, heldPageTargets, heldPageVisitorSentence, isOpenPageHoldStatus, isPageHoldNoticeKind, PAGE_HOLD_NOTICE_KINDS, pageHoldChip, parseHeldPageItemPath } from "@aglyn/shared-util-email/held-page";
|
|
74
75
|
import { FieldValue } from "firebase-admin/firestore";
|
|
@@ -82,6 +83,14 @@ import { RISK_REVIEW_REQUESTED_EMAIL_KEY } from "@aglyn/shared-util-email/risk-n
|
|
|
82
83
|
/** Admin-SDK only; no client rule opens it. One document per notice. */ export const RISK_NOTICE_COLLECTION = 'riskNotices';
|
|
83
84
|
/** Admin-SDK only: each workspace's burst window and folded notices. */ export const RISK_NOTICE_LEDGER_COLLECTION = 'riskNoticeLedger';
|
|
84
85
|
/** The in-app notification type every owner notice is written as. */ export const RISK_NOTICE_NOTIFICATION_TYPE = 'system.riskNotice';
|
|
86
|
+
/**
|
|
87
|
+
* The level an owner notice is drawn at (AGL-3437), from its kind's
|
|
88
|
+
* severity: a release or a lift is `info`, not the red of the hold it ends.
|
|
89
|
+
*/ export const RISK_NOTICE_LEVELS = {
|
|
90
|
+
urgent: 'critical',
|
|
91
|
+
warning: 'warning',
|
|
92
|
+
info: 'info'
|
|
93
|
+
};
|
|
85
94
|
/**
|
|
86
95
|
* The burst allowance: how many notices one workspace's owners, and staff
|
|
87
96
|
* about one workspace, receive one by one in {@link RISK_NOTICE_BURST.windowMs}
|
|
@@ -249,12 +258,19 @@ function consoleOrigin() {
|
|
|
249
258
|
var _input_orgId;
|
|
250
259
|
let orgId = (_input_orgId = input.orgId) != null ? _input_orgId : null;
|
|
251
260
|
let hostSubdomain = null;
|
|
261
|
+
let siteLabel = null;
|
|
252
262
|
let siteManagerUids = [];
|
|
253
263
|
if (input.hostId) {
|
|
254
264
|
const host = await firestore.collection('hosts').doc(input.hostId).get().catch(()=>null);
|
|
255
265
|
if (host == null ? void 0 : host.exists) {
|
|
256
|
-
var _host_get, _host_get1, _host_get2;
|
|
266
|
+
var _host_get, _map_find, _host_get1, _host_get2;
|
|
257
267
|
hostSubdomain = String((_host_get = host.get('subdomain')) != null ? _host_get : '') || null;
|
|
268
|
+
const siteName = (_map_find = [
|
|
269
|
+
host.get('displayName'),
|
|
270
|
+
host.get('name'),
|
|
271
|
+
hostSubdomain
|
|
272
|
+
].map((value)=>typeof value === 'string' ? value.trim() : '').find(Boolean)) != null ? _map_find : '';
|
|
273
|
+
siteLabel = siteName ? `the site "${siteName.replace(/"/g, '”')}"` : null;
|
|
258
274
|
orgId = orgId != null ? orgId : String((_host_get1 = host.get('orgId')) != null ? _host_get1 : '') || null;
|
|
259
275
|
const roles = (_host_get2 = host.get('memberRoles')) != null ? _host_get2 : {};
|
|
260
276
|
siteManagerUids = Object.entries(roles).filter(([, role])=>role === 'admin' || role === 'editor').map(([uid])=>uid);
|
|
@@ -275,6 +291,7 @@ function consoleOrigin() {
|
|
|
275
291
|
orgName,
|
|
276
292
|
orgSlug,
|
|
277
293
|
hostSubdomain,
|
|
294
|
+
siteLabel,
|
|
278
295
|
siteManagerUids
|
|
279
296
|
};
|
|
280
297
|
}
|
|
@@ -285,6 +302,15 @@ function consoleOrigin() {
|
|
|
285
302
|
*/ export async function riskNoticeEmailBrand(kind, orgId) {
|
|
286
303
|
return kind && RISK_NOTICE_WORKSPACE_BRANDED.has(kind) ? orgSystemEmailBrand(orgId) : systemEmailBrand(null);
|
|
287
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* The product name a notice's own words use (`your Aglyn account`): the one
|
|
307
|
+
* the email it rides in is branded with, so the sentence and the header
|
|
308
|
+
* never disagree — the platform's for platform mail, a white-label
|
|
309
|
+
* workspace's own for the notices sent in its brand.
|
|
310
|
+
*/ export function riskNoticeProductName(brand) {
|
|
311
|
+
var _brand_merge_brandproductName, _PLATFORM_BRAND_MERGE_TOKENS_brandproductName;
|
|
312
|
+
return String((_brand_merge_brandproductName = brand.merge['brand.productName']) != null ? _brand_merge_brandproductName : '').trim() || String((_PLATFORM_BRAND_MERGE_TOKENS_brandproductName = PLATFORM_BRAND_MERGE_TOKENS['brand.productName']) != null ? _PLATFORM_BRAND_MERGE_TOKENS_brandproductName : '').trim();
|
|
313
|
+
}
|
|
288
314
|
/**
|
|
289
315
|
* Renders one notice email through the System emails seam (AGL-3367): the
|
|
290
316
|
* published design for the kind's own `risk-…` key, or its built-in copy,
|
|
@@ -429,7 +455,11 @@ function emptyDelivery(skipped = null) {
|
|
|
429
455
|
if (owner.email) emails[owner.uid] = owner.email;
|
|
430
456
|
}
|
|
431
457
|
}
|
|
432
|
-
|
|
458
|
+
// A closing kind is shared by openings with different audiences, so it
|
|
459
|
+
// follows the one it closes: a site manager told a review opened is told
|
|
460
|
+
// it closed.
|
|
461
|
+
const audience = isRiskEventKind(input.openedAs) ? RISK_NOTICE_CATALOG[input.openedAs] : definition;
|
|
462
|
+
if (audience.includeSiteManagers) for (const uid of siteManagerUids)uids.add(uid);
|
|
433
463
|
return {
|
|
434
464
|
uids: [
|
|
435
465
|
...uids
|
|
@@ -469,7 +499,7 @@ function emptyDelivery(skipped = null) {
|
|
|
469
499
|
const noticeId = riskNoticeId(riskNoticeDedupeKey(input, occurredAtMs));
|
|
470
500
|
result.noticeId = noticeId;
|
|
471
501
|
try {
|
|
472
|
-
var _abuseReportContactEmail, _ref1, _input_reference, _ref2, _ref3, _input_amount, _context_orgName, _input_staffEvidence, _ref4, _input_reviewId, _input_hostId, _input_stripeUrl, _ref5, _ref6, _ownerActions_find;
|
|
502
|
+
var _abuseReportContactEmail, _ref1, _input_reference, _ref2, _ref3, _input_amount, _input_paymentEvent, _context_orgName, _input_staffEvidence, _ref4, _input_reviewId, _input_hostId, _input_stripeUrl, _ref5, _ref6, _ownerActions_find;
|
|
473
503
|
var _input_item, _input_lock, _input_lock1, _input_item1, _input_lock2, _input_lock3;
|
|
474
504
|
const context = await readContext(deps.firestore, input);
|
|
475
505
|
const orgId = context.orgId;
|
|
@@ -480,8 +510,12 @@ function emptyDelivery(skipped = null) {
|
|
|
480
510
|
* The values the OWNER half renders with. `staff.evidence` is absent by
|
|
481
511
|
* construction: the owner record and the owner channels never hold it.
|
|
482
512
|
*/ const supportEmail = (_abuseReportContactEmail = abuseReportContactEmail('support')) != null ? _abuseReportContactEmail : '';
|
|
513
|
+
// The brand the email is sent in, read once: its product name is also a
|
|
514
|
+
// word in the notice (an account lock says whose account it is).
|
|
515
|
+
const brand = await riskNoticeEmailBrand(input.kind, orgId);
|
|
483
516
|
const ownerValues = {
|
|
484
517
|
'workspace.name': context.orgName,
|
|
518
|
+
'site.label': context.siteLabel,
|
|
485
519
|
'item.label': (_ref1 = (_input_item = input.item) == null ? void 0 : _input_item.label) != null ? _ref1 : null,
|
|
486
520
|
occurredAt: formatRiskNoticeTime(occurredAtMs),
|
|
487
521
|
reference: (_input_reference = input.reference) != null ? _input_reference : null,
|
|
@@ -489,7 +523,11 @@ function emptyDelivery(skipped = null) {
|
|
|
489
523
|
'lock.message': (_ref2 = (_input_lock = input.lock) == null ? void 0 : _input_lock.message) != null ? _ref2 : null,
|
|
490
524
|
'lock.affected': (_ref3 = (_input_lock1 = input.lock) == null ? void 0 : _input_lock1.affected) != null ? _ref3 : null,
|
|
491
525
|
amount: (_input_amount = input.amount) != null ? _input_amount : null,
|
|
492
|
-
'
|
|
526
|
+
'payment.event': (_input_paymentEvent = input.paymentEvent) != null ? _input_paymentEvent : null,
|
|
527
|
+
'evidence.dueBy': input.evidenceDueByMs ? formatRiskNoticeTime(input.evidenceDueByMs) : null,
|
|
528
|
+
'payout.delay': input.payout ? riskPayoutDelayText(input.payout) : null,
|
|
529
|
+
'page.visitors': input.page ? heldPageVisitorSentence(input.page) : null,
|
|
530
|
+
'brand.productName': riskNoticeProductName(brand) || null
|
|
493
531
|
};
|
|
494
532
|
const staffValues = _extends({}, ownerValues, {
|
|
495
533
|
'workspace.name': (_context_orgName = context.orgName) != null ? _context_orgName : orgId,
|
|
@@ -578,6 +616,7 @@ function emptyDelivery(skipped = null) {
|
|
|
578
616
|
type: RISK_NOTICE_NOTIFICATION_TYPE,
|
|
579
617
|
title: owner.title,
|
|
580
618
|
body: `${owner.summary} ${owner.meaning}`.slice(0, 1000),
|
|
619
|
+
level: RISK_NOTICE_LEVELS[definition.severity],
|
|
581
620
|
link: (_ref9 = primary == null ? void 0 : primary.href) != null ? _ref9 : riskHoldsPath(noticeId)
|
|
582
621
|
}, orgId ? {
|
|
583
622
|
orgId
|
|
@@ -613,7 +652,7 @@ function emptyDelivery(skipped = null) {
|
|
|
613
652
|
workspaceName: context.orgName,
|
|
614
653
|
lockMessage: (_ref10 = (_input_lock4 = input.lock) == null ? void 0 : _input_lock4.message) != null ? _ref10 : null
|
|
615
654
|
});
|
|
616
|
-
const rendered = await renderRiskNoticeEmail(riskNoticeEmailKey(input.kind), merge,
|
|
655
|
+
const rendered = await renderRiskNoticeEmail(riskNoticeEmailKey(input.kind), merge, brand);
|
|
617
656
|
const delivery = await emailPeople(deps, recipients, rendered, supportEmail, {
|
|
618
657
|
lockNotice: LOCK_NOTICE_KINDS.has(input.kind)
|
|
619
658
|
});
|
|
@@ -629,6 +668,7 @@ function emptyDelivery(skipped = null) {
|
|
|
629
668
|
await deps.notifyUsers(recipients.uids, _extends({
|
|
630
669
|
type: RISK_NOTICE_NOTIFICATION_TYPE,
|
|
631
670
|
title: 'More account notices are arriving',
|
|
671
|
+
level: 'warning',
|
|
632
672
|
body: 'Several items on your workspace were held or flagged in the last hour. ' + 'We will send one summary instead of a message for each. Every item is listed on Holds & reviews.',
|
|
633
673
|
link: riskHoldsPath()
|
|
634
674
|
}, orgId ? {
|
|
@@ -818,18 +858,17 @@ function isAlreadyExists(error) {
|
|
|
818
858
|
if (count > listed) lines.push(`• …and ${count - listed} more`);
|
|
819
859
|
return lines.join('\n');
|
|
820
860
|
};
|
|
861
|
+
// The workspace by name, for both halves: staff read a digest about
|
|
862
|
+
// `Harbor View`, not about a ledger key.
|
|
863
|
+
const context = orgId ? await readContext(deps.firestore, {
|
|
864
|
+
kind: 'email-held',
|
|
865
|
+
orgId
|
|
866
|
+
}) : {
|
|
867
|
+
orgName: null,
|
|
868
|
+
orgSlug: null
|
|
869
|
+
};
|
|
821
870
|
if (taken.ownerCount > 0) {
|
|
822
871
|
var _context_orgName;
|
|
823
|
-
const context = orgId ? await readContext(deps.firestore, {
|
|
824
|
-
kind: 'email-held',
|
|
825
|
-
orgId
|
|
826
|
-
}) : {
|
|
827
|
-
orgName: null,
|
|
828
|
-
orgSlug: null,
|
|
829
|
-
siteManagerUids: [],
|
|
830
|
-
orgId: null,
|
|
831
|
-
hostSubdomain: null
|
|
832
|
-
};
|
|
833
872
|
const recipients = scope === 'user' ? {
|
|
834
873
|
uids: [
|
|
835
874
|
id
|
|
@@ -842,7 +881,7 @@ function isAlreadyExists(error) {
|
|
|
842
881
|
uids: [],
|
|
843
882
|
emails: {}
|
|
844
883
|
};
|
|
845
|
-
const title = `Summary: more
|
|
884
|
+
const title = `Summary: more notices about ${(_context_orgName = context.orgName) != null ? _context_orgName : 'your workspace'} in the last hour`;
|
|
846
885
|
const summary = summarize(taken.owners, taken.ownerCount);
|
|
847
886
|
if (recipients.uids.length) {
|
|
848
887
|
var _context_orgName1, _abuseReportContactEmail;
|
|
@@ -850,6 +889,7 @@ function isAlreadyExists(error) {
|
|
|
850
889
|
type: RISK_NOTICE_NOTIFICATION_TYPE,
|
|
851
890
|
title,
|
|
852
891
|
body: summary.slice(0, 1000),
|
|
892
|
+
level: 'warning',
|
|
853
893
|
link: riskHoldsPath()
|
|
854
894
|
}, orgId ? {
|
|
855
895
|
orgId
|
|
@@ -863,7 +903,7 @@ function isAlreadyExists(error) {
|
|
|
863
903
|
});
|
|
864
904
|
const rendered = await renderRiskNoticeEmail(RISK_NOTICE_DIGEST_EMAIL_KEY, {
|
|
865
905
|
'notice.title': title,
|
|
866
|
-
'notice.summary': renderRiskNoticeText('In the last hour,
|
|
906
|
+
'notice.summary': renderRiskNoticeText('In the last hour, {{workspace.name}} had more notices than we send one by one. Each one, with its status and anything that is due, is listed on Holds & reviews. By title:', {
|
|
867
907
|
'workspace.name': context.orgName
|
|
868
908
|
}),
|
|
869
909
|
'notice.meaning': summary,
|
|
@@ -881,10 +921,12 @@ function isAlreadyExists(error) {
|
|
|
881
921
|
}
|
|
882
922
|
}
|
|
883
923
|
if (taken.staffCount > 0) {
|
|
924
|
+
var _context_orgName2;
|
|
925
|
+
const about = context.orgName ? `workspace ${context.orgName} (${orgId})` : scope === 'user' ? `account ${id}` : scope === 'host' ? `site ${id}` : ledgerId;
|
|
884
926
|
await deps.notifyStaff({
|
|
885
927
|
type: 'system.abuseReportUrgent',
|
|
886
|
-
title: `Digest: ${taken.staffCount} more risk alerts on ${ledgerId}`,
|
|
887
|
-
body: summarize(taken.staff, taken.staffCount).slice(0, 1000),
|
|
928
|
+
title: `Digest: ${taken.staffCount} more risk alerts on ${(_context_orgName2 = context.orgName) != null ? _context_orgName2 : ledgerId}`,
|
|
929
|
+
body: (`${taken.staffCount} more risk alerts on ${about} arrived in the last hour ` + `and were batched here instead of sent one by one:\n` + summarize(taken.staff, taken.staffCount)).slice(0, 1000),
|
|
888
930
|
link: '/admin/abuse-reports'
|
|
889
931
|
});
|
|
890
932
|
}
|
|
@@ -942,7 +984,8 @@ function isAlreadyExists(error) {
|
|
|
942
984
|
label: stamp.itemLabel,
|
|
943
985
|
path: (_stamp_itemPath = stamp.itemPath) != null ? _stamp_itemPath : null
|
|
944
986
|
} : null,
|
|
945
|
-
dedupeKey: `${closing}:${input.reviewId}
|
|
987
|
+
dedupeKey: `${closing}:${input.reviewId}`,
|
|
988
|
+
openedAs: stamp.kind
|
|
946
989
|
}, overrides);
|
|
947
990
|
} catch (error) {
|
|
948
991
|
console.error('[risk-notice] the closing notice failed', error);
|
|
@@ -957,7 +1000,8 @@ function isAlreadyExists(error) {
|
|
|
957
1000
|
* here, so no request can release a hold or lift a lock. Staff see the
|
|
958
1001
|
* request as a note on the row, and are notified with a link to it.
|
|
959
1002
|
*/ export async function requestRiskReview(input, overrides = {}) {
|
|
960
|
-
var _input_note, _notice_get;
|
|
1003
|
+
var _input_note, _notice_get, _ref, _ref1, _ref2;
|
|
1004
|
+
var _notice_get1;
|
|
961
1005
|
const deps = resolveDeps(overrides);
|
|
962
1006
|
const note = String((_input_note = input.note) != null ? _input_note : '').trim().slice(0, RISK_REVIEW_NOTE_MAX);
|
|
963
1007
|
if (note.length < 10) {
|
|
@@ -1031,23 +1075,41 @@ function isAlreadyExists(error) {
|
|
|
1031
1075
|
error: 'We already have a request on this. You can add another in a few minutes.'
|
|
1032
1076
|
};
|
|
1033
1077
|
}
|
|
1078
|
+
// What the request is about, by name, for staff and for the person who
|
|
1079
|
+
// asked: the item in the owner's words and the workspace.
|
|
1080
|
+
const itemLabel = String((_ref = (_notice_get1 = notice.get('ownerValues')) == null ? void 0 : _notice_get1['item.label']) != null ? _ref : '').trim() || null;
|
|
1081
|
+
const org = await deps.firestore.collection('orgs').doc(input.orgId).get().catch(()=>null);
|
|
1082
|
+
const orgName = String((_ref1 = org == null ? void 0 : org.get('name')) != null ? _ref1 : '').trim() || null;
|
|
1083
|
+
const orgSlug = String((_ref2 = org == null ? void 0 : org.get('slug')) != null ? _ref2 : '').trim() || null;
|
|
1034
1084
|
await deps.notifyStaff({
|
|
1035
1085
|
type: 'system.abuseReportUrgent',
|
|
1036
1086
|
title: `Review requested${outcome.reference ? ` — ${outcome.reference}` : ''}`,
|
|
1037
|
-
body: `
|
|
1087
|
+
body: `An owner or admin of ${orgName ? `${orgName} (${input.orgId})` : `workspace ${input.orgId}`} ` + `asked for a review of ${itemLabel != null ? itemLabel : 'a held or flagged item'} (${kind}): "${note.slice(0, 280)}"`,
|
|
1038
1088
|
link: staffAbuseRowPath(reviewId)
|
|
1039
1089
|
});
|
|
1040
1090
|
// The person who asked is told it arrived — account mail, platform brand.
|
|
1041
1091
|
if (input.email) {
|
|
1042
1092
|
var _outcome_reference, _abuseReportContactEmail;
|
|
1093
|
+
const holdsPath = emailPath(riskHoldsPath(input.noticeId), {
|
|
1094
|
+
orgSlug,
|
|
1095
|
+
hostId: null,
|
|
1096
|
+
hostSubdomain: null
|
|
1097
|
+
});
|
|
1098
|
+
const holdsUrl = holdsPath ? absolute(holdsPath) : '';
|
|
1099
|
+
const about = `${itemLabel != null ? itemLabel : 'the held or flagged item'}${orgName ? ` on ${orgName}` : ''}`;
|
|
1043
1100
|
const rendered = await renderRiskNoticeEmail(RISK_REVIEW_REQUESTED_EMAIL_KEY, {
|
|
1044
1101
|
'notice.title': 'We received your review request',
|
|
1045
|
-
'notice.summary':
|
|
1046
|
-
'notice.meaning': 'Nothing changes until the review is done
|
|
1102
|
+
'notice.summary': `We received your request to review ${about}` + (outcome.reference ? ` (reference ${outcome.reference})` : '') + '. A person on our review team reads every request.',
|
|
1103
|
+
'notice.meaning': 'Nothing changes until the review is done, and you will get a notice either way.',
|
|
1047
1104
|
'notice.steps': numbered([
|
|
1048
1105
|
'Nothing more is needed.',
|
|
1049
|
-
'You can follow
|
|
1106
|
+
'You can follow its status on Holds & reviews.'
|
|
1050
1107
|
]),
|
|
1108
|
+
'notice.actions': holdsUrl ? `View holds and reviews: ${holdsUrl}` : '',
|
|
1109
|
+
'notice.primaryActionLabel': holdsUrl ? 'View holds and reviews' : '',
|
|
1110
|
+
'notice.primaryActionUrl': holdsUrl,
|
|
1111
|
+
'holds.url': holdsUrl,
|
|
1112
|
+
'workspace.name': orgName != null ? orgName : '',
|
|
1051
1113
|
reference: (_outcome_reference = outcome.reference) != null ? _outcome_reference : '',
|
|
1052
1114
|
'help.url': riskNoticeHelpUrl('requesting-a-review')
|
|
1053
1115
|
});
|