@aglyn/tenant-data-admin 1.0.0-beta.262 → 1.0.0-beta.263
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/keep-alive.d.ts +18 -0
- package/src/lib/server/keep-alive.js +59 -0
- package/src/lib/server/keep-alive.js.map +1 -0
- package/src/lib/server/notifications.d.ts +16 -5
- package/src/lib/server/notifications.js +26 -5
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/push-apns.js +3 -1
- package/src/lib/server/push-apns.js.map +1 -1
- package/src/lib/server/push-delivery.d.ts +9 -0
- package/src/lib/server/push-delivery.js +10 -0
- package/src/lib/server/push-delivery.js.map +1 -1
- package/src/lib/server/render-system-email.d.ts +7 -1
- package/src/lib/server/render-system-email.js +1 -1
- package/src/lib/server/render-system-email.js.map +1 -1
- package/src/lib/server/sso-domain-policy.d.ts +43 -7
- package/src/lib/server/sso-domain-policy.js +61 -9
- package/src/lib/server/sso-domain-policy.js.map +1 -1
- package/src/lib/server/weekly-summary.d.ts +41 -25
- package/src/lib/server/weekly-summary.js +206 -117
- package/src/lib/server/weekly-summary.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.263",
|
|
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.263",
|
|
29
|
+
"@aglyn/shared-util-email": "1.0.0-beta.263",
|
|
30
|
+
"@aglyn/shared-util-fbserver": "1.0.0-beta.263",
|
|
31
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.263",
|
|
32
|
+
"@aglyn/shared-util-http": "1.0.0-beta.263",
|
|
33
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.263",
|
|
34
34
|
"@msgpack/msgpack": "^3.1.3",
|
|
35
35
|
"@swc/helpers": "0.5.23",
|
|
36
36
|
"sharp": "^0.35.5",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/** Returns `work` unchanged, after asking the platform to wait for it. */
|
|
18
|
+
export declare function keepAlive<T>(work: Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /**
|
|
17
|
+
* Keeps fire-and-forget work alive after the response is sent.
|
|
18
|
+
*
|
|
19
|
+
* A serverless function may be frozen the moment its handler returns, and a
|
|
20
|
+
* notification is usually started with `void notify…()` beside the mutation
|
|
21
|
+
* it reports. The feed write lands first, so the console shows it, while the
|
|
22
|
+
* push and the email after it are cut off part way: the native apps then miss
|
|
23
|
+
* notifications the browser shows. Handing the promise to the platform's
|
|
24
|
+
* `waitUntil` keeps the invocation alive until it settles.
|
|
25
|
+
*
|
|
26
|
+
* Next reads `waitUntil` from the `@next/request-context` slot, and Vercel's
|
|
27
|
+
* own helpers read `@vercel/request-context`; both are asked. Outside a
|
|
28
|
+
* request (a script, a test, a cron worker that awaits anyway) there is no
|
|
29
|
+
* context and the work simply runs as it would have.
|
|
30
|
+
*/ const CONTEXT_SLOTS = [
|
|
31
|
+
Symbol.for('@next/request-context'),
|
|
32
|
+
Symbol.for('@vercel/request-context')
|
|
33
|
+
];
|
|
34
|
+
function platformWaitUntil() {
|
|
35
|
+
const scope = globalThis;
|
|
36
|
+
for (const slot of CONTEXT_SLOTS){
|
|
37
|
+
try {
|
|
38
|
+
var _scope_slot_get, _scope_slot_get1, _scope_slot;
|
|
39
|
+
const waitUntil = (_scope_slot = scope[slot]) == null ? void 0 : (_scope_slot_get1 = _scope_slot.get) == null ? void 0 : (_scope_slot_get = _scope_slot_get1.call(_scope_slot)) == null ? void 0 : _scope_slot_get.waitUntil;
|
|
40
|
+
if (typeof waitUntil === 'function') return waitUntil;
|
|
41
|
+
} catch (unused) {
|
|
42
|
+
// A slot that cannot answer is treated as absent.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
/** Returns `work` unchanged, after asking the platform to wait for it. */ export function keepAlive(work) {
|
|
48
|
+
const waitUntil = platformWaitUntil();
|
|
49
|
+
if (waitUntil) {
|
|
50
|
+
try {
|
|
51
|
+
waitUntil(work.catch(()=>undefined));
|
|
52
|
+
} catch (unused) {
|
|
53
|
+
// The platform refused the promise: the work still runs.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return work;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
//# sourceMappingURL=keep-alive.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/keep-alive.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 * Keeps fire-and-forget work alive after the response is sent.\n *\n * A serverless function may be frozen the moment its handler returns, and a\n * notification is usually started with `void notify…()` beside the mutation\n * it reports. The feed write lands first, so the console shows it, while the\n * push and the email after it are cut off part way: the native apps then miss\n * notifications the browser shows. Handing the promise to the platform's\n * `waitUntil` keeps the invocation alive until it settles.\n *\n * Next reads `waitUntil` from the `@next/request-context` slot, and Vercel's\n * own helpers read `@vercel/request-context`; both are asked. Outside a\n * request (a script, a test, a cron worker that awaits anyway) there is no\n * context and the work simply runs as it would have.\n */\n\ntype WaitUntil = (promise: Promise<unknown>) => void\n\ninterface RequestContextSlot {\n get?: () => { waitUntil?: WaitUntil } | undefined\n}\n\nconst CONTEXT_SLOTS = [Symbol.for('@next/request-context'), Symbol.for('@vercel/request-context')]\n\nfunction platformWaitUntil(): WaitUntil | undefined {\n const scope = globalThis as unknown as Record<symbol, RequestContextSlot | undefined>\n for (const slot of CONTEXT_SLOTS) {\n try {\n const waitUntil = scope[slot]?.get?.()?.waitUntil\n if (typeof waitUntil === 'function') return waitUntil\n } catch {\n // A slot that cannot answer is treated as absent.\n }\n }\n return undefined\n}\n\n/** Returns `work` unchanged, after asking the platform to wait for it. */\nexport function keepAlive<T>(work: Promise<T>): Promise<T> {\n const waitUntil = platformWaitUntil()\n if (waitUntil) {\n try {\n waitUntil(work.catch(() => undefined))\n } catch {\n // The platform refused the promise: the work still runs.\n }\n }\n return work\n}\n"],"names":["CONTEXT_SLOTS","Symbol","for","platformWaitUntil","scope","globalThis","slot","waitUntil","get","undefined","keepAlive","work","catch"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;CAcC,GAQD,MAAMA,gBAAgB;IAACC,OAAOC,GAAG,CAAC;IAA0BD,OAAOC,GAAG,CAAC;CAA2B;AAElG,SAASC;IACP,MAAMC,QAAQC;IACd,KAAK,MAAMC,QAAQN,cAAe;QAChC,IAAI;gBACgBI,iBAAAA,kBAAAA;YAAlB,MAAMG,aAAYH,cAAAA,KAAK,CAACE,KAAK,sBAAXF,mBAAAA,YAAaI,GAAG,sBAAhBJ,kBAAAA,sBAAAA,iCAAAA,gBAAsBG,SAAS;YACjD,IAAI,OAAOA,cAAc,YAAY,OAAOA;QAC9C,EAAE,eAAM;QACN,kDAAkD;QACpD;IACF;IACA,OAAOE;AACT;AAEA,wEAAwE,GACxE,OAAO,SAASC,UAAaC,IAAgB;IAC3C,MAAMJ,YAAYJ;IAClB,IAAII,WAAW;QACb,IAAI;YACFA,UAAUI,KAAKC,KAAK,CAAC,IAAMH;QAC7B,EAAE,eAAM;QACN,yDAAyD;QAC3D;IACF;IACA,OAAOE;AACT"}
|
|
@@ -77,7 +77,7 @@ export declare function formatNotificationEmailDetails(details: readonly Notific
|
|
|
77
77
|
* (AGL-3224) — sends one email beside it. Never throws: a notification miss,
|
|
78
78
|
* or a send that failed, must not break the mutation that emitted it.
|
|
79
79
|
*/
|
|
80
|
-
|
|
80
|
+
declare function notifyUsersNow(uids: Iterable<string>, payload: NotificationPayload, options?: NotifyUsersOptions): Promise<void>;
|
|
81
81
|
/**
|
|
82
82
|
* Notifies every staff-claim holder (AGL-850) — the support-desk audience.
|
|
83
83
|
* Staff are not org members, so `notifyOrgAdmins`/`notifyHostManagers` never
|
|
@@ -90,13 +90,13 @@ export declare function notifyUsers(uids: Iterable<string>, payload: Notificatio
|
|
|
90
90
|
* instead: the same console notification, plus staff's switches, the
|
|
91
91
|
* operator email or digest, and the webhook. See `raiseOperatorAlert`.
|
|
92
92
|
*/
|
|
93
|
-
|
|
93
|
+
declare function notifyStaffNow(payload: NotificationPayload): Promise<void>;
|
|
94
94
|
/**
|
|
95
95
|
* The staff console notification alone, with the per-person email channel
|
|
96
96
|
* it carries: what `notifyStaff` did before the registry, and what
|
|
97
97
|
* `raiseOperatorAlert` writes as its first step. Never throws.
|
|
98
98
|
*/
|
|
99
|
-
|
|
99
|
+
declare function notifyStaffConsoleNow(payload: NotificationPayload, options?: {
|
|
100
100
|
skipEmail?: boolean;
|
|
101
101
|
}): Promise<void>;
|
|
102
102
|
/**
|
|
@@ -106,7 +106,7 @@ export declare function notifyStaffConsole(payload: NotificationPayload, options
|
|
|
106
106
|
* itself — the usage alerts do (AGL-3431) — so an admin who switched the
|
|
107
107
|
* email channel on is not sent it twice.
|
|
108
108
|
*/
|
|
109
|
-
|
|
109
|
+
declare function notifyOrgAdminsNow(orgId: string, payload: NotificationPayload, options?: Pick<NotifyUsersOptions, 'skipEmail'>): Promise<void>;
|
|
110
110
|
/**
|
|
111
111
|
* The name a site's managers know it by: the name they gave it, then its
|
|
112
112
|
* address, then — for a doc with neither — its id.
|
|
@@ -141,7 +141,7 @@ export declare function withSiteName(text: string, site: string): string;
|
|
|
141
141
|
* read. Spread conditionally: the field is optional on the host model and
|
|
142
142
|
* Firestore rejects `undefined`.
|
|
143
143
|
*/
|
|
144
|
-
|
|
144
|
+
declare function notifyHostManagersNow(hostId: string, payload: NotificationPayload, options?: {
|
|
145
145
|
/**
|
|
146
146
|
* Mail the workspace owner nothing through the email channel, because
|
|
147
147
|
* the caller sends them their own email about the same event.
|
|
@@ -150,3 +150,14 @@ export declare function notifyHostManagers(hostId: string, payload: Notification
|
|
|
150
150
|
/** What the email alone carries; see {@link NotifyUsersOptions.emailDetails}. */
|
|
151
151
|
emailDetails?: readonly NotificationEmailDetail[];
|
|
152
152
|
}): Promise<void>;
|
|
153
|
+
/** {@link notifyUsersNow}, kept alive past the response. */
|
|
154
|
+
export declare function notifyUsers(...args: Parameters<typeof notifyUsersNow>): Promise<void>;
|
|
155
|
+
/** {@link notifyStaffNow}, kept alive past the response. */
|
|
156
|
+
export declare function notifyStaff(...args: Parameters<typeof notifyStaffNow>): Promise<void>;
|
|
157
|
+
/** {@link notifyStaffConsoleNow}, kept alive past the response. */
|
|
158
|
+
export declare function notifyStaffConsole(...args: Parameters<typeof notifyStaffConsoleNow>): Promise<void>;
|
|
159
|
+
/** {@link notifyOrgAdminsNow}, kept alive past the response. */
|
|
160
|
+
export declare function notifyOrgAdmins(...args: Parameters<typeof notifyOrgAdminsNow>): Promise<void>;
|
|
161
|
+
/** {@link notifyHostManagersNow}, kept alive past the response. */
|
|
162
|
+
export declare function notifyHostManagers(...args: Parameters<typeof notifyHostManagersNow>): Promise<void>;
|
|
163
|
+
export {};
|
|
@@ -25,6 +25,7 @@ import { findUserByUidAcrossPools, listStaffUidsAcrossPools } from "./auth-pools
|
|
|
25
25
|
import { filterSuppressedEmails } from "./email-suppression.js";
|
|
26
26
|
import { meterOrgEmail, meterPlatformEmail } from "./email-metering.js";
|
|
27
27
|
import firebaseAdmin from "./firebase-admin.js";
|
|
28
|
+
import { keepAlive } from "./keep-alive.js";
|
|
28
29
|
import { mobilePushEnabled, mobilePushSenders } from "./mobile-push-switch.js";
|
|
29
30
|
import { listOrgMembers } from "./organizations.js";
|
|
30
31
|
import { loadSystemEmail, renderLoadedSystemEmail, systemEmailBrand, systemEmailContent } from "./render-system-email.js";
|
|
@@ -199,7 +200,7 @@ const NOTIFY_EMAIL_MAX_SENDS = 50;
|
|
|
199
200
|
* `users/{uid}/notifications`, and — for recipients who asked for it
|
|
200
201
|
* (AGL-3224) — sends one email beside it. Never throws: a notification miss,
|
|
201
202
|
* or a send that failed, must not break the mutation that emitted it.
|
|
202
|
-
*/
|
|
203
|
+
*/ async function notifyUsersNow(uids, payload, options = {}) {
|
|
203
204
|
try {
|
|
204
205
|
const db = firestore();
|
|
205
206
|
const targets = [
|
|
@@ -315,7 +316,7 @@ async function listStaffUids() {
|
|
|
315
316
|
* alert, a dispute nobody owns, a support ticket — is raised as that alert
|
|
316
317
|
* instead: the same console notification, plus staff's switches, the
|
|
317
318
|
* operator email or digest, and the webhook. See `raiseOperatorAlert`.
|
|
318
|
-
*/
|
|
319
|
+
*/ async function notifyStaffNow(payload) {
|
|
319
320
|
try {
|
|
320
321
|
const alert = operatorAlertForNotificationType(payload.type);
|
|
321
322
|
if (alert) {
|
|
@@ -344,7 +345,7 @@ async function listStaffUids() {
|
|
|
344
345
|
* The staff console notification alone, with the per-person email channel
|
|
345
346
|
* it carries: what `notifyStaff` did before the registry, and what
|
|
346
347
|
* `raiseOperatorAlert` writes as its first step. Never throws.
|
|
347
|
-
*/
|
|
348
|
+
*/ async function notifyStaffConsoleNow(payload, options = {}) {
|
|
348
349
|
try {
|
|
349
350
|
await notifyUsers(await listStaffUids(), payload, _extends({
|
|
350
351
|
audience: 'staff'
|
|
@@ -361,7 +362,7 @@ async function listStaffUids() {
|
|
|
361
362
|
* `skipEmail` is for a caller that emails the same notice to the same people
|
|
362
363
|
* itself — the usage alerts do (AGL-3431) — so an admin who switched the
|
|
363
364
|
* email channel on is not sent it twice.
|
|
364
|
-
*/
|
|
365
|
+
*/ async function notifyOrgAdminsNow(orgId, payload, options = {}) {
|
|
365
366
|
try {
|
|
366
367
|
const members = await listOrgMembers(orgId);
|
|
367
368
|
const admins = members.filter((member)=>member.role === 'owner' || member.role === 'admin');
|
|
@@ -423,7 +424,7 @@ async function listStaffUids() {
|
|
|
423
424
|
* The host doc already carries `orgId` (AGL-233), so this costs no extra
|
|
424
425
|
* read. Spread conditionally: the field is optional on the host model and
|
|
425
426
|
* Firestore rejects `undefined`.
|
|
426
|
-
*/
|
|
427
|
+
*/ async function notifyHostManagersNow(hostId, payload, options = {}) {
|
|
427
428
|
try {
|
|
428
429
|
var _host_get, _payload_orgId;
|
|
429
430
|
const host = await firestore().collection('hosts').doc(hostId).get();
|
|
@@ -451,5 +452,25 @@ async function listStaffUids() {
|
|
|
451
452
|
console.error('host manager notification failed', error);
|
|
452
453
|
}
|
|
453
454
|
}
|
|
455
|
+
/*
|
|
456
|
+
* The fan-out's public doors. Most callers start a notification with
|
|
457
|
+
* `void notify…()` beside the mutation it reports, so each door hands its
|
|
458
|
+
* work to the platform's `waitUntil` (`keep-alive.ts`): the push and the email
|
|
459
|
+
* after the feed write are no longer cut off when the response goes out.
|
|
460
|
+
*/ /** {@link notifyUsersNow}, kept alive past the response. */ export function notifyUsers(...args) {
|
|
461
|
+
return keepAlive(notifyUsersNow(...args));
|
|
462
|
+
}
|
|
463
|
+
/** {@link notifyStaffNow}, kept alive past the response. */ export function notifyStaff(...args) {
|
|
464
|
+
return keepAlive(notifyStaffNow(...args));
|
|
465
|
+
}
|
|
466
|
+
/** {@link notifyStaffConsoleNow}, kept alive past the response. */ export function notifyStaffConsole(...args) {
|
|
467
|
+
return keepAlive(notifyStaffConsoleNow(...args));
|
|
468
|
+
}
|
|
469
|
+
/** {@link notifyOrgAdminsNow}, kept alive past the response. */ export function notifyOrgAdmins(...args) {
|
|
470
|
+
return keepAlive(notifyOrgAdminsNow(...args));
|
|
471
|
+
}
|
|
472
|
+
/** {@link notifyHostManagersNow}, kept alive past the response. */ export function notifyHostManagers(...args) {
|
|
473
|
+
return keepAlive(notifyHostManagersNow(...args));
|
|
474
|
+
}
|
|
454
475
|
|
|
455
476
|
//# sourceMappingURL=notifications.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/notifications.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n buildRoute,\n NOTIFICATION_SELF_SENT_EMAIL_TYPES,\n NOTIFICATION_SETTINGS_FIELD,\n normalizeNotificationLink,\n notificationChannelEnabled,\n type AglynNotification,\n Route,\n type NotificationSettings,\n} from '@aglyn/aglyn/server'\nimport { operatorAlertForNotificationType } from '@aglyn/aglyn/plugin-manager/operator-alerts'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { notificationPushEnabled } from '@aglyn/aglyn/app-utils/notification-push'\nimport { platformConsoleOrigin } from '@aglyn/aglyn/app-utils/platform-brand'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { withoutMailWithheldAccounts } from './account-mail'\nimport { findUserByUidAcrossPools, listStaffUidsAcrossPools } from './auth-pools'\nimport { filterSuppressedEmails } from './email-suppression'\nimport { meterOrgEmail, meterPlatformEmail } from './email-metering'\nimport firebaseAdmin from './firebase-admin'\nimport { mobilePushEnabled, mobilePushSenders } from './mobile-push-switch'\nimport { listOrgMembers } from './organizations'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type SystemEmailBrand,\n} from './render-system-email'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\nexport type NotificationPayload = Omit<\n AglynNotification,\n '$id' | 'createdAt' | 'readAt'\n>\n\nexport interface NotifyUsersOptions {\n /**\n * Addresses the caller already holds, by uid (AGL-3224).\n *\n * Purely an optimization, and one worth taking where it is free: a caller\n * that read the roster — `notifyOrgAdmins` does — has every address in hand\n * already, and without this the email half would look each one up again in\n * the directory. Absent uids fall back to that lookup, so passing a partial\n * map is fine and passing none is correct.\n */\n emails?: Readonly<Record<string, string | null | undefined>>\n /**\n * Who the fan-out is for (AGL-3367). `staff` is the platform talking to its\n * own people, so the email wears the platform's brand and is the platform's\n * cost even when the notification is ABOUT an org, which a canceled\n * subscription or a new ticket always is. Anything else speaks in the org's\n * brand when the notification names one.\n */\n audience?: 'staff'\n /**\n * Write the console notification only, and mail nobody through the\n * per-person email channel (AGL-3375): the caller has emailed the same\n * alert to the operator's inbox, and a staff member who also switched\n * email on would otherwise get it twice.\n */\n skipEmail?: boolean\n /**\n * People not to mail through the email channel for this one notification,\n * because the caller emails them the same event itself — the seller's\n * designed sale email beside a Buy Now order's notification. Everyone else\n * the fan-out reaches is mailed as their preferences say.\n */\n skipEmailFor?: readonly string[]\n /**\n * Particulars the email carries and the notification does not: a form\n * submission's answers, so the person reading the email has the inquiry\n * without opening the console.\n *\n * Email only, never written to the notification document or pushed. The\n * document is copied once per recipient and outlives the record it points\n * at, and a push lands on a lock screen; the record itself stays the one\n * place the visitor's answers are kept.\n */\n emailDetails?: readonly NotificationEmailDetail[]\n}\n\n/** One labeled line of a notification email's details. */\nexport interface NotificationEmailDetail {\n label: string\n value: string\n}\n\n/**\n * At most this many characters of details per email. A submission is capped\n * at 2,000 characters a field, so a long form would otherwise be a long email.\n */\nconst NOTIFY_EMAIL_MAX_DETAILS_CHARS = 10_000\n\n/**\n * The details as the email prints them: `Label: value`, one per line, a\n * value's own line breaks kept and blank values left out.\n */\nexport function formatNotificationEmailDetails(\n details: readonly NotificationEmailDetail[] | undefined,\n): string {\n const text = (details ?? [])\n .map(({ label, value }) => ({ label: label.trim(), value: value.trim() }))\n .filter(({ value }) => value)\n .map(({ label, value }) => (label ? `${label}: ${value}` : value))\n .join('\\n')\n return text.length > NOTIFY_EMAIL_MAX_DETAILS_CHARS\n ? `${text.slice(0, NOTIFY_EMAIL_MAX_DETAILS_CHARS).trimEnd()}…`\n : text\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 bite only on a fan-out where many recipients are emailed —\n * the email channel defaults off outside the types in\n * `NOTIFICATION_TYPE_CHANNEL_DEFAULTS`, and every fan-out goes to one\n * workspace's or one site's people, or to staff. 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 emailDetails: NotifyUsersOptions['emailDetails'],\n): Promise<void> {\n /*\n * `platformConsoleOrigin`, never `NEXT_PUBLIC_CONSOLE_URL` read bare: this\n * runs in the site app as well as the console, and the site app's\n * deployment does not set that variable. Read bare, a notification a site\n * sends (a form submission, an order) would carry a button that opens\n * nothing and a settings line with no address.\n */\n const origin = platformConsoleOrigin()\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 details = formatNotificationEmailDetails(emailDetails)\n const loaded = await loadSystemEmail('notification').catch(() => null)\n const fallbackText = [\n payload.title,\n payload.body ?? '',\n details,\n url,\n `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.details': details,\n 'notification.url': url,\n 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 headers: { 'List-Unsubscribe': `<${settingsUrl}>` },\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.\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 return `${origin}${link?.startsWith('/') ? link : buildRoute(Route.MANAGE_NOTIFICATIONS)}`\n}\n\n/**\n * Notification fan-out (AGL-259): batch-writes one doc per recipient at\n * `users/{uid}/notifications`, and — for recipients who asked for it\n * (AGL-3224) — sends one email beside it. Never throws: a notification miss,\n * or a send that failed, must not break the mutation that emitted it.\n */\nexport async function notifyUsers(\n uids: Iterable<string>,\n payload: NotificationPayload,\n options: NotifyUsersOptions = {},\n): Promise<void> {\n try {\n const db = firestore()\n const targets = [...new Set(uids)].filter(Boolean).slice(0, 400)\n if (!targets.length) return\n // Per-user preferences (AGL-267, AGL-3223): one getAll over the user\n // docs, which now answers for both channels and for all three scopes —\n // the settings live on this document precisely so that stays one read.\n const userDocs = await db.getAll(\n ...targets.map((uid) => db.collection('users').doc(uid)),\n )\n const scope = { orgId: payload.orgId, hostId: payload.hostId }\n const batch = db.batch()\n let count = 0\n const mailTo: string[] = []\n const pushTo: string[] = []\n for (const userDoc of userDocs) {\n const settings = userDoc.get(NOTIFICATION_SETTINGS_FIELD) as\n | NotificationSettings\n | undefined\n const legacy = userDoc.get('notificationPrefs') as\n | Record<string, boolean>\n | undefined\n if (\n notificationChannelEnabled(\n settings,\n 'console',\n payload.type,\n scope,\n legacy,\n )\n ) {\n batch.set(\n db\n .collection('users')\n .doc(userDoc.id)\n .collection('notifications')\n .doc(),\n // `read: false` on create, so the feed can ask for unread and read\n // alike by equality (AGL-3321; see `AglynNotification.read`).\n { ...payload, read: false, createdAt: FieldValue.serverTimestamp() },\n )\n count += 1\n }\n // The mobile push channel (AGL-3620); unanswered, it follows the feed.\n if (notificationPushEnabled(settings, payload.type, scope, legacy)) {\n pushTo.push(userDoc.id)\n }\n if (\n notificationChannelEnabled(settings, 'email', payload.type, scope, legacy) &&\n !options.skipEmailFor?.includes(userDoc.id)\n ) {\n mailTo.push(userDoc.id)\n }\n }\n if (count > 0) await batch.commit()\n /*\n * The push after the commit for the same reason as the email below:\n * every registered transport is handed the recipients whose preferences\n * say push, unless the deployment has pulled the kill switch. A failing\n * transport is logged and never stops the email or another transport.\n */\n if (pushTo.length && mobilePushEnabled()) {\n for (const send of mobilePushSenders()) {\n try {\n await send(pushTo, payload, { db })\n } catch (error) {\n console.error('push delivery failed', error)\n }\n }\n }\n /*\n * THE EMAIL AFTER THE COMMIT, and not inside its `try`.\n *\n * The console notification is the durable record and the send is a\n * courtesy on top of it, so the order is the one where a failing network\n * call cannot lose the record. The two channels are independent by\n * design — a person may have muted the feed and asked for mail, or the\n * reverse — so neither is gated on the other having happened.\n *\n * Digests are excluded: they compose and send their own mail, under\n * their own switches, and the generic channel would send a second\n * message announcing that the first one had been sent.\n *\n * A locked or disabled account is not mailed (AGL-3418); its feed entry\n * above still records the event.\n */\n if (\n mailTo.length &&\n !options.skipEmail &&\n isEmailConfigured() &&\n !NOTIFICATION_SELF_SENT_EMAIL_TYPES.has(payload.type)\n ) {\n const reachable = await withoutMailWithheldAccounts(mailTo)\n if (!reachable.length) return\n await emailNotification(\n reachable,\n payload,\n options.emails ?? {},\n options.audience,\n options.emailDetails,\n )\n }\n } catch (error) {\n console.error('notification fan-out failed', error)\n }\n}\n\n// Staff are identified by a Firebase Auth custom claim (`staff`), which is not\n// a Firestore query — so the roster comes from paginating the auth users. A\n// burst of ticket activity shouldn't rescan every time, so the (small) result\n// is cached briefly. Fails soft to an empty list.\nlet staffUidCache: { uids: string[]; at: number } | null = null\nconst STAFF_CACHE_MS = 60_000\n\nasync function listStaffUids(): Promise<string[]> {\n const now = Date.now()\n if (staffUidCache && now - staffUidCache.at < STAFF_CACHE_MS) {\n return staffUidCache.uids\n }\n // Across ALL auth pools (AGL-1122). This scanned only the project pool, so\n // a staff member who signs in through enterprise SSO — whose account lives\n // in a GCIP tenant pool — was never in the fan-out. Nothing errored; the\n // notification simply never arrived, which is the worst shape a miss can\n // take on an alerting path.\n const uids = await listStaffUidsAcrossPools()\n staffUidCache = { uids, at: now }\n return uids\n}\n\n/**\n * Notifies every staff-claim holder (AGL-850) — the support-desk audience.\n * Staff are not org members, so `notifyOrgAdmins`/`notifyHostManagers` never\n * reach them; this enumerates the `staff` custom claim instead. The docs land\n * at `users/{uid}/notifications`, which the console notifications menu already\n * renders, so no separate staff inbox is needed. Never throws.\n *\n * A type the operator alert registry names (AGL-3377) — a fraud or risk\n * alert, a dispute nobody owns, a support ticket — is raised as that alert\n * instead: the same console notification, plus staff's switches, the\n * operator email or digest, and the webhook. See `raiseOperatorAlert`.\n */\nexport async function notifyStaff(payload: NotificationPayload): Promise<void> {\n try {\n const alert = operatorAlertForNotificationType(payload.type)\n if (alert) {\n const { raiseOperatorAlert } = await import('./operator-alerts')\n await raiseOperatorAlert(alert, {\n subject: payload.title,\n ...(payload.body ? { body: payload.body } : {}),\n ...(payload.level ? { level: payload.level } : {}),\n ...(payload.link ? { url: payload.link } : {}),\n ...(payload.orgId ? { orgId: payload.orgId } : {}),\n ...(payload.hostId ? { hostId: payload.hostId } : {}),\n })\n return\n }\n await notifyStaffConsole(payload)\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/**\n * The staff console notification alone, with the per-person email channel\n * it carries: what `notifyStaff` did before the registry, and what\n * `raiseOperatorAlert` writes as its first step. Never throws.\n */\nexport async function notifyStaffConsole(\n payload: NotificationPayload,\n options: { skipEmail?: boolean } = {},\n): Promise<void> {\n try {\n await notifyUsers(await listStaffUids(), payload, {\n audience: 'staff',\n ...(options.skipEmail ? { skipEmail: true } : {}),\n })\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/**\n * Notifies the org's owner + admins (billing, membership, org events).\n *\n * `skipEmail` is for a caller that emails the same notice to the same people\n * itself — the usage alerts do (AGL-3431) — so an admin who switched the\n * email channel on is not sent it twice.\n */\nexport async function notifyOrgAdmins(\n orgId: string,\n payload: NotificationPayload,\n options: Pick<NotifyUsersOptions, 'skipEmail'> = {},\n): Promise<void> {\n try {\n const members = await listOrgMembers(orgId)\n const admins = members.filter(\n (member) => member.role === 'owner' || member.role === 'admin',\n )\n // The roster rows carry addresses, so the email channel (AGL-3224) needs\n // no directory lookup for anyone reached this way.\n const emails: Record<string, string | undefined> = {}\n for (const member of admins) {\n if (member.email) emails[member.$id] = member.email\n }\n await notifyUsers(\n admins.map((member) => member.$id),\n { ...payload, orgId },\n { emails, ...(options.skipEmail ? { skipEmail: true } : {}) },\n )\n } catch (error) {\n console.error('org admin notification failed', error)\n }\n}\n\n/**\n * The name a site's managers know it by: the name they gave it, then its\n * address, then — for a doc with neither — its id.\n */\nexport function hostDisplayName(\n host: Record<string, unknown> | undefined,\n hostId: string,\n): string {\n for (const value of [host?.['displayName'], host?.['subdomain']]) {\n if (typeof value === 'string' && value.trim()) return value.trim()\n }\n return hostId\n}\n\n/**\n * Fills `{site}` in a host notification's title or body (AGL-3432).\n *\n * A manager of ten storefronts reads \"New order — $42.00\" and cannot tell\n * which store it came from, and the email shows only the body to someone who\n * skips the subject. `notifyHostManagers` already reads the host doc, so a\n * caller writes \"New order on {site}\" and pays no read of its own.\n */\nexport function withSiteName(text: string, site: string): string {\n return text.includes('{site}') ? text.split('{site}').join(site) : text\n}\n\n/**\n * Notifies everyone who manages a host (admin/editor in the host doc's\n * `memberRoles` projection) — the audience for form submissions and\n * bookings on that site.\n *\n * The OWNING ORG travels with the notification (AGL-1773). Host links are\n * stored in the legacy `/{hostDocId}/rest` shape and rewritten to\n * `/{orgSlug}/hosts/{subdomain}/rest` when they are FOLLOWED\n * (`normalizeNotificationLink`, AGL-644) — a rewrite that needs an org slug.\n * With no `orgId` on the doc the console had nothing to key on and fell back\n * to whichever org the reader happens to have OPEN, so a manager who belongs\n * to more than one workspace was routed to `/{other-org}/hosts/{subdomain}/…`\n * and got a designed 404: `HostGuard` only resolves subdomains belonging to\n * the current org. `notifyOrgAdmins` has always stamped it; the host fan-out\n * — every form submission, booking, order and stock alert — never did.\n *\n * The host doc already carries `orgId` (AGL-233), so this costs no extra\n * read. Spread conditionally: the field is optional on the host model and\n * Firestore rejects `undefined`.\n */\nexport async function notifyHostManagers(\n hostId: string,\n payload: NotificationPayload,\n options: {\n /**\n * Mail the workspace owner nothing through the email channel, because\n * the caller sends them their own email about the same event.\n */\n skipOwnerEmail?: boolean\n /** What the email alone carries; see {@link NotifyUsersOptions.emailDetails}. */\n emailDetails?: readonly NotificationEmailDetail[]\n } = {},\n): Promise<void> {\n try {\n const host = await firestore().collection('hosts').doc(hostId).get()\n const memberRoles =\n (host.get('memberRoles') as Record<string, string> | undefined) ?? {}\n const managers = Object.entries(memberRoles)\n .filter(([, role]) => role === 'admin' || role === 'editor')\n .map(([uid]) => uid)\n const orgId = payload.orgId ?? (host.get('orgId') as string | undefined)\n const site = hostDisplayName(host.data(), hostId)\n const ownerUid =\n options.skipOwnerEmail && orgId\n ? ((await firestore().collection('orgs').doc(orgId).get()).get('ownerUid') as\n | string\n | undefined)\n : undefined\n await notifyUsers(\n managers,\n {\n ...payload,\n title: withSiteName(payload.title, site),\n ...(payload.body !== undefined\n ? { body: withSiteName(payload.body, site) }\n : {}),\n hostId,\n ...(orgId ? { orgId } : {}),\n },\n {\n ...(ownerUid ? { skipEmailFor: [ownerUid] } : {}),\n ...(options.emailDetails ? { emailDetails: options.emailDetails } : {}),\n },\n )\n } catch (error) {\n console.error('host manager notification failed', error)\n }\n}\n"],"names":["buildRoute","NOTIFICATION_SELF_SENT_EMAIL_TYPES","NOTIFICATION_SETTINGS_FIELD","normalizeNotificationLink","notificationChannelEnabled","Route","operatorAlertForNotificationType","isEmailConfigured","sendEmail","notificationPushEnabled","platformConsoleOrigin","FieldValue","withoutMailWithheldAccounts","findUserByUidAcrossPools","listStaffUidsAcrossPools","filterSuppressedEmails","meterOrgEmail","meterPlatformEmail","firebaseAdmin","mobilePushEnabled","mobilePushSenders","listOrgMembers","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","firestore","app","NOTIFY_EMAIL_MAX_DETAILS_CHARS","formatNotificationEmailDetails","details","text","map","label","value","trim","filter","join","length","slice","trimEnd","NOTIFY_EMAIL_MAX_LOOKUPS","NOTIFY_EMAIL_MAX_SENDS","emailNotification","uids","payload","known","audience","emailDetails","origin","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","context","notificationEmailContext","url","notificationEmailUrl","loaded","catch","fallbackText","title","body","Boolean","lookups","sent","uid","address","String","toLowerCase","includes","pooled","record","email","recipients","rendered","brand","merge","options","result","to","subject","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","userDocs","getAll","scope","batch","count","mailTo","pushTo","userDoc","settings","legacy","type","set","id","read","createdAt","serverTimestamp","push","skipEmailFor","commit","send","skipEmail","has","reachable","emails","staffUidCache","STAFF_CACHE_MS","listStaffUids","now","Date","at","notifyStaff","alert","raiseOperatorAlert","level","notifyStaffConsole","notifyOrgAdmins","members","admins","member","role","$id","hostDisplayName","withSiteName","site","split","notifyHostManagers","memberRoles","managers","Object","entries","ownerUid","skipOwnerEmail"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/notifications.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n buildRoute,\n NOTIFICATION_SELF_SENT_EMAIL_TYPES,\n NOTIFICATION_SETTINGS_FIELD,\n normalizeNotificationLink,\n notificationChannelEnabled,\n type AglynNotification,\n Route,\n type NotificationSettings,\n} from '@aglyn/aglyn/server'\nimport { operatorAlertForNotificationType } from '@aglyn/aglyn/plugin-manager/operator-alerts'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { notificationPushEnabled } from '@aglyn/aglyn/app-utils/notification-push'\nimport { platformConsoleOrigin } from '@aglyn/aglyn/app-utils/platform-brand'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { withoutMailWithheldAccounts } from './account-mail'\nimport { findUserByUidAcrossPools, listStaffUidsAcrossPools } from './auth-pools'\nimport { filterSuppressedEmails } from './email-suppression'\nimport { meterOrgEmail, meterPlatformEmail } from './email-metering'\nimport firebaseAdmin from './firebase-admin'\nimport { keepAlive } from './keep-alive'\nimport { mobilePushEnabled, mobilePushSenders } from './mobile-push-switch'\nimport { listOrgMembers } from './organizations'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type SystemEmailBrand,\n} from './render-system-email'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\nexport type NotificationPayload = Omit<\n AglynNotification,\n '$id' | 'createdAt' | 'readAt'\n>\n\nexport interface NotifyUsersOptions {\n /**\n * Addresses the caller already holds, by uid (AGL-3224).\n *\n * Purely an optimization, and one worth taking where it is free: a caller\n * that read the roster — `notifyOrgAdmins` does — has every address in hand\n * already, and without this the email half would look each one up again in\n * the directory. Absent uids fall back to that lookup, so passing a partial\n * map is fine and passing none is correct.\n */\n emails?: Readonly<Record<string, string | null | undefined>>\n /**\n * Who the fan-out is for (AGL-3367). `staff` is the platform talking to its\n * own people, so the email wears the platform's brand and is the platform's\n * cost even when the notification is ABOUT an org, which a canceled\n * subscription or a new ticket always is. Anything else speaks in the org's\n * brand when the notification names one.\n */\n audience?: 'staff'\n /**\n * Write the console notification only, and mail nobody through the\n * per-person email channel (AGL-3375): the caller has emailed the same\n * alert to the operator's inbox, and a staff member who also switched\n * email on would otherwise get it twice.\n */\n skipEmail?: boolean\n /**\n * People not to mail through the email channel for this one notification,\n * because the caller emails them the same event itself — the seller's\n * designed sale email beside a Buy Now order's notification. Everyone else\n * the fan-out reaches is mailed as their preferences say.\n */\n skipEmailFor?: readonly string[]\n /**\n * Particulars the email carries and the notification does not: a form\n * submission's answers, so the person reading the email has the inquiry\n * without opening the console.\n *\n * Email only, never written to the notification document or pushed. The\n * document is copied once per recipient and outlives the record it points\n * at, and a push lands on a lock screen; the record itself stays the one\n * place the visitor's answers are kept.\n */\n emailDetails?: readonly NotificationEmailDetail[]\n}\n\n/** One labeled line of a notification email's details. */\nexport interface NotificationEmailDetail {\n label: string\n value: string\n}\n\n/**\n * At most this many characters of details per email. A submission is capped\n * at 2,000 characters a field, so a long form would otherwise be a long email.\n */\nconst NOTIFY_EMAIL_MAX_DETAILS_CHARS = 10_000\n\n/**\n * The details as the email prints them: `Label: value`, one per line, a\n * value's own line breaks kept and blank values left out.\n */\nexport function formatNotificationEmailDetails(\n details: readonly NotificationEmailDetail[] | undefined,\n): string {\n const text = (details ?? [])\n .map(({ label, value }) => ({ label: label.trim(), value: value.trim() }))\n .filter(({ value }) => value)\n .map(({ label, value }) => (label ? `${label}: ${value}` : value))\n .join('\\n')\n return text.length > NOTIFY_EMAIL_MAX_DETAILS_CHARS\n ? `${text.slice(0, NOTIFY_EMAIL_MAX_DETAILS_CHARS).trimEnd()}…`\n : text\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 bite only on a fan-out where many recipients are emailed —\n * the email channel defaults off outside the types in\n * `NOTIFICATION_TYPE_CHANNEL_DEFAULTS`, and every fan-out goes to one\n * workspace's or one site's people, or to staff. 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 emailDetails: NotifyUsersOptions['emailDetails'],\n): Promise<void> {\n /*\n * `platformConsoleOrigin`, never `NEXT_PUBLIC_CONSOLE_URL` read bare: this\n * runs in the site app as well as the console, and the site app's\n * deployment does not set that variable. Read bare, a notification a site\n * sends (a form submission, an order) would carry a button that opens\n * nothing and a settings line with no address.\n */\n const origin = platformConsoleOrigin()\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 details = formatNotificationEmailDetails(emailDetails)\n const loaded = await loadSystemEmail('notification').catch(() => null)\n const fallbackText = [\n payload.title,\n payload.body ?? '',\n details,\n url,\n `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.details': details,\n 'notification.url': url,\n 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 headers: { 'List-Unsubscribe': `<${settingsUrl}>` },\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.\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 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 */\nasync function notifyUsersNow(\n uids: Iterable<string>,\n payload: NotificationPayload,\n options: NotifyUsersOptions = {},\n): Promise<void> {\n try {\n const db = firestore()\n const targets = [...new Set(uids)].filter(Boolean).slice(0, 400)\n if (!targets.length) return\n // Per-user preferences (AGL-267, AGL-3223): one getAll over the user\n // docs, which now answers for both channels and for all three scopes —\n // the settings live on this document precisely so that stays one read.\n const userDocs = await db.getAll(\n ...targets.map((uid) => db.collection('users').doc(uid)),\n )\n const scope = { orgId: payload.orgId, hostId: payload.hostId }\n const batch = db.batch()\n let count = 0\n const mailTo: string[] = []\n const pushTo: string[] = []\n for (const userDoc of userDocs) {\n const settings = userDoc.get(NOTIFICATION_SETTINGS_FIELD) as\n | NotificationSettings\n | undefined\n const legacy = userDoc.get('notificationPrefs') as\n | Record<string, boolean>\n | undefined\n if (\n notificationChannelEnabled(\n settings,\n 'console',\n payload.type,\n scope,\n legacy,\n )\n ) {\n batch.set(\n db\n .collection('users')\n .doc(userDoc.id)\n .collection('notifications')\n .doc(),\n // `read: false` on create, so the feed can ask for unread and read\n // alike by equality (AGL-3321; see `AglynNotification.read`).\n { ...payload, read: false, createdAt: FieldValue.serverTimestamp() },\n )\n count += 1\n }\n // The mobile push channel (AGL-3620); unanswered, it follows the feed.\n if (notificationPushEnabled(settings, payload.type, scope, legacy)) {\n pushTo.push(userDoc.id)\n }\n if (\n notificationChannelEnabled(settings, 'email', payload.type, scope, legacy) &&\n !options.skipEmailFor?.includes(userDoc.id)\n ) {\n mailTo.push(userDoc.id)\n }\n }\n if (count > 0) await batch.commit()\n /*\n * The push after the commit for the same reason as the email below:\n * every registered transport is handed the recipients whose preferences\n * say push, unless the deployment has pulled the kill switch. A failing\n * transport is logged and never stops the email or another transport.\n */\n if (pushTo.length && mobilePushEnabled()) {\n for (const send of mobilePushSenders()) {\n try {\n await send(pushTo, payload, { db })\n } catch (error) {\n console.error('push delivery failed', error)\n }\n }\n }\n /*\n * THE EMAIL AFTER THE COMMIT, and not inside its `try`.\n *\n * The console notification is the durable record and the send is a\n * courtesy on top of it, so the order is the one where a failing network\n * call cannot lose the record. The two channels are independent by\n * design — a person may have muted the feed and asked for mail, or the\n * reverse — so neither is gated on the other having happened.\n *\n * Digests are excluded: they compose and send their own mail, under\n * their own switches, and the generic channel would send a second\n * message announcing that the first one had been sent.\n *\n * A locked or disabled account is not mailed (AGL-3418); its feed entry\n * above still records the event.\n */\n if (\n mailTo.length &&\n !options.skipEmail &&\n isEmailConfigured() &&\n !NOTIFICATION_SELF_SENT_EMAIL_TYPES.has(payload.type)\n ) {\n const reachable = await withoutMailWithheldAccounts(mailTo)\n if (!reachable.length) return\n await emailNotification(\n reachable,\n payload,\n options.emails ?? {},\n options.audience,\n options.emailDetails,\n )\n }\n } catch (error) {\n console.error('notification fan-out failed', error)\n }\n}\n\n// Staff are identified by a Firebase Auth custom claim (`staff`), which is not\n// a Firestore query — so the roster comes from paginating the auth users. A\n// burst of ticket activity shouldn't rescan every time, so the (small) result\n// is cached briefly. Fails soft to an empty list.\nlet staffUidCache: { uids: string[]; at: number } | null = null\nconst STAFF_CACHE_MS = 60_000\n\nasync function listStaffUids(): Promise<string[]> {\n const now = Date.now()\n if (staffUidCache && now - staffUidCache.at < STAFF_CACHE_MS) {\n return staffUidCache.uids\n }\n // Across ALL auth pools (AGL-1122). This scanned only the project pool, so\n // a staff member who signs in through enterprise SSO — whose account lives\n // in a GCIP tenant pool — was never in the fan-out. Nothing errored; the\n // notification simply never arrived, which is the worst shape a miss can\n // take on an alerting path.\n const uids = await listStaffUidsAcrossPools()\n staffUidCache = { uids, at: now }\n return uids\n}\n\n/**\n * Notifies every staff-claim holder (AGL-850) — the support-desk audience.\n * Staff are not org members, so `notifyOrgAdmins`/`notifyHostManagers` never\n * reach them; this enumerates the `staff` custom claim instead. The docs land\n * at `users/{uid}/notifications`, which the console notifications menu already\n * renders, so no separate staff inbox is needed. Never throws.\n *\n * A type the operator alert registry names (AGL-3377) — a fraud or risk\n * alert, a dispute nobody owns, a support ticket — is raised as that alert\n * instead: the same console notification, plus staff's switches, the\n * operator email or digest, and the webhook. See `raiseOperatorAlert`.\n */\nasync function notifyStaffNow(payload: NotificationPayload): Promise<void> {\n try {\n const alert = operatorAlertForNotificationType(payload.type)\n if (alert) {\n const { raiseOperatorAlert } = await import('./operator-alerts')\n await raiseOperatorAlert(alert, {\n subject: payload.title,\n ...(payload.body ? { body: payload.body } : {}),\n ...(payload.level ? { level: payload.level } : {}),\n ...(payload.link ? { url: payload.link } : {}),\n ...(payload.orgId ? { orgId: payload.orgId } : {}),\n ...(payload.hostId ? { hostId: payload.hostId } : {}),\n })\n return\n }\n await notifyStaffConsole(payload)\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/**\n * The staff console notification alone, with the per-person email channel\n * it carries: what `notifyStaff` did before the registry, and what\n * `raiseOperatorAlert` writes as its first step. Never throws.\n */\nasync function notifyStaffConsoleNow(\n payload: NotificationPayload,\n options: { skipEmail?: boolean } = {},\n): Promise<void> {\n try {\n await notifyUsers(await listStaffUids(), payload, {\n audience: 'staff',\n ...(options.skipEmail ? { skipEmail: true } : {}),\n })\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/**\n * Notifies the org's owner + admins (billing, membership, org events).\n *\n * `skipEmail` is for a caller that emails the same notice to the same people\n * itself — the usage alerts do (AGL-3431) — so an admin who switched the\n * email channel on is not sent it twice.\n */\nasync function notifyOrgAdminsNow(\n orgId: string,\n payload: NotificationPayload,\n options: Pick<NotifyUsersOptions, 'skipEmail'> = {},\n): Promise<void> {\n try {\n const members = await listOrgMembers(orgId)\n const admins = members.filter(\n (member) => member.role === 'owner' || member.role === 'admin',\n )\n // The roster rows carry addresses, so the email channel (AGL-3224) needs\n // no directory lookup for anyone reached this way.\n const emails: Record<string, string | undefined> = {}\n for (const member of admins) {\n if (member.email) emails[member.$id] = member.email\n }\n await notifyUsers(\n admins.map((member) => member.$id),\n { ...payload, orgId },\n { emails, ...(options.skipEmail ? { skipEmail: true } : {}) },\n )\n } catch (error) {\n console.error('org admin notification failed', error)\n }\n}\n\n/**\n * The name a site's managers know it by: the name they gave it, then its\n * address, then — for a doc with neither — its id.\n */\nexport function hostDisplayName(\n host: Record<string, unknown> | undefined,\n hostId: string,\n): string {\n for (const value of [host?.['displayName'], host?.['subdomain']]) {\n if (typeof value === 'string' && value.trim()) return value.trim()\n }\n return hostId\n}\n\n/**\n * Fills `{site}` in a host notification's title or body (AGL-3432).\n *\n * A manager of ten storefronts reads \"New order — $42.00\" and cannot tell\n * which store it came from, and the email shows only the body to someone who\n * skips the subject. `notifyHostManagers` already reads the host doc, so a\n * caller writes \"New order on {site}\" and pays no read of its own.\n */\nexport function withSiteName(text: string, site: string): string {\n return text.includes('{site}') ? text.split('{site}').join(site) : text\n}\n\n/**\n * Notifies everyone who manages a host (admin/editor in the host doc's\n * `memberRoles` projection) — the audience for form submissions and\n * bookings on that site.\n *\n * The OWNING ORG travels with the notification (AGL-1773). Host links are\n * stored in the legacy `/{hostDocId}/rest` shape and rewritten to\n * `/{orgSlug}/hosts/{subdomain}/rest` when they are FOLLOWED\n * (`normalizeNotificationLink`, AGL-644) — a rewrite that needs an org slug.\n * With no `orgId` on the doc the console had nothing to key on and fell back\n * to whichever org the reader happens to have OPEN, so a manager who belongs\n * to more than one workspace was routed to `/{other-org}/hosts/{subdomain}/…`\n * and got a designed 404: `HostGuard` only resolves subdomains belonging to\n * the current org. `notifyOrgAdmins` has always stamped it; the host fan-out\n * — every form submission, booking, order and stock alert — never did.\n *\n * The host doc already carries `orgId` (AGL-233), so this costs no extra\n * read. Spread conditionally: the field is optional on the host model and\n * Firestore rejects `undefined`.\n */\nasync function notifyHostManagersNow(\n hostId: string,\n payload: NotificationPayload,\n options: {\n /**\n * Mail the workspace owner nothing through the email channel, because\n * the caller sends them their own email about the same event.\n */\n skipOwnerEmail?: boolean\n /** What the email alone carries; see {@link NotifyUsersOptions.emailDetails}. */\n emailDetails?: readonly NotificationEmailDetail[]\n } = {},\n): Promise<void> {\n try {\n const host = await firestore().collection('hosts').doc(hostId).get()\n const memberRoles =\n (host.get('memberRoles') as Record<string, string> | undefined) ?? {}\n const managers = Object.entries(memberRoles)\n .filter(([, role]) => role === 'admin' || role === 'editor')\n .map(([uid]) => uid)\n const orgId = payload.orgId ?? (host.get('orgId') as string | undefined)\n const site = hostDisplayName(host.data(), hostId)\n const ownerUid =\n options.skipOwnerEmail && orgId\n ? ((await firestore().collection('orgs').doc(orgId).get()).get('ownerUid') as\n | string\n | undefined)\n : undefined\n await notifyUsers(\n managers,\n {\n ...payload,\n title: withSiteName(payload.title, site),\n ...(payload.body !== undefined\n ? { body: withSiteName(payload.body, site) }\n : {}),\n hostId,\n ...(orgId ? { orgId } : {}),\n },\n {\n ...(ownerUid ? { skipEmailFor: [ownerUid] } : {}),\n ...(options.emailDetails ? { emailDetails: options.emailDetails } : {}),\n },\n )\n } catch (error) {\n console.error('host manager notification failed', error)\n }\n}\n\n/*\n * The fan-out's public doors. Most callers start a notification with\n * `void notify…()` beside the mutation it reports, so each door hands its\n * work to the platform's `waitUntil` (`keep-alive.ts`): the push and the email\n * after the feed write are no longer cut off when the response goes out.\n */\n\n/** {@link notifyUsersNow}, kept alive past the response. */\nexport function notifyUsers(...args: Parameters<typeof notifyUsersNow>): Promise<void> {\n return keepAlive(notifyUsersNow(...args))\n}\n\n/** {@link notifyStaffNow}, kept alive past the response. */\nexport function notifyStaff(...args: Parameters<typeof notifyStaffNow>): Promise<void> {\n return keepAlive(notifyStaffNow(...args))\n}\n\n/** {@link notifyStaffConsoleNow}, kept alive past the response. */\nexport function notifyStaffConsole(...args: Parameters<typeof notifyStaffConsoleNow>): Promise<void> {\n return keepAlive(notifyStaffConsoleNow(...args))\n}\n\n/** {@link notifyOrgAdminsNow}, kept alive past the response. */\nexport function notifyOrgAdmins(...args: Parameters<typeof notifyOrgAdminsNow>): Promise<void> {\n return keepAlive(notifyOrgAdminsNow(...args))\n}\n\n/** {@link notifyHostManagersNow}, kept alive past the response. */\nexport function notifyHostManagers(...args: Parameters<typeof notifyHostManagersNow>): Promise<void> {\n return keepAlive(notifyHostManagersNow(...args))\n}\n"],"names":["buildRoute","NOTIFICATION_SELF_SENT_EMAIL_TYPES","NOTIFICATION_SETTINGS_FIELD","normalizeNotificationLink","notificationChannelEnabled","Route","operatorAlertForNotificationType","isEmailConfigured","sendEmail","notificationPushEnabled","platformConsoleOrigin","FieldValue","withoutMailWithheldAccounts","findUserByUidAcrossPools","listStaffUidsAcrossPools","filterSuppressedEmails","meterOrgEmail","meterPlatformEmail","firebaseAdmin","keepAlive","mobilePushEnabled","mobilePushSenders","listOrgMembers","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","firestore","app","NOTIFY_EMAIL_MAX_DETAILS_CHARS","formatNotificationEmailDetails","details","text","map","label","value","trim","filter","join","length","slice","trimEnd","NOTIFY_EMAIL_MAX_LOOKUPS","NOTIFY_EMAIL_MAX_SENDS","emailNotification","uids","payload","known","audience","emailDetails","origin","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","context","notificationEmailContext","url","notificationEmailUrl","loaded","catch","fallbackText","title","body","Boolean","lookups","sent","uid","address","String","toLowerCase","includes","pooled","record","email","recipients","rendered","brand","merge","options","result","to","subject","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","notifyUsersNow","targets","Set","userDocs","getAll","scope","batch","count","mailTo","pushTo","userDoc","settings","legacy","type","set","id","read","createdAt","serverTimestamp","push","skipEmailFor","commit","send","skipEmail","has","reachable","emails","staffUidCache","STAFF_CACHE_MS","listStaffUids","now","Date","at","notifyStaffNow","alert","raiseOperatorAlert","level","notifyStaffConsole","notifyStaffConsoleNow","notifyUsers","notifyOrgAdminsNow","members","admins","member","role","$id","hostDisplayName","withSiteName","site","split","notifyHostManagersNow","memberRoles","managers","Object","entries","ownerUid","skipOwnerEmail","args","notifyStaff","notifyOrgAdmins","notifyHostManagers"],"mappings":"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"}
|
|
@@ -181,7 +181,9 @@ export function createApnsTransport(options) {
|
|
|
181
181
|
authorization: `bearer ${providerToken()}`,
|
|
182
182
|
'apns-topic': target.topic,
|
|
183
183
|
'apns-push-type': 'alert',
|
|
184
|
-
|
|
184
|
+
// 10 for every alert: 5 lets the device hold, batch or drop it to save power, which is
|
|
185
|
+
// why native pushes arrived late or never while the console showed them at once.
|
|
186
|
+
'apns-priority': '10',
|
|
185
187
|
'content-type': 'application/json'
|
|
186
188
|
}, body);
|
|
187
189
|
// An expired or refused provider token is ours to mint again, not the device's fault.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/push-apns.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 * Apple Push Notification service, sent directly (AGL-3651): HTTP/2 to\n * `api.push.apple.com` (or `api.sandbox.push.apple.com` for a development\n * build's token), authenticated with a provider token, an ES256 JWT signed\n * with the team's APNs key.\n *\n * APNS_KEY_P8 the `.p8` key: PEM text, or that PEM (or its DER) base64-encoded\n * APNS_KEY_ID the key's 10-character id\n * APNS_TEAM_ID the Apple Developer team id\n *\n * Without all three the transport is off: a device on APNs is skipped and\n * that is logged once per process. A provider token is reused for up to 50\n * minutes (Apple refuses one older than an hour, and one refreshed more often\n * than every 20). A `410`, `BadDeviceToken` or `Unregistered` answer means the\n * token is gone, and the row is pruned.\n *\n * Server-only, imported by `push-delivery.ts` only when a device is on APNs.\n */\n\nimport { createPrivateKey, sign, type KeyObject } from 'node:crypto'\nimport { connect, type ClientHttp2Session } from 'node:http2'\nimport type { PushMessage, PushOutcome, PushTarget, PushTransport } from './push-delivery'\n\nexport const APNS_HOSTS = {\n production: 'https://api.push.apple.com',\n sandbox: 'https://api.sandbox.push.apple.com',\n} as const\n\n/** A provider token is reused for at most this long. */\nexport const APNS_TOKEN_TTL_MS = 50 * 60 * 1000\n\n/** Requests in flight at once on one connection. */\nconst CONCURRENCY = 20\n\nexport interface ApnsCredentials {\n key: KeyObject\n keyId: string\n teamId: string\n}\n\nexport interface ApnsResponse {\n status: number\n body: string\n}\n\n/** One HTTP/2 POST to an APNs origin; injectable for tests. */\nexport type ApnsRequest = (origin: string, headers: Record<string, string>, body: string) => Promise<ApnsResponse>\n\nconst base64url = (input: Buffer | string) => Buffer.from(input).toString('base64url')\n\n/** The `.p8` key from `APNS_KEY_P8`: PEM, base64 of the PEM, or base64 of the DER. */\nexport function parseApnsKey(raw: string): KeyObject {\n const text = raw.trim().replace(/\\\\n/g, '\\n')\n if (text.includes('-----BEGIN')) return createPrivateKey(text)\n const decoded = Buffer.from(text, 'base64')\n const asText = decoded.toString('utf8')\n if (asText.includes('-----BEGIN')) return createPrivateKey(asText)\n return createPrivateKey({ key: decoded, format: 'der', type: 'pkcs8' })\n}\n\nexport function apnsCredentials(env: Record<string, string | undefined>): ApnsCredentials | null {\n const raw = env['APNS_KEY_P8']?.trim()\n const keyId = env['APNS_KEY_ID']?.trim()\n const teamId = env['APNS_TEAM_ID']?.trim()\n if (!raw || !keyId || !teamId) return null\n return { key: parseApnsKey(raw), keyId, teamId }\n}\n\n/** An ES256 provider token: `{alg, kid}.{iss, iat}`, signed raw (r‖s), as APNs requires. */\nexport function apnsProviderToken(credentials: ApnsCredentials, nowMs: number): string {\n const header = base64url(JSON.stringify({ alg: 'ES256', kid: credentials.keyId }))\n const claims = base64url(JSON.stringify({ iss: credentials.teamId, iat: Math.floor(nowMs / 1000) }))\n const signature = sign('sha256', Buffer.from(`${header}.${claims}`), {\n key: credentials.key,\n dsaEncoding: 'ieee-p1363',\n })\n return `${header}.${claims}.${base64url(signature)}`\n}\n\n/** The APNs payload: the alert, the default sound, and the tap data beside `aps`. */\nexport function apnsPayload(message: PushMessage): string {\n return JSON.stringify({\n aps: {\n alert: { title: message.title, ...(message.body ? { body: message.body } : {}) },\n sound: 'default',\n },\n ...message.data,\n })\n}\n\nconst GONE = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic'])\n\nexport function apnsOutcome(response: ApnsResponse): PushOutcome {\n if (response.status === 200) return 'sent'\n if (response.status === 410) return 'prune'\n let reason = ''\n try {\n reason = String(JSON.parse(response.body)?.reason ?? '')\n } catch {\n // An unreadable body is a failure, not a verdict on the token.\n }\n return GONE.has(reason) ? 'prune' : 'failed'\n}\n\n/** The default request: one HTTP/2 session per origin for the life of a send. */\nfunction http2Requester(): { request: ApnsRequest; close: () => void } {\n const sessions = new Map<string, ClientHttp2Session>()\n const session = (origin: string) => {\n let open = sessions.get(origin)\n if (!open || open.closed || open.destroyed) {\n open = connect(origin)\n open.on('error', () => undefined)\n sessions.set(origin, open)\n }\n return open\n }\n const request: ApnsRequest = (origin, headers, body) =>\n new Promise((resolve, reject) => {\n const stream = session(origin).request({ ':method': 'POST', ...headers })\n let status = 0\n let text = ''\n stream.setEncoding('utf8')\n stream.setTimeout(15_000, () => stream.close())\n stream.on('response', (head) => {\n status = Number(head[':status'] ?? 0)\n })\n stream.on('data', (chunk: string) => {\n text += chunk\n })\n stream.on('end', () => resolve({ status, body: text }))\n stream.on('error', reject)\n stream.end(body)\n })\n return {\n request,\n close: () => {\n for (const open of sessions.values()) open.close()\n sessions.clear()\n },\n }\n}\n\nexport interface ApnsTransportOptions {\n credentials: ApnsCredentials\n /** Injected in tests; the default opens HTTP/2 sessions per send. */\n request?: ApnsRequest\n now?: () => number\n}\n\nexport function createApnsTransport(options: ApnsTransportOptions): PushTransport {\n const now = options.now ?? Date.now\n let cached: { token: string; at: number } | null = null\n const providerToken = () => {\n const at = now()\n if (!cached || at - cached.at >= APNS_TOKEN_TTL_MS) cached = { token: apnsProviderToken(options.credentials, at), at }\n return cached.token\n }\n return {\n async send(targets: readonly PushTarget[], message: PushMessage): Promise<PushOutcome[]> {\n const { request, close } = options.request\n ? { request: options.request, close: () => undefined }\n : http2Requester()\n const body = apnsPayload(message)\n const outcomes: PushOutcome[] = new Array(targets.length).fill('failed')\n try {\n for (let start = 0; start < targets.length; start += CONCURRENCY) {\n const batch = targets.slice(start, start + CONCURRENCY)\n await Promise.all(\n batch.map(async (target, offset) => {\n try {\n const response = await request(\n APNS_HOSTS[target.apnsEnvironment ?? 'production'],\n {\n ':path': `/3/device/${target.token}`,\n authorization: `bearer ${providerToken()}`,\n 'apns-topic': target.topic,\n 'apns-push-type': 'alert',\n 'apns-priority': message.urgent ? '10' : '5',\n 'content-type': 'application/json',\n },\n body,\n )\n // An expired or refused provider token is ours to mint again, not the device's fault.\n if (response.status === 403) cached = null\n outcomes[start + offset] = apnsOutcome(response)\n } catch {\n outcomes[start + offset] = 'failed'\n }\n }),\n )\n }\n } finally {\n close()\n }\n return outcomes\n },\n }\n}\n\nlet warnedMissingKey = false\nlet configured: { transport: PushTransport } | null = null\n\n/**\n * The deployment's APNs transport from `APNS_*`, or null — logged once —\n * when they are not set.\n */\nexport function apnsTransport(env: Record<string, string | undefined> = process.env): PushTransport | null {\n if (configured) return configured.transport\n const credentials = apnsCredentials(env)\n if (!credentials) {\n if (!warnedMissingKey) {\n warnedMissingKey = true\n console.warn('push: APNS_KEY_P8, APNS_KEY_ID and APNS_TEAM_ID are not all set; iOS and macOS devices are skipped')\n }\n return null\n }\n configured = { transport: createApnsTransport({ credentials }) }\n return configured.transport\n}\n"],"names":["createPrivateKey","sign","connect","APNS_HOSTS","production","sandbox","APNS_TOKEN_TTL_MS","CONCURRENCY","base64url","input","Buffer","from","toString","parseApnsKey","raw","text","trim","replace","includes","decoded","asText","key","format","type","apnsCredentials","env","keyId","teamId","apnsProviderToken","credentials","nowMs","header","JSON","stringify","alg","kid","claims","iss","iat","Math","floor","signature","dsaEncoding","apnsPayload","message","aps","alert","title","body","sound","data","GONE","Set","apnsOutcome","response","status","reason","String","parse","has","http2Requester","sessions","Map","session","origin","open","get","closed","destroyed","on","undefined","set","request","headers","Promise","resolve","reject","stream","setEncoding","setTimeout","close","head","Number","chunk","end","values","clear","createApnsTransport","options","now","Date","cached","providerToken","at","token","send","targets","outcomes","Array","length","fill","start","batch","slice","all","map","target","offset","apnsEnvironment","authorization","topic","urgent","warnedMissingKey","configured","apnsTransport","process","transport","console","warn"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/push-apns.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 * Apple Push Notification service, sent directly (AGL-3651): HTTP/2 to\n * `api.push.apple.com` (or `api.sandbox.push.apple.com` for a development\n * build's token), authenticated with a provider token, an ES256 JWT signed\n * with the team's APNs key.\n *\n * APNS_KEY_P8 the `.p8` key: PEM text, or that PEM (or its DER) base64-encoded\n * APNS_KEY_ID the key's 10-character id\n * APNS_TEAM_ID the Apple Developer team id\n *\n * Without all three the transport is off: a device on APNs is skipped and\n * that is logged once per process. A provider token is reused for up to 50\n * minutes (Apple refuses one older than an hour, and one refreshed more often\n * than every 20). A `410`, `BadDeviceToken` or `Unregistered` answer means the\n * token is gone, and the row is pruned.\n *\n * Server-only, imported by `push-delivery.ts` only when a device is on APNs.\n */\n\nimport { createPrivateKey, sign, type KeyObject } from 'node:crypto'\nimport { connect, type ClientHttp2Session } from 'node:http2'\nimport type { PushMessage, PushOutcome, PushTarget, PushTransport } from './push-delivery'\n\nexport const APNS_HOSTS = {\n production: 'https://api.push.apple.com',\n sandbox: 'https://api.sandbox.push.apple.com',\n} as const\n\n/** A provider token is reused for at most this long. */\nexport const APNS_TOKEN_TTL_MS = 50 * 60 * 1000\n\n/** Requests in flight at once on one connection. */\nconst CONCURRENCY = 20\n\nexport interface ApnsCredentials {\n key: KeyObject\n keyId: string\n teamId: string\n}\n\nexport interface ApnsResponse {\n status: number\n body: string\n}\n\n/** One HTTP/2 POST to an APNs origin; injectable for tests. */\nexport type ApnsRequest = (origin: string, headers: Record<string, string>, body: string) => Promise<ApnsResponse>\n\nconst base64url = (input: Buffer | string) => Buffer.from(input).toString('base64url')\n\n/** The `.p8` key from `APNS_KEY_P8`: PEM, base64 of the PEM, or base64 of the DER. */\nexport function parseApnsKey(raw: string): KeyObject {\n const text = raw.trim().replace(/\\\\n/g, '\\n')\n if (text.includes('-----BEGIN')) return createPrivateKey(text)\n const decoded = Buffer.from(text, 'base64')\n const asText = decoded.toString('utf8')\n if (asText.includes('-----BEGIN')) return createPrivateKey(asText)\n return createPrivateKey({ key: decoded, format: 'der', type: 'pkcs8' })\n}\n\nexport function apnsCredentials(env: Record<string, string | undefined>): ApnsCredentials | null {\n const raw = env['APNS_KEY_P8']?.trim()\n const keyId = env['APNS_KEY_ID']?.trim()\n const teamId = env['APNS_TEAM_ID']?.trim()\n if (!raw || !keyId || !teamId) return null\n return { key: parseApnsKey(raw), keyId, teamId }\n}\n\n/** An ES256 provider token: `{alg, kid}.{iss, iat}`, signed raw (r‖s), as APNs requires. */\nexport function apnsProviderToken(credentials: ApnsCredentials, nowMs: number): string {\n const header = base64url(JSON.stringify({ alg: 'ES256', kid: credentials.keyId }))\n const claims = base64url(JSON.stringify({ iss: credentials.teamId, iat: Math.floor(nowMs / 1000) }))\n const signature = sign('sha256', Buffer.from(`${header}.${claims}`), {\n key: credentials.key,\n dsaEncoding: 'ieee-p1363',\n })\n return `${header}.${claims}.${base64url(signature)}`\n}\n\n/** The APNs payload: the alert, the default sound, and the tap data beside `aps`. */\nexport function apnsPayload(message: PushMessage): string {\n return JSON.stringify({\n aps: {\n alert: { title: message.title, ...(message.body ? { body: message.body } : {}) },\n sound: 'default',\n },\n ...message.data,\n })\n}\n\nconst GONE = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic'])\n\nexport function apnsOutcome(response: ApnsResponse): PushOutcome {\n if (response.status === 200) return 'sent'\n if (response.status === 410) return 'prune'\n let reason = ''\n try {\n reason = String(JSON.parse(response.body)?.reason ?? '')\n } catch {\n // An unreadable body is a failure, not a verdict on the token.\n }\n return GONE.has(reason) ? 'prune' : 'failed'\n}\n\n/** The default request: one HTTP/2 session per origin for the life of a send. */\nfunction http2Requester(): { request: ApnsRequest; close: () => void } {\n const sessions = new Map<string, ClientHttp2Session>()\n const session = (origin: string) => {\n let open = sessions.get(origin)\n if (!open || open.closed || open.destroyed) {\n open = connect(origin)\n open.on('error', () => undefined)\n sessions.set(origin, open)\n }\n return open\n }\n const request: ApnsRequest = (origin, headers, body) =>\n new Promise((resolve, reject) => {\n const stream = session(origin).request({ ':method': 'POST', ...headers })\n let status = 0\n let text = ''\n stream.setEncoding('utf8')\n stream.setTimeout(15_000, () => stream.close())\n stream.on('response', (head) => {\n status = Number(head[':status'] ?? 0)\n })\n stream.on('data', (chunk: string) => {\n text += chunk\n })\n stream.on('end', () => resolve({ status, body: text }))\n stream.on('error', reject)\n stream.end(body)\n })\n return {\n request,\n close: () => {\n for (const open of sessions.values()) open.close()\n sessions.clear()\n },\n }\n}\n\nexport interface ApnsTransportOptions {\n credentials: ApnsCredentials\n /** Injected in tests; the default opens HTTP/2 sessions per send. */\n request?: ApnsRequest\n now?: () => number\n}\n\nexport function createApnsTransport(options: ApnsTransportOptions): PushTransport {\n const now = options.now ?? Date.now\n let cached: { token: string; at: number } | null = null\n const providerToken = () => {\n const at = now()\n if (!cached || at - cached.at >= APNS_TOKEN_TTL_MS) cached = { token: apnsProviderToken(options.credentials, at), at }\n return cached.token\n }\n return {\n async send(targets: readonly PushTarget[], message: PushMessage): Promise<PushOutcome[]> {\n const { request, close } = options.request\n ? { request: options.request, close: () => undefined }\n : http2Requester()\n const body = apnsPayload(message)\n const outcomes: PushOutcome[] = new Array(targets.length).fill('failed')\n try {\n for (let start = 0; start < targets.length; start += CONCURRENCY) {\n const batch = targets.slice(start, start + CONCURRENCY)\n await Promise.all(\n batch.map(async (target, offset) => {\n try {\n const response = await request(\n APNS_HOSTS[target.apnsEnvironment ?? 'production'],\n {\n ':path': `/3/device/${target.token}`,\n authorization: `bearer ${providerToken()}`,\n 'apns-topic': target.topic,\n 'apns-push-type': 'alert',\n // 10 for every alert: 5 lets the device hold, batch or drop it to save power, which is\n // why native pushes arrived late or never while the console showed them at once.\n 'apns-priority': '10',\n 'content-type': 'application/json',\n },\n body,\n )\n // An expired or refused provider token is ours to mint again, not the device's fault.\n if (response.status === 403) cached = null\n outcomes[start + offset] = apnsOutcome(response)\n } catch {\n outcomes[start + offset] = 'failed'\n }\n }),\n )\n }\n } finally {\n close()\n }\n return outcomes\n },\n }\n}\n\nlet warnedMissingKey = false\nlet configured: { transport: PushTransport } | null = null\n\n/**\n * The deployment's APNs transport from `APNS_*`, or null — logged once —\n * when they are not set.\n */\nexport function apnsTransport(env: Record<string, string | undefined> = process.env): PushTransport | null {\n if (configured) return configured.transport\n const credentials = apnsCredentials(env)\n if (!credentials) {\n if (!warnedMissingKey) {\n warnedMissingKey = true\n console.warn('push: APNS_KEY_P8, APNS_KEY_ID and APNS_TEAM_ID are not all set; iOS and macOS devices are skipped')\n }\n return null\n }\n configured = { transport: createApnsTransport({ credentials }) }\n return configured.transport\n}\n"],"names":["createPrivateKey","sign","connect","APNS_HOSTS","production","sandbox","APNS_TOKEN_TTL_MS","CONCURRENCY","base64url","input","Buffer","from","toString","parseApnsKey","raw","text","trim","replace","includes","decoded","asText","key","format","type","apnsCredentials","env","keyId","teamId","apnsProviderToken","credentials","nowMs","header","JSON","stringify","alg","kid","claims","iss","iat","Math","floor","signature","dsaEncoding","apnsPayload","message","aps","alert","title","body","sound","data","GONE","Set","apnsOutcome","response","status","reason","String","parse","has","http2Requester","sessions","Map","session","origin","open","get","closed","destroyed","on","undefined","set","request","headers","Promise","resolve","reject","stream","setEncoding","setTimeout","close","head","Number","chunk","end","values","clear","createApnsTransport","options","now","Date","cached","providerToken","at","token","send","targets","outcomes","Array","length","fill","start","batch","slice","all","map","target","offset","apnsEnvironment","authorization","topic","warnedMissingKey","configured","apnsTransport","process","transport","console","warn"],"mappings":"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"}
|
|
@@ -68,6 +68,15 @@ export interface PushDeliveryResult {
|
|
|
68
68
|
skipped: number;
|
|
69
69
|
}
|
|
70
70
|
export declare function pushMessage(payload: MobilePushPayload, link: string | undefined): PushMessage;
|
|
71
|
+
/**
|
|
72
|
+
* The notification types the Aglyn POS app is pushed. The register is not a
|
|
73
|
+
* second inbox: a person who has both apps would otherwise get every
|
|
74
|
+
* workspace notification twice on the same Mac or iPad, so the Aglyn app
|
|
75
|
+
* carries them all and the POS app only what a register needs. None yet.
|
|
76
|
+
*/
|
|
77
|
+
export declare const POS_PUSH_TYPES: ReadonlySet<string>;
|
|
78
|
+
/** Whether `app` is sent a notification of `type`. */
|
|
79
|
+
export declare function appReceivesPush(app: MobileDeviceApp, type: string | undefined): boolean;
|
|
71
80
|
/**
|
|
72
81
|
* Sends one notification to every live device of `uids`, each through its
|
|
73
82
|
* own transport. Never throws: the console notification is the record, and a
|
|
@@ -70,6 +70,15 @@ export function pushMessage(payload, link) {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
const isApp = (value)=>value === 'aglyn' || value === 'aglyn-pos';
|
|
73
|
+
/**
|
|
74
|
+
* The notification types the Aglyn POS app is pushed. The register is not a
|
|
75
|
+
* second inbox: a person who has both apps would otherwise get every
|
|
76
|
+
* workspace notification twice on the same Mac or iPad, so the Aglyn app
|
|
77
|
+
* carries them all and the POS app only what a register needs. None yet.
|
|
78
|
+
*/ export const POS_PUSH_TYPES = new Set();
|
|
79
|
+
/** Whether `app` is sent a notification of `type`. */ export function appReceivesPush(app, type) {
|
|
80
|
+
return app === 'aglyn' || type !== undefined && POS_PUSH_TYPES.has(type);
|
|
81
|
+
}
|
|
73
82
|
/**
|
|
74
83
|
* Sends one notification to every live device of `uids`, each through its
|
|
75
84
|
* own transport. Never throws: the console notification is the record, and a
|
|
@@ -109,6 +118,7 @@ const isApp = (value)=>value === 'aglyn' || value === 'aglyn-pos';
|
|
|
109
118
|
await prune(device.ref);
|
|
110
119
|
continue;
|
|
111
120
|
}
|
|
121
|
+
if (!appReceivesPush(app, payload.type)) continue;
|
|
112
122
|
if (seen.has(token) || total >= MAX_DEVICES) continue;
|
|
113
123
|
seen.add(token);
|
|
114
124
|
total += 1;
|