@reddoorla/maintenance 0.41.0 → 0.42.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
@@ -651,6 +651,7 @@ async function createDraft(base, input) {
651
651
  if (input.searchFoundPage1 !== void 0) fields["Search found page 1"] = input.searchFoundPage1;
652
652
  if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
653
653
  if (input.period !== void 0) fields["Period"] = input.period;
654
+ if (input.subjectOverride !== void 0) fields["Subject override"] = input.subjectOverride;
654
655
  const created = await base(REPORTS_TABLE).create([{ fields }]);
655
656
  const rec = created[0];
656
657
  if (!rec) throw new Error("Airtable create returned no records");
@@ -717,7 +718,7 @@ var init_reports = __esm({
717
718
  "src/reports/airtable/reports.ts"() {
718
719
  "use strict";
719
720
  REPORTS_TABLE = "Reports";
720
- REPORT_TYPES = ["Maintenance", "Testing", "Launch"];
721
+ REPORT_TYPES = ["Maintenance", "Testing", "Launch", "Announcement"];
721
722
  }
722
723
  });
723
724
 
@@ -777,7 +778,15 @@ var init_copy = __esm({
777
778
  "Hosting, DNS, and SSL configured",
778
779
  "Continuous integration + automatic dependency updates",
779
780
  "Analytics and uptime monitoring"
780
- ]
781
+ ],
782
+ announceHeading: "YOUR MONTHLY REPORT",
783
+ announceBody: "We've set up ongoing monitoring and maintenance for your site. Each month we quietly check that everything's healthy and up to date \u2014 and now you'll get a short report so you can see it at a glance.",
784
+ announceMonitorItems: ["Performance", "Accessibility", "Security", "Uptime"],
785
+ announcePreviewLabel: "A snapshot of your latest scores:",
786
+ announceImprovementResend: "Your contact forms now deliver straight to your inbox through reliable infrastructure, so no inquiry slips through the cracks.",
787
+ announceImprovementSvelte5: "We've modernized your site to the latest framework \u2014 it's faster, more secure, and built to last.",
788
+ announceCadence: "You'll receive this every month. There's nothing you need to do.",
789
+ announceOpenDoor: "And if you'd ever like to expand the scope, add features, or freshen anything up, just reply \u2014 we'd love to help."
781
790
  };
782
791
  }
783
792
  });
@@ -1114,10 +1123,115 @@ var init_template2 = __esm({
1114
1123
  }
1115
1124
  });
1116
1125
 
