@aglyn/tenant-data-admin 1.0.0-beta.204 → 1.0.0-beta.206
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/host-memberships.d.ts +20 -0
- package/src/lib/server/host-memberships.js +29 -1
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/notifications.d.ts +7 -0
- package/src/lib/server/notifications.js +27 -4
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/staff-alert-email.d.ts +29 -15
- package/src/lib/server/staff-alert-email.js +131 -28
- package/src/lib/server/staff-alert-email.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/tenant-data-admin",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.206",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,12 +25,12 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-util-fbserver": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-first-touch": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.206",
|
|
29
|
+
"@aglyn/shared-util-email": "1.0.0-beta.206",
|
|
30
|
+
"@aglyn/shared-util-fbserver": "1.0.0-beta.206",
|
|
31
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.206",
|
|
32
|
+
"@aglyn/shared-util-http": "1.0.0-beta.206",
|
|
33
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.206",
|
|
34
34
|
"@msgpack/msgpack": "^3.1.3",
|
|
35
35
|
"@swc/helpers": "0.5.23",
|
|
36
36
|
"sharp": "^0.35.3",
|
|
@@ -23,6 +23,8 @@ interface HostMeta {
|
|
|
23
23
|
cname?: string;
|
|
24
24
|
/** When the site was created: set once, by `claimHostForOrg`, and never moved. */
|
|
25
25
|
createdAt?: unknown;
|
|
26
|
+
/** Whether the site document was there to read. */
|
|
27
|
+
exists?: boolean;
|
|
26
28
|
}
|
|
27
29
|
/**
|
|
28
30
|
* The words a site is FOUND by in the Sites list's quick search (AGL-3321):
|
|
@@ -51,6 +53,24 @@ export declare function hostMembershipSearchTokens(meta: Pick<HostMeta, 'display
|
|
|
51
53
|
* detach route removes.
|
|
52
54
|
*/
|
|
53
55
|
export declare const hasCustomDomain: (meta: Pick<HostMeta, "cname"> | undefined) => boolean;
|
|
56
|
+
/**
|
|
57
|
+
* The keys the staff Sites list (`/admin/sites`) queries on the SITE document
|
|
58
|
+
* itself: the same name key, search tokens and custom-domain flag the
|
|
59
|
+
* membership rows carry, so staff find a site by its name, subdomain or
|
|
60
|
+
* domain across every organization without walking anyone's projection.
|
|
61
|
+
*
|
|
62
|
+
* Stamped by `syncHostProjectionForMembers` — the funnel every create,
|
|
63
|
+
* rename, domain attach and domain release already calls — and by
|
|
64
|
+
* `tools/scripts/backfill-host-search-fields.mjs` on the sites written before
|
|
65
|
+
* it. `hasCustomDomain` is stored as `false`, never left out, for the reason
|
|
66
|
+
* the membership row gives: a query cannot find a document by a field it
|
|
67
|
+
* lacks.
|
|
68
|
+
*/
|
|
69
|
+
export declare const hostSearchFields: (meta: Pick<HostMeta, "displayName" | "subdomain" | "cname"> | undefined) => {
|
|
70
|
+
nameLower: string;
|
|
71
|
+
searchTokens: string[];
|
|
72
|
+
hasCustomDomain: boolean;
|
|
73
|
+
};
|
|
54
74
|
/**
|
|
55
75
|
* Shape one projection row. Exported for tests: the favicon rule below is a
|
|
56
76
|
* delete-vs-omit decision that is invisible in review and silent in
|
|
@@ -80,7 +80,8 @@ const readHostMeta = async (db, hostIds)=>{
|
|
|
80
80
|
// favicon has to travel with it (AGL-1071).
|
|
81
81
|
favicon: (_snap_get = snap.get('seo')) == null ? void 0 : _snap_get.favicon,
|
|
82
82
|
cname: snap.get('cname'),
|
|
83
|
-
createdAt: snap.get('createdAt')
|
|
83
|
+
createdAt: snap.get('createdAt'),
|
|
84
|
+
exists: snap.exists
|
|
84
85
|
}
|
|
85
86
|
];
|
|
86
87
|
}));
|
|
@@ -143,6 +144,26 @@ const membershipRef = (db, uid, hostId)=>db.collection('users').doc(uid).collect
|
|
|
143
144
|
* a non-blank `cname`, which only the domain attach route writes and only the
|
|
144
145
|
* detach route removes.
|
|
145
146
|
*/ export const hasCustomDomain = (meta)=>typeof (meta == null ? void 0 : meta.cname) === 'string' && meta.cname.trim() !== '';
|
|
147
|
+
/**
|
|
148
|
+
* The keys the staff Sites list (`/admin/sites`) queries on the SITE document
|
|
149
|
+
* itself: the same name key, search tokens and custom-domain flag the
|
|
150
|
+
* membership rows carry, so staff find a site by its name, subdomain or
|
|
151
|
+
* domain across every organization without walking anyone's projection.
|
|
152
|
+
*
|
|
153
|
+
* Stamped by `syncHostProjectionForMembers` — the funnel every create,
|
|
154
|
+
* rename, domain attach and domain release already calls — and by
|
|
155
|
+
* `tools/scripts/backfill-host-search-fields.mjs` on the sites written before
|
|
156
|
+
* it. `hasCustomDomain` is stored as `false`, never left out, for the reason
|
|
157
|
+
* the membership row gives: a query cannot find a document by a field it
|
|
158
|
+
* lacks.
|
|
159
|
+
*/ export const hostSearchFields = (meta)=>{
|
|
160
|
+
var _ref;
|
|
161
|
+
return {
|
|
162
|
+
nameLower: nameSearchKey((_ref = meta == null ? void 0 : meta.displayName) != null ? _ref : ''),
|
|
163
|
+
searchTokens: hostMembershipSearchTokens(meta),
|
|
164
|
+
hasCustomDomain: hasCustomDomain(meta)
|
|
165
|
+
};
|
|
166
|
+
};
|
|
146
167
|
/** A Firestore timestamp, as opposed to a legacy number or string. */ const isTimestamp = (value)=>typeof (value == null ? void 0 : value.toMillis) === 'function';
|
|
147
168
|
/**
|
|
148
169
|
* Shape one projection row. Exported for tests: the favicon rule below is a
|
|
@@ -263,6 +284,13 @@ const membershipRef = (db, uid, hostId)=>db.collection('users').doc(uid).collect
|
|
|
263
284
|
else batch.delete(ref);
|
|
264
285
|
});
|
|
265
286
|
await commitChunked(db, ops);
|
|
287
|
+
// The staff Sites list's keys, on the site itself. `update`, never a
|
|
288
|
+
// merge-set: a site erased since the read must not be minted back as a
|
|
289
|
+
// phantom carrying only these three keys (the AGL-1765 shape). Outside the
|
|
290
|
+
// batch so a site gone missing cannot fail its members' rows.
|
|
291
|
+
if (hostMeta == null ? void 0 : hostMeta.exists) {
|
|
292
|
+
await db.collection('hosts').doc(hostId).update(hostSearchFields(hostMeta)).catch(()=>undefined);
|
|
293
|
+
}
|
|
266
294
|
}
|
|
267
295
|
/** Delete every projection row for a member across the org's hosts (member removed). */ export async function deleteMemberHostProjections(orgId, uid) {
|
|
268
296
|
const db = firestore();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-memberships.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Per-user host membership projection (AGL-844): a reverse index at\n * `users/{uid}/hostMemberships/{hostId}` mirroring `hosts/{hostId}.memberRoles`\n * — the hosts a user can reach in an org, denormalized with the name so the\n * site switcher and subdomain→id routing can query + order + name-prefix-search\n * a user's own sites without loading the whole `hosts` collection (which the\n * rules can only serve unordered under a per-user `memberRoles.{uid}` filter).\n *\n * Because it lives under the user's own doc it is owner-readable and already\n * membership-filtered, so `where(orgId==…) orderBy(nameLower|updatedAt)` is a\n * plain composite index (no dynamic map path). Admin-SDK-written only.\n *\n * It is a best-effort convenience index, NOT an authorization source: real\n * access stays gated by `hosts/{hostId}.memberRoles` in the rules, so a stale\n * row at worst shows a site that then 404s on read — never grants access.\n *\n * Writers are scoped, mirroring the `users/{uid}/orgs` reverse index: a member\n * change re-syncs THAT member across the org's hosts; a host change re-syncs\n * THAT host across members. Kept in sync at the same points as `memberRoles`\n * (see `syncHostMemberRoles` callers) plus the erase/displayName gaps (AGL-855).\n *\n * ## The Sites list is a query over these rows (AGL-3321)\n *\n * The organization's Sites list pages, filters and searches THIS collection —\n * `where(orgId ==)`, plus the reader's clauses, ordered by `nameLower` or\n * `createdAt` — and joins each row to the host document by id for display.\n * It cannot query `hosts` itself: the rules serve a non-staff LIST of `hosts`\n * only under a `memberRoles.{uid}` clause, a composite on that per-user map\n * path can exist for no order but the document id, and a predicated listen\n * over the mutable `memberRoles`/`orgId` is what tombstoned host documents\n * for every other reader (AGL-1190, `useOrgHosts`).\n *\n * So a row carries what that list asks of a site: `searchTokens` (the quick\n * search, over the name, the platform subdomain and the custom domain),\n * `hasCustomDomain` (the Custom domain filter) and the host's `createdAt`\n * (immutable, set once at create). Every field is derived from the host\n * document in `membershipRow`, so a re-sync from any writer re-derives all of\n * them; the writers of their source fields — create, the owner and staff\n * renames, the Setup name save, and the domain attach and detach routes (the\n * only writers of `cname`) — each re-sync. Rows written before AGL-3321 are\n * stamped by `tools/scripts/backfill-host-memberships-list-fields.mjs`.\n */\n\nimport {\n hostRoleFor,\n isOrgWideMember,\n nameSearchKey,\n type AglynOrganization,\n type AglynOrgMember,\n} from '@aglyn/aglyn/server'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** Firestore caps a batch at 500 writes; commit in chunks below that. */\nconst CHUNK = 400\n\ninterface HostMeta {\n displayName?: string\n subdomain?: string\n favicon?: string\n /** The custom domain, which the Sites list's search finds a site by. */\n cname?: string\n /** When the site was created: set once, by `claimHostForOrg`, and never moved. */\n createdAt?: unknown\n}\n\nconst orgHostIds = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> => {\n const org = (await db.collection('orgs').doc(orgId).get()).data() as\n | AglynOrganization\n | undefined\n return Object.keys(org?.hosts ?? {})\n}\n\nconst readHostMeta = async (\n db: FirebaseFirestore.Firestore,\n hostIds: string[],\n): Promise<Map<string, HostMeta>> => {\n if (hostIds.length === 0) return new Map()\n const snaps = await db.getAll(\n ...hostIds.map((id) => db.collection('hosts').doc(id)),\n )\n return new Map(\n snaps.map((snap) => [\n snap.id,\n {\n displayName: snap.get('displayName'),\n subdomain: snap.get('subdomain'),\n // The switcher renders from the projection, not the host doc, so the\n // favicon has to travel with it (AGL-1071).\n favicon: snap.get('seo')?.favicon,\n cname: snap.get('cname'),\n createdAt: snap.get('createdAt'),\n },\n ]),\n )\n}\n\nconst listMembers = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<AglynOrgMember[]> => {\n const snap = await db\n .collection('orgs')\n .doc(orgId)\n .collection('members')\n .get()\n return snap.docs.map((d) => ({ $id: d.id, ...d.data() }) as AglynOrgMember)\n}\n\nconst membershipRef = (\n db: FirebaseFirestore.Firestore,\n uid: string,\n hostId: string,\n) => db.collection('users').doc(uid).collection('hostMemberships').doc(hostId)\n\n/** A tail of a word begins after any character that is not a letter or a digit. */\nconst WORD_SEPARATOR = /[^\\p{L}\\p{N}]/u\n\n/**\n * A word, and every tail of it that begins after a separator:\n * `shop.harbor-bakery.com` is itself, `harbor-bakery.com`, `bakery.com` and\n * `com`. Walked by codepoint, so a tail never starts inside a surrogate pair.\n */\nconst wordTails = (word: string): string[] => {\n const chars = [...word]\n const tails = [word]\n for (let at = 1; at < chars.length; at += 1) {\n if (WORD_SEPARATOR.test(chars[at - 1]) && !WORD_SEPARATOR.test(chars[at])) {\n tails.push(chars.slice(at).join(''))\n }\n }\n return tails\n}\n\n/**\n * The words a site is FOUND by in the Sites list's quick search (AGL-3321):\n * its name, its platform subdomain and its custom domain, as the word-prefix\n * tokens `nameSearchTokens` writes, which the list's query asks for with one\n * `array-contains` on the first typed word (`nameSearchNormalizers.token`).\n *\n * A subdomain or a domain is one \"word\" to `nameSearchTokens`, so the prefixes\n * of `harbor-bakery` alone would find it by `harbor` and never by `bakery`.\n * Every tail that begins after a separator is tokenized too, which is what\n * lets `bakery`, `bakery.com` and `shop.harbor` all find\n * `shop.harbor-bakery.com` — the words a reader remembers of an address.\n *\n * The platform apex is not a word here: it is the same on every site, so it\n * narrows nothing, and the subdomain in front of it is.\n *\n * Its script-side twin, for the backfill and the seeds that write these rows,\n * is `tools/scripts/lib/host-membership-search-tokens.mjs`; both answer\n * `tools/scripts/lib/host-membership-search-tokens.fixtures.json`, which this\n * library's spec asserts too.\n */\nexport function hostMembershipSearchTokens(\n meta: Pick<HostMeta, 'displayName' | 'subdomain' | 'cname'> | undefined,\n): string[] {\n const words = [meta?.displayName, meta?.subdomain, meta?.cname]\n .map((value) => nameSearchKey(typeof value === 'string' ? value : ''))\n .flatMap((key) => (key ? key.split(' ') : []))\n .flatMap(wordTails)\n return nameSearchTokens(words.join(' '))\n}\n\n/**\n * Whether a site has a custom domain connected, as the Sites card lists it:\n * a non-blank `cname`, which only the domain attach route writes and only the\n * detach route removes.\n */\nexport const hasCustomDomain = (meta: Pick<HostMeta, 'cname'> | undefined): boolean =>\n typeof meta?.cname === 'string' && meta.cname.trim() !== ''\n\n/** A Firestore timestamp, as opposed to a legacy number or string. */\nconst isTimestamp = (value: unknown): boolean =>\n typeof (value as { toMillis?: unknown } | null | undefined)?.toMillis === 'function'\n\n/**\n * Shape one projection row. Exported for tests: the favicon rule below is a\n * delete-vs-omit decision that is invisible in review and silent in\n * production when wrong (AGL-1071).\n */\nexport const membershipRow = (\n orgId: string,\n meta: HostMeta | undefined,\n role: string,\n) => {\n const displayName = meta?.displayName ?? ''\n return {\n orgId,\n ...(meta?.subdomain ? { subdomain: meta.subdomain } : {}),\n displayName,\n nameLower: nameSearchKey(displayName),\n // What the Sites list searches (AGL-3321). Re-derived on every sync, so a\n // rename, a domain attach or a domain release moves it with the host.\n searchTokens: hostMembershipSearchTokens(meta),\n // The Custom domain filter (AGL-3321): an equality, which every order the\n // list takes can carry. Stored as `false`, never left out, because a query\n // cannot find a document by a field it lacks.\n hasCustomDomain: hasCustomDomain(meta),\n // What the Sites list's Created filter and order read. NULL rather than\n // absent for a site that never recorded one: `orderBy('createdAt')` drops\n // a document MISSING the field, and a site must not vanish from the list\n // because it is sorted by a date it does not have.\n createdAt: isTimestamp(meta?.createdAt) ? meta?.createdAt : null,\n // DELETE rather than omit when the site has no favicon (AGL-1071). These\n // rows are written with `{ merge: true }`, so omitting the key leaves\n // whatever was there — clearing a favicon would keep showing the old one\n // in the switcher until something unrelated rewrote the row. The clear\n // path writes `seo.favicon: ''`, so test truthiness, not nullishness.\n favicon: meta?.favicon ? meta.favicon : FieldValue.delete(),\n role,\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/** Runs a set of write operations in ≤CHUNK-sized batches. */\nconst commitChunked = async (\n db: FirebaseFirestore.Firestore,\n ops: Array<(batch: FirebaseFirestore.WriteBatch) => void>,\n): Promise<void> => {\n for (let i = 0; i < ops.length; i += CHUNK) {\n const batch = db.batch()\n for (const op of ops.slice(i, i + CHUNK)) op(batch)\n await batch.commit()\n }\n}\n\n/**\n * Mirror the member's org-wide reach onto their `users/{uid}/orgs/{orgId}`\n * row (AGL-1032), so the console can tell a site collaborator from an\n * org-wide viewer without a second read on every org route.\n *\n * Rides the projection pass rather than being a call the membership APIs\n * each have to remember: every path that changes a member's reach — add,\n * role change, grant, revoke, ownership transfer — already re-syncs their\n * host rows, so one funnel keeps the mirror true and self-heals a row an\n * earlier failure left stale.\n *\n * Only ever UPDATES an existing row. The reverse index is created with the\n * membership and deleted with it; a `set`/merge here on a removed member\n * would resurrect a half-row with no `orgName` or `slug`, and the console\n * renders that as a nameless workspace nobody can open.\n */\nasync function syncMemberOrgReach(\n db: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n member: AglynOrgMember | null,\n): Promise<void> {\n if (!member) return\n const ref = db.collection('users').doc(uid).collection('orgs').doc(orgId)\n const snap = await ref.get()\n if (!snap.exists) return\n const orgWide = isOrgWideMember(member)\n if (snap.get('orgWide') === orgWide) return\n await ref.set({ orgWide }, { merge: true })\n}\n\n/**\n * Re-sync ONE member's projection rows across all the org's hosts: set a row\n * for every host the member can reach, delete the row for every host they\n * cannot (a role downgrade silently drops hosts). Call after any change to a\n * single member (add / role change / grant / revoke / ownership step).\n */\nexport async function syncMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const [memberSnap, hostIds] = await Promise.all([\n db.collection('orgs').doc(orgId).collection('members').doc(uid).get(),\n orgHostIds(db, orgId),\n ])\n const member = memberSnap.exists\n ? ({ $id: uid, ...memberSnap.data() } as AglynOrgMember)\n : null\n await syncMemberOrgReach(db, orgId, uid, member)\n const meta = await readHostMeta(db, hostIds)\n const ops = hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, uid, hostId)\n if (role) batch.set(ref, membershipRow(orgId, meta.get(hostId), role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n}\n\n/**\n * Re-sync ONE host's projection rows across all org members: set a row for\n * every member who can reach it, delete for those who cannot. Call after a\n * host is created, renamed, or its displayName changes.\n */\nexport async function syncHostProjectionForMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const [members, meta] = await Promise.all([\n listMembers(db, orgId),\n readHostMeta(db, [hostId]),\n ])\n const hostMeta = meta.get(hostId)\n const ops = members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, member.$id, hostId)\n if (role) batch.set(ref, membershipRow(orgId, hostMeta, role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n}\n\n/** Delete every projection row for a member across the org's hosts (member removed). */\nexport async function deleteMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const hostIds = await orgHostIds(db, orgId)\n await commitChunked(\n db,\n hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, uid, hostId))\n }),\n )\n}\n\n/** Delete every member's projection row for one host (host erased). */\nexport async function deleteHostProjectionForAllMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const members = await listMembers(db, orgId)\n await commitChunked(\n db,\n members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, member.$id, hostId))\n }),\n )\n}\n"],"names":["hostRoleFor","isOrgWideMember","nameSearchKey","nameSearchTokens","FieldValue","firebaseAdmin","firestore","app","CHUNK","orgHostIds","db","orgId","org","collection","doc","get","data","Object","keys","hosts","readHostMeta","hostIds","length","Map","snaps","getAll","map","id","snap","displayName","subdomain","favicon","cname","createdAt","listMembers","docs","d","$id","membershipRef","uid","hostId","WORD_SEPARATOR","wordTails","word","chars","tails","at","test","push","slice","join","hostMembershipSearchTokens","meta","words","value","flatMap","key","split","hasCustomDomain","trim","isTimestamp","toMillis","membershipRow","role","nameLower","searchTokens","delete","updatedAt","serverTimestamp","commitChunked","ops","i","batch","op","commit","syncMemberOrgReach","member","ref","exists","orgWide","set","merge","syncMemberHostProjections","memberSnap","Promise","all","syncHostProjectionForMembers","members","hostMeta","deleteMemberHostProjections","deleteHostProjectionForAllMembers"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-memberships.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Per-user host membership projection (AGL-844): a reverse index at\n * `users/{uid}/hostMemberships/{hostId}` mirroring `hosts/{hostId}.memberRoles`\n * — the hosts a user can reach in an org, denormalized with the name so the\n * site switcher and subdomain→id routing can query + order + name-prefix-search\n * a user's own sites without loading the whole `hosts` collection (which the\n * rules can only serve unordered under a per-user `memberRoles.{uid}` filter).\n *\n * Because it lives under the user's own doc it is owner-readable and already\n * membership-filtered, so `where(orgId==…) orderBy(nameLower|updatedAt)` is a\n * plain composite index (no dynamic map path). Admin-SDK-written only.\n *\n * It is a best-effort convenience index, NOT an authorization source: real\n * access stays gated by `hosts/{hostId}.memberRoles` in the rules, so a stale\n * row at worst shows a site that then 404s on read — never grants access.\n *\n * Writers are scoped, mirroring the `users/{uid}/orgs` reverse index: a member\n * change re-syncs THAT member across the org's hosts; a host change re-syncs\n * THAT host across members. Kept in sync at the same points as `memberRoles`\n * (see `syncHostMemberRoles` callers) plus the erase/displayName gaps (AGL-855).\n *\n * ## The Sites list is a query over these rows (AGL-3321)\n *\n * The organization's Sites list pages, filters and searches THIS collection —\n * `where(orgId ==)`, plus the reader's clauses, ordered by `nameLower` or\n * `createdAt` — and joins each row to the host document by id for display.\n * It cannot query `hosts` itself: the rules serve a non-staff LIST of `hosts`\n * only under a `memberRoles.{uid}` clause, a composite on that per-user map\n * path can exist for no order but the document id, and a predicated listen\n * over the mutable `memberRoles`/`orgId` is what tombstoned host documents\n * for every other reader (AGL-1190, `useOrgHosts`).\n *\n * So a row carries what that list asks of a site: `searchTokens` (the quick\n * search, over the name, the platform subdomain and the custom domain),\n * `hasCustomDomain` (the Custom domain filter) and the host's `createdAt`\n * (immutable, set once at create). Every field is derived from the host\n * document in `membershipRow`, so a re-sync from any writer re-derives all of\n * them; the writers of their source fields — create, the owner and staff\n * renames, the Setup name save, and the domain attach and detach routes (the\n * only writers of `cname`) — each re-sync. Rows written before AGL-3321 are\n * stamped by `tools/scripts/backfill-host-memberships-list-fields.mjs`.\n */\n\nimport {\n hostRoleFor,\n isOrgWideMember,\n nameSearchKey,\n type AglynOrganization,\n type AglynOrgMember,\n} from '@aglyn/aglyn/server'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** Firestore caps a batch at 500 writes; commit in chunks below that. */\nconst CHUNK = 400\n\ninterface HostMeta {\n displayName?: string\n subdomain?: string\n favicon?: string\n /** The custom domain, which the Sites list's search finds a site by. */\n cname?: string\n /** When the site was created: set once, by `claimHostForOrg`, and never moved. */\n createdAt?: unknown\n /** Whether the site document was there to read. */\n exists?: boolean\n}\n\nconst orgHostIds = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> => {\n const org = (await db.collection('orgs').doc(orgId).get()).data() as\n | AglynOrganization\n | undefined\n return Object.keys(org?.hosts ?? {})\n}\n\nconst readHostMeta = async (\n db: FirebaseFirestore.Firestore,\n hostIds: string[],\n): Promise<Map<string, HostMeta>> => {\n if (hostIds.length === 0) return new Map()\n const snaps = await db.getAll(\n ...hostIds.map((id) => db.collection('hosts').doc(id)),\n )\n return new Map(\n snaps.map((snap) => [\n snap.id,\n {\n displayName: snap.get('displayName'),\n subdomain: snap.get('subdomain'),\n // The switcher renders from the projection, not the host doc, so the\n // favicon has to travel with it (AGL-1071).\n favicon: snap.get('seo')?.favicon,\n cname: snap.get('cname'),\n createdAt: snap.get('createdAt'),\n exists: snap.exists,\n },\n ]),\n )\n}\n\nconst listMembers = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<AglynOrgMember[]> => {\n const snap = await db\n .collection('orgs')\n .doc(orgId)\n .collection('members')\n .get()\n return snap.docs.map((d) => ({ $id: d.id, ...d.data() }) as AglynOrgMember)\n}\n\nconst membershipRef = (\n db: FirebaseFirestore.Firestore,\n uid: string,\n hostId: string,\n) => db.collection('users').doc(uid).collection('hostMemberships').doc(hostId)\n\n/** A tail of a word begins after any character that is not a letter or a digit. */\nconst WORD_SEPARATOR = /[^\\p{L}\\p{N}]/u\n\n/**\n * A word, and every tail of it that begins after a separator:\n * `shop.harbor-bakery.com` is itself, `harbor-bakery.com`, `bakery.com` and\n * `com`. Walked by codepoint, so a tail never starts inside a surrogate pair.\n */\nconst wordTails = (word: string): string[] => {\n const chars = [...word]\n const tails = [word]\n for (let at = 1; at < chars.length; at += 1) {\n if (WORD_SEPARATOR.test(chars[at - 1]) && !WORD_SEPARATOR.test(chars[at])) {\n tails.push(chars.slice(at).join(''))\n }\n }\n return tails\n}\n\n/**\n * The words a site is FOUND by in the Sites list's quick search (AGL-3321):\n * its name, its platform subdomain and its custom domain, as the word-prefix\n * tokens `nameSearchTokens` writes, which the list's query asks for with one\n * `array-contains` on the first typed word (`nameSearchNormalizers.token`).\n *\n * A subdomain or a domain is one \"word\" to `nameSearchTokens`, so the prefixes\n * of `harbor-bakery` alone would find it by `harbor` and never by `bakery`.\n * Every tail that begins after a separator is tokenized too, which is what\n * lets `bakery`, `bakery.com` and `shop.harbor` all find\n * `shop.harbor-bakery.com` — the words a reader remembers of an address.\n *\n * The platform apex is not a word here: it is the same on every site, so it\n * narrows nothing, and the subdomain in front of it is.\n *\n * Its script-side twin, for the backfill and the seeds that write these rows,\n * is `tools/scripts/lib/host-membership-search-tokens.mjs`; both answer\n * `tools/scripts/lib/host-membership-search-tokens.fixtures.json`, which this\n * library's spec asserts too.\n */\nexport function hostMembershipSearchTokens(\n meta: Pick<HostMeta, 'displayName' | 'subdomain' | 'cname'> | undefined,\n): string[] {\n const words = [meta?.displayName, meta?.subdomain, meta?.cname]\n .map((value) => nameSearchKey(typeof value === 'string' ? value : ''))\n .flatMap((key) => (key ? key.split(' ') : []))\n .flatMap(wordTails)\n return nameSearchTokens(words.join(' '))\n}\n\n/**\n * Whether a site has a custom domain connected, as the Sites card lists it:\n * a non-blank `cname`, which only the domain attach route writes and only the\n * detach route removes.\n */\nexport const hasCustomDomain = (meta: Pick<HostMeta, 'cname'> | undefined): boolean =>\n typeof meta?.cname === 'string' && meta.cname.trim() !== ''\n\n/**\n * The keys the staff Sites list (`/admin/sites`) queries on the SITE document\n * itself: the same name key, search tokens and custom-domain flag the\n * membership rows carry, so staff find a site by its name, subdomain or\n * domain across every organization without walking anyone's projection.\n *\n * Stamped by `syncHostProjectionForMembers` — the funnel every create,\n * rename, domain attach and domain release already calls — and by\n * `tools/scripts/backfill-host-search-fields.mjs` on the sites written before\n * it. `hasCustomDomain` is stored as `false`, never left out, for the reason\n * the membership row gives: a query cannot find a document by a field it\n * lacks.\n */\nexport const hostSearchFields = (\n meta: Pick<HostMeta, 'displayName' | 'subdomain' | 'cname'> | undefined,\n) => ({\n nameLower: nameSearchKey(meta?.displayName ?? ''),\n searchTokens: hostMembershipSearchTokens(meta),\n hasCustomDomain: hasCustomDomain(meta),\n})\n\n/** A Firestore timestamp, as opposed to a legacy number or string. */\nconst isTimestamp = (value: unknown): boolean =>\n typeof (value as { toMillis?: unknown } | null | undefined)?.toMillis === 'function'\n\n/**\n * Shape one projection row. Exported for tests: the favicon rule below is a\n * delete-vs-omit decision that is invisible in review and silent in\n * production when wrong (AGL-1071).\n */\nexport const membershipRow = (\n orgId: string,\n meta: HostMeta | undefined,\n role: string,\n) => {\n const displayName = meta?.displayName ?? ''\n return {\n orgId,\n ...(meta?.subdomain ? { subdomain: meta.subdomain } : {}),\n displayName,\n nameLower: nameSearchKey(displayName),\n // What the Sites list searches (AGL-3321). Re-derived on every sync, so a\n // rename, a domain attach or a domain release moves it with the host.\n searchTokens: hostMembershipSearchTokens(meta),\n // The Custom domain filter (AGL-3321): an equality, which every order the\n // list takes can carry. Stored as `false`, never left out, because a query\n // cannot find a document by a field it lacks.\n hasCustomDomain: hasCustomDomain(meta),\n // What the Sites list's Created filter and order read. NULL rather than\n // absent for a site that never recorded one: `orderBy('createdAt')` drops\n // a document MISSING the field, and a site must not vanish from the list\n // because it is sorted by a date it does not have.\n createdAt: isTimestamp(meta?.createdAt) ? meta?.createdAt : null,\n // DELETE rather than omit when the site has no favicon (AGL-1071). These\n // rows are written with `{ merge: true }`, so omitting the key leaves\n // whatever was there — clearing a favicon would keep showing the old one\n // in the switcher until something unrelated rewrote the row. The clear\n // path writes `seo.favicon: ''`, so test truthiness, not nullishness.\n favicon: meta?.favicon ? meta.favicon : FieldValue.delete(),\n role,\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/** Runs a set of write operations in ≤CHUNK-sized batches. */\nconst commitChunked = async (\n db: FirebaseFirestore.Firestore,\n ops: Array<(batch: FirebaseFirestore.WriteBatch) => void>,\n): Promise<void> => {\n for (let i = 0; i < ops.length; i += CHUNK) {\n const batch = db.batch()\n for (const op of ops.slice(i, i + CHUNK)) op(batch)\n await batch.commit()\n }\n}\n\n/**\n * Mirror the member's org-wide reach onto their `users/{uid}/orgs/{orgId}`\n * row (AGL-1032), so the console can tell a site collaborator from an\n * org-wide viewer without a second read on every org route.\n *\n * Rides the projection pass rather than being a call the membership APIs\n * each have to remember: every path that changes a member's reach — add,\n * role change, grant, revoke, ownership transfer — already re-syncs their\n * host rows, so one funnel keeps the mirror true and self-heals a row an\n * earlier failure left stale.\n *\n * Only ever UPDATES an existing row. The reverse index is created with the\n * membership and deleted with it; a `set`/merge here on a removed member\n * would resurrect a half-row with no `orgName` or `slug`, and the console\n * renders that as a nameless workspace nobody can open.\n */\nasync function syncMemberOrgReach(\n db: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n member: AglynOrgMember | null,\n): Promise<void> {\n if (!member) return\n const ref = db.collection('users').doc(uid).collection('orgs').doc(orgId)\n const snap = await ref.get()\n if (!snap.exists) return\n const orgWide = isOrgWideMember(member)\n if (snap.get('orgWide') === orgWide) return\n await ref.set({ orgWide }, { merge: true })\n}\n\n/**\n * Re-sync ONE member's projection rows across all the org's hosts: set a row\n * for every host the member can reach, delete the row for every host they\n * cannot (a role downgrade silently drops hosts). Call after any change to a\n * single member (add / role change / grant / revoke / ownership step).\n */\nexport async function syncMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const [memberSnap, hostIds] = await Promise.all([\n db.collection('orgs').doc(orgId).collection('members').doc(uid).get(),\n orgHostIds(db, orgId),\n ])\n const member = memberSnap.exists\n ? ({ $id: uid, ...memberSnap.data() } as AglynOrgMember)\n : null\n await syncMemberOrgReach(db, orgId, uid, member)\n const meta = await readHostMeta(db, hostIds)\n const ops = hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, uid, hostId)\n if (role) batch.set(ref, membershipRow(orgId, meta.get(hostId), role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n}\n\n/**\n * Re-sync ONE host's projection rows across all org members: set a row for\n * every member who can reach it, delete for those who cannot. Call after a\n * host is created, renamed, or its displayName changes.\n */\nexport async function syncHostProjectionForMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const [members, meta] = await Promise.all([\n listMembers(db, orgId),\n readHostMeta(db, [hostId]),\n ])\n const hostMeta = meta.get(hostId)\n const ops = members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, member.$id, hostId)\n if (role) batch.set(ref, membershipRow(orgId, hostMeta, role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n // The staff Sites list's keys, on the site itself. `update`, never a\n // merge-set: a site erased since the read must not be minted back as a\n // phantom carrying only these three keys (the AGL-1765 shape). Outside the\n // batch so a site gone missing cannot fail its members' rows.\n if (hostMeta?.exists) {\n await db\n .collection('hosts')\n .doc(hostId)\n .update(hostSearchFields(hostMeta))\n .catch(() => undefined)\n }\n}\n\n/** Delete every projection row for a member across the org's hosts (member removed). */\nexport async function deleteMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const hostIds = await orgHostIds(db, orgId)\n await commitChunked(\n db,\n hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, uid, hostId))\n }),\n )\n}\n\n/** Delete every member's projection row for one host (host erased). */\nexport async function deleteHostProjectionForAllMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const members = await listMembers(db, orgId)\n await commitChunked(\n db,\n members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, member.$id, hostId))\n }),\n )\n}\n"],"names":["hostRoleFor","isOrgWideMember","nameSearchKey","nameSearchTokens","FieldValue","firebaseAdmin","firestore","app","CHUNK","orgHostIds","db","orgId","org","collection","doc","get","data","Object","keys","hosts","readHostMeta","hostIds","length","Map","snaps","getAll","map","id","snap","displayName","subdomain","favicon","cname","createdAt","exists","listMembers","docs","d","$id","membershipRef","uid","hostId","WORD_SEPARATOR","wordTails","word","chars","tails","at","test","push","slice","join","hostMembershipSearchTokens","meta","words","value","flatMap","key","split","hasCustomDomain","trim","hostSearchFields","nameLower","searchTokens","isTimestamp","toMillis","membershipRow","role","delete","updatedAt","serverTimestamp","commitChunked","ops","i","batch","op","commit","syncMemberOrgReach","member","ref","orgWide","set","merge","syncMemberHostProjections","memberSnap","Promise","all","syncHostProjectionForMembers","members","hostMeta","update","catch","undefined","deleteMemberHostProjections","deleteHostProjectionForAllMembers"],"mappings":"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"}
|
|
@@ -35,6 +35,13 @@ export interface NotifyUsersOptions {
|
|
|
35
35
|
* brand when the notification names one.
|
|
36
36
|
*/
|
|
37
37
|
audience?: 'staff';
|
|
38
|
+
/**
|
|
39
|
+
* Write the console notification only, and mail nobody through the
|
|
40
|
+
* per-person email channel (AGL-3375): the caller has emailed the same
|
|
41
|
+
* alert to the operator's inbox, and a staff member who also switched
|
|
42
|
+
* email on would otherwise get it twice.
|
|
43
|
+
*/
|
|
44
|
+
skipEmail?: boolean;
|
|
38
45
|
}
|
|
39
46
|
/**
|
|
40
47
|
* Notification fan-out (AGL-259): batch-writes one doc per recipient at
|
|
@@ -14,7 +14,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
|
-
*/ import { buildRoute, NOTIFICATION_SELF_SENT_EMAIL_TYPES, NOTIFICATION_SETTINGS_FIELD, normalizeNotificationLink, notificationChannelEnabled, Route } from "@aglyn/aglyn/server";
|
|
17
|
+
*/ import { buildRoute, NOTIFICATION_SELF_SENT_EMAIL_TYPES, NOTIFICATION_SETTINGS_FIELD, OPERATOR_ALERT_NOTIFICATION_TYPES, normalizeNotificationLink, notificationChannelEnabled, Route } from "@aglyn/aglyn/server";
|
|
18
18
|
import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
|
|
19
19
|
import { FieldValue } from "firebase-admin/firestore";
|
|
20
20
|
import { findUserByUidAcrossPools, listStaffUidsAcrossPools } from "./auth-pools.js";
|
|
@@ -22,6 +22,7 @@ import { filterSuppressedEmails } from "./email-suppression.js";
|
|
|
22
22
|
import { meterOrgEmail, meterPlatformEmail } from "./email-metering.js";
|
|
23
23
|
import firebaseAdmin from "./firebase-admin.js";
|
|
24
24
|
import { listOrgMembers } from "./organizations.js";
|
|
25
|
+
import { resolveStaffAlertRecipients, sendOperatorAlertEmail } from "./staff-alert-email.js";
|
|
25
26
|
import { loadSystemEmail, renderLoadedSystemEmail, systemEmailBrand, systemEmailContent } from "./render-system-email.js";
|
|
26
27
|
const firestore = ()=>firebaseAdmin.app().firestore();
|
|
27
28
|
/** The console's absolute origin for an email link, or `''` when unset. */ function consoleOrigin() {
|
|
@@ -224,7 +225,7 @@ const NOTIFY_EMAIL_MAX_SENDS = 50;
|
|
|
224
225
|
* Digests are excluded: they compose and send their own mail, under
|
|
225
226
|
* their own switches, and the generic channel would send a second
|
|
226
227
|
* message announcing that the first one had been sent.
|
|
227
|
-
*/ if (mailTo.length && isEmailConfigured() && !NOTIFICATION_SELF_SENT_EMAIL_TYPES.has(payload.type)) {
|
|
228
|
+
*/ if (mailTo.length && !options.skipEmail && isEmailConfigured() && !NOTIFICATION_SELF_SENT_EMAIL_TYPES.has(payload.type)) {
|
|
228
229
|
var _options_emails;
|
|
229
230
|
await emailNotification(mailTo, payload, (_options_emails = options.emails) != null ? _options_emails : {}, options.audience);
|
|
230
231
|
}
|
|
@@ -263,13 +264,35 @@ async function listStaffUids() {
|
|
|
263
264
|
* renders, so no separate staff inbox is needed. Never throws.
|
|
264
265
|
*/ export async function notifyStaff(payload) {
|
|
265
266
|
try {
|
|
266
|
-
|
|
267
|
+
// A fraud or risk alert also reaches the operator's inbox by email
|
|
268
|
+
// (AGL-3375), on every install and whatever anyone switched on. Whether
|
|
269
|
+
// there is an inbox is settled first, so the per-person channel stands
|
|
270
|
+
// down only when the alert is actually going somewhere.
|
|
271
|
+
const operatorAlert = OPERATOR_ALERT_NOTIFICATION_TYPES.has(payload.type) && isEmailConfigured() && (await resolveStaffAlertRecipients()).length > 0;
|
|
272
|
+
await notifyUsers(await listStaffUids(), payload, _extends({
|
|
267
273
|
audience: 'staff'
|
|
268
|
-
}
|
|
274
|
+
}, operatorAlert ? {
|
|
275
|
+
skipEmail: true
|
|
276
|
+
} : {}));
|
|
277
|
+
if (operatorAlert) {
|
|
278
|
+
await sendOperatorAlertEmail({
|
|
279
|
+
title: payload.title,
|
|
280
|
+
body: payload.body,
|
|
281
|
+
url: absoluteConsoleLink(payload.link),
|
|
282
|
+
context: `operator-alert ${payload.type}`
|
|
283
|
+
});
|
|
284
|
+
}
|
|
269
285
|
} catch (error) {
|
|
270
286
|
console.error('staff notification failed', error);
|
|
271
287
|
}
|
|
272
288
|
}
|
|
289
|
+
/** A notification link as an inbox can follow it, or `''`. */ function absoluteConsoleLink(link) {
|
|
290
|
+
const value = String(link != null ? link : '').trim();
|
|
291
|
+
if (!value) return '';
|
|
292
|
+
if (/^https?:\/\//i.test(value)) return value;
|
|
293
|
+
const origin = consoleOrigin();
|
|
294
|
+
return origin && value.startsWith('/') ? `${origin}${value}` : '';
|
|
295
|
+
}
|
|
273
296
|
/** Notifies the org's owner + admins (billing, membership, org events). */ export async function notifyOrgAdmins(orgId, payload) {
|
|
274
297
|
try {
|
|
275
298
|
const members = await listOrgMembers(orgId);
|
|
@@ -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 { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { FieldValue } from 'firebase-admin/firestore'\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\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, 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 ) {\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 if (\n mailTo.length &&\n isEmailConfigured() &&\n !NOTIFICATION_SELF_SENT_EMAIL_TYPES.has(payload.type)\n ) {\n await emailNotification(\n mailTo,\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 */\nexport async function notifyStaff(payload: NotificationPayload): Promise<void> {\n try {\n await notifyUsers(await listStaffUids(), payload, { audience: 'staff' })\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/** Notifies the org's owner + admins (billing, membership, org events). */\nexport async function notifyOrgAdmins(\n orgId: string,\n payload: NotificationPayload,\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 },\n )\n } catch (error) {\n console.error('org admin notification failed', error)\n }\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): 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 await notifyUsers(managers, {\n ...payload,\n hostId,\n ...(orgId ? { orgId } : {}),\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","isEmailConfigured","sendEmail","FieldValue","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","push","commit","has","emails","staffUidCache","STAFF_CACHE_MS","listStaffUids","now","Date","at","notifyStaff","notifyOrgAdmins","members","admins","member","role","$id","notifyHostManagers","memberRoles","managers","Object","entries"],"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 OPERATOR_ALERT_NOTIFICATION_TYPES,\n normalizeNotificationLink,\n notificationChannelEnabled,\n type AglynNotification,\n Route,\n type NotificationSettings,\n} from '@aglyn/aglyn/server'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { FieldValue } from 'firebase-admin/firestore'\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 resolveStaffAlertRecipients,\n sendOperatorAlertEmail,\n} from './staff-alert-email'\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\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, 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 ) {\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 if (\n mailTo.length &&\n !options.skipEmail &&\n isEmailConfigured() &&\n !NOTIFICATION_SELF_SENT_EMAIL_TYPES.has(payload.type)\n ) {\n await emailNotification(\n mailTo,\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 */\nexport async function notifyStaff(payload: NotificationPayload): Promise<void> {\n try {\n // A fraud or risk alert also reaches the operator's inbox by email\n // (AGL-3375), on every install and whatever anyone switched on. Whether\n // there is an inbox is settled first, so the per-person channel stands\n // down only when the alert is actually going somewhere.\n const operatorAlert =\n OPERATOR_ALERT_NOTIFICATION_TYPES.has(payload.type) &&\n isEmailConfigured() &&\n (await resolveStaffAlertRecipients()).length > 0\n await notifyUsers(await listStaffUids(), payload, {\n audience: 'staff',\n ...(operatorAlert ? { skipEmail: true } : {}),\n })\n if (operatorAlert) {\n await sendOperatorAlertEmail({\n title: payload.title,\n body: payload.body,\n url: absoluteConsoleLink(payload.link),\n context: `operator-alert ${payload.type}`,\n })\n }\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/** A notification link as an inbox can follow it, or `''`. */\nfunction absoluteConsoleLink(link: string | undefined): string {\n const value = String(link ?? '').trim()\n if (!value) return ''\n if (/^https?:\\/\\//i.test(value)) return value\n const origin = consoleOrigin()\n return origin && value.startsWith('/') ? `${origin}${value}` : ''\n}\n\n/** Notifies the org's owner + admins (billing, membership, org events). */\nexport async function notifyOrgAdmins(\n orgId: string,\n payload: NotificationPayload,\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 },\n )\n } catch (error) {\n console.error('org admin notification failed', error)\n }\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): 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 await notifyUsers(managers, {\n ...payload,\n hostId,\n ...(orgId ? { orgId } : {}),\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","OPERATOR_ALERT_NOTIFICATION_TYPES","normalizeNotificationLink","notificationChannelEnabled","Route","isEmailConfigured","sendEmail","FieldValue","findUserByUidAcrossPools","listStaffUidsAcrossPools","filterSuppressedEmails","meterOrgEmail","meterPlatformEmail","firebaseAdmin","listOrgMembers","resolveStaffAlertRecipients","sendOperatorAlertEmail","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","push","commit","skipEmail","has","emails","staffUidCache","STAFF_CACHE_MS","listStaffUids","now","Date","at","notifyStaff","operatorAlert","absoluteConsoleLink","value","notifyOrgAdmins","members","admins","member","role","$id","notifyHostManagers","memberRoles","managers","Object","entries"],"mappings":"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"}
|
|
@@ -16,27 +16,41 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { type SendEmailResult } from '@aglyn/shared-util-email';
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
19
|
+
* Where a staff alarm goes (AGL-3375), first answer wins:
|
|
20
20
|
*
|
|
21
|
-
* `STAFF_ALERT_EMAIL
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* sends through this, to the same address, with the same posture: unset is
|
|
27
|
-
* the ordinary answer outside production and reports `unconfigured`, and
|
|
28
|
-
* nothing here throws, because the mail is a courtesy and the refusal it
|
|
29
|
-
* announces is the control.
|
|
21
|
+
* 1. `STAFF_ALERT_EMAIL`, the operator's one alerts inbox.
|
|
22
|
+
* 2. The operator's published support address,
|
|
23
|
+
* `NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL`, which every self-hosted install is
|
|
24
|
+
* asked for and most set.
|
|
25
|
+
* 3. Every staff account's own address, up to {@link STAFF_FALLBACK_MAX}.
|
|
30
26
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
27
|
+
* Only the first applies on a preview or development deployment.
|
|
28
|
+
*
|
|
29
|
+
* The chain exists because the alarms that matter most are the ones an
|
|
30
|
+
* install nobody tuned would otherwise send to no one: card testing on its
|
|
31
|
+
* checkout, a stolen card on its first subscription, a phishing campaign
|
|
32
|
+
* held on its sending domain. An unset variable used to mean silence.
|
|
33
|
+
*
|
|
34
|
+
* Empty only when none of the three yields an address.
|
|
36
35
|
*/
|
|
36
|
+
export declare function resolveStaffAlertRecipients(): Promise<string[]>;
|
|
37
37
|
export declare function sendStaffAlertEmail(input: {
|
|
38
38
|
subject: string;
|
|
39
39
|
text: string;
|
|
40
40
|
/** Resend tag / log label. */
|
|
41
41
|
context: string;
|
|
42
42
|
}): Promise<SendEmailResult>;
|
|
43
|
+
/**
|
|
44
|
+
* A fraud or risk alert for the operator (AGL-3375), as the `operator-alert`
|
|
45
|
+
* system email: the alert, a button to the item it is about, in the
|
|
46
|
+
* operator's own brand. Sent by `notifyStaff` for every
|
|
47
|
+
* `OPERATOR_ALERT_NOTIFICATION_TYPES` notification, beside the console one.
|
|
48
|
+
* Never throws.
|
|
49
|
+
*/
|
|
50
|
+
export declare function sendOperatorAlertEmail(input: {
|
|
51
|
+
title: string;
|
|
52
|
+
body?: string | null;
|
|
53
|
+
/** Absolute link to the item, when there is one. */
|
|
54
|
+
url?: string | null;
|
|
55
|
+
context: string;
|
|
56
|
+
}): Promise<SendEmailResult>;
|
|
@@ -16,33 +16,113 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { sendEmail } from "@aglyn/shared-util-email";
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
19
|
+
* Staff alerts by email, from LIBRARY code (AGL-2925).
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* the ordinary answer outside production and reports `unconfigured`, and
|
|
28
|
-
* nothing here throws, because the mail is a courtesy and the refusal it
|
|
29
|
-
* announces is the control.
|
|
21
|
+
* One destination for every subsystem's alarm — the console's
|
|
22
|
+
* `emailStaffAlert` in `apps/console/app/api/_lib/usage-alert-email.ts` sends
|
|
23
|
+
* through here too — resolved by {@link resolveStaffAlertRecipients}
|
|
24
|
+
* (AGL-3375). Nothing here throws, because the mail is a courtesy and the
|
|
25
|
+
* refusal it announces is the control; with no address anywhere the answer
|
|
26
|
+
* is `unconfigured`.
|
|
30
27
|
*
|
|
31
28
|
* Platform email metering is loaded lazily and only after a send, so that
|
|
32
29
|
* importing this module — which every unit test of the meter does — never
|
|
33
30
|
* pulls the Firestore-backed meter, and with it the admin app, into a test
|
|
34
|
-
* that only wanted to count credits. The system-email renderer
|
|
35
|
-
*
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
31
|
+
* that only wanted to count credits. The system-email renderer and the staff
|
|
32
|
+
* roster, which read through the same admin app, are loaded the same way.
|
|
33
|
+
*/ /** At most this many staff accounts are mailed when no inbox is configured. */ const STAFF_FALLBACK_MAX = 10;
|
|
34
|
+
/**
|
|
35
|
+
* Where a staff alarm goes (AGL-3375), first answer wins:
|
|
36
|
+
*
|
|
37
|
+
* 1. `STAFF_ALERT_EMAIL`, the operator's one alerts inbox.
|
|
38
|
+
* 2. The operator's published support address,
|
|
39
|
+
* `NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL`, which every self-hosted install is
|
|
40
|
+
* asked for and most set.
|
|
41
|
+
* 3. Every staff account's own address, up to {@link STAFF_FALLBACK_MAX}.
|
|
42
|
+
*
|
|
43
|
+
* Only the first applies on a preview or development deployment.
|
|
44
|
+
*
|
|
45
|
+
* The chain exists because the alarms that matter most are the ones an
|
|
46
|
+
* install nobody tuned would otherwise send to no one: card testing on its
|
|
47
|
+
* checkout, a stolen card on its first subscription, a phishing campaign
|
|
48
|
+
* held on its sending domain. An unset variable used to mean silence.
|
|
49
|
+
*
|
|
50
|
+
* Empty only when none of the three yields an address.
|
|
51
|
+
*/ export async function resolveStaffAlertRecipients() {
|
|
52
|
+
var _process_env_STAFF_ALERT_EMAIL, _process_env_VERCEL_ENV, _process_env_NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL;
|
|
53
|
+
const configured = String((_process_env_STAFF_ALERT_EMAIL = process.env.STAFF_ALERT_EMAIL) != null ? _process_env_STAFF_ALERT_EMAIL : '').trim().toLowerCase();
|
|
54
|
+
if (configured.includes('@')) return [
|
|
55
|
+
configured
|
|
56
|
+
];
|
|
57
|
+
// A preview or development deployment says what it is; only a production
|
|
58
|
+
// one, or a self-hosted install that sets no such thing, falls back. A
|
|
59
|
+
// preview's alarms reaching the operator's real inbox would be noise.
|
|
60
|
+
const deployment = String((_process_env_VERCEL_ENV = process.env.VERCEL_ENV) != null ? _process_env_VERCEL_ENV : '').trim();
|
|
61
|
+
if (deployment && deployment !== 'production') return [];
|
|
62
|
+
const operator = String((_process_env_NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL = process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL) != null ? _process_env_NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL : '').trim().toLowerCase();
|
|
63
|
+
if (operator.includes('@')) return [
|
|
64
|
+
operator
|
|
65
|
+
];
|
|
66
|
+
try {
|
|
67
|
+
const { findUserByUidAcrossPools, listStaffUidsAcrossPools } = await import("./auth-pools.js");
|
|
68
|
+
const uids = (await listStaffUidsAcrossPools()).slice(0, STAFF_FALLBACK_MAX);
|
|
69
|
+
const addresses = new Set();
|
|
70
|
+
for (const uid of uids){
|
|
71
|
+
var _ref;
|
|
72
|
+
var _pooled_record;
|
|
73
|
+
const pooled = await findUserByUidAcrossPools(uid).catch(()=>null);
|
|
74
|
+
const address = String((_ref = pooled == null ? void 0 : (_pooled_record = pooled.record) == null ? void 0 : _pooled_record.email) != null ? _ref : '').trim().toLowerCase();
|
|
75
|
+
if (address.includes('@')) addresses.add(address);
|
|
76
|
+
}
|
|
77
|
+
return [
|
|
78
|
+
...addresses
|
|
79
|
+
];
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error('[staff-alert-email] staff roster unavailable', error);
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Sends one rendered alert to each operator recipient, one message each so
|
|
87
|
+
* no staff member is shown another's address. Metered to the platform: our
|
|
88
|
+
* own alarm is our own cost (AGL-1438). Never throws.
|
|
89
|
+
*/ async function sendToOperators(content, context) {
|
|
90
|
+
const recipients = await resolveStaffAlertRecipients();
|
|
91
|
+
if (!recipients.length) return {
|
|
40
92
|
sent: false,
|
|
41
93
|
reason: 'unconfigured'
|
|
42
94
|
};
|
|
95
|
+
let delivered = null;
|
|
96
|
+
let failure = {
|
|
97
|
+
sent: false,
|
|
98
|
+
reason: 'unconfigured'
|
|
99
|
+
};
|
|
100
|
+
let sent = 0;
|
|
101
|
+
for (const to of recipients){
|
|
102
|
+
const result = await sendEmail(_extends({
|
|
103
|
+
to
|
|
104
|
+
}, content, {
|
|
105
|
+
context
|
|
106
|
+
}));
|
|
107
|
+
if (result.sent) {
|
|
108
|
+
sent += 1;
|
|
109
|
+
delivered != null ? delivered : delivered = result;
|
|
110
|
+
} else {
|
|
111
|
+
failure = result;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (sent) {
|
|
115
|
+
const { meterPlatformEmail } = await import("./email-metering.js");
|
|
116
|
+
await meterPlatformEmail(sent).catch(()=>undefined);
|
|
117
|
+
}
|
|
118
|
+
// Delivered to anyone is delivered: one dead staff mailbox does not make
|
|
119
|
+
// the alert a failure for the caller.
|
|
120
|
+
return delivered != null ? delivered : failure;
|
|
121
|
+
}
|
|
122
|
+
export async function sendStaffAlertEmail(input) {
|
|
43
123
|
try {
|
|
44
124
|
// In the platform's header and footer, as the `staff-alert` system email
|
|
45
|
-
// (AGL-3367). Loaded lazily for the reason the meter
|
|
125
|
+
// (AGL-3367). Loaded lazily for the reason the meter is.
|
|
46
126
|
const { renderSystemEmailContent, systemEmailBrand } = await import("./render-system-email.js");
|
|
47
127
|
const content = await renderSystemEmailContent('staff-alert', {
|
|
48
128
|
'alert.subject': input.subject,
|
|
@@ -51,17 +131,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
51
131
|
subject: input.subject,
|
|
52
132
|
text: input.text
|
|
53
133
|
});
|
|
54
|
-
|
|
55
|
-
to
|
|
56
|
-
}, content, {
|
|
57
|
-
context: input.context
|
|
58
|
-
}));
|
|
59
|
-
if (result.sent) {
|
|
60
|
-
// Platform-scoped (AGL-1438): our own alert is our own cost.
|
|
61
|
-
const { meterPlatformEmail } = await import("./email-metering.js");
|
|
62
|
-
await meterPlatformEmail().catch(()=>undefined);
|
|
63
|
-
}
|
|
64
|
-
return result;
|
|
134
|
+
return await sendToOperators(content, input.context);
|
|
65
135
|
} catch (error) {
|
|
66
136
|
console.error('[staff-alert-email] send failed', error);
|
|
67
137
|
return {
|
|
@@ -70,5 +140,38 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
70
140
|
};
|
|
71
141
|
}
|
|
72
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* A fraud or risk alert for the operator (AGL-3375), as the `operator-alert`
|
|
145
|
+
* system email: the alert, a button to the item it is about, in the
|
|
146
|
+
* operator's own brand. Sent by `notifyStaff` for every
|
|
147
|
+
* `OPERATOR_ALERT_NOTIFICATION_TYPES` notification, beside the console one.
|
|
148
|
+
* Never throws.
|
|
149
|
+
*/ export async function sendOperatorAlertEmail(input) {
|
|
150
|
+
try {
|
|
151
|
+
var _input_body, _input_url;
|
|
152
|
+
const { renderSystemEmailContent, systemEmailBrand } = await import("./render-system-email.js");
|
|
153
|
+
const body = String((_input_body = input.body) != null ? _input_body : '').trim();
|
|
154
|
+
const url = String((_input_url = input.url) != null ? _input_url : '').trim();
|
|
155
|
+
const content = await renderSystemEmailContent('operator-alert', {
|
|
156
|
+
'alert.title': input.title,
|
|
157
|
+
'alert.body': body,
|
|
158
|
+
'alert.url': url
|
|
159
|
+
}, systemEmailBrand(null), {
|
|
160
|
+
subject: input.title,
|
|
161
|
+
text: [
|
|
162
|
+
input.title,
|
|
163
|
+
body,
|
|
164
|
+
url
|
|
165
|
+
].filter(Boolean).join('\n\n')
|
|
166
|
+
});
|
|
167
|
+
return await sendToOperators(content, input.context);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
console.error('[staff-alert-email] operator alert failed', error);
|
|
170
|
+
return {
|
|
171
|
+
sent: false,
|
|
172
|
+
reason: 'network'
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
73
176
|
|
|
74
177
|
//# sourceMappingURL=staff-alert-email.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/staff-alert-email.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 { sendEmail, type SendEmailResult } from '@aglyn/shared-util-email'\n\n/**\n * A staff alert by email, from LIBRARY code (AGL-2925).\n *\n * `STAFF_ALERT_EMAIL` is the one inbox every subsystem's alert lands in —\n * the console's `emailStaffAlert` in `apps/console/app/api/_lib/\n * usage-alert-email.ts` sends the cron's alerts there. A library cannot\n * call that, because a library cannot import from an app. So a library's\n * own alert — the AI meter's platform-wide free-spend ceiling is one —\n * sends through this, to the same address, with the same posture: unset is\n * the ordinary answer outside production and reports `unconfigured`, and\n * nothing here throws, because the mail is a courtesy and the refusal it\n * announces is the control.\n *\n * Platform email metering is loaded lazily and only after a send, so that\n * importing this module — which every unit test of the meter does — never\n * pulls the Firestore-backed meter, and with it the admin app, into a test\n * that only wanted to count credits. The system-email renderer, which reads\n * its template through the same admin app, is loaded the same way.\n */\nexport async function sendStaffAlertEmail(input: {\n subject: string\n text: string\n /** Resend tag / log label. */\n context: string\n}): Promise<SendEmailResult> {\n const to = String(process.env.STAFF_ALERT_EMAIL ?? '').trim()\n if (!to.includes('@')) return { sent: false, reason: 'unconfigured' }\n try {\n // In the platform's header and footer, as the `staff-alert` system email\n // (AGL-3367). Loaded lazily for the reason the meter below is.\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const content = await renderSystemEmailContent(\n 'staff-alert',\n { 'alert.subject': input.subject, 'alert.body': input.text },\n systemEmailBrand(null),\n { subject: input.subject, text: input.text },\n )\n const result = await sendEmail({ to, ...content, context: input.context })\n if (result.sent) {\n // Platform-scoped (AGL-1438): our own alert is our own cost.\n const { meterPlatformEmail } = await import('./email-metering')\n await meterPlatformEmail().catch(() => undefined)\n }\n return result\n } catch (error) {\n console.error('[staff-alert-email] send failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n"],"names":["sendEmail","sendStaffAlertEmail","input","process","to","String","env","STAFF_ALERT_EMAIL","trim","includes","sent","reason","renderSystemEmailContent","systemEmailBrand","content","subject","text","result","context","meterPlatformEmail","catch","undefined","error","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,SAAS,QAA8B,2BAA0B;AAE1E;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,eAAeC,oBAAoBC,KAKzC;QACmBC;IAAlB,MAAMC,KAAKC,QAAOF,iCAAAA,QAAQG,GAAG,CAACC,iBAAiB,YAA7BJ,iCAAiC,IAAIK,IAAI;IAC3D,IAAI,CAACJ,GAAGK,QAAQ,CAAC,MAAM,OAAO;QAAEC,MAAM;QAAOC,QAAQ;IAAe;IACpE,IAAI;QACF,yEAAyE;QACzE,+DAA+D;QAC/D,MAAM,EAAEC,wBAAwB,EAAEC,gBAAgB,EAAE,GAAG,MAAM,MAAM,CACjE;QAEF,MAAMC,UAAU,MAAMF,yBACpB,eACA;YAAE,iBAAiBV,MAAMa,OAAO;YAAE,cAAcb,MAAMc,IAAI;QAAC,GAC3DH,iBAAiB,OACjB;YAAEE,SAASb,MAAMa,OAAO;YAAEC,MAAMd,MAAMc,IAAI;QAAC;QAE7C,MAAMC,SAAS,MAAMjB,UAAU;YAAEI;WAAOU;YAASI,SAAShB,MAAMgB,OAAO;;QACvE,IAAID,OAAOP,IAAI,EAAE;YACf,6DAA6D;YAC7D,MAAM,EAAES,kBAAkB,EAAE,GAAG,MAAM,MAAM,CAAC;YAC5C,MAAMA,qBAAqBC,KAAK,CAAC,IAAMC;QACzC;QACA,OAAOJ;IACT,EAAE,OAAOK,OAAO;QACdC,QAAQD,KAAK,CAAC,mCAAmCA;QACjD,OAAO;YAAEZ,MAAM;YAAOC,QAAQ;QAAU;IAC1C;AACF"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/staff-alert-email.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 { sendEmail, type SendEmailResult } from '@aglyn/shared-util-email'\n\n/**\n * Staff alerts by email, from LIBRARY code (AGL-2925).\n *\n * One destination for every subsystem's alarm — the console's\n * `emailStaffAlert` in `apps/console/app/api/_lib/usage-alert-email.ts` sends\n * through here too — resolved by {@link resolveStaffAlertRecipients}\n * (AGL-3375). Nothing here throws, because the mail is a courtesy and the\n * refusal it announces is the control; with no address anywhere the answer\n * is `unconfigured`.\n *\n * Platform email metering is loaded lazily and only after a send, so that\n * importing this module — which every unit test of the meter does — never\n * pulls the Firestore-backed meter, and with it the admin app, into a test\n * that only wanted to count credits. The system-email renderer and the staff\n * roster, which read through the same admin app, are loaded the same way.\n */\n\n/** At most this many staff accounts are mailed when no inbox is configured. */\nconst STAFF_FALLBACK_MAX = 10\n\n/**\n * Where a staff alarm goes (AGL-3375), first answer wins:\n *\n * 1. `STAFF_ALERT_EMAIL`, the operator's one alerts inbox.\n * 2. The operator's published support address,\n * `NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL`, which every self-hosted install is\n * asked for and most set.\n * 3. Every staff account's own address, up to {@link STAFF_FALLBACK_MAX}.\n *\n * Only the first applies on a preview or development deployment.\n *\n * The chain exists because the alarms that matter most are the ones an\n * install nobody tuned would otherwise send to no one: card testing on its\n * checkout, a stolen card on its first subscription, a phishing campaign\n * held on its sending domain. An unset variable used to mean silence.\n *\n * Empty only when none of the three yields an address.\n */\nexport async function resolveStaffAlertRecipients(): Promise<string[]> {\n const configured = String(process.env.STAFF_ALERT_EMAIL ?? '').trim().toLowerCase()\n if (configured.includes('@')) return [configured]\n // A preview or development deployment says what it is; only a production\n // one, or a self-hosted install that sets no such thing, falls back. A\n // preview's alarms reaching the operator's real inbox would be noise.\n const deployment = String(process.env.VERCEL_ENV ?? '').trim()\n if (deployment && deployment !== 'production') return []\n const operator = String(process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL ?? '')\n .trim()\n .toLowerCase()\n if (operator.includes('@')) return [operator]\n try {\n const { findUserByUidAcrossPools, listStaffUidsAcrossPools } = await import(\n './auth-pools'\n )\n const uids = (await listStaffUidsAcrossPools()).slice(0, STAFF_FALLBACK_MAX)\n const addresses = new Set<string>()\n for (const uid of uids) {\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n const address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n if (address.includes('@')) addresses.add(address)\n }\n return [...addresses]\n } catch (error) {\n console.error('[staff-alert-email] staff roster unavailable', error)\n return []\n }\n}\n\n/**\n * Sends one rendered alert to each operator recipient, one message each so\n * no staff member is shown another's address. Metered to the platform: our\n * own alarm is our own cost (AGL-1438). Never throws.\n */\nasync function sendToOperators(\n content: { subject: string; text: string; html?: string },\n context: string,\n): Promise<SendEmailResult> {\n const recipients = await resolveStaffAlertRecipients()\n if (!recipients.length) return { sent: false, reason: 'unconfigured' }\n let delivered: SendEmailResult | null = null\n let failure: SendEmailResult = { sent: false, reason: 'unconfigured' }\n let sent = 0\n for (const to of recipients) {\n const result = await sendEmail({ to, ...content, context })\n if (result.sent) {\n sent += 1\n delivered ??= result\n } else {\n failure = result\n }\n }\n if (sent) {\n const { meterPlatformEmail } = await import('./email-metering')\n await meterPlatformEmail(sent).catch(() => undefined)\n }\n // Delivered to anyone is delivered: one dead staff mailbox does not make\n // the alert a failure for the caller.\n return delivered ?? failure\n}\n\nexport async function sendStaffAlertEmail(input: {\n subject: string\n text: string\n /** Resend tag / log label. */\n context: string\n}): Promise<SendEmailResult> {\n try {\n // In the platform's header and footer, as the `staff-alert` system email\n // (AGL-3367). Loaded lazily for the reason the meter is.\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const content = await renderSystemEmailContent(\n 'staff-alert',\n { 'alert.subject': input.subject, 'alert.body': input.text },\n systemEmailBrand(null),\n { subject: input.subject, text: input.text },\n )\n return await sendToOperators(content, input.context)\n } catch (error) {\n console.error('[staff-alert-email] send failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n\n/**\n * A fraud or risk alert for the operator (AGL-3375), as the `operator-alert`\n * system email: the alert, a button to the item it is about, in the\n * operator's own brand. Sent by `notifyStaff` for every\n * `OPERATOR_ALERT_NOTIFICATION_TYPES` notification, beside the console one.\n * Never throws.\n */\nexport async function sendOperatorAlertEmail(input: {\n title: string\n body?: string | null\n /** Absolute link to the item, when there is one. */\n url?: string | null\n context: string\n}): Promise<SendEmailResult> {\n try {\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const body = String(input.body ?? '').trim()\n const url = String(input.url ?? '').trim()\n const content = await renderSystemEmailContent(\n 'operator-alert',\n { 'alert.title': input.title, 'alert.body': body, 'alert.url': url },\n systemEmailBrand(null),\n { subject: input.title, text: [input.title, body, url].filter(Boolean).join('\\n\\n') },\n )\n return await sendToOperators(content, input.context)\n } catch (error) {\n console.error('[staff-alert-email] operator alert failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n"],"names":["sendEmail","STAFF_FALLBACK_MAX","resolveStaffAlertRecipients","process","configured","String","env","STAFF_ALERT_EMAIL","trim","toLowerCase","includes","deployment","VERCEL_ENV","operator","NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL","findUserByUidAcrossPools","listStaffUidsAcrossPools","uids","slice","addresses","Set","uid","pooled","catch","address","record","email","add","error","console","sendToOperators","content","context","recipients","length","sent","reason","delivered","failure","to","result","meterPlatformEmail","undefined","sendStaffAlertEmail","input","renderSystemEmailContent","systemEmailBrand","subject","text","sendOperatorAlertEmail","body","url","title","filter","Boolean","join"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,SAAS,QAA8B,2BAA0B;AAE1E;;;;;;;;;;;;;;;CAeC,GAED,6EAA6E,GAC7E,MAAMC,qBAAqB;AAE3B;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,eAAeC;QACMC,gCAKAA,yBAEFA;IAPxB,MAAMC,aAAaC,QAAOF,iCAAAA,QAAQG,GAAG,CAACC,iBAAiB,YAA7BJ,iCAAiC,IAAIK,IAAI,GAAGC,WAAW;IACjF,IAAIL,WAAWM,QAAQ,CAAC,MAAM,OAAO;QAACN;KAAW;IACjD,yEAAyE;IACzE,uEAAuE;IACvE,sEAAsE;IACtE,MAAMO,aAAaN,QAAOF,0BAAAA,QAAQG,GAAG,CAACM,UAAU,YAAtBT,0BAA0B,IAAIK,IAAI;IAC5D,IAAIG,cAAcA,eAAe,cAAc,OAAO,EAAE;IACxD,MAAME,WAAWR,QAAOF,kDAAAA,QAAQG,GAAG,CAACQ,kCAAkC,YAA9CX,kDAAkD,IACvEK,IAAI,GACJC,WAAW;IACd,IAAII,SAASH,QAAQ,CAAC,MAAM,OAAO;QAACG;KAAS;IAC7C,IAAI;QACF,MAAM,EAAEE,wBAAwB,EAAEC,wBAAwB,EAAE,GAAG,MAAM,MAAM,CACzE;QAEF,MAAMC,OAAO,AAAC,CAAA,MAAMD,0BAAyB,EAAGE,KAAK,CAAC,GAAGjB;QACzD,MAAMkB,YAAY,IAAIC;QACtB,KAAK,MAAMC,OAAOJ,KAAM;;gBAECK;YADvB,MAAMA,SAAS,MAAMP,yBAAyBM,KAAKE,KAAK,CAAC,IAAM;YAC/D,MAAMC,UAAUnB,eAAOiB,2BAAAA,iBAAAA,OAAQG,MAAM,qBAAdH,eAAgBI,KAAK,mBAAI,IAAIlB,IAAI,GAAGC,WAAW;YACtE,IAAIe,QAAQd,QAAQ,CAAC,MAAMS,UAAUQ,GAAG,CAACH;QAC3C;QACA,OAAO;eAAIL;SAAU;IACvB,EAAE,OAAOS,OAAO;QACdC,QAAQD,KAAK,CAAC,gDAAgDA;QAC9D,OAAO,EAAE;IACX;AACF;AAEA;;;;CAIC,GACD,eAAeE,gBACbC,OAAyD,EACzDC,OAAe;IAEf,MAAMC,aAAa,MAAM/B;IACzB,IAAI,CAAC+B,WAAWC,MAAM,EAAE,OAAO;QAAEC,MAAM;QAAOC,QAAQ;IAAe;IACrE,IAAIC,YAAoC;IACxC,IAAIC,UAA2B;QAAEH,MAAM;QAAOC,QAAQ;IAAe;IACrE,IAAID,OAAO;IACX,KAAK,MAAMI,MAAMN,WAAY;QAC3B,MAAMO,SAAS,MAAMxC,UAAU;YAAEuC;WAAOR;YAASC;;QACjD,IAAIQ,OAAOL,IAAI,EAAE;YACfA,QAAQ;YACRE,oBAAAA,YAAAA,YAAcG;QAChB,OAAO;YACLF,UAAUE;QACZ;IACF;IACA,IAAIL,MAAM;QACR,MAAM,EAAEM,kBAAkB,EAAE,GAAG,MAAM,MAAM,CAAC;QAC5C,MAAMA,mBAAmBN,MAAMZ,KAAK,CAAC,IAAMmB;IAC7C;IACA,yEAAyE;IACzE,sCAAsC;IACtC,OAAOL,oBAAAA,YAAaC;AACtB;AAEA,OAAO,eAAeK,oBAAoBC,KAKzC;IACC,IAAI;QACF,yEAAyE;QACzE,yDAAyD;QACzD,MAAM,EAAEC,wBAAwB,EAAEC,gBAAgB,EAAE,GAAG,MAAM,MAAM,CACjE;QAEF,MAAMf,UAAU,MAAMc,yBACpB,eACA;YAAE,iBAAiBD,MAAMG,OAAO;YAAE,cAAcH,MAAMI,IAAI;QAAC,GAC3DF,iBAAiB,OACjB;YAAEC,SAASH,MAAMG,OAAO;YAAEC,MAAMJ,MAAMI,IAAI;QAAC;QAE7C,OAAO,MAAMlB,gBAAgBC,SAASa,MAAMZ,OAAO;IACrD,EAAE,OAAOJ,OAAO;QACdC,QAAQD,KAAK,CAAC,mCAAmCA;QACjD,OAAO;YAAEO,MAAM;YAAOC,QAAQ;QAAU;IAC1C;AACF;AAEA;;;;;;CAMC,GACD,OAAO,eAAea,uBAAuBL,KAM5C;IACC,IAAI;YAIkBA,aACDA;QAJnB,MAAM,EAAEC,wBAAwB,EAAEC,gBAAgB,EAAE,GAAG,MAAM,MAAM,CACjE;QAEF,MAAMI,OAAO7C,QAAOuC,cAAAA,MAAMM,IAAI,YAAVN,cAAc,IAAIpC,IAAI;QAC1C,MAAM2C,MAAM9C,QAAOuC,aAAAA,MAAMO,GAAG,YAATP,aAAa,IAAIpC,IAAI;QACxC,MAAMuB,UAAU,MAAMc,yBACpB,kBACA;YAAE,eAAeD,MAAMQ,KAAK;YAAE,cAAcF;YAAM,aAAaC;QAAI,GACnEL,iBAAiB,OACjB;YAAEC,SAASH,MAAMQ,KAAK;YAAEJ,MAAM;gBAACJ,MAAMQ,KAAK;gBAAEF;gBAAMC;aAAI,CAACE,MAAM,CAACC,SAASC,IAAI,CAAC;QAAQ;QAEtF,OAAO,MAAMzB,gBAAgBC,SAASa,MAAMZ,OAAO;IACrD,EAAE,OAAOJ,OAAO;QACdC,QAAQD,KAAK,CAAC,6CAA6CA;QAC3D,OAAO;YAAEO,MAAM;YAAOC,QAAQ;QAAU;IAC1C;AACF"}
|