@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.
Files changed (34) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/figure-readers.d.ts +44 -0
  3. package/src/lib/server/figure-readers.js +45 -0
  4. package/src/lib/server/figure-readers.js.map +1 -0
  5. package/src/lib/server/keep-alive.d.ts +18 -0
  6. package/src/lib/server/keep-alive.js +59 -0
  7. package/src/lib/server/keep-alive.js.map +1 -0
  8. package/src/lib/server/mobile-push-switch.js +17 -4
  9. package/src/lib/server/mobile-push-switch.js.map +1 -1
  10. package/src/lib/server/native-push.js +6 -3
  11. package/src/lib/server/native-push.js.map +1 -1
  12. package/src/lib/server/notifications.d.ts +16 -5
  13. package/src/lib/server/notifications.js +30 -9
  14. package/src/lib/server/notifications.js.map +1 -1
  15. package/src/lib/server/operator-alerts.d.ts +7 -0
  16. package/src/lib/server/operator-alerts.js +6 -3
  17. package/src/lib/server/operator-alerts.js.map +1 -1
  18. package/src/lib/server/operator-health.d.ts +69 -0
  19. package/src/lib/server/operator-health.js +87 -0
  20. package/src/lib/server/operator-health.js.map +1 -1
  21. package/src/lib/server/push-apns.js +3 -1
  22. package/src/lib/server/push-apns.js.map +1 -1
  23. package/src/lib/server/push-delivery.d.ts +9 -0
  24. package/src/lib/server/push-delivery.js +10 -0
  25. package/src/lib/server/push-delivery.js.map +1 -1
  26. package/src/lib/server/render-system-email.d.ts +7 -1
  27. package/src/lib/server/render-system-email.js +1 -1
  28. package/src/lib/server/render-system-email.js.map +1 -1
  29. package/src/lib/server/sso-domain-policy.d.ts +43 -7
  30. package/src/lib/server/sso-domain-policy.js +61 -9
  31. package/src/lib/server/sso-domain-policy.js.map +1 -1
  32. package/src/lib/server/weekly-summary.d.ts +160 -0
  33. package/src/lib/server/weekly-summary.js +568 -0
  34. 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