@aglyn/tenant-data-admin 1.0.0-beta.262 → 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.
@@ -16,20 +16,30 @@
16
16
  */
17
17
  import type { AglynOrgMember } from '@aglyn/aglyn/foundation/definitions/organization.types';
18
18
  import { type ReleaseFlagKey } from '@aglyn/aglyn/app-utils/release-flags';
19
+ import { type WeeklySummaryColors, type WeeklySummaryEmail } from '@aglyn/aglyn/app-utils/weekly-summary';
19
20
  import { type ResolvedPluginFigureReader } from '@aglyn/aglyn/plugin-manager/plugin-figures';
20
21
  import { type FigureReaderContext } from './figure-readers';
21
22
  /**
22
23
  * THE WEEKLY SUMMARY'S SENDS (AGL-3867): `POST /api/admin/weekly-summary`,
23
24
  * Mondays at 13:00 UTC (08:00 Central) from `scheduled-crons.yml`.
24
25
  *
25
- * ## Who is told
26
+ * ## What is sent, and to whom
26
27
  *
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.
28
+ * One email for each of a workspace's sites that had something to report,
29
+ * and a workspace overview that adds them up: totals across the sites, a line
30
+ * for each, and the workspace's own figures, such as its deals.
31
+ *
32
+ * - The workspace's owners and admins get the overview and every site's
33
+ * email. In a workspace where only one site had anything to report, the
34
+ * overview would repeat it, so that site's email carries the workspace's
35
+ * own figures instead and no overview is sent.
36
+ * - A member who is an admin of a site, without being one of the
37
+ * workspace's, gets that site's email: they run it.
38
+ *
39
+ * The **Weekly summary** switch on each person's account
40
+ * (`users/{uid}.digestPrefs.weeklySummary`, on until turned off) governs all
41
+ * of it. A locked or disabled account, a suppressed address and a suspended
42
+ * member are not mailed.
33
43
  *
34
44
  * ## What it reads
35
45
  *
@@ -38,32 +48,38 @@ import { type FigureReaderContext } from './figure-readers';
38
48
  * `weeklySummary`, and the workspace through every workspace reader that
39
49
  * does. Each reader is offered only where its plan feature is included and
40
50
  * 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.
51
+ * before the run, and summarized by its owner. A reader that fails is left
52
+ * out; the rest are still read. Every figure is read once per workspace, and
53
+ * every recipient is sent the same figures.
43
54
  *
44
55
  * ## Once a week, and resumable
45
56
  *
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.
57
+ * `orgs/{orgId}/weeklySummaries/{week}.sent.{uid}.{email}` records each
58
+ * email sent — `overview`, or a site's id — stamped after each send, so a run
59
+ * that dies mid-workspace sends only what never went, and a second run in the
60
+ * same week sends nothing. Workspaces are swept in id order behind a cursor,
61
+ * the `done: false` loop the workflow already runs for the CRM digest.
51
62
  *
52
63
  * 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.
64
+ * stops the run and answers `done: true`, and a manual re-run in the same week
65
+ * picks up the rest. Metered as PLATFORM mail, not the workspace's: it is the
66
+ * platform reporting to the people who run it.
56
67
  */
57
68
  type Firestore = FirebaseFirestore.Firestore;
58
69
  /** The beat `/api/health/crons` reads, once this job joins its inventory. */
59
70
  export declare const WEEKLY_SUMMARY_JOB_ID = "weekly-summary";
60
71
  /** `orgs/{orgId}/weeklySummaries/{week}` — server-written, matched by no rule. */
61
72
  export declare const WEEKLY_SUMMARY_MARKERS = "weeklySummaries";
73
+ /** The marker key of the overview; a site's email is keyed by the site's id. */
74
+ export declare const WEEKLY_SUMMARY_OVERVIEW = "overview";
75
+ /** The system emails: the overview, and one site's. */
76
+ export declare const WEEKLY_SUMMARY_TEMPLATE = "weekly-summary";
77
+ export declare const WEEKLY_SUMMARY_SITE_TEMPLATE = "weekly-summary-site";
62
78
  /** Workspaces per invocation; a chunk that times out never advances its cursor. */
63
79
  export declare const WEEKLY_SUMMARY_ORG_CHUNK = 20;
64
80
  /** Sites a workspace's sweep ranks, by document id. */
65
81
  export declare const WEEKLY_SUMMARY_HOST_READ_LIMIT = 50;
66
- /** Sites one summary reports on: the busiest by last week's page views. */
82
+ /** Sites a week's summary reports on: the busiest by last week's page views. */
67
83
  export declare const WEEKLY_SUMMARY_MAX_SITES = 5;
