@aglyn/tenant-data-admin 1.0.0-beta.203 → 1.0.0-beta.204
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/capture-email-check.d.ts +109 -0
- package/src/lib/server/capture-email-check.js +362 -0
- package/src/lib/server/capture-email-check.js.map +1 -0
- package/src/lib/server/card-payment-velocity.d.ts +11 -16
- package/src/lib/server/card-payment-velocity.js +24 -28
- package/src/lib/server/card-payment-velocity.js.map +1 -1
- package/src/lib/server/email-deliverability.d.ts +19 -3
- package/src/lib/server/email-deliverability.js +34 -16
- package/src/lib/server/email-deliverability.js.map +1 -1
- package/src/lib/server/held-page-subject.d.ts +93 -0
- package/src/lib/server/held-page-subject.js +206 -0
- package/src/lib/server/held-page-subject.js.map +1 -0
- package/src/lib/server/host-email-tokens.d.ts +42 -1
- package/src/lib/server/host-email-tokens.js +100 -12
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-visitor-records.js +21 -5
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +16 -5
- package/src/lib/server/hosted-page-review.js +242 -61
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/list-members.js +16 -0
- package/src/lib/server/list-members.js.map +1 -1
- package/src/lib/server/outbound-send-review.d.ts +22 -0
- package/src/lib/server/outbound-send-review.js +74 -6
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/payment-fraud-signal.d.ts +20 -12
- package/src/lib/server/payment-fraud-signal.js +41 -11
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/payment-risk-record.d.ts +37 -13
- package/src/lib/server/payment-risk-record.js +23 -8
- package/src/lib/server/payment-risk-record.js.map +1 -1
- package/src/lib/server/render-system-email.d.ts +3 -0
- package/src/lib/server/render-system-email.js +39 -11
- package/src/lib/server/render-system-email.js.map +1 -1
- package/src/lib/server/risk-notice.d.ts +327 -0
- package/src/lib/server/risk-notice.js +1240 -0
- package/src/lib/server/risk-notice.js.map +1 -0
- package/src/lib/server/sender-readiness.d.ts +36 -0
- package/src/lib/server/sender-readiness.js +99 -0
- package/src/lib/server/sender-readiness.js.map +1 -0
- package/src/lib/server/test-firestore.js +1 -0
- package/src/lib/server/test-firestore.js.map +1 -1
- package/src/lib/server/upsert-contact.js +19 -3
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +2 -0
- package/src/tenancy.js +3 -0
- 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
|