@aglyn/tenant-data-admin 1.0.0-beta.261 → 1.0.0-beta.262
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/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.js +4 -4
- 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/weekly-summary.d.ts +144 -0
- package/src/lib/server/weekly-summary.js +479 -0
- package/src/lib/server/weekly-summary.js.map +1 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { AglynOrgMember } from '@aglyn/aglyn/foundation/definitions/organization.types';
|
|
18
|
+
import { type ReleaseFlagKey } from '@aglyn/aglyn/app-utils/release-flags';
|
|
19
|
+
import { type ResolvedPluginFigureReader } from '@aglyn/aglyn/plugin-manager/plugin-figures';
|
|
20
|
+
import { type FigureReaderContext } from './figure-readers';
|
|
21
|
+
/**
|
|
22
|
+
* THE WEEKLY SUMMARY'S SENDS (AGL-3867): `POST /api/admin/weekly-summary`,
|
|
23
|
+
* Mondays at 13:00 UTC (08:00 Central) from `scheduled-crons.yml`.
|
|
24
|
+
*
|
|
25
|
+
* ## Who is told
|
|
26
|
+
*
|
|
27
|
+
* A workspace's owners and admins: the people who reach every site and every
|
|
28
|
+
* figure, so one set of figures per workspace is true for each of them. The
|
|
29
|
+
* **Weekly summary** switch on their own account
|
|
30
|
+
* (`users/{uid}.digestPrefs.weeklySummary`, on until turned off) governs it.
|
|
31
|
+
* A locked or disabled account, a suppressed address and a suspended member
|
|
32
|
+
* are not mailed.
|
|
33
|
+
*
|
|
34
|
+
* ## What it reads
|
|
35
|
+
*
|
|
36
|
+
* The workspace's busiest `WEEKLY_SUMMARY_MAX_SITES` sites by last week's
|
|
37
|
+
* page views, each through every site reader that declares a
|
|
38
|
+
* `weeklySummary`, and the workspace through every workspace reader that
|
|
39
|
+
* does. Each reader is offered only where its plan feature is included and
|
|
40
|
+
* its plugin runs (`eligibleFigureReaders`), read over the seven whole days
|
|
41
|
+
* before the run, and worded by its owner. A reader that fails is left out;
|
|
42
|
+
* the rest are still read.
|
|
43
|
+
*
|
|
44
|
+
* ## Once a week, and resumable
|
|
45
|
+
*
|
|
46
|
+
* `orgs/{orgId}/weeklySummaries/{week}` records each person sent, stamped
|
|
47
|
+
* after each send, so a run that dies mid-workspace re-sends only to those it
|
|
48
|
+
* never reached and a second run in the same week sends nothing. Workspaces
|
|
49
|
+
* are swept in id order behind a cursor, the `done: false` loop the workflow
|
|
50
|
+
* already runs for the CRM digest.
|
|
51
|
+
*
|
|
52
|
+
* The email is a `bulk` send under the platform send-rate governor: a refusal
|
|
53
|
+
* stops the run and answers `done: true`, and the unsent are picked up by a
|
|
54
|
+
* manual re-run in the same week. Metered as PLATFORM mail, not the
|
|
55
|
+
* workspace's: it is the platform reporting to the people who run it.
|
|
56
|
+
*/
|
|
57
|
+
type Firestore = FirebaseFirestore.Firestore;
|
|
58
|
+
/** The beat `/api/health/crons` reads, once this job joins its inventory. */
|
|
59
|
+
export declare const WEEKLY_SUMMARY_JOB_ID = "weekly-summary";
|
|
60
|
+
/** `orgs/{orgId}/weeklySummaries/{week}` — server-written, matched by no rule. */
|
|
61
|
+
export declare const WEEKLY_SUMMARY_MARKERS = "weeklySummaries";
|
|
62
|
+
/** Workspaces per invocation; a chunk that times out never advances its cursor. */
|
|
63
|
+
export declare const WEEKLY_SUMMARY_ORG_CHUNK = 20;
|
|
64
|
+
/** Sites a workspace's sweep ranks, by document id. */
|
|
65
|
+
export declare const WEEKLY_SUMMARY_HOST_READ_LIMIT = 50;
|
|
66
|
+
/** Sites one summary reports on: the busiest by last week's page views. */
|
|
67
|
+
export declare const WEEKLY_SUMMARY_MAX_SITES = 5;
|
|
68
|
+
/** Members per workspace whose address is chased through the auth pools when their member document has none. */
|
|
69
|
+
export declare const WEEKLY_SUMMARY_MAX_UID_LOOKUPS = 5;
|
|
70
|
+
/** How long one call works before it stops and leaves the rest to the next. */
|
|
71
|
+
export declare const WEEKLY_SUMMARY_RUN_BUDGET_MS = 45000;
|
|
72
|
+
export interface WeeklySummarySendResult {
|
|
73
|
+
sent: boolean;
|
|
74
|
+
/** The platform's send rate refused it for now. */
|
|
75
|
+
rateLimited: boolean;
|
|
76
|
+
reason?: string;
|
|
77
|
+
}
|
|
78
|
+
export interface WeeklySummaryEmail {
|
|
79
|
+
to: string;
|
|
80
|
+
subject: string;
|
|
81
|
+
/** The body token: the figures without the closing line the footer carries. */
|
|
82
|
+
body: string;
|
|
83
|
+
/** The plain fallback, closing line included. */
|
|
84
|
+
text: string;
|
|
85
|
+
org: Record<string, unknown>;
|
|
86
|
+
merge: Record<string, string>;
|
|
87
|
+
}
|
|
88
|
+
/** Everything the sweep reaches beyond Firestore, so a spec can hold each one. */
|
|
89
|
+
export interface WeeklySummaryDeps {
|
|
90
|
+
firestore: Firestore;
|
|
91
|
+
runAt: Date;
|
|
92
|
+
dryRun: boolean;
|
|
93
|
+
/** The console's absolute origin, or `''` when none is configured. */
|
|
94
|
+
origin: string;
|
|
95
|
+
/** Whether a release flag is on for an org. */
|
|
96
|
+
flagOn: (flag: ReleaseFlagKey, orgId: string, org: Record<string, unknown>) => boolean;
|
|
97
|
+
orgMembers: (orgId: string) => Promise<AglynOrgMember[]>;
|
|
98
|
+
/** The uids whose accounts may be mailed. */
|
|
99
|
+
reachable: (uids: string[]) => Promise<string[]>;
|
|
100
|
+
/** The readers that declare a `weeklySummary` and pass the gates here. */
|
|
101
|
+
readers: (context: Omit<FigureReaderContext, 'accept'>) => Promise<ResolvedPluginFigureReader[]>;
|
|
102
|
+
/** The member's deliverable address, or `null` when there is none or it is suppressed. */
|
|
103
|
+
address: (member: AglynOrgMember, lookups: {
|
|
104
|
+
left: number;
|
|
105
|
+
}) => Promise<string | null>;
|
|
106
|
+
send: (email: WeeklySummaryEmail) => Promise<WeeklySummarySendResult>;
|
|
107
|
+
budgetMs?: number;
|
|
108
|
+
}
|
|
109
|
+
export interface WeeklySummaryOrgReport {
|
|
110
|
+
skipped?: string;
|
|
111
|
+
sites: number;
|
|
112
|
+
recipients: number;
|
|
113
|
+
sent: number;
|
|
114
|
+
failed?: number;
|
|
115
|
+
/** Why the workspace stopped short: the call's time ran out, or the send rate refused. */
|
|
116
|
+
deferred?: 'time' | 'rate';
|
|
117
|
+
}
|
|
118
|
+
export interface WeeklySummaryReport {
|
|
119
|
+
week: string;
|
|
120
|
+
dryRun: boolean;
|
|
121
|
+
swept: number;
|
|
122
|
+
sent: number;
|
|
123
|
+
orgs: Record<string, WeeklySummaryOrgReport>;
|
|
124
|
+
deferred: false | 'time' | 'rate';
|
|
125
|
+
nextCursor: string | null;
|
|
126
|
+
done: boolean;
|
|
127
|
+
}
|
|
128
|
+
/** One chunk of the sweep: workspaces in id order after `cursor`. */
|
|
129
|
+
export declare function runWeeklySummarySweep(deps: WeeklySummaryDeps, options?: {
|
|
130
|
+
cursor?: string | null;
|
|
131
|
+
limit?: number;
|
|
132
|
+
}): Promise<WeeklySummaryReport>;
|
|
133
|
+
/** The sweep's reach into the real platform: Firestore, the flags, the roster, the mail. */
|
|
134
|
+
export declare function weeklySummaryDeps(options: {
|
|
135
|
+
runAt: Date;
|
|
136
|
+
dryRun: boolean;
|
|
137
|
+
}): Promise<WeeklySummaryDeps>;
|
|
138
|
+
/**
|
|
139
|
+
* `POST /api/admin/weekly-summary` sends; a GET, or `{"dryRun": true}`,
|
|
140
|
+
* reports who would be sent what and sends nothing. The body may carry the
|
|
141
|
+
* `cursor` a previous chunk answered and a smaller `limit`.
|
|
142
|
+
*/
|
|
143
|
+
export declare function weeklySummaryRoute(request: Request): Promise<Response>;
|
|
144
|
+
export {};
|
|
@@ -0,0 +1,479 @@
|
|
|
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 { brandSupportLine } from "@aglyn/aglyn/app-utils/brand-support-line";
|
|
18
|
+
import { buildRoute, Route } from "@aglyn/aglyn/app-utils/console-routes";
|
|
19
|
+
import { writeCronBeat } from "@aglyn/aglyn/app-utils/health-report";
|
|
20
|
+
import { DIGEST_PREFS_FIELD, digestEnabled, INSIGHT_DIGESTS_FIELD, insightDigestSubscribed } from "@aglyn/aglyn/app-utils/notifications";
|
|
21
|
+
import { checkEntitlement, resolveBrandingProfile, resolveEffectivePlan } from "@aglyn/aglyn/app-utils/plan-entitlements";
|
|
22
|
+
import { platformConsoleOrigin } from "@aglyn/aglyn/app-utils/platform-brand";
|
|
23
|
+
import { isReleaseFlagOnForOrg, parseOrgReleaseFlagOverrides } from "@aglyn/aglyn/app-utils/release-flags";
|
|
24
|
+
import { composeWeeklySummaryEmailText, composeWeeklySummarySubject, WEEKLY_SUMMARY_DAYS, WEEKLY_SUMMARY_DIGEST_KEY, weeklySummaryPeriod } from "@aglyn/aglyn/app-utils/weekly-summary";
|
|
25
|
+
import { normalizePluginFigureTable, pluginFigureSummarySection } from "@aglyn/aglyn/plugin-manager/plugin-figures";
|
|
26
|
+
import { rateLimitedRetryAtMs, sendEmail } from "@aglyn/shared-util-email";
|
|
27
|
+
import { withoutMailWithheldAccounts } from "./account-mail.js";
|
|
28
|
+
import { findUserByUidAcrossPools } from "./auth-pools.js";
|
|
29
|
+
import { isCronAuthorized, isCronDryRun } from "./cron-auth.js";
|
|
30
|
+
import { meterPlatformEmail } from "./email-metering.js";
|
|
31
|
+
import { filterSuppressedEmails } from "./email-suppression.js";
|
|
32
|
+
import { eligibleFigureReaders } from "./figure-readers.js";
|
|
33
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
34
|
+
import { listOrgMembers } from "./organizations.js";
|
|
35
|
+
import { getServerReleaseFlagValues } from "./release-flags.js";
|
|
36
|
+
import { loadSystemEmail, renderLoadedSystemEmail, systemEmailBrand, systemEmailContent } from "./render-system-email.js";
|
|
37
|
+
/** The beat `/api/health/crons` reads, once this job joins its inventory. */ export const WEEKLY_SUMMARY_JOB_ID = 'weekly-summary';
|
|
38
|
+
/** `orgs/{orgId}/weeklySummaries/{week}` — server-written, matched by no rule. */ export const WEEKLY_SUMMARY_MARKERS = 'weeklySummaries';
|
|
39
|
+
/** Workspaces per invocation; a chunk that times out never advances its cursor. */ export const WEEKLY_SUMMARY_ORG_CHUNK = 20;
|
|
40
|
+
/** Sites a workspace's sweep ranks, by document id. */ export const WEEKLY_SUMMARY_HOST_READ_LIMIT = 50;
|
|
41
|
+
/** Sites one summary reports on: the busiest by last week's page views. */ export const WEEKLY_SUMMARY_MAX_SITES = 5;
|
|
42
|
+
/** 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;
|
|
43
|
+
/** How long one call works before it stops and leaves the rest to the next. */ export const WEEKLY_SUMMARY_RUN_BUDGET_MS = 45000;
|
|
44
|
+
const str = (value)=>typeof value === 'string' ? value.trim() : '';
|
|
45
|
+
function siteName(data, id) {
|
|
46
|
+
return str(data['displayName']) || str(data['name']) || str(data['subdomain']) || id;
|
|
47
|
+
}
|
|
48
|
+
/** Last week's page views for each site, read from the daily analytics documents. */ async function weekViews(firestore, hosts, days) {
|
|
49
|
+
const views = new Map();
|
|
50
|
+
if (!hosts.length) return views;
|
|
51
|
+
const refs = hosts.flatMap((host)=>days.map((day)=>host.ref.collection('analytics').doc(day)));
|
|
52
|
+
const docs = await firestore.getAll(...refs);
|
|
53
|
+
for (const doc of docs){
|
|
54
|
+
var _ref, _views_get, _doc_get;
|
|
55
|
+
var _doc_ref_parent_parent;
|
|
56
|
+
const hostId = (_ref = (_doc_ref_parent_parent = doc.ref.parent.parent) == null ? void 0 : _doc_ref_parent_parent.id) != null ? _ref : '';
|
|
57
|
+
views.set(hostId, ((_views_get = views.get(hostId)) != null ? _views_get : 0) + (Number((_doc_get = doc.get('total')) != null ? _doc_get : 0) || 0));
|
|
58
|
+
}
|
|
59
|
+
return views;
|
|
60
|
+
}
|
|
61
|
+
function weekDays(from) {
|
|
62
|
+
const start = Date.parse(`${from}T00:00:00Z`);
|
|
63
|
+
return Array.from({
|
|
64
|
+
length: WEEKLY_SUMMARY_DAYS
|
|
65
|
+
}, (_, index)=>new Date(start + index * 86400000).toISOString().slice(0, 10));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Each reader's entry, in its declared order: read over the week, then worded
|
|
69
|
+
* by its owner. A reader that fails, answers no table or cannot be read over
|
|
70
|
+
* seven days is left out.
|
|
71
|
+
*/ async function readEntries(readers, base) {
|
|
72
|
+
const ordered = readers.filter(({ reader })=>reader.weeklySummary).sort((a, b)=>{
|
|
73
|
+
var _ref, _ref1;
|
|
74
|
+
var _a_reader_weeklySummary, _b_reader_weeklySummary;
|
|
75
|
+
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);
|
|
76
|
+
});
|
|
77
|
+
const entries = [];
|
|
78
|
+
for (const { reader } of ordered){
|
|
79
|
+
const summary = reader.weeklySummary;
|
|
80
|
+
if (!summary) continue;
|
|
81
|
+
const days = reader.windows.length ? reader.windows.includes(WEEKLY_SUMMARY_DAYS) ? WEEKLY_SUMMARY_DAYS : null : 0;
|
|
82
|
+
if (days === null) continue;
|
|
83
|
+
try {
|
|
84
|
+
var _ref;
|
|
85
|
+
const read = await reader.read(_extends({}, base, {
|
|
86
|
+
days,
|
|
87
|
+
uid: null,
|
|
88
|
+
params: {}
|
|
89
|
+
}));
|
|
90
|
+
if (read.ok === false) continue;
|
|
91
|
+
const table = normalizePluginFigureTable(read.table);
|
|
92
|
+
if (!table) continue;
|
|
93
|
+
entries.push({
|
|
94
|
+
lines: summary.lines(table),
|
|
95
|
+
headline: (_ref = summary.headline == null ? void 0 : summary.headline.call(summary, table)) != null ? _ref : null
|
|
96
|
+
});
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error('[weekly-summary] reader failed', {
|
|
99
|
+
reader: reader.id,
|
|
100
|
+
orgId: base.orgId,
|
|
101
|
+
error
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return entries;
|
|
106
|
+
}
|
|
107
|
+
async function summarizeOrg(deps, orgDoc, period, overBudget) {
|
|
108
|
+
var _orgDoc_data, _get;
|
|
109
|
+
const { firestore } = deps;
|
|
110
|
+
const orgId = orgDoc.id;
|
|
111
|
+
const org = (_orgDoc_data = orgDoc.data()) != null ? _orgDoc_data : {};
|
|
112
|
+
const report = {
|
|
113
|
+
sites: 0,
|
|
114
|
+
recipients: 0,
|
|
115
|
+
sent: 0
|
|
116
|
+
};
|
|
117
|
+
const markerRef = orgDoc.ref.collection(WEEKLY_SUMMARY_MARKERS).doc(period.week);
|
|
118
|
+
const sentAlready = (_get = (await markerRef.get()).get('sent')) != null ? _get : {};
|
|
119
|
+
// Owners and admins, not suspended, whose accounts may be mailed, who kept the switch on.
|
|
120
|
+
const managers = (await deps.orgMembers(orgId)).filter((member)=>(member.role === 'owner' || member.role === 'admin') && member.orgSuspended !== true);
|
|
121
|
+
const reachable = new Set(await deps.reachable(managers.map((member)=>member.$id)));
|
|
122
|
+
const candidates = managers.filter((member)=>reachable.has(member.$id) && !sentAlready[member.$id]).sort((a, b)=>a.$id.localeCompare(b.$id));
|
|
123
|
+
if (!candidates.length) return _extends({}, report, {
|
|
124
|
+
skipped: managers.length ? 'nobody-left' : 'no-managers'
|
|
125
|
+
});
|
|
126
|
+
const userDocs = await firestore.getAll(...candidates.map((member)=>firestore.collection('users').doc(member.$id)));
|
|
127
|
+
const users = new Map(userDocs.map((doc)=>[
|
|
128
|
+
doc.id,
|
|
129
|
+
doc
|
|
130
|
+
]));
|
|
131
|
+
const recipients = candidates.filter((member)=>{
|
|
132
|
+
var _users_get;
|
|
133
|
+
return digestEnabled((_users_get = users.get(member.$id)) == null ? void 0 : _users_get.get(DIGEST_PREFS_FIELD), WEEKLY_SUMMARY_DIGEST_KEY);
|
|
134
|
+
});
|
|
135
|
+
report.recipients = recipients.length;
|
|
136
|
+
if (!recipients.length) return _extends({}, report, {
|
|
137
|
+
skipped: 'summary-off'
|
|
138
|
+
});
|
|
139
|
+
// The busiest sites by last week's page views, ties by id.
|
|
140
|
+
const hostPage = await firestore.collection('hosts').where('orgId', '==', orgId).orderBy(firebaseAdmin.firestore.FieldPath.documentId()).limit(WEEKLY_SUMMARY_HOST_READ_LIMIT).get();
|
|
141
|
+
const hosts = hostPage.docs.filter((doc)=>!doc.get('deletedAt'));
|
|
142
|
+
const views = await weekViews(firestore, hosts, weekDays(period.from));
|
|
143
|
+
const chosen = [
|
|
144
|
+
...hosts
|
|
145
|
+
].sort((a, b)=>{
|
|
146
|
+
var _views_get, _views_get1;
|
|
147
|
+
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);
|
|
148
|
+
}).slice(0, WEEKLY_SUMMARY_MAX_SITES);
|
|
149
|
+
const orgSlug = str(org['slug']);
|
|
150
|
+
const sites = [];
|
|
151
|
+
for (const host of chosen){
|
|
152
|
+
var _host_data;
|
|
153
|
+
const data = (_host_data = host.data()) != null ? _host_data : {};
|
|
154
|
+
const readers = (await deps.readers({
|
|
155
|
+
orgId,
|
|
156
|
+
hostId: host.id,
|
|
157
|
+
org,
|
|
158
|
+
host: data
|
|
159
|
+
})).filter(({ reader })=>pluginFigureSummarySection(reader) === 'site');
|
|
160
|
+
const entries = await readEntries(readers, {
|
|
161
|
+
orgId,
|
|
162
|
+
hostId: host.id,
|
|
163
|
+
now: period.now
|
|
164
|
+
});
|
|
165
|
+
const subdomain = str(data['subdomain']);
|
|
166
|
+
sites.push({
|
|
167
|
+
name: siteName(data, host.id),
|
|
168
|
+
url: deps.origin && orgSlug && subdomain ? `${deps.origin}${buildRoute(Route.HOST_DASHBOARD, {
|
|
169
|
+
orgSlug,
|
|
170
|
+
host: subdomain
|
|
171
|
+
})}` : null,
|
|
172
|
+
entries
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const orgReaders = await deps.readers({
|
|
176
|
+
orgId,
|
|
177
|
+
hostId: null,
|
|
178
|
+
org,
|
|
179
|
+
host: null
|
|
180
|
+
});
|
|
181
|
+
const workspace = await readEntries(orgReaders.filter(({ reader })=>pluginFigureSummarySection(reader) === 'workspace'), {
|
|
182
|
+
orgId,
|
|
183
|
+
hostId: null,
|
|
184
|
+
now: period.now
|
|
185
|
+
});
|
|
186
|
+
const branding = resolveBrandingProfile(org);
|
|
187
|
+
const workspaceName = str(org['name']) || orgSlug || 'your workspace';
|
|
188
|
+
const settingsUrl = `${deps.origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`;
|
|
189
|
+
// AI weekly insights, pointed to only where the plan includes them and they are released.
|
|
190
|
+
const offersInsights = checkEntitlement(org, 'aiGenerative') && deps.flagOn('release_ai_generative', orgId, org);
|
|
191
|
+
const busiest = chosen[0];
|
|
192
|
+
const analyticsUrl = offersInsights && deps.origin && orgSlug && busiest && str(busiest.get('subdomain')) ? `${deps.origin}${buildRoute(Route.HOST_ANALYTICS, {
|
|
193
|
+
orgSlug,
|
|
194
|
+
host: str(busiest.get('subdomain'))
|
|
195
|
+
})}` : null;
|
|
196
|
+
const supportLine = brandSupportLine(branding);
|
|
197
|
+
const subject = composeWeeklySummarySubject(workspaceName, sites);
|
|
198
|
+
const input = {
|
|
199
|
+
workspaceName,
|
|
200
|
+
productName: branding.productName,
|
|
201
|
+
periodLabel: period.label,
|
|
202
|
+
sites,
|
|
203
|
+
workspace,
|
|
204
|
+
settingsUrl
|
|
205
|
+
};
|
|
206
|
+
report.sites = sites.length;
|
|
207
|
+
const lookups = {
|
|
208
|
+
left: WEEKLY_SUMMARY_MAX_UID_LOOKUPS
|
|
209
|
+
};
|
|
210
|
+
if (!composeWeeklySummaryEmailText(_extends({}, input, {
|
|
211
|
+
aiInsightsUrl: null
|
|
212
|
+
}))) {
|
|
213
|
+
return _extends({}, report, {
|
|
214
|
+
skipped: 'nothing-to-report'
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
for (const member of recipients){
|
|
218
|
+
var _composeWeeklySummaryEmailText, _composeWeeklySummaryEmailText1;
|
|
219
|
+
var _users_get;
|
|
220
|
+
if (overBudget()) return _extends({}, report, {
|
|
221
|
+
deferred: 'time'
|
|
222
|
+
});
|
|
223
|
+
const subscribed = insightDigestSubscribed((_users_get = users.get(member.$id)) == null ? void 0 : _users_get.get(INSIGHT_DIGESTS_FIELD), orgId);
|
|
224
|
+
const aiInsightsUrl = analyticsUrl && !subscribed ? analyticsUrl : null;
|
|
225
|
+
const body = (_composeWeeklySummaryEmailText = composeWeeklySummaryEmailText(_extends({}, input, {
|
|
226
|
+
aiInsightsUrl,
|
|
227
|
+
omitClosing: true
|
|
228
|
+
}))) != null ? _composeWeeklySummaryEmailText : '';
|
|
229
|
+
if (deps.dryRun) {
|
|
230
|
+
report.sent += 1;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const to = await deps.address(member, lookups);
|
|
234
|
+
if (!to) continue;
|
|
235
|
+
const text = [
|
|
236
|
+
(_composeWeeklySummaryEmailText1 = composeWeeklySummaryEmailText(_extends({}, input, {
|
|
237
|
+
aiInsightsUrl
|
|
238
|
+
}))) != null ? _composeWeeklySummaryEmailText1 : body,
|
|
239
|
+
supportLine
|
|
240
|
+
].filter(Boolean).join('\n\n');
|
|
241
|
+
const result = await deps.send({
|
|
242
|
+
to,
|
|
243
|
+
subject,
|
|
244
|
+
body,
|
|
245
|
+
text,
|
|
246
|
+
org,
|
|
247
|
+
merge: {
|
|
248
|
+
'summary.subject': subject,
|
|
249
|
+
'org.name': workspaceName,
|
|
250
|
+
'summary.period': period.label,
|
|
251
|
+
'summary.body': body,
|
|
252
|
+
settingsUrl
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
if (result.sent) {
|
|
256
|
+
report.sent += 1;
|
|
257
|
+
await markerRef.set({
|
|
258
|
+
week: period.week,
|
|
259
|
+
sent: {
|
|
260
|
+
[member.$id]: true
|
|
261
|
+
},
|
|
262
|
+
updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
|
|
263
|
+
}, {
|
|
264
|
+
merge: true
|
|
265
|
+
});
|
|
266
|
+
} else if (result.rateLimited) {
|
|
267
|
+
return _extends({}, report, {
|
|
268
|
+
deferred: 'rate'
|
|
269
|
+
});
|
|
270
|
+
} else {
|
|
271
|
+
var _report_failed;
|
|
272
|
+
report.failed = ((_report_failed = report.failed) != null ? _report_failed : 0) + 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return report;
|
|
276
|
+
}
|
|
277
|
+
/** One chunk of the sweep: workspaces in id order after `cursor`. */ export async function runWeeklySummarySweep(deps, options = {}) {
|
|
278
|
+
var _deps_budgetMs, _options_limit;
|
|
279
|
+
const { firestore } = deps;
|
|
280
|
+
const startedAt = Date.now();
|
|
281
|
+
const budget = (_deps_budgetMs = deps.budgetMs) != null ? _deps_budgetMs : WEEKLY_SUMMARY_RUN_BUDGET_MS;
|
|
282
|
+
const overBudget = ()=>Date.now() - startedAt > budget;
|
|
283
|
+
const period = weeklySummaryPeriod(deps.runAt);
|
|
284
|
+
const pageSize = Math.max(1, Math.min(Math.floor((_options_limit = options.limit) != null ? _options_limit : WEEKLY_SUMMARY_ORG_CHUNK), WEEKLY_SUMMARY_ORG_CHUNK));
|
|
285
|
+
let query = firestore.collection('orgs').orderBy(firebaseAdmin.firestore.FieldPath.documentId()).limit(pageSize + 1);
|
|
286
|
+
if (options.cursor) query = query.startAfter(firestore.collection('orgs').doc(options.cursor));
|
|
287
|
+
const page = await query.get();
|
|
288
|
+
const hasMore = page.docs.length > pageSize;
|
|
289
|
+
const orgDocs = hasMore ? page.docs.slice(0, pageSize) : page.docs;
|
|
290
|
+
const report = {
|
|
291
|
+
week: period.week,
|
|
292
|
+
dryRun: deps.dryRun,
|
|
293
|
+
swept: 0,
|
|
294
|
+
sent: 0,
|
|
295
|
+
orgs: {},
|
|
296
|
+
deferred: false,
|
|
297
|
+
nextCursor: null,
|
|
298
|
+
done: true
|
|
299
|
+
};
|
|
300
|
+
let last = null;
|
|
301
|
+
for (const orgDoc of orgDocs){
|
|
302
|
+
if (overBudget()) {
|
|
303
|
+
report.deferred = 'time';
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
const orgReport = await summarizeOrg(deps, orgDoc, period, overBudget);
|
|
308
|
+
report.orgs[orgDoc.id] = orgReport;
|
|
309
|
+
report.sent += orgReport.sent;
|
|
310
|
+
if (orgReport.deferred) {
|
|
311
|
+
report.deferred = orgReport.deferred;
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
} catch (error) {
|
|
315
|
+
console.error('[weekly-summary] workspace failed', orgDoc.id, error);
|
|
316
|
+
report.orgs[orgDoc.id] = {
|
|
317
|
+
skipped: 'failed',
|
|
318
|
+
sites: 0,
|
|
319
|
+
recipients: 0,
|
|
320
|
+
sent: 0
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
report.swept += 1;
|
|
324
|
+
last = orgDoc.id;
|
|
325
|
+
}
|
|
326
|
+
/*
|
|
327
|
+
* Out of time: the next call starts after the last workspace that finished,
|
|
328
|
+
* and the marker skips whoever the unfinished one already reached. Refused
|
|
329
|
+
* by the send rate: the window is full, so the run stops (`done: true`)
|
|
330
|
+
* rather than re-POSTing into it; a re-run in the same week reaches the rest.
|
|
331
|
+
*/ if (report.deferred === 'time') {
|
|
332
|
+
var _ref;
|
|
333
|
+
report.nextCursor = (_ref = last != null ? last : options.cursor) != null ? _ref : null;
|
|
334
|
+
report.done = false;
|
|
335
|
+
} else if (report.deferred === 'rate') {
|
|
336
|
+
report.done = true;
|
|
337
|
+
} else {
|
|
338
|
+
report.nextCursor = hasMore ? last : null;
|
|
339
|
+
report.done = report.nextCursor === null;
|
|
340
|
+
}
|
|
341
|
+
return report;
|
|
342
|
+
}
|
|
343
|
+
/** The sweep's reach into the real platform: Firestore, the flags, the roster, the mail. */ export async function weeklySummaryDeps(options) {
|
|
344
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
345
|
+
const flags = await getServerReleaseFlagValues();
|
|
346
|
+
// The designed email is read once per call and rendered per recipient.
|
|
347
|
+
let loaded;
|
|
348
|
+
return {
|
|
349
|
+
firestore,
|
|
350
|
+
runAt: options.runAt,
|
|
351
|
+
dryRun: options.dryRun,
|
|
352
|
+
origin: platformConsoleOrigin(),
|
|
353
|
+
flagOn: (flag, orgId, org)=>isReleaseFlagOnForOrg(flag, flags[flag], orgId, parseOrgReleaseFlagOverrides(org['releaseFlags']), resolveEffectivePlan(org)),
|
|
354
|
+
orgMembers: (orgId)=>listOrgMembers(orgId),
|
|
355
|
+
reachable: (uids)=>withoutMailWithheldAccounts(uids),
|
|
356
|
+
readers: (context)=>eligibleFigureReaders(_extends({}, context, {
|
|
357
|
+
accept: (reader)=>Boolean(reader.weeklySummary)
|
|
358
|
+
})),
|
|
359
|
+
address: async (member, lookups)=>{
|
|
360
|
+
let address = str(member.email).toLowerCase();
|
|
361
|
+
if (!address.includes('@') && lookups.left > 0) {
|
|
362
|
+
var _pooled_record;
|
|
363
|
+
lookups.left -= 1;
|
|
364
|
+
const pooled = await findUserByUidAcrossPools(member.$id).catch(()=>null);
|
|
365
|
+
address = str(pooled == null ? void 0 : (_pooled_record = pooled.record) == null ? void 0 : _pooled_record.email).toLowerCase();
|
|
366
|
+
}
|
|
367
|
+
if (!address.includes('@')) return null;
|
|
368
|
+
const [kept] = await filterSuppressedEmails([
|
|
369
|
+
address
|
|
370
|
+
], firestore);
|
|
371
|
+
return kept != null ? kept : null;
|
|
372
|
+
},
|
|
373
|
+
send: async (email)=>{
|
|
374
|
+
var _result_reason;
|
|
375
|
+
loaded != null ? loaded : loaded = loadSystemEmail('weekly-summary').catch(()=>null);
|
|
376
|
+
const template = await loaded;
|
|
377
|
+
const brand = systemEmailBrand(email.org);
|
|
378
|
+
const rendered = template ? renderLoadedSystemEmail(template, _extends({}, brand.merge, email.merge), brand.options) : null;
|
|
379
|
+
const result = await sendEmail(_extends({
|
|
380
|
+
to: [
|
|
381
|
+
email.to
|
|
382
|
+
]
|
|
383
|
+
}, systemEmailContent(rendered, {
|
|
384
|
+
subject: email.subject,
|
|
385
|
+
text: email.text
|
|
386
|
+
}), {
|
|
387
|
+
fromName: resolveBrandingProfile(email.org).fromName,
|
|
388
|
+
context: 'weekly-summary',
|
|
389
|
+
priority: 'bulk'
|
|
390
|
+
}));
|
|
391
|
+
if (result.sent) {
|
|
392
|
+
await meterPlatformEmail().catch(()=>undefined);
|
|
393
|
+
return {
|
|
394
|
+
sent: true,
|
|
395
|
+
rateLimited: false
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
sent: false,
|
|
400
|
+
rateLimited: rateLimitedRetryAtMs(result) !== null,
|
|
401
|
+
reason: (_result_reason = result.reason) != null ? _result_reason : 'failed'
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
// 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.
|
|
407
|
+
/**
|
|
408
|
+
* `POST /api/admin/weekly-summary` sends; a GET, or `{"dryRun": true}`,
|
|
409
|
+
* reports who would be sent what and sends nothing. The body may carry the
|
|
410
|
+
* `cursor` a previous chunk answered and a smaller `limit`.
|
|
411
|
+
*/ export async function weeklySummaryRoute(request) {
|
|
412
|
+
const method = request.method.toUpperCase();
|
|
413
|
+
if (method !== 'POST' && method !== 'GET') {
|
|
414
|
+
return Response.json({
|
|
415
|
+
error: 'Method not allowed'
|
|
416
|
+
}, {
|
|
417
|
+
status: 405,
|
|
418
|
+
headers: {
|
|
419
|
+
Allow: 'GET, POST'
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
if (!process.env['CRON_SECRET']) {
|
|
424
|
+
return Response.json({
|
|
425
|
+
error: 'The weekly summary is not configured (CRON_SECRET).'
|
|
426
|
+
}, {
|
|
427
|
+
status: 501
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
const headers = Object.fromEntries(request.headers.entries());
|
|
431
|
+
if (!isCronAuthorized(headers)) return Response.json({
|
|
432
|
+
error: 'Unauthenticated'
|
|
433
|
+
}, {
|
|
434
|
+
status: 401
|
|
435
|
+
});
|
|
436
|
+
const body = method === 'POST' ? await request.json().catch(()=>null) : null;
|
|
437
|
+
const query = Object.fromEntries(new URL(request.url).searchParams.entries());
|
|
438
|
+
const dryRun = isCronDryRun({
|
|
439
|
+
method,
|
|
440
|
+
body: body != null ? body : undefined,
|
|
441
|
+
query
|
|
442
|
+
});
|
|
443
|
+
// Stamped on the invocation, not on the work (AGL-1955), so the beat is
|
|
444
|
+
// already there the day this job joins `SCHEDULED_JOBS`.
|
|
445
|
+
if (method === 'POST') {
|
|
446
|
+
try {
|
|
447
|
+
await writeCronBeat(firebaseAdmin.app().firestore(), WEEKLY_SUMMARY_JOB_ID);
|
|
448
|
+
} catch (unused) {
|
|
449
|
+
// A job that stops stamping reds its own row; it never fails the run.
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
const rawCursor = body == null ? void 0 : body['cursor'];
|
|
454
|
+
const cursor = typeof rawCursor === 'string' && rawCursor.length > 0 && !rawCursor.includes('/') ? rawCursor : null;
|
|
455
|
+
const limit = Number(body == null ? void 0 : body['limit']);
|
|
456
|
+
const report = await runWeeklySummarySweep(await weeklySummaryDeps({
|
|
457
|
+
runAt: new Date(),
|
|
458
|
+
dryRun
|
|
459
|
+
}), _extends({
|
|
460
|
+
cursor
|
|
461
|
+
}, Number.isFinite(limit) && limit > 0 ? {
|
|
462
|
+
limit
|
|
463
|
+
} : {}));
|
|
464
|
+
return Response.json(report, {
|
|
465
|
+
headers: {
|
|
466
|
+
'Cache-Control': 'no-store'
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
} catch (error) {
|
|
470
|
+
console.error('[weekly-summary] run failed', error);
|
|
471
|
+
return Response.json({
|
|
472
|
+
error: 'Weekly summary run failed'
|
|
473
|
+
}, {
|
|
474
|
+
status: 500
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
//# sourceMappingURL=weekly-summary.js.map
|