@aglyn/tenant-data-admin 1.0.0-beta.203 → 1.0.0-beta.205

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.
Files changed (54) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/capture-email-check.d.ts +109 -0
  3. package/src/lib/server/capture-email-check.js +362 -0
  4. package/src/lib/server/capture-email-check.js.map +1 -0
  5. package/src/lib/server/card-payment-velocity.d.ts +11 -16
  6. package/src/lib/server/card-payment-velocity.js +24 -28
  7. package/src/lib/server/card-payment-velocity.js.map +1 -1
  8. package/src/lib/server/email-deliverability.d.ts +19 -3
  9. package/src/lib/server/email-deliverability.js +34 -16
  10. package/src/lib/server/email-deliverability.js.map +1 -1
  11. package/src/lib/server/held-page-subject.d.ts +93 -0
  12. package/src/lib/server/held-page-subject.js +206 -0
  13. package/src/lib/server/held-page-subject.js.map +1 -0
  14. package/src/lib/server/host-email-tokens.d.ts +42 -1
  15. package/src/lib/server/host-email-tokens.js +100 -12
  16. package/src/lib/server/host-email-tokens.js.map +1 -1
  17. package/src/lib/server/host-visitor-records.js +21 -5
  18. package/src/lib/server/host-visitor-records.js.map +1 -1
  19. package/src/lib/server/hosted-page-review.d.ts +16 -5
  20. package/src/lib/server/hosted-page-review.js +242 -61
  21. package/src/lib/server/hosted-page-review.js.map +1 -1
  22. package/src/lib/server/list-members.js +16 -0
  23. package/src/lib/server/list-members.js.map +1 -1
  24. package/src/lib/server/notifications.d.ts +7 -0
  25. package/src/lib/server/notifications.js +27 -4
  26. package/src/lib/server/notifications.js.map +1 -1
  27. package/src/lib/server/outbound-send-review.d.ts +22 -0
  28. package/src/lib/server/outbound-send-review.js +74 -6
  29. package/src/lib/server/outbound-send-review.js.map +1 -1
  30. package/src/lib/server/payment-fraud-signal.d.ts +20 -12
  31. package/src/lib/server/payment-fraud-signal.js +41 -11
  32. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  33. package/src/lib/server/payment-risk-record.d.ts +37 -13
  34. package/src/lib/server/payment-risk-record.js +23 -8
  35. package/src/lib/server/payment-risk-record.js.map +1 -1
  36. package/src/lib/server/render-system-email.d.ts +3 -0
  37. package/src/lib/server/render-system-email.js +39 -11
  38. package/src/lib/server/render-system-email.js.map +1 -1
  39. package/src/lib/server/risk-notice.d.ts +327 -0
  40. package/src/lib/server/risk-notice.js +1240 -0
  41. package/src/lib/server/risk-notice.js.map +1 -0
  42. package/src/lib/server/sender-readiness.d.ts +36 -0
  43. package/src/lib/server/sender-readiness.js +99 -0
  44. package/src/lib/server/sender-readiness.js.map +1 -0
  45. package/src/lib/server/staff-alert-email.d.ts +29 -15
  46. package/src/lib/server/staff-alert-email.js +131 -28
  47. package/src/lib/server/staff-alert-email.js.map +1 -1
  48. package/src/lib/server/test-firestore.js +1 -0
  49. package/src/lib/server/test-firestore.js.map +1 -1
  50. package/src/lib/server/upsert-contact.js +19 -3
  51. package/src/lib/server/upsert-contact.js.map +1 -1
  52. package/src/tenancy.d.ts +2 -0
  53. package/src/tenancy.js +3 -0
  54. package/src/tenancy.js.map +1 -1
