@reddoorla/maintenance 0.54.4 → 0.55.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/cli/bin.js CHANGED
@@ -174,6 +174,7 @@ __export(websites_exports, {
174
174
  parseSecurityAdvisories: () => parseSecurityAdvisories,
175
175
  siteSlug: () => siteSlug,
176
176
  updateA11yCounts: () => updateA11yCounts,
177
+ updateAnalyticsHealth: () => updateAnalyticsHealth,
177
178
  updateAuditFields: () => updateAuditFields,
178
179
  updateDepsCounts: () => updateDepsCounts,
179
180
  updateGitHubSignals: () => updateGitHubSignals,
@@ -234,6 +235,7 @@ function mapRow(rec) {
234
235
  ga4PropertyId: f["GA4 property ID"] ?? null,
235
236
  searchQuery: f["Search query"] ?? null,
236
237
  searchConsoleProperty: f["Search Console property"] ?? null,
238
+ analyticsSoftFailAt: f["Analytics soft-fail at"] ?? null,
237
239
  gitRepo: f["Git repo"] ?? null,
238
240
  reportRecipientsTo: f["Report recipients (To)"] ?? null,
239
241
  reportRecipientsCc: f["Report recipients (CC)"] ?? null,
@@ -376,6 +378,10 @@ function browserFields(r) {
376
378
  async function updateScores(base, recordId, scores) {
377
379
  await base(WEBSITES_TABLE).update([{ id: recordId, fields: scoreFields(scores) }]);
378
380
  }
381
+ async function updateAnalyticsHealth(base, recordId, at) {
382
+ const fields = { "Analytics soft-fail at": at };
383
+ await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
384
+ }
379
385
  async function updateA11yCounts(base, recordId, counts) {
380
386
  await base(WEBSITES_TABLE).update([{ id: recordId, fields: a11yFields(counts) }]);
381
387
  }
@@ -1629,6 +1635,10 @@ var init_attachments = __esm({
1629
1635
  });
1630
1636
 
1631
1637
  // src/reports/send/resend.ts
1638
+ var resend_exports = {};
1639
+ __export(resend_exports, {
1640
+ defaultResendClient: () => defaultResendClient
1641
+ });
1632
1642
  import { Resend } from "resend";
1633
1643
  function defaultResendClient() {
1634
1644
  const key = process.env.RESEND_API_KEY;
@@ -1777,7 +1787,26 @@ function collectCiAlerts(sites, baseUrl, now = /* @__PURE__ */ new Date()) {
1777
1787
  }
1778
1788
  return items;
1779
1789
  }
1780
- var GITHUB_SIGNALS_STALE_DAYS, MS_PER_DAY4, LIGHTHOUSE_FLOOR, LIGHTHOUSE_CATEGORIES2;
1790
+ function collectAnalyticsFailures(sites, baseUrl, now = /* @__PURE__ */ new Date()) {
1791
+ const items = [];
1792
+ for (const s of sites) {
1793
+ const at = s.analyticsSoftFailAt;
1794
+ if (at === null) continue;
1795
+ const ageMs = now.getTime() - Date.parse(at);
1796
+ if (Number.isFinite(ageMs) && ageMs > ANALYTICS_SOFT_FAIL_STALE_DAYS * MS_PER_DAY4) continue;
1797
+ items.push({
1798
+ key: `analytics:${s.id}`,
1799
+ kind: "analytics",
1800
+ siteName: s.name,
1801
+ title: "GA/Search enrichment failing (analytics blank)",
1802
+ url: dashboardUrl(baseUrl, s.name),
1803
+ severity: "warning",
1804
+ metric: 1
1805
+ });
1806
+ }
1807
+ return items;
1808
+ }
1809
+ var GITHUB_SIGNALS_STALE_DAYS, MS_PER_DAY4, LIGHTHOUSE_FLOOR, LIGHTHOUSE_CATEGORIES2, ANALYTICS_SOFT_FAIL_STALE_DAYS;
1781
1810
  var init_digest_collectors = __esm({
1782
1811
  "src/alerts/digest-collectors.ts"() {
1783
1812
  "use strict";
@@ -1791,6 +1820,7 @@ var init_digest_collectors = __esm({
1791
1820
  { field: "bpScore", slug: "best-practices", label: "Best Practices" },
1792
1821
  { field: "seoScore", slug: "seo", label: "SEO" }
1793
1822
  ];
1823
+ ANALYTICS_SOFT_FAIL_STALE_DAYS = 45;
1794
1824
  }
1795
1825
  });
1796
1826
 
@@ -1946,7 +1976,8 @@ async function collectAttention(deps) {
1946
1976
  ...runCollector("delivery", () => collectDeliveryFailures(reports, sitesById, deps.baseUrl)),
1947
1977
  ...runCollector("lighthouse", () => collectLighthouseAlerts(websites, deps.baseUrl)),
1948
1978
  ...runCollector("renovate", () => collectRenovateAlerts(websites, deps.baseUrl, now)),
1949
- ...runCollector("ci", () => collectCiAlerts(websites, deps.baseUrl, now))
1979
+ ...runCollector("ci", () => collectCiAlerts(websites, deps.baseUrl, now)),
1980
+ ...runCollector("analytics", () => collectAnalyticsFailures(websites, deps.baseUrl, now))
1950
1981
  ];
1951
1982
  }
1952
1983
  async function runDigest(options) {
@@ -6817,6 +6848,17 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
6817
6848
  return { reportRow: null, htmlPath: path, html, softFailures, queued: null, supersededIds: [] };
6818
6849
  }
6819
6850
  if (base === null) throw new Error("base required when previewOnly=false");
6851
+ if (readGaConfig() !== null && Boolean(siteRow.ga4PropertyId || siteRow.searchQuery)) {
6852
+ try {
6853
+ await updateAnalyticsHealth(
6854
+ base,
6855
+ siteRow.id,
6856
+ softFailures.length > 0 ? today.toISOString() : null
6857
+ );
6858
+ } catch (e) {
6859
+ console.warn(`\u26A0 analytics-health write skipped for ${siteRow.name}: ${e.message}`);
6860
+ }
6861
+ }
6820
6862
  if (options.completeRowId) {
6821
6863
  await uploadDraftHtml(options.completeRowId, slug, periodEnd, html);
6822
6864
  const outcome2 = await queueDraft(base, {
@@ -6919,6 +6961,25 @@ async function derivePeriodStart(base, siteRow, reportType, today) {
6919
6961
  return start;
6920
6962
  }
6921
6963
 
6964
+ // src/alerts/analytics-health.ts
6965
+ init_html();
6966
+ var MIN_FAILED_SITES = 2;
6967
+ function assessAnalyticsAlert(h) {
6968
+ const { softFailedSites, configuredSites } = h;
6969
+ const fire = configuredSites >= 2 && softFailedSites >= MIN_FAILED_SITES && softFailedSites * 2 >= configuredSites;
6970
+ const reason = fire ? `${softFailedSites} of ${configuredSites} analytics-configured sites had GA/Search enrichment fail this run \u2014 the shared GA_SUBJECT likely lost access (an offboarded user, revoked property access, or a botched role-account cutover). Reports were drafted with BLANK analytics.` : "";
6971
+ return { fire, reason };
6972
+ }
6973
+ function composeAnalyticsAlertEmail(h, dashboardUrl2) {
6974
+ const subject = `\u26A0 Fleet analytics enrichment failing \u2014 ${h.softFailedSites}/${h.configuredSites} sites`;
6975
+ const { reason } = assessAnalyticsAlert(h);
6976
+ const html = `<p><strong>${escapeHtml(reason)}</strong></p>
6977
+ <p>This usually means the Google Workspace user the service account impersonates can no longer read the GA4 / Search Console properties. Reports still send \u2014 but with blank analytics \u2014 until the subject is restored.</p>
6978
+ <p>Next step: follow the GA/Search subject runbook (<code>docs/runbooks/ga-search-role-account-cutover.md</code>) to restore or move the subject, then re-run <code>reddoor-maint report --due</code> and confirm the warning clears.</p>
6979
+ <p><a href="${escapeHtml(dashboardUrl2)}">Open the fleet dashboard \u2192</a></p>`;
6980
+ return { subject, html };
6981
+ }
6982
+
6922
6983
  // src/cli/commands/report.ts
6923
6984
  function draftLine(reportId, queued, supersededIds, verb = "drafted") {
6924
6985
  const id = reportId ?? "(unknown)";
@@ -6969,15 +7030,44 @@ async function runReportCommand(slug, opts) {
6969
7030
  }
6970
7031
  async function runDueDraft() {
6971
7032
  const base = openBase(readAirtableConfig());
6972
- return draftDueReports(base, /* @__PURE__ */ new Date());
7033
+ const result = await draftDueReports(base, /* @__PURE__ */ new Date());
7034
+ await alertOnFleetAnalyticsFailure(result.health);
7035
+ return { output: result.output, code: result.code };
7036
+ }
7037
+ async function alertOnFleetAnalyticsFailure(health) {
7038
+ if (!assessAnalyticsAlert(health).fire) return;
7039
+ try {
7040
+ const to = process.env.OPERATOR_EMAIL?.trim() || "info@reddoorla.com";
7041
+ const { subject, html } = composeAnalyticsAlertEmail(health, dashboardBaseUrl());
7042
+ const { defaultResendClient: defaultResendClient2 } = await Promise.resolve().then(() => (init_resend(), resend_exports));
7043
+ await defaultResendClient2().send({
7044
+ from: "Reddoor Reports <reports@reddoorla.com>",
7045
+ to: [to],
7046
+ subject,
7047
+ html,
7048
+ idempotencyKey: `analytics-alert-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`
7049
+ });
7050
+ console.warn(`\u26A0 ${subject} \u2014 operator alerted (${to})`);
7051
+ } catch (e) {
7052
+ console.warn(`\u26A0 analytics-failure alert send failed: ${e.message}`);
7053
+ }
6973
7054
  }
6974
7055
  async function draftDueReports(base, today) {
6975
7056
  const websites = await listWebsites(base);
6976
7057
  const reports = await listAllReports(base);
6977
7058
  const due = findDueReports(websites, reports, today);
6978
- if (due.length === 0) return { output: "No reports due.", code: 0 };
7059
+ const gaConfigured = readGaConfig() !== null;
7060
+ const isAnalyticsConfigured = (s) => gaConfigured && Boolean(s.ga4PropertyId || s.searchQuery);
7061
+ if (due.length === 0) {
7062
+ return {
7063
+ output: "No reports due.",
7064
+ code: 0,
7065
+ health: { softFailedSites: 0, configuredSites: 0 }
7066
+ };
7067
+ }
6979
7068
  const lines = [];
6980
7069
  let softFailedSites = 0;
7070
+ let gaConfiguredSites = 0;
6981
7071
  let skipped = 0;
6982
7072
  for (const item of due) {
6983
7073
  const period = reportPeriodKey(item.dueDate);
@@ -7015,6 +7105,7 @@ async function draftDueReports(base, today) {
7015
7105
  "completed half-made draft"
7016
7106
  )
7017
7107
  );
7108
+ if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;
7018
7109
  if (result.softFailures.length > 0) softFailedSites++;
7019
7110
  } catch (e) {
7020
7111
  lines.push(`\u2717 failed: ${item.site.name} ${item.reportType} \u2014 ${e.message}`);
@@ -7035,6 +7126,7 @@ async function draftDueReports(base, today) {
7035
7126
  const result = await draftReportForSite(base, item.site, item.reportType, { period });
7036
7127
  lines.push(draftLine(result.reportRow?.reportId, result.queued, result.supersededIds));
7037
7128
  if (result.reportRow) reports.push(result.reportRow);
7129
+ if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;
7038
7130
  if (result.softFailures.length > 0) softFailedSites++;
7039
7131
  } catch (e) {
7040
7132
  lines.push(`\u2717 failed: ${item.site.name} ${item.reportType} \u2014 ${e.message}`);
@@ -7048,7 +7140,11 @@ async function draftDueReports(base, today) {
7048
7140
  `\u26A0 ${softFailedSites} site${softFailedSites === 1 ? "" : "s"} had GA/Search enrichment fail \u2014 drafted with blank analytics; check the logs above`
7049
7141
  );
7050
7142
  }
7051
- return { output: lines.join("\n"), code: lines.some((l) => l.startsWith("\u2717")) ? 1 : 0 };
7143
+ return {
7144
+ output: lines.join("\n"),
7145
+ code: lines.some((l) => l.startsWith("\u2717")) ? 1 : 0,
7146
+ health: { softFailedSites, configuredSites: gaConfiguredSites }
7147
+ };
7052
7148
  }
7053
7149
  async function runSingleSiteDraft(slug, opts) {
7054
7150
  const base = openBase(readAirtableConfig());