@aglyn/tenant-data-admin 1.0.0-beta.261 → 1.0.0-beta.263
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/lib/server/figure-readers.d.ts +44 -0
- package/src/lib/server/figure-readers.js +45 -0
- package/src/lib/server/figure-readers.js.map +1 -0
- package/src/lib/server/keep-alive.d.ts +18 -0
- package/src/lib/server/keep-alive.js +59 -0
- package/src/lib/server/keep-alive.js.map +1 -0
- package/src/lib/server/mobile-push-switch.js +17 -4
- package/src/lib/server/mobile-push-switch.js.map +1 -1
- package/src/lib/server/native-push.js +6 -3
- package/src/lib/server/native-push.js.map +1 -1
- package/src/lib/server/notifications.d.ts +16 -5
- package/src/lib/server/notifications.js +30 -9
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/operator-alerts.d.ts +7 -0
- package/src/lib/server/operator-alerts.js +6 -3
- package/src/lib/server/operator-alerts.js.map +1 -1
- package/src/lib/server/operator-health.d.ts +69 -0
- package/src/lib/server/operator-health.js +87 -0
- package/src/lib/server/operator-health.js.map +1 -1
- package/src/lib/server/push-apns.js +3 -1
- package/src/lib/server/push-apns.js.map +1 -1
- package/src/lib/server/push-delivery.d.ts +9 -0
- package/src/lib/server/push-delivery.js +10 -0
- package/src/lib/server/push-delivery.js.map +1 -1
- package/src/lib/server/render-system-email.d.ts +7 -1
- package/src/lib/server/render-system-email.js +1 -1
- package/src/lib/server/render-system-email.js.map +1 -1
- package/src/lib/server/sso-domain-policy.d.ts +43 -7
- package/src/lib/server/sso-domain-policy.js +61 -9
- package/src/lib/server/sso-domain-policy.js.map +1 -1
- package/src/lib/server/weekly-summary.d.ts +160 -0
- package/src/lib/server/weekly-summary.js +568 -0
- package/src/lib/server/weekly-summary.js.map +1 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
+
import { buildRoute, Route } from "@aglyn/aglyn/app-utils/console-routes";
|
|
18
|
+
import { writeCronBeat } from "@aglyn/aglyn/app-utils/health-report";
|
|
19
|
+
import { DIGEST_PREFS_FIELD, digestEnabled, INSIGHT_DIGESTS_FIELD, insightDigestSubscribed } from "@aglyn/aglyn/app-utils/notifications";
|
|
20
|
+
import { liveCustomDomain, TENANT_APEX } from "@aglyn/aglyn/app-utils/host-naming";
|
|
21
|
+
import { hostRoleFor } from "@aglyn/aglyn/app-utils/organizations";
|
|
22
|
+
import { checkEntitlement, resolveBrandingProfile, resolveEffectivePlan } from "@aglyn/aglyn/app-utils/plan-entitlements";
|
|
23
|
+
import { platformConsoleOrigin } from "@aglyn/aglyn/app-utils/platform-brand";
|
|
24
|
+
import { isReleaseFlagOnForOrg, parseOrgReleaseFlagOverrides } from "@aglyn/aglyn/app-utils/release-flags";
|
|
25
|
+
import { composeWeeklySummaryOverviewEmail, composeWeeklySummarySiteEmail, WEEKLY_SUMMARY_DAYS, WEEKLY_SUMMARY_DIGEST_KEY, weeklySummaryHasNews, weeklySummaryPeriod } from "@aglyn/aglyn/app-utils/weekly-summary";
|
|
26
|
+
import { normalizePluginFigureTable, pluginFigureSummarySection } from "@aglyn/aglyn/plugin-manager/plugin-figures";
|
|
27
|
+
import { emailAccent, PLATFORM_EMAIL_PALETTE, rateLimitedRetryAtMs, resolveEmailColor, sendEmail } from "@aglyn/shared-util-email";
|
|
28
|
+
import { withoutMailWithheldAccounts } from "./account-mail.js";
|
|
29
|
+
import { findUserByUidAcrossPools } from "./auth-pools.js";
|
|
30
|
+
import { isCronAuthorized, isCronDryRun } from "./cron-auth.js";
|
|
31
|
+
import { meterPlatformEmail } from "./email-metering.js";
|
|
32
|
+
import { filterSuppressedEmails } from "./email-suppression.js";
|
|
33
|
+
import { eligibleFigureReaders } from "./figure-readers.js";
|
|
34
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
35
|
+
import { listOrgMembers } from "./organizations.js";
|
|
36
|
+
import { getServerReleaseFlagValues } from "./release-flags.js";
|
|
37
|
+
import { loadSystemEmail, renderLoadedSystemEmail, systemEmailBrand, systemEmailContent, systemEmailTheme } from "./render-system-email.js";
|
|
38
|
+
/** The beat `/api/health/crons` reads, once this job joins its inventory. */ export const WEEKLY_SUMMARY_JOB_ID = 'weekly-summary';
|
|
39
|
+
/** `orgs/{orgId}/weeklySummaries/{week}` — server-written, matched by no rule. */ export const WEEKLY_SUMMARY_MARKERS = 'weeklySummaries';
|
|
40
|
+
/** The marker key of the overview; a site's email is keyed by the site's id. */ export const WEEKLY_SUMMARY_OVERVIEW = 'overview';
|
|
41
|
+
/** The system emails: the overview, and one site's. */ export const WEEKLY_SUMMARY_TEMPLATE = 'weekly-summary';
|
|
42
|
+
export const WEEKLY_SUMMARY_SITE_TEMPLATE = 'weekly-summary-site';
|
|
43
|
+
/** Workspaces per invocation; a chunk that times out never advances its cursor. */ export const WEEKLY_SUMMARY_ORG_CHUNK = 20;
|
|
44
|
+
/** Sites a workspace's sweep ranks, by document id. */ export const WEEKLY_SUMMARY_HOST_READ_LIMIT = 50;
|
|
45
|
+
/** Sites a week's summary reports on: the busiest by last week's page views. */ export const WEEKLY_SUMMARY_MAX_SITES = 5;
|
|
46
|
+
/** Members per workspace whose address is chased through the auth pools when their member document has none. */ export const WEEKLY_SUMMARY_MAX_UID_LOOKUPS = 5;
|
|
47
|
+
/** How long one call works before it stops and leaves the rest to the next. */ export const WEEKLY_SUMMARY_RUN_BUDGET_MS = 45000;
|
|
48
|
+
/** The theme tokens each color of the layout is drawn from. */ const COLOR_TOKENS = {
|
|
49
|
+
text: 'text.primary',
|
|
50
|
+
muted: 'text.secondary',
|
|
51
|
+
border: 'divider',
|
|
52
|
+
surface: 'grey.50',
|
|
53
|
+
accent: 'primary.main',
|
|
54
|
+
link: 'primary.main',
|
|
55
|
+
up: 'success.dark',
|
|
56
|
+
down: 'error.dark'
|
|
57
|
+
};
|
|
58
|
+
const str = (value)=>typeof value === 'string' ? value.trim() : '';
|
|
59
|
+
function siteName(data, id) {
|
|
60
|
+
return str(data['displayName']) || str(data['name']) || str(data['subdomain']) || id;
|
|
61
|
+
}
|
|
62
|
+
/** Last week's page views for each site, read from the daily analytics documents. */ async function weekViews(firestore, hosts, days) {
|
|
63
|
+
const views = new Map();
|
|
64
|
+
if (!hosts.length) return views;
|
|
65
|
+
const refs = hosts.flatMap((host)=>days.map((day)=>host.ref.collection('analytics').doc(day)));
|
|
66
|
+
const docs = await firestore.getAll(...refs);
|
|
67
|
+
for (const doc of docs){
|
|
68
|
+
var _ref, _views_get, _doc_get;
|
|
69
|
+
var _doc_ref_parent_parent;
|
|
70
|
+
const hostId = (_ref = (_doc_ref_parent_parent = doc.ref.parent.parent) == null ? void 0 : _doc_ref_parent_parent.id) != null ? _ref : '';
|
|
71
|
+
views.set(hostId, ((_views_get = views.get(hostId)) != null ? _views_get : 0) + (Number((_doc_get = doc.get('total')) != null ? _doc_get : 0) || 0));
|
|
72
|
+
}
|
|
73
|
+
return views;
|
|
74
|
+
}
|
|
75
|
+
function weekDays(from) {
|
|
76
|
+
const start = Date.parse(`${from}T00:00:00Z`);
|
|
77
|
+
return Array.from({
|
|
78
|
+
length: WEEKLY_SUMMARY_DAYS
|
|
79
|
+
}, (_, index)=>new Date(start + index * 86400000).toISOString().slice(0, 10));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Each reader's part, in its declared order: read over the week, then
|
|
83
|
+
* summarized by its owner. A reader that fails, answers no table, has nothing
|
|
84
|
+
* to say or cannot be read over seven days is left out.
|
|
85
|
+
*/ async function readParts(readers, base) {
|
|
86
|
+
const ordered = readers.filter(({ reader })=>reader.weeklySummary).sort((a, b)=>{
|
|
87
|
+
var _ref, _ref1;
|
|
88
|
+
var _a_reader_weeklySummary, _b_reader_weeklySummary;
|
|
89
|
+
return ((_ref = (_a_reader_weeklySummary = a.reader.weeklySummary) == null ? void 0 : _a_reader_weeklySummary.order) != null ? _ref : 0) - ((_ref1 = (_b_reader_weeklySummary = b.reader.weeklySummary) == null ? void 0 : _b_reader_weeklySummary.order) != null ? _ref1 : 0) || a.reader.id.localeCompare(b.reader.id);
|
|
90
|
+
});
|
|
91
|
+
const parts = [];
|
|
92
|
+
for (const { reader } of ordered){
|
|
93
|
+
const summary = reader.weeklySummary;
|
|
94
|
+
if (!summary) continue;
|
|
95
|
+
const days = reader.windows.length ? reader.windows.includes(WEEKLY_SUMMARY_DAYS) ? WEEKLY_SUMMARY_DAYS : null : 0;
|
|
96
|
+
if (days === null) continue;
|
|
97
|
+
try {
|
|
98
|
+
var _part_stats, _part_rows;
|
|
99
|
+
const read = await reader.read(_extends({}, base, {
|
|
100
|
+
days,
|
|
101
|
+
uid: null,
|
|
102
|
+
params: {}
|
|
103
|
+
}));
|
|
104
|
+
if (read.ok === false) continue;
|
|
105
|
+
const table = normalizePluginFigureTable(read.table);
|
|
106
|
+
if (!table) continue;
|
|
107
|
+
const part = summary.summarize(table);
|
|
108
|
+
if (part && (((_part_stats = part.stats) == null ? void 0 : _part_stats.length) || ((_part_rows = part.rows) == null ? void 0 : _part_rows.length))) parts.push(part);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error('[weekly-summary] reader failed', {
|
|
111
|
+
reader: reader.id,
|
|
112
|
+
orgId: base.orgId,
|
|
113
|
+
error
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return parts;
|
|
118
|
+
}
|
|
119
|
+
/** The emails one person is owed this week, by marker key, in the order they are sent. */ function emailsFor(member, manager, sites, workspace) {
|
|
120
|
+
const active = sites.filter(({ site })=>weeklySummaryHasNews(site));
|
|
121
|
+
if (!manager) {
|
|
122
|
+
return active.filter(({ hostId })=>hostRoleFor(member, hostId) === 'admin').map(({ hostId })=>({
|
|
123
|
+
key: hostId,
|
|
124
|
+
hostId,
|
|
125
|
+
withWorkspace: false
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
// One active site: its email carries the workspace's figures, and an
|
|
129
|
+
// overview would only say it again.
|
|
130
|
+
if (active.length === 1) {
|
|
131
|
+
return [
|
|
132
|
+
{
|
|
133
|
+
key: active[0].hostId,
|
|
134
|
+
hostId: active[0].hostId,
|
|
135
|
+
withWorkspace: true
|
|
136
|
+
}
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
return [
|
|
140
|
+
...active.length || workspace.length ? [
|
|
141
|
+
{
|
|
142
|
+
key: WEEKLY_SUMMARY_OVERVIEW,
|
|
143
|
+
hostId: null,
|
|
144
|
+
withWorkspace: false
|
|
145
|
+
}
|
|
146
|
+
] : [],
|
|
147
|
+
...active.map(({ hostId })=>({
|
|
148
|
+
key: hostId,
|
|
149
|
+
hostId,
|
|
150
|
+
withWorkspace: false
|
|
151
|
+
}))
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
async function summarizeOrg(deps, orgDoc, period, overBudget) {
|
|
155
|
+
var _orgDoc_data, _get, _sites_find;
|
|
156
|
+
const { firestore } = deps;
|
|
157
|
+
const orgId = orgDoc.id;
|
|
158
|
+
const org = (_orgDoc_data = orgDoc.data()) != null ? _orgDoc_data : {};
|
|
159
|
+
const report = {
|
|
160
|
+
sites: 0,
|
|
161
|
+
recipients: 0,
|
|
162
|
+
sent: 0
|
|
163
|
+
};
|
|
164
|
+
const markerRef = orgDoc.ref.collection(WEEKLY_SUMMARY_MARKERS).doc(period.week);
|
|
165
|
+
const sentAlready = (_get = (await markerRef.get()).get('sent')) != null ? _get : {};
|
|
166
|
+
// Who could be told: owners and admins, and each site's own admins. Not
|
|
167
|
+
// suspended, an account that may be mailed, the switch kept on.
|
|
168
|
+
const members = (await deps.orgMembers(orgId)).filter((member)=>member.orgSuspended !== true);
|
|
169
|
+
const isManager = (member)=>member.role === 'owner' || member.role === 'admin';
|
|
170
|
+
const candidates = members.filter((member)=>{
|
|
171
|
+
var _member_hostAccess;
|
|
172
|
+
return isManager(member) || Object.values((_member_hostAccess = member.hostAccess) != null ? _member_hostAccess : {}).includes('admin');
|
|
173
|
+
});
|
|
174
|
+
const reachable = new Set(await deps.reachable(candidates.map((member)=>member.$id)));
|
|
175
|
+
const reached = candidates.filter((member)=>reachable.has(member.$id)).sort((a, b)=>a.$id.localeCompare(b.$id));
|
|
176
|
+
if (!reached.length) return _extends({}, report, {
|
|
177
|
+
skipped: candidates.length ? 'nobody-reachable' : 'no-managers'
|
|
178
|
+
});
|
|
179
|
+
const userDocs = await firestore.getAll(...reached.map((member)=>firestore.collection('users').doc(member.$id)));
|
|
180
|
+
const users = new Map(userDocs.map((doc)=>[
|
|
181
|
+
doc.id,
|
|
182
|
+
doc
|
|
183
|
+
]));
|
|
184
|
+
const recipients = reached.filter((member)=>{
|
|
185
|
+
var _users_get;
|
|
186
|
+
return digestEnabled((_users_get = users.get(member.$id)) == null ? void 0 : _users_get.get(DIGEST_PREFS_FIELD), WEEKLY_SUMMARY_DIGEST_KEY);
|
|
187
|
+
});
|
|
188
|
+
report.recipients = recipients.length;
|
|
189
|
+
if (!recipients.length) return _extends({}, report, {
|
|
190
|
+
skipped: 'summary-off'
|
|
191
|
+
});
|
|
192
|
+
// The busiest sites by last week's page views, ties by id.
|
|
193
|
+
const hostPage = await firestore.collection('hosts').where('orgId', '==', orgId).orderBy(firebaseAdmin.firestore.FieldPath.documentId()).limit(WEEKLY_SUMMARY_HOST_READ_LIMIT).get();
|
|
194
|
+
const hosts = hostPage.docs.filter((doc)=>!doc.get('deletedAt'));
|
|
195
|
+
const views = await weekViews(firestore, hosts, weekDays(period.from));
|
|
196
|
+
const chosen = [
|
|
197
|
+
...hosts
|
|
198
|
+
].sort((a, b)=>{
|
|
199
|
+
var _views_get, _views_get1;
|
|
200
|
+
return ((_views_get = views.get(b.id)) != null ? _views_get : 0) - ((_views_get1 = views.get(a.id)) != null ? _views_get1 : 0) || a.id.localeCompare(b.id);
|
|
201
|
+
}).slice(0, WEEKLY_SUMMARY_MAX_SITES);
|
|
202
|
+
const orgSlug = str(org['slug']);
|
|
203
|
+
const sites = [];
|
|
204
|
+
for (const host of chosen){
|
|
205
|
+
var _host_data, _liveCustomDomain;
|
|
206
|
+
const data = (_host_data = host.data()) != null ? _host_data : {};
|
|
207
|
+
const readers = (await deps.readers({
|
|
208
|
+
orgId,
|
|
209
|
+
hostId: host.id,
|
|
210
|
+
org,
|
|
211
|
+
host: data
|
|
212
|
+
})).filter(({ reader })=>pluginFigureSummarySection(reader) === 'site');
|
|
213
|
+
const parts = await readParts(readers, {
|
|
214
|
+
orgId,
|
|
215
|
+
hostId: host.id,
|
|
216
|
+
now: period.now
|
|
217
|
+
});
|
|
218
|
+
const subdomain = str(data['subdomain']);
|
|
219
|
+
const domain = (_liveCustomDomain = liveCustomDomain(data)) != null ? _liveCustomDomain : subdomain ? `${subdomain}.${TENANT_APEX}` : '';
|
|
220
|
+
sites.push({
|
|
221
|
+
hostId: host.id,
|
|
222
|
+
subdomain,
|
|
223
|
+
site: {
|
|
224
|
+
name: siteName(data, host.id),
|
|
225
|
+
address: domain ? {
|
|
226
|
+
label: domain,
|
|
227
|
+
url: `https://${domain}`
|
|
228
|
+
} : null,
|
|
229
|
+
url: deps.origin && orgSlug && subdomain ? `${deps.origin}${buildRoute(Route.HOST_DASHBOARD, {
|
|
230
|
+
orgSlug,
|
|
231
|
+
host: subdomain
|
|
232
|
+
})}` : null,
|
|
233
|
+
parts
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const orgReaders = await deps.readers({
|
|
238
|
+
orgId,
|
|
239
|
+
hostId: null,
|
|
240
|
+
org,
|
|
241
|
+
host: null
|
|
242
|
+
});
|
|
243
|
+
const workspace = await readParts(orgReaders.filter(({ reader })=>pluginFigureSummarySection(reader) === 'workspace'), {
|
|
244
|
+
orgId,
|
|
245
|
+
hostId: null,
|
|
246
|
+
now: period.now
|
|
247
|
+
});
|
|
248
|
+
report.sites = sites.filter(({ site })=>weeklySummaryHasNews(site)).length;
|
|
249
|
+
if (!report.sites && !workspace.length) return _extends({}, report, {
|
|
250
|
+
skipped: 'nothing-to-report'
|
|
251
|
+
});
|
|
252
|
+
const branding = resolveBrandingProfile(org);
|
|
253
|
+
const workspaceName = str(org['name']) || orgSlug || 'your workspace';
|
|
254
|
+
const settingsUrl = `${deps.origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`;
|
|
255
|
+
const colors = deps.colors(org);
|
|
256
|
+
// AI weekly insights, pointed to only where the plan includes them and they are released.
|
|
257
|
+
const offersInsights = checkEntitlement(org, 'aiGenerative') && deps.flagOn('release_ai_generative', orgId, org);
|
|
258
|
+
const analyticsUrl = (subdomain)=>offersInsights && deps.origin && orgSlug && subdomain ? `${deps.origin}${buildRoute(Route.HOST_ANALYTICS, {
|
|
259
|
+
orgSlug,
|
|
260
|
+
host: subdomain
|
|
261
|
+
})}` : null;
|
|
262
|
+
const common = {
|
|
263
|
+
workspaceName,
|
|
264
|
+
productName: branding.productName,
|
|
265
|
+
periodLabel: period.label,
|
|
266
|
+
colors,
|
|
267
|
+
settingsUrl
|
|
268
|
+
};
|
|
269
|
+
const lookups = {
|
|
270
|
+
left: WEEKLY_SUMMARY_MAX_UID_LOOKUPS
|
|
271
|
+
};
|
|
272
|
+
const busiest = (_sites_find = sites.find(({ site })=>weeklySummaryHasNews(site))) != null ? _sites_find : sites[0];
|
|
273
|
+
for (const member of recipients){
|
|
274
|
+
var _users_get;
|
|
275
|
+
const owed = emailsFor(member, isManager(member), sites, workspace).filter(({ key })=>{
|
|
276
|
+
var _sentAlready_member_$id;
|
|
277
|
+
return !((_sentAlready_member_$id = sentAlready[member.$id]) == null ? void 0 : _sentAlready_member_$id[key]);
|
|
278
|
+
});
|
|
279
|
+
if (!owed.length) continue;
|
|
280
|
+
const subscribed = insightDigestSubscribed((_users_get = users.get(member.$id)) == null ? void 0 : _users_get.get(INSIGHT_DIGESTS_FIELD), orgId);
|
|
281
|
+
const to = deps.dryRun ? '' : await deps.address(member, lookups);
|
|
282
|
+
if (!deps.dryRun && !to) continue;
|
|
283
|
+
for (const { key, hostId, withWorkspace } of owed){
|
|
284
|
+
var _ref, _ref1;
|
|
285
|
+
if (overBudget()) return _extends({}, report, {
|
|
286
|
+
deferred: 'time'
|
|
287
|
+
});
|
|
288
|
+
const entry = hostId ? sites.find((site)=>site.hostId === hostId) : null;
|
|
289
|
+
const aiInsightsUrl = subscribed ? null : analyticsUrl((_ref = (_ref1 = entry == null ? void 0 : entry.subdomain) != null ? _ref1 : busiest == null ? void 0 : busiest.subdomain) != null ? _ref : '');
|
|
290
|
+
const email = entry ? composeWeeklySummarySiteEmail(_extends({}, common, {
|
|
291
|
+
aiInsightsUrl,
|
|
292
|
+
site: entry.site
|
|
293
|
+
}, withWorkspace ? {
|
|
294
|
+
workspace
|
|
295
|
+
} : {})) : composeWeeklySummaryOverviewEmail(_extends({}, common, {
|
|
296
|
+
aiInsightsUrl,
|
|
297
|
+
sites: sites.map(({ site })=>site),
|
|
298
|
+
workspace,
|
|
299
|
+
url: deps.origin && orgSlug ? `${deps.origin}${buildRoute(Route.ORG_HOME, {
|
|
300
|
+
orgSlug
|
|
301
|
+
})}` : ''
|
|
302
|
+
}));
|
|
303
|
+
if (!email) continue;
|
|
304
|
+
if (deps.dryRun) {
|
|
305
|
+
report.sent += 1;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const result = await deps.send({
|
|
309
|
+
to: to,
|
|
310
|
+
template: entry ? WEEKLY_SUMMARY_SITE_TEMPLATE : WEEKLY_SUMMARY_TEMPLATE,
|
|
311
|
+
email,
|
|
312
|
+
org,
|
|
313
|
+
merge: {
|
|
314
|
+
'summary.subject': email.subject,
|
|
315
|
+
'summary.title': email.title,
|
|
316
|
+
'summary.period': email.period,
|
|
317
|
+
'summary.html': email.html,
|
|
318
|
+
'summary.buttonLabel': email.buttonLabel,
|
|
319
|
+
'summary.url': email.url,
|
|
320
|
+
'org.name': workspaceName,
|
|
321
|
+
settingsUrl
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
if (result.sent) {
|
|
325
|
+
report.sent += 1;
|
|
326
|
+
await markerRef.set({
|
|
327
|
+
week: period.week,
|
|
328
|
+
sent: {
|
|
329
|
+
[member.$id]: {
|
|
330
|
+
[key]: true
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
|
|
334
|
+
}, {
|
|
335
|
+
merge: true
|
|
336
|
+
});
|
|
337
|
+
} else if (result.rateLimited) {
|
|
338
|
+
return _extends({}, report, {
|
|
339
|
+
deferred: 'rate'
|
|
340
|
+
});
|
|
341
|
+
} else {
|
|
342
|
+
var _report_failed;
|
|
343
|
+
report.failed = ((_report_failed = report.failed) != null ? _report_failed : 0) + 1;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return report;
|
|
348
|
+
}
|
|
349
|
+
/** One chunk of the sweep: workspaces in id order after `cursor`. */ export async function runWeeklySummarySweep(deps, options = {}) {
|
|
350
|
+
var _deps_budgetMs, _options_limit;
|
|
351
|
+
const { firestore } = deps;
|
|
352
|
+
const startedAt = Date.now();
|
|
353
|
+
const budget = (_deps_budgetMs = deps.budgetMs) != null ? _deps_budgetMs : WEEKLY_SUMMARY_RUN_BUDGET_MS;
|
|
354
|
+
const overBudget = ()=>Date.now() - startedAt > budget;
|
|
355
|
+
const period = weeklySummaryPeriod(deps.runAt);
|
|
356
|
+
const pageSize = Math.max(1, Math.min(Math.floor((_options_limit = options.limit) != null ? _options_limit : WEEKLY_SUMMARY_ORG_CHUNK), WEEKLY_SUMMARY_ORG_CHUNK));
|
|
357
|
+
let query = firestore.collection('orgs').orderBy(firebaseAdmin.firestore.FieldPath.documentId()).limit(pageSize + 1);
|
|
358
|
+
if (options.cursor) query = query.startAfter(firestore.collection('orgs').doc(options.cursor));
|
|
359
|
+
const page = await query.get();
|
|
360
|
+
const hasMore = page.docs.length > pageSize;
|
|
361
|
+
const orgDocs = hasMore ? page.docs.slice(0, pageSize) : page.docs;
|
|
362
|
+
const report = {
|
|
363
|
+
week: period.week,
|
|
364
|
+
dryRun: deps.dryRun,
|
|
365
|
+
swept: 0,
|
|
366
|
+
sent: 0,
|
|
367
|
+
orgs: {},
|
|
368
|
+
deferred: false,
|
|
369
|
+
nextCursor: null,
|
|
370
|
+
done: true
|
|
371
|
+
};
|
|
372
|
+
let last = null;
|
|
373
|
+
for (const orgDoc of orgDocs){
|
|
374
|
+
if (overBudget()) {
|
|
375
|
+
report.deferred = 'time';
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
const orgReport = await summarizeOrg(deps, orgDoc, period, overBudget);
|
|
380
|
+
report.orgs[orgDoc.id] = orgReport;
|
|
381
|
+
report.sent += orgReport.sent;
|
|
382
|
+
if (orgReport.deferred) {
|
|
383
|
+
report.deferred = orgReport.deferred;
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
} catch (error) {
|
|
387
|
+
console.error('[weekly-summary] workspace failed', orgDoc.id, error);
|
|
388
|
+
report.orgs[orgDoc.id] = {
|
|
389
|
+
skipped: 'failed',
|
|
390
|
+
sites: 0,
|
|
391
|
+
recipients: 0,
|
|
392
|
+
sent: 0
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
report.swept += 1;
|
|
396
|
+
last = orgDoc.id;
|
|
397
|
+
}
|
|
398
|
+
/*
|
|
399
|
+
* Out of time: the next call starts after the last workspace that finished,
|
|
400
|
+
* and the marker skips whoever the unfinished one already reached. Refused
|
|
401
|
+
* by the send rate: the window is full, so the run stops (`done: true`)
|
|
402
|
+
* rather than re-POSTing into it; a re-run in the same week reaches the rest.
|
|
403
|
+
*/ if (report.deferred === 'time') {
|
|
404
|
+
var _ref;
|
|
405
|
+
report.nextCursor = (_ref = last != null ? last : options.cursor) != null ? _ref : null;
|
|
406
|
+
report.done = false;
|
|
407
|
+
} else if (report.deferred === 'rate') {
|
|
408
|
+
report.done = true;
|
|
409
|
+
} else {
|
|
410
|
+
report.nextCursor = hasMore ? last : null;
|
|
411
|
+
report.done = report.nextCursor === null;
|
|
412
|
+
}
|
|
413
|
+
return report;
|
|
414
|
+
}
|
|
415
|
+
/** The sweep's reach into the real platform: Firestore, the flags, the roster, the mail. */ /** The sweep's reach into the real platform: Firestore, the flags, the roster, the mail. */ export async function weeklySummaryDeps(options) {
|
|
416
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
417
|
+
const flags = await getServerReleaseFlagValues();
|
|
418
|
+
// Each designed email is read once per call and rendered per recipient.
|
|
419
|
+
const loaded = new Map();
|
|
420
|
+
return {
|
|
421
|
+
firestore,
|
|
422
|
+
runAt: options.runAt,
|
|
423
|
+
dryRun: options.dryRun,
|
|
424
|
+
origin: platformConsoleOrigin(),
|
|
425
|
+
flagOn: (flag, orgId, org)=>isReleaseFlagOnForOrg(flag, flags[flag], orgId, parseOrgReleaseFlagOverrides(org['releaseFlags']), resolveEffectivePlan(org)),
|
|
426
|
+
orgMembers: (orgId)=>listOrgMembers(orgId),
|
|
427
|
+
reachable: (uids)=>withoutMailWithheldAccounts(uids),
|
|
428
|
+
readers: (context)=>eligibleFigureReaders(_extends({}, context, {
|
|
429
|
+
accept: (reader)=>Boolean(reader.weeklySummary)
|
|
430
|
+
})),
|
|
431
|
+
colors: (org)=>{
|
|
432
|
+
const { palette } = systemEmailTheme(systemEmailBrand(org).options);
|
|
433
|
+
const colors = {};
|
|
434
|
+
for (const key of Object.keys(COLOR_TOKENS)){
|
|
435
|
+
var _ref, _resolveEmailColor;
|
|
436
|
+
// A brand palette that cannot resolve a token falls back to the
|
|
437
|
+
// platform's, which resolves every one of them.
|
|
438
|
+
colors[key] = (_ref = (_resolveEmailColor = resolveEmailColor(COLOR_TOKENS[key], palette)) != null ? _resolveEmailColor : resolveEmailColor(COLOR_TOKENS[key], PLATFORM_EMAIL_PALETTE)) != null ? _ref : 'inherit';
|
|
439
|
+
}
|
|
440
|
+
// Links take the color the renderer gives every other link in the
|
|
441
|
+
// email, not the raw primary, which is too light to read as text.
|
|
442
|
+
colors.link = emailAccent(palette).link;
|
|
443
|
+
return colors;
|
|
444
|
+
},
|
|
445
|
+
address: async (member, lookups)=>{
|
|
446
|
+
let address = str(member.email).toLowerCase();
|
|
447
|
+
if (!address.includes('@') && lookups.left > 0) {
|
|
448
|
+
var _pooled_record;
|
|
449
|
+
lookups.left -= 1;
|
|
450
|
+
const pooled = await findUserByUidAcrossPools(member.$id).catch(()=>null);
|
|
451
|
+
address = str(pooled == null ? void 0 : (_pooled_record = pooled.record) == null ? void 0 : _pooled_record.email).toLowerCase();
|
|
452
|
+
}
|
|
453
|
+
if (!address.includes('@')) return null;
|
|
454
|
+
const [kept] = await filterSuppressedEmails([
|
|
455
|
+
address
|
|
456
|
+
], firestore);
|
|
457
|
+
return kept != null ? kept : null;
|
|
458
|
+
},
|
|
459
|
+
send: async ({ to, template, email, org, merge })=>{
|
|
460
|
+
var _result_reason;
|
|
461
|
+
if (!loaded.has(template)) loaded.set(template, loadSystemEmail(template).catch(()=>null));
|
|
462
|
+
const design = await loaded.get(template);
|
|
463
|
+
const brand = systemEmailBrand(org);
|
|
464
|
+
const rendered = design ? renderLoadedSystemEmail(design, _extends({}, brand.merge, merge), brand.options) : null;
|
|
465
|
+
const content = systemEmailContent(rendered, {
|
|
466
|
+
subject: email.subject,
|
|
467
|
+
text: email.text
|
|
468
|
+
});
|
|
469
|
+
const result = await sendEmail(_extends({
|
|
470
|
+
to: [
|
|
471
|
+
to
|
|
472
|
+
]
|
|
473
|
+
}, content, {
|
|
474
|
+
// The figures' plain text, rather than the drawn block with its tags stripped.
|
|
475
|
+
text: email.text,
|
|
476
|
+
fromName: resolveBrandingProfile(org).fromName,
|
|
477
|
+
context: template,
|
|
478
|
+
priority: 'bulk'
|
|
479
|
+
}));
|
|
480
|
+
if (result.sent) {
|
|
481
|
+
await meterPlatformEmail().catch(()=>undefined);
|
|
482
|
+
return {
|
|
483
|
+
sent: true,
|
|
484
|
+
rateLimited: false
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
sent: false,
|
|
489
|
+
rateLimited: rateLimitedRetryAtMs(result) !== null,
|
|
490
|
+
reason: (_result_reason = result.reason) != null ? _result_reason : 'failed'
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
// lockdown-423: exempt — server-internal cron (x-cron-secret), no user caller; it reads each workspace's figures to report them to its owners and admins and writes nothing a locked org could lose.
|
|
496
|
+
/**
|
|
497
|
+
* `POST /api/admin/weekly-summary` sends; a GET, or `{"dryRun": true}`,
|
|
498
|
+
* reports who would be sent what and sends nothing. The body may carry the
|
|
499
|
+
* `cursor` a previous chunk answered and a smaller `limit`.
|
|
500
|
+
*/ export async function weeklySummaryRoute(request) {
|
|
501
|
+
const method = request.method.toUpperCase();
|
|
502
|
+
if (method !== 'POST' && method !== 'GET') {
|
|
503
|
+
return Response.json({
|
|
504
|
+
error: 'Method not allowed'
|
|
505
|
+
}, {
|
|
506
|
+
status: 405,
|
|
507
|
+
headers: {
|
|
508
|
+
Allow: 'GET, POST'
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
if (!process.env['CRON_SECRET']) {
|
|
513
|
+
return Response.json({
|
|
514
|
+
error: 'The weekly summary is not configured (CRON_SECRET).'
|
|
515
|
+
}, {
|
|
516
|
+
status: 501
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
const headers = Object.fromEntries(request.headers.entries());
|
|
520
|
+
if (!isCronAuthorized(headers)) return Response.json({
|
|
521
|
+
error: 'Unauthenticated'
|
|
522
|
+
}, {
|
|
523
|
+
status: 401
|
|
524
|
+
});
|
|
525
|
+
const body = method === 'POST' ? await request.json().catch(()=>null) : null;
|
|
526
|
+
const query = Object.fromEntries(new URL(request.url).searchParams.entries());
|
|
527
|
+
const dryRun = isCronDryRun({
|
|
528
|
+
method,
|
|
529
|
+
body: body != null ? body : undefined,
|
|
530
|
+
query
|
|
531
|
+
});
|
|
532
|
+
// Stamped on the invocation, not on the work (AGL-1955), so the beat is
|
|
533
|
+
// already there the day this job joins `SCHEDULED_JOBS`.
|
|
534
|
+
if (method === 'POST') {
|
|
535
|
+
try {
|
|
536
|
+
await writeCronBeat(firebaseAdmin.app().firestore(), WEEKLY_SUMMARY_JOB_ID);
|
|
537
|
+
} catch (unused) {
|
|
538
|
+
// A job that stops stamping reds its own row; it never fails the run.
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
const rawCursor = body == null ? void 0 : body['cursor'];
|
|
543
|
+
const cursor = typeof rawCursor === 'string' && rawCursor.length > 0 && !rawCursor.includes('/') ? rawCursor : null;
|
|
544
|
+
const limit = Number(body == null ? void 0 : body['limit']);
|
|
545
|
+
const report = await runWeeklySummarySweep(await weeklySummaryDeps({
|
|
546
|
+
runAt: new Date(),
|
|
547
|
+
dryRun
|
|
548
|
+
}), _extends({
|
|
549
|
+
cursor
|
|
550
|
+
}, Number.isFinite(limit) && limit > 0 ? {
|
|
551
|
+
limit
|
|
552
|
+
} : {}));
|
|
553
|
+
return Response.json(report, {
|
|
554
|
+
headers: {
|
|
555
|
+
'Cache-Control': 'no-store'
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
} catch (error) {
|
|
559
|
+
console.error('[weekly-summary] run failed', error);
|
|
560
|
+
return Response.json({
|
|
561
|
+
error: 'Weekly summary run failed'
|
|
562
|
+
}, {
|
|
563
|
+
status: 500
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
//# sourceMappingURL=weekly-summary.js.map
|