@reddoorla/maintenance 0.6.8 → 0.7.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 +299 -7
- package/dist/cli/bin.js +769 -2
- package/dist/cli/bin.js.map +1 -1
- package/dist/index.d.ts +178 -1
- package/dist/index.js +636 -0
- package/dist/index.js.map +1 -1
- package/package.json +7 -1
package/dist/index.js
CHANGED
|
@@ -1923,6 +1923,638 @@ function fromJsonFile(path) {
|
|
|
1923
1923
|
return validate(raw);
|
|
1924
1924
|
};
|
|
1925
1925
|
}
|
|
1926
|
+
|
|
1927
|
+
// src/reports/draft.ts
|
|
1928
|
+
import { mkdir, writeFile as writeFile9 } from "fs/promises";
|
|
1929
|
+
import { dirname as dirname2 } from "path";
|
|
1930
|
+
|
|
1931
|
+
// src/reports/render.ts
|
|
1932
|
+
import mjml2html from "mjml";
|
|
1933
|
+
|
|
1934
|
+
// src/reports/maintenance-email/template.ts
|
|
1935
|
+
var CHECK_PNG = "https://d3eq0h5l8sxf6t.cloudfront.net/maintenance-email/check.png";
|
|
1936
|
+
var BLURRED_TESTS = "https://d3eq0h5l8sxf6t.cloudfront.net/maintenance-email/blurredTests.jpg";
|
|
1937
|
+
function fmtDate(d) {
|
|
1938
|
+
if (!d) return "";
|
|
1939
|
+
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
1940
|
+
const dd = String(d.getUTCDate()).padStart(2, "0");
|
|
1941
|
+
const yyyy = d.getUTCFullYear();
|
|
1942
|
+
return `${mm}.${dd}.${yyyy}`;
|
|
1943
|
+
}
|
|
1944
|
+
function fmtUsers(n) {
|
|
1945
|
+
return n.toLocaleString("en-US");
|
|
1946
|
+
}
|
|
1947
|
+
function maintenanceChecksSection() {
|
|
1948
|
+
const rows = [
|
|
1949
|
+
"Reviewed Logs",
|
|
1950
|
+
"CMS Checked",
|
|
1951
|
+
"DNS Checked",
|
|
1952
|
+
"Google Indexed",
|
|
1953
|
+
"Reviewed Certificate",
|
|
1954
|
+
"Security Updates"
|
|
1955
|
+
];
|
|
1956
|
+
return rows.map(
|
|
1957
|
+
(label, i) => `
|
|
1958
|
+
<mj-section background-color="white" padding="0px"${i === rows.length - 1 ? ' padding-bottom="36px"' : ""}>
|
|
1959
|
+
<mj-group>
|
|
1960
|
+
<mj-column padding-left="0px" width="90%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""}>
|
|
1961
|
+
<mj-text height="25px" padding-left="0px" color="#757575" padding-top="20px" padding-bottom="7.5px" font-size="16px">${label}</mj-text>
|
|
1962
|
+
</mj-column>
|
|
1963
|
+
<mj-column width="10%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""} padding-top="15px">
|
|
1964
|
+
<mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
|
|
1965
|
+
</mj-column>
|
|
1966
|
+
</mj-group>
|
|
1967
|
+
</mj-section>`
|
|
1968
|
+
).join("");
|
|
1969
|
+
}
|
|
1970
|
+
function testingChecklistSection() {
|
|
1971
|
+
const rows = [
|
|
1972
|
+
"Desktop Browsers",
|
|
1973
|
+
"Mobile Browsers",
|
|
1974
|
+
"Package Updates",
|
|
1975
|
+
"Bottlenecks",
|
|
1976
|
+
"Form Functionality",
|
|
1977
|
+
"Animation Functionality"
|
|
1978
|
+
];
|
|
1979
|
+
return rows.map(
|
|
1980
|
+
(label, i) => `
|
|
1981
|
+
<mj-section background-color="#F4F4F4" padding="0px"${i === rows.length - 1 ? ' padding-bottom="60px"' : ""}>
|
|
1982
|
+
<mj-group>
|
|
1983
|
+
<mj-column width="90%" padding-left="0px"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""}>
|
|
1984
|
+
<mj-text height="25px" padding-left="0px" color="#757575" padding-top="20px" padding-bottom="7.5px" font-size="16px">${label}</mj-text>
|
|
1985
|
+
</mj-column>
|
|
1986
|
+
<mj-column width="10%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""} padding-top="15px">
|
|
1987
|
+
<mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
|
|
1988
|
+
</mj-column>
|
|
1989
|
+
</mj-group>
|
|
1990
|
+
</mj-section>`
|
|
1991
|
+
).join("");
|
|
1992
|
+
}
|
|
1993
|
+
function maintenanceTestingPlaceholder(lastTested) {
|
|
1994
|
+
return `
|
|
1995
|
+
<mj-section background-color="#F4F4F4">
|
|
1996
|
+
<mj-column>
|
|
1997
|
+
<mj-image href="mailto:info@reddoorla.com" src="${BLURRED_TESTS}" />
|
|
1998
|
+
</mj-column>
|
|
1999
|
+
</mj-section>
|
|
2000
|
+
<mj-section background-color="#F4F4F4" padding-top="0px">
|
|
2001
|
+
<mj-column>
|
|
2002
|
+
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">Last Tested: ${fmtDate(lastTested)}</mj-text>
|
|
2003
|
+
</mj-column>
|
|
2004
|
+
</mj-section>`;
|
|
2005
|
+
}
|
|
2006
|
+
function testingIntroSection() {
|
|
2007
|
+
return `
|
|
2008
|
+
<mj-section background-color="#F4F4F4">
|
|
2009
|
+
<mj-column>
|
|
2010
|
+
<mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">TESTING</mj-text>
|
|
2011
|
+
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">Testing includes checks similar to those at launch: testing on common browsers and operating systems, at different screen sizes, and checking every function, and updating all packages for performance rather than just those needed for security.</mj-text>
|
|
2012
|
+
</mj-column>
|
|
2013
|
+
</mj-section>`;
|
|
2014
|
+
}
|
|
2015
|
+
function commentarySection(text) {
|
|
2016
|
+
return `
|
|
2017
|
+
<mj-section background-color="white">
|
|
2018
|
+
<mj-column>
|
|
2019
|
+
<mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">NOTES</mj-text>
|
|
2020
|
+
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${text.replace(/\n/g, "<br/>")}</mj-text>
|
|
2021
|
+
</mj-column>
|
|
2022
|
+
</mj-section>`;
|
|
2023
|
+
}
|
|
2024
|
+
function buildMjml(data) {
|
|
2025
|
+
const isTesting = data.reportType === "Testing";
|
|
2026
|
+
const headerSrc = `cid:${data.headerImageCid}`;
|
|
2027
|
+
const previewText = `Checked up on ${data.siteName}`;
|
|
2028
|
+
return `<mjml>
|
|
2029
|
+
<mj-head>
|
|
2030
|
+
<mj-attributes>
|
|
2031
|
+
<mj-text font-family="helvetica, sans-serif" padding-left="5px" padding-right="5px" />
|
|
2032
|
+
<mj-section padding-left="11%" padding-right="11%"/>
|
|
2033
|
+
<mj-image padding="0px" />
|
|
2034
|
+
</mj-attributes>
|
|
2035
|
+
<mj-preview>${previewText}</mj-preview>
|
|
2036
|
+
</mj-head>
|
|
2037
|
+
<mj-body background-color="white">
|
|
2038
|
+
<mj-section background-color="#F4F4F4" padding-top="0px" padding-bottom="0px" padding-left="0px" padding-right="0px">
|
|
2039
|
+
<mj-column>
|
|
2040
|
+
<mj-image href="${data.siteUrl}" src="${headerSrc}" />
|
|
2041
|
+
</mj-column>
|
|
2042
|
+
</mj-section>
|
|
2043
|
+
<mj-section background-color="white">
|
|
2044
|
+
<mj-column>
|
|
2045
|
+
<mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">COMPLETED ON</mj-text>
|
|
2046
|
+
<mj-text color="#C00" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
|
|
2047
|
+
<mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">MAINTENANCE CHECKS</mj-text>
|
|
2048
|
+
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">Includes checking the hosting, DNS, Content Management System (CMS, if applicable), search indexing and security of the site for major flaws and updating as necessary.</mj-text>
|
|
2049
|
+
</mj-column>
|
|
2050
|
+
</mj-section>
|
|
2051
|
+
${maintenanceChecksSection()}
|
|
2052
|
+
<mj-section background-color="#F4F4F4">
|
|
2053
|
+
<mj-column>
|
|
2054
|
+
<mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">LIGHTHOUSE SCORES*</mj-text>
|
|
2055
|
+
<mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Performance</mj-text>
|
|
2056
|
+
<mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.performance}</mj-text>
|
|
2057
|
+
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="0px" padding-bottom="36px">Acceptable 50\u201389 // Ideal 90\u2013100</mj-text>
|
|
2058
|
+
<mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
|
|
2059
|
+
<mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Readability</mj-text>
|
|
2060
|
+
<mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.accessibility}</mj-text>
|
|
2061
|
+
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="0px" padding-bottom="36px">Acceptable 80\u201399 // Ideal 100</mj-text>
|
|
2062
|
+
<mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
|
|
2063
|
+
<mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Best Practices</mj-text>
|
|
2064
|
+
<mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.bestPractices}</mj-text>
|
|
2065
|
+
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="0px" padding-bottom="36px">Acceptable 60\u201379 // Ideal 80\u201392</mj-text>
|
|
2066
|
+
<mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
|
|
2067
|
+
<mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Site Structure</mj-text>
|
|
2068
|
+
<mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.seo}</mj-text>
|
|
2069
|
+
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="0px" padding-bottom="36px">Acceptable 50\u201389 // Ideal 90\u2013100</mj-text>
|
|
2070
|
+
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" padding-bottom="36px" line-height="20px">*A Lighthouse score is a numerical measure provided by Google's Lighthouse tool, which evaluates various aspects of a web page's quality.</mj-text>
|
|
2071
|
+
</mj-column>
|
|
2072
|
+
</mj-section>
|
|
2073
|
+
<mj-section background-color="white">
|
|
2074
|
+
<mj-column>
|
|
2075
|
+
<mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">ANALYTICS</mj-text>
|
|
2076
|
+
<mj-text color="#C00" font-size="44px" font-weight="400">${fmtUsers(data.gaUsersCurrent)} Users</mj-text>
|
|
2077
|
+
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">Last Period: ${fmtUsers(data.gaUsersPrevious)}</mj-text>
|
|
2078
|
+
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" padding-bottom="36px" line-height="20px">Contact us if you are interested in more in-depth data or have questions about SEO.</mj-text>
|
|
2079
|
+
</mj-column>
|
|
2080
|
+
</mj-section>
|
|
2081
|
+
${isTesting ? testingIntroSection() + testingChecklistSection() : maintenanceTestingPlaceholder(data.lastTestedDate)}
|
|
2082
|
+
${data.commentary ? commentarySection(data.commentary) : ""}
|
|
2083
|
+
<mj-section background-color="white">
|
|
2084
|
+
<mj-column padding-top="36px">
|
|
2085
|
+
<mj-text color="#C00" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="36px" line-height="36px">Any questions, concerns or requests?</mj-text>
|
|
2086
|
+
<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">Just hit reply.</mj-text>
|
|
2087
|
+
<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" padding-top="0px" line-height="30px" padding-bottom="36px">We're here to help in any way we can.</mj-text>
|
|
2088
|
+
<mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
|
|
2089
|
+
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" line-height="20px" font-style="italic">Copyright ${(/* @__PURE__ */ new Date()).getFullYear()} Reddoor Creative, LLC. All rights reserved.</mj-text>
|
|
2090
|
+
<mj-text color="#757575" 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>
|
|
2091
|
+
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">Reddoor Creative, LLC</mj-text>
|
|
2092
|
+
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">29027 Dapper Dan</mj-text>
|
|
2093
|
+
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">Fair Oaks Ranch, TX 78015</mj-text>
|
|
2094
|
+
</mj-column>
|
|
2095
|
+
</mj-section>
|
|
2096
|
+
</mj-body>
|
|
2097
|
+
</mjml>`;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
// src/reports/render.ts
|
|
2101
|
+
async function renderReportHtml(data) {
|
|
2102
|
+
const mjml = buildMjml(data);
|
|
2103
|
+
const out = await mjml2html(mjml, { validationLevel: "strict" });
|
|
2104
|
+
return { html: out.html, warnings: out.errors ?? [] };
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
// src/reports/airtable/websites.ts
|
|
2108
|
+
var WEBSITES_TABLE = "Websites";
|
|
2109
|
+
function siteSlug(name) {
|
|
2110
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
2111
|
+
}
|
|
2112
|
+
function mapRow(rec) {
|
|
2113
|
+
const f = rec.fields;
|
|
2114
|
+
const attachments = f["Header image"] ?? [];
|
|
2115
|
+
const header = attachments[0] ?? null;
|
|
2116
|
+
return {
|
|
2117
|
+
id: rec.id,
|
|
2118
|
+
name: String(f["Name"] ?? ""),
|
|
2119
|
+
url: String(f["url"] ?? ""),
|
|
2120
|
+
pointOfContact: f["point of contact"] ?? null,
|
|
2121
|
+
maintenanceFreq: f["maintenence freq"] ?? "None",
|
|
2122
|
+
testingFreq: f["testing freq"] ?? "None",
|
|
2123
|
+
maintenanceDay: f["maintenance day"] ?? null,
|
|
2124
|
+
testingDay: f["testing day"] ?? null,
|
|
2125
|
+
ga4PropertyId: f["GA4 property ID"] ?? null,
|
|
2126
|
+
reportRecipientsTo: f["Report recipients (To)"] ?? null,
|
|
2127
|
+
reportRecipientsCc: f["Report recipients (CC)"] ?? null,
|
|
2128
|
+
headerImage: header,
|
|
2129
|
+
pScore: f["pScore"] ?? null,
|
|
2130
|
+
rScore: f["rScore"] ?? null,
|
|
2131
|
+
bpScore: f["bpScore"] ?? null,
|
|
2132
|
+
seoScore: f["seoScore"] ?? null
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
async function listWebsites(base) {
|
|
2136
|
+
const out = [];
|
|
2137
|
+
await base(WEBSITES_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
|
|
2138
|
+
for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));
|
|
2139
|
+
fetchNextPage();
|
|
2140
|
+
});
|
|
2141
|
+
return out;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
// src/reports/airtable/reports.ts
|
|
2145
|
+
var REPORTS_TABLE = "Reports";
|
|
2146
|
+
function mapRow2(rec) {
|
|
2147
|
+
const f = rec.fields;
|
|
2148
|
+
const linkSites = f["Site"] ?? [];
|
|
2149
|
+
const html = (f["Rendered HTML"] ?? [])[0] ?? null;
|
|
2150
|
+
return {
|
|
2151
|
+
id: rec.id,
|
|
2152
|
+
reportId: String(f["Report ID"] ?? ""),
|
|
2153
|
+
siteId: linkSites[0] ?? "",
|
|
2154
|
+
reportType: f["Report type"] ?? "Maintenance",
|
|
2155
|
+
periodStart: f["Period start"] ?? null,
|
|
2156
|
+
periodEnd: f["Period end"] ?? null,
|
|
2157
|
+
completedOn: f["Completed on"] ?? null,
|
|
2158
|
+
lighthouse: lighthouseFromFields(f),
|
|
2159
|
+
gaUsersCurrent: f["GA users (period)"] ?? null,
|
|
2160
|
+
gaUsersPrevious: f["GA users (prev period)"] ?? null,
|
|
2161
|
+
lastTestedDate: f["Last tested date"] ?? null,
|
|
2162
|
+
commentary: f["Commentary"] ?? null,
|
|
2163
|
+
subjectOverride: f["Subject override"] ?? null,
|
|
2164
|
+
draftReady: Boolean(f["Draft ready"]),
|
|
2165
|
+
approvedToSend: Boolean(f["Approved to send"]),
|
|
2166
|
+
sentAt: f["Sent at"] ?? null,
|
|
2167
|
+
deliveryStatus: f["Delivery status"] ?? "pending",
|
|
2168
|
+
renderedHtmlAttachment: html,
|
|
2169
|
+
resendMessageId: f["Resend message ID"] ?? null
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
function lighthouseFromFields(f) {
|
|
2173
|
+
const p = f["Lighthouse \u2014 Performance"];
|
|
2174
|
+
const a = f["Lighthouse \u2014 Accessibility"];
|
|
2175
|
+
const b = f["Lighthouse \u2014 Best Practices"];
|
|
2176
|
+
const s = f["Lighthouse \u2014 SEO"];
|
|
2177
|
+
if (typeof p !== "number" || typeof a !== "number" || typeof b !== "number" || typeof s !== "number")
|
|
2178
|
+
return null;
|
|
2179
|
+
return { performance: p, accessibility: a, bestPractices: b, seo: s };
|
|
2180
|
+
}
|
|
2181
|
+
function ymd(d) {
|
|
2182
|
+
return d.toISOString().slice(0, 10);
|
|
2183
|
+
}
|
|
2184
|
+
function escapeFormulaString(s) {
|
|
2185
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
2186
|
+
}
|
|
2187
|
+
async function createDraft(base, input) {
|
|
2188
|
+
const fields = {
|
|
2189
|
+
"Report ID": input.reportId,
|
|
2190
|
+
Site: [input.siteId],
|
|
2191
|
+
"Report type": input.reportType,
|
|
2192
|
+
"Period start": ymd(input.periodStart),
|
|
2193
|
+
"Period end": ymd(input.periodEnd),
|
|
2194
|
+
"Completed on": ymd(input.completedOn),
|
|
2195
|
+
"Lighthouse \u2014 Performance": input.lighthouse.performance,
|
|
2196
|
+
"Lighthouse \u2014 Accessibility": input.lighthouse.accessibility,
|
|
2197
|
+
"Lighthouse \u2014 Best Practices": input.lighthouse.bestPractices,
|
|
2198
|
+
"Lighthouse \u2014 SEO": input.lighthouse.seo,
|
|
2199
|
+
"Delivery status": "pending"
|
|
2200
|
+
};
|
|
2201
|
+
if (input.lastTestedDate) fields["Last tested date"] = ymd(input.lastTestedDate);
|
|
2202
|
+
const created = await base(REPORTS_TABLE).create([{ fields }]);
|
|
2203
|
+
const rec = created[0];
|
|
2204
|
+
if (!rec) throw new Error("Airtable create returned no records");
|
|
2205
|
+
return mapRow2({ id: rec.id, fields: rec.fields });
|
|
2206
|
+
}
|
|
2207
|
+
async function setDraftReady(base, recordId, ready) {
|
|
2208
|
+
await base(REPORTS_TABLE).update([{ id: recordId, fields: { "Draft ready": ready } }]);
|
|
2209
|
+
}
|
|
2210
|
+
async function listSendableReports(base) {
|
|
2211
|
+
const out = [];
|
|
2212
|
+
await base(REPORTS_TABLE).select({
|
|
2213
|
+
filterByFormula: "AND({Draft ready} = TRUE(), {Approved to send} = TRUE(), {Sent at} = BLANK())",
|
|
2214
|
+
pageSize: 100
|
|
2215
|
+
}).eachPage((records, fetchNextPage) => {
|
|
2216
|
+
for (const rec of records) out.push(mapRow2({ id: rec.id, fields: rec.fields }));
|
|
2217
|
+
fetchNextPage();
|
|
2218
|
+
});
|
|
2219
|
+
return out;
|
|
2220
|
+
}
|
|
2221
|
+
async function listReportsForSite(base, siteId) {
|
|
2222
|
+
const safeId = escapeFormulaString(siteId);
|
|
2223
|
+
const out = [];
|
|
2224
|
+
await base(REPORTS_TABLE).select({
|
|
2225
|
+
filterByFormula: `FIND(",${safeId},", "," & ARRAYJOIN({Site}, ",") & ",") > 0`,
|
|
2226
|
+
pageSize: 100
|
|
2227
|
+
}).eachPage((records, fetchNextPage) => {
|
|
2228
|
+
for (const rec of records) out.push(mapRow2({ id: rec.id, fields: rec.fields }));
|
|
2229
|
+
fetchNextPage();
|
|
2230
|
+
});
|
|
2231
|
+
return out;
|
|
2232
|
+
}
|
|
2233
|
+
async function stampSent(base, recordId, sentAt, messageId) {
|
|
2234
|
+
await base(REPORTS_TABLE).update([
|
|
2235
|
+
{
|
|
2236
|
+
id: recordId,
|
|
2237
|
+
fields: {
|
|
2238
|
+
"Sent at": sentAt.toISOString(),
|
|
2239
|
+
"Resend message ID": messageId
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
]);
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// src/reports/draft.ts
|
|
2246
|
+
function scoresFromWebsite(siteRow) {
|
|
2247
|
+
const { pScore, rScore, bpScore, seoScore } = siteRow;
|
|
2248
|
+
if (pScore === null || rScore === null || bpScore === null || seoScore === null) {
|
|
2249
|
+
throw new Error(
|
|
2250
|
+
`Site '${siteRow.name}' is missing one or more Lighthouse scores on the Websites row (pScore, rScore, bpScore, seoScore). Run 'reddoor-maint audit lighthouse' from the site's checkout and paste the four numbers into Airtable, then retry.`
|
|
2251
|
+
);
|
|
2252
|
+
}
|
|
2253
|
+
return { performance: pScore, accessibility: rScore, bestPractices: bpScore, seo: seoScore };
|
|
2254
|
+
}
|
|
2255
|
+
function daysAgo(today, n) {
|
|
2256
|
+
const out = new Date(today);
|
|
2257
|
+
out.setDate(out.getDate() - n);
|
|
2258
|
+
return out;
|
|
2259
|
+
}
|
|
2260
|
+
async function draftReportForSite(base, siteRow, reportType, options = {}) {
|
|
2261
|
+
const scores = scoresFromWebsite(siteRow);
|
|
2262
|
+
const today = /* @__PURE__ */ new Date();
|
|
2263
|
+
const slug = siteSlug(siteRow.name);
|
|
2264
|
+
const periodStart = base !== null ? await derivePeriodStart(base, siteRow, reportType, today) : daysAgo(today, 30);
|
|
2265
|
+
const periodEnd = today;
|
|
2266
|
+
const completedOn = today;
|
|
2267
|
+
const lastTestedDate = reportType === "Maintenance" && siteRow.testingDay ? new Date(siteRow.testingDay) : null;
|
|
2268
|
+
const cidName = `${slug}-header`;
|
|
2269
|
+
const { html } = await renderReportHtml({
|
|
2270
|
+
siteName: siteRow.name,
|
|
2271
|
+
siteUrl: siteRow.url,
|
|
2272
|
+
reportType,
|
|
2273
|
+
completedOn,
|
|
2274
|
+
lighthouse: scores,
|
|
2275
|
+
gaUsersCurrent: 0,
|
|
2276
|
+
gaUsersPrevious: 0,
|
|
2277
|
+
lastTestedDate,
|
|
2278
|
+
commentary: null,
|
|
2279
|
+
headerImageCid: cidName
|
|
2280
|
+
});
|
|
2281
|
+
if (options.previewOnly) {
|
|
2282
|
+
const path = options.previewPath ?? `reports/${slug}/draft.html`;
|
|
2283
|
+
await mkdir(dirname2(path), { recursive: true });
|
|
2284
|
+
await writeFile9(path, html, "utf-8");
|
|
2285
|
+
return { reportRow: null, htmlPath: path, html };
|
|
2286
|
+
}
|
|
2287
|
+
if (base === null) throw new Error("base required when previewOnly=false");
|
|
2288
|
+
const reportId = `${siteRow.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
|
|
2289
|
+
const created = await createDraft(base, {
|
|
2290
|
+
reportId,
|
|
2291
|
+
siteId: siteRow.id,
|
|
2292
|
+
reportType,
|
|
2293
|
+
periodStart,
|
|
2294
|
+
periodEnd,
|
|
2295
|
+
completedOn,
|
|
2296
|
+
lighthouse: scores,
|
|
2297
|
+
lastTestedDate
|
|
2298
|
+
});
|
|
2299
|
+
await uploadHtmlAttachment(created.id, html, slug, periodEnd);
|
|
2300
|
+
await setDraftReady(base, created.id, true);
|
|
2301
|
+
return { reportRow: created, htmlPath: null, html };
|
|
2302
|
+
}
|
|
2303
|
+
async function derivePeriodStart(base, siteRow, reportType, today) {
|
|
2304
|
+
const prior = await listReportsForSite(base, siteRow.id);
|
|
2305
|
+
const sameType = prior.filter((r) => r.reportType === reportType && r.periodEnd).map((r) => r.periodEnd).sort();
|
|
2306
|
+
const latest = sameType[sameType.length - 1];
|
|
2307
|
+
return latest ? new Date(latest) : daysAgo(today, 30);
|
|
2308
|
+
}
|
|
2309
|
+
async function uploadHtmlAttachment(recordId, html, slug, periodEnd) {
|
|
2310
|
+
const apiKey = process.env.AIRTABLE_PAT;
|
|
2311
|
+
const baseId = process.env.AIRTABLE_BASE_ID;
|
|
2312
|
+
const filename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;
|
|
2313
|
+
const body = {
|
|
2314
|
+
contentType: "text/html",
|
|
2315
|
+
file: Buffer.from(html, "utf-8").toString("base64"),
|
|
2316
|
+
filename
|
|
2317
|
+
};
|
|
2318
|
+
const url = `https://content.airtable.com/v0/${baseId}/${recordId}/Rendered%20HTML/uploadAttachment`;
|
|
2319
|
+
const res = await fetch(url, {
|
|
2320
|
+
method: "POST",
|
|
2321
|
+
headers: {
|
|
2322
|
+
Authorization: `Bearer ${apiKey}`,
|
|
2323
|
+
"Content-Type": "application/json"
|
|
2324
|
+
},
|
|
2325
|
+
body: JSON.stringify(body)
|
|
2326
|
+
});
|
|
2327
|
+
if (!res.ok) {
|
|
2328
|
+
throw new Error(`Airtable upload failed: ${res.status} ${res.statusText} ${await res.text()}`);
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
// src/reports/airtable/client.ts
|
|
2333
|
+
import Airtable from "airtable";
|
|
2334
|
+
function readAirtableConfig() {
|
|
2335
|
+
const apiKey = process.env.AIRTABLE_PAT;
|
|
2336
|
+
const baseId = process.env.AIRTABLE_BASE_ID;
|
|
2337
|
+
if (!apiKey) throw Object.assign(new Error("AIRTABLE_PAT not set"), { exitCode: 2 });
|
|
2338
|
+
if (!baseId) throw Object.assign(new Error("AIRTABLE_BASE_ID not set"), { exitCode: 2 });
|
|
2339
|
+
return { apiKey, baseId };
|
|
2340
|
+
}
|
|
2341
|
+
function openBase(cfg) {
|
|
2342
|
+
return new Airtable({ apiKey: cfg.apiKey }).base(cfg.baseId);
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// src/reports/airtable/attachments.ts
|
|
2346
|
+
async function fetchAttachmentBytes(url) {
|
|
2347
|
+
const res = await fetch(url);
|
|
2348
|
+
if (!res.ok) {
|
|
2349
|
+
throw new Error(
|
|
2350
|
+
`Failed to fetch Airtable attachment ${res.status} ${res.statusText} (url=${url})`
|
|
2351
|
+
);
|
|
2352
|
+
}
|
|
2353
|
+
const contentType = res.headers.get("content-type") ?? "application/octet-stream";
|
|
2354
|
+
const ab = await res.arrayBuffer();
|
|
2355
|
+
return { bytes: new Uint8Array(ab), contentType };
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// src/reports/send/resend.ts
|
|
2359
|
+
import { Resend } from "resend";
|
|
2360
|
+
function defaultResendClient() {
|
|
2361
|
+
const key = process.env.RESEND_API_KEY;
|
|
2362
|
+
if (!key) throw Object.assign(new Error("RESEND_API_KEY not set"), { exitCode: 2 });
|
|
2363
|
+
const resend = new Resend(key);
|
|
2364
|
+
return {
|
|
2365
|
+
async send(input) {
|
|
2366
|
+
const payload = {
|
|
2367
|
+
from: input.from,
|
|
2368
|
+
to: input.to,
|
|
2369
|
+
subject: input.subject,
|
|
2370
|
+
html: input.html
|
|
2371
|
+
};
|
|
2372
|
+
if (input.cc) payload.cc = input.cc;
|
|
2373
|
+
if (input.replyTo) payload.replyTo = input.replyTo;
|
|
2374
|
+
if (input.attachments) payload.attachments = input.attachments;
|
|
2375
|
+
const options = {};
|
|
2376
|
+
if (input.idempotencyKey) options.idempotencyKey = input.idempotencyKey;
|
|
2377
|
+
const { data, error } = await resend.emails.send(payload, options);
|
|
2378
|
+
if (error) throw new Error(`Resend error: ${error.message}`);
|
|
2379
|
+
if (!data?.id) throw new Error("Resend returned no message id");
|
|
2380
|
+
return { messageId: data.id };
|
|
2381
|
+
}
|
|
2382
|
+
};
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
// src/reports/send/orchestrate.ts
|
|
2386
|
+
var FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
|
|
2387
|
+
var REPLY_TO = "info@reddoorla.com";
|
|
2388
|
+
async function sendApprovedReports(options = {}) {
|
|
2389
|
+
const base = openBase(readAirtableConfig());
|
|
2390
|
+
const client = options.resend ?? defaultResendClient();
|
|
2391
|
+
const sendable = await listSendableReports(base);
|
|
2392
|
+
if (sendable.length === 0) return { output: "No reports ready to send.", code: 0 };
|
|
2393
|
+
const websites = await listWebsites(base);
|
|
2394
|
+
const sites = new Map(websites.map((w) => [w.id, w]));
|
|
2395
|
+
const lines = [];
|
|
2396
|
+
let anyFailed = false;
|
|
2397
|
+
for (const report of sendable) {
|
|
2398
|
+
const site = sites.get(report.siteId);
|
|
2399
|
+
if (!site) {
|
|
2400
|
+
lines.push(`\u2717 ${report.reportId} \u2014 Site row not found for id=${report.siteId}`);
|
|
2401
|
+
anyFailed = true;
|
|
2402
|
+
continue;
|
|
2403
|
+
}
|
|
2404
|
+
try {
|
|
2405
|
+
const messageId = await sendOne(client, base, site, report);
|
|
2406
|
+
lines.push(`\u2713 sent: ${report.reportId} (${messageId})`);
|
|
2407
|
+
} catch (e) {
|
|
2408
|
+
lines.push(`\u2717 ${report.reportId} \u2014 ${e.message}`);
|
|
2409
|
+
anyFailed = true;
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
return { output: lines.join("\n"), code: anyFailed ? 1 : 0 };
|
|
2413
|
+
}
|
|
2414
|
+
async function sendOne(client, base, site, report) {
|
|
2415
|
+
if (!site.headerImage) {
|
|
2416
|
+
throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);
|
|
2417
|
+
}
|
|
2418
|
+
if (!report.lighthouse) {
|
|
2419
|
+
throw new Error(`Report ${report.reportId} has no Lighthouse scores`);
|
|
2420
|
+
}
|
|
2421
|
+
const { bytes, contentType } = await fetchAttachmentBytes(site.headerImage.url);
|
|
2422
|
+
const slug = siteSlug(site.name);
|
|
2423
|
+
const cidName = `${slug}-header`;
|
|
2424
|
+
const { html } = await renderReportHtml({
|
|
2425
|
+
siteName: site.name,
|
|
2426
|
+
siteUrl: site.url,
|
|
2427
|
+
reportType: report.reportType,
|
|
2428
|
+
completedOn: report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date(),
|
|
2429
|
+
lighthouse: report.lighthouse,
|
|
2430
|
+
gaUsersCurrent: report.gaUsersCurrent ?? 0,
|
|
2431
|
+
gaUsersPrevious: report.gaUsersPrevious ?? 0,
|
|
2432
|
+
lastTestedDate: report.lastTestedDate ? new Date(report.lastTestedDate) : null,
|
|
2433
|
+
commentary: report.commentary,
|
|
2434
|
+
headerImageCid: cidName
|
|
2435
|
+
});
|
|
2436
|
+
const subject = report.subjectOverride ?? `${site.name} ${report.reportType} Report`;
|
|
2437
|
+
const explicitTo = parseAddresses(site.reportRecipientsTo);
|
|
2438
|
+
const fallbackTo = parseAddresses(site.pointOfContact);
|
|
2439
|
+
const to = explicitTo ?? fallbackTo ?? [];
|
|
2440
|
+
if (to.length === 0) {
|
|
2441
|
+
throw new Error(
|
|
2442
|
+
`Site '${site.name}' has no recipients (Report recipients (To) AND point of contact are both empty)`
|
|
2443
|
+
);
|
|
2444
|
+
}
|
|
2445
|
+
for (const addr of to) {
|
|
2446
|
+
if (!isProbablyEmail(addr)) {
|
|
2447
|
+
throw new Error(
|
|
2448
|
+
`Site '${site.name}' recipient is malformed: ${addr} \u2014 fix Report recipients (To) or point of contact in Airtable`
|
|
2449
|
+
);
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
const cc = parseAddresses(site.reportRecipientsCc);
|
|
2453
|
+
if (cc) {
|
|
2454
|
+
for (const addr of cc) {
|
|
2455
|
+
if (!isProbablyEmail(addr)) {
|
|
2456
|
+
throw new Error(
|
|
2457
|
+
`Site '${site.name}' CC is malformed: ${addr} \u2014 fix Report recipients (CC) in Airtable`
|
|
2458
|
+
);
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
const payload = {
|
|
2463
|
+
from: FROM_ADDRESS,
|
|
2464
|
+
to,
|
|
2465
|
+
replyTo: REPLY_TO,
|
|
2466
|
+
subject,
|
|
2467
|
+
html,
|
|
2468
|
+
attachments: [
|
|
2469
|
+
{
|
|
2470
|
+
filename: site.headerImage.filename,
|
|
2471
|
+
content: Buffer.from(bytes).toString("base64"),
|
|
2472
|
+
contentType,
|
|
2473
|
+
inlineContentId: cidName
|
|
2474
|
+
}
|
|
2475
|
+
],
|
|
2476
|
+
// Stable across retries of the same row — if Airtable stamping fails after a
|
|
2477
|
+
// successful Resend, the next --send-ready replays with the same key and
|
|
2478
|
+
// Resend returns the original message id rather than sending a duplicate.
|
|
2479
|
+
idempotencyKey: `report:${report.id}`
|
|
2480
|
+
};
|
|
2481
|
+
if (cc) payload.cc = cc;
|
|
2482
|
+
const result = await client.send(payload);
|
|
2483
|
+
await stampSent(base, report.id, /* @__PURE__ */ new Date(), result.messageId);
|
|
2484
|
+
return result.messageId;
|
|
2485
|
+
}
|
|
2486
|
+
function parseAddresses(field) {
|
|
2487
|
+
if (!field) return null;
|
|
2488
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2489
|
+
const list = [];
|
|
2490
|
+
for (const raw of field.split(/[,\n]/)) {
|
|
2491
|
+
const trimmed = raw.trim().toLowerCase();
|
|
2492
|
+
if (!trimmed) continue;
|
|
2493
|
+
if (seen.has(trimmed)) continue;
|
|
2494
|
+
seen.add(trimmed);
|
|
2495
|
+
list.push(trimmed);
|
|
2496
|
+
}
|
|
2497
|
+
return list.length > 0 ? list : null;
|
|
2498
|
+
}
|
|
2499
|
+
function isProbablyEmail(s) {
|
|
2500
|
+
const at = s.indexOf("@");
|
|
2501
|
+
if (at < 1 || at !== s.lastIndexOf("@")) return false;
|
|
2502
|
+
const local = s.slice(0, at);
|
|
2503
|
+
const domain = s.slice(at + 1);
|
|
2504
|
+
if (!local || !domain) return false;
|
|
2505
|
+
if (!domain.includes(".")) return false;
|
|
2506
|
+
if (/\s/.test(s)) return false;
|
|
2507
|
+
return true;
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
// src/reports/due.ts
|
|
2511
|
+
var MONTHS = {
|
|
2512
|
+
Monthly: 1,
|
|
2513
|
+
Quarterly: 3,
|
|
2514
|
+
Yearly: 12
|
|
2515
|
+
};
|
|
2516
|
+
function addMonths(d, n) {
|
|
2517
|
+
const out = new Date(d);
|
|
2518
|
+
const day = out.getUTCDate();
|
|
2519
|
+
out.setUTCDate(1);
|
|
2520
|
+
out.setUTCMonth(out.getUTCMonth() + n);
|
|
2521
|
+
const lastDayOfTargetMonth = new Date(
|
|
2522
|
+
Date.UTC(out.getUTCFullYear(), out.getUTCMonth() + 1, 0)
|
|
2523
|
+
).getUTCDate();
|
|
2524
|
+
out.setUTCDate(Math.min(day, lastDayOfTargetMonth));
|
|
2525
|
+
return out;
|
|
2526
|
+
}
|
|
2527
|
+
function startOfDay(d) {
|
|
2528
|
+
const out = new Date(d);
|
|
2529
|
+
out.setUTCHours(0, 0, 0, 0);
|
|
2530
|
+
return out;
|
|
2531
|
+
}
|
|
2532
|
+
function lastSentForType(reports, siteId, type) {
|
|
2533
|
+
const candidates = reports.filter((r) => r.siteId === siteId && r.reportType === type && r.sentAt !== null).map((r) => r.sentAt).sort();
|
|
2534
|
+
return candidates[candidates.length - 1] ?? null;
|
|
2535
|
+
}
|
|
2536
|
+
function findDueReports(websites, reports, today) {
|
|
2537
|
+
const out = [];
|
|
2538
|
+
const todayStart = startOfDay(today);
|
|
2539
|
+
for (const site of websites) {
|
|
2540
|
+
for (const type of ["Maintenance", "Testing"]) {
|
|
2541
|
+
const freq = type === "Maintenance" ? site.maintenanceFreq : site.testingFreq;
|
|
2542
|
+
if (freq === "None") continue;
|
|
2543
|
+
const lastSent = lastSentForType(reports, site.id, type);
|
|
2544
|
+
const fallback = type === "Maintenance" ? site.maintenanceDay : site.testingDay;
|
|
2545
|
+
const baseIso = lastSent ?? fallback;
|
|
2546
|
+
if (!baseIso) {
|
|
2547
|
+
out.push({ site, reportType: type, dueDate: todayStart, lastSent });
|
|
2548
|
+
continue;
|
|
2549
|
+
}
|
|
2550
|
+
const dueDate = addMonths(new Date(baseIso), MONTHS[freq]);
|
|
2551
|
+
if (todayStart.getTime() >= startOfDay(dueDate).getTime()) {
|
|
2552
|
+
out.push({ site, reportType: type, dueDate, lastSent });
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
return out;
|
|
2557
|
+
}
|
|
1926
2558
|
export {
|
|
1927
2559
|
ALL_AUDIT_NAMES,
|
|
1928
2560
|
ALL_RECIPE_NAMES,
|
|
@@ -1930,15 +2562,19 @@ export {
|
|
|
1930
2562
|
bumpDeps,
|
|
1931
2563
|
convertToPnpm,
|
|
1932
2564
|
depsAudit,
|
|
2565
|
+
draftReportForSite,
|
|
2566
|
+
findDueReports,
|
|
1933
2567
|
fromJsonFile,
|
|
1934
2568
|
isRecipeName,
|
|
1935
2569
|
lighthouseAudit,
|
|
1936
2570
|
lintAudit,
|
|
1937
2571
|
localPath,
|
|
1938
2572
|
onboard,
|
|
2573
|
+
renderReportHtml,
|
|
1939
2574
|
runAudits,
|
|
1940
2575
|
runAuditsAcross,
|
|
1941
2576
|
securityAudit,
|
|
2577
|
+
sendApprovedReports,
|
|
1942
2578
|
svelteCodemods,
|
|
1943
2579
|
syncConfigs,
|
|
1944
2580
|
upgradeSvelte4to5
|