@reddoorla/maintenance 0.62.0 → 0.63.0
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/dist/{announce-54EBITHX.js → announce-QEON4O7E.js} +15 -23
- package/dist/announce-QEON4O7E.js.map +1 -0
- package/dist/chunk-GECESMVS.js +36 -0
- package/dist/chunk-GECESMVS.js.map +1 -0
- package/dist/{chunk-76VIUWR3.js → chunk-J37CZORZ.js} +58 -56
- package/dist/chunk-J37CZORZ.js.map +1 -0
- package/dist/{chunk-TYRCYHRA.js → chunk-NPJFHBAX.js} +2 -2
- package/dist/chunk-P7VJM46I.js +63 -0
- package/dist/chunk-P7VJM46I.js.map +1 -0
- package/dist/{chunk-2T3HZ3DB.js → chunk-U3MF6RCS.js} +18 -9
- package/dist/chunk-U3MF6RCS.js.map +1 -0
- package/dist/cli/bin.js +12 -3
- package/dist/cli/bin.js.map +1 -1
- package/dist/index.js +6 -5
- package/dist/index.js.map +1 -1
- package/dist/{launch-UZPPB4IE.js → launch-IBP23PNY.js} +2 -2
- package/dist/{orchestrate-5GGOL5EX.js → orchestrate-CYYZ737H.js} +4 -3
- package/dist/{report-SZGZIDDG.js → report-QNE7HFOZ.js} +4 -4
- package/dist/selftest-5ELLRH6Q.js +161 -0
- package/dist/selftest-5ELLRH6Q.js.map +1 -0
- package/package.json +1 -1
- package/dist/announce-54EBITHX.js.map +0 -1
- package/dist/chunk-2T3HZ3DB.js.map +0 -1
- package/dist/chunk-76VIUWR3.js.map +0 -1
- /package/dist/{chunk-TYRCYHRA.js.map → chunk-NPJFHBAX.js.map} +0 -0
- /package/dist/{launch-UZPPB4IE.js.map → launch-IBP23PNY.js.map} +0 -0
- /package/dist/{orchestrate-5GGOL5EX.js.map → orchestrate-CYYZ737H.js.map} +0 -0
- /package/dist/{report-SZGZIDDG.js.map → report-QNE7HFOZ.js.map} +0 -0
|
@@ -1,16 +1,22 @@
|
|
|
1
|
+
import {
|
|
2
|
+
scoresFromRow
|
|
3
|
+
} from "./chunk-P7VJM46I.js";
|
|
1
4
|
import {
|
|
2
5
|
fetchGaUsers,
|
|
3
6
|
fetchSearch
|
|
4
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-NPJFHBAX.js";
|
|
5
8
|
import {
|
|
6
9
|
queueDraft
|
|
7
10
|
} from "./chunk-6LIWDIXU.js";
|
|
11
|
+
import {
|
|
12
|
+
defaultReportSubject
|
|
13
|
+
} from "./chunk-GECESMVS.js";
|
|
8
14
|
import {
|
|
9
15
|
announcementSiteExtras,
|
|
10
16
|
renderReportHtml,
|
|
11
17
|
resolveCopy,
|
|
12
18
|
uploadAttachment
|
|
13
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-U3MF6RCS.js";
|
|
14
20
|
import {
|
|
15
21
|
createDraft,
|
|
16
22
|
findReportByPeriod,
|
|
@@ -123,25 +129,6 @@ async function announce(deps) {
|
|
|
123
129
|
}
|
|
124
130
|
return { results };
|
|
125
131
|
}
|
|
126
|
-
function siteLabel(w) {
|
|
127
|
-
try {
|
|
128
|
-
const host = new URL(w.url).hostname.replace(/^www\./, "");
|
|
129
|
-
return `${w.name} (${host})`;
|
|
130
|
-
} catch {
|
|
131
|
-
return w.name;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
function scoresFromRow(w) {
|
|
135
|
-
if (w.pScore === null || w.rScore === null || w.bpScore === null || w.seoScore === null) {
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
return {
|
|
139
|
-
performance: w.pScore,
|
|
140
|
-
accessibility: w.rScore,
|
|
141
|
-
bestPractices: w.bpScore,
|
|
142
|
-
seo: w.seoScore
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
132
|
function draftInputFor(w, scores, now, period, enrichment) {
|
|
146
133
|
return {
|
|
147
134
|
reportId: `${w.name} \u2014 Announcement \u2014 ${now.toISOString().slice(0, 10)}`,
|
|
@@ -153,7 +140,12 @@ function draftInputFor(w, scores, now, period, enrichment) {
|
|
|
153
140
|
completedOn: now,
|
|
154
141
|
lighthouse: scores,
|
|
155
142
|
lastTestedDate: null,
|
|
156
|
-
subjectOverride:
|
|
143
|
+
subjectOverride: defaultReportSubject({
|
|
144
|
+
name: w.name,
|
|
145
|
+
url: w.url,
|
|
146
|
+
type: "Announcement",
|
|
147
|
+
date: now
|
|
148
|
+
}),
|
|
157
149
|
...enrichment
|
|
158
150
|
};
|
|
159
151
|
}
|
|
@@ -178,4 +170,4 @@ export {
|
|
|
178
170
|
formatAnnounceResult,
|
|
179
171
|
runAnnounceCommand
|
|
180
172
|
};
|
|
181
|
-
//# sourceMappingURL=announce-
|
|
173
|
+
//# sourceMappingURL=announce-QEON4O7E.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/recipes/announce.ts","../src/cli/commands/announce.ts"],"sourcesContent":["import { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport { listWebsites, siteSlug } from \"../reports/airtable/websites.js\";\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport {\n createDraft,\n findReportByPeriod,\n updateReportScores,\n type ReportEnrichment,\n} from \"../reports/airtable/reports.js\";\nimport { queueDraft } from \"../reports/queue.js\";\nimport { uploadAttachment } from \"../reports/airtable/attachments.js\";\nimport { renderReportHtml } from \"../reports/render.js\";\nimport { resolveCopy } from \"../reports/copy.js\";\nimport { fetchGaUsers, fetchSearch } from \"../reports/draft.js\";\nimport { announcementSiteExtras } from \"../reports/announcement-email/template.js\";\nimport type { LighthouseScores } from \"../reports/types.js\";\nimport { defaultReportSubject } from \"../reports/subject.js\";\nimport { scoresFromRow } from \"../reports/report-data.js\";\n\nexport type AnnounceSiteResult =\n | {\n site: string;\n status: \"drafted\" | \"reused\";\n reportId: string;\n recipientMissing: boolean;\n /** False when a higher-or-equal-tier report was already queued (single-queue rule). */\n queued: boolean;\n }\n | { site: string; status: \"skipped-no-scores\" }\n | { site: string; status: \"error\"; message: string };\n\nexport type AnnounceResult = { results: AnnounceSiteResult[] };\n\n/** The traffic/search lookback window (days) the announcement reports on. The trend compares it\n * against the equal-length prior window, and the email labels it \"vs the previous N days\". */\nconst GA_WINDOW_DAYS = 30;\n\nexport type AnnounceDeps = {\n /** Airtable handle. Defaults to opening the live base from credentials. */\n base?: AirtableBase;\n /** When set, restrict to the single site whose slug matches. Default: all maintenance sites. */\n site?: string;\n /** Single timestamp driving the period key, render, draft, and preview filename. */\n now?: Date;\n};\n\n/**\n * Draft the monthly-report ANNOUNCEMENT email for every `maintenance` site (or one,\n * via `deps.site`). Airtable-driven and fleet-wide: unlike `launch`, it runs no audits\n * and takes no `Site`/inventory object — it reads the Lighthouse scores already stored\n * on each Websites row. DRAFTS ONLY; the M3 approve loop sends.\n *\n * One bad site must never abort the run: each site is wrapped in its own try/catch that\n * records an `error` result and continues.\n */\nexport async function announce(deps?: AnnounceDeps): Promise<AnnounceResult> {\n const base = deps?.base ?? openBase(readAirtableConfig());\n const now = deps?.now ?? new Date();\n\n const websites = await listWebsites(base);\n let targets = websites.filter((w) => w.status === \"maintenance\");\n if (deps?.site) {\n const wanted = siteSlug(deps.site);\n targets = targets.filter((w) => siteSlug(w.name) === wanted);\n }\n\n const period = now.toISOString().slice(0, 7);\n const results: AnnounceSiteResult[] = [];\n\n for (const w of targets) {\n try {\n const scores = scoresFromRow(w);\n if (scores === null) {\n results.push({ site: w.name, status: \"skipped-no-scores\" });\n continue;\n }\n\n // Traffic + search snapshot over a ~30-day window ending now (fetchPeriodUsers derives\n // the equal-length previous window for the trend). Reuses the report pipeline's\n // soft-failing enrichment: GA/search unconfigured or an API error leaves the numbers\n // null and the email simply omits the traffic section — it never blocks the draft.\n const periodEnd = now;\n const periodStart = new Date(now.getTime() - GA_WINDOW_DAYS * 24 * 60 * 60 * 1000);\n const gaUsers = (await fetchGaUsers(w, periodStart, periodEnd)).value;\n const search = (await fetchSearch(w, periodStart, periodEnd)).value;\n const enrichment: ReportEnrichment = {\n ...(gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {}),\n ...(search ? { searchFoundPage1: search.foundOnPage1 } : {}),\n ...(search?.foundOnPage1 && search.position !== null\n ? { searchPosition: search.position }\n : {}),\n };\n\n // Dedupe: reuse an existing Announcement row for this (site, period) rather than\n // stacking a second draft. The reuse path refreshes the stored scores + traffic/search\n // (and Completed on) so the eventually-sent email — which reads the row — isn't stale.\n // The create path writes them via createDraft.\n let report;\n let statusKind: \"drafted\" | \"reused\";\n const existing = await findReportByPeriod(base, w.id, \"Announcement\", period);\n if (existing) {\n await updateReportScores(base, existing.id, scores, now, enrichment);\n report = existing;\n statusKind = \"reused\";\n } else {\n report = await createDraft(base, draftInputFor(w, scores, now, period, enrichment));\n statusKind = \"drafted\";\n }\n\n const slug = siteSlug(w.name);\n const { html } = await renderReportHtml({\n siteName: w.name,\n siteUrl: w.url,\n reportType: \"Announcement\",\n completedOn: now,\n lighthouse: scores,\n gaUsersCurrent: gaUsers?.current,\n gaUsersPrevious: gaUsers?.previous,\n gaPeriodDays: GA_WINDOW_DAYS,\n searchPosition: search?.foundOnPage1 ? (search.position ?? undefined) : undefined,\n lastTestedDate: null,\n commentary: null,\n copy: resolveCopy(w),\n headerImageCid: `${slug}-header`,\n // cadence (the client's go-forward pace, \"None\" omitted) + default-on improvement\n // callouts. Shared with the send re-render via announcementSiteExtras so the sent\n // email matches this reviewed preview.\n ...announcementSiteExtras(w),\n });\n\n // A preview-upload hiccup must NOT fail the site — log and continue.\n try {\n await uploadAttachment(\n report.id,\n \"Rendered HTML\",\n html,\n `${slug}-${now.toISOString().slice(0, 10)}.html`,\n \"text/html\",\n );\n } catch (uploadErr) {\n console.warn(\n `⚠ Announcement preview upload skipped for ${w.name}: ${\n uploadErr instanceof Error ? uploadErr.message : String(uploadErr)\n }`,\n );\n }\n\n // Critical: NOT wrapped — without queueing, the draft never enters the approve queue,\n // so a failure here must surface as an error result for the site. queueDraft also\n // supersedes any lower-tier (Maintenance/Testing) drafts queued for this site, and\n // stands down if an equal-or-higher report is already queued (single-queue rule).\n const queue = await queueDraft(base, {\n id: report.id,\n siteId: w.id,\n reportType: \"Announcement\",\n });\n\n const recipientMissing = !(w.reportRecipientsTo && w.reportRecipientsTo.trim());\n results.push({\n site: w.name,\n status: statusKind,\n reportId: report.id,\n recipientMissing,\n queued: queue.queued,\n });\n } catch (err) {\n results.push({\n site: w.name,\n status: \"error\",\n message: err instanceof Error ? err.message : String(err),\n });\n }\n }\n\n return { results };\n}\n\n/** Build the Announcement `DraftInput`. Announcements have no period window and no prior\n * maintenance test, so periodStart/periodEnd/completedOn all collapse to `now` and\n * `lastTestedDate` is null. The subject override gives the email a purpose-built line. */\nfunction draftInputFor(\n w: WebsiteRow,\n scores: LighthouseScores,\n now: Date,\n period: string,\n enrichment: ReportEnrichment,\n): Parameters<typeof createDraft>[1] {\n return {\n reportId: `${w.name} — Announcement — ${now.toISOString().slice(0, 10)}`,\n siteId: w.id,\n reportType: \"Announcement\",\n period,\n periodStart: now,\n periodEnd: now,\n completedOn: now,\n lighthouse: scores,\n lastTestedDate: null,\n subjectOverride: defaultReportSubject({\n name: w.name,\n url: w.url,\n type: \"Announcement\",\n date: now,\n }),\n ...enrichment,\n };\n}\n","import { announce, type AnnounceResult, type AnnounceSiteResult } from \"../../recipes/announce.js\";\n\nexport type AnnounceCommandOptions = {\n cwd?: string;\n};\n\nfunction formatSiteResult(r: AnnounceSiteResult): string {\n if (r.status === \"skipped-no-scores\") return `[${r.site}] skipped-no-scores`;\n if (r.status === \"error\") return `[${r.site}] error: ${r.message}`;\n const note = r.recipientMissing ? \" ⚠ recipient missing\" : \"\";\n return `[${r.site}] ${r.status}${note}`;\n}\n\nexport function formatAnnounceResult(result: AnnounceResult): string {\n if (result.results.length === 0) return \"No maintenance sites to announce.\";\n return result.results.map(formatSiteResult).join(\"\\n\");\n}\n\n/**\n * `announce [site]` — Airtable-driven and fleet-wide. Draft the monthly-report\n * announcement email for every `maintenance` site (or one, when `site` is given) into\n * the M3 approve queue. Never sends; the operator approves each draft and the next send\n * run delivers it. Reads the Lighthouse scores already stored on each Websites row —\n * no audits are run.\n */\nexport async function runAnnounceCommand(\n site: string | undefined,\n _opts: AnnounceCommandOptions,\n): Promise<{ output: string; code: number }> {\n const result = await announce(site ? { site } : {});\n const hadError = result.results.some((r) => r.status === \"error\");\n return { output: formatAnnounceResult(result), code: hadError ? 1 : 0 };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/reports/subject.ts
|
|
2
|
+
var MONTHS = [
|
|
3
|
+
"January",
|
|
4
|
+
"February",
|
|
5
|
+
"March",
|
|
6
|
+
"April",
|
|
7
|
+
"May",
|
|
8
|
+
"June",
|
|
9
|
+
"July",
|
|
10
|
+
"August",
|
|
11
|
+
"September",
|
|
12
|
+
"October",
|
|
13
|
+
"November",
|
|
14
|
+
"December"
|
|
15
|
+
];
|
|
16
|
+
function monthYear(d) {
|
|
17
|
+
return `${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
|
18
|
+
}
|
|
19
|
+
function siteLabel(name, url) {
|
|
20
|
+
try {
|
|
21
|
+
return `${name} (${new URL(url).hostname.replace(/^www\./, "")})`;
|
|
22
|
+
} catch {
|
|
23
|
+
return name;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function defaultReportSubject(args) {
|
|
27
|
+
if (args.type === "Announcement") {
|
|
28
|
+
return `Your testing & maintenance report for ${siteLabel(args.name, args.url)}`;
|
|
29
|
+
}
|
|
30
|
+
return `${args.name} \u2014 ${monthYear(args.date)} ${args.type} Report`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
defaultReportSubject
|
|
35
|
+
};
|
|
36
|
+
//# sourceMappingURL=chunk-GECESMVS.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/subject.ts"],"sourcesContent":["import type { ReportType } from \"./types.js\";\n\nconst MONTHS = [\n \"January\",\n \"February\",\n \"March\",\n \"April\",\n \"May\",\n \"June\",\n \"July\",\n \"August\",\n \"September\",\n \"October\",\n \"November\",\n \"December\",\n];\n\n/** \"May 2026\" — UTC month/year, consistent with the rest of the reports pipeline's dates. */\nfunction monthYear(d: Date): string {\n return `${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;\n}\n\n/** \"Acme Co (acme.com)\" — name plus its bare, www-stripped host; name alone if the URL\n * can't be parsed (mirrors the announce recipe's prior siteLabel). */\nfunction siteLabel(name: string, url: string): string {\n try {\n return `${name} (${new URL(url).hostname.replace(/^www\\./, \"\")})`;\n } catch {\n return name;\n }\n}\n\n/**\n * The default subject for a report email, per type. Announcement → \"Your testing & maintenance\n * report for {Name} ({domain})\"; every other type → \"{Name} — {Month YYYY} {Type} Report\".\n * Shared by the `announce` recipe (which stores it as the Reports row's subjectOverride) and by\n * `renderReportEmail` (the send/self-test default) so the subject can't drift between them. PURE.\n */\nexport function defaultReportSubject(args: {\n name: string;\n url: string;\n type: ReportType;\n date: Date;\n}): string {\n if (args.type === \"Announcement\") {\n return `Your testing & maintenance report for ${siteLabel(args.name, args.url)}`;\n }\n return `${args.name} — ${monthYear(args.date)} ${args.type} Report`;\n}\n"],"mappings":";AAEA,IAAM,SAAS;AAAA,EACb;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGA,SAAS,UAAU,GAAiB;AAClC,SAAO,GAAG,OAAO,EAAE,YAAY,CAAC,CAAC,IAAI,EAAE,eAAe,CAAC;AACzD;AAIA,SAAS,UAAU,MAAc,KAAqB;AACpD,MAAI;AACF,WAAO,GAAG,IAAI,KAAK,IAAI,IAAI,GAAG,EAAE,SAAS,QAAQ,UAAU,EAAE,CAAC;AAAA,EAChE,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAQO,SAAS,qBAAqB,MAK1B;AACT,MAAI,KAAK,SAAS,gBAAgB;AAChC,WAAO,yCAAyC,UAAU,KAAK,MAAM,KAAK,GAAG,CAAC;AAAA,EAChF;AACA,SAAO,GAAG,KAAK,IAAI,WAAM,UAAU,KAAK,IAAI,CAAC,IAAI,KAAK,IAAI;AAC5D;","names":[]}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isIdempotencyConflict
|
|
3
|
+
} from "./chunk-OGGRFBL4.js";
|
|
4
|
+
import {
|
|
5
|
+
defaultReportSubject
|
|
6
|
+
} from "./chunk-GECESMVS.js";
|
|
1
7
|
import {
|
|
2
8
|
announcementSiteExtras,
|
|
3
9
|
fetchAttachmentBytes,
|
|
4
10
|
loadBundledImages,
|
|
5
11
|
renderReportHtml,
|
|
6
12
|
resolveCopy
|
|
7
|
-
} from "./chunk-
|
|
8
|
-
import {
|
|
9
|
-
isIdempotencyConflict
|
|
10
|
-
} from "./chunk-OGGRFBL4.js";
|
|
13
|
+
} from "./chunk-U3MF6RCS.js";
|
|
11
14
|
import {
|
|
12
15
|
checklistFor,
|
|
13
16
|
isChecklistComplete,
|
|
@@ -30,6 +33,47 @@ import {
|
|
|
30
33
|
defaultResendClient
|
|
31
34
|
} from "./chunk-WEKL2HYJ.js";
|
|
32
35
|
|
|
36
|
+
// src/reports/send/render-email.ts
|
|
37
|
+
function toInlineAttachment(a) {
|
|
38
|
+
return {
|
|
39
|
+
filename: a.filename,
|
|
40
|
+
content: Buffer.from(a.bytes).toString("base64"),
|
|
41
|
+
contentType: a.contentType,
|
|
42
|
+
inlineContentId: a.cid
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
async function renderReportEmail(reportData, ctx) {
|
|
46
|
+
const { html } = await renderReportHtml(reportData);
|
|
47
|
+
const bundled = await loadBundledImages();
|
|
48
|
+
const attachments = [
|
|
49
|
+
toInlineAttachment({
|
|
50
|
+
bytes: ctx.header.bytes,
|
|
51
|
+
filename: `${ctx.cidName}.jpg`,
|
|
52
|
+
contentType: ctx.header.contentType,
|
|
53
|
+
cid: ctx.cidName
|
|
54
|
+
})
|
|
55
|
+
];
|
|
56
|
+
for (const img of [bundled.check, bundled.blurred]) {
|
|
57
|
+
if (html.includes(`cid:${img.cid}`)) {
|
|
58
|
+
attachments.push(
|
|
59
|
+
toInlineAttachment({
|
|
60
|
+
bytes: img.bytes,
|
|
61
|
+
filename: img.filename,
|
|
62
|
+
contentType: img.contentType,
|
|
63
|
+
cid: img.cid
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const subject = ctx.subjectOverride ?? defaultReportSubject({
|
|
69
|
+
name: reportData.siteName,
|
|
70
|
+
url: reportData.siteUrl,
|
|
71
|
+
type: reportData.reportType,
|
|
72
|
+
date: reportData.completedOn
|
|
73
|
+
});
|
|
74
|
+
return { html, attachments, subject };
|
|
75
|
+
}
|
|
76
|
+
|
|
33
77
|
// src/reports/maintenance-email/header-image.ts
|
|
34
78
|
import sharp from "sharp";
|
|
35
79
|
var DEFAULT_DISPLAY_WIDTH = 600;
|
|
@@ -72,37 +116,12 @@ function withGlobalCc(perSiteCc, to) {
|
|
|
72
116
|
if (!present.has(GLOBAL_REPORT_CC.toLowerCase())) cc.push(GLOBAL_REPORT_CC);
|
|
73
117
|
return cc;
|
|
74
118
|
}
|
|
75
|
-
var MONTHS = [
|
|
76
|
-
"January",
|
|
77
|
-
"February",
|
|
78
|
-
"March",
|
|
79
|
-
"April",
|
|
80
|
-
"May",
|
|
81
|
-
"June",
|
|
82
|
-
"July",
|
|
83
|
-
"August",
|
|
84
|
-
"September",
|
|
85
|
-
"October",
|
|
86
|
-
"November",
|
|
87
|
-
"December"
|
|
88
|
-
];
|
|
89
|
-
function monthYear(d) {
|
|
90
|
-
return `${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
|
91
|
-
}
|
|
92
119
|
function windowDays(start, end) {
|
|
93
120
|
if (!start || !end) return void 0;
|
|
94
121
|
const ms = new Date(end).getTime() - new Date(start).getTime();
|
|
95
122
|
if (!Number.isFinite(ms) || ms <= 0) return void 0;
|
|
96
123
|
return Math.round(ms / (24 * 60 * 60 * 1e3));
|
|
97
124
|
}
|
|
98
|
-
function toInlineAttachment(a) {
|
|
99
|
-
return {
|
|
100
|
-
filename: a.filename,
|
|
101
|
-
content: Buffer.from(a.bytes).toString("base64"),
|
|
102
|
-
contentType: a.contentType,
|
|
103
|
-
inlineContentId: a.cid
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
125
|
async function sendApprovedReports(options = {}) {
|
|
107
126
|
const base = openBase(readAirtableConfig());
|
|
108
127
|
const client = options.resend ?? defaultResendClient();
|
|
@@ -194,11 +213,10 @@ async function sendOne(client, base, site, report) {
|
|
|
194
213
|
}
|
|
195
214
|
const original = await fetchAttachmentBytes(site.headerImage.url);
|
|
196
215
|
const header = await prepareHeaderImage(original.bytes);
|
|
197
|
-
const bundled = await loadBundledImages();
|
|
198
216
|
const slug = siteSlug(site.name);
|
|
199
217
|
const cidName = `${slug}-header`;
|
|
200
218
|
const gaPeriodDays = report.reportType === "Announcement" ? 30 : windowDays(report.periodStart, report.periodEnd);
|
|
201
|
-
const
|
|
219
|
+
const reportData = {
|
|
202
220
|
siteName: site.name,
|
|
203
221
|
siteUrl: site.url,
|
|
204
222
|
reportType: report.reportType,
|
|
@@ -216,32 +234,14 @@ async function sendOne(client, base, site, report) {
|
|
|
216
234
|
headerHeight: header.displayHeight,
|
|
217
235
|
headerBgColor: header.placeholderColor,
|
|
218
236
|
// Announcement-only: re-derive cadence + improvements from the site row so the SENT email
|
|
219
|
-
// keeps its cadence copy + improvement callouts
|
|
220
|
-
// them entirely (they're not stored on the Reports row). Ignored by the other templates.
|
|
237
|
+
// keeps its cadence copy + improvement callouts (not stored on the Reports row).
|
|
221
238
|
...report.reportType === "Announcement" ? announcementSiteExtras(site) : {}
|
|
239
|
+
};
|
|
240
|
+
const { html, attachments, subject } = await renderReportEmail(reportData, {
|
|
241
|
+
header,
|
|
242
|
+
cidName,
|
|
243
|
+
subjectOverride: report.subjectOverride ?? void 0
|
|
222
244
|
});
|
|
223
|
-
const reportDate = report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date();
|
|
224
|
-
const subject = report.subjectOverride ?? `${site.name} \u2014 ${monthYear(reportDate)} ${report.reportType} Report`;
|
|
225
|
-
const attachments = [
|
|
226
|
-
toInlineAttachment({
|
|
227
|
-
bytes: header.bytes,
|
|
228
|
-
filename: `${cidName}.jpg`,
|
|
229
|
-
contentType: header.contentType,
|
|
230
|
-
cid: cidName
|
|
231
|
-
})
|
|
232
|
-
];
|
|
233
|
-
for (const img of [bundled.check, bundled.blurred]) {
|
|
234
|
-
if (html.includes(`cid:${img.cid}`)) {
|
|
235
|
-
attachments.push(
|
|
236
|
-
toInlineAttachment({
|
|
237
|
-
bytes: img.bytes,
|
|
238
|
-
filename: img.filename,
|
|
239
|
-
contentType: img.contentType,
|
|
240
|
-
cid: img.cid
|
|
241
|
-
})
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
245
|
const payload = {
|
|
246
246
|
from: FROM_ADDRESS,
|
|
247
247
|
to,
|
|
@@ -295,10 +295,12 @@ function isProbablyEmail(s) {
|
|
|
295
295
|
}
|
|
296
296
|
|
|
297
297
|
export {
|
|
298
|
+
renderReportEmail,
|
|
299
|
+
prepareHeaderImage,
|
|
298
300
|
GLOBAL_REPORT_CC,
|
|
299
301
|
withGlobalCc,
|
|
300
302
|
sendApprovedReports,
|
|
301
303
|
parseAddresses,
|
|
302
304
|
isProbablyEmail
|
|
303
305
|
};
|
|
304
|
-
//# sourceMappingURL=chunk-
|
|
306
|
+
//# sourceMappingURL=chunk-J37CZORZ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/send/render-email.ts","../src/reports/maintenance-email/header-image.ts","../src/reports/send/orchestrate.ts"],"sourcesContent":["import { renderReportHtml } from \"../render.js\";\nimport { loadBundledImages } from \"../maintenance-email/assets/index.js\";\nimport { defaultReportSubject } from \"../subject.js\";\nimport type { ReportData } from \"../types.js\";\nimport type { ResendSendInput } from \"./resend.js\";\n\n/** A single Resend inline attachment (CID-referenced). */\nexport type InlineAttachment = NonNullable<ResendSendInput[\"attachments\"]>[number];\n\n/** The downscaled header image + display metadata produced by `prepareHeaderImage`. */\nexport type PreparedHeader = {\n bytes: Uint8Array;\n contentType: string;\n displayWidth: number;\n displayHeight: number;\n placeholderColor: string;\n};\n\nexport type RenderedReportEmail = {\n html: string;\n attachments: InlineAttachment[];\n subject: string;\n};\n\n/** Build a Resend inline (CID-referenced) attachment from raw bytes — the header image and both\n * bundled images share this exact shape. */\nfunction toInlineAttachment(a: {\n bytes: Uint8Array;\n filename: string;\n contentType: string;\n cid: string;\n}): InlineAttachment {\n return {\n filename: a.filename,\n content: Buffer.from(a.bytes).toString(\"base64\"),\n contentType: a.contentType,\n inlineContentId: a.cid,\n };\n}\n\n/**\n * Render a report email from fully-assembled `ReportData`: produce the HTML, the gated inline\n * attachments, and the subject. The per-site header attaches always; the two bundled images\n * (`rd-check-png`, `rd-blurred-tests-jpg`) attach only when their cid actually appears in the\n * rendered HTML (a dangling inline part shows as a stray download in some clients). The subject\n * is `subjectOverride` when given, else `defaultReportSubject`. Shared by the production send path\n * (`sendOne`) and the `selftest` command so the rendered email, attachments, and subject can't\n * drift between them. The only I/O is `loadBundledImages` (a disk read of two bundled images).\n */\nexport async function renderReportEmail(\n reportData: ReportData,\n ctx: { header: PreparedHeader; cidName: string; subjectOverride?: string | undefined },\n): Promise<RenderedReportEmail> {\n const { html } = await renderReportHtml(reportData);\n const bundled = await loadBundledImages();\n const attachments: InlineAttachment[] = [\n toInlineAttachment({\n bytes: ctx.header.bytes,\n filename: `${ctx.cidName}.jpg`,\n contentType: ctx.header.contentType,\n cid: ctx.cidName,\n }),\n ];\n for (const img of [bundled.check, bundled.blurred]) {\n if (html.includes(`cid:${img.cid}`)) {\n attachments.push(\n toInlineAttachment({\n bytes: img.bytes,\n filename: img.filename,\n contentType: img.contentType,\n cid: img.cid,\n }),\n );\n }\n }\n const subject =\n ctx.subjectOverride ??\n defaultReportSubject({\n name: reportData.siteName,\n url: reportData.siteUrl,\n type: reportData.reportType,\n date: reportData.completedOn,\n });\n return { html, attachments, subject };\n}\n","import sharp from \"sharp\";\n\nexport type PreparedHeaderImage = {\n /** Resized JPEG bytes to attach inline (CID) in place of the Airtable original. */\n bytes: Uint8Array;\n /** Always \"image/jpeg\" — we re-encode for predictable size and a flat white background. */\n contentType: string;\n /** CSS display width in px (≤ requested, never wider than the source has pixels for). */\n displayWidth: number;\n /** CSS display height in px, source aspect ratio preserved (no distortion). */\n displayHeight: number;\n /** Dominant-color hex (e.g. \"#cfc3a8\"), used as the loading/blocked placeholder box. */\n placeholderColor: string;\n};\n\nexport type PrepareHeaderImageOptions = {\n /** Intended CSS display width. The email body is 600px, so that's the default. */\n displayWidth?: number;\n};\n\nconst DEFAULT_DISPLAY_WIDTH = 600;\n/** Encode the source at 2× display width so it stays crisp on retina screens. */\nconst RETINA_SCALE = 2;\n/** Quality is for *resized* pixels — at 1200px the texture/text read as sharp; bytes are tiny. */\nconst JPEG_QUALITY = 82;\n\nfunction channelToHex(value: number): string {\n return Math.max(0, Math.min(255, Math.round(value)))\n .toString(16)\n .padStart(2, \"0\");\n}\n\n/**\n * Downscale an oversized header image for email: 2× the display width (retina) at most,\n * never upscaled, re-encoded as JPEG on a flat white background. Also reports the display\n * dimensions (so the template can reserve the box and stop reflow) and a dominant color\n * (so the reserved box shows a matched placeholder while the image loads).\n *\n * Root cause this addresses: Airtable headers can be multi-MB / 2400px+ while the email\n * renders them at ~600px — shipping ~16× more pixels than the display can use.\n */\nexport async function prepareHeaderImage(\n bytes: Uint8Array,\n options: PrepareHeaderImageOptions = {},\n): Promise<PreparedHeaderImage> {\n const requestedDisplayWidth = options.displayWidth ?? DEFAULT_DISPLAY_WIDTH;\n const input = Buffer.from(bytes);\n\n const meta = await sharp(input).metadata();\n const origWidth = meta.width;\n const origHeight = meta.height;\n if (!origWidth || !origHeight) {\n throw new Error(\"prepareHeaderImage: could not read source image dimensions\");\n }\n\n // Never claim a wider display than the source can fill at 1×.\n const displayWidth = Math.min(requestedDisplayWidth, origWidth);\n const displayHeight = Math.round((displayWidth * origHeight) / origWidth);\n\n // Encode at 2× display for retina, but never enlarge a smaller original.\n const targetSourceWidth = Math.min(origWidth, displayWidth * RETINA_SCALE);\n\n const out = await sharp(input)\n .resize({ width: targetSourceWidth, withoutEnlargement: true })\n .flatten({ background: \"#ffffff\" })\n .jpeg({ quality: JPEG_QUALITY })\n .toBuffer();\n\n const { dominant } = await sharp(out).stats();\n const placeholderColor = `#${channelToHex(dominant.r)}${channelToHex(dominant.g)}${channelToHex(dominant.b)}`;\n\n return {\n bytes: new Uint8Array(out),\n contentType: \"image/jpeg\",\n displayWidth,\n displayHeight,\n placeholderColor,\n };\n}\n","import { openBase, readAirtableConfig } from \"../airtable/client.js\";\nimport { listSendableReports, stampSent } from \"../airtable/reports.js\";\nimport { listWebsites, siteSlug, updateLaunched } from \"../airtable/websites.js\";\nimport type { WebsiteRow } from \"../airtable/websites.js\";\nimport type { ReportRow } from \"../airtable/reports.js\";\nimport { fetchAttachmentBytes } from \"../airtable/attachments.js\";\nimport { resolveCopy } from \"../copy.js\";\nimport { announcementSiteExtras } from \"../announcement-email/template.js\";\nimport { renderReportEmail } from \"./render-email.js\";\nimport type { ReportData } from \"../types.js\";\nimport { prepareHeaderImage } from \"../maintenance-email/header-image.js\";\nimport { defaultResendClient, type ResendClient } from \"./resend.js\";\nimport { isIdempotencyConflict } from \"./idempotency.js\";\nimport { checklistFor, isChecklistComplete } from \"../checklist.js\";\nimport { recordFleetEventsBestEffort } from \"../../audits/fleet-events-writer.js\";\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\nconst REPLY_TO = \"info@reddoorla.com\";\n\n/** Operations inbox CC'd on every outgoing report so there's always an internal\n * copy on file alongside the client recipients. */\nexport const GLOBAL_REPORT_CC = \"info@reddoorla.com\";\n\n/**\n * Append {@link GLOBAL_REPORT_CC} to a site's per-site CC list. The per-site CC is\n * passed through unchanged (preserving prior behavior); the global address is added\n * only when it isn't already present in the CC or To lists (case-insensitive), so a\n * report is never double-addressed to the ops inbox. Returns the final CC list (may\n * be empty if the global address is already the sole To recipient — the caller omits\n * an empty CC).\n */\nexport function withGlobalCc(perSiteCc: string[] | null, to: string[]): string[] {\n const cc = [...(perSiteCc ?? [])];\n const present = new Set([...cc, ...to].map((a) => a.toLowerCase()));\n if (!present.has(GLOBAL_REPORT_CC.toLowerCase())) cc.push(GLOBAL_REPORT_CC);\n return cc;\n}\n\n/** Whole days spanned by an ISO period window, or undefined when either bound is missing or the\n * span isn't positive. Drives the analytics trend's \"vs the previous N days\" label. */\nfunction windowDays(start: string | null, end: string | null): number | undefined {\n if (!start || !end) return undefined;\n const ms = new Date(end).getTime() - new Date(start).getTime();\n if (!Number.isFinite(ms) || ms <= 0) return undefined;\n return Math.round(ms / (24 * 60 * 60 * 1000));\n}\n\nexport type OrchestrateOptions = {\n resend?: ResendClient;\n};\n\nexport async function sendApprovedReports(\n options: OrchestrateOptions = {},\n): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n const client = options.resend ?? defaultResendClient();\n\n const sendable = await listSendableReports(base);\n if (sendable.length === 0) return { output: \"No reports ready to send.\", code: 0 };\n\n const websites = await listWebsites(base);\n const sites = new Map(websites.map((w) => [w.id, w]));\n\n const lines: string[] = [];\n let anyFailed = false;\n for (const report of sendable) {\n const site = sites.get(report.siteId);\n if (!site) {\n lines.push(`✗ ${report.reportId} — Site row not found for id=${report.siteId}`);\n anyFailed = true;\n continue;\n }\n try {\n const messageId = await sendOne(client, base, site, report);\n lines.push(`✓ sent: ${report.reportId} (${messageId})`);\n if (report.reportType === \"Launch\") {\n try {\n await updateLaunched(base, site.id, new Date().toISOString());\n lines.push(` ↳ launched: ${site.name} flipped to maintenance`);\n await recordFleetEventsBestEffort(\n [\n {\n id: `site_launched:${site.id}`,\n ts: new Date().toISOString(),\n type: \"site_launched\",\n siteId: site.id,\n siteName: site.name,\n summary: \"launched — now in maintenance\",\n data: null,\n },\n ],\n new Date(),\n );\n } catch (e) {\n lines.push(` ⚠ launch flip failed for ${site.name}: ${(e as Error).message}`);\n }\n }\n } catch (e) {\n lines.push(`✗ ${report.reportId} — ${(e as Error).message}`);\n anyFailed = true;\n }\n }\n return { output: lines.join(\"\\n\"), code: anyFailed ? 1 : 0 };\n}\n\nasync function sendOne(\n client: ResendClient,\n base: ReturnType<typeof openBase>,\n site: WebsiteRow,\n report: ReportRow,\n): Promise<string> {\n // Hard checklist gate: a Maintenance/Testing report whose operator checklist isn't\n // fully checked must never go out — even if \"Approved to send\" was ticked directly in\n // Airtable, bypassing the dashboard's approve gate. Throw so the report is skipped and\n // `Sent at` stays null (at-least-once retry preserved), exactly like the other sendOne\n // guards. Launch/Announcement have an empty checklist → vacuously complete, never gated.\n if (!isChecklistComplete(report)) {\n const items = checklistFor(report.reportType);\n const done = items.filter((i) => report.checklist[i.field] === true).length;\n throw new Error(\n `Report ${report.reportId} checklist incomplete — ${done}/${items.length} items checked`,\n );\n }\n if (!site.headerImage) {\n throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);\n }\n if (!report.lighthouse) {\n throw new Error(\n `Report ${report.reportId} has no Lighthouse scores — all four cells ` +\n `(Lighthouse — Performance / Accessibility / Best Practices / SEO) must be numeric ` +\n `on the Reports row; one non-numeric or blank cell nulls all four`,\n );\n }\n\n // Resolve + validate recipients BEFORE the expensive work (header fetch + sharp\n // downscale + full MJML render). A misconfigured-recipients site is a guaranteed\n // failure, so fail fast here rather than after burning that work. Same checks +\n // messages as before — only the position moved.\n const explicitTo = parseAddresses(site.reportRecipientsTo);\n // Run pointOfContact through the parser too — operators sometimes paste\n // \"a@x, b@y\" into that single-line field.\n const fallbackTo = parseAddresses(site.pointOfContact);\n const to = explicitTo ?? fallbackTo ?? [];\n if (to.length === 0) {\n throw new Error(\n `Site '${site.name}' has no recipients (Report recipients (To) AND point of contact are both empty)`,\n );\n }\n for (const addr of to) {\n if (!isProbablyEmail(addr)) {\n throw new Error(\n `Site '${site.name}' recipient is malformed: ${addr} — use a bare address only ` +\n `(no \\`Name <addr>\\` display-name syntax); fix Report recipients (To) or point of contact in Airtable`,\n );\n }\n }\n const cc = parseAddresses(site.reportRecipientsCc);\n if (cc) {\n for (const addr of cc) {\n if (!isProbablyEmail(addr)) {\n throw new Error(\n `Site '${site.name}' CC is malformed: ${addr} — fix Report recipients (CC) in Airtable`,\n );\n }\n }\n }\n\n const original = await fetchAttachmentBytes(site.headerImage.url);\n // Downscale the (often multi-MB / 2400px+) Airtable header to email display size, and get\n // back display dims + a placeholder color so the template can reserve the box.\n const header = await prepareHeaderImage(original.bytes);\n\n const slug = siteSlug(site.name);\n const cidName = `${slug}-header`;\n const gaPeriodDays =\n report.reportType === \"Announcement\" ? 30 : windowDays(report.periodStart, report.periodEnd);\n const reportData: ReportData = {\n siteName: site.name,\n siteUrl: site.url,\n reportType: report.reportType,\n completedOn: report.completedOn ? new Date(report.completedOn) : new Date(),\n lighthouse: report.lighthouse,\n gaUsersCurrent: report.gaUsersCurrent ?? undefined,\n gaUsersPrevious: report.gaUsersPrevious ?? undefined,\n gaPeriodDays,\n searchPosition:\n report.searchFoundPage1 && report.searchPosition !== null ? report.searchPosition : undefined,\n lastTestedDate: report.lastTestedDate ? new Date(report.lastTestedDate) : null,\n commentary: report.commentary,\n copy: resolveCopy(site),\n headerImageCid: cidName,\n headerWidth: header.displayWidth,\n headerHeight: header.displayHeight,\n headerBgColor: header.placeholderColor,\n // Announcement-only: re-derive cadence + improvements from the site row so the SENT email\n // keeps its cadence copy + improvement callouts (not stored on the Reports row).\n ...(report.reportType === \"Announcement\" ? announcementSiteExtras(site) : {}),\n };\n const { html, attachments, subject } = await renderReportEmail(reportData, {\n header,\n cidName,\n subjectOverride: report.subjectOverride ?? undefined,\n });\n\n const payload: Parameters<ResendClient[\"send\"]>[0] = {\n from: FROM_ADDRESS,\n to,\n replyTo: REPLY_TO,\n subject,\n html,\n attachments,\n // Stable across retries of the same row — if Airtable stamping fails after a\n // successful Resend, the next --send-ready replays with the same key and\n // Resend returns the original message id rather than sending a duplicate.\n idempotencyKey: `report:${report.id}`,\n };\n // Always CC the ops inbox (info@reddoorla.com), in addition to any per-site CC.\n const finalCc = withGlobalCc(cc, to);\n if (finalCc.length > 0) payload.cc = finalCc;\n\n let result: Awaited<ReturnType<ResendClient[\"send\"]>>;\n try {\n result = await client.send(payload);\n } catch (err) {\n // The send path is at-least-once: client.send succeeds → stampSent writes\n // `Sent at` (the ONLY thing that removes the row from listSendableReports). If\n // stampSent threw on a PRIOR run (an Airtable blip), `Sent at` stayed null and\n // the row replays here. By replay time the rendered body has usually changed\n // (operator Commentary edit, `report --due` rewrote scores, or the header\n // re-encodes non-deterministically), so Resend rejects the same-key\n // (`report:<id>`) / different-body re-send with a 409 (`invalid_idempotent_request`).\n //\n // That 409 means the email ALREADY WENT OUT under this key on the prior run.\n // Do NOT re-throw and do NOT re-send (re-throwing leaves the row unstamped, and\n // after the 24h key TTL a SECOND real email would go out). Instead stamp the row\n // so it stops replaying, then return success so the caller runs the Launch flip —\n // which self-heals a launch that sent-but-never-flipped on the prior run.\n //\n // Any OTHER error (real network/Resend failure) re-throws, exactly as before, so\n // a genuine failure still fails loudly and the row replays next run.\n if (isIdempotencyConflict(err)) {\n // Stamp `Sent at` ONLY — the original send's messageId is unrecoverable on\n // the 409 path, so we leave `Resend message ID` null rather than writing a\n // sentinel that would masquerade as a real id and orphan webhook lookups.\n // Still return the sentinel string so the caller logs the already-sent path\n // and runs the Launch flip.\n await stampSent(base, report.id, new Date(), null);\n console.log(`↻ already sent (idempotency conflict), stamped: ${report.reportId}`);\n return \"idempotent-conflict\";\n }\n throw err;\n }\n await stampSent(base, report.id, new Date(), result.messageId);\n return result.messageId;\n}\n\n/**\n * Split a comma/newline-separated address field into a clean array.\n * Lowercases (case-insensitive dedupe) and removes empty entries. Returns\n * null if nothing survives. Does NOT understand `Display Name <email>` —\n * operators should put a bare address in the Airtable field, or use multiple\n * lines if needing multiple recipients.\n */\nexport function parseAddresses(field: string | null): string[] | null {\n if (!field) return null;\n const seen = new Set<string>();\n const list: string[] = [];\n for (const raw of field.split(/[,\\n]/)) {\n const trimmed = raw.trim().toLowerCase();\n if (!trimmed) continue;\n if (seen.has(trimmed)) continue;\n seen.add(trimmed);\n list.push(trimmed);\n }\n return list.length > 0 ? list : null;\n}\n\n/**\n * Cheap email shape check — must contain exactly one @, with non-empty\n * local and domain parts and at least one dot in the domain. We're not\n * trying to be a full RFC validator; we're trying to catch operator\n * mistakes like \"ops at acme dot com\" or a missing @ before they 422\n * at Resend.\n */\nexport function isProbablyEmail(s: string): boolean {\n const at = s.indexOf(\"@\");\n if (at < 1 || at !== s.lastIndexOf(\"@\")) return false;\n const local = s.slice(0, at);\n const domain = s.slice(at + 1);\n if (!local || !domain) return false;\n if (!domain.includes(\".\")) return false;\n if (/\\s/.test(s)) return false;\n return true;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
renderReportHtml,
|
|
6
6
|
resolveCopy,
|
|
7
7
|
uploadAttachment
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-U3MF6RCS.js";
|
|
9
9
|
import {
|
|
10
10
|
checklistFor,
|
|
11
11
|
createDraft,
|
|
@@ -441,4 +441,4 @@ export {
|
|
|
441
441
|
fetchGaUsers,
|
|
442
442
|
fetchSearch
|
|
443
443
|
};
|
|
444
|
-
//# sourceMappingURL=chunk-
|
|
444
|
+
//# sourceMappingURL=chunk-NPJFHBAX.js.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fetchGaUsers,
|
|
3
|
+
fetchSearch
|
|
4
|
+
} from "./chunk-NPJFHBAX.js";
|
|
5
|
+
import {
|
|
6
|
+
announcementSiteExtras,
|
|
7
|
+
resolveCopy
|
|
8
|
+
} from "./chunk-U3MF6RCS.js";
|
|
9
|
+
import {
|
|
10
|
+
siteSlug
|
|
11
|
+
} from "./chunk-BLD6AYJO.js";
|
|
12
|
+
|
|
13
|
+
// src/reports/report-data.ts
|
|
14
|
+
var PREVIEW_WINDOW_DAYS = 30;
|
|
15
|
+
function scoresFromRow(site) {
|
|
16
|
+
if (site.pScore === null || site.rScore === null || site.bpScore === null || site.seoScore === null) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
performance: site.pScore,
|
|
21
|
+
accessibility: site.rScore,
|
|
22
|
+
bestPractices: site.bpScore,
|
|
23
|
+
seo: site.seoScore
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
async function buildReportDataForSite(site, type, now, opts) {
|
|
27
|
+
const { scores, header } = opts;
|
|
28
|
+
const cidName = `${siteSlug(site.name)}-header`;
|
|
29
|
+
const base = {
|
|
30
|
+
siteName: site.name,
|
|
31
|
+
siteUrl: site.url,
|
|
32
|
+
reportType: type,
|
|
33
|
+
completedOn: now,
|
|
34
|
+
lighthouse: scores,
|
|
35
|
+
lastTestedDate: type === "Maintenance" && site.lastLighthouseAuditAt ? new Date(site.lastLighthouseAuditAt) : null,
|
|
36
|
+
commentary: null,
|
|
37
|
+
copy: resolveCopy(site),
|
|
38
|
+
headerImageCid: cidName,
|
|
39
|
+
headerWidth: header.displayWidth,
|
|
40
|
+
headerHeight: header.displayHeight,
|
|
41
|
+
headerBgColor: header.placeholderColor
|
|
42
|
+
};
|
|
43
|
+
if (type === "Launch") return base;
|
|
44
|
+
const periodStart = new Date(now.getTime() - PREVIEW_WINDOW_DAYS * 24 * 60 * 60 * 1e3);
|
|
45
|
+
const gaUsers = (await fetchGaUsers(site, periodStart, now)).value;
|
|
46
|
+
const search = (await fetchSearch(site, periodStart, now)).value;
|
|
47
|
+
const withAnalytics = {
|
|
48
|
+
...base,
|
|
49
|
+
...gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {},
|
|
50
|
+
gaPeriodDays: PREVIEW_WINDOW_DAYS,
|
|
51
|
+
...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {}
|
|
52
|
+
};
|
|
53
|
+
if (type === "Announcement") {
|
|
54
|
+
return { ...withAnalytics, ...announcementSiteExtras(site) };
|
|
55
|
+
}
|
|
56
|
+
return withAnalytics;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export {
|
|
60
|
+
scoresFromRow,
|
|
61
|
+
buildReportDataForSite
|
|
62
|
+
};
|
|
63
|
+
//# sourceMappingURL=chunk-P7VJM46I.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/report-data.ts"],"sourcesContent":["import type { WebsiteRow } from \"./airtable/websites.js\";\nimport { siteSlug } from \"./airtable/websites.js\";\nimport type { LighthouseScores, ReportData, ReportType } from \"./types.js\";\nimport { resolveCopy } from \"./copy.js\";\nimport { fetchGaUsers, fetchSearch } from \"./draft.js\";\nimport { announcementSiteExtras } from \"./announcement-email/template.js\";\nimport type { PreparedHeader } from \"./send/render-email.js\";\n\n/** The traffic/search lookback window (days) used for report-email previews. */\nconst PREVIEW_WINDOW_DAYS = 30;\n\n/** The four stored Lighthouse scores off a Websites row, or null if ANY is missing. */\nexport function scoresFromRow(site: WebsiteRow): LighthouseScores | null {\n if (\n site.pScore === null ||\n site.rScore === null ||\n site.bpScore === null ||\n site.seoScore === null\n ) {\n return null;\n }\n return {\n performance: site.pScore,\n accessibility: site.rScore,\n bestPractices: site.bpScore,\n seo: site.seoScore,\n };\n}\n\n/**\n * Assemble the `ReportData` for a report email from a Websites row, for a given report type. Used\n * by the `selftest` command to preview any report type without an Airtable Reports row. Reuses the\n * same enrichment helpers as the real drafts (`fetchGaUsers`/`fetchSearch`, `resolveCopy`,\n * `announcementSiteExtras`). The GA window is a fixed 30 days (a no-write preview can't read the\n * real recurrence anchor). `Launch` skips GA entirely — the launch email shows no analytics.\n */\nexport async function buildReportDataForSite(\n site: WebsiteRow,\n type: ReportType,\n now: Date,\n opts: { scores: LighthouseScores; header: PreparedHeader },\n): Promise<ReportData> {\n const { scores, header } = opts;\n const cidName = `${siteSlug(site.name)}-header`;\n const base: ReportData = {\n siteName: site.name,\n siteUrl: site.url,\n reportType: type,\n completedOn: now,\n lighthouse: scores,\n lastTestedDate:\n type === \"Maintenance\" && site.lastLighthouseAuditAt\n ? new Date(site.lastLighthouseAuditAt)\n : null,\n commentary: null,\n copy: resolveCopy(site),\n headerImageCid: cidName,\n headerWidth: header.displayWidth,\n headerHeight: header.displayHeight,\n headerBgColor: header.placeholderColor,\n };\n\n // The launch email renders no analytics — don't even fetch GA/search.\n if (type === \"Launch\") return base;\n\n const periodStart = new Date(now.getTime() - PREVIEW_WINDOW_DAYS * 24 * 60 * 60 * 1000);\n const gaUsers = (await fetchGaUsers(site, periodStart, now)).value;\n const search = (await fetchSearch(site, periodStart, now)).value;\n\n const withAnalytics: ReportData = {\n ...base,\n ...(gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {}),\n gaPeriodDays: PREVIEW_WINDOW_DAYS,\n ...(search?.foundOnPage1 && search.position !== null\n ? { searchPosition: search.position }\n : {}),\n };\n\n if (type === \"Announcement\") {\n return { ...withAnalytics, ...announcementSiteExtras(site) };\n }\n return withAnalytics; // Maintenance / Testing\n}\n"],"mappings":";;;;;;;;;;;;;AASA,IAAM,sBAAsB;AAGrB,SAAS,cAAc,MAA2C;AACvE,MACE,KAAK,WAAW,QAChB,KAAK,WAAW,QAChB,KAAK,YAAY,QACjB,KAAK,aAAa,MAClB;AACA,WAAO;AAAA,EACT;AACA,SAAO;AAAA,IACL,aAAa,KAAK;AAAA,IAClB,eAAe,KAAK;AAAA,IACpB,eAAe,KAAK;AAAA,IACpB,KAAK,KAAK;AAAA,EACZ;AACF;AASA,eAAsB,uBACpB,MACA,MACA,KACA,MACqB;AACrB,QAAM,EAAE,QAAQ,OAAO,IAAI;AAC3B,QAAM,UAAU,GAAG,SAAS,KAAK,IAAI,CAAC;AACtC,QAAM,OAAmB;AAAA,IACvB,UAAU,KAAK;AAAA,IACf,SAAS,KAAK;AAAA,IACd,YAAY;AAAA,IACZ,aAAa;AAAA,IACb,YAAY;AAAA,IACZ,gBACE,SAAS,iBAAiB,KAAK,wBAC3B,IAAI,KAAK,KAAK,qBAAqB,IACnC;AAAA,IACN,YAAY;AAAA,IACZ,MAAM,YAAY,IAAI;AAAA,IACtB,gBAAgB;AAAA,IAChB,aAAa,OAAO;AAAA,IACpB,cAAc,OAAO;AAAA,IACrB,eAAe,OAAO;AAAA,EACxB;AAGA,MAAI,SAAS,SAAU,QAAO;AAE9B,QAAM,cAAc,IAAI,KAAK,IAAI,QAAQ,IAAI,sBAAsB,KAAK,KAAK,KAAK,GAAI;AACtF,QAAM,WAAW,MAAM,aAAa,MAAM,aAAa,GAAG,GAAG;AAC7D,QAAM,UAAU,MAAM,YAAY,MAAM,aAAa,GAAG,GAAG;AAE3D,QAAM,gBAA4B;AAAA,IAChC,GAAG;AAAA,IACH,GAAI,UAAU,EAAE,gBAAgB,QAAQ,SAAS,iBAAiB,QAAQ,SAAS,IAAI,CAAC;AAAA,IACxF,cAAc;AAAA,IACd,GAAI,QAAQ,gBAAgB,OAAO,aAAa,OAC5C,EAAE,gBAAgB,OAAO,SAAS,IAClC,CAAC;AAAA,EACP;AAEA,MAAI,SAAS,gBAAgB;AAC3B,WAAO,EAAE,GAAG,eAAe,GAAG,uBAAuB,IAAI,EAAE;AAAA,EAC7D;AACA,SAAO;AACT;","names":[]}
|
|
@@ -196,18 +196,22 @@ function trendLine(color, text) {
|
|
|
196
196
|
function footnoteLine(text) {
|
|
197
197
|
return `<mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" padding-bottom="36px" line-height="20px">${text}</mj-text>`;
|
|
198
198
|
}
|
|
199
|
+
function hasAnalyticsData(opts) {
|
|
200
|
+
return opts.current !== void 0 || (opts.bodyLines?.length ?? 0) > 0;
|
|
201
|
+
}
|
|
199
202
|
function analyticsSection(opts) {
|
|
200
|
-
|
|
203
|
+
if (!hasAnalyticsData(opts)) return "";
|
|
201
204
|
const body = (opts.bodyLines ?? []).map((l) => trendLine(TREND_NEUTRAL, l)).join("\n ");
|
|
202
205
|
const footnotes = (opts.footnoteLines ?? []).map(footnoteLine).join("\n ");
|
|
203
206
|
const sectionPad = opts.pad ? ` padding-top="${opts.pad}" padding-bottom="${opts.pad}"` : "";
|
|
204
207
|
const labelTop = opts.pad ?? "75px";
|
|
208
|
+
const usersBlock = opts.current !== void 0 ? `
|
|
209
|
+
<mj-text color="${RED}" font-size="44px" font-weight="400">${fmtUsers(opts.current)} Users</mj-text>
|
|
210
|
+
${analyticsTrendLine(opts.current, opts.previous, opts.periodDays)}` : "";
|
|
205
211
|
return `
|
|
206
212
|
<mj-section background-color="${opts.background}"${sectionPad}>
|
|
207
213
|
<mj-column>
|
|
208
|
-
<mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="${labelTop}">ANALYTICS</mj-text
|
|
209
|
-
<mj-text color="${RED}" font-size="44px" font-weight="400">${users} Users</mj-text>
|
|
210
|
-
${analyticsTrendLine(opts.current, opts.previous, opts.periodDays)}
|
|
214
|
+
<mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="${labelTop}">ANALYTICS</mj-text>${usersBlock}
|
|
211
215
|
${body}
|
|
212
216
|
${footnotes}
|
|
213
217
|
</mj-column>
|
|
@@ -376,6 +380,11 @@ function buildAnnouncementMjml(data) {
|
|
|
376
380
|
if (data.improvements?.resendForms) improvementItems.push(copy.announceImprovementResend);
|
|
377
381
|
if (data.improvements?.svelte5) improvementItems.push(copy.announceImprovementSvelte5);
|
|
378
382
|
const hasImpr = improvementItems.length > 0;
|
|
383
|
+
const analyticsBodyLines = data.searchPosition !== void 0 ? [`Page 1 Google result (#${data.searchPosition}) for your brand search`] : [];
|
|
384
|
+
const hasAnalytics = hasAnalyticsData({
|
|
385
|
+
current: data.gaUsersCurrent,
|
|
386
|
+
bodyLines: analyticsBodyLines
|
|
387
|
+
});
|
|
379
388
|
const BANDS = ["white", "#F4F4F4"];
|
|
380
389
|
let bandN = 0;
|
|
381
390
|
const nextBg = () => BANDS[bandN++ % 2];
|
|
@@ -383,7 +392,7 @@ function buildAnnouncementMjml(data) {
|
|
|
383
392
|
const maintBg = hasMaint ? nextBg() : "";
|
|
384
393
|
const testBg = hasTesting ? nextBg() : "";
|
|
385
394
|
const lighthouseBg = nextBg();
|
|
386
|
-
const analyticsBg = nextBg();
|
|
395
|
+
const analyticsBg = hasAnalytics ? nextBg() : "";
|
|
387
396
|
const improvementsBg = hasImpr ? nextBg() : "";
|
|
388
397
|
const contactBg = nextBg();
|
|
389
398
|
const maintenanceSection = cad && cad.maintenance !== "None" ? `
|
|
@@ -406,14 +415,14 @@ function buildAnnouncementMjml(data) {
|
|
|
406
415
|
background: testBg,
|
|
407
416
|
lastPaddingBottom: SECTION_PAD
|
|
408
417
|
})}` : "";
|
|
409
|
-
const analytics = analyticsSection({
|
|
418
|
+
const analytics = hasAnalytics ? analyticsSection({
|
|
410
419
|
current: data.gaUsersCurrent,
|
|
411
420
|
previous: data.gaUsersPrevious,
|
|
412
421
|
periodDays: data.gaPeriodDays,
|
|
413
422
|
background: analyticsBg,
|
|
414
423
|
pad: SECTION_PAD,
|
|
415
|
-
bodyLines:
|
|
416
|
-
});
|
|
424
|
+
bodyLines: analyticsBodyLines
|
|
425
|
+
}) : "";
|
|
417
426
|
const improvementsSection = hasImpr ? `
|
|
418
427
|
<mj-section background-color="${improvementsBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
|
|
419
428
|
<mj-column>
|
|
@@ -592,4 +601,4 @@ export {
|
|
|
592
601
|
fetchAttachmentBytes,
|
|
593
602
|
uploadAttachment
|
|
594
603
|
};
|
|
595
|
-
//# sourceMappingURL=chunk-
|
|
604
|
+
//# sourceMappingURL=chunk-U3MF6RCS.js.map
|