@@ -0,0 +1,1240 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /*==========================================
18
+ * notifyRiskEvent: ONE WAY TO TELL PEOPLE SOMETHING WAS HELD (AGL-3368).
19
+ *
20
+ * Every place the platform holds, flags, locks or pauses something on a
21
+ * workspace calls this, with the kind from the catalog
22
+ * (`@aglyn/shared-util-email/risk-notice-catalog`) and the facts it knows.
23
+ * This decides who hears, on which channel, and in which words:
24
+ *
25
+ * - THE WORKSPACE'S OWNERS AND ADMINS (the org roster's `owner`/`admin`
26
+ * roles, the audience `notifyOrgAdmins` reaches), plus — for a kind about
27
+ * one site's sales or pages — the site's managers, the audience
28
+ * `notifyHostManagers` reaches and the one a sale's fraud signal always
29
+ * went to. An account lock goes to the account's own person instead.
30
+ * They get an in-app notification (`system.riskNotice`) and, for every
31
+ * kind the catalog marks `emailOwners`, an email.
32
+ * - STAFF, through `notifyStaff`, for the kinds the catalog marks
33
+ * `alertStaff`. A row-backed alert links the row itself.
34
+ *
35
+ * ## The owner email is account mail from the platform
36
+ *
37
+ * It is sent with no sending identity and no `audience: 'tenant'`, so it
38
+ * leaves on the platform's own configured sender — never the workspace's.
39
+ * That is what lets a LOCKED workspace's owners receive their lock notice:
40
+ * a suspended workspace's own identity refuses every send
41
+ * (`workspace-suspended`), but this mail is not the workspace's. It is
42
+ * `owedFor: 'account'`, carries no marketing context, and ignores
43
+ * notification preferences: the owner did not opt into it and cannot opt
44
+ * out of being told their email was held. The tenant phishing screen never
45
+ * sees it — that screen asks only about a site's mail.
46
+ *
47
+ * ## One notice per event, and a digest under a burst
48
+ *
49
+ * A notice's id is a hash of its dedupe key (by default the kind and the
50
+ * abuse-queue row), created once — so a redelivered webhook or a re-held
51
+ * message never tells anyone twice, on top of the one-alert-per-row rule
52
+ * the row writers already keep.
53
+ *
54
+ * Past {@link RISK_NOTICE_BURST.owners} notices in an hour for one
55
+ * workspace (and {@link RISK_NOTICE_BURST.staff} for staff), further notices
56
+ * are recorded but not delivered one by one. The first one folded sends a
57
+ * short "more are arriving" note in-app; when the hour closes,
58
+ * {@link flushRiskNoticeDigests} (hourly cron, and the next notice after the
59
+ * window) sends ONE summary. A workspace under attack gets a digest, not
60
+ * five hundred emails. Locks, lifts and cancellations (`neverDigest`) always
61
+ * go out on their own.
62
+ *
63
+ * ## Never throws
64
+ *
65
+ * Every caller is a write that has already happened — a hold, a lock, a
66
+ * webhook. A failed notice is reported in the result (the lockdown route
67
+ * shows it as an unconfirmed step) and logged; it never undoes the event.
68
+ *=========================================*/ import { createHash } from "crypto";
69
+ import { ABUSE_REPORT_COLLECTION, abuseReportContactEmail } from "@aglyn/aglyn/app-utils/abuse-report";
70
+ import { DOCS_BASE_URL } from "@aglyn/aglyn/app-utils/docs-help";
71
+ import { isRiskEventKind, renderOwnerRiskNotice, renderRiskNoticeText, renderStaffRiskNotice, resolveOwnerRiskActions, RISK_NOTICE_CATALOG, RISK_NOTICE_CLOSING_KINDS, RISK_NOTICE_DIGEST_EMAIL_KEY, RISK_NOTICE_HELP_PATH, RISK_NOTICE_WORKSPACE_BRANDED, riskNoticeEmailKey } from "@aglyn/shared-util-email/risk-notice-catalog";
72
+ import { sendEmail as sendEmailImpl } from "@aglyn/shared-util-email";
73
+ import { heldPageDetails, heldPageLabel, heldPageTargets, heldPageVisitorSentence, isOpenPageHoldStatus, isPageHoldNoticeKind, PAGE_HOLD_NOTICE_KINDS, pageHoldChip, parseHeldPageItemPath } from "@aglyn/shared-util-email/held-page";
74
+ import { FieldValue } from "firebase-admin/firestore";
75
+ import { findUserByUidAcrossPools } from "./auth-pools.js";
76
+ import firebaseAdmin from "./firebase-admin.js";
77
+ import { notifyStaff as notifyStaffImpl, notifyUsers as notifyUsersImpl } from "./notifications.js";
78
+ import { meterPlatformEmail } from "./email-metering.js";
79
+ import { listOrgMembers } from "./organizations.js";
80
+ import { orgSystemEmailBrand, renderSystemEmailContent, systemEmailBrand } from "./render-system-email.js";
81
+ import { RISK_REVIEW_REQUESTED_EMAIL_KEY } from "@aglyn/shared-util-email/risk-notice-emails";
82
+ /** Admin-SDK only; no client rule opens it. One document per notice. */ export const RISK_NOTICE_COLLECTION = 'riskNotices';
83
+ /** Admin-SDK only: each workspace's burst window and folded notices. */ export const RISK_NOTICE_LEDGER_COLLECTION = 'riskNoticeLedger';
84
+ /** The in-app notification type every owner notice is written as. */ export const RISK_NOTICE_NOTIFICATION_TYPE = 'system.riskNotice';
85
+ /**
86
+ * The burst allowance: how many notices one workspace's owners, and staff
87
+ * about one workspace, receive one by one in {@link RISK_NOTICE_BURST.windowMs}
88
+ * before the rest are folded into a digest.
89
+ */ export const RISK_NOTICE_BURST = {
90
+ owners: 5,
91
+ staff: 10,
92
+ windowMs: 60 * 60 * 1000,
93
+ /** How many folded notices a digest lists by name. */ digestListMax: 25
94
+ };
95
+ /** Longest review-request note stored. */ export const RISK_REVIEW_NOTE_MAX = 2000;
96
+ /** How many review requests one row keeps. */ export const RISK_REVIEW_REQUESTS_MAX = 20;
97
+ /** How soon the same workspace may ask again about the same row. */ export const RISK_REVIEW_REQUEST_COOLDOWN_MS = 10 * 60 * 1000;
98
+ async function defaultListOwners(orgId) {
99
+ const members = await listOrgMembers(orgId);
100
+ return members.filter((member)=>member.role === 'owner' || member.role === 'admin').map((member)=>{
101
+ var _member_email;
102
+ return {
103
+ uid: String(member.$id),
104
+ email: (_member_email = member.email) != null ? _member_email : null
105
+ };
106
+ });
107
+ }
108
+ async function defaultLookupEmail(uid) {
109
+ var _ref;
110
+ var _found_record;
111
+ const found = await findUserByUidAcrossPools(uid).catch(()=>null);
112
+ const email = String((_ref = found == null ? void 0 : (_found_record = found.record) == null ? void 0 : _found_record.email) != null ? _ref : '').trim();
113
+ return email.includes('@') ? email : null;
114
+ }
115
+ function resolveDeps(overrides = {}) {
116
+ var _overrides_firestore, _overrides_sendEmail, _overrides_notifyUsers, _overrides_notifyStaff, _overrides_listOwners, _overrides_lookupEmail, _overrides_nowMs;
117
+ return {
118
+ firestore: (_overrides_firestore = overrides.firestore) != null ? _overrides_firestore : firebaseAdmin.app().firestore(),
119
+ sendEmail: (_overrides_sendEmail = overrides.sendEmail) != null ? _overrides_sendEmail : sendEmailImpl,
120
+ notifyUsers: (_overrides_notifyUsers = overrides.notifyUsers) != null ? _overrides_notifyUsers : notifyUsersImpl,
121
+ notifyStaff: (_overrides_notifyStaff = overrides.notifyStaff) != null ? _overrides_notifyStaff : notifyStaffImpl,
122
+ listOwners: (_overrides_listOwners = overrides.listOwners) != null ? _overrides_listOwners : defaultListOwners,
123
+ lookupEmail: (_overrides_lookupEmail = overrides.lookupEmail) != null ? _overrides_lookupEmail : defaultLookupEmail,
124
+ nowMs: (_overrides_nowMs = overrides.nowMs) != null ? _overrides_nowMs : ()=>Date.now()
125
+ };
126
+ }
127
+ function consoleOrigin() {
128
+ var _process_env_NEXT_PUBLIC_CONSOLE_URL;
129
+ return ((_process_env_NEXT_PUBLIC_CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL) != null ? _process_env_NEXT_PUBLIC_CONSOLE_URL : '').trim().replace(/\/+$/, '');
130
+ }
131
+ /** The help page every notice links, on the docs site. */ export function riskNoticeHelpUrl(anchor) {
132
+ return `${DOCS_BASE_URL.replace(/\/+$/, '')}${RISK_NOTICE_HELP_PATH}${anchor ? `#${anchor}` : ''}`;
133
+ }
134
+ /** `Sep 28, 2026, 16:31 UTC` — one format for every notice. */ export function formatRiskNoticeTime(ms) {
135
+ const date = new Date(ms);
136
+ if (!Number.isFinite(date.getTime())) return '';
137
+ return `${date.toLocaleString('en-US', {
138
+ month: 'short',
139
+ day: 'numeric',
140
+ year: 'numeric',
141
+ hour: '2-digit',
142
+ minute: '2-digit',
143
+ hour12: false,
144
+ timeZone: 'UTC'
145
+ })} UTC`;
146
+ }
147
+ /** The id of the notice a dedupe key names: hex, like the abuse queue's. */ export function riskNoticeId(dedupeKey) {
148
+ return createHash('sha256').update(`risk-notice:${dedupeKey}`).digest('hex').slice(0, 40);
149
+ }
150
+ /** The default dedupe key: one notice per kind per row, else per event. */ export function riskNoticeDedupeKey(input, occurredAtMs) {
151
+ var _input_orgId, _input_hostId, _input_userUid, _ref, _ref1;
152
+ var _input_item, _input_item1;
153
+ if (input.dedupeKey) return input.dedupeKey;
154
+ if (input.reviewId) return `${input.kind}:${input.reviewId}`;
155
+ return [
156
+ input.kind,
157
+ (_input_orgId = input.orgId) != null ? _input_orgId : '',
158
+ (_input_hostId = input.hostId) != null ? _input_hostId : '',
159
+ (_input_userUid = input.userUid) != null ? _input_userUid : '',
160
+ (_ref = (_ref1 = (_input_item = input.item) == null ? void 0 : _input_item.path) != null ? _ref1 : (_input_item1 = input.item) == null ? void 0 : _input_item1.label) != null ? _ref : '',
161
+ String(occurredAtMs)
162
+ ].join(':');
163
+ }
164
+ /** The ledger a notice's burst allowance is counted on. */ function ledgerIdFor(input) {
165
+ if (input.orgId) return `org:${input.orgId}`;
166
+ if (input.userUid) return `user:${input.userUid}`;
167
+ if (input.hostId) return `host:${input.hostId}`;
168
+ return null;
169
+ }
170
+ /** The console path an owner's Holds & reviews page is at, in stored shape. */ export function riskHoldsPath(noticeId) {
171
+ return `/org/settings/holds${noticeId ? `?notice=${encodeURIComponent(noticeId)}` : ''}`;
172
+ }
173
+ /** A staff deep link to one abuse-queue row. */ export function staffAbuseRowPath(reviewId) {
174
+ return `/admin/abuse-reports?report=${encodeURIComponent(reviewId)}`;
175
+ }
176
+ /** Absolute, for an email: the console origin before a stored path. */ function absolute(path) {
177
+ if (!path) return '';
178
+ if (/^https?:\/\//.test(path)) return path;
179
+ const origin = consoleOrigin();
180
+ return origin ? `${origin}${path}` : '';
181
+ }
182
+ /**
183
+ * The stored-shape console links resolve per reader in the console
184
+ * (`normalizeNotificationLink`); an EMAIL has no reader context, so its
185
+ * links are rewritten here from the org slug and the site's subdomain.
186
+ */ function emailPath(path, context) {
187
+ if (/^https?:\/\//.test(path)) return path;
188
+ if (path.startsWith('/admin/') || path.startsWith('/manage/')) return path;
189
+ if (path === '/org' || path.startsWith('/org/') || path.startsWith('/org?')) {
190
+ return context.orgSlug ? `/${context.orgSlug}${path.slice(4)}` : null;
191
+ }
192
+ if (context.hostId && (path === `/${context.hostId}` || path.startsWith(`/${context.hostId}/`) || path.startsWith(`/${context.hostId}?`))) {
193
+ return context.orgSlug && context.hostSubdomain ? `/${context.orgSlug}/hosts/${context.hostSubdomain}${path.slice(context.hostId.length + 1)}` : null;
194
+ }
195
+ return path;
196
+ }
197
+ /**
198
+ * Stored-shape site paths (`/{hostId}/…`) as console routes, for a reader
199
+ * with no org context of its own — staff reading the abuse queue
200
+ * (AGL-3374). One read of the sites and one of their workspaces for the
201
+ * whole batch; a path that cannot be resolved is left out.
202
+ */ export async function resolveHostConsolePaths(firestore, entries) {
203
+ const resolved = new Map();
204
+ const hostIds = [
205
+ ...new Set(entries.map((entry)=>entry.hostId).filter(Boolean))
206
+ ];
207
+ if (!hostIds.length) return resolved;
208
+ const hosts = await firestore.getAll(...hostIds.map((id)=>firestore.collection('hosts').doc(id)));
209
+ const hostFacts = new Map(hosts.map((host)=>{
210
+ var _host_get, _host_get1;
211
+ return [
212
+ host.id,
213
+ {
214
+ subdomain: String((_host_get = host.get('subdomain')) != null ? _host_get : '') || null,
215
+ orgId: String((_host_get1 = host.get('orgId')) != null ? _host_get1 : '') || null
216
+ }
217
+ ];
218
+ }));
219
+ // The row names its workspace; a site document may too.
220
+ const orgOf = (entry)=>{
221
+ var _hostFacts_get;
222
+ return entry.orgId || ((_hostFacts_get = hostFacts.get(entry.hostId)) == null ? void 0 : _hostFacts_get.orgId) || null;
223
+ };
224
+ const orgIds = [
225
+ ...new Set(entries.map(orgOf).filter(Boolean))
226
+ ];
227
+ const orgs = orgIds.length ? await firestore.getAll(...orgIds.map((id)=>firestore.collection('orgs').doc(id))) : [];
228
+ const slugs = new Map(orgs.map((org)=>{
229
+ var _org_get;
230
+ return [
231
+ org.id,
232
+ String((_org_get = org.get('slug')) != null ? _org_get : '') || null
233
+ ];
234
+ }));
235
+ for (const entry of entries){
236
+ var _slugs_get, _ref;
237
+ const facts = hostFacts.get(entry.hostId);
238
+ const orgId = orgOf(entry);
239
+ const href = emailPath(entry.path, {
240
+ orgSlug: orgId ? (_slugs_get = slugs.get(orgId)) != null ? _slugs_get : null : null,
241
+ hostId: entry.hostId,
242
+ hostSubdomain: (_ref = facts == null ? void 0 : facts.subdomain) != null ? _ref : null
243
+ });
244
+ if (href && href !== entry.path) resolved.set(`${entry.hostId}\n${entry.path}`, href);
245
+ }
246
+ return resolved;
247
+ }
248
+ /** The workspace facts every notice renders with. Never throws. */ async function readContext(firestore, input) {
249
+ var _input_orgId;
250
+ let orgId = (_input_orgId = input.orgId) != null ? _input_orgId : null;
251
+ let hostSubdomain = null;
252
+ let siteManagerUids = [];
253
+ if (input.hostId) {
254
+ const host = await firestore.collection('hosts').doc(input.hostId).get().catch(()=>null);
255
+ if (host == null ? void 0 : host.exists) {
256
+ var _host_get, _host_get1, _host_get2;
257
+ hostSubdomain = String((_host_get = host.get('subdomain')) != null ? _host_get : '') || null;
258
+ orgId = orgId != null ? orgId : String((_host_get1 = host.get('orgId')) != null ? _host_get1 : '') || null;
259
+ const roles = (_host_get2 = host.get('memberRoles')) != null ? _host_get2 : {};
260
+ siteManagerUids = Object.entries(roles).filter(([, role])=>role === 'admin' || role === 'editor').map(([uid])=>uid);
261
+ }
262
+ }
263
+ let orgName = null;
264
+ let orgSlug = null;
265
+ if (orgId) {
266
+ const org = await firestore.collection('orgs').doc(orgId).get().catch(()=>null);
267
+ if (org == null ? void 0 : org.exists) {
268
+ var _org_get, _org_get1;
269
+ orgName = String((_org_get = org.get('name')) != null ? _org_get : '') || null;
270
+ orgSlug = String((_org_get1 = org.get('slug')) != null ? _org_get1 : '') || null;
271
+ }
272
+ }
273
+ return {
274
+ orgId,
275
+ orgName,
276
+ orgSlug,
277
+ hostSubdomain,
278
+ siteManagerUids
279
+ };
280
+ }
281
+ /**
282
+ * The brand a kind's email is sent in: the workspace's own for a routine
283
+ * notice about its customers' payments, the platform's for everything the
284
+ * platform does (holds, locks, lifts, cancellations, reviews).
285
+ */ export async function riskNoticeEmailBrand(kind, orgId) {
286
+ return kind && RISK_NOTICE_WORKSPACE_BRANDED.has(kind) ? orgSystemEmailBrand(orgId) : systemEmailBrand(null);
287
+ }
288
+ /**
289
+ * Renders one notice email through the System emails seam (AGL-3367): the
290
+ * published design for the kind's own `risk-…` key, or its built-in copy,
291
+ * inside the header and footer every platform email wears. The plain-text
292
+ * composition below is the last resort for a render that produced nothing.
293
+ * ONE function, so every notice renders the same way.
294
+ */ export async function renderRiskNoticeEmail(key, merge, brand = systemEmailBrand(null)) {
295
+ const content = await renderSystemEmailContent(key, merge, brand, riskNoticeFallback(merge)).catch(()=>riskNoticeFallback(merge));
296
+ return _extends({
297
+ key
298
+ }, content, brand.fromName ? {
299
+ fromName: brand.fromName
300
+ } : {});
301
+ }
302
+ /** The notice as plain text, when no design renders. */ function riskNoticeFallback(merge) {
303
+ var _merge_noticetitle;
304
+ const lines = [
305
+ merge['notice.summary'],
306
+ merge['notice.meaning'],
307
+ merge['notice.steps'] ? `What to do next:\n${merge['notice.steps']}` : '',
308
+ merge['notice.actions'],
309
+ merge['reference'] ? `Reference: ${merge['reference']}` : '',
310
+ merge['help.url'] ? `Why was something held or flagged? ${merge['help.url']}` : ''
311
+ ].filter((line)=>Boolean(line && line.trim()));
312
+ return {
313
+ subject: (_merge_noticetitle = merge['notice.title']) != null ? _merge_noticetitle : '',
314
+ text: lines.join('\n\n')
315
+ };
316
+ }
317
+ function numbered(steps) {
318
+ return steps.map((step, index)=>`${index + 1}. ${step}`).join('\n');
319
+ }
320
+ function emptyDelivery(skipped = null) {
321
+ return {
322
+ recipients: 0,
323
+ inApp: 0,
324
+ emailed: 0,
325
+ emailFailed: 0,
326
+ emailSkipped: skipped,
327
+ digested: false
328
+ };
329
+ }
330
+ /**
331
+ * Count this notice against its workspace's hour and say whether it is
332
+ * delivered now. Fails OPEN: a ledger that cannot be written never costs a
333
+ * notice.
334
+ */ async function countBurst(deps, ledgerId, entry) {
335
+ const nowMs = deps.nowMs();
336
+ const ref = deps.firestore.collection(RISK_NOTICE_LEDGER_COLLECTION).doc(ledgerId);
337
+ try {
338
+ return await deps.firestore.runTransaction(async (transaction)=>{
339
+ var _snapshot_get, _snapshot_get1, _snapshot_get2;
340
+ const snapshot = await transaction.get(ref);
341
+ const startedAtMs = Number((_snapshot_get = snapshot.get('windowStartedAtMs')) != null ? _snapshot_get : 0);
342
+ const fresh = !startedAtMs || nowMs - startedAtMs >= RISK_NOTICE_BURST.windowMs;
343
+ const ownerCount = (fresh ? 0 : Number((_snapshot_get1 = snapshot.get('ownerCount')) != null ? _snapshot_get1 : 0)) + 1;
344
+ const staffCount = (fresh ? 0 : Number((_snapshot_get2 = snapshot.get('staffCount')) != null ? _snapshot_get2 : 0)) + (entry.alertStaff ? 1 : 0);
345
+ const owners = ownerCount <= RISK_NOTICE_BURST.owners ? 'deliver' : ownerCount === RISK_NOTICE_BURST.owners + 1 ? 'fold-first' : 'fold';
346
+ const staff = staffCount <= RISK_NOTICE_BURST.staff ? 'deliver' : 'fold';
347
+ const pendingOwners = Array.isArray(snapshot.get('pendingOwners')) ? snapshot.get('pendingOwners') : [];
348
+ const pendingStaff = Array.isArray(snapshot.get('pendingStaff')) ? snapshot.get('pendingStaff') : [];
349
+ // How many of each notice were folded, however many the list keeps.
350
+ const tally = (field, title)=>{
351
+ var _snapshot_get, _current_title;
352
+ const current = (_snapshot_get = snapshot.get(field)) != null ? _snapshot_get : {};
353
+ return _extends({}, current, {
354
+ [title]: Number((_current_title = current[title]) != null ? _current_title : 0) + 1
355
+ });
356
+ };
357
+ transaction.set(ref, _extends({
358
+ ledgerId
359
+ }, fresh ? {
360
+ windowStartedAtMs: nowMs
361
+ } : {}, {
362
+ ownerCount,
363
+ staffCount
364
+ }, owners !== 'deliver' ? {
365
+ pendingOwners: [
366
+ ...pendingOwners,
367
+ {
368
+ kind: entry.kind,
369
+ title: entry.title,
370
+ noticeId: entry.noticeId,
371
+ atMs: nowMs
372
+ }
373
+ ].slice(-RISK_NOTICE_BURST.digestListMax),
374
+ pendingOwnerTally: tally('pendingOwnerTally', entry.title),
375
+ pendingOwnerCount: FieldValue.increment(1)
376
+ } : {}, entry.alertStaff && staff !== 'deliver' ? {
377
+ pendingStaff: [
378
+ ...pendingStaff,
379
+ {
380
+ kind: entry.kind,
381
+ title: entry.staffTitle,
382
+ noticeId: entry.noticeId,
383
+ atMs: nowMs
384
+ }
385
+ ].slice(-RISK_NOTICE_BURST.digestListMax),
386
+ pendingStaffTally: tally('pendingStaffTally', entry.staffTitle),
387
+ pendingStaffCount: FieldValue.increment(1)
388
+ } : {}, {
389
+ updatedAtMs: nowMs
390
+ }), {
391
+ merge: true
392
+ });
393
+ return {
394
+ owners,
395
+ staff
396
+ };
397
+ });
398
+ } catch (error) {
399
+ console.error('[risk-notice] burst ledger unavailable — delivering', error);
400
+ return {
401
+ owners: 'deliver',
402
+ staff: 'deliver'
403
+ };
404
+ }
405
+ }
406
+ /** Where the owner half goes: uids, and the addresses already in hand. */ async function ownerRecipients(deps, input, siteManagerUids) {
407
+ const emails = {};
408
+ const uids = new Set();
409
+ const definition = RISK_NOTICE_CATALOG[input.kind];
410
+ if (input.recipients) {
411
+ for (const recipient of input.recipients){
412
+ if (!recipient.uid) continue;
413
+ uids.add(recipient.uid);
414
+ if (recipient.email) emails[recipient.uid] = recipient.email;
415
+ }
416
+ return {
417
+ uids: [
418
+ ...uids
419
+ ],
420
+ emails
421
+ };
422
+ }
423
+ if (input.kind === 'account-locked' || input.kind === 'account-unlocked') {
424
+ if (input.userUid) uids.add(input.userUid);
425
+ } else if (input.orgId) {
426
+ const owners = await deps.listOwners(input.orgId).catch(()=>[]);
427
+ for (const owner of owners){
428
+ uids.add(owner.uid);
429
+ if (owner.email) emails[owner.uid] = owner.email;
430
+ }
431
+ }
432
+ if (definition.includeSiteManagers) for (const uid of siteManagerUids)uids.add(uid);
433
+ return {
434
+ uids: [
435
+ ...uids
436
+ ],
437
+ emails
438
+ };
439
+ }
440
+ /**
441
+ * Tell the right people about one risk event. See the module header.
442
+ * Never throws.
443
+ */ export async function notifyRiskEvent(input, overrides = {}) {
444
+ const result = {
445
+ noticeId: null,
446
+ duplicate: false,
447
+ owners: emptyDelivery(),
448
+ staff: {
449
+ alerted: false,
450
+ digested: false
451
+ },
452
+ error: null
453
+ };
454
+ if (!isRiskEventKind(input == null ? void 0 : input.kind)) {
455
+ result.error = `Unknown risk event kind: ${String(input == null ? void 0 : input.kind)}`;
456
+ return result;
457
+ }
458
+ let deps;
459
+ try {
460
+ deps = resolveDeps(overrides);
461
+ } catch (error) {
462
+ var _ref;
463
+ result.error = (_ref = error == null ? void 0 : error.message) != null ? _ref : String(error);
464
+ return result;
465
+ }
466
+ const definition = RISK_NOTICE_CATALOG[input.kind];
467
+ const nowMs = deps.nowMs();
468
+ const occurredAtMs = Number.isFinite(input.occurredAtMs) ? Number(input.occurredAtMs) : nowMs;
469
+ const noticeId = riskNoticeId(riskNoticeDedupeKey(input, occurredAtMs));
470
+ result.noticeId = noticeId;
471
+ try {
472
+ var _abuseReportContactEmail, _ref1, _input_reference, _ref2, _ref3, _input_amount, _context_orgName, _input_staffEvidence, _ref4, _input_reviewId, _input_hostId, _input_stripeUrl, _ref5, _ref6, _ownerActions_find;
473
+ var _input_item, _input_lock, _input_lock1, _input_item1, _input_lock2, _input_lock3;
474
+ const context = await readContext(deps.firestore, input);
475
+ const orgId = context.orgId;
476
+ const event = _extends({}, input, {
477
+ orgId
478
+ });
479
+ /*
480
+ * The values the OWNER half renders with. `staff.evidence` is absent by
481
+ * construction: the owner record and the owner channels never hold it.
482
+ */ const supportEmail = (_abuseReportContactEmail = abuseReportContactEmail('support')) != null ? _abuseReportContactEmail : '';
483
+ const ownerValues = {
484
+ 'workspace.name': context.orgName,
485
+ 'item.label': (_ref1 = (_input_item = input.item) == null ? void 0 : _input_item.label) != null ? _ref1 : null,
486
+ occurredAt: formatRiskNoticeTime(occurredAtMs),
487
+ reference: (_input_reference = input.reference) != null ? _input_reference : null,
488
+ 'support.email': supportEmail || null,
489
+ 'lock.message': (_ref2 = (_input_lock = input.lock) == null ? void 0 : _input_lock.message) != null ? _ref2 : null,
490
+ 'lock.affected': (_ref3 = (_input_lock1 = input.lock) == null ? void 0 : _input_lock1.affected) != null ? _ref3 : null,
491
+ amount: (_input_amount = input.amount) != null ? _input_amount : null,
492
+ 'evidence.dueBy': input.evidenceDueByMs ? formatRiskNoticeTime(input.evidenceDueByMs) : null
493
+ };
494
+ const staffValues = _extends({}, ownerValues, {
495
+ 'workspace.name': (_context_orgName = context.orgName) != null ? _context_orgName : orgId,
496
+ 'staff.evidence': (_input_staffEvidence = input.staffEvidence) != null ? _input_staffEvidence : null
497
+ });
498
+ const actionParams = {
499
+ itemPath: (_ref4 = (_input_item1 = input.item) == null ? void 0 : _input_item1.path) != null ? _ref4 : null,
500
+ noticeId,
501
+ reviewId: (_input_reviewId = input.reviewId) != null ? _input_reviewId : null,
502
+ orgId,
503
+ hostId: (_input_hostId = input.hostId) != null ? _input_hostId : null,
504
+ stripeUrl: (_input_stripeUrl = input.stripeUrl) != null ? _input_stripeUrl : null,
505
+ lockScope: (_ref5 = (_input_lock2 = input.lock) == null ? void 0 : _input_lock2.scope) != null ? _ref5 : null,
506
+ lockTargetId: (_ref6 = (_input_lock3 = input.lock) == null ? void 0 : _input_lock3.targetId) != null ? _ref6 : null
507
+ };
508
+ const owner = renderOwnerRiskNotice(input.kind, ownerValues);
509
+ const staff = renderStaffRiskNotice(input.kind, staffValues);
510
+ // ONE notice per dedupe key: `create` refuses a second.
511
+ const noticeRef = deps.firestore.collection(RISK_NOTICE_COLLECTION).doc(noticeId);
512
+ try {
513
+ var _input_hostId1, _input_userUid, _input_reviewId1, _input_reference1;
514
+ await noticeRef.create(_extends({
515
+ kind: input.kind,
516
+ severity: definition.severity,
517
+ orgId,
518
+ hostId: (_input_hostId1 = input.hostId) != null ? _input_hostId1 : null,
519
+ userUid: (_input_userUid = input.userUid) != null ? _input_userUid : null,
520
+ reviewId: (_input_reviewId1 = input.reviewId) != null ? _input_reviewId1 : null,
521
+ reference: (_input_reference1 = input.reference) != null ? _input_reference1 : null,
522
+ // What the owner surface renders, and nothing staff-only.
523
+ ownerValues: stripEmpty(ownerValues),
524
+ actionParams: stripEmpty(actionParams),
525
+ occurredAtMs,
526
+ createdAtMs: nowMs,
527
+ createdAt: FieldValue.serverTimestamp()
528
+ }, input.page ? {
529
+ page: input.page
530
+ } : {}));
531
+ } catch (error) {
532
+ if (isAlreadyExists(error)) {
533
+ result.duplicate = true;
534
+ return result;
535
+ }
536
+ throw error;
537
+ }
538
+ // A row-backed notice stamps the row, so the staff queue renders the
539
+ // catalog's text and actions and the owner surface finds the row.
540
+ if (input.reviewId && !RISK_NOTICE_CLOSING_KINDS.has(input.kind)) {
541
+ var _ref7, _ref8, _input_stripeUrl1;
542
+ var _input_item2, _input_item3;
543
+ await deps.firestore.collection(ABUSE_REPORT_COLLECTION).doc(input.reviewId).set({
544
+ riskNotice: {
545
+ kind: input.kind,
546
+ noticeId,
547
+ itemLabel: (_ref7 = (_input_item2 = input.item) == null ? void 0 : _input_item2.label) != null ? _ref7 : null,
548
+ itemPath: (_ref8 = (_input_item3 = input.item) == null ? void 0 : _input_item3.path) != null ? _ref8 : null,
549
+ stripeUrl: (_input_stripeUrl1 = input.stripeUrl) != null ? _input_stripeUrl1 : null,
550
+ ownersNotifiedAtMs: nowMs
551
+ }
552
+ }, {
553
+ merge: true
554
+ }).catch((error)=>console.error('[risk-notice] could not stamp the abuse row', error));
555
+ }
556
+ // Anything folded in an hour that has now closed goes first.
557
+ const ledgerId = ledgerIdFor(event);
558
+ if (ledgerId) await flushLedger(deps, ledgerId).catch(()=>undefined);
559
+ const verdict = definition.neverDigest || !ledgerId ? {
560
+ owners: 'deliver',
561
+ staff: 'deliver'
562
+ } : await countBurst(deps, ledgerId, {
563
+ kind: input.kind,
564
+ title: owner.title,
565
+ staffTitle: staff.title,
566
+ noticeId,
567
+ alertStaff: definition.alertStaff
568
+ });
569
+ // ---- The owners and admins ----
570
+ const recipients = await ownerRecipients(deps, event, context.siteManagerUids);
571
+ result.owners.recipients = recipients.uids.length;
572
+ const ownerActions = resolveOwnerRiskActions(input.kind, actionParams);
573
+ const primary = (_ownerActions_find = ownerActions.find((action)=>action.id === 'request-review')) != null ? _ownerActions_find : ownerActions[0];
574
+ if (verdict.owners === 'deliver') {
575
+ if (recipients.uids.length) {
576
+ var _ref9;
577
+ await deps.notifyUsers(recipients.uids, _extends({
578
+ type: RISK_NOTICE_NOTIFICATION_TYPE,
579
+ title: owner.title,
580
+ body: `${owner.summary} ${owner.meaning}`.slice(0, 1000),
581
+ link: (_ref9 = primary == null ? void 0 : primary.href) != null ? _ref9 : riskHoldsPath(noticeId)
582
+ }, orgId ? {
583
+ orgId
584
+ } : {}, input.hostId ? {
585
+ hostId: input.hostId
586
+ } : {}), {
587
+ emails: recipients.emails
588
+ });
589
+ result.owners.inApp = recipients.uids.length;
590
+ }
591
+ if (!definition.emailOwners) {
592
+ result.owners.emailSkipped = 'This kind is not emailed.';
593
+ } else if (input.emailOwners === false) {
594
+ result.owners.emailSkipped = 'Staff chose not to email the owners for this event.';
595
+ } else if (!recipients.uids.length) {
596
+ result.owners.emailSkipped = 'Nobody to email: the workspace has no owner or admin on record.';
597
+ } else {
598
+ var _input_hostId2, _input_reference2, _ref10;
599
+ var _input_lock4;
600
+ const merge = emailMerge({
601
+ title: owner.title,
602
+ summary: owner.summary,
603
+ meaning: owner.meaning,
604
+ steps: owner.steps,
605
+ actions: ownerActions,
606
+ context: {
607
+ orgSlug: context.orgSlug,
608
+ hostId: (_input_hostId2 = input.hostId) != null ? _input_hostId2 : null,
609
+ hostSubdomain: context.hostSubdomain
610
+ },
611
+ reference: (_input_reference2 = input.reference) != null ? _input_reference2 : null,
612
+ helpAnchor: definition.helpAnchor,
613
+ workspaceName: context.orgName,
614
+ lockMessage: (_ref10 = (_input_lock4 = input.lock) == null ? void 0 : _input_lock4.message) != null ? _ref10 : null
615
+ });
616
+ const rendered = await renderRiskNoticeEmail(riskNoticeEmailKey(input.kind), merge, await riskNoticeEmailBrand(input.kind, orgId));
617
+ const delivery = await emailPeople(deps, recipients, rendered, supportEmail);
618
+ result.owners.emailed = delivery.sent;
619
+ result.owners.emailFailed = delivery.failed;
620
+ if (!delivery.sent && !delivery.failed) {
621
+ result.owners.emailSkipped = 'No recipient has an email address on record.';
622
+ }
623
+ }
624
+ } else {
625
+ result.owners.digested = true;
626
+ if (verdict.owners === 'fold-first' && recipients.uids.length) {
627
+ await deps.notifyUsers(recipients.uids, _extends({
628
+ type: RISK_NOTICE_NOTIFICATION_TYPE,
629
+ title: 'More account notices are arriving',
630
+ body: 'Several items on your workspace were held or flagged in the last hour. ' + 'We will send one summary instead of a message for each. Every item is listed on Holds & reviews.',
631
+ link: riskHoldsPath()
632
+ }, orgId ? {
633
+ orgId
634
+ } : {}), {
635
+ emails: recipients.emails
636
+ });
637
+ result.owners.inApp = recipients.uids.length;
638
+ }
639
+ }
640
+ // ---- Staff ----
641
+ if (definition.alertStaff) {
642
+ if (verdict.staff === 'deliver') {
643
+ await deps.notifyStaff({
644
+ type: 'system.abuseReportUrgent',
645
+ title: staff.title,
646
+ body: staff.summary.slice(0, 1000),
647
+ link: input.reviewId ? staffAbuseRowPath(input.reviewId) : '/admin/abuse-reports'
648
+ });
649
+ result.staff.alerted = true;
650
+ } else {
651
+ result.staff.digested = true;
652
+ }
653
+ }
654
+ await noticeRef.set({
655
+ delivery: {
656
+ owners: result.owners,
657
+ staff: result.staff
658
+ }
659
+ }, {
660
+ merge: true
661
+ }).catch(()=>undefined);
662
+ return result;
663
+ } catch (error) {
664
+ var _ref11;
665
+ console.error('[risk-notice] a notice could not be delivered', error);
666
+ result.error = (_ref11 = error == null ? void 0 : error.message) != null ? _ref11 : String(error);
667
+ return result;
668
+ }
669
+ }
670
+ function stripEmpty(values) {
671
+ const out = {};
672
+ for (const [key, value] of Object.entries(values)){
673
+ if (typeof value === 'string' && value) out[key] = value;
674
+ }
675
+ return out;
676
+ }
677
+ function isAlreadyExists(error) {
678
+ const code = error == null ? void 0 : error.code;
679
+ return code === 6 || code === 'already-exists' || code === 'ALREADY_EXISTS';
680
+ }
681
+ /** The flat merge map a notice email renders with. */ function emailMerge(input) {
682
+ var _links_find, _ref, _ref1, _input_workspaceName, _input_lockMessage, _input_reference;
683
+ const links = input.actions.flatMap((action)=>{
684
+ const path = emailPath(action.href, input.context);
685
+ const url = path ? absolute(path) : '';
686
+ return url ? [
687
+ {
688
+ label: action.label,
689
+ url
690
+ }
691
+ ] : [];
692
+ });
693
+ const primary = (_links_find = links.find((link)=>link.label === 'Request a review')) != null ? _links_find : links[0];
694
+ const holdsPath = emailPath(riskHoldsPath(), input.context);
695
+ return {
696
+ 'notice.title': input.title,
697
+ 'notice.summary': input.summary,
698
+ 'notice.meaning': input.meaning,
699
+ 'notice.steps': numbered(input.steps),
700
+ 'notice.actions': links.map((link)=>`${link.label}: ${link.url}`).join('\n'),
701
+ 'notice.primaryActionLabel': (_ref = primary == null ? void 0 : primary.label) != null ? _ref : '',
702
+ 'notice.primaryActionUrl': (_ref1 = primary == null ? void 0 : primary.url) != null ? _ref1 : '',
703
+ 'workspace.name': (_input_workspaceName = input.workspaceName) != null ? _input_workspaceName : '',
704
+ 'lock.message': (_input_lockMessage = input.lockMessage) != null ? _input_lockMessage : '',
705
+ reference: (_input_reference = input.reference) != null ? _input_reference : '',
706
+ 'holds.url': holdsPath ? absolute(holdsPath) : '',
707
+ 'help.url': riskNoticeHelpUrl(input.helpAnchor)
708
+ };
709
+ }
710
+ /**
711
+ * One email per person — two owners have not agreed to see each other's
712
+ * addresses. Platform sender, transactional, owed for the account.
713
+ */ async function emailPeople(deps, recipients, rendered, supportEmail) {
714
+ let sent = 0;
715
+ let failed = 0;
716
+ const seen = new Set();
717
+ for (const uid of recipients.uids.slice(0, 50)){
718
+ var _ref, _recipients_emails_uid;
719
+ const address = String((_ref = (_recipients_emails_uid = recipients.emails[uid]) != null ? _recipients_emails_uid : await deps.lookupEmail(uid).catch(()=>null)) != null ? _ref : '').trim().toLowerCase();
720
+ if (!address.includes('@') || seen.has(address)) continue;
721
+ seen.add(address);
722
+ const outcome = await deps.sendEmail(_extends({
723
+ to: address,
724
+ subject: rendered.subject,
725
+ text: rendered.text
726
+ }, rendered.html ? {
727
+ html: rendered.html
728
+ } : {}, rendered.fromName ? {
729
+ fromName: rendered.fromName
730
+ } : {}, supportEmail ? {
731
+ replyTo: supportEmail
732
+ } : {}, {
733
+ context: 'risk-notice',
734
+ owedFor: 'account'
735
+ })).catch((error)=>({
736
+ sent: false,
737
+ reason: 'provider-error',
738
+ detail: String(error)
739
+ }));
740
+ if (outcome.sent) {
741
+ sent += 1;
742
+ // The platform's own account mail: its cost is the platform's, whatever
743
+ // workspace it is about (AGL-1438).
744
+ await meterPlatformEmail().catch(()=>undefined);
745
+ } else failed += 1;
746
+ }
747
+ return {
748
+ sent,
749
+ failed
750
+ };
751
+ }
752
+ /**
753
+ * Send one ledger's digest if its hour has closed and anything was folded.
754
+ * Returns whether a digest went out.
755
+ */ async function flushLedger(deps, ledgerId) {
756
+ const nowMs = deps.nowMs();
757
+ const ref = deps.firestore.collection(RISK_NOTICE_LEDGER_COLLECTION).doc(ledgerId);
758
+ const taken = await deps.firestore.runTransaction(async (transaction)=>{
759
+ var _snapshot_get, _snapshot_get1, _snapshot_get2, _snapshot_get3, _snapshot_get4;
760
+ const snapshot = await transaction.get(ref);
761
+ if (!snapshot.exists) return null;
762
+ const startedAtMs = Number((_snapshot_get = snapshot.get('windowStartedAtMs')) != null ? _snapshot_get : 0);
763
+ const ownerCount = Number((_snapshot_get1 = snapshot.get('pendingOwnerCount')) != null ? _snapshot_get1 : 0);
764
+ const staffCount = Number((_snapshot_get2 = snapshot.get('pendingStaffCount')) != null ? _snapshot_get2 : 0);
765
+ if (!ownerCount && !staffCount) return null;
766
+ if (startedAtMs && nowMs - startedAtMs < RISK_NOTICE_BURST.windowMs) return null;
767
+ transaction.set(ref, {
768
+ pendingOwners: [],
769
+ pendingStaff: [],
770
+ pendingOwnerCount: 0,
771
+ pendingStaffCount: 0,
772
+ // Deleted rather than set to `{}`: a merge leaves an existing map's
773
+ // keys in place when handed an empty one.
774
+ pendingOwnerTally: FieldValue.delete(),
775
+ pendingStaffTally: FieldValue.delete(),
776
+ ownerCount: 0,
777
+ staffCount: 0,
778
+ windowStartedAtMs: 0,
779
+ lastDigestAtMs: nowMs
780
+ }, {
781
+ merge: true
782
+ });
783
+ return {
784
+ owners: (_snapshot_get3 = snapshot.get('pendingOwnerTally')) != null ? _snapshot_get3 : {},
785
+ staff: (_snapshot_get4 = snapshot.get('pendingStaffTally')) != null ? _snapshot_get4 : {},
786
+ ownerCount,
787
+ staffCount
788
+ };
789
+ });
790
+ if (!taken) return false;
791
+ const [scope, id] = [
792
+ ledgerId.slice(0, ledgerId.indexOf(':')),
793
+ ledgerId.slice(ledgerId.indexOf(':') + 1)
794
+ ];
795
+ const orgId = scope === 'org' ? id : null;
796
+ const summarize = (tally, count)=>{
797
+ const entries = Object.entries(tally).sort((a, b)=>b[1] - a[1]);
798
+ const lines = entries.slice(0, RISK_NOTICE_BURST.digestListMax).map(([title, n])=>`• ${title}${n > 1 ? ` (${n})` : ''}`);
799
+ const listed = entries.slice(0, RISK_NOTICE_BURST.digestListMax).reduce((total, [, n])=>total + n, 0);
800
+ if (count > listed) lines.push(`• …and ${count - listed} more`);
801
+ return lines.join('\n');
802
+ };
803
+ if (taken.ownerCount > 0) {
804
+ var _context_orgName;
805
+ const context = orgId ? await readContext(deps.firestore, {
806
+ kind: 'email-held',
807
+ orgId
808
+ }) : {
809
+ orgName: null,
810
+ orgSlug: null,
811
+ siteManagerUids: [],
812
+ orgId: null,
813
+ hostSubdomain: null
814
+ };
815
+ const recipients = scope === 'user' ? {
816
+ uids: [
817
+ id
818
+ ],
819
+ emails: {}
820
+ } : orgId ? await ownerRecipients(deps, {
821
+ kind: 'email-held',
822
+ orgId
823
+ }, []) : {
824
+ uids: [],
825
+ emails: {}
826
+ };
827
+ const title = `Summary: more items on ${(_context_orgName = context.orgName) != null ? _context_orgName : 'your workspace'} were held or flagged`;
828
+ const summary = summarize(taken.owners, taken.ownerCount);
829
+ if (recipients.uids.length) {
830
+ var _context_orgName1, _abuseReportContactEmail;
831
+ await deps.notifyUsers(recipients.uids, _extends({
832
+ type: RISK_NOTICE_NOTIFICATION_TYPE,
833
+ title,
834
+ body: summary.slice(0, 1000),
835
+ link: riskHoldsPath()
836
+ }, orgId ? {
837
+ orgId
838
+ } : {}), {
839
+ emails: recipients.emails
840
+ });
841
+ const holdsPath = emailPath(riskHoldsPath(), {
842
+ orgSlug: context.orgSlug,
843
+ hostId: null,
844
+ hostSubdomain: null
845
+ });
846
+ const rendered = await renderRiskNoticeEmail(RISK_NOTICE_DIGEST_EMAIL_KEY, {
847
+ 'notice.title': title,
848
+ 'notice.summary': renderRiskNoticeText('In the last hour, more items on {{workspace.name}} were held or flagged than we send one by one:', {
849
+ 'workspace.name': context.orgName
850
+ }),
851
+ 'notice.meaning': summary,
852
+ 'notice.steps': numbered([
853
+ 'Open Holds & reviews to see each item, its status and what to do.',
854
+ 'If any of them is a mistake, choose Request a review on it.'
855
+ ]),
856
+ 'notice.actions': holdsPath && absolute(holdsPath) ? `View holds and reviews: ${absolute(holdsPath)}` : '',
857
+ 'holds.url': holdsPath ? absolute(holdsPath) : '',
858
+ 'help.url': riskNoticeHelpUrl(),
859
+ reference: '',
860
+ 'workspace.name': (_context_orgName1 = context.orgName) != null ? _context_orgName1 : ''
861
+ });
862
+ await emailPeople(deps, recipients, rendered, (_abuseReportContactEmail = abuseReportContactEmail('support')) != null ? _abuseReportContactEmail : '');
863
+ }
864
+ }
865
+ if (taken.staffCount > 0) {
866
+ await deps.notifyStaff({
867
+ type: 'system.abuseReportUrgent',
868
+ title: `Digest: ${taken.staffCount} more risk alerts on ${ledgerId}`,
869
+ body: summarize(taken.staff, taken.staffCount).slice(0, 1000),
870
+ link: '/admin/abuse-reports'
871
+ });
872
+ }
873
+ return true;
874
+ }
875
+ /**
876
+ * Send every digest whose hour has closed (the hourly cron). Returns how
877
+ * many went out. Never throws.
878
+ */ export async function flushRiskNoticeDigests(overrides = {}) {
879
+ const deps = resolveDeps(overrides);
880
+ const cutoff = deps.nowMs() - RISK_NOTICE_BURST.windowMs;
881
+ let flushed = 0;
882
+ let failed = 0;
883
+ try {
884
+ const snapshot = await deps.firestore.collection(RISK_NOTICE_LEDGER_COLLECTION).where('windowStartedAtMs', '<=', cutoff).where('windowStartedAtMs', '>', 0).limit(200).get();
885
+ for (const doc of snapshot.docs){
886
+ try {
887
+ if (await flushLedger(deps, doc.id)) flushed += 1;
888
+ } catch (error) {
889
+ failed += 1;
890
+ console.error('[risk-notice] a digest could not be sent', error);
891
+ }
892
+ }
893
+ } catch (error) {
894
+ console.error('[risk-notice] the digest sweep failed', error);
895
+ failed += 1;
896
+ }
897
+ return {
898
+ flushed,
899
+ failed
900
+ };
901
+ }
902
+ /**
903
+ * The closing notice for a row staff decided: the kind the row's opening
904
+ * notice names in `closesWith`, sent to the same people about the same item.
905
+ * Silent (returns null) for a row with no risk notice or a kind that does
906
+ * not close. Never throws.
907
+ */ export async function closeRiskNotice(input, overrides = {}) {
908
+ try {
909
+ var _row_get, _row_get1, _row_get2, _stamp_itemPath;
910
+ var _RISK_NOTICE_CATALOG_stamp_kind_closesWith;
911
+ const deps = resolveDeps(overrides);
912
+ const row = await deps.firestore.collection(ABUSE_REPORT_COLLECTION).doc(input.reviewId).get();
913
+ const stamp = row.get('riskNotice');
914
+ if (!(stamp == null ? void 0 : stamp.kind) || !isRiskEventKind(stamp.kind)) return null;
915
+ const closing = (_RISK_NOTICE_CATALOG_stamp_kind_closesWith = RISK_NOTICE_CATALOG[stamp.kind].closesWith) == null ? void 0 : _RISK_NOTICE_CATALOG_stamp_kind_closesWith[input.decision];
916
+ if (!closing) return null;
917
+ return await notifyRiskEvent({
918
+ kind: closing,
919
+ orgId: (_row_get = row.get('orgId')) != null ? _row_get : null,
920
+ hostId: (_row_get1 = row.get('hostId')) != null ? _row_get1 : null,
921
+ reviewId: input.reviewId,
922
+ reference: (_row_get2 = row.get('reference')) != null ? _row_get2 : null,
923
+ item: stamp.itemLabel ? {
924
+ label: stamp.itemLabel,
925
+ path: (_stamp_itemPath = stamp.itemPath) != null ? _stamp_itemPath : null
926
+ } : null,
927
+ dedupeKey: `${closing}:${input.reviewId}`
928
+ }, overrides);
929
+ } catch (error) {
930
+ console.error('[risk-notice] the closing notice failed', error);
931
+ return null;
932
+ }
933
+ }
934
+ /**
935
+ * An owner or admin asks for a review of a held or flagged item.
936
+ *
937
+ * APPENDS to the item's existing abuse-queue row — never a new row, and
938
+ * never a decision: the row's `status` and its `heldSend` are not written
939
+ * here, so no request can release a hold or lift a lock. Staff see the
940
+ * request as a note on the row, and are notified with a link to it.
941
+ */ export async function requestRiskReview(input, overrides = {}) {
942
+ var _input_note, _notice_get;
943
+ const deps = resolveDeps(overrides);
944
+ const note = String((_input_note = input.note) != null ? _input_note : '').trim().slice(0, RISK_REVIEW_NOTE_MAX);
945
+ if (note.length < 10) {
946
+ return {
947
+ ok: false,
948
+ status: 400,
949
+ error: 'Tell us a little about it — at least a sentence.'
950
+ };
951
+ }
952
+ const notice = await deps.firestore.collection(RISK_NOTICE_COLLECTION).doc(input.noticeId).get();
953
+ if (!notice.exists || notice.get('orgId') !== input.orgId) {
954
+ return {
955
+ ok: false,
956
+ status: 404,
957
+ error: 'No such notice on this workspace.'
958
+ };
959
+ }
960
+ const kind = notice.get('kind');
961
+ const reviewId = String((_notice_get = notice.get('reviewId')) != null ? _notice_get : '');
962
+ if (!isRiskEventKind(kind) || !RISK_NOTICE_CATALOG[kind].reviewable || !reviewId) {
963
+ return {
964
+ ok: false,
965
+ status: 409,
966
+ error: 'This notice has nothing to review. Contact support instead.'
967
+ };
968
+ }
969
+ const nowMs = deps.nowMs();
970
+ const rowRef = deps.firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId);
971
+ const outcome = await deps.firestore.runTransaction(async (transaction)=>{
972
+ var _last_atMs, _row_get;
973
+ const row = await transaction.get(rowRef);
974
+ if (!row.exists || row.get('orgId') !== input.orgId) return 'missing';
975
+ const requests = Array.isArray(row.get('ownerReviewRequests')) ? row.get('ownerReviewRequests') : [];
976
+ const last = requests[requests.length - 1];
977
+ if (last && nowMs - Number((_last_atMs = last.atMs) != null ? _last_atMs : 0) < RISK_REVIEW_REQUEST_COOLDOWN_MS) {
978
+ return 'cooldown';
979
+ }
980
+ const entry = {
981
+ atMs: nowMs,
982
+ uid: input.uid,
983
+ email: input.email,
984
+ note,
985
+ noticeId: input.noticeId
986
+ };
987
+ // Only these three fields. Never `status`, never `heldSend`.
988
+ transaction.set(rowRef, {
989
+ ownerReviewRequests: [
990
+ ...requests,
991
+ entry
992
+ ].slice(-RISK_REVIEW_REQUESTS_MAX),
993
+ reviewRequestedAtMs: nowMs,
994
+ updatedAt: FieldValue.serverTimestamp()
995
+ }, {
996
+ merge: true
997
+ });
998
+ return {
999
+ reference: (_row_get = row.get('reference')) != null ? _row_get : null
1000
+ };
1001
+ });
1002
+ if (outcome === 'missing') {
1003
+ return {
1004
+ ok: false,
1005
+ status: 404,
1006
+ error: 'The case for this notice is gone. Contact support instead.'
1007
+ };
1008
+ }
1009
+ if (outcome === 'cooldown') {
1010
+ return {
1011
+ ok: false,
1012
+ status: 429,
1013
+ error: 'We already have a request on this. You can add another in a few minutes.'
1014
+ };
1015
+ }
1016
+ await deps.notifyStaff({
1017
+ type: 'system.abuseReportUrgent',
1018
+ title: `Review requested${outcome.reference ? ` — ${outcome.reference}` : ''}`,
1019
+ body: `A workspace owner or admin asked for a review: "${note.slice(0, 280)}"`,
1020
+ link: staffAbuseRowPath(reviewId)
1021
+ });
1022
+ // The person who asked is told it arrived — account mail, platform brand.
1023
+ if (input.email) {
1024
+ var _outcome_reference, _abuseReportContactEmail;
1025
+ const rendered = await renderRiskNoticeEmail(RISK_REVIEW_REQUESTED_EMAIL_KEY, {
1026
+ 'notice.title': 'We received your review request',
1027
+ 'notice.summary': 'Your request reached our review team' + (outcome.reference ? ` under the reference ${outcome.reference}` : '') + '. A person reads every request.',
1028
+ 'notice.meaning': 'Nothing changes until the review is done; you will get a notice either way.',
1029
+ 'notice.steps': numbered([
1030
+ 'Nothing more is needed.',
1031
+ 'You can follow the item\'s status on Holds & reviews.'
1032
+ ]),
1033
+ reference: (_outcome_reference = outcome.reference) != null ? _outcome_reference : '',
1034
+ 'help.url': riskNoticeHelpUrl('requesting-a-review')
1035
+ });
1036
+ await emailPeople(deps, {
1037
+ uids: [
1038
+ input.uid
1039
+ ],
1040
+ emails: {
1041
+ [input.uid]: input.email
1042
+ }
1043
+ }, rendered, (_abuseReportContactEmail = abuseReportContactEmail('support')) != null ? _abuseReportContactEmail : '').catch(()=>undefined);
1044
+ }
1045
+ return {
1046
+ ok: true,
1047
+ reference: outcome.reference,
1048
+ requestedAtMs: nowMs
1049
+ };
1050
+ }
1051
+ /**
1052
+ * A page notice's facts from its stored subject, or — for a notice written
1053
+ * before notices carried one — from its label and item path. Pure.
1054
+ */ export function ownerPageHoldFacts(kind, notice) {
1055
+ var _notice_ownerValues, _notice_actionParams;
1056
+ if (!isPageHoldNoticeKind(kind)) return null;
1057
+ const subject = notice.page;
1058
+ if (subject && typeof subject === 'object' && subject.screenId) {
1059
+ return {
1060
+ label: heldPageLabel(subject),
1061
+ details: heldPageDetails(subject),
1062
+ visitorSentence: heldPageVisitorSentence(subject),
1063
+ targets: heldPageTargets(subject)
1064
+ };
1065
+ }
1066
+ const values = (_notice_ownerValues = notice.ownerValues) != null ? _notice_ownerValues : {};
1067
+ const params = (_notice_actionParams = notice.actionParams) != null ? _notice_actionParams : {};
1068
+ const parsed = parseHeldPageItemPath(params.itemPath);
1069
+ return {
1070
+ label: values['item.label'] || 'a page on your site',
1071
+ details: [],
1072
+ visitorSentence: kind === 'page-flagged' ? heldPageVisitorSentence({
1073
+ visitorView: 'live'
1074
+ }) : 'Visitors see the previous version of this page, or a not-found page if it was never published, until the review is done.',
1075
+ targets: parsed ? [
1076
+ {
1077
+ type: 'screen',
1078
+ id: parsed.screenId
1079
+ }
1080
+ ] : []
1081
+ };
1082
+ }
1083
+ /**
1084
+ * Every page on a site that is held, flagged or not approved (AGL-3374): the
1085
+ * `riskNotices` of the two page kinds for this host, newest first — one
1086
+ * query on (`hostId`, `kind`, `createdAtMs`), which
1087
+ * `cloud/firebase-firestore.indexes.json` holds — and each notice's row for
1088
+ * its state. A released or cleared page is not listed.
1089
+ */ export async function listHostPageHolds(input, overrides = {}) {
1090
+ var _input_limit;
1091
+ const deps = resolveDeps(overrides);
1092
+ const snapshot = await deps.firestore.collection(RISK_NOTICE_COLLECTION).where('hostId', '==', input.hostId).where('kind', 'in', [
1093
+ ...PAGE_HOLD_NOTICE_KINDS
1094
+ ]).orderBy('createdAtMs', 'desc').limit(Math.min(Math.max((_input_limit = input.limit) != null ? _input_limit : 50, 1), 100)).get();
1095
+ const reviewIds = [
1096
+ ...new Set(snapshot.docs.map((doc)=>{
1097
+ var _doc_get;
1098
+ return String((_doc_get = doc.get('reviewId')) != null ? _doc_get : '');
1099
+ }).filter(Boolean))
1100
+ ];
1101
+ const rows = new Map();
1102
+ if (reviewIds.length) {
1103
+ const read = await deps.firestore.getAll(...reviewIds.map((id)=>deps.firestore.collection(ABUSE_REPORT_COLLECTION).doc(id)));
1104
+ for (const row of read)rows.set(row.id, row);
1105
+ }
1106
+ const views = [];
1107
+ const seenRows = new Set();
1108
+ for (const doc of snapshot.docs){
1109
+ var _doc_get, _doc_get1, _ref, _doc_get2;
1110
+ const kind = doc.get('kind');
1111
+ if (!isPageHoldNoticeKind(kind)) continue;
1112
+ const reviewId = String((_doc_get = doc.get('reviewId')) != null ? _doc_get : '');
1113
+ // One entry per case: a row re-notified keeps its newest notice.
1114
+ if (reviewId && seenRows.has(reviewId)) continue;
1115
+ if (reviewId) seenRows.add(reviewId);
1116
+ const row = reviewId ? rows.get(reviewId) : undefined;
1117
+ const status = (row == null ? void 0 : row.exists) ? ownerRowStatus(row) : kind === 'page-flagged' ? 'in-review' : 'held';
1118
+ if (!isOpenPageHoldStatus(status)) continue;
1119
+ const facts = ownerPageHoldFacts(kind, {
1120
+ page: doc.get('page'),
1121
+ ownerValues: doc.get('ownerValues'),
1122
+ actionParams: doc.get('actionParams')
1123
+ });
1124
+ if (!facts) continue;
1125
+ views.push(_extends({}, facts, {
1126
+ noticeId: doc.id,
1127
+ kind,
1128
+ status,
1129
+ chip: pageHoldChip(kind, status),
1130
+ reference: (_doc_get1 = doc.get('reference')) != null ? _doc_get1 : null,
1131
+ occurredAtMs: Number((_ref = (_doc_get2 = doc.get('occurredAtMs')) != null ? _doc_get2 : doc.get('createdAtMs')) != null ? _ref : 0),
1132
+ reviewable: RISK_NOTICE_CATALOG[kind].reviewable && Boolean(row == null ? void 0 : row.exists),
1133
+ reviewRequestedAtMs: typeof (row == null ? void 0 : row.get('reviewRequestedAtMs')) === 'number' ? row.get('reviewRequestedAtMs') : null
1134
+ }));
1135
+ }
1136
+ return views;
1137
+ }
1138
+ /**
1139
+ * The workspace's notices, newest first, rendered for its owners: the
1140
+ * catalog's owner half only, and the row's decision state — never its
1141
+ * evidence, never its staff notes.
1142
+ */ export async function listOwnerRiskNotices(input, overrides = {}) {
1143
+ var _input_limit, _org_get;
1144
+ const deps = resolveDeps(overrides);
1145
+ const snapshot = await deps.firestore.collection(RISK_NOTICE_COLLECTION).where('orgId', '==', input.orgId).orderBy('createdAtMs', 'desc').limit(Math.min(Math.max((_input_limit = input.limit) != null ? _input_limit : 50, 1), 100)).get();
1146
+ const reviewIds = [
1147
+ ...new Set(snapshot.docs.map((doc)=>{
1148
+ var _doc_get;
1149
+ return String((_doc_get = doc.get('reviewId')) != null ? _doc_get : '');
1150
+ }).filter(Boolean))
1151
+ ];
1152
+ const rows = new Map();
1153
+ if (reviewIds.length) {
1154
+ const read = await deps.firestore.getAll(...reviewIds.map((id)=>deps.firestore.collection(ABUSE_REPORT_COLLECTION).doc(id)));
1155
+ for (const row of read)rows.set(row.id, row);
1156
+ }
1157
+ // The reader's own console paths: the org's slug and each site's
1158
+ // subdomain, read once for the page rather than per notice.
1159
+ const org = await deps.firestore.collection('orgs').doc(input.orgId).get();
1160
+ const orgSlug = String((_org_get = org.get('slug')) != null ? _org_get : '') || null;
1161
+ const hostIds = [
1162
+ ...new Set(snapshot.docs.map((doc)=>{
1163
+ var _doc_get;
1164
+ return String((_doc_get = doc.get('hostId')) != null ? _doc_get : '');
1165
+ }).filter(Boolean))
1166
+ ];
1167
+ const subdomains = new Map();
1168
+ if (hostIds.length) {
1169
+ var _host_get;
1170
+ const hosts = await deps.firestore.getAll(...hostIds.map((id)=>deps.firestore.collection('hosts').doc(id)));
1171
+ for (const host of hosts)subdomains.set(host.id, String((_host_get = host.get('subdomain')) != null ? _host_get : '') || null);
1172
+ }
1173
+ const views = [];
1174
+ for (const doc of snapshot.docs){
1175
+ var _doc_get, _doc_get1, _doc_get2, _doc_get3, _ref, _doc_get4;
1176
+ const kind = doc.get('kind');
1177
+ if (!isRiskEventKind(kind)) continue;
1178
+ const values = (_doc_get = doc.get('ownerValues')) != null ? _doc_get : {};
1179
+ const params = (_doc_get1 = doc.get('actionParams')) != null ? _doc_get1 : {};
1180
+ const definition = RISK_NOTICE_CATALOG[kind];
1181
+ const copy = renderOwnerRiskNotice(kind, values);
1182
+ const reviewId = String((_doc_get2 = doc.get('reviewId')) != null ? _doc_get2 : '');
1183
+ const row = reviewId ? rows.get(reviewId) : undefined;
1184
+ const requests = row && Array.isArray(row.get('ownerReviewRequests')) ? row.get('ownerReviewRequests') : [];
1185
+ views.push(_extends({
1186
+ noticeId: doc.id,
1187
+ kind,
1188
+ severity: definition.severity
1189
+ }, copy, {
1190
+ actions: resolveOwnerRiskActions(kind, _extends({}, params, {
1191
+ noticeId: doc.id
1192
+ })).flatMap((action)=>{
1193
+ var _doc_get, _subdomains_get;
1194
+ const hostId = String((_doc_get = doc.get('hostId')) != null ? _doc_get : '') || null;
1195
+ const href = emailPath(action.href, {
1196
+ orgSlug,
1197
+ hostId,
1198
+ hostSubdomain: hostId ? (_subdomains_get = subdomains.get(hostId)) != null ? _subdomains_get : null : null
1199
+ });
1200
+ return href ? [
1201
+ _extends({}, action, {
1202
+ href
1203
+ })
1204
+ ] : [];
1205
+ }),
1206
+ reference: (_doc_get3 = doc.get('reference')) != null ? _doc_get3 : null,
1207
+ occurredAtMs: Number((_ref = (_doc_get4 = doc.get('occurredAtMs')) != null ? _doc_get4 : doc.get('createdAtMs')) != null ? _ref : 0),
1208
+ helpUrl: riskNoticeHelpUrl(definition.helpAnchor),
1209
+ status: (row == null ? void 0 : row.exists) ? ownerRowStatus(row) : null,
1210
+ reviewable: definition.reviewable && Boolean(row == null ? void 0 : row.exists),
1211
+ reviewRequests: requests.filter((request)=>request.noticeId === doc.id).map((request)=>{
1212
+ var _request_atMs, _request_note;
1213
+ return {
1214
+ atMs: Number((_request_atMs = request.atMs) != null ? _request_atMs : 0),
1215
+ note: String((_request_note = request.note) != null ? _request_note : ''),
1216
+ mine: request.uid === input.viewerUid
1217
+ };
1218
+ }),
1219
+ page: ownerPageHoldFacts(kind, {
1220
+ page: doc.get('page'),
1221
+ ownerValues: values,
1222
+ actionParams: params
1223
+ })
1224
+ }));
1225
+ }
1226
+ return views;
1227
+ }
1228
+ /** A row's state in the owner's words. Pure. */ export function ownerRowStatus(row) {
1229
+ var _row_get;
1230
+ const held = row.get('heldSend');
1231
+ if ((held == null ? void 0 : held.state) === 'released') return 'released';
1232
+ if ((held == null ? void 0 : held.state) === 'rejected') return 'rejected';
1233
+ const status = String((_row_get = row.get('status')) != null ? _row_get : 'open');
1234
+ if (status === 'dismissed') return held ? 'released' : 'closed';
1235
+ if (status === 'actioned') return held ? 'rejected' : 'closed';
1236
+ if ((held == null ? void 0 : held.state) === 'held') return 'held';
1237
+ return 'in-review';
1238
+ }
1239
+
1240
+ //# sourceMappingURL=risk-notice.js.map