@aglyn/tenant-data-admin 1.0.0-beta.146 → 1.0.0-beta.149
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
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.149",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,11 +25,11 @@
|
|
|
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-http": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.149",
|
|
29
|
+
"@aglyn/shared-util-email": "1.0.0-beta.149",
|
|
30
|
+
"@aglyn/shared-util-fbserver": "1.0.0-beta.149",
|
|
31
|
+
"@aglyn/shared-util-http": "1.0.0-beta.149",
|
|
32
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.149",
|
|
33
33
|
"@msgpack/msgpack": "^3.1.3",
|
|
34
34
|
"@swc/helpers": "0.5.23",
|
|
35
35
|
"sharp": "^0.35.3",
|
|
@@ -16,12 +16,25 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { type AglynNotification } from '@aglyn/aglyn/server';
|
|
18
18
|
export type NotificationPayload = Omit<AglynNotification, '$id' | 'createdAt' | 'readAt'>;
|
|
19
|
+
export interface NotifyUsersOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Addresses the caller already holds, by uid (AGL-3224).
|
|
22
|
+
*
|
|
23
|
+
* Purely an optimization, and one worth taking where it is free: a caller
|
|
24
|
+
* that read the roster — `notifyOrgAdmins` does — has every address in hand
|
|
25
|
+
* already, and without this the email half would look each one up again in
|
|
26
|
+
* the directory. Absent uids fall back to that lookup, so passing a partial
|
|
27
|
+
* map is fine and passing none is correct.
|
|
28
|
+
*/
|
|
29
|
+
emails?: Readonly<Record<string, string | null | undefined>>;
|
|
30
|
+
}
|
|
19
31
|
/**
|
|
20
32
|
* Notification fan-out (AGL-259): batch-writes one doc per recipient at
|
|
21
|
-
* `users/{uid}/notifications
|
|
22
|
-
*
|
|
33
|
+
* `users/{uid}/notifications`, and — for recipients who asked for it
|
|
34
|
+
* (AGL-3224) — sends one email beside it. Never throws: a notification miss,
|
|
35
|
+
* or a send that failed, must not break the mutation that emitted it.
|
|
23
36
|
*/
|
|
24
|
-
export declare function notifyUsers(uids: Iterable<string>, payload: NotificationPayload): Promise<void>;
|
|
37
|
+
export declare function notifyUsers(uids: Iterable<string>, payload: NotificationPayload, options?: NotifyUsersOptions): Promise<void>;
|
|
25
38
|
/**
|
|
26
39
|
* Notifies every staff-claim holder (AGL-850) — the support-desk audience.
|
|
27
40
|
* Staff are not org members, so `notifyOrgAdmins`/`notifyHostManagers` never
|
|
@@ -14,36 +14,144 @@ 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 {
|
|
17
|
+
*/ import { buildRoute, NOTIFICATION_SELF_SENT_EMAIL_TYPES, NOTIFICATION_SETTINGS_FIELD, notificationChannelEnabled, Route } from "@aglyn/aglyn/server";
|
|
18
|
+
import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
|
|
18
19
|
import { FieldValue } from "firebase-admin/firestore";
|
|
19
|
-
import { listStaffUidsAcrossPools } from "./auth-pools.js";
|
|
20
|
+
import { findUserByUidAcrossPools, listStaffUidsAcrossPools } from "./auth-pools.js";
|
|
21
|
+
import { filterSuppressedEmails } from "./email-suppression.js";
|
|
22
|
+
import { meterOrgEmail, meterPlatformEmail } from "./email-metering.js";
|
|
20
23
|
import firebaseAdmin from "./firebase-admin.js";
|
|
21
24
|
import { listOrgMembers } from "./organizations.js";
|
|
22
25
|
const firestore = ()=>firebaseAdmin.app().firestore();
|
|
26
|
+
/** The console's absolute origin for an email link, or `''` when unset. */ function consoleOrigin() {
|
|
27
|
+
var _process_env_NEXT_PUBLIC_CONSOLE_URL;
|
|
28
|
+
return ((_process_env_NEXT_PUBLIC_CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL) != null ? _process_env_NEXT_PUBLIC_CONSOLE_URL : '').trim().replace(/\/+$/, '');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* At most this many directory lookups per fan-out, and at most this many
|
|
32
|
+
* notification emails.
|
|
33
|
+
*
|
|
34
|
+
* Both ceilings only ever bite on a fan-out where many recipients have
|
|
35
|
+
* OPTED IN — the email channel defaults off, so the ordinary notification
|
|
36
|
+
* costs exactly what it cost before this existed. They are here because a
|
|
37
|
+
* fan-out takes up to 400 uids and `notifyUsers` runs inside the mutation
|
|
38
|
+
* that emitted it: a notification must not be able to turn one write into
|
|
39
|
+
* four hundred directory reads and four hundred sends.
|
|
40
|
+
*/ const NOTIFY_EMAIL_MAX_LOOKUPS = 25;
|
|
41
|
+
const NOTIFY_EMAIL_MAX_SENDS = 50;
|
|
42
|
+
/**
|
|
43
|
+
* The email beside the console notification (AGL-3224).
|
|
44
|
+
*
|
|
45
|
+
* One send per recipient, never a shared `to:` list — two people who manage
|
|
46
|
+
* the same site have not agreed to be shown each other's addresses, and the
|
|
47
|
+
* settings link in the footer belongs to one person.
|
|
48
|
+
*
|
|
49
|
+
* `List-Unsubscribe` points at the settings page rather than at a one-click
|
|
50
|
+
* endpoint: this is not marketing mail and RFC 8058's POST form would be
|
|
51
|
+
* claiming a capability that does not exist. What it does do is put the way
|
|
52
|
+
* out in the headers as well as the body, so a client that surfaces it shows
|
|
53
|
+
* the person the page where the switch they want actually is.
|
|
54
|
+
*
|
|
55
|
+
* Never throws, like everything else on this path.
|
|
56
|
+
*/ async function emailNotification(uids, payload, known) {
|
|
57
|
+
const origin = consoleOrigin();
|
|
58
|
+
const settingsUrl = `${origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`;
|
|
59
|
+
let lookups = 0;
|
|
60
|
+
let sent = 0;
|
|
61
|
+
for (const uid of uids){
|
|
62
|
+
var _known_uid, _payload_body;
|
|
63
|
+
if (sent >= NOTIFY_EMAIL_MAX_SENDS) break;
|
|
64
|
+
let address = String((_known_uid = known[uid]) != null ? _known_uid : '').trim().toLowerCase();
|
|
65
|
+
if (!address.includes('@')) {
|
|
66
|
+
var _ref;
|
|
67
|
+
var _pooled_record;
|
|
68
|
+
if (lookups >= NOTIFY_EMAIL_MAX_LOOKUPS) continue;
|
|
69
|
+
lookups += 1;
|
|
70
|
+
const pooled = await findUserByUidAcrossPools(uid).catch(()=>null);
|
|
71
|
+
address = String((_ref = pooled == null ? void 0 : (_pooled_record = pooled.record) == null ? void 0 : _pooled_record.email) != null ? _ref : '').trim().toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
if (!address.includes('@')) continue;
|
|
74
|
+
const recipients = await filterSuppressedEmails([
|
|
75
|
+
address
|
|
76
|
+
]);
|
|
77
|
+
if (!recipients.length) continue;
|
|
78
|
+
const link = payload.link && origin ? `${origin}${payload.link}` : '';
|
|
79
|
+
const body = [
|
|
80
|
+
payload.title,
|
|
81
|
+
(_payload_body = payload.body) != null ? _payload_body : '',
|
|
82
|
+
link,
|
|
83
|
+
origin ? `Change what you are emailed about: ${settingsUrl}` : ''
|
|
84
|
+
].filter(Boolean).join('\n\n');
|
|
85
|
+
const result = await sendEmail(_extends({
|
|
86
|
+
to: recipients,
|
|
87
|
+
subject: payload.title,
|
|
88
|
+
text: body,
|
|
89
|
+
context: 'notification'
|
|
90
|
+
}, origin ? {
|
|
91
|
+
headers: {
|
|
92
|
+
'List-Unsubscribe': `<${settingsUrl}>`
|
|
93
|
+
}
|
|
94
|
+
} : {}));
|
|
95
|
+
if (!result.sent) continue;
|
|
96
|
+
sent += 1;
|
|
97
|
+
// Whose cost it is: a notification about a workspace is that workspace's
|
|
98
|
+
// mail, and a staff alert or an account-level notice is the platform's.
|
|
99
|
+
await (payload.orgId ? meterOrgEmail(payload.orgId) : meterPlatformEmail()).catch(()=>undefined);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
23
102
|
/**
|
|
24
103
|
* Notification fan-out (AGL-259): batch-writes one doc per recipient at
|
|
25
|
-
* `users/{uid}/notifications
|
|
26
|
-
*
|
|
27
|
-
|
|
104
|
+
* `users/{uid}/notifications`, and — for recipients who asked for it
|
|
105
|
+
* (AGL-3224) — sends one email beside it. Never throws: a notification miss,
|
|
106
|
+
* or a send that failed, must not break the mutation that emitted it.
|
|
107
|
+
*/ export async function notifyUsers(uids, payload, options = {}) {
|
|
28
108
|
try {
|
|
29
109
|
const db = firestore();
|
|
30
110
|
const targets = [
|
|
31
111
|
...new Set(uids)
|
|
32
112
|
].filter(Boolean).slice(0, 400);
|
|
33
113
|
if (!targets.length) return;
|
|
34
|
-
// Per-user
|
|
114
|
+
// Per-user preferences (AGL-267, AGL-3223): one getAll over the user
|
|
115
|
+
// docs, which now answers for both channels and for all three scopes —
|
|
116
|
+
// the settings live on this document precisely so that stays one read.
|
|
35
117
|
const userDocs = await db.getAll(...targets.map((uid)=>db.collection('users').doc(uid)));
|
|
118
|
+
const scope = {
|
|
119
|
+
orgId: payload.orgId,
|
|
120
|
+
hostId: payload.hostId
|
|
121
|
+
};
|
|
36
122
|
const batch = db.batch();
|
|
37
123
|
let count = 0;
|
|
124
|
+
const mailTo = [];
|
|
38
125
|
for (const userDoc of userDocs){
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
126
|
+
const settings = userDoc.get(NOTIFICATION_SETTINGS_FIELD);
|
|
127
|
+
const legacy = userDoc.get('notificationPrefs');
|
|
128
|
+
if (notificationChannelEnabled(settings, 'console', payload.type, scope, legacy)) {
|
|
129
|
+
batch.set(db.collection('users').doc(userDoc.id).collection('notifications').doc(), _extends({}, payload, {
|
|
130
|
+
createdAt: FieldValue.serverTimestamp()
|
|
131
|
+
}));
|
|
132
|
+
count += 1;
|
|
133
|
+
}
|
|
134
|
+
if (notificationChannelEnabled(settings, 'email', payload.type, scope, legacy)) {
|
|
135
|
+
mailTo.push(userDoc.id);
|
|
136
|
+
}
|
|
45
137
|
}
|
|
46
138
|
if (count > 0) await batch.commit();
|
|
139
|
+
/*
|
|
140
|
+
* THE EMAIL AFTER THE COMMIT, and not inside its `try`.
|
|
141
|
+
*
|
|
142
|
+
* The console notification is the durable record and the send is a
|
|
143
|
+
* courtesy on top of it, so the order is the one where a failing network
|
|
144
|
+
* call cannot lose the record. The two channels are independent by
|
|
145
|
+
* design — a person may have muted the feed and asked for mail, or the
|
|
146
|
+
* reverse — so neither is gated on the other having happened.
|
|
147
|
+
*
|
|
148
|
+
* Digests are excluded: they compose and send their own mail, under
|
|
149
|
+
* their own switches, and the generic channel would send a second
|
|
150
|
+
* message announcing that the first one had been sent.
|
|
151
|
+
*/ if (mailTo.length && isEmailConfigured() && !NOTIFICATION_SELF_SENT_EMAIL_TYPES.has(payload.type)) {
|
|
152
|
+
var _options_emails;
|
|
153
|
+
await emailNotification(mailTo, payload, (_options_emails = options.emails) != null ? _options_emails : {});
|
|
154
|
+
}
|
|
47
155
|
} catch (error) {
|
|
48
156
|
console.error('notification fan-out failed', error);
|
|
49
157
|
}
|
|
@@ -87,10 +195,18 @@ async function listStaffUids() {
|
|
|
87
195
|
/** Notifies the org's owner + admins (billing, membership, org events). */ export async function notifyOrgAdmins(orgId, payload) {
|
|
88
196
|
try {
|
|
89
197
|
const members = await listOrgMembers(orgId);
|
|
90
|
-
const admins = members.filter((member)=>member.role === 'owner' || member.role === 'admin')
|
|
91
|
-
|
|
198
|
+
const admins = members.filter((member)=>member.role === 'owner' || member.role === 'admin');
|
|
199
|
+
// The roster rows carry addresses, so the email channel (AGL-3224) needs
|
|
200
|
+
// no directory lookup for anyone reached this way.
|
|
201
|
+
const emails = {};
|
|
202
|
+
for (const member of admins){
|
|
203
|
+
if (member.email) emails[member.$id] = member.email;
|
|
204
|
+
}
|
|
205
|
+
await notifyUsers(admins.map((member)=>member.$id), _extends({}, payload, {
|
|
92
206
|
orgId
|
|
93
|
-
})
|
|
207
|
+
}), {
|
|
208
|
+
emails
|
|
209
|
+
});
|
|
94
210
|
} catch (error) {
|
|
95
211
|
console.error('org admin notification failed', error);
|
|
96
212
|
}
|
|
@@ -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 { notificationMuted, type AglynNotification } from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { listStaffUidsAcrossPools } from './auth-pools'\nimport firebaseAdmin from './firebase-admin'\nimport { listOrgMembers } from './organizations'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\nexport type NotificationPayload = Omit<\n AglynNotification,\n '$id' | 'createdAt' | 'readAt'\n>\n\n/**\n * Notification fan-out (AGL-259): batch-writes one doc per recipient at\n * `users/{uid}/notifications`. Never throws — a notification miss must\n * not break the mutation that emitted it.\n */\nexport async function notifyUsers(\n uids: Iterable<string>,\n payload: NotificationPayload,\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 category mutes (AGL-267): one getAll over the user docs.\n const userDocs = await db.getAll(\n ...targets.map((uid) => db.collection('users').doc(uid)),\n )\n const batch = db.batch()\n let count = 0\n for (const userDoc of userDocs) {\n const prefs = userDoc.get('notificationPrefs') as\n | Record<string, boolean>\n | undefined\n if (notificationMuted(prefs, payload.type)) continue\n batch.set(\n db\n .collection('users')\n .doc(userDoc.id)\n .collection('notifications')\n .doc(),\n { ...payload, createdAt: FieldValue.serverTimestamp() },\n )\n count += 1\n }\n if (count > 0) await batch.commit()\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)\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\n .filter((member) => member.role === 'owner' || member.role === 'admin')\n .map((member) => member.$id)\n await notifyUsers(admins, { ...payload, orgId })\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":["notificationMuted","FieldValue","listStaffUidsAcrossPools","firebaseAdmin","listOrgMembers","firestore","app","notifyUsers","uids","payload","db","targets","Set","filter","Boolean","slice","length","userDocs","getAll","map","uid","collection","doc","batch","count","userDoc","prefs","get","type","set","id","createdAt","serverTimestamp","commit","error","console","staffUidCache","STAFF_CACHE_MS","listStaffUids","now","Date","at","notifyStaff","notifyOrgAdmins","orgId","members","admins","member","role","$id","notifyHostManagers","hostId","host","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 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'\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\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 * 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): Promise<void> {\n const origin = consoleOrigin()\n const settingsUrl = `${origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\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 link = payload.link && origin ? `${origin}${payload.link}` : ''\n const body = [\n payload.title,\n payload.body ?? '',\n link,\n origin ? `Change what you are emailed about: ${settingsUrl}` : '',\n ]\n .filter(Boolean)\n .join('\\n\\n')\n const result = await sendEmail({\n to: recipients,\n subject: payload.title,\n text: body,\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 about a workspace is that workspace's\n // mail, and a staff alert or an account-level notice is the platform's.\n await (payload.orgId ? meterOrgEmail(payload.orgId) : meterPlatformEmail())\n .catch(() => undefined)\n }\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 { ...payload, 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(mailTo, payload, options.emails ?? {})\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)\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","notificationChannelEnabled","Route","isEmailConfigured","sendEmail","FieldValue","findUserByUidAcrossPools","listStaffUidsAcrossPools","filterSuppressedEmails","meterOrgEmail","meterPlatformEmail","firebaseAdmin","listOrgMembers","firestore","app","consoleOrigin","process","env","NEXT_PUBLIC_CONSOLE_URL","trim","replace","NOTIFY_EMAIL_MAX_LOOKUPS","NOTIFY_EMAIL_MAX_SENDS","emailNotification","uids","payload","known","origin","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","lookups","sent","uid","address","String","toLowerCase","includes","pooled","catch","record","email","recipients","length","link","body","title","filter","Boolean","join","result","to","subject","text","context","headers","orgId","undefined","notifyUsers","options","db","targets","Set","slice","userDocs","getAll","map","collection","doc","scope","hostId","batch","count","mailTo","userDoc","settings","get","legacy","type","set","id","createdAt","serverTimestamp","push","commit","has","emails","error","console","staffUidCache","STAFF_CACHE_MS","listStaffUids","now","Date","at","notifyStaff","notifyOrgAdmins","members","admins","member","role","$id","notifyHostManagers","host","memberRoles","managers","Object","entries"],"mappings":"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"}
|