1126
+ // src/reports/announcement-email/template.ts
1127
+ function buildAnnouncementMjml(data) {
1128
+ const copy = data.copy ?? DEFAULT_COPY;
1129
+ const previewText = "Your monthly report from Reddoor";
1130
+ const improvementItems = [];
1131
+ if (data.improvements?.resendForms) improvementItems.push(copy.announceImprovementResend);
1132
+ if (data.improvements?.svelte5) improvementItems.push(copy.announceImprovementSvelte5);
1133
+ const improvementsSection = improvementItems.length > 0 ? `
1134
+ <mj-section background-color="white">
1135
+ <mj-column>
1136
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">RECENT IMPROVEMENTS</mj-text>
1137
+ ${improvementItems.map(
1138
+ (item) => `
1139
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="4px" padding-bottom="4px">\u2022 ${escapeXml(item)}</mj-text>`
1140
+ ).join("")}
1141
+ </mj-column>
1142
+ </mj-section>` : "";
1143
+ const monitorRows = copy.announceMonitorItems.map(
1144
+ (item) => `
1145
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="4px" padding-bottom="4px">\u2022 ${escapeXml(item)}</mj-text>`
1146
+ ).join("");
1147
+ const scoreRows = SCORE_PREVIEW.map(
1148
+ ({ label, key }) => `
1149
+ <mj-text color="${RED2}" font-size="20px" font-weight="300" padding-top="25px">${label}</mj-text>
1150
+ <mj-text color="${RED2}" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse[key]}</mj-text>`
1151
+ ).join("");
1152
+ const contactRows = copy.contact.map(
1153
+ (line) => `
1154
+ <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
1155
+ ).join("");
1156
+ const footerAddressRows = copy.footerAddress.map(
1157
+ (line) => `
1158
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(line)}</mj-text>`
1159
+ ).join("");
1160
+ return `<mjml>
1161
+ <mj-head>
1162
+ <mj-attributes>
1163
+ <mj-text font-family="helvetica, sans-serif" padding-left="5px" padding-right="5px" />
1164
+ <mj-section padding-left="11%" padding-right="11%"/>
1165
+ <mj-image padding="0px" />
1166
+ </mj-attributes>
1167
+ <mj-preview>${escapeXml(previewText)}</mj-preview>
1168
+ ${headerStyleBlock(data)}
1169
+ </mj-head>
1170
+ <mj-body background-color="white">
1171
+ <mj-section background-color="#F4F4F4" padding-top="0px" padding-bottom="0px" padding-left="0px" padding-right="0px">
1172
+ <mj-column>${headerImageTag(data)}</mj-column>
1173
+ </mj-section>
1174
+ <mj-section background-color="white">
1175
+ <mj-column>
1176
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml(copy.announceHeading)}</mj-text>
1177
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="20px">Prepared for ${escapeXml(data.siteName)}</mj-text>
1178
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="8px">${escapeXml(copy.announceBody)}</mj-text>
1179
+ </mj-column>
1180
+ </mj-section>
1181
+ ${improvementsSection}
1182
+ <mj-section background-color="white">
1183
+ <mj-column>
1184
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">WHAT WE MONITOR</mj-text>
1185
+ ${monitorRows}
1186
+ </mj-column>
1187
+ </mj-section>
1188
+ <mj-section background-color="#F4F4F4">
1189
+ <mj-column>
1190
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="55px">${escapeXml(copy.announcePreviewLabel)}</mj-text>
1191
+ ${scoreRows}
1192
+ </mj-column>
1193
+ </mj-section>
1194
+ <mj-section background-color="white">
1195
+ <mj-column>
1196
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="36px">${escapeXml(copy.announceCadence)}</mj-text>
1197
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="8px">${escapeXml(copy.announceOpenDoor)}</mj-text>
1198
+ </mj-column>
1199
+ </mj-section>
1200
+ <mj-section background-color="white">
1201
+ <mj-column padding-top="36px">
1202
+ <mj-text color="${RED2}" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="36px" line-height="36px">Any questions, concerns or requests?</mj-text>
1203
+ ${contactRows}
1204
+ <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
1205
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" line-height="20px" font-style="italic">Copyright ${(/* @__PURE__ */ new Date()).getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>
1206
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="700" line-height="16px" padding-top="0" padding-bottom="0px">Our mailing address is:</mj-text>
1207
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(copy.footerOrg)}</mj-text>
1208
+ ${footerAddressRows}
1209
+ </mj-column>
1210
+ </mj-section>
1211
+ </mj-body>
1212
+ </mjml>`;
1213
+ }
1214
+ var RED2, GREY2, SCORE_PREVIEW;
1215
+ var init_template3 = __esm({
1216
+ "src/reports/announcement-email/template.ts"() {
1217
+ "use strict";
1218
+ init_copy();
1219
+ init_template();
1220
+ RED2 = "#C00";
1221
+ GREY2 = "#757575";
1222
+ SCORE_PREVIEW = [
1223
+ { label: "Performance", key: "performance" },
1224
+ { label: "Readability", key: "accessibility" },
1225
+ { label: "Best Practices", key: "bestPractices" },
1226
+ { label: "Site Structure", key: "seo" }
1227
+ ];
1228
+ }
1229
+ });
1230
+
1117
1231
  // src/reports/render.ts
1118
1232
  import mjml2html from "mjml";
1119
1233
  async function renderReportHtml(data) {
1120
- const mjml = data.reportType === "Launch" ? buildLaunchMjml(data) : buildMjml(data);
1234
+ const mjml = data.reportType === "Launch" ? buildLaunchMjml(data) : data.reportType === "Announcement" ? buildAnnouncementMjml(data) : buildMjml(data);
1121
1235
  const out = await mjml2html(mjml, { validationLevel: "strict" });
1122
1236
  return { html: out.html, warnings: out.errors ?? [] };
1123
1237
  }
@@ -1126,6 +1240,7 @@ var init_render = __esm({
1126
1240
  "use strict";
1127
1241
  init_template();
1128
1242
  init_template2();
1243
+ init_template3();
1129
1244
  }
1130
1245
  });
1131
1246
 
@@ -1419,16 +1534,16 @@ __export(digest_exports, {
1419
1534
  runDigest: () => runDigest
1420
1535
  });
1421
1536
  function readySection(items) {
1422
- const heading = `<h2 style="color:${RED2};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Ready for your yes</h2>`;
1537
+ const heading = `<h2 style="color:${RED3};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Ready for your yes</h2>`;
1423
1538
  if (items.length === 0) {
1424
- return `${heading}<p style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;margin:0">Nothing waiting on you.</p>`;
1539
+ return `${heading}<p style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;margin:0">Nothing waiting on you.</p>`;
1425
1540
  }