68
84
  /** Members per workspace whose address is chased through the auth pools when their member document has none. */
69
85
  export declare const WEEKLY_SUMMARY_MAX_UID_LOOKUPS = 5;
@@ -75,13 +91,10 @@ export interface WeeklySummarySendResult {
75
91
  rateLimited: boolean;
76
92
  reason?: string;
77
93
  }
78
- export interface WeeklySummaryEmail {
94
+ export interface WeeklySummaryMessage {
79
95
  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;
96
+ template: typeof WEEKLY_SUMMARY_TEMPLATE | typeof WEEKLY_SUMMARY_SITE_TEMPLATE;
97
+ email: WeeklySummaryEmail;
85
98
  org: Record<string, unknown>;
86
99
  merge: Record<string, string>;
87
100
  }
@@ -99,11 +112,13 @@ export interface WeeklySummaryDeps {
99
112
  reachable: (uids: string[]) => Promise<string[]>;
100
113
  /** The readers that declare a `weeklySummary` and pass the gates here. */
101
114
  readers: (context: Omit<FigureReaderContext, 'accept'>) => Promise<ResolvedPluginFigureReader[]>;
115
+ /** The org's email colors, from its theme. */
116
+ colors: (org: Record<string, unknown>) => WeeklySummaryColors;
102
117
  /** The member's deliverable address, or `null` when there is none or it is suppressed. */
103
118
  address: (member: AglynOrgMember, lookups: {
104
119
  left: number;
105
120
  }) => Promise<string | null>;
106
- send: (email: WeeklySummaryEmail) => Promise<WeeklySummarySendResult>;
121
+ send: (message: WeeklySummaryMessage) => Promise<WeeklySummarySendResult>;
107
122
  budgetMs?: number;
108
123
  }
109
124
  export interface WeeklySummaryOrgReport {
@@ -131,6 +146,7 @@ export declare function runWeeklySummarySweep(deps: WeeklySummaryDeps, options?:
131
146
  limit?: number;
132
147
  }): Promise<WeeklySummaryReport>;
133
148
  /** The sweep's reach into the real platform: Firestore, the flags, the roster, the mail. */
149
+ /** The sweep's reach into the real platform: Firestore, the flags, the roster, the mail. */
134
150
  export declare function weeklySummaryDeps(options: {
135
151
  runAt: Date;
136
152
  dryRun: boolean;
@@ -14,16 +14,17 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
- import { brandSupportLine } from "@aglyn/aglyn/app-utils/brand-support-line";
18
17
  import { buildRoute, Route } from "@aglyn/aglyn/app-utils/console-routes";
19
18
  import { writeCronBeat } from "@aglyn/aglyn/app-utils/health-report";
20
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";
21
22
  import { checkEntitlement, resolveBrandingProfile, resolveEffectivePlan } from "@aglyn/aglyn/app-utils/plan-entitlements";
22
23
  import { platformConsoleOrigin } from "@aglyn/aglyn/app-utils/platform-brand";
23
24
  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 { composeWeeklySummaryOverviewEmail, composeWeeklySummarySiteEmail, WEEKLY_SUMMARY_DAYS, WEEKLY_SUMMARY_DIGEST_KEY, weeklySummaryHasNews, weeklySummaryPeriod } from "@aglyn/aglyn/app-utils/weekly-summary";
25
26
  import { normalizePluginFigureTable, pluginFigureSummarySection } from "@aglyn/aglyn/plugin-manager/plugin-figures";
26
- import { rateLimitedRetryAtMs, sendEmail } from "@aglyn/shared-util-email";
27
+ import { emailAccent, PLATFORM_EMAIL_PALETTE, rateLimitedRetryAtMs, resolveEmailColor, sendEmail } from "@aglyn/shared-util-email";
27
28
  import { withoutMailWithheldAccounts } from "./account-mail.js";
28
29
  import { findUserByUidAcrossPools } from "./auth-pools.js";
29
30
  import { isCronAuthorized, isCronDryRun } from "./cron-auth.js";
@@ -33,14 +34,27 @@ import { eligibleFigureReaders } from "./figure-readers.js";
33
34
  import firebaseAdmin from "./firebase-admin.js";
34
35
  import { listOrgMembers } from "./organizations.js";
35
36
  import { getServerReleaseFlagValues } from "./release-flags.js";
36
- import { loadSystemEmail, renderLoadedSystemEmail, systemEmailBrand, systemEmailContent } from "./render-system-email.js";
37
+ import { loadSystemEmail, renderLoadedSystemEmail, systemEmailBrand, systemEmailContent, systemEmailTheme } from "./render-system-email.js";
37
38
  /** The beat `/api/health/crons` reads, once this job joins its inventory. */ export const WEEKLY_SUMMARY_JOB_ID = 'weekly-summary';
38
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';
39
43
  /** Workspaces per invocation; a chunk that times out never advances its cursor. */ export const WEEKLY_SUMMARY_ORG_CHUNK = 20;
40
44
  /** 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;
45
+ /** Sites a week's summary reports on: the busiest by last week's page views. */ export const WEEKLY_SUMMARY_MAX_SITES = 5;
42
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;
43
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
+ };
44
58
  const str = (value)=>typeof value === 'string' ? value.trim() : '';
45
59
  function siteName(data, id) {
46
60
  return str(data['displayName']) || str(data['name']) || str(data['subdomain']) || id;
@@ -65,23 +79,23 @@ function weekDays(from) {
65
79
  }, (_, index)=>new Date(start + index * 86400000).toISOString().slice(0, 10));
66
80
  }
67
81
  /**
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) {
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) {
72
86
  const ordered = readers.filter(({ reader })=>reader.weeklySummary).sort((a, b)=>{
73
87
  var _ref, _ref1;
74
88
  var _a_reader_weeklySummary, _b_reader_weeklySummary;
75
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);
76
90
  });
77
- const entries = [];
91
+ const parts = [];
78
92
  for (const { reader } of ordered){
79
93
  const summary = reader.weeklySummary;
80
94
  if (!summary) continue;
81
95
  const days = reader.windows.length ? reader.windows.includes(WEEKLY_SUMMARY_DAYS) ? WEEKLY_SUMMARY_DAYS : null : 0;
82
96
  if (days === null) continue;
83
97
  try {
84
- var _ref;
98
+ var _part_stats, _part_rows;
85
99
  const read = await reader.read(_extends({}, base, {
86
100
  days,
87
101
  uid: null,
@@ -90,10 +104,8 @@ function weekDays(from) {
90
104
  if (read.ok === false) continue;
91
105
  const table = normalizePluginFigureTable(read.table);
92
106
  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
- });
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);
97
109
  } catch (error) {
98
110
  console.error('[weekly-summary] reader failed', {
99
111
  reader: reader.id,
@@ -102,10 +114,45 @@ function weekDays(from) {
102
114
  });
103
115
  }
104
116
  }
105
- return entries;
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
+ ];
106
153
  }
107
154
  async function summarizeOrg(deps, orgDoc, period, overBudget) {
108
- var _orgDoc_data, _get;
155
+ var _orgDoc_data, _get, _sites_find;
109
156
  const { firestore } = deps;
110
157
  const orgId = orgDoc.id;
111
158
  const org = (_orgDoc_data = orgDoc.data()) != null ? _orgDoc_data : {};
@@ -116,19 +163,25 @@ async function summarizeOrg(deps, orgDoc, period, overBudget) {
116
163
  };
117
164
  const markerRef = orgDoc.ref.collection(WEEKLY_SUMMARY_MARKERS).doc(period.week);
118
165
  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'
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');
125
173
  });
126
- const userDocs = await firestore.getAll(...candidates.map((member)=>firestore.collection('users').doc(member.$id)));
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)));
127
180
  const users = new Map(userDocs.map((doc)=>[
128
181
  doc.id,
129
182
  doc
130
183
  ]));
131
- const recipients = candidates.filter((member)=>{
184
+ const recipients = reached.filter((member)=>{
132
185
  var _users_get;
133
186
  return digestEnabled((_users_get = users.get(member.$id)) == null ? void 0 : _users_get.get(DIGEST_PREFS_FIELD), WEEKLY_SUMMARY_DIGEST_KEY);
134
187
  });
@@ -149,7 +202,7 @@ async function summarizeOrg(deps, orgDoc, period, overBudget) {
149
202
  const orgSlug = str(org['slug']);
150
203
  const sites = [];
151
204
  for (const host of chosen){
152
- var _host_data;
205
+ var _host_data, _liveCustomDomain;
153
206
  const data = (_host_data = host.data()) != null ? _host_data : {};
154
207
  const readers = (await deps.readers({
155
208
  orgId,
@@ -157,19 +210,28 @@ async function summarizeOrg(deps, orgDoc, period, overBudget) {
157
210
  org,
158
211
  host: data
159
212
  })).filter(({ reader })=>pluginFigureSummarySection(reader) === 'site');
160
- const entries = await readEntries(readers, {
213
+ const parts = await readParts(readers, {
161
214
  orgId,
162
215
  hostId: host.id,
163
216
  now: period.now
164
217
  });
165
218
  const subdomain = str(data['subdomain']);
219
+ const domain = (_liveCustomDomain = liveCustomDomain(data)) != null ? _liveCustomDomain : subdomain ? `${subdomain}.${TENANT_APEX}` : '';
166
220
  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
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
+ }
173
235
  });
174
236
  }
175
237
  const orgReaders = await deps.readers({
@@ -178,98 +240,108 @@ async function summarizeOrg(deps, orgDoc, period, overBudget) {
178
240
  org,
179
241
  host: null
180
242
  });
181
- const workspace = await readEntries(orgReaders.filter(({ reader })=>pluginFigureSummarySection(reader) === 'workspace'), {
243
+ const workspace = await readParts(orgReaders.filter(({ reader })=>pluginFigureSummarySection(reader) === 'workspace'), {
182
244
  orgId,
183
245
  hostId: null,
184
246
  now: period.now
185
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
+ });
186
252
  const branding = resolveBrandingProfile(org);
187
253
  const workspaceName = str(org['name']) || orgSlug || 'your workspace';
188
254
  const settingsUrl = `${deps.origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`;
255
+ const colors = deps.colors(org);
189
256
  // AI weekly insights, pointed to only where the plan includes them and they are released.
190
257
  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 = {
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 = {
199
263
  workspaceName,
200
264
  productName: branding.productName,
201
265
  periodLabel: period.label,
202
- sites,
203
- workspace,
266
+ colors,
204
267
  settingsUrl
205
268
  };
206
- report.sites = sites.length;
207
269
  const lookups = {
208
270
  left: WEEKLY_SUMMARY_MAX_UID_LOOKUPS
209
271
  };
210
- if (!composeWeeklySummaryEmailText(_extends({}, input, {
211
- aiInsightsUrl: null
212
- }))) {
213
- return _extends({}, report, {
214
- skipped: 'nothing-to-report'
215
- });
216
- }
272
+ const busiest = (_sites_find = sites.find(({ site })=>weeklySummaryHasNews(site))) != null ? _sites_find : sites[0];
217
273
  for (const member of recipients){
218
- var _composeWeeklySummaryEmailText, _composeWeeklySummaryEmailText1;
219
274
  var _users_get;
220
- if (overBudget()) return _extends({}, report, {
221
- deferred: 'time'
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]);
222
278
  });
279
+ if (!owed.length) continue;
223
280
  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
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'
265
287
  });
266
- } else if (result.rateLimited) {
267
- return _extends({}, report, {
268
- deferred: 'rate'
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
+ }
269
323
  });
270
- } else {
271
- var _report_failed;
272
- report.failed = ((_report_failed = report.failed) != null ? _report_failed : 0) + 1;
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
+ }
273
345
  }
274
346
  }
275
347
  return report;
@@ -340,11 +412,11 @@ async function summarizeOrg(deps, orgDoc, period, overBudget) {
340
412
  }
341
413
  return report;
342
414
  }
343
- /** The sweep's reach into the real platform: Firestore, the flags, the roster, the mail. */ export async function weeklySummaryDeps(options) {
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) {
344
416
  const firestore = firebaseAdmin.app().firestore();
345
417
  const flags = await getServerReleaseFlagValues();
346
- // The designed email is read once per call and rendered per recipient.
347
- let loaded;
418
+ // Each designed email is read once per call and rendered per recipient.
419
+ const loaded = new Map();
348
420
  return {
349
421
  firestore,
350
422
  runAt: options.runAt,
@@ -356,6 +428,20 @@ async function summarizeOrg(deps, orgDoc, period, overBudget) {
356
428
  readers: (context)=>eligibleFigureReaders(_extends({}, context, {
357
429
  accept: (reader)=>Boolean(reader.weeklySummary)
358
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
+ },
359
445
  address: async (member, lookups)=>{
360
446
  let address = str(member.email).toLowerCase();
361
447
  if (!address.includes('@') && lookups.left > 0) {
@@ -370,22 +456,25 @@ async function summarizeOrg(deps, orgDoc, period, overBudget) {
370
456
  ], firestore);
371
457
  return kept != null ? kept : null;
372
458
  },
373
- send: async (email)=>{
459
+ send: async ({ to, template, email, org, merge })=>{
374
460
  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;
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
+ });
379
469
  const result = await sendEmail(_extends({
380
470
  to: [
381
- email.to
471
+ to
382
472
  ]
383
- }, systemEmailContent(rendered, {
384
- subject: email.subject,
385
- text: email.text
386
- }), {
387
- fromName: resolveBrandingProfile(email.org).fromName,
388
- context: 'weekly-summary',
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,
389
478
  priority: 'bulk'
390
479
  }));
391
480
  if (result.sent) {