@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.
@@ -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-TYRCYHRA.js";
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-2T3HZ3DB.js";
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: `Your testing & maintenance report for ${siteLabel(w)}`,
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-54EBITHX.js.map
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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCA,IAAM,iBAAiB;AAoBvB,eAAsB,SAAS,MAA8C;AAC3E,QAAM,OAAO,MAAM,QAAQ,SAAS,mBAAmB,CAAC;AACxD,QAAM,MAAM,MAAM,OAAO,oBAAI,KAAK;AAElC,QAAM,WAAW,MAAM,aAAa,IAAI;AACxC,MAAI,UAAU,SAAS,OAAO,CAAC,MAAM,EAAE,WAAW,aAAa;AAC/D,MAAI,MAAM,MAAM;AACd,UAAM,SAAS,SAAS,KAAK,IAAI;AACjC,cAAU,QAAQ,OAAO,CAAC,MAAM,SAAS,EAAE,IAAI,MAAM,MAAM;AAAA,EAC7D;AAEA,QAAM,SAAS,IAAI,YAAY,EAAE,MAAM,GAAG,CAAC;AAC3C,QAAM,UAAgC,CAAC;AAEvC,aAAW,KAAK,SAAS;AACvB,QAAI;AACF,YAAM,SAAS,cAAc,CAAC;AAC9B,UAAI,WAAW,MAAM;AACnB,gBAAQ,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,oBAAoB,CAAC;AAC1D;AAAA,MACF;AAMA,YAAM,YAAY;AAClB,YAAM,cAAc,IAAI,KAAK,IAAI,QAAQ,IAAI,iBAAiB,KAAK,KAAK,KAAK,GAAI;AACjF,YAAM,WAAW,MAAM,aAAa,GAAG,aAAa,SAAS,GAAG;AAChE,YAAM,UAAU,MAAM,YAAY,GAAG,aAAa,SAAS,GAAG;AAC9D,YAAM,aAA+B;AAAA,QACnC,GAAI,UAAU,EAAE,gBAAgB,QAAQ,SAAS,iBAAiB,QAAQ,SAAS,IAAI,CAAC;AAAA,QACxF,GAAI,SAAS,EAAE,kBAAkB,OAAO,aAAa,IAAI,CAAC;AAAA,QAC1D,GAAI,QAAQ,gBAAgB,OAAO,aAAa,OAC5C,EAAE,gBAAgB,OAAO,SAAS,IAClC,CAAC;AAAA,MACP;AAMA,UAAI;AACJ,UAAI;AACJ,YAAM,WAAW,MAAM,mBAAmB,MAAM,EAAE,IAAI,gBAAgB,MAAM;AAC5E,UAAI,UAAU;AACZ,cAAM,mBAAmB,MAAM,SAAS,IAAI,QAAQ,KAAK,UAAU;AACnE,iBAAS;AACT,qBAAa;AAAA,MACf,OAAO;AACL,iBAAS,MAAM,YAAY,MAAM,cAAc,GAAG,QAAQ,KAAK,QAAQ,UAAU,CAAC;AAClF,qBAAa;AAAA,MACf;AAEA,YAAM,OAAO,SAAS,EAAE,IAAI;AAC5B,YAAM,EAAE,KAAK,IAAI,MAAM,iBAAiB;AAAA,QACtC,UAAU,EAAE;AAAA,QACZ,SAAS,EAAE;AAAA,QACX,YAAY;AAAA,QACZ,aAAa;AAAA,QACb,YAAY;AAAA,QACZ,gBAAgB,SAAS;AAAA,QACzB,iBAAiB,SAAS;AAAA,QAC1B,cAAc;AAAA,QACd,gBAAgB,QAAQ,eAAgB,OAAO,YAAY,SAAa;AAAA,QACxE,gBAAgB;AAAA,QAChB,YAAY;AAAA,QACZ,MAAM,YAAY,CAAC;AAAA,QACnB,gBAAgB,GAAG,IAAI;AAAA;AAAA;AAAA;AAAA,QAIvB,GAAG,uBAAuB,CAAC;AAAA,MAC7B,CAAC;AAGD,UAAI;AACF,cAAM;AAAA,UACJ,OAAO;AAAA,UACP;AAAA,UACA;AAAA,UACA,GAAG,IAAI,IAAI,IAAI,YAAY,EAAE,MAAM,GAAG,EAAE,CAAC;AAAA,UACzC;AAAA,QACF;AAAA,MACF,SAAS,WAAW;AAClB,gBAAQ;AAAA,UACN,kDAA6C,EAAE,IAAI,KACjD,qBAAqB,QAAQ,UAAU,UAAU,OAAO,SAAS,CACnE;AAAA,QACF;AAAA,MACF;AAMA,YAAM,QAAQ,MAAM,WAAW,MAAM;AAAA,QACnC,IAAI,OAAO;AAAA,QACX,QAAQ,EAAE;AAAA,QACV,YAAY;AAAA,MACd,CAAC;AAED,YAAM,mBAAmB,EAAE,EAAE,sBAAsB,EAAE,mBAAmB,KAAK;AAC7E,cAAQ,KAAK;AAAA,QACX,MAAM,EAAE;AAAA,QACR,QAAQ;AAAA,QACR,UAAU,OAAO;AAAA,QACjB;AAAA,QACA,QAAQ,MAAM;AAAA,MAChB,CAAC;AAAA,IACH,SAAS,KAAK;AACZ,cAAQ,KAAK;AAAA,QACX,MAAM,EAAE;AAAA,QACR,QAAQ;AAAA,QACR,SAAS,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,MAC1D,CAAC;AAAA,IACH;AAAA,EACF;AAEA,SAAO,EAAE,QAAQ;AACnB;AAKA,SAAS,cACP,GACA,QACA,KACA,QACA,YACmC;AACnC,SAAO;AAAA,IACL,UAAU,GAAG,EAAE,IAAI,+BAAqB,IAAI,YAAY,EAAE,MAAM,GAAG,EAAE,CAAC;AAAA,IACtE,QAAQ,EAAE;AAAA,IACV,YAAY;AAAA,IACZ;AAAA,IACA,aAAa;AAAA,IACb,WAAW;AAAA,IACX,aAAa;AAAA,IACb,YAAY;AAAA,IACZ,gBAAgB;AAAA,IAChB,iBAAiB,qBAAqB;AAAA,MACpC,MAAM,EAAE;AAAA,MACR,KAAK,EAAE;AAAA,MACP,MAAM;AAAA,MACN,MAAM;AAAA,IACR,CAAC;AAAA,IACD,GAAG;AAAA,EACL;AACF;;;ACxMA,SAAS,iBAAiB,GAA+B;AACvD,MAAI,EAAE,WAAW,oBAAqB,QAAO,IAAI,EAAE,IAAI;AACvD,MAAI,EAAE,WAAW,QAAS,QAAO,IAAI,EAAE,IAAI,YAAY,EAAE,OAAO;AAChE,QAAM,OAAO,EAAE,mBAAmB,8BAAyB;AAC3D,SAAO,IAAI,EAAE,IAAI,KAAK,EAAE,MAAM,GAAG,IAAI;AACvC;AAEO,SAAS,qBAAqB,QAAgC;AACnE,MAAI,OAAO,QAAQ,WAAW,EAAG,QAAO;AACxC,SAAO,OAAO,QAAQ,IAAI,gBAAgB,EAAE,KAAK,IAAI;AACvD;AASA,eAAsB,mBACpB,MACA,OAC2C;AAC3C,QAAM,SAAS,MAAM,SAAS,OAAO,EAAE,KAAK,IAAI,CAAC,CAAC;AAClD,QAAM,WAAW,OAAO,QAAQ,KAAK,CAAC,MAAM,EAAE,WAAW,OAAO;AAChE,SAAO,EAAE,QAAQ,qBAAqB,MAAM,GAAG,MAAM,WAAW,IAAI,EAAE;AACxE;","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-2T3HZ3DB.js";
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 { html } = await renderReportHtml({
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. Without this the send-time re-render drops
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-76VIUWR3.js.map
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-2T3HZ3DB.js";
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-TYRCYHRA.js.map
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
- const users = opts.current !== void 0 ? fmtUsers(opts.current) : "\u2014";
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: data.searchPosition !== void 0 ? [`Page 1 Google result (#${data.searchPosition}) for your brand search`] : []
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-2T3HZ3DB.js.map
604
+ //# sourceMappingURL=chunk-U3MF6RCS.js.map