@aglyn/tenant-data-admin 1.0.0-beta.230 → 1.0.0-beta.232
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/lib/server/account-acquisition.d.ts +30 -0
- package/src/lib/server/account-acquisition.js +37 -2
- package/src/lib/server/account-acquisition.js.map +1 -1
- package/src/lib/server/api-keys.d.ts +14 -1
- package/src/lib/server/api-keys.js +28 -0
- package/src/lib/server/api-keys.js.map +1 -1
- package/src/lib/server/content-entry-drafts.d.ts +131 -0
- package/src/lib/server/content-entry-drafts.js +307 -0
- package/src/lib/server/content-entry-drafts.js.map +1 -0
- package/src/lib/server/erase.js +14 -0
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-released-names.d.ts +22 -0
- package/src/lib/server/host-released-names.js +31 -0
- package/src/lib/server/host-released-names.js.map +1 -0
- package/src/lib/server/host-visitor-records.d.ts +1 -1
- package/src/lib/server/host-visitor-records.js +1 -1
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/mobile-push-switch.d.ts +46 -0
- package/src/lib/server/mobile-push-switch.js +45 -0
- package/src/lib/server/mobile-push-switch.js.map +1 -0
- package/src/lib/server/native-push.d.ts +17 -0
- package/src/lib/server/native-push.js +35 -0
- package/src/lib/server/native-push.js.map +1 -0
- package/src/lib/server/notifications.js +23 -0
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/personal-data-export.js +230 -0
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/plugin-event-outbox.d.ts +138 -0
- package/src/lib/server/plugin-event-outbox.js +229 -0
- package/src/lib/server/plugin-event-outbox.js.map +1 -0
- package/src/lib/server/push-apns.d.ts +73 -0
- package/src/lib/server/push-apns.js +225 -0
- package/src/lib/server/push-apns.js.map +1 -0
- package/src/lib/server/push-delivery.d.ts +78 -0
- package/src/lib/server/push-delivery.js +176 -0
- package/src/lib/server/push-delivery.js.map +1 -0
- package/src/lib/server/push-fcm.d.ts +29 -0
- package/src/lib/server/push-fcm.js +83 -0
- package/src/lib/server/push-fcm.js.map +1 -0
- package/src/lib/server/serve-media-cdn.d.ts +6 -0
- package/src/lib/server/serve-media-cdn.js +20 -3
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/site-marketing-sync.d.ts +146 -0
- package/src/lib/server/site-marketing-sync.js +315 -0
- package/src/lib/server/site-marketing-sync.js.map +1 -0
- package/src/lib/server/upsert-contact.js +6 -5
- package/src/lib/server/upsert-contact.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/notifications.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n buildRoute,\n NOTIFICATION_SELF_SENT_EMAIL_TYPES,\n NOTIFICATION_SETTINGS_FIELD,\n normalizeNotificationLink,\n notificationChannelEnabled,\n type AglynNotification,\n Route,\n type NotificationSettings,\n} from '@aglyn/aglyn/server'\nimport { operatorAlertForNotificationType } from '@aglyn/aglyn/plugin-manager/operator-alerts'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { withoutMailWithheldAccounts } from './account-mail'\nimport { findUserByUidAcrossPools, listStaffUidsAcrossPools } from './auth-pools'\nimport { filterSuppressedEmails } from './email-suppression'\nimport { meterOrgEmail, meterPlatformEmail } from './email-metering'\nimport firebaseAdmin from './firebase-admin'\nimport { listOrgMembers } from './organizations'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type SystemEmailBrand,\n} from './render-system-email'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\nexport type NotificationPayload = Omit<\n AglynNotification,\n '$id' | 'createdAt' | 'readAt'\n>\n\nexport interface NotifyUsersOptions {\n /**\n * Addresses the caller already holds, by uid (AGL-3224).\n *\n * Purely an optimization, and one worth taking where it is free: a caller\n * that read the roster — `notifyOrgAdmins` does — has every address in hand\n * already, and without this the email half would look each one up again in\n * the directory. Absent uids fall back to that lookup, so passing a partial\n * map is fine and passing none is correct.\n */\n emails?: Readonly<Record<string, string | null | undefined>>\n /**\n * Who the fan-out is for (AGL-3367). `staff` is the platform talking to its\n * own people, so the email wears the platform's brand and is the platform's\n * cost even when the notification is ABOUT an org, which a canceled\n * subscription or a new ticket always is. Anything else speaks in the org's\n * brand when the notification names one.\n */\n audience?: 'staff'\n /**\n * Write the console notification only, and mail nobody through the\n * per-person email channel (AGL-3375): the caller has emailed the same\n * alert to the operator's inbox, and a staff member who also switched\n * email on would otherwise get it twice.\n */\n skipEmail?: boolean\n /**\n * People not to mail through the email channel for this one notification,\n * because the caller emails them the same event itself — the seller's\n * designed sale email beside a Buy Now order's notification. Everyone else\n * the fan-out reaches is mailed as their preferences say.\n */\n skipEmailFor?: readonly string[]\n}\n\n/** The console's absolute origin for an email link, or `''` when unset. */\nfunction consoleOrigin(): string {\n return (process.env.NEXT_PUBLIC_CONSOLE_URL ?? '').trim().replace(/\\/+$/, '')\n}\n\n/**\n * At most this many directory lookups per fan-out, and at most this many\n * notification emails.\n *\n * Both ceilings only ever bite on a fan-out where many recipients have\n * OPTED IN — the email channel defaults off outside a site's transactions\n * (`NOTIFICATION_TYPE_CHANNEL_DEFAULTS`), so the ordinary notification\n * costs exactly what it cost before this existed. They are here because a\n * fan-out takes up to 400 uids and `notifyUsers` runs inside the mutation\n * that emitted it: a notification must not be able to turn one write into\n * four hundred directory reads and four hundred sends.\n */\nconst NOTIFY_EMAIL_MAX_LOOKUPS = 25\nconst NOTIFY_EMAIL_MAX_SENDS = 50\n\n/**\n * The email beside the console notification (AGL-3224).\n *\n * One send per recipient, never a shared `to:` list — two people who manage\n * the same site have not agreed to be shown each other's addresses, and the\n * settings link in the footer belongs to one person.\n *\n * `List-Unsubscribe` points at the settings page rather than at a one-click\n * endpoint: this is not marketing mail and RFC 8058's POST form would be\n * claiming a capability that does not exist. What it does do is put the way\n * out in the headers as well as the body, so a client that surfaces it shows\n * the person the page where the switch they want actually is.\n *\n * Rendered through the `notification` system email (AGL-3367), which is what\n * gives it the header and footer every other system email wears, and makes\n * it designable on the staff System emails page. It used to hand `sendEmail`\n * bare text, which is the unbranded card the fallback HTML part draws. The\n * template is read once per fan-out and rendered per recipient.\n *\n * Never throws, like everything else on this path.\n */\nasync function emailNotification(\n uids: string[],\n payload: NotificationPayload,\n known: Readonly<Record<string, string | null | undefined>>,\n audience: NotifyUsersOptions['audience'],\n): Promise<void> {\n const origin = consoleOrigin()\n const settingsUrl = `${origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n const context = await notificationEmailContext(payload, audience)\n const url = notificationEmailUrl(payload, context, origin)\n const loaded = await loadSystemEmail('notification').catch(() => null)\n const fallbackText = [\n payload.title,\n payload.body ?? '',\n url,\n origin ? `Change what you are emailed about: ${settingsUrl}` : '',\n ]\n .filter(Boolean)\n .join('\\n\\n')\n let lookups = 0\n let sent = 0\n for (const uid of uids) {\n if (sent >= NOTIFY_EMAIL_MAX_SENDS) break\n let address = String(known[uid] ?? '').trim().toLowerCase()\n if (!address.includes('@')) {\n if (lookups >= NOTIFY_EMAIL_MAX_LOOKUPS) continue\n lookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n if (!address.includes('@')) continue\n const recipients = await filterSuppressedEmails([address])\n if (!recipients.length) continue\n const rendered = loaded\n ? renderLoadedSystemEmail(\n loaded,\n {\n ...context.brand.merge,\n 'notification.title': payload.title,\n 'notification.body': payload.body ?? '',\n 'notification.url': url,\n settingsUrl: origin ? settingsUrl : '',\n },\n context.brand.options,\n )\n : null\n const result = await sendEmail({\n to: recipients,\n ...systemEmailContent(rendered, {\n subject: payload.title,\n text: fallbackText,\n }),\n ...(context.brand.fromName ? { fromName: context.brand.fromName } : {}),\n context: 'notification',\n ...(origin\n ? { headers: { 'List-Unsubscribe': `<${settingsUrl}>` } }\n : {}),\n })\n if (!result.sent) continue\n sent += 1\n // Whose cost it is: a notification to a workspace's people is that\n // workspace's mail, and a staff alert or an account-level notice is the\n // platform's, whichever org it is about.\n await (payload.orgId && audience !== 'staff'\n ? meterOrgEmail(payload.orgId)\n : meterPlatformEmail()\n ).catch(() => undefined)\n }\n}\n\n/** What a notification email needs from the org and site it is about. */\ninterface NotificationEmailContext {\n brand: SystemEmailBrand\n orgSlug?: string\n hostSubdomain?: string\n}\n\n/**\n * The org's brand, and the slug and subdomain its stored link is rewritten\n * with (AGL-3367): at most two reads per fan-out, made only when someone is\n * being emailed.\n *\n * A staff fan-out reads nothing. Its brand is the platform's, and its links\n * are staff routes that need no rewriting.\n */\nasync function notificationEmailContext(\n payload: NotificationPayload,\n audience: NotifyUsersOptions['audience'],\n): Promise<NotificationEmailContext> {\n if (audience === 'staff' || !payload.orgId) {\n return { brand: systemEmailBrand(null) }\n }\n try {\n const db = firestore()\n const [org, host] = await Promise.all([\n db.collection('orgs').doc(payload.orgId).get(),\n payload.hostId\n ? db.collection('hosts').doc(payload.hostId).get()\n : Promise.resolve(null),\n ])\n const slug = org.get('slug')\n const subdomain = host?.get('subdomain')\n return {\n brand: systemEmailBrand(org.exists ? (org.data() ?? null) : null),\n ...(typeof slug === 'string' && slug ? { orgSlug: slug } : {}),\n ...(typeof subdomain === 'string' && subdomain\n ? { hostSubdomain: subdomain }\n : {}),\n }\n } catch (error) {\n console.error('notification email context failed', error)\n return { brand: systemEmailBrand(null) }\n }\n}\n\n/**\n * Where the email's button goes: the notification's own link, rewritten onto\n * today's routes the way the console rewrites it when it is clicked, else\n * the notifications page. A stored host link is `/{hostDocId}/…`, which only\n * that rewrite turns into a page that exists. Empty when the deployment has\n * no console origin to make a link absolute with.\n */\nfunction notificationEmailUrl(\n payload: NotificationPayload,\n context: NotificationEmailContext,\n origin: string,\n): string {\n const link = normalizeNotificationLink(payload.link, {\n orgSlug: context.orgSlug,\n hostId: payload.hostId,\n hostSubdomain: context.hostSubdomain,\n })\n if (link && /^https?:\\/\\//i.test(link)) return link\n if (!origin) return ''\n return `${origin}${link?.startsWith('/') ? link : buildRoute(Route.MANAGE_NOTIFICATIONS)}`\n}\n\n/**\n * Notification fan-out (AGL-259): batch-writes one doc per recipient at\n * `users/{uid}/notifications`, and — for recipients who asked for it\n * (AGL-3224) — sends one email beside it. Never throws: a notification miss,\n * or a send that failed, must not break the mutation that emitted it.\n */\nexport async function notifyUsers(\n uids: Iterable<string>,\n payload: NotificationPayload,\n options: NotifyUsersOptions = {},\n): Promise<void> {\n try {\n const db = firestore()\n const targets = [...new Set(uids)].filter(Boolean).slice(0, 400)\n if (!targets.length) return\n // Per-user preferences (AGL-267, AGL-3223): one getAll over the user\n // docs, which now answers for both channels and for all three scopes —\n // the settings live on this document precisely so that stays one read.\n const userDocs = await db.getAll(\n ...targets.map((uid) => db.collection('users').doc(uid)),\n )\n const scope = { orgId: payload.orgId, hostId: payload.hostId }\n const batch = db.batch()\n let count = 0\n const mailTo: string[] = []\n for (const userDoc of userDocs) {\n const settings = userDoc.get(NOTIFICATION_SETTINGS_FIELD) as\n | NotificationSettings\n | undefined\n const legacy = userDoc.get('notificationPrefs') as\n | Record<string, boolean>\n | undefined\n if (\n notificationChannelEnabled(\n settings,\n 'console',\n payload.type,\n scope,\n legacy,\n )\n ) {\n batch.set(\n db\n .collection('users')\n .doc(userDoc.id)\n .collection('notifications')\n .doc(),\n // `read: false` on create, so the feed can ask for unread and read\n // alike by equality (AGL-3321; see `AglynNotification.read`).\n { ...payload, read: false, createdAt: FieldValue.serverTimestamp() },\n )\n count += 1\n }\n if (\n notificationChannelEnabled(settings, 'email', payload.type, scope, legacy) &&\n !options.skipEmailFor?.includes(userDoc.id)\n ) {\n mailTo.push(userDoc.id)\n }\n }\n if (count > 0) await batch.commit()\n /*\n * THE EMAIL AFTER THE COMMIT, and not inside its `try`.\n *\n * The console notification is the durable record and the send is a\n * courtesy on top of it, so the order is the one where a failing network\n * call cannot lose the record. The two channels are independent by\n * design — a person may have muted the feed and asked for mail, or the\n * reverse — so neither is gated on the other having happened.\n *\n * Digests are excluded: they compose and send their own mail, under\n * their own switches, and the generic channel would send a second\n * message announcing that the first one had been sent.\n *\n * A locked or disabled account is not mailed (AGL-3418); its feed entry\n * above still records the event.\n */\n if (\n mailTo.length &&\n !options.skipEmail &&\n isEmailConfigured() &&\n !NOTIFICATION_SELF_SENT_EMAIL_TYPES.has(payload.type)\n ) {\n const reachable = await withoutMailWithheldAccounts(mailTo)\n if (!reachable.length) return\n await emailNotification(\n reachable,\n payload,\n options.emails ?? {},\n options.audience,\n )\n }\n } catch (error) {\n console.error('notification fan-out failed', error)\n }\n}\n\n// Staff are identified by a Firebase Auth custom claim (`staff`), which is not\n// a Firestore query — so the roster comes from paginating the auth users. A\n// burst of ticket activity shouldn't rescan every time, so the (small) result\n// is cached briefly. Fails soft to an empty list.\nlet staffUidCache: { uids: string[]; at: number } | null = null\nconst STAFF_CACHE_MS = 60_000\n\nasync function listStaffUids(): Promise<string[]> {\n const now = Date.now()\n if (staffUidCache && now - staffUidCache.at < STAFF_CACHE_MS) {\n return staffUidCache.uids\n }\n // Across ALL auth pools (AGL-1122). This scanned only the project pool, so\n // a staff member who signs in through enterprise SSO — whose account lives\n // in a GCIP tenant pool — was never in the fan-out. Nothing errored; the\n // notification simply never arrived, which is the worst shape a miss can\n // take on an alerting path.\n const uids = await listStaffUidsAcrossPools()\n staffUidCache = { uids, at: now }\n return uids\n}\n\n/**\n * Notifies every staff-claim holder (AGL-850) — the support-desk audience.\n * Staff are not org members, so `notifyOrgAdmins`/`notifyHostManagers` never\n * reach them; this enumerates the `staff` custom claim instead. The docs land\n * at `users/{uid}/notifications`, which the console notifications menu already\n * renders, so no separate staff inbox is needed. Never throws.\n *\n * A type the operator alert registry names (AGL-3377) — a fraud or risk\n * alert, a dispute nobody owns, a support ticket — is raised as that alert\n * instead: the same console notification, plus staff's switches, the\n * operator email or digest, and the webhook. See `raiseOperatorAlert`.\n */\nexport async function notifyStaff(payload: NotificationPayload): Promise<void> {\n try {\n const alert = operatorAlertForNotificationType(payload.type)\n if (alert) {\n const { raiseOperatorAlert } = await import('./operator-alerts')\n await raiseOperatorAlert(alert, {\n subject: payload.title,\n ...(payload.body ? { body: payload.body } : {}),\n ...(payload.level ? { level: payload.level } : {}),\n ...(payload.link ? { url: payload.link } : {}),\n ...(payload.orgId ? { orgId: payload.orgId } : {}),\n ...(payload.hostId ? { hostId: payload.hostId } : {}),\n })\n return\n }\n await notifyStaffConsole(payload)\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/**\n * The staff console notification alone, with the per-person email channel\n * it carries: what `notifyStaff` did before the registry, and what\n * `raiseOperatorAlert` writes as its first step. Never throws.\n */\nexport async function notifyStaffConsole(\n payload: NotificationPayload,\n options: { skipEmail?: boolean } = {},\n): Promise<void> {\n try {\n await notifyUsers(await listStaffUids(), payload, {\n audience: 'staff',\n ...(options.skipEmail ? { skipEmail: true } : {}),\n })\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/**\n * Notifies the org's owner + admins (billing, membership, org events).\n *\n * `skipEmail` is for a caller that emails the same notice to the same people\n * itself — the usage alerts do (AGL-3431) — so an admin who switched the\n * email channel on is not sent it twice.\n */\nexport async function notifyOrgAdmins(\n orgId: string,\n payload: NotificationPayload,\n options: Pick<NotifyUsersOptions, 'skipEmail'> = {},\n): Promise<void> {\n try {\n const members = await listOrgMembers(orgId)\n const admins = members.filter(\n (member) => member.role === 'owner' || member.role === 'admin',\n )\n // The roster rows carry addresses, so the email channel (AGL-3224) needs\n // no directory lookup for anyone reached this way.\n const emails: Record<string, string | undefined> = {}\n for (const member of admins) {\n if (member.email) emails[member.$id] = member.email\n }\n await notifyUsers(\n admins.map((member) => member.$id),\n { ...payload, orgId },\n { emails, ...(options.skipEmail ? { skipEmail: true } : {}) },\n )\n } catch (error) {\n console.error('org admin notification failed', error)\n }\n}\n\n/**\n * The name a site's managers know it by: the name they gave it, then its\n * address, then — for a doc with neither — its id.\n */\nexport function hostDisplayName(\n host: Record<string, unknown> | undefined,\n hostId: string,\n): string {\n for (const value of [host?.['displayName'], host?.['subdomain']]) {\n if (typeof value === 'string' && value.trim()) return value.trim()\n }\n return hostId\n}\n\n/**\n * Fills `{site}` in a host notification's title or body (AGL-3432).\n *\n * A manager of ten storefronts reads \"New order — $42.00\" and cannot tell\n * which store it came from, and the email shows only the body to someone who\n * skips the subject. `notifyHostManagers` already reads the host doc, so a\n * caller writes \"New order on {site}\" and pays no read of its own.\n */\nexport function withSiteName(text: string, site: string): string {\n return text.includes('{site}') ? text.split('{site}').join(site) : text\n}\n\n/**\n * Notifies everyone who manages a host (admin/editor in the host doc's\n * `memberRoles` projection) — the audience for form submissions and\n * bookings on that site.\n *\n * The OWNING ORG travels with the notification (AGL-1773). Host links are\n * stored in the legacy `/{hostDocId}/rest` shape and rewritten to\n * `/{orgSlug}/hosts/{subdomain}/rest` when they are FOLLOWED\n * (`normalizeNotificationLink`, AGL-644) — a rewrite that needs an org slug.\n * With no `orgId` on the doc the console had nothing to key on and fell back\n * to whichever org the reader happens to have OPEN, so a manager who belongs\n * to more than one workspace was routed to `/{other-org}/hosts/{subdomain}/…`\n * and got a designed 404: `HostGuard` only resolves subdomains belonging to\n * the current org. `notifyOrgAdmins` has always stamped it; the host fan-out\n * — every form submission, booking, order and stock alert — never did.\n *\n * The host doc already carries `orgId` (AGL-233), so this costs no extra\n * read. Spread conditionally: the field is optional on the host model and\n * Firestore rejects `undefined`.\n */\nexport async function notifyHostManagers(\n hostId: string,\n payload: NotificationPayload,\n options: {\n /**\n * Mail the workspace owner nothing through the email channel, because\n * the caller sends them their own email about the same event.\n */\n skipOwnerEmail?: boolean\n } = {},\n): Promise<void> {\n try {\n const host = await firestore().collection('hosts').doc(hostId).get()\n const memberRoles =\n (host.get('memberRoles') as Record<string, string> | undefined) ?? {}\n const managers = Object.entries(memberRoles)\n .filter(([, role]) => role === 'admin' || role === 'editor')\n .map(([uid]) => uid)\n const orgId = payload.orgId ?? (host.get('orgId') as string | undefined)\n const site = hostDisplayName(host.data(), hostId)\n const ownerUid =\n options.skipOwnerEmail && orgId\n ? ((await firestore().collection('orgs').doc(orgId).get()).get('ownerUid') as\n | string\n | undefined)\n : undefined\n await notifyUsers(\n managers,\n {\n ...payload,\n title: withSiteName(payload.title, site),\n ...(payload.body !== undefined\n ? { body: withSiteName(payload.body, site) }\n : {}),\n hostId,\n ...(orgId ? { orgId } : {}),\n },\n ownerUid ? { skipEmailFor: [ownerUid] } : {},\n )\n } catch (error) {\n console.error('host manager notification failed', error)\n }\n}\n"],"names":["buildRoute","NOTIFICATION_SELF_SENT_EMAIL_TYPES","NOTIFICATION_SETTINGS_FIELD","normalizeNotificationLink","notificationChannelEnabled","Route","operatorAlertForNotificationType","isEmailConfigured","sendEmail","FieldValue","withoutMailWithheldAccounts","findUserByUidAcrossPools","listStaffUidsAcrossPools","filterSuppressedEmails","meterOrgEmail","meterPlatformEmail","firebaseAdmin","listOrgMembers","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","firestore","app","consoleOrigin","process","env","NEXT_PUBLIC_CONSOLE_URL","trim","replace","NOTIFY_EMAIL_MAX_LOOKUPS","NOTIFY_EMAIL_MAX_SENDS","emailNotification","uids","payload","known","audience","origin","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","context","notificationEmailContext","url","notificationEmailUrl","loaded","catch","fallbackText","title","body","filter","Boolean","join","lookups","sent","uid","address","String","toLowerCase","includes","pooled","record","email","recipients","length","rendered","brand","merge","options","result","to","subject","text","fromName","headers","orgId","undefined","org","db","host","Promise","all","collection","doc","get","hostId","resolve","slug","subdomain","exists","data","orgSlug","hostSubdomain","error","console","link","test","startsWith","MANAGE_NOTIFICATIONS","notifyUsers","targets","Set","slice","userDocs","getAll","map","scope","batch","count","mailTo","userDoc","settings","legacy","type","set","id","read","createdAt","serverTimestamp","skipEmailFor","push","commit","skipEmail","has","reachable","emails","staffUidCache","STAFF_CACHE_MS","listStaffUids","now","Date","at","notifyStaff","alert","raiseOperatorAlert","level","notifyStaffConsole","notifyOrgAdmins","members","admins","member","role","$id","hostDisplayName","value","withSiteName","site","split","notifyHostManagers","memberRoles","managers","Object","entries","ownerUid","skipOwnerEmail"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/notifications.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n buildRoute,\n NOTIFICATION_SELF_SENT_EMAIL_TYPES,\n NOTIFICATION_SETTINGS_FIELD,\n normalizeNotificationLink,\n notificationChannelEnabled,\n type AglynNotification,\n Route,\n type NotificationSettings,\n} from '@aglyn/aglyn/server'\nimport { operatorAlertForNotificationType } from '@aglyn/aglyn/plugin-manager/operator-alerts'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { notificationPushEnabled } from '@aglyn/aglyn/app-utils/notification-push'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { withoutMailWithheldAccounts } from './account-mail'\nimport { findUserByUidAcrossPools, listStaffUidsAcrossPools } from './auth-pools'\nimport { filterSuppressedEmails } from './email-suppression'\nimport { meterOrgEmail, meterPlatformEmail } from './email-metering'\nimport firebaseAdmin from './firebase-admin'\nimport { mobilePushEnabled, mobilePushSenders } from './mobile-push-switch'\nimport { listOrgMembers } from './organizations'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type SystemEmailBrand,\n} from './render-system-email'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\nexport type NotificationPayload = Omit<\n AglynNotification,\n '$id' | 'createdAt' | 'readAt'\n>\n\nexport interface NotifyUsersOptions {\n /**\n * Addresses the caller already holds, by uid (AGL-3224).\n *\n * Purely an optimization, and one worth taking where it is free: a caller\n * that read the roster — `notifyOrgAdmins` does — has every address in hand\n * already, and without this the email half would look each one up again in\n * the directory. Absent uids fall back to that lookup, so passing a partial\n * map is fine and passing none is correct.\n */\n emails?: Readonly<Record<string, string | null | undefined>>\n /**\n * Who the fan-out is for (AGL-3367). `staff` is the platform talking to its\n * own people, so the email wears the platform's brand and is the platform's\n * cost even when the notification is ABOUT an org, which a canceled\n * subscription or a new ticket always is. Anything else speaks in the org's\n * brand when the notification names one.\n */\n audience?: 'staff'\n /**\n * Write the console notification only, and mail nobody through the\n * per-person email channel (AGL-3375): the caller has emailed the same\n * alert to the operator's inbox, and a staff member who also switched\n * email on would otherwise get it twice.\n */\n skipEmail?: boolean\n /**\n * People not to mail through the email channel for this one notification,\n * because the caller emails them the same event itself — the seller's\n * designed sale email beside a Buy Now order's notification. Everyone else\n * the fan-out reaches is mailed as their preferences say.\n */\n skipEmailFor?: readonly string[]\n}\n\n/** The console's absolute origin for an email link, or `''` when unset. */\nfunction consoleOrigin(): string {\n return (process.env.NEXT_PUBLIC_CONSOLE_URL ?? '').trim().replace(/\\/+$/, '')\n}\n\n/**\n * At most this many directory lookups per fan-out, and at most this many\n * notification emails.\n *\n * Both ceilings only ever bite on a fan-out where many recipients have\n * OPTED IN — the email channel defaults off outside a site's transactions\n * (`NOTIFICATION_TYPE_CHANNEL_DEFAULTS`), so the ordinary notification\n * costs exactly what it cost before this existed. They are here because a\n * fan-out takes up to 400 uids and `notifyUsers` runs inside the mutation\n * that emitted it: a notification must not be able to turn one write into\n * four hundred directory reads and four hundred sends.\n */\nconst NOTIFY_EMAIL_MAX_LOOKUPS = 25\nconst NOTIFY_EMAIL_MAX_SENDS = 50\n\n/**\n * The email beside the console notification (AGL-3224).\n *\n * One send per recipient, never a shared `to:` list — two people who manage\n * the same site have not agreed to be shown each other's addresses, and the\n * settings link in the footer belongs to one person.\n *\n * `List-Unsubscribe` points at the settings page rather than at a one-click\n * endpoint: this is not marketing mail and RFC 8058's POST form would be\n * claiming a capability that does not exist. What it does do is put the way\n * out in the headers as well as the body, so a client that surfaces it shows\n * the person the page where the switch they want actually is.\n *\n * Rendered through the `notification` system email (AGL-3367), which is what\n * gives it the header and footer every other system email wears, and makes\n * it designable on the staff System emails page. It used to hand `sendEmail`\n * bare text, which is the unbranded card the fallback HTML part draws. The\n * template is read once per fan-out and rendered per recipient.\n *\n * Never throws, like everything else on this path.\n */\nasync function emailNotification(\n uids: string[],\n payload: NotificationPayload,\n known: Readonly<Record<string, string | null | undefined>>,\n audience: NotifyUsersOptions['audience'],\n): Promise<void> {\n const origin = consoleOrigin()\n const settingsUrl = `${origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n const context = await notificationEmailContext(payload, audience)\n const url = notificationEmailUrl(payload, context, origin)\n const loaded = await loadSystemEmail('notification').catch(() => null)\n const fallbackText = [\n payload.title,\n payload.body ?? '',\n url,\n origin ? `Change what you are emailed about: ${settingsUrl}` : '',\n ]\n .filter(Boolean)\n .join('\\n\\n')\n let lookups = 0\n let sent = 0\n for (const uid of uids) {\n if (sent >= NOTIFY_EMAIL_MAX_SENDS) break\n let address = String(known[uid] ?? '').trim().toLowerCase()\n if (!address.includes('@')) {\n if (lookups >= NOTIFY_EMAIL_MAX_LOOKUPS) continue\n lookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n if (!address.includes('@')) continue\n const recipients = await filterSuppressedEmails([address])\n if (!recipients.length) continue\n const rendered = loaded\n ? renderLoadedSystemEmail(\n loaded,\n {\n ...context.brand.merge,\n 'notification.title': payload.title,\n 'notification.body': payload.body ?? '',\n 'notification.url': url,\n settingsUrl: origin ? settingsUrl : '',\n },\n context.brand.options,\n )\n : null\n const result = await sendEmail({\n to: recipients,\n ...systemEmailContent(rendered, {\n subject: payload.title,\n text: fallbackText,\n }),\n ...(context.brand.fromName ? { fromName: context.brand.fromName } : {}),\n context: 'notification',\n ...(origin\n ? { headers: { 'List-Unsubscribe': `<${settingsUrl}>` } }\n : {}),\n })\n if (!result.sent) continue\n sent += 1\n // Whose cost it is: a notification to a workspace's people is that\n // workspace's mail, and a staff alert or an account-level notice is the\n // platform's, whichever org it is about.\n await (payload.orgId && audience !== 'staff'\n ? meterOrgEmail(payload.orgId)\n : meterPlatformEmail()\n ).catch(() => undefined)\n }\n}\n\n/** What a notification email needs from the org and site it is about. */\ninterface NotificationEmailContext {\n brand: SystemEmailBrand\n orgSlug?: string\n hostSubdomain?: string\n}\n\n/**\n * The org's brand, and the slug and subdomain its stored link is rewritten\n * with (AGL-3367): at most two reads per fan-out, made only when someone is\n * being emailed.\n *\n * A staff fan-out reads nothing. Its brand is the platform's, and its links\n * are staff routes that need no rewriting.\n */\nasync function notificationEmailContext(\n payload: NotificationPayload,\n audience: NotifyUsersOptions['audience'],\n): Promise<NotificationEmailContext> {\n if (audience === 'staff' || !payload.orgId) {\n return { brand: systemEmailBrand(null) }\n }\n try {\n const db = firestore()\n const [org, host] = await Promise.all([\n db.collection('orgs').doc(payload.orgId).get(),\n payload.hostId\n ? db.collection('hosts').doc(payload.hostId).get()\n : Promise.resolve(null),\n ])\n const slug = org.get('slug')\n const subdomain = host?.get('subdomain')\n return {\n brand: systemEmailBrand(org.exists ? (org.data() ?? null) : null),\n ...(typeof slug === 'string' && slug ? { orgSlug: slug } : {}),\n ...(typeof subdomain === 'string' && subdomain\n ? { hostSubdomain: subdomain }\n : {}),\n }\n } catch (error) {\n console.error('notification email context failed', error)\n return { brand: systemEmailBrand(null) }\n }\n}\n\n/**\n * Where the email's button goes: the notification's own link, rewritten onto\n * today's routes the way the console rewrites it when it is clicked, else\n * the notifications page. A stored host link is `/{hostDocId}/…`, which only\n * that rewrite turns into a page that exists. Empty when the deployment has\n * no console origin to make a link absolute with.\n */\nfunction notificationEmailUrl(\n payload: NotificationPayload,\n context: NotificationEmailContext,\n origin: string,\n): string {\n const link = normalizeNotificationLink(payload.link, {\n orgSlug: context.orgSlug,\n hostId: payload.hostId,\n hostSubdomain: context.hostSubdomain,\n })\n if (link && /^https?:\\/\\//i.test(link)) return link\n if (!origin) return ''\n return `${origin}${link?.startsWith('/') ? link : buildRoute(Route.MANAGE_NOTIFICATIONS)}`\n}\n\n/**\n * Notification fan-out (AGL-259): batch-writes one doc per recipient at\n * `users/{uid}/notifications`, and — for recipients who asked for it\n * (AGL-3224) — sends one email beside it. Never throws: a notification miss,\n * or a send that failed, must not break the mutation that emitted it.\n */\nexport async function notifyUsers(\n uids: Iterable<string>,\n payload: NotificationPayload,\n options: NotifyUsersOptions = {},\n): Promise<void> {\n try {\n const db = firestore()\n const targets = [...new Set(uids)].filter(Boolean).slice(0, 400)\n if (!targets.length) return\n // Per-user preferences (AGL-267, AGL-3223): one getAll over the user\n // docs, which now answers for both channels and for all three scopes —\n // the settings live on this document precisely so that stays one read.\n const userDocs = await db.getAll(\n ...targets.map((uid) => db.collection('users').doc(uid)),\n )\n const scope = { orgId: payload.orgId, hostId: payload.hostId }\n const batch = db.batch()\n let count = 0\n const mailTo: string[] = []\n const pushTo: string[] = []\n for (const userDoc of userDocs) {\n const settings = userDoc.get(NOTIFICATION_SETTINGS_FIELD) as\n | NotificationSettings\n | undefined\n const legacy = userDoc.get('notificationPrefs') as\n | Record<string, boolean>\n | undefined\n if (\n notificationChannelEnabled(\n settings,\n 'console',\n payload.type,\n scope,\n legacy,\n )\n ) {\n batch.set(\n db\n .collection('users')\n .doc(userDoc.id)\n .collection('notifications')\n .doc(),\n // `read: false` on create, so the feed can ask for unread and read\n // alike by equality (AGL-3321; see `AglynNotification.read`).\n { ...payload, read: false, createdAt: FieldValue.serverTimestamp() },\n )\n count += 1\n }\n // The mobile push channel (AGL-3620); unanswered, it follows the feed.\n if (notificationPushEnabled(settings, payload.type, scope, legacy)) {\n pushTo.push(userDoc.id)\n }\n if (\n notificationChannelEnabled(settings, 'email', payload.type, scope, legacy) &&\n !options.skipEmailFor?.includes(userDoc.id)\n ) {\n mailTo.push(userDoc.id)\n }\n }\n if (count > 0) await batch.commit()\n /*\n * The push after the commit for the same reason as the email below:\n * every registered transport is handed the recipients whose preferences\n * say push, unless the deployment has pulled the kill switch. A failing\n * transport is logged and never stops the email or another transport.\n */\n if (pushTo.length && mobilePushEnabled()) {\n for (const send of mobilePushSenders()) {\n try {\n await send(pushTo, payload, { db })\n } catch (error) {\n console.error('push delivery failed', error)\n }\n }\n }\n /*\n * THE EMAIL AFTER THE COMMIT, and not inside its `try`.\n *\n * The console notification is the durable record and the send is a\n * courtesy on top of it, so the order is the one where a failing network\n * call cannot lose the record. The two channels are independent by\n * design — a person may have muted the feed and asked for mail, or the\n * reverse — so neither is gated on the other having happened.\n *\n * Digests are excluded: they compose and send their own mail, under\n * their own switches, and the generic channel would send a second\n * message announcing that the first one had been sent.\n *\n * A locked or disabled account is not mailed (AGL-3418); its feed entry\n * above still records the event.\n */\n if (\n mailTo.length &&\n !options.skipEmail &&\n isEmailConfigured() &&\n !NOTIFICATION_SELF_SENT_EMAIL_TYPES.has(payload.type)\n ) {\n const reachable = await withoutMailWithheldAccounts(mailTo)\n if (!reachable.length) return\n await emailNotification(\n reachable,\n payload,\n options.emails ?? {},\n options.audience,\n )\n }\n } catch (error) {\n console.error('notification fan-out failed', error)\n }\n}\n\n// Staff are identified by a Firebase Auth custom claim (`staff`), which is not\n// a Firestore query — so the roster comes from paginating the auth users. A\n// burst of ticket activity shouldn't rescan every time, so the (small) result\n// is cached briefly. Fails soft to an empty list.\nlet staffUidCache: { uids: string[]; at: number } | null = null\nconst STAFF_CACHE_MS = 60_000\n\nasync function listStaffUids(): Promise<string[]> {\n const now = Date.now()\n if (staffUidCache && now - staffUidCache.at < STAFF_CACHE_MS) {\n return staffUidCache.uids\n }\n // Across ALL auth pools (AGL-1122). This scanned only the project pool, so\n // a staff member who signs in through enterprise SSO — whose account lives\n // in a GCIP tenant pool — was never in the fan-out. Nothing errored; the\n // notification simply never arrived, which is the worst shape a miss can\n // take on an alerting path.\n const uids = await listStaffUidsAcrossPools()\n staffUidCache = { uids, at: now }\n return uids\n}\n\n/**\n * Notifies every staff-claim holder (AGL-850) — the support-desk audience.\n * Staff are not org members, so `notifyOrgAdmins`/`notifyHostManagers` never\n * reach them; this enumerates the `staff` custom claim instead. The docs land\n * at `users/{uid}/notifications`, which the console notifications menu already\n * renders, so no separate staff inbox is needed. Never throws.\n *\n * A type the operator alert registry names (AGL-3377) — a fraud or risk\n * alert, a dispute nobody owns, a support ticket — is raised as that alert\n * instead: the same console notification, plus staff's switches, the\n * operator email or digest, and the webhook. See `raiseOperatorAlert`.\n */\nexport async function notifyStaff(payload: NotificationPayload): Promise<void> {\n try {\n const alert = operatorAlertForNotificationType(payload.type)\n if (alert) {\n const { raiseOperatorAlert } = await import('./operator-alerts')\n await raiseOperatorAlert(alert, {\n subject: payload.title,\n ...(payload.body ? { body: payload.body } : {}),\n ...(payload.level ? { level: payload.level } : {}),\n ...(payload.link ? { url: payload.link } : {}),\n ...(payload.orgId ? { orgId: payload.orgId } : {}),\n ...(payload.hostId ? { hostId: payload.hostId } : {}),\n })\n return\n }\n await notifyStaffConsole(payload)\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/**\n * The staff console notification alone, with the per-person email channel\n * it carries: what `notifyStaff` did before the registry, and what\n * `raiseOperatorAlert` writes as its first step. Never throws.\n */\nexport async function notifyStaffConsole(\n payload: NotificationPayload,\n options: { skipEmail?: boolean } = {},\n): Promise<void> {\n try {\n await notifyUsers(await listStaffUids(), payload, {\n audience: 'staff',\n ...(options.skipEmail ? { skipEmail: true } : {}),\n })\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/**\n * Notifies the org's owner + admins (billing, membership, org events).\n *\n * `skipEmail` is for a caller that emails the same notice to the same people\n * itself — the usage alerts do (AGL-3431) — so an admin who switched the\n * email channel on is not sent it twice.\n */\nexport async function notifyOrgAdmins(\n orgId: string,\n payload: NotificationPayload,\n options: Pick<NotifyUsersOptions, 'skipEmail'> = {},\n): Promise<void> {\n try {\n const members = await listOrgMembers(orgId)\n const admins = members.filter(\n (member) => member.role === 'owner' || member.role === 'admin',\n )\n // The roster rows carry addresses, so the email channel (AGL-3224) needs\n // no directory lookup for anyone reached this way.\n const emails: Record<string, string | undefined> = {}\n for (const member of admins) {\n if (member.email) emails[member.$id] = member.email\n }\n await notifyUsers(\n admins.map((member) => member.$id),\n { ...payload, orgId },\n { emails, ...(options.skipEmail ? { skipEmail: true } : {}) },\n )\n } catch (error) {\n console.error('org admin notification failed', error)\n }\n}\n\n/**\n * The name a site's managers know it by: the name they gave it, then its\n * address, then — for a doc with neither — its id.\n */\nexport function hostDisplayName(\n host: Record<string, unknown> | undefined,\n hostId: string,\n): string {\n for (const value of [host?.['displayName'], host?.['subdomain']]) {\n if (typeof value === 'string' && value.trim()) return value.trim()\n }\n return hostId\n}\n\n/**\n * Fills `{site}` in a host notification's title or body (AGL-3432).\n *\n * A manager of ten storefronts reads \"New order — $42.00\" and cannot tell\n * which store it came from, and the email shows only the body to someone who\n * skips the subject. `notifyHostManagers` already reads the host doc, so a\n * caller writes \"New order on {site}\" and pays no read of its own.\n */\nexport function withSiteName(text: string, site: string): string {\n return text.includes('{site}') ? text.split('{site}').join(site) : text\n}\n\n/**\n * Notifies everyone who manages a host (admin/editor in the host doc's\n * `memberRoles` projection) — the audience for form submissions and\n * bookings on that site.\n *\n * The OWNING ORG travels with the notification (AGL-1773). Host links are\n * stored in the legacy `/{hostDocId}/rest` shape and rewritten to\n * `/{orgSlug}/hosts/{subdomain}/rest` when they are FOLLOWED\n * (`normalizeNotificationLink`, AGL-644) — a rewrite that needs an org slug.\n * With no `orgId` on the doc the console had nothing to key on and fell back\n * to whichever org the reader happens to have OPEN, so a manager who belongs\n * to more than one workspace was routed to `/{other-org}/hosts/{subdomain}/…`\n * and got a designed 404: `HostGuard` only resolves subdomains belonging to\n * the current org. `notifyOrgAdmins` has always stamped it; the host fan-out\n * — every form submission, booking, order and stock alert — never did.\n *\n * The host doc already carries `orgId` (AGL-233), so this costs no extra\n * read. Spread conditionally: the field is optional on the host model and\n * Firestore rejects `undefined`.\n */\nexport async function notifyHostManagers(\n hostId: string,\n payload: NotificationPayload,\n options: {\n /**\n * Mail the workspace owner nothing through the email channel, because\n * the caller sends them their own email about the same event.\n */\n skipOwnerEmail?: boolean\n } = {},\n): Promise<void> {\n try {\n const host = await firestore().collection('hosts').doc(hostId).get()\n const memberRoles =\n (host.get('memberRoles') as Record<string, string> | undefined) ?? {}\n const managers = Object.entries(memberRoles)\n .filter(([, role]) => role === 'admin' || role === 'editor')\n .map(([uid]) => uid)\n const orgId = payload.orgId ?? (host.get('orgId') as string | undefined)\n const site = hostDisplayName(host.data(), hostId)\n const ownerUid =\n options.skipOwnerEmail && orgId\n ? ((await firestore().collection('orgs').doc(orgId).get()).get('ownerUid') as\n | string\n | undefined)\n : undefined\n await notifyUsers(\n managers,\n {\n ...payload,\n title: withSiteName(payload.title, site),\n ...(payload.body !== undefined\n ? { body: withSiteName(payload.body, site) }\n : {}),\n hostId,\n ...(orgId ? { orgId } : {}),\n },\n ownerUid ? { skipEmailFor: [ownerUid] } : {},\n )\n } catch (error) {\n console.error('host manager notification failed', error)\n }\n}\n"],"names":["buildRoute","NOTIFICATION_SELF_SENT_EMAIL_TYPES","NOTIFICATION_SETTINGS_FIELD","normalizeNotificationLink","notificationChannelEnabled","Route","operatorAlertForNotificationType","isEmailConfigured","sendEmail","notificationPushEnabled","FieldValue","withoutMailWithheldAccounts","findUserByUidAcrossPools","listStaffUidsAcrossPools","filterSuppressedEmails","meterOrgEmail","meterPlatformEmail","firebaseAdmin","mobilePushEnabled","mobilePushSenders","listOrgMembers","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","firestore","app","consoleOrigin","process","env","NEXT_PUBLIC_CONSOLE_URL","trim","replace","NOTIFY_EMAIL_MAX_LOOKUPS","NOTIFY_EMAIL_MAX_SENDS","emailNotification","uids","payload","known","audience","origin","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","context","notificationEmailContext","url","notificationEmailUrl","loaded","catch","fallbackText","title","body","filter","Boolean","join","lookups","sent","uid","address","String","toLowerCase","includes","pooled","record","email","recipients","length","rendered","brand","merge","options","result","to","subject","text","fromName","headers","orgId","undefined","org","db","host","Promise","all","collection","doc","get","hostId","resolve","slug","subdomain","exists","data","orgSlug","hostSubdomain","error","console","link","test","startsWith","MANAGE_NOTIFICATIONS","notifyUsers","targets","Set","slice","userDocs","getAll","map","scope","batch","count","mailTo","pushTo","userDoc","settings","legacy","type","set","id","read","createdAt","serverTimestamp","push","skipEmailFor","commit","send","skipEmail","has","reachable","emails","staffUidCache","STAFF_CACHE_MS","listStaffUids","now","Date","at","notifyStaff","alert","raiseOperatorAlert","level","notifyStaffConsole","notifyOrgAdmins","members","admins","member","role","$id","hostDisplayName","value","withSiteName","site","split","notifyHostManagers","memberRoles","managers","Object","entries","ownerUid","skipOwnerEmail"],"mappings":"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"}
|
|
@@ -166,6 +166,204 @@ const firestoreApp = ()=>firebaseAdmin.app().firestore();
|
|
|
166
166
|
exported: true,
|
|
167
167
|
note: 'Where each short tracking link in a sent Outreach sequence email points (AGL-3297): the enrollment, the step, the link’s index and the destination the step wrote. The organization’s own record of what it sent; the id is random and derived from no secret.'
|
|
168
168
|
},
|
|
169
|
+
{
|
|
170
|
+
collection: 'shippingTrackers',
|
|
171
|
+
keyedBy: 'field',
|
|
172
|
+
subjects: [
|
|
173
|
+
'org'
|
|
174
|
+
],
|
|
175
|
+
exported: true,
|
|
176
|
+
note: 'One row per tracking number a label was bought for (AGL-3612): the site, the order record, the carrier, the tracking number and its latest status. The organization’s own record of what it shipped; the order it points at is already disclosed under its site. Org-only, for the reason `supplierDeliveries` gives: a tracking number locates a shopper’s parcel.'
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
collection: 'shippingQuoteCache',
|
|
180
|
+
keyedBy: 'field',
|
|
181
|
+
subjects: [
|
|
182
|
+
'org'
|
|
183
|
+
],
|
|
184
|
+
exported: true,
|
|
185
|
+
note: 'Carrier quotes for a site’s checkout, kept ten minutes (AGL-3612): the site, each service’s price and transit estimate, and when the row expires. No address is stored; the document id is a SHA-256 of the quoted request, an identifier rather than a verifier of any credential. Disclosed while it exists so the export does not omit a collection the erasure destroys.'
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
collection: 'taxEngineConnections',
|
|
189
|
+
keyedBy: 'field',
|
|
190
|
+
subjects: [
|
|
191
|
+
'org'
|
|
192
|
+
],
|
|
193
|
+
exported: true,
|
|
194
|
+
note: 'EXISTENCE ONLY for the credential — each site’s connection to the merchant’s own Avalara AvaTax or TaxJar account (AGL-3631): provider, environment, account id and company code, ship-from address, default tax code, last test result and the member who connected it. The sealed API token and the id of the key that sealed it both carry `token` in their names and are redacted (see redactSecrets); the document id is the site id.'
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
collection: 'taxEngineProductCodes',
|
|
198
|
+
keyedBy: 'field',
|
|
199
|
+
subjects: [
|
|
200
|
+
'org'
|
|
201
|
+
],
|
|
202
|
+
exported: true,
|
|
203
|
+
note: 'The tax code the merchant assigned each product at their tax service (AGL-3631). Catalog configuration the customer set, so it is theirs to take.'
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
collection: 'taxEngineExemptions',
|
|
207
|
+
keyedBy: 'field',
|
|
208
|
+
subjects: [
|
|
209
|
+
'org'
|
|
210
|
+
],
|
|
211
|
+
exported: true,
|
|
212
|
+
note: 'Customers the merchant recorded as tax-exempt (AGL-3631): email, name, exemption type, certificate number and the regions it covers. A SHOPPER’s data held for the merchant as controller, so disclosed to the ORG only, for the reason `supplierDeliveries` gives. The document id carries a truncated SHA-256 of the email — a lookup key, not a credential.'
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
collection: 'taxEngineTransactions',
|
|
216
|
+
keyedBy: 'field',
|
|
217
|
+
subjects: [
|
|
218
|
+
'org'
|
|
219
|
+
],
|
|
220
|
+
exported: true,
|
|
221
|
+
note: 'Each paid order as recorded with the merchant’s tax service, and the refunds reversed against it (AGL-3631): the sale sent (lines, ship-to address, the customer code, which is the shopper’s email), its status and last error. The merchant’s own tax record and a shopper’s address, so disclosed to the ORG only.'
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
collection: 'marketingPlatformConnections',
|
|
225
|
+
keyedBy: 'field',
|
|
226
|
+
subjects: [
|
|
227
|
+
'org'
|
|
228
|
+
],
|
|
229
|
+
exported: true,
|
|
230
|
+
note: 'EXISTENCE ONLY for the credential — each site’s connection to the merchant’s own Mailchimp, Klaviyo, Omnisend or Attentive account (AGL-3639): provider, status, account name, the chosen list and tag, the sync switches, cursors and run timing. The sealed access and refresh tokens and the id of the key that sealed them carry `token` in their names and are redacted (see redactSecrets); the document id is the site id and the provider.'
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
collection: 'marketingPlatformEvents',
|
|
234
|
+
keyedBy: 'field',
|
|
235
|
+
subjects: [
|
|
236
|
+
'org'
|
|
237
|
+
],
|
|
238
|
+
exported: true,
|
|
239
|
+
note: 'Commerce events still owed to a connected marketing platform (AGL-3639): checkout started, order paid, fulfilled, refunded or cancelled, with the shopper’s email, the order, its items and value, and the tracking number. A SHOPPER’s data held for the merchant as controller, so disclosed to the ORG only, for the reason `supplierDeliveries` gives; a row is deleted once delivered.'
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
collection: 'fulfillmentNetworkConnections',
|
|
243
|
+
keyedBy: 'field',
|
|
244
|
+
subjects: [
|
|
245
|
+
'org'
|
|
246
|
+
],
|
|
247
|
+
exported: true,
|
|
248
|
+
note: 'EXISTENCE ONLY for the grant — each site’s connection to the merchant’s own ShipBob or Amazon Multi-Channel Fulfillment account (AGL-3634): network, status, account name, ShipBob channel, Amazon marketplaces, routing and stock settings, the last stock count by SKU and totals. The sealed access and refresh tokens, the webhook token’s hash and the id of the key that sealed them carry `token` in their names and are redacted (see redactSecrets); the document id is the site id and the network.'
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
collection: 'fulfillmentNetworkOrders',
|
|
252
|
+
keyedBy: 'field',
|
|
253
|
+
subjects: [
|
|
254
|
+
'org'
|
|
255
|
+
],
|
|
256
|
+
exported: true,
|
|
257
|
+
note: 'Each paid order’s hand-off to a fulfillment network (AGL-3634): the order number, the lines sent with SKU and quantity, the network’s reference, the parcels read back with carrier, tracking number and status, and why anything was not sent. The merchant’s own fulfillment record of a SHOPPER’s order, so disclosed to the ORG only.'
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
collection: 'marketplaceConnections',
|
|
261
|
+
keyedBy: 'field',
|
|
262
|
+
subjects: [
|
|
263
|
+
'org'
|
|
264
|
+
],
|
|
265
|
+
exported: true,
|
|
266
|
+
note: 'EXISTENCE ONLY for the grant — each site’s connection to the merchant’s own Amazon, eBay, Etsy, TikTok Shop, Walmart or Faire seller account (AGL-3638): marketplace, status, account name, the marketplace sites the account sells on, the listing and order settings, the order cursor and the last sync’s counts. The sealed access and refresh tokens and the id of the key that sealed them carry `token` in their names and are redacted (see redactSecrets); the account’s marketplace ids are disclosed as stored. The document id is the site id and the marketplace. Its `log` and `listingState` subcollections (what each listing was last sent) are not read by this export.'
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
collection: 'marketplaceOrders',
|
|
270
|
+
keyedBy: 'field',
|
|
271
|
+
subjects: [
|
|
272
|
+
'org'
|
|
273
|
+
],
|
|
274
|
+
exported: true,
|
|
275
|
+
note: 'Each marketplace order the store imported (AGL-3638): the marketplace and its order id, the store order it became, which order line each marketplace line became, the marketplace’s fees, and each shipment confirmed back with carrier and tracking number. The merchant’s own record of a marketplace BUYER’s order, so disclosed to the ORG only; the buyer’s name and address are on the store’s order, not here.'
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
collection: 'inventorySyncConnections',
|
|
279
|
+
keyedBy: 'field',
|
|
280
|
+
subjects: [
|
|
281
|
+
'org'
|
|
282
|
+
],
|
|
283
|
+
exported: true,
|
|
284
|
+
note: 'EXISTENCE ONLY for the keys — each site’s connection to the merchant’s own Cin7 Core, inFlow or Brightpearl account (AGL-3642): system, status, account name, the stock, product and order settings (location, the customer orders are recorded under, the tax rule), the last count by SKU, the last syncs’ counts, cursors and totals. The sealed API keys or grant and the id of the key that sealed them carry `credential` in their names and are redacted (see redactSecrets); Brightpearl’s account code and datacenter are disclosed as stored. The document id is the site id. Its `log` subcollection is not read by this export.'
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
collection: 'inventorySyncOrders',
|
|
288
|
+
keyedBy: 'field',
|
|
289
|
+
subjects: [
|
|
290
|
+
'org'
|
|
291
|
+
],
|
|
292
|
+
exported: true,
|
|
293
|
+
note: 'Each paid order’s hand-off to the connected inventory system (AGL-3642): the order number and our reference, the lines sent with SKU, quantity and price, the buyer’s name, email address and shipping address as sent, the totals, the system’s id and number for it, attempts and why it was not sent. The merchant’s own record of a SHOPPER’s order, so disclosed to the ORG only.'
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
collection: 'inventorySyncProducts',
|
|
297
|
+
keyedBy: 'field',
|
|
298
|
+
subjects: [
|
|
299
|
+
'org'
|
|
300
|
+
],
|
|
301
|
+
exported: true,
|
|
302
|
+
note: 'Each product linked between the store and the connected inventory system (AGL-3642): the system’s product id, the SKU, the version last synced, the store’s product and variant ids, and why it was not synced. Catalog data, no person’s.'
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
collection: 'podConnections',
|
|
306
|
+
keyedBy: 'field',
|
|
307
|
+
subjects: [
|
|
308
|
+
'org'
|
|
309
|
+
],
|
|
310
|
+
exported: true,
|
|
311
|
+
note: 'EXISTENCE ONLY for the tokens — each site’s connection to the merchant’s own Printful store or Printify shop (AGL-3641): service, store id and name, currency, whether orders are sent automatically or held for review, whether prices sync, how shipments are heard about, and the last error. The sealed API token and webhook token and the id of the key that sealed them carry `token` in their names and are redacted (see redactSecrets).'
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
collection: 'podProductLinks',
|
|
315
|
+
keyedBy: 'field',
|
|
316
|
+
subjects: [
|
|
317
|
+
'org'
|
|
318
|
+
],
|
|
319
|
+
exported: true,
|
|
320
|
+
note: 'Each product imported from a print-on-demand service (AGL-3641): the service’s product and variant ids, the store product and variants it became, names, SKUs, what the service charges for each variant and the store’s price. Catalog data, no person’s.'
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
collection: 'podOrders',
|
|
324
|
+
keyedBy: 'field',
|
|
325
|
+
subjects: [
|
|
326
|
+
'org'
|
|
327
|
+
],
|
|
328
|
+
exported: true,
|
|
329
|
+
note: 'The part of each paid order a print-on-demand service makes and ships (AGL-3641): the store order and its number, the service’s order id, the lines sent with quantity and cost, its status, costs, attempts and errors, and each parcel’s carrier and tracking number. No buyer name, address or email is kept here; they are on the store’s order. The merchant’s own record of a SHOPPER’s order, so disclosed to the ORG only.'
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
collection: 'deliveryAppStores',
|
|
333
|
+
keyedBy: 'field',
|
|
334
|
+
subjects: [
|
|
335
|
+
'org'
|
|
336
|
+
],
|
|
337
|
+
exported: true,
|
|
338
|
+
note: 'Each site’s link to the merchant’s own DoorDash, Uber Eats or Grubhub store (AGL-3644): the service, the store id it shows the merchant, the settings (accept automatically, prep minutes), the items matched to products and those waiting, and the menu’s last send. No credential is stored: every call is signed with the deployment’s partner credentials.'
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
collection: 'deliveryAppOrders',
|
|
342
|
+
keyedBy: 'field',
|
|
343
|
+
subjects: [
|
|
344
|
+
'org'
|
|
345
|
+
],
|
|
346
|
+
exported: true,
|
|
347
|
+
note: 'Each order a delivery service sent a site’s register (AGL-3644): the service and its order id and short code, the items with their options and instructions, the totals and refunds, where it stood at the counter, and the store order it became. The buyer appears by first name and last initial only. The merchant’s own record of a delivery BUYER’s order, so disclosed to the ORG only.'
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
collection: 'zapierHooks',
|
|
351
|
+
keyedBy: 'field',
|
|
352
|
+
subjects: [
|
|
353
|
+
'org'
|
|
354
|
+
],
|
|
355
|
+
exported: true,
|
|
356
|
+
note: 'Each Zapier REST hook a site’s API key subscribed (AGL-3643): the site, the events, the API key’s public id and name, delivery counts and dates. EXISTENCE ONLY for the URL — `targetUrl` is the capability Zapier minted for the Zap, a bearer URL whose secret is in its path, so it is redacted by name (see redactSecrets).'
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
collection: 'zapierHookDeliveries',
|
|
360
|
+
keyedBy: 'field',
|
|
361
|
+
subjects: [
|
|
362
|
+
'org'
|
|
363
|
+
],
|
|
364
|
+
exported: true,
|
|
365
|
+
note: 'Markers that one event reached one Zapier hook, so a retry does not post it twice (AGL-3643): the hook, the status and the time. The document id is a SHA-256 of the event and hook, an identifier rather than a verifier of any credential; no customer data, and a TTL policy removes each row.'
|
|
366
|
+
},
|
|
169
367
|
{
|
|
170
368
|
collection: 'ssoDomains',
|
|
171
369
|
keyedBy: 'field',
|
|
@@ -406,6 +604,10 @@ const firestoreApp = ()=>firebaseAdmin.app().firestore();
|
|
|
406
604
|
// A Stripe payment link is a live bearer URL: whoever holds it can pay.
|
|
407
605
|
// Named in the AGL-1443 inventory of what the old dump actually carried.
|
|
408
606
|
'payment link',
|
|
607
|
+
// A Zapier REST hook's `targetUrl` (AGL-3643) is the same kind of bearer
|
|
608
|
+
// URL: whoever holds it can post into the Zap, and its secret is in the
|
|
609
|
+
// path, which the URL scrub does not read.
|
|
610
|
+
'target url',
|
|
409
611
|
'auth code',
|
|
410
612
|
'recovery code',
|
|
411
613
|
'backup code'
|
|
@@ -835,6 +1037,34 @@ function sourcesFor(subject) {
|
|
|
835
1037
|
data['outreachMailboxCredentials'] = await readByField(db, 'outreachMailboxCredentials', 'orgId', orgId);
|
|
836
1038
|
// Where each short tracking link the org's sequences sent points (AGL-3297).
|
|
837
1039
|
data['outreachLinks'] = await readByField(db, 'outreachLinks', 'orgId', orgId);
|
|
1040
|
+
// Shipping (AGL-3612), tax services (AGL-3631) and marketing platforms
|
|
1041
|
+
// (AGL-3639): top-level and keyed by `orgId`, the bound their
|
|
1042
|
+
// `orgKeyedCollections` erasure sweeps by. The sealed credentials are
|
|
1043
|
+
// withheld by `redactSecrets` on their names.
|
|
1044
|
+
for (const collection of [
|
|
1045
|
+
'shippingTrackers',
|
|
1046
|
+
'shippingQuoteCache',
|
|
1047
|
+
'taxEngineConnections',
|
|
1048
|
+
'taxEngineProductCodes',
|
|
1049
|
+
'taxEngineExemptions',
|
|
1050
|
+
'taxEngineTransactions',
|
|
1051
|
+
'marketingPlatformConnections',
|
|
1052
|
+
'marketingPlatformEvents',
|
|
1053
|
+
'fulfillmentNetworkConnections',
|
|
1054
|
+
'fulfillmentNetworkOrders',
|
|
1055
|
+
'marketplaceConnections',
|
|
1056
|
+
'marketplaceOrders',
|
|
1057
|
+
'inventorySyncConnections',
|
|
1058
|
+
'inventorySyncOrders',
|
|
1059
|
+
'inventorySyncProducts',
|
|
1060
|
+
'podConnections',
|
|
1061
|
+
'podProductLinks',
|
|
1062
|
+
'podOrders',
|
|
1063
|
+
'deliveryAppStores',
|
|
1064
|
+
'deliveryAppOrders'
|
|
1065
|
+
]){
|
|
1066
|
+
data[collection] = await readByField(db, collection, 'orgId', orgId);
|
|
1067
|
+
}
|
|
838
1068
|
data['publisherProfiles'] = await readDoc(db, 'publisherProfiles', orgId);
|
|
839
1069
|
data['marketplaceListings'] = await readByField(db, 'marketplaceListings', 'profileId', orgId);
|
|
840
1070
|
// Support threads WITH their messages — for an org the whole thread is the
|