@reddoorla/maintenance 0.67.0 → 0.68.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/README.md +16 -0
- package/dist/airtable-WGQQ3KAD.js +9 -0
- package/dist/{announce-74ZIAV26.js → announce-72R3LRNT.js} +9 -11
- package/dist/{announce-74ZIAV26.js.map → announce-72R3LRNT.js.map} +1 -1
- package/dist/{bump-deps-HYBYNPMY.js → bump-deps-QNM3H4GI.js} +2 -2
- package/dist/{chunk-JDGIO54P.js → chunk-2E2X5BCG.js} +43 -3
- package/dist/chunk-2E2X5BCG.js.map +1 -0
- package/dist/{chunk-VKAYZVMI.js → chunk-45AUGDFR.js} +2 -1
- package/dist/chunk-45AUGDFR.js.map +1 -0
- package/dist/{chunk-SYTPVYIK.js → chunk-6JFOAPW4.js} +241 -4
- package/dist/chunk-6JFOAPW4.js.map +1 -0
- package/dist/{chunk-Y4O44LZB.js → chunk-6MT5W6UF.js} +2 -2
- package/dist/{chunk-OAMJINM5.js → chunk-A5KYRGQA.js} +4 -4
- package/dist/{chunk-CL5DYUWQ.js → chunk-C3PDS623.js} +3 -1
- package/dist/chunk-C3PDS623.js.map +1 -0
- package/dist/{chunk-KN5OC5YH.js → chunk-GKSXZIMX.js} +3 -3
- package/dist/{chunk-YKOOAWVL.js → chunk-H6YZBXBQ.js} +18 -12
- package/dist/chunk-H6YZBXBQ.js.map +1 -0
- package/dist/chunk-MAYHLXCS.js +322 -0
- package/dist/chunk-MAYHLXCS.js.map +1 -0
- package/dist/{chunk-ULL4AD3Y.js → chunk-O6G4HHMF.js} +7 -9
- package/dist/{chunk-ULL4AD3Y.js.map → chunk-O6G4HHMF.js.map} +1 -1
- package/dist/{chunk-6LIWDIXU.js → chunk-RINXPU3H.js} +2 -2
- package/dist/{chunk-T34EUM5B.js → chunk-XWPZ46IN.js} +2 -2
- package/dist/{chunk-7MJJEEII.js → chunk-YQMYQAJX.js} +2 -2
- package/dist/cli/bin.js +32 -14
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +5 -5
- package/dist/{convert-to-pnpm-5MPZWFHM.js → convert-to-pnpm-XH7NYRVK.js} +2 -2
- package/dist/{digest-MECMZPFE.js → digest-J7TDG62Q.js} +14 -5
- package/dist/digest-J7TDG62Q.js.map +1 -0
- package/dist/ensure-site-2PF522VL.js +87 -0
- package/dist/ensure-site-2PF522VL.js.map +1 -0
- package/dist/{github-signals-VWBMQNLH.js → github-signals-PP7GWYIY.js} +4 -4
- package/dist/index.d.ts +7 -1
- package/dist/index.js +95 -29
- package/dist/index.js.map +1 -1
- package/dist/{init-X3BCJAUG.js → init-WLR35YGN.js} +2 -2
- package/dist/{launch-NQCL5K5H.js → launch-2HPQXWII.js} +10 -12
- package/dist/{launch-NQCL5K5H.js.map → launch-2HPQXWII.js.map} +1 -1
- package/dist/{lighthouse-airtable-QUNOSDT5.js → lighthouse-airtable-UBSXKHAQ.js} +3 -3
- package/dist/{onboard-7JOBZY35.js → onboard-X6KJ5Z2V.js} +2 -2
- package/dist/{orchestrate-JQX72RJE.js → orchestrate-PVCGBH4K.js} +4 -6
- package/dist/preflight-KWVYJ2DU.js +64 -0
- package/dist/preflight-KWVYJ2DU.js.map +1 -0
- package/dist/{renovate-dispatch-PWXFZNHN.js → renovate-dispatch-LG4RJGDY.js} +2 -2
- package/dist/{report-QUKNKUUE.js → report-ZBFMTT76.js} +8 -9
- package/dist/{report-QUKNKUUE.js.map → report-ZBFMTT76.js.map} +1 -1
- package/dist/{self-updating-OKJKU2FE.js → self-updating-JTTFOJ7Q.js} +2 -2
- package/dist/{selftest-6JVYR24F.js → selftest-LFZPZRJP.js} +7 -9
- package/dist/{selftest-6JVYR24F.js.map → selftest-LFZPZRJP.js.map} +1 -1
- package/dist/{svelte-codemods-GG6EEES5.js → svelte-codemods-KJLNH6FQ.js} +2 -2
- package/dist/{sync-configs-UFHZZPL7.js → sync-configs-6GBZLGRQ.js} +2 -2
- package/dist/{upgrade-S25N3473.js → upgrade-TPTE26FG.js} +2 -2
- package/dist/{websites-U3UZS5VG.js → websites-6YTT5ZTO.js} +2 -2
- package/dist/{write-audits-to-airtable-GAJUCSTY.js → write-audits-to-airtable-IWOO4FFH.js} +4 -4
- package/package.json +1 -1
- package/dist/airtable-33SY2UBZ.js +0 -9
- package/dist/chunk-CL5DYUWQ.js.map +0 -1
- package/dist/chunk-JDGIO54P.js.map +0 -1
- package/dist/chunk-OCS7SZUZ.js +0 -243
- package/dist/chunk-OCS7SZUZ.js.map +0 -1
- package/dist/chunk-OGGRFBL4.js +0 -14
- package/dist/chunk-OGGRFBL4.js.map +0 -1
- package/dist/chunk-SYTPVYIK.js.map +0 -1
- package/dist/chunk-VKAYZVMI.js.map +0 -1
- package/dist/chunk-YKOOAWVL.js.map +0 -1
- package/dist/digest-MECMZPFE.js.map +0 -1
- /package/dist/{airtable-33SY2UBZ.js.map → airtable-WGQQ3KAD.js.map} +0 -0
- /package/dist/{bump-deps-HYBYNPMY.js.map → bump-deps-QNM3H4GI.js.map} +0 -0
- /package/dist/{chunk-Y4O44LZB.js.map → chunk-6MT5W6UF.js.map} +0 -0
- /package/dist/{chunk-OAMJINM5.js.map → chunk-A5KYRGQA.js.map} +0 -0
- /package/dist/{chunk-KN5OC5YH.js.map → chunk-GKSXZIMX.js.map} +0 -0
- /package/dist/{chunk-6LIWDIXU.js.map → chunk-RINXPU3H.js.map} +0 -0
- /package/dist/{chunk-T34EUM5B.js.map → chunk-XWPZ46IN.js.map} +0 -0
- /package/dist/{chunk-7MJJEEII.js.map → chunk-YQMYQAJX.js.map} +0 -0
- /package/dist/{convert-to-pnpm-5MPZWFHM.js.map → convert-to-pnpm-XH7NYRVK.js.map} +0 -0
- /package/dist/{github-signals-VWBMQNLH.js.map → github-signals-PP7GWYIY.js.map} +0 -0
- /package/dist/{init-X3BCJAUG.js.map → init-WLR35YGN.js.map} +0 -0
- /package/dist/{lighthouse-airtable-QUNOSDT5.js.map → lighthouse-airtable-UBSXKHAQ.js.map} +0 -0
- /package/dist/{onboard-7JOBZY35.js.map → onboard-X6KJ5Z2V.js.map} +0 -0
- /package/dist/{orchestrate-JQX72RJE.js.map → orchestrate-PVCGBH4K.js.map} +0 -0
- /package/dist/{renovate-dispatch-PWXFZNHN.js.map → renovate-dispatch-LG4RJGDY.js.map} +0 -0
- /package/dist/{self-updating-OKJKU2FE.js.map → self-updating-JTTFOJ7Q.js.map} +0 -0
- /package/dist/{svelte-codemods-GG6EEES5.js.map → svelte-codemods-KJLNH6FQ.js.map} +0 -0
- /package/dist/{sync-configs-UFHZZPL7.js.map → sync-configs-6GBZLGRQ.js.map} +0 -0
- /package/dist/{upgrade-S25N3473.js.map → upgrade-TPTE26FG.js.map} +0 -0
- /package/dist/{websites-U3UZS5VG.js.map → websites-6YTT5ZTO.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-GAJUCSTY.js.map → write-audits-to-airtable-IWOO4FFH.js.map} +0 -0
package/README.md
CHANGED
|
@@ -300,6 +300,22 @@ RESEND_WEBHOOK_SECRET=whsec_XXXX # only for the deployed webhook
|
|
|
300
300
|
|
|
301
301
|
### Operator flow
|
|
302
302
|
|
|
303
|
+
Before any send day, run the **read-only preflight** — it surfaces everything that
|
|
304
|
+
would make a send fail or reach the wrong inbox (missing/malformed recipients,
|
|
305
|
+
operator-address leftovers in client To fields, To-override shadowing the point of
|
|
306
|
+
contact, missing header image or Lighthouse scores, queued unsent drafts that would
|
|
307
|
+
race the new report, unrecognized/stale schedule values, and renamed Airtable
|
|
308
|
+
columns):
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
reddoor-maint preflight --all # announcement targets (maintenance sites) + fleet checks
|
|
312
|
+
reddoor-maint preflight <slug> # one site
|
|
313
|
+
reddoor-maint preflight --all --type maintenance # everything `report --due` schedules
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Exit 0 = safe to send (warnings printed for review); 1 = at least one hard failure.
|
|
317
|
+
Nothing is written and nothing is sent.
|
|
318
|
+
|
|
303
319
|
0. **Prereq: refresh Lighthouse scores on each Websites row.** From each site's checkout:
|
|
304
320
|
|
|
305
321
|
```bash
|
|
@@ -1,34 +1,32 @@
|
|
|
1
1
|
import {
|
|
2
2
|
scoresFromRow
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-A5KYRGQA.js";
|
|
4
4
|
import {
|
|
5
5
|
fetchGaUsers,
|
|
6
6
|
fetchSearch,
|
|
7
7
|
readGaConfig
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-O6G4HHMF.js";
|
|
9
9
|
import {
|
|
10
10
|
queueDraft
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-RINXPU3H.js";
|
|
12
12
|
import {
|
|
13
13
|
defaultReportSubject
|
|
14
14
|
} from "./chunk-GECESMVS.js";
|
|
15
15
|
import {
|
|
16
16
|
announcementSiteExtras,
|
|
17
|
+
createDraft,
|
|
18
|
+
findReportByPeriod,
|
|
17
19
|
renderReportHtml,
|
|
18
20
|
resolveCopy,
|
|
21
|
+
updateReportScores,
|
|
19
22
|
uploadAttachment
|
|
20
|
-
} from "./chunk-
|
|
21
|
-
import {
|
|
22
|
-
createDraft,
|
|
23
|
-
findReportByPeriod,
|
|
24
|
-
updateReportScores
|
|
25
|
-
} from "./chunk-OCS7SZUZ.js";
|
|
23
|
+
} from "./chunk-6JFOAPW4.js";
|
|
26
24
|
import "./chunk-U5SZ4FOP.js";
|
|
27
25
|
import {
|
|
28
26
|
listWebsites,
|
|
29
27
|
siteSlug,
|
|
30
28
|
updateAnalyticsHealth
|
|
31
|
-
} from "./chunk-
|
|
29
|
+
} from "./chunk-C3PDS623.js";
|
|
32
30
|
import {
|
|
33
31
|
openBase,
|
|
34
32
|
readAirtableConfig
|
|
@@ -185,4 +183,4 @@ export {
|
|
|
185
183
|
formatAnnounceResult,
|
|
186
184
|
runAnnounceCommand
|
|
187
185
|
};
|
|
188
|
-
//# sourceMappingURL=announce-
|
|
186
|
+
//# sourceMappingURL=announce-72R3LRNT.js.map
|
|
@@ -1 +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, updateAnalyticsHealth } from \"../reports/airtable/websites.js\";\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { readGaConfig } from \"../reports/ga/config.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 gaResult = await fetchGaUsers(w, periodStart, periodEnd);\n const searchResult = await fetchSearch(w, periodStart, periodEnd);\n const gaUsers = gaResult.value;\n const search = searchResult.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 // Record this site's GA/Search enrichment health for the per-site analytics-failure\n // signal (cockpit/digest) — exactly as the `--due` draft path does. Without this, an\n // announcement-time GA outage hides the traffic block with NO operator signal (it reads\n // identically to \"site has no GA configured\"). Set the timestamp on a soft-fail, clear\n // it on a clean enrichment so the signal self-heals. Best-effort: the column is\n // operator-added, so until it exists the write throws — which must not break the draft.\n if (readGaConfig() !== null && Boolean(w.ga4PropertyId || w.searchQuery)) {\n try {\n await updateAnalyticsHealth(\n base,\n w.id,\n gaResult.softFailed || searchResult.softFailed ? now.toISOString() : null,\n );\n } catch (e) {\n console.warn(`⚠ analytics-health write skipped for ${w.name}: ${(e as Error).message}`);\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":[]}
|
|
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, updateAnalyticsHealth } from \"../reports/airtable/websites.js\";\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { readGaConfig } from \"../reports/ga/config.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 gaResult = await fetchGaUsers(w, periodStart, periodEnd);\n const searchResult = await fetchSearch(w, periodStart, periodEnd);\n const gaUsers = gaResult.value;\n const search = searchResult.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 // Record this site's GA/Search enrichment health for the per-site analytics-failure\n // signal (cockpit/digest) — exactly as the `--due` draft path does. Without this, an\n // announcement-time GA outage hides the traffic block with NO operator signal (it reads\n // identically to \"site has no GA configured\"). Set the timestamp on a soft-fail, clear\n // it on a clean enrichment so the signal self-heals. Best-effort: the column is\n // operator-added, so until it exists the write throws — which must not break the draft.\n if (readGaConfig() !== null && Boolean(w.ga4PropertyId || w.searchQuery)) {\n try {\n await updateAnalyticsHealth(\n base,\n w.id,\n gaResult.softFailed || searchResult.softFailed ? now.toISOString() : null,\n );\n } catch (e) {\n console.warn(`⚠ analytics-health write skipped for ${w.name}: ${(e as Error).message}`);\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":[]}
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "./chunk-4NAOR44V.js";
|
|
9
9
|
import {
|
|
10
10
|
resolveSites
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-YQMYQAJX.js";
|
|
12
12
|
import {
|
|
13
13
|
bumpDeps
|
|
14
14
|
} from "./chunk-D3DQFC32.js";
|
|
@@ -56,4 +56,4 @@ async function runBumpDepsCommand(site, opts) {
|
|
|
56
56
|
export {
|
|
57
57
|
runBumpDepsCommand
|
|
58
58
|
};
|
|
59
|
-
//# sourceMappingURL=bump-deps-
|
|
59
|
+
//# sourceMappingURL=bump-deps-QNM3H4GI.js.map
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
import {
|
|
2
|
+
approveBlockers
|
|
3
|
+
} from "./chunk-MAYHLXCS.js";
|
|
1
4
|
import {
|
|
2
5
|
siteSlug
|
|
3
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-C3PDS623.js";
|
|
4
7
|
|
|
5
8
|
// src/alerts/digest-collectors.ts
|
|
6
9
|
function dashboardUrl(baseUrl, siteName) {
|
|
7
|
-
|
|
10
|
+
const root = baseUrl.replace(/\/$/, "");
|
|
11
|
+
const slug = siteSlug(siteName);
|
|
12
|
+
return slug ? `${root}/s/${slug}` : root;
|
|
8
13
|
}
|
|
9
14
|
var GITHUB_SIGNALS_STALE_DAYS = 3;
|
|
10
15
|
var MS_PER_DAY = 24 * 60 * 60 * 1e3;
|
|
@@ -64,6 +69,40 @@ function collectLighthouseAlerts(sites, baseUrl) {
|
|
|
64
69
|
}
|
|
65
70
|
return items;
|
|
66
71
|
}
|
|
72
|
+
function collectPreflightBlocked(reports, sitesById, baseUrl) {
|
|
73
|
+
const items = [];
|
|
74
|
+
for (const r of reports) {
|
|
75
|
+
if (!r.draftReady || r.sentAt !== null) continue;
|
|
76
|
+
const state = r.approvedToSend ? "approved" : "pending";
|
|
77
|
+
const site = sitesById.get(r.siteId);
|
|
78
|
+
if (!site) {
|
|
79
|
+
items.push({
|
|
80
|
+
key: `preflight:${r.id}:${state}`,
|
|
81
|
+
kind: "preflight",
|
|
82
|
+
siteName: "(unlinked site)",
|
|
83
|
+
title: r.approvedToSend ? `Approved ${r.reportType} will fail at send \u2014 site-not-found` : `${r.reportType} draft can't be approved \u2014 site-not-found`,
|
|
84
|
+
url: dashboardUrl(baseUrl, ""),
|
|
85
|
+
severity: r.approvedToSend ? "critical" : "warning",
|
|
86
|
+
metric: 1
|
|
87
|
+
});
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const fails = approveBlockers(site, r).filter((f) => f.level === "fail");
|
|
91
|
+
if (fails.length === 0) continue;
|
|
92
|
+
const first = fails[0];
|
|
93
|
+
const more = fails.length > 1 ? ` (+${fails.length - 1} more)` : "";
|
|
94
|
+
items.push({
|
|
95
|
+
key: `preflight:${r.id}:${state}`,
|
|
96
|
+
kind: "preflight",
|
|
97
|
+
siteName: site.name,
|
|
98
|
+
title: r.approvedToSend ? `Approved ${r.reportType} will fail at send \u2014 ${first.check}${more}` : `${r.reportType} draft can't be approved \u2014 ${first.check}${more}`,
|
|
99
|
+
url: dashboardUrl(baseUrl, site.name),
|
|
100
|
+
severity: r.approvedToSend ? "critical" : "warning",
|
|
101
|
+
metric: fails.length
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return items;
|
|
105
|
+
}
|
|
67
106
|
function collectDeliveryFailures(reports, sitesById, baseUrl) {
|
|
68
107
|
const items = [];
|
|
69
108
|
for (const r of reports) {
|
|
@@ -200,6 +239,7 @@ async function writeDigestState(base, snap, updatedAt = (/* @__PURE__ */ new Dat
|
|
|
200
239
|
export {
|
|
201
240
|
collectVulnAlerts,
|
|
202
241
|
collectLighthouseAlerts,
|
|
242
|
+
collectPreflightBlocked,
|
|
203
243
|
collectDeliveryFailures,
|
|
204
244
|
collectRenovateAlerts,
|
|
205
245
|
collectCiAlerts,
|
|
@@ -208,4 +248,4 @@ export {
|
|
|
208
248
|
readDigestState,
|
|
209
249
|
writeDigestState
|
|
210
250
|
};
|
|
211
|
-
//# sourceMappingURL=chunk-
|
|
251
|
+
//# sourceMappingURL=chunk-2E2X5BCG.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/alerts/digest-collectors.ts","../src/alerts/digest-state.ts"],"sourcesContent":["// src/alerts/digest-collectors.ts\nimport type { AttentionItem } from \"./attention.js\";\nimport { siteSlug, type WebsiteRow } from \"../reports/airtable/websites.js\";\nimport type { ReportRow } from \"../reports/airtable/reports.js\";\nimport { approveBlockers } from \"../reports/preflight.js\";\n\n/** Build the same `/s/<slug>` dashboard link the M3 ready-section uses, trailing-slash-safe.\n * An empty Name slugs to \"\" and `/s/` is a dead link — fall back to the fleet homepage,\n * exactly like the ready-section does. */\nfunction dashboardUrl(baseUrl: string, siteName: string): string {\n const root = baseUrl.replace(/\\/$/, \"\");\n const slug = siteSlug(siteName);\n return slug ? `${root}/s/${slug}` : root;\n}\n\n/**\n * A GitHub-signals sweep older than this (or never run) is no longer trustworthy:\n * a repo whose nightly probe THREW stops being re-swept, so its persisted\n * `Default Branch CI` / `Renovate Failing CIs` freeze at their last value forever\n * — a phantom 🔴 that can never clear. 3 days ≈ 3× the daily sweep interval, so a\n * single missed/flaky run doesn't drop a real signal. The CI/Renovate collectors\n * (only) skip a site whose `githubSignalsAt` is staler than this. Vuln/Lighthouse/\n * delivery signals come from other sweeps and are unaffected.\n */\nconst GITHUB_SIGNALS_STALE_DAYS = 3;\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/** True when a site's GitHub-signals sweep is too old (or never ran) to trust the\n * persisted CI/Renovate fields. A null `githubSignalsAt` (never swept) is stale —\n * there's no sweep to vouch for the values. A future timestamp (now - swept < 0)\n * is fresh. `now` is injected so the gate is testable. */\nfunction gitHubSignalsStale(swept: string | null, now: Date): boolean {\n if (swept === null) return true;\n const ageMs = now.getTime() - Date.parse(swept);\n if (!Number.isFinite(ageMs)) return true; // unparseable timestamp → don't trust it\n return ageMs > GITHUB_SIGNALS_STALE_DAYS * MS_PER_DAY;\n}\n\n/** Renovate auto-fix dispatches for one vuln episode before it's \"exhausted\" (manual fix needed). */\nconst AUTO_FIX_EXHAUSTED_CYCLES = 3;\n\n/**\n * One attention item per site carrying current critical+high vulns (medium/low omitted\n * per the locked threshold). PURE: takes already-fetched Websites rows. `metric` is the\n * critical+high count (so a rising count diffs as WORSE); `severity` is `critical` when\n * any critical exists, else `warning`. Null counts (never audited) read as 0 → skipped.\n * Once `securityAutoFixAttempts` reaches AUTO_FIX_EXHAUSTED_CYCLES the item is flagged\n * `autoFixExhausted` (forced-critical, escalated title) — Renovate tried and couldn't fix it.\n */\nexport function collectVulnAlerts(sites: WebsiteRow[], baseUrl: string): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const critical = s.securityVulnsCritical ?? 0;\n const high = s.securityVulnsHigh ?? 0;\n const metric = critical + high;\n if (metric <= 0) continue;\n const attempts = s.securityAutoFixAttempts ?? 0;\n const exhausted = attempts >= AUTO_FIX_EXHAUSTED_CYCLES;\n const noun = metric === 1 ? \"vuln\" : \"vulns\";\n items.push({\n key: `vuln:${s.id}`,\n kind: \"vuln\",\n siteName: s.name,\n title: exhausted\n ? `${metric} critical/high ${noun} — auto-fix failed (${attempts}×)`\n : `${metric} critical/high ${noun}`,\n url: dashboardUrl(baseUrl, s.name),\n severity: exhausted || critical > 0 ? \"critical\" : \"warning\",\n metric,\n ...(exhausted ? { autoFixExhausted: true } : {}),\n });\n }\n return items;\n}\n\n/** Absolute floor below which a Lighthouse category is \"Needs attention\" (Tucker's call). */\nconst LIGHTHOUSE_FLOOR = 75;\n\n/** The four Lighthouse categories, each mapped to its WebsiteRow score field, URL slug,\n * and the human label rendered in the digest title. Order is the operator's reading order. */\nconst LIGHTHOUSE_CATEGORIES: ReadonlyArray<{\n field: \"pScore\" | \"rScore\" | \"bpScore\" | \"seoScore\";\n slug: string;\n label: string;\n}> = [\n { field: \"pScore\", slug: \"performance\", label: \"Performance\" },\n { field: \"rScore\", slug: \"accessibility\", label: \"Accessibility\" },\n { field: \"bpScore\", slug: \"best-practices\", label: \"Best Practices\" },\n { field: \"seoScore\", slug: \"seo\", label: \"SEO\" },\n];\n\n/**\n * One attention item per Lighthouse category below the absolute floor (75) for each site.\n * PURE: takes already-fetched Websites rows. Categories are Performance/Accessibility/\n * Best-Practices/SEO. A null score (never audited) or a score >= 75 is skipped. The\n * `metric` is the DEFICIT (`100 - score`): a lower score → higher metric, so a category\n * that drops further diffs as WORSE and one that first crosses below 75 diffs as NEW —\n * which is how `diffAttention`'s \"WORSE on increase\" rule reads an inverted score. `key`\n * is `lighthouse:<siteId>:<categorySlug>`, so the four categories stay distinct per site.\n */\nexport function collectLighthouseAlerts(sites: WebsiteRow[], baseUrl: string): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n for (const cat of LIGHTHOUSE_CATEGORIES) {\n const score = s[cat.field];\n if (score === null || score >= LIGHTHOUSE_FLOOR) continue;\n items.push({\n key: `lighthouse:${s.id}:${cat.slug}`,\n kind: \"lighthouse\",\n siteName: s.name,\n title: `Lighthouse ${cat.label} ${score} (below ${LIGHTHOUSE_FLOOR})`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 100 - score,\n });\n }\n }\n return items;\n}\n\n/**\n * One attention item per report whose `deliveryStatus` is a failure (`bounced` or\n * `complained` — `delivered`/`pending` are ignored). PURE: takes already-fetched\n * Reports rows + a record-id→site map. A complaint ranks above a bounce (locked\n * threshold), so `severity` is `critical` for complained / `warning` for bounced.\n * `metric` is 1 (a binary event). Orphans (siteId not in the map) are skipped, as\n * the M3 ready-section does, so the digest never renders a broken link. The diff\n * key is the report RECORD id, so two failures on one site stay distinct.\n */\n/**\n * One attention item per unsent draft whose send is ALREADY known to fail\n * (approveBlockers: recipients / header image / report scores). An APPROVED one\n * is critical — the next 09:23 UTC run will go red on it; a pending one is a\n * warning — approving it just schedules that failure. Keyed `preflight:<reportId>`.\n * PURE; same predicate the approve gate and the dashboard chip use.\n */\nexport function collectPreflightBlocked(\n reports: ReportRow[],\n sitesById: Map<string, WebsiteRow>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const r of reports) {\n if (!r.draftReady || r.sentAt !== null) continue;\n // The approved state rides the KEY so a pending→approved escalation with\n // unchanged blockers re-news as a fresh critical instead of diffing\n // \"standing\" against its old warning self.\n const state = r.approvedToSend ? \"approved\" : \"pending\";\n const site = sitesById.get(r.siteId);\n if (!site) {\n // A dangling/empty Site link is itself a send blocker (sendApprovedReports\n // fails with \"Site row not found\") — surface it; the empty-slug\n // dashboardUrl fallback yields the fleet root, not a broken /s/ link.\n items.push({\n key: `preflight:${r.id}:${state}`,\n kind: \"preflight\",\n siteName: \"(unlinked site)\",\n title: r.approvedToSend\n ? `Approved ${r.reportType} will fail at send — site-not-found`\n : `${r.reportType} draft can't be approved — site-not-found`,\n url: dashboardUrl(baseUrl, \"\"),\n severity: r.approvedToSend ? \"critical\" : \"warning\",\n metric: 1,\n });\n continue;\n }\n const fails = approveBlockers(site, r).filter((f) => f.level === \"fail\");\n if (fails.length === 0) continue;\n const first = fails[0]!;\n const more = fails.length > 1 ? ` (+${fails.length - 1} more)` : \"\";\n items.push({\n key: `preflight:${r.id}:${state}`,\n kind: \"preflight\",\n siteName: site.name,\n title: r.approvedToSend\n ? `Approved ${r.reportType} will fail at send — ${first.check}${more}`\n : `${r.reportType} draft can't be approved — ${first.check}${more}`,\n url: dashboardUrl(baseUrl, site.name),\n severity: r.approvedToSend ? \"critical\" : \"warning\",\n metric: fails.length,\n });\n }\n return items;\n}\n\nexport function collectDeliveryFailures(\n reports: ReportRow[],\n sitesById: Map<string, WebsiteRow>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const r of reports) {\n if (r.deliveryStatus !== \"bounced\" && r.deliveryStatus !== \"complained\") continue;\n const site = sitesById.get(r.siteId);\n if (!site) continue; // orphan → skip rather than render a broken link\n const complained = r.deliveryStatus === \"complained\";\n items.push({\n key: `delivery:${r.id}`,\n kind: \"delivery\",\n siteName: site.name,\n title: complained ? \"Spam complaint on a sent report\" : \"A sent report bounced\",\n url: dashboardUrl(baseUrl, site.name),\n severity: complained ? \"critical\" : \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/**\n * One attention item per site carrying failing Renovate PRs, read from the\n * slice-2a-persisted `renovateFailingCis` field (the nightly github-signals sweep\n * populates it). PURE. Keyed `renovate:<siteId>` so the digest and the cockpit\n * share one diff key. `metric` is the count (a rising count diffs WORSE); severity\n * `warning`. Null/0 → skipped. A site whose `githubSignalsAt` is >3 days stale (or\n * null) is ALSO skipped — a repo that stopped being swept must not show a phantom\n * count forever (`now` injected, defaults to wall-clock).\n */\nexport function collectRenovateAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;\n const n = s.renovateFailingCis ?? 0;\n if (n <= 0) continue;\n items.push({\n key: `renovate:${s.id}`,\n kind: \"renovate\",\n siteName: s.name,\n title: `${n} Renovate ${n === 1 ? \"PR\" : \"PRs\"} failing CI`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: n,\n });\n }\n return items;\n}\n\n/**\n * One attention item per site whose persisted default-branch CI rollup is\n * `failing` (slice 2a). PURE. `metric` 1 (binary); severity `warning`. Any other\n * state (passing/pending/none) or null is skipped. A site whose `githubSignalsAt`\n * is >3 days stale (or null) is ALSO skipped — a repo that stopped being swept must\n * not show a phantom 🔴 forever (`now` injected, defaults to wall-clock).\n */\nexport function collectCiAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;\n if (s.defaultBranchCi !== \"failing\") continue;\n items.push({\n key: `ci:${s.id}`,\n kind: \"ci\",\n siteName: s.name,\n title: \"Default-branch CI failing\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/**\n * A soft-fail older than this is no longer a trustworthy CURRENT signal. Drafting\n * self-clears `analyticsSoftFailAt` on the next clean enrichment, so a non-null\n * value normally means \"errored on the most recent draft and hasn't recovered\" —\n * but a site that stopped being drafted (freq→None, deprecated) would otherwise\n * show a phantom forever. 45 days covers a monthly cadence + margin; older drops.\n */\nconst ANALYTICS_SOFT_FAIL_STALE_DAYS = 45;\n\n/**\n * One attention item per site whose last draft's GA/Search enrichment ERRORED, read\n * from the `analyticsSoftFailAt` timestamp (drafting sets it on a soft-fail, clears\n * it on a clean enrichment). PURE. Keyed `analytics:<siteId>` so the digest and the\n * cockpit share one diff key; `metric` 1 (binary), severity `warning`. A null\n * timestamp (clean, or the operator-added `Analytics soft-fail at` column absent) is\n * skipped, as is one staler than {@link ANALYTICS_SOFT_FAIL_STALE_DAYS}. On a\n * FLEET-WIDE subject outage many sites surface this at once — that breadth IS the\n * signal here; the report cron additionally emails a single concise fleet-wide alert\n * (see `assessAnalyticsAlert`). `now` injected, defaults to wall-clock.\n */\nexport function collectAnalyticsFailures(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const at = s.analyticsSoftFailAt;\n if (at === null) continue;\n const ageMs = now.getTime() - Date.parse(at);\n // Unparseable timestamp (NaN) → keep the item (don't silently drop a real failure\n // on a parse glitch); only a parseable, beyond-window age is skipped as stale.\n if (Number.isFinite(ageMs) && ageMs > ANALYTICS_SOFT_FAIL_STALE_DAYS * MS_PER_DAY) continue;\n items.push({\n key: `analytics:${s.id}`,\n kind: \"analytics\",\n siteName: s.name,\n title: \"GA/Search enrichment failing (analytics blank)\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n","// src/alerts/digest-state.ts\nimport type { FieldSet } from \"airtable\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport type { AttentionItem } from \"./attention.js\";\n\n/**\n * The persisted prior-run snapshot: stable item `key` → its last metric + the\n * date it was FIRST flagged. Lives as JSON in the single \"Digest State\" Airtable\n * row (the IO that loads/stores it — readDigestState/writeDigestState — is added\n * in component 2). `next` from diffAttention is what gets written back.\n */\nexport type DigestSnapshot = Record<string, { metric: number; firstFlaggedAt: string }>;\n\n/**\n * PURE diff — the testable core of the hybrid \"snapshot now, mark what's new\".\n * For each current item vs the prior snapshot:\n * - key absent from prior → NEW (firstFlaggedAt = today)\n * - present and metric > prior.metric → WORSE (keep the original firstFlaggedAt)\n * - otherwise (equal or dropped) → STANDING (keep the original firstFlaggedAt)\n * `next` contains EXACTLY the current items' keys: resolved keys drop out, so a\n * fixed-then-recurring problem re-news correctly. Neither input is mutated.\n */\nexport function diffAttention(\n items: AttentionItem[],\n prior: DigestSnapshot,\n today: string,\n): { tagged: AttentionItem[]; next: DigestSnapshot } {\n const tagged: AttentionItem[] = [];\n const next: DigestSnapshot = {};\n for (const it of items) {\n const was = prior[it.key];\n let status: AttentionItem[\"status\"];\n let firstFlaggedAt: string;\n if (!was) {\n status = \"new\";\n firstFlaggedAt = today;\n } else if (it.metric > was.metric) {\n status = \"worse\";\n firstFlaggedAt = was.firstFlaggedAt;\n } else {\n status = \"standing\";\n firstFlaggedAt = was.firstFlaggedAt;\n }\n tagged.push({ ...it, status });\n next[it.key] = { metric: it.metric, firstFlaggedAt };\n }\n return { tagged, next };\n}\n\n/** The single-row Airtable table that persists the prior digest snapshot. */\nexport const DIGEST_STATE_TABLE = \"Digest State\";\n\n/**\n * Read the persisted prior snapshot from the \"Digest State\" singleton.\n *\n * Reads the FIRST row of an unfiltered select (the table holds exactly one row;\n * the test fake does not evaluate filterByFormula, so we never rely on one). A\n * read miss (no row) OR a parse error (malformed Snapshot JSON) collapses to `{}`\n * — every key then reads as NEW once, which is safe degradation (never crashes\n * the digest).\n */\nexport async function readDigestState(base: AirtableBase): Promise<DigestSnapshot> {\n const rows: { id: string; fields: Record<string, unknown> }[] = [];\n await base(DIGEST_STATE_TABLE)\n .select({ maxRecords: 1, pageSize: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push({ id: rec.id, fields: rec.fields });\n fetchNextPage();\n });\n const first = rows[0];\n if (!first) return {};\n const raw = first.fields[\"Snapshot\"];\n if (typeof raw !== \"string\") return {};\n try {\n return JSON.parse(raw) as DigestSnapshot;\n } catch {\n return {};\n }\n}\n\n/**\n * Persist the next snapshot to the \"Digest State\" singleton: get-or-create the\n * one row. If a row exists, UPDATE it (keyed by its record id); otherwise CREATE\n * one. `Snapshot` = JSON.stringify(snap); `Updated At` = the injected ISO\n * timestamp (or now). A caller that catches+logs a write failure keeps the\n * already-sent digest unaffected (next run re-news at worst).\n */\nexport async function writeDigestState(\n base: AirtableBase,\n snap: DigestSnapshot,\n updatedAt: string = new Date().toISOString(),\n): Promise<void> {\n const rows: { id: string }[] = [];\n await base(DIGEST_STATE_TABLE)\n .select({ maxRecords: 1, pageSize: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push({ id: rec.id });\n fetchNextPage();\n });\n const fields: FieldSet = {\n Snapshot: JSON.stringify(snap),\n \"Updated At\": updatedAt,\n };\n const existing = rows[0];\n if (existing) {\n await base(DIGEST_STATE_TABLE).update([{ id: existing.id, fields }]);\n } else {\n await base(DIGEST_STATE_TABLE).create([{ fields }]);\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/due.ts"],"sourcesContent":["import type { WebsiteRow, Frequency, Status } from \"./airtable/websites.js\";\nimport type { ReportRow } from \"./airtable/reports.js\";\nimport type { ReportType } from \"./types.js\";\n\n/** Statuses where reports are appropriate. Drops \"deprecated\" and\n * \"probably not our problem\" — even if the operator left a freq set, we don't\n * want to surface those sites in --due output. Sites with status=null pass\n * through (existing data is partial; better to surface than silently skip). */\nexport const ELIGIBLE_STATUSES: ReadonlySet<Status> = new Set<Status>([\n \"in development\",\n \"launch period\",\n \"maintenance\",\n \"hosting\",\n]);\n\nexport type DueItem = {\n site: WebsiteRow;\n reportType: ReportType;\n /** Inclusive: the day the next report became due. */\n dueDate: Date;\n /** ISO date of the last `Sent at` for this (site, type), or null if there's never been one. */\n lastSent: string | null;\n};\n\nconst MONTHS: Record<Exclude<Frequency, \"None\">, number> = {\n Monthly: 1,\n Quarterly: 3,\n Yearly: 12,\n};\n\n/**\n * Add `n` calendar months in UTC, clamped to the last day of the target month.\n * Jan 31 + 1 month = Feb 28 (not Mar 3, which is what naive setMonth produces).\n * All-UTC accessors mean the result is timezone-independent.\n */\nfunction addMonths(d: Date, n: number): Date {\n const out = new Date(d);\n const day = out.getUTCDate();\n out.setUTCDate(1);\n out.setUTCMonth(out.getUTCMonth() + n);\n const lastDayOfTargetMonth = new Date(\n Date.UTC(out.getUTCFullYear(), out.getUTCMonth() + 1, 0),\n ).getUTCDate();\n out.setUTCDate(Math.min(day, lastDayOfTargetMonth));\n return out;\n}\n\n/** Truncate to UTC midnight. Avoids local-TZ skew when comparing Airtable date-only fields. */\nfunction startOfDay(d: Date): Date {\n const out = new Date(d);\n out.setUTCHours(0, 0, 0, 0);\n return out;\n}\n\nfunction lastSentForType(reports: ReportRow[], siteId: string, type: ReportType): string | null {\n const candidates = reports\n .filter((r) => r.siteId === siteId && r.reportType === type && r.sentAt !== null)\n .map((r) => r.sentAt!)\n .sort();\n return candidates[candidates.length - 1] ?? null;\n}\n\n/**\n * The next-due date for one (site, type): the date the next report of that type is\n * scheduled to draft, whether or not it's due yet. `null` when there's no schedule —\n * an ineligible status, a \"None\"/blank frequency, or an unrecognized frequency value.\n *\n * baseDate = the last `Sent at` for this (site, type), else the site's\n * `maintenance day`/`testing day` anchor. With no baseDate at all the next report is\n * due now (returns `today` at UTC midnight). Otherwise baseDate + frequency.\n *\n * Shared with {@link findDueReports} so the scheduler and any schedule display can't\n * drift on what \"next\" means. NOTE: callers wanting the LOUD unrecognized-frequency\n * warning use findDueReports; this returns null silently for that case.\n */\nexport function nextDueDate(\n site: WebsiteRow,\n reports: ReportRow[],\n type: ReportType,\n today: Date,\n): Date | null {\n if (site.status !== null && !ELIGIBLE_STATUSES.has(site.status)) return null;\n const rawFreq = type === \"Maintenance\" ? site.maintenanceFreq : site.testingFreq;\n const freq = (typeof rawFreq === \"string\" ? rawFreq.trim() : rawFreq) as Frequency;\n if (freq === \"None\" || freq === (\"\" as Frequency)) return null;\n if (!(freq in MONTHS)) return null;\n const lastSent = lastSentForType(reports, site.id, type);\n const fallback = type === \"Maintenance\" ? site.maintenanceDay : site.testingDay;\n const baseIso = lastSent ?? fallback;\n if (!baseIso) return startOfDay(today);\n return addMonths(new Date(baseIso), MONTHS[freq]);\n}\n\n/**\n * Computes which (site, type) pairs are due as of `today`.\n *\n * Algorithm per (site, type):\n * 1. If freq === \"None\", skip.\n * 2. baseDate = max(last Sent at for this type, site's `maintenance/testing day` fallback).\n * 3. If no baseDate exists at all, the site is due now.\n * 4. dueDate = baseDate + frequency months.\n * 5. Due iff startOfDay(today) >= startOfDay(dueDate).\n */\nexport function findDueReports(\n websites: WebsiteRow[],\n reports: ReportRow[],\n today: Date,\n): DueItem[] {\n const out: DueItem[] = [];\n const todayStart = startOfDay(today);\n\n for (const site of websites) {\n // Skip explicitly-non-active statuses (deprecated, \"probably not our problem\").\n // Null status is treated as active for backwards compat with rows that pre-date\n // the Status convention.\n if (site.status !== null && !ELIGIBLE_STATUSES.has(site.status)) continue;\n\n for (const type of [\"Maintenance\", \"Testing\"] as const) {\n const rawFreq = type === \"Maintenance\" ? site.maintenanceFreq : site.testingFreq;\n // Normalize obvious whitespace so a trailing-space typo (\"Quarterly \") still\n // schedules. The LOUD warning below is the real safety net for genuine\n // casing/spelling mistakes (\"monthly\", \"Quaterly\").\n const freq = (typeof rawFreq === \"string\" ? rawFreq.trim() : rawFreq) as Frequency;\n // Intentional silent skip — \"None\" (and the empty/blank default) means \"no\n // schedule\", not a mistake.\n if (freq === \"None\" || freq === (\"\" as Frequency)) continue;\n // A non-empty, non-None value that doesn't match a known schedule used to\n // silently produce no due date — the site just vanished from the loop. Warn\n // LOUDLY so a casing/typo Airtable value is fixable instead of invisible.\n if (!(freq in MONTHS)) {\n console.warn(\n `⚠ ${site.name}: unrecognized ${type === \"Maintenance\" ? \"maintenance\" : \"testing\"} frequency '${rawFreq}' — not scheduling; fix the Airtable value`,\n );\n continue;\n }\n\n const lastSent = lastSentForType(reports, site.id, type);\n // Same computation as the schedule write-back uses. The guards above already\n // proved a schedule exists, so dueDate is non-null; a no-anchor site returns\n // `today`, so the comparison below pushes it as due-now (the prior behavior).\n const dueDate = nextDueDate(site, reports, type, today);\n if (dueDate !== null && todayStart.getTime() >= startOfDay(dueDate).getTime()) {\n out.push({ site, reportType: type, dueDate, lastSent });\n }\n }\n }\n\n return out;\n}\n\n/**\n * The UTC `YYYY-MM` of a `dueDate` from {@link findDueReports} — the per-recurrence\n * idempotency key for drafting. Monthly recurrences land in distinct months; quarterly\n * and yearly land in distinct due-months too, so this uniquely names one draft per cycle.\n * UTC accessors keep it timezone-independent, consistent with the rest of this module.\n */\nexport function reportPeriodKey(dueDate: Date): string {\n if (Number.isNaN(dueDate.getTime())) throw new TypeError(\"reportPeriodKey: invalid Date\");\n const year = dueDate.getUTCFullYear();\n const month = String(dueDate.getUTCMonth() + 1).padStart(2, \"0\");\n return `${year}-${month}`;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
escapeHtml
|
|
3
|
-
} from "./chunk-OCS7SZUZ.js";
|
|
4
1
|
import {
|
|
5
2
|
isHttpUrl
|
|
6
3
|
} from "./chunk-U5SZ4FOP.js";
|
|
@@ -121,6 +118,19 @@ async function loadBundledImages() {
|
|
|
121
118
|
};
|
|
122
119
|
}
|
|
123
120
|
|
|
121
|
+
// src/util/html.ts
|
|
122
|
+
function escapeHtml(s) {
|
|
123
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
124
|
+
}
|
|
125
|
+
function safeUrl(raw) {
|
|
126
|
+
try {
|
|
127
|
+
const u = new URL(raw);
|
|
128
|
+
if (u.protocol === "http:" || u.protocol === "https:") return raw;
|
|
129
|
+
} catch {
|
|
130
|
+
}
|
|
131
|
+
return "#";
|
|
132
|
+
}
|
|
133
|
+
|
|
124
134
|
// src/reports/email-sections.ts
|
|
125
135
|
var escapeXml = escapeHtml;
|
|
126
136
|
var RED = "#C00";
|
|
@@ -544,6 +554,220 @@ async function renderReportHtml(data) {
|
|
|
544
554
|
return { html: out.html, warnings: out.errors ?? [] };
|
|
545
555
|
}
|
|
546
556
|
|
|
557
|
+
// src/reports/checklist.ts
|
|
558
|
+
var MAINTENANCE_CHECKLIST = [
|
|
559
|
+
{ key: "deploy", label: "Deploy & Function Health", field: "Maint: Deploy & Function Health" },
|
|
560
|
+
{ key: "cms", label: "CMS Checked", field: "Maint: CMS Checked" },
|
|
561
|
+
{ key: "domain", label: "Domain, DNS & SSL", field: "Maint: Domain, DNS & SSL" },
|
|
562
|
+
{ key: "google", label: "Google Indexed", field: "Maint: Google Indexed" },
|
|
563
|
+
{ key: "security", label: "Security Updates", field: "Maint: Security Updates" },
|
|
564
|
+
{ key: "uptime", label: "Uptime Checked", field: "Maint: Uptime Checked" }
|
|
565
|
+
];
|
|
566
|
+
var TESTING_CHECKLIST = [
|
|
567
|
+
{ key: "desktop", label: "Desktop Browsers", field: "Test: Desktop Browsers" },
|
|
568
|
+
{ key: "mobile", label: "Mobile Browsers", field: "Test: Mobile Browsers" },
|
|
569
|
+
{ key: "titles", label: "Page Titles & Meta", field: "Test: Page Titles & Meta" },
|
|
570
|
+
{ key: "links", label: "Links & Navigation", field: "Test: Links & Navigation" },
|
|
571
|
+
{ key: "forms", label: "Form Functionality", field: "Test: Form Functionality" },
|
|
572
|
+
{
|
|
573
|
+
key: "interactions",
|
|
574
|
+
label: "Interactions & Animations",
|
|
575
|
+
field: "Test: Interactions & Animations"
|
|
576
|
+
},
|
|
577
|
+
// `field` keeps its original Airtable column name ("…Verified After Updates") even though
|
|
578
|
+
// the client-facing label is now "Tested After Updates" — the column holds operator data,
|
|
579
|
+
// so renaming the label is display-only and avoids a live-base column migration.
|
|
580
|
+
{ key: "updates", label: "Tested After Updates", field: "Test: Verified After Updates" }
|
|
581
|
+
];
|
|
582
|
+
var ALL_CHECKLIST_FIELDS = [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST].map(
|
|
583
|
+
(i) => i.field
|
|
584
|
+
);
|
|
585
|
+
function checklistFor(type) {
|
|
586
|
+
if (type === "Maintenance") return MAINTENANCE_CHECKLIST;
|
|
587
|
+
if (type === "Testing") return [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST];
|
|
588
|
+
return [];
|
|
589
|
+
}
|
|
590
|
+
function isChecklistComplete(report) {
|
|
591
|
+
return checklistFor(report.reportType).every((i) => report.checklist[i.field] === true);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// src/reports/airtable/reports.ts
|
|
595
|
+
var REPORTS_TABLE = "Reports";
|
|
596
|
+
var REPORT_TYPES = ["Maintenance", "Testing", "Launch", "Announcement"];
|
|
597
|
+
function toReportType(raw) {
|
|
598
|
+
if (raw && REPORT_TYPES.includes(raw)) return raw;
|
|
599
|
+
if (raw)
|
|
600
|
+
console.warn(`[reports] unknown Report type ${JSON.stringify(raw)} \u2014 treating as Maintenance`);
|
|
601
|
+
return "Maintenance";
|
|
602
|
+
}
|
|
603
|
+
function isPendingApproval(r) {
|
|
604
|
+
return r.draftReady && !r.approvedToSend && r.sentAt === null;
|
|
605
|
+
}
|
|
606
|
+
function mapRow(rec) {
|
|
607
|
+
const f = rec.fields;
|
|
608
|
+
const linkSites = f["Site"] ?? [];
|
|
609
|
+
const html = (f["Rendered HTML"] ?? [])[0] ?? null;
|
|
610
|
+
return {
|
|
611
|
+
id: rec.id,
|
|
612
|
+
reportId: String(f["Report ID"] ?? ""),
|
|
613
|
+
siteId: linkSites[0] ?? "",
|
|
614
|
+
reportType: toReportType(f["Report type"]),
|
|
615
|
+
period: f["Period"] ?? null,
|
|
616
|
+
periodStart: f["Period start"] ?? null,
|
|
617
|
+
periodEnd: f["Period end"] ?? null,
|
|
618
|
+
completedOn: f["Completed on"] ?? null,
|
|
619
|
+
lighthouse: lighthouseFromFields(f),
|
|
620
|
+
gaUsersCurrent: f["GA users (period)"] ?? null,
|
|
621
|
+
gaUsersPrevious: f["GA users (prev period)"] ?? null,
|
|
622
|
+
searchFoundPage1: typeof f["Search found page 1"] === "boolean" ? f["Search found page 1"] : null,
|
|
623
|
+
searchPosition: f["Search position"] ?? null,
|
|
624
|
+
lastTestedDate: f["Last tested date"] ?? null,
|
|
625
|
+
commentary: f["Commentary"] ?? null,
|
|
626
|
+
subjectOverride: f["Subject override"] ?? null,
|
|
627
|
+
draftReady: Boolean(f["Draft ready"]),
|
|
628
|
+
approvedToSend: Boolean(f["Approved to send"]),
|
|
629
|
+
sentAt: f["Sent at"] ?? null,
|
|
630
|
+
approvedAt: f["Approved At"] ?? null,
|
|
631
|
+
approvedBy: f["Approved By"] ?? null,
|
|
632
|
+
deliveryStatus: f["Delivery status"] ?? "pending",
|
|
633
|
+
renderedHtmlAttachment: html,
|
|
634
|
+
resendMessageId: f["Resend message ID"] ?? null,
|
|
635
|
+
checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])])),
|
|
636
|
+
autoEvidence: parseAutoEvidence(f["Checklist auto-evidence"])
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
function parseAutoEvidence(raw) {
|
|
640
|
+
if (typeof raw !== "string" || !raw.trim()) return null;
|
|
641
|
+
let parsed;
|
|
642
|
+
try {
|
|
643
|
+
parsed = JSON.parse(raw);
|
|
644
|
+
} catch {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
648
|
+
const out = {};
|
|
649
|
+
for (const [field, v] of Object.entries(parsed)) {
|
|
650
|
+
if (!v || typeof v !== "object") continue;
|
|
651
|
+
const o = v;
|
|
652
|
+
if (o.result !== "pass" && o.result !== "fail" && o.result !== "unknown") continue;
|
|
653
|
+
out[field] = {
|
|
654
|
+
result: o.result,
|
|
655
|
+
checkedAt: typeof o.checkedAt === "string" ? o.checkedAt : null,
|
|
656
|
+
note: typeof o.note === "string" ? o.note : ""
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
660
|
+
}
|
|
661
|
+
function lighthouseFromFields(f) {
|
|
662
|
+
const p = f["Lighthouse \u2014 Performance"];
|
|
663
|
+
const a = f["Lighthouse \u2014 Accessibility"];
|
|
664
|
+
const b = f["Lighthouse \u2014 Best Practices"];
|
|
665
|
+
const s = f["Lighthouse \u2014 SEO"];
|
|
666
|
+
if (typeof p !== "number" || typeof a !== "number" || typeof b !== "number" || typeof s !== "number")
|
|
667
|
+
return null;
|
|
668
|
+
return { performance: p, accessibility: a, bestPractices: b, seo: s };
|
|
669
|
+
}
|
|
670
|
+
function ymd(d) {
|
|
671
|
+
return d.toISOString().slice(0, 10);
|
|
672
|
+
}
|
|
673
|
+
function escapeFormulaString(s) {
|
|
674
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
675
|
+
}
|
|
676
|
+
async function createDraft(base, input) {
|
|
677
|
+
const fields = {
|
|
678
|
+
"Report ID": input.reportId,
|
|
679
|
+
Site: [input.siteId],
|
|
680
|
+
"Report type": input.reportType,
|
|
681
|
+
"Period start": ymd(input.periodStart),
|
|
682
|
+
"Period end": ymd(input.periodEnd),
|
|
683
|
+
"Completed on": ymd(input.completedOn),
|
|
684
|
+
"Lighthouse \u2014 Performance": input.lighthouse.performance,
|
|
685
|
+
"Lighthouse \u2014 Accessibility": input.lighthouse.accessibility,
|
|
686
|
+
"Lighthouse \u2014 Best Practices": input.lighthouse.bestPractices,
|
|
687
|
+
"Lighthouse \u2014 SEO": input.lighthouse.seo,
|
|
688
|
+
"Delivery status": "pending"
|
|
689
|
+
};
|
|
690
|
+
if (input.lastTestedDate) fields["Last tested date"] = ymd(input.lastTestedDate);
|
|
691
|
+
if (input.gaUsersCurrent !== void 0) fields["GA users (period)"] = input.gaUsersCurrent;
|
|
692
|
+
if (input.gaUsersPrevious !== void 0) fields["GA users (prev period)"] = input.gaUsersPrevious;
|
|
693
|
+
if (input.searchFoundPage1 !== void 0) fields["Search found page 1"] = input.searchFoundPage1;
|
|
694
|
+
if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
|
|
695
|
+
if (input.period !== void 0) fields["Period"] = input.period;
|
|
696
|
+
if (input.subjectOverride !== void 0) fields["Subject override"] = input.subjectOverride;
|
|
697
|
+
for (const field of input.checklistTicks ?? []) fields[field] = true;
|
|
698
|
+
if (input.autoEvidence && Object.keys(input.autoEvidence).length > 0) {
|
|
699
|
+
fields["Checklist auto-evidence"] = JSON.stringify(input.autoEvidence);
|
|
700
|
+
}
|
|
701
|
+
const created = await base(REPORTS_TABLE).create([{ fields }]);
|
|
702
|
+
const rec = created[0];
|
|
703
|
+
if (!rec) throw new Error("Airtable create returned no records");
|
|
704
|
+
return mapRow({ id: rec.id, fields: rec.fields });
|
|
705
|
+
}
|
|
706
|
+
async function setDraftReady(base, recordId, ready) {
|
|
707
|
+
await base(REPORTS_TABLE).update([{ id: recordId, fields: { "Draft ready": ready } }]);
|
|
708
|
+
}
|
|
709
|
+
async function updateReportScores(base, recordId, scores, completedOn, enrichment) {
|
|
710
|
+
const fields = {
|
|
711
|
+
"Lighthouse \u2014 Performance": scores.performance,
|
|
712
|
+
"Lighthouse \u2014 Accessibility": scores.accessibility,
|
|
713
|
+
"Lighthouse \u2014 Best Practices": scores.bestPractices,
|
|
714
|
+
"Lighthouse \u2014 SEO": scores.seo
|
|
715
|
+
};
|
|
716
|
+
if (completedOn) fields["Completed on"] = ymd(completedOn);
|
|
717
|
+
if (enrichment?.gaUsersCurrent !== void 0)
|
|
718
|
+
fields["GA users (period)"] = enrichment.gaUsersCurrent;
|
|
719
|
+
if (enrichment?.gaUsersPrevious !== void 0)
|
|
720
|
+
fields["GA users (prev period)"] = enrichment.gaUsersPrevious;
|
|
721
|
+
if (enrichment?.searchFoundPage1 !== void 0)
|
|
722
|
+
fields["Search found page 1"] = enrichment.searchFoundPage1;
|
|
723
|
+
if (enrichment?.searchPosition !== void 0)
|
|
724
|
+
fields["Search position"] = enrichment.searchPosition;
|
|
725
|
+
await base(REPORTS_TABLE).update([{ id: recordId, fields }]);
|
|
726
|
+
}
|
|
727
|
+
async function listSendableReports(base) {
|
|
728
|
+
const out = [];
|
|
729
|
+
await base(REPORTS_TABLE).select({
|
|
730
|
+
filterByFormula: "AND({Draft ready} = TRUE(), {Approved to send} = TRUE(), {Sent at} = BLANK())",
|
|
731
|
+
pageSize: 100
|
|
732
|
+
}).eachPage((records, fetchNextPage) => {
|
|
733
|
+
for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));
|
|
734
|
+
fetchNextPage();
|
|
735
|
+
});
|
|
736
|
+
return out;
|
|
737
|
+
}
|
|
738
|
+
async function listAllReports(base) {
|
|
739
|
+
const out = [];
|
|
740
|
+
await base(REPORTS_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
|
|
741
|
+
for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));
|
|
742
|
+
fetchNextPage();
|
|
743
|
+
});
|
|
744
|
+
return out;
|
|
745
|
+
}
|
|
746
|
+
async function listReportsForSite(base, siteId) {
|
|
747
|
+
return (await listAllReports(base)).filter((r) => r.siteId === siteId);
|
|
748
|
+
}
|
|
749
|
+
async function stampSent(base, recordId, sentAt, messageId) {
|
|
750
|
+
const fields = { "Sent at": sentAt.toISOString() };
|
|
751
|
+
if (messageId !== null) fields["Resend message ID"] = messageId;
|
|
752
|
+
await base(REPORTS_TABLE).update([
|
|
753
|
+
{
|
|
754
|
+
id: recordId,
|
|
755
|
+
fields
|
|
756
|
+
}
|
|
757
|
+
]);
|
|
758
|
+
}
|
|
759
|
+
async function findReportByPeriod(base, siteId, reportType, period) {
|
|
760
|
+
const safeType = escapeFormulaString(reportType);
|
|
761
|
+
const safePeriod = escapeFormulaString(period);
|
|
762
|
+
const formula = `AND({Report type} = "${safeType}", {Period} = "${safePeriod}")`;
|
|
763
|
+
const rows = [];
|
|
764
|
+
await base(REPORTS_TABLE).select({ filterByFormula: formula, pageSize: 100 }).eachPage((records, fetchNextPage) => {
|
|
765
|
+
for (const rec of records) rows.push(mapRow({ id: rec.id, fields: rec.fields }));
|
|
766
|
+
fetchNextPage();
|
|
767
|
+
});
|
|
768
|
+
return rows.find((r) => r.siteId === siteId) ?? null;
|
|
769
|
+
}
|
|
770
|
+
|
|
547
771
|
// src/reports/airtable/attachments.ts
|
|
548
772
|
function looksLikeHtml(bytes) {
|
|
549
773
|
const start = bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191 ? 3 : 0;
|
|
@@ -595,9 +819,22 @@ export {
|
|
|
595
819
|
CHECK_CID,
|
|
596
820
|
BLURRED_CID,
|
|
597
821
|
loadBundledImages,
|
|
822
|
+
escapeHtml,
|
|
823
|
+
safeUrl,
|
|
598
824
|
announcementSiteExtras,
|
|
599
825
|
renderReportHtml,
|
|
826
|
+
checklistFor,
|
|
827
|
+
isChecklistComplete,
|
|
828
|
+
isPendingApproval,
|
|
829
|
+
createDraft,
|
|
830
|
+
setDraftReady,
|
|
831
|
+
updateReportScores,
|
|
832
|
+
listSendableReports,
|
|
833
|
+
listAllReports,
|
|
834
|
+
listReportsForSite,
|
|
835
|
+
stampSent,
|
|
836
|
+
findReportByPeriod,
|
|
600
837
|
fetchAttachmentBytes,
|
|
601
838
|
uploadAttachment
|
|
602
839
|
};
|
|
603
|
-
//# sourceMappingURL=chunk-
|
|
840
|
+
//# sourceMappingURL=chunk-6JFOAPW4.js.map
|