1426
1541
  const rows = items.map((it) => {
1427
1542
  const safeUrl = it.dashboardUrl.startsWith("https://") ? it.dashboardUrl : void 0;
1428
1543
  const link = safeUrl ? `<a href="${escapeHtml(safeUrl)}" style="${ANCHOR_STYLE}">review &amp; approve</a>` : `review &amp; approve`;
1429
1544
  return `
1430
1545
  <tr>
1431
- <td style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">
1546
+ <td style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">
1432
1547
  <strong style="color:#222">${escapeHtml(it.siteName)}</strong> \u2014 ${escapeHtml(it.reportType)} (${escapeHtml(it.period)})
1433
1548
  \u2014 ${link}
1434
1549
  </td>
@@ -1438,15 +1553,15 @@ function readySection(items) {
1438
1553
  }
1439
1554
  function attentionBadge(status) {
1440
1555
  if (status === "new")
1441
- return `<strong style="color:${RED2};font-family:helvetica,sans-serif">NEW</strong> `;
1556
+ return `<strong style="color:${RED3};font-family:helvetica,sans-serif">NEW</strong> `;
1442
1557
  if (status === "worse")
1443
- return `<strong style="color:${RED2};font-family:helvetica,sans-serif">WORSE</strong> `;
1558
+ return `<strong style="color:${RED3};font-family:helvetica,sans-serif">WORSE</strong> `;
1444
1559
  return "";
1445
1560
  }
1446
1561
  function attentionSection(items) {
1447
- const heading = `<h2 style="color:${RED2};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Needs attention</h2>`;
1562
+ const heading = `<h2 style="color:${RED3};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Needs attention</h2>`;
1448
1563
  if (items.length === 0) {
1449
- return `${heading}<p style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;margin:0">All clear \u2014 nothing needs attention.</p>`;
1564
+ return `${heading}<p style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;margin:0">All clear \u2014 nothing needs attention.</p>`;
1450
1565
  }
1451
1566
  const bySite = /* @__PURE__ */ new Map();
1452
1567
  for (const it of items) {
@@ -1463,7 +1578,7 @@ function attentionSection(items) {
1463
1578
  const titleHtml = safeUrl ? `<a href="${escapeHtml(safeUrl)}" style="${ANCHOR_STYLE}">${escapeHtml(it.title)}</a>` : escapeHtml(it.title);
1464
1579
  return `
1465
1580
  <tr>
1466
- <td style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">${attentionBadge(it.status)}${titleHtml}</td>
1581
+ <td style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">${attentionBadge(it.status)}${titleHtml}</td>
1467
1582
  </tr>`;
1468
1583
  }).join("");
1469
1584
  return `
@@ -1588,7 +1703,7 @@ function renderDigestHtml(sections) {
1588
1703
  <table width="600" style="border-collapse:collapse">
1589
1704
  <tr>
1590
1705
  <td>
1591
- <h1 style="color:${RED2};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px">Your fleet today</h1>
1706
+ <h1 style="color:${RED3};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px">Your fleet today</h1>
1592
1707
  ${readySection(sections.readyForYourYes)}
1593
1708
  ${attentionSection(sections.needsAttention)}
1594
1709
  </td>
@@ -1600,7 +1715,7 @@ function renderDigestHtml(sections) {
1600
1715
  </body>
1601
1716
  </html>`;
1602
1717
  }
1603
- var GREY2, RED2, ANCHOR_STYLE, SEVERITY_ORDER, FROM_ADDRESS, DIGEST_OPERATOR_FALLBACK;
1718
+ var GREY3, RED3, ANCHOR_STYLE, SEVERITY_ORDER, FROM_ADDRESS, DIGEST_OPERATOR_FALLBACK;
1604
1719
  var init_digest = __esm({
1605
1720
  "src/reports/digest.ts"() {
1606
1721
  "use strict";
@@ -1612,9 +1727,9 @@ var init_digest = __esm({
1612
1727
  init_digest_collectors();
1613
1728
  init_digest_state();
1614
1729
  init_html();
1615
- GREY2 = "#757575";
1616
- RED2 = "#C00";
1617
- ANCHOR_STYLE = `color:${RED2};font-family:helvetica,sans-serif`;
1730
+ GREY3 = "#757575";
1731
+ RED3 = "#C00";
1732
+ ANCHOR_STYLE = `color:${RED3};font-family:helvetica,sans-serif`;
1618
1733
  SEVERITY_ORDER = { critical: 0, warning: 1 };
1619
1734
  FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
1620
1735
  DIGEST_OPERATOR_FALLBACK = "info@reddoorla.com";
@@ -6211,6 +6326,127 @@ async function runLaunchCommand(site, opts) {
6211
6326
  return { output: formatResult9(result), code: result.complete ? 0 : 1 };
6212
6327
  }
6213
6328
 
6329
+ // src/recipes/announce.ts
6330
+ init_client();
6331
+ init_websites();
6332
+ init_reports();
6333
+ init_attachments();
6334
+ init_render();
6335
+ init_copy();
6336
+ async function announce(deps) {
6337
+ const base = deps?.base ?? openBase(readAirtableConfig());
6338
+ const now = deps?.now ?? /* @__PURE__ */ new Date();
6339
+ const websites = await listWebsites(base);
6340
+ let targets = websites.filter((w) => w.status === "maintenance");
6341
+ if (deps?.site) {
6342
+ const wanted = siteSlug(deps.site);
6343
+ targets = targets.filter((w) => siteSlug(w.name) === wanted);
6344
+ }
6345
+ const period = now.toISOString().slice(0, 7);
6346
+ const results = [];
6347
+ for (const w of targets) {
6348
+ try {
6349
+ const scores = scoresFromRow(w);
6350
+ if (scores === null) {
6351
+ results.push({ site: w.name, status: "skipped-no-scores" });
6352
+ continue;
6353
+ }
6354
+ let report;
6355
+ let statusKind;
6356
+ const existing = await findReportByPeriod(base, w.id, "Announcement", period);
6357
+ if (existing) {
6358
+ await updateReportScores(base, existing.id, scores, now);
6359
+ report = existing;
6360
+ statusKind = "reused";
6361
+ } else {
6362
+ report = await createDraft(base, draftInputFor2(w, scores, now, period));
6363
+ statusKind = "drafted";
6364
+ }
6365
+ const slug = siteSlug(w.name);
6366
+ const { html } = await renderReportHtml({
6367
+ siteName: w.name,
6368
+ siteUrl: w.url,
6369
+ reportType: "Announcement",
6370
+ completedOn: now,
6371
+ lighthouse: scores,
6372
+ lastTestedDate: null,
6373
+ commentary: null,
6374
+ copy: resolveCopy(w),
6375
+ headerImageCid: `${slug}-header`,
6376
+ // Default-on fleet-wide for v1: both recent-improvement callouts render for every
6377
+ // site. Operator review (the draft never auto-sends) is the relevance backstop;
6378
+ // per-site conditioning of these flags is a future lever, not a v1 requirement.
6379
+ improvements: { resendForms: true, svelte5: true }
6380
+ });
6381
+ try {
6382
+ await uploadAttachment(
6383
+ report.id,
6384
+ "Rendered HTML",
6385
+ html,
6386
+ `${slug}-${now.toISOString().slice(0, 10)}.html`,
6387
+ "text/html"
6388
+ );
6389
+ } catch (uploadErr) {
6390
+ console.warn(
6391
+ `\u26A0 Announcement preview upload skipped for ${w.name}: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`
6392
+ );
6393
+ }
6394
+ await setDraftReady(base, report.id, true);
6395
+ const recipientMissing = !(w.reportRecipientsTo && w.reportRecipientsTo.trim());
6396
+ results.push({ site: w.name, status: statusKind, reportId: report.id, recipientMissing });
6397
+ } catch (err) {
6398
+ results.push({
6399
+ site: w.name,
6400
+ status: "error",
6401
+ message: err instanceof Error ? err.message : String(err)
6402
+ });
6403
+ }
6404
+ }
6405
+ return { results };
6406
+ }
6407
+ function scoresFromRow(w) {
6408
+ if (w.pScore === null || w.rScore === null || w.bpScore === null || w.seoScore === null) {
6409
+ return null;
6410
+ }
6411
+ return {
6412
+ performance: w.pScore,
6413
+ accessibility: w.rScore,
6414
+ bestPractices: w.bpScore,
6415
+ seo: w.seoScore
6416
+ };
6417
+ }
6418
+ function draftInputFor2(w, scores, now, period) {
6419
+ return {
6420
+ reportId: `${w.name} \u2014 Announcement \u2014 ${now.toISOString().slice(0, 10)}`,
6421
+ siteId: w.id,
6422
+ reportType: "Announcement",
6423
+ period,
6424
+ periodStart: now,
6425
+ periodEnd: now,
6426
+ completedOn: now,
6427
+ lighthouse: scores,
6428
+ lastTestedDate: null,
6429
+ subjectOverride: `Your new monthly report for ${w.name}`
6430
+ };
6431
+ }
6432
+
6433
+ // src/cli/commands/announce.ts
6434
+ function formatSiteResult(r) {
6435
+ if (r.status === "skipped-no-scores") return `[${r.site}] skipped-no-scores`;
6436
+ if (r.status === "error") return `[${r.site}] error: ${r.message}`;
6437
+ const note = r.recipientMissing ? " \u26A0 recipient missing" : "";
6438
+ return `[${r.site}] ${r.status}${note}`;
6439
+ }
6440
+ function formatAnnounceResult(result) {
6441
+ if (result.results.length === 0) return "No maintenance sites to announce.";
6442
+ return result.results.map(formatSiteResult).join("\n");
6443
+ }
6444
+ async function runAnnounceCommand(site, _opts) {
6445
+ const result = await announce(site ? { site } : {});
6446
+ const hadError = result.results.some((r) => r.status === "error");
6447
+ return { output: formatAnnounceResult(result), code: hadError ? 1 : 0 };
6448
+ }
6449
+
6214
6450
  // src/cli/commands/github-signals.ts
6215
6451
  init_client();
6216
6452
  init_websites();
@@ -6464,6 +6700,12 @@ cli.command(
6464
6700
  ).action(
6465
6701
  async (site, opts) => runOrExit(() => runLaunchCommand(site, opts), opts)
6466
6702
  );
6703
+ cli.command(
6704
+ "announce [site]",
6705
+ "Draft the monthly-report announcement email for maintenance sites (all, or one) for approval."
6706
+ ).action(
6707
+ async (site, opts) => runOrExit(() => runAnnounceCommand(site, opts), opts)
6708
+ );
6467
6709
  cli.command("report [site]", "Draft or send maintenance/testing reports.").option("--due", "Scan all Websites and draft overdue reports.").option(
6468
6710
  "--preview",
6469
6711
  "Single-site dry run; writes reports/<slug>/draft.html, never touches Airtable."