@reddoorla/maintenance 0.85.2 → 0.86.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 +8 -0
- package/dist/airtable-G7WBQQMI.js +9 -0
- package/dist/{announce-GIAYN67S.js → announce-MXTUQ6C7.js} +26 -19
- package/dist/announce-MXTUQ6C7.js.map +1 -0
- package/dist/{bump-deps-SWJB6H5V.js → bump-deps-EC74CPLS.js} +9 -9
- package/dist/chunk-2EVEFOZY.js +129 -0
- package/dist/chunk-2EVEFOZY.js.map +1 -0
- package/dist/{chunk-COZPL62X.js → chunk-2NDRHXMR.js} +25 -17
- package/dist/chunk-2NDRHXMR.js.map +1 -0
- package/dist/{chunk-SDUJIANJ.js → chunk-2NJQLSEU.js} +3 -3
- package/dist/chunk-2NQUCS7O.js +243 -0
- package/dist/chunk-2NQUCS7O.js.map +1 -0
- package/dist/{chunk-DTTIFYAM.js → chunk-2ZPT7GLV.js} +42 -403
- package/dist/chunk-2ZPT7GLV.js.map +1 -0
- package/dist/{chunk-ZTPPCHDB.js → chunk-3G25KIWW.js} +4 -4
- package/dist/chunk-3SF7D4DY.js +359 -0
- package/dist/chunk-3SF7D4DY.js.map +1 -0
- package/dist/{chunk-Q276MZ7E.js → chunk-3VQWSE3F.js} +4 -4
- package/dist/{chunk-37S6L6G4.js → chunk-5UINJNAJ.js} +72 -2
- package/dist/chunk-5UINJNAJ.js.map +1 -0
- package/dist/{chunk-THEFTZ7F.js → chunk-62CMMGEO.js} +5 -5
- package/dist/{chunk-FDIFTIXC.js → chunk-6TWWY7ZU.js} +2 -2
- package/dist/{chunk-UYCAF77E.js → chunk-7RJ2HMMJ.js} +3 -3
- package/dist/chunk-ACBRLLFT.js +198 -0
- package/dist/chunk-ACBRLLFT.js.map +1 -0
- package/dist/chunk-BNM5POSJ.js +284 -0
- package/dist/chunk-BNM5POSJ.js.map +1 -0
- package/dist/{chunk-R6FPFMOS.js → chunk-BQRFQSGY.js} +3 -3
- package/dist/{chunk-MGR2H7F4.js → chunk-BYK66EWO.js} +19 -12
- package/dist/chunk-BYK66EWO.js.map +1 -0
- package/dist/{chunk-6QRLRT3P.js → chunk-CQR4LIM7.js} +12 -265
- package/dist/chunk-CQR4LIM7.js.map +1 -0
- package/dist/{chunk-SEEA6RXF.js → chunk-DJZWYIPP.js} +2 -2
- package/dist/chunk-DJZWYIPP.js.map +1 -0
- package/dist/chunk-EVBCK2KC.js +68 -0
- package/dist/chunk-EVBCK2KC.js.map +1 -0
- package/dist/{chunk-TP5M2NQB.js → chunk-F5KM4LEQ.js} +3 -3
- package/dist/{chunk-62VF32IC.js → chunk-ILUNZHYV.js} +2 -2
- package/dist/chunk-ITIOFN7A.js +396 -0
- package/dist/chunk-ITIOFN7A.js.map +1 -0
- package/dist/{chunk-LS6QPANO.js → chunk-IYSEC543.js} +2 -2
- package/dist/{chunk-DC5H7KEQ.js → chunk-JHGHLKFS.js} +2 -2
- package/dist/{chunk-PBWDDDQH.js → chunk-KTH5ZODA.js} +2 -2
- package/dist/{chunk-4DHQCN5H.js → chunk-LBYOLBW7.js} +17 -2
- package/dist/chunk-LBYOLBW7.js.map +1 -0
- package/dist/{chunk-577HCGZO.js → chunk-LDWZSLSU.js} +38 -23
- package/dist/chunk-LDWZSLSU.js.map +1 -0
- package/dist/{chunk-LTB6KVJW.js → chunk-N45M3FDK.js} +13 -11
- package/dist/{chunk-LTB6KVJW.js.map → chunk-N45M3FDK.js.map} +1 -1
- package/dist/chunk-NYUYNYQL.js +48 -0
- package/dist/chunk-NYUYNYQL.js.map +1 -0
- package/dist/{chunk-7J53SUT6.js → chunk-P2FE5AI6.js} +4 -4
- package/dist/{chunk-3YXDSCM6.js → chunk-PPE5KZJK.js} +29 -11
- package/dist/chunk-PPE5KZJK.js.map +1 -0
- package/dist/chunk-PTDMVGKL.js +31 -0
- package/dist/chunk-PTDMVGKL.js.map +1 -0
- package/dist/{chunk-D4FVAIEJ.js → chunk-PZR4EBPY.js} +5 -2
- package/dist/chunk-PZR4EBPY.js.map +1 -0
- package/dist/{chunk-NWB4O5EY.js → chunk-SLUQ7R6I.js} +3 -3
- package/dist/chunk-SLUQ7R6I.js.map +1 -0
- package/dist/{chunk-GZNBCHAZ.js → chunk-TERE5SDF.js} +4 -4
- package/dist/{chunk-ZMIPG6TR.js → chunk-TVL5MVUF.js} +4 -4
- package/dist/chunk-UDRHAQT7.js +172 -0
- package/dist/chunk-UDRHAQT7.js.map +1 -0
- package/dist/chunk-VAD43MOR.js +72 -0
- package/dist/chunk-VAD43MOR.js.map +1 -0
- package/dist/chunk-VSBFM7DS.js +229 -0
- package/dist/chunk-VSBFM7DS.js.map +1 -0
- package/dist/chunk-W7Z3BGCI.js +274 -0
- package/dist/chunk-W7Z3BGCI.js.map +1 -0
- package/dist/chunk-X3B723J4.js +1209 -0
- package/dist/chunk-X3B723J4.js.map +1 -0
- package/dist/{chunk-VRWUL4TR.js → chunk-XHHMM5HX.js} +2 -2
- package/dist/{chunk-RJB3QUSP.js → chunk-XIDUYLSR.js} +3 -3
- package/dist/{chunk-QG6OSL4M.js → chunk-XUGSZP7Z.js} +2 -2
- package/dist/{chunk-S56JQJ4F.js → chunk-Y53WOQIJ.js} +3 -3
- package/dist/{chunk-ICXV25N7.js → chunk-Z3KYFUN6.js} +3 -3
- package/dist/{chunk-MPBXOJ3N.js → chunk-Z7AXYKQB.js} +33 -2
- package/dist/chunk-Z7AXYKQB.js.map +1 -0
- package/dist/{chunk-WYYQK4D3.js → chunk-ZKXSX4LO.js} +11 -11
- package/dist/cli/bin.js +46 -28
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +30 -1
- package/dist/cli/commands/audit.js +62 -30
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/client-AUE3XUPO.js +11 -0
- package/dist/{convert-to-pnpm-AT52NWZB.js → convert-to-pnpm-6L6P74WR.js} +9 -9
- package/dist/db-B35OGV3G.js +217 -0
- package/dist/db-B35OGV3G.js.map +1 -0
- package/dist/{digest-UVHYZUYE.js → digest-BL6UJYKH.js} +23 -20
- package/dist/{digest-UVHYZUYE.js.map → digest-BL6UJYKH.js.map} +1 -1
- package/dist/dump-P6EBSIOH.js +68 -0
- package/dist/dump-P6EBSIOH.js.map +1 -0
- package/dist/email-VTTTKKL5.js +195 -0
- package/dist/email-VTTTKKL5.js.map +1 -0
- package/dist/{ensure-site-S25MDDHW.js → ensure-site-DFTB7MN3.js} +8 -7
- package/dist/ensure-site-DFTB7MN3.js.map +1 -0
- package/dist/fleet-events-cF2Om88w.d.ts +24 -0
- package/dist/{forms-notify-target-TWN4EH6K.js → forms-notify-target-75U6OU7L.js} +38 -132
- package/dist/forms-notify-target-75U6OU7L.js.map +1 -0
- package/dist/{github-signals-G4ILZAJ6.js → github-signals-IJUPGCUX.js} +40 -19
- package/dist/github-signals-IJUPGCUX.js.map +1 -0
- package/dist/{header-image-T7XD37JL.js → header-image-AWLLQA4L.js} +34 -11
- package/dist/header-image-AWLLQA4L.js.map +1 -0
- package/dist/header-images-335L2AKB.js +68 -0
- package/dist/header-images-335L2AKB.js.map +1 -0
- package/dist/{health-endpoint-242WG2OJ.js → health-endpoint-WJC2UMYS.js} +9 -9
- package/dist/health-mirror-4B2L7T2Y.js +75 -0
- package/dist/health-mirror-4B2L7T2Y.js.map +1 -0
- package/dist/import-airtable-HE43FB4H.js +35 -0
- package/dist/index.d.ts +175 -21
- package/dist/index.js +115 -73
- package/dist/index.js.map +1 -1
- package/dist/{init-JN26NTWF.js → init-NJEOEO7I.js} +20 -17
- package/dist/{init-JN26NTWF.js.map → init-NJEOEO7I.js.map} +1 -1
- package/dist/{launch-JA7Z4CIM.js → launch-XNAX427U.js} +31 -25
- package/dist/{launch-JA7Z4CIM.js.map → launch-XNAX427U.js.map} +1 -1
- package/dist/lighthouse-VHI77PFB.js +11 -0
- package/dist/{lighthouse-airtable-BIPM26SZ.js → lighthouse-airtable-LBVIYYP5.js} +3 -3
- package/dist/mailchimp-XYCTEEJY.js +72 -0
- package/dist/mailchimp-XYCTEEJY.js.map +1 -0
- package/dist/migrate-GQH237A6.js +7 -0
- package/dist/notify-C645PGD5.js +22 -0
- package/dist/{onboard-53CITIM7.js → onboard-GK4PHPNI.js} +9 -9
- package/dist/orchestrate-77UO4BYJ.js +30 -0
- package/dist/orchestrate-77UO4BYJ.js.map +1 -0
- package/dist/parity-MWS24A3H.js +12 -0
- package/dist/parity-MWS24A3H.js.map +1 -0
- package/dist/pipeline-S6CVHDGB.js +12 -0
- package/dist/pipeline-S6CVHDGB.js.map +1 -0
- package/dist/{preflight-3XPDUHFJ.js → preflight-6WBNU2GZ.js} +16 -13
- package/dist/{preflight-3XPDUHFJ.js.map → preflight-6WBNU2GZ.js.map} +1 -1
- package/dist/{prismic-ci-UTQ6LRR3.js → prismic-ci-3RHOZUUT.js} +15 -15
- package/dist/{prismic-models-PVES6R5V.js → prismic-models-QBGUT2Q3.js} +10 -10
- package/dist/prospect-audit-IUNCJ2LV.js +174 -0
- package/dist/prospect-audit-IUNCJ2LV.js.map +1 -0
- package/dist/prospect-audits-3PMIO73D.js +19 -0
- package/dist/prospect-audits-3PMIO73D.js.map +1 -0
- package/dist/{protection-audit-64PLLBLI.js → protection-audit-SJO53MEQ.js} +3 -3
- package/dist/recipes/sync-configs.js +3 -3
- package/dist/render-AQAVQDPZ.js +10 -0
- package/dist/render-AQAVQDPZ.js.map +1 -0
- package/dist/{renovate-dispatch-ICJGSFCE.js → renovate-dispatch-QJY75ODR.js} +8 -8
- package/dist/replay-C2LXMJZP.js +66 -0
- package/dist/replay-C2LXMJZP.js.map +1 -0
- package/dist/{report-6CKSWJNG.js → report-4EVNDHOZ.js} +55 -29
- package/dist/report-4EVNDHOZ.js.map +1 -0
- package/dist/reports/header-image/assets/headline-announcement.png +0 -0
- package/dist/reports/header-image/assets/headline-launch.png +0 -0
- package/dist/reports/header-image/assets/headline-maintenance.png +0 -0
- package/dist/reports/header-image/assets/headline-testing.png +0 -0
- package/dist/reports/header-image/assets/plate-clean.png +0 -0
- package/dist/{self-updating-PPQVV6UA.js → self-updating-6DWI2Z56.js} +9 -9
- package/dist/{selftest-CQ6NOX52.js → selftest-RC36MTKP.js} +25 -20
- package/dist/selftest-RC36MTKP.js.map +1 -0
- package/dist/smoke-B2GQOSIZ.js +20 -0
- package/dist/smoke-B2GQOSIZ.js.map +1 -0
- package/dist/{smoke-suite-TMCSOYRK.js → smoke-suite-VYY2LXM2.js} +9 -9
- package/dist/{submissions-GJ6ITBRH.js → submissions-Q7DRFOCD.js} +3 -3
- package/dist/{svelte-codemods-ZOBDHIES.js → svelte-codemods-25J2FO22.js} +9 -9
- package/dist/sync-34HNZZ56.js +44 -0
- package/dist/sync-34HNZZ56.js.map +1 -0
- package/dist/{sync-configs-CJXUX6MD.js → sync-configs-3SE6PSCO.js} +9 -9
- package/dist/{upgrade-WLX27UPC.js → upgrade-WBRQ62FX.js} +9 -9
- package/dist/{webflow-624FEBFD.js → webflow-IRKOIT7S.js} +4 -4
- package/dist/webhook-VOGOW3W6.js +38 -0
- package/dist/webhook-VOGOW3W6.js.map +1 -0
- package/dist/{websites-SADZR4XQ.js → websites-SXWTP72S.js} +6 -2
- package/dist/websites-SXWTP72S.js.map +1 -0
- package/dist/{write-audits-to-airtable-7PH75YWY.js → write-audits-to-airtable-MBEILLPU.js} +4 -4
- package/dist/write-audits-to-airtable-MBEILLPU.js.map +1 -0
- package/package.json +5 -2
- package/dist/airtable-HRKSA7V4.js +0 -9
- package/dist/announce-GIAYN67S.js.map +0 -1
- package/dist/chunk-37S6L6G4.js.map +0 -1
- package/dist/chunk-3YXDSCM6.js.map +0 -1
- package/dist/chunk-4DHQCN5H.js.map +0 -1
- package/dist/chunk-4SOCPK7A.js +0 -108
- package/dist/chunk-4SOCPK7A.js.map +0 -1
- package/dist/chunk-577HCGZO.js.map +0 -1
- package/dist/chunk-6QRLRT3P.js.map +0 -1
- package/dist/chunk-COZPL62X.js.map +0 -1
- package/dist/chunk-D4FVAIEJ.js.map +0 -1
- package/dist/chunk-DTTIFYAM.js.map +0 -1
- package/dist/chunk-MGR2H7F4.js.map +0 -1
- package/dist/chunk-MPBXOJ3N.js.map +0 -1
- package/dist/chunk-NWB4O5EY.js.map +0 -1
- package/dist/chunk-SEEA6RXF.js.map +0 -1
- package/dist/chunk-U5SZ4FOP.js +0 -26
- package/dist/chunk-U5SZ4FOP.js.map +0 -1
- package/dist/client-X5WUJWDE.js +0 -11
- package/dist/db-JSIZYT23.js +0 -23
- package/dist/db-JSIZYT23.js.map +0 -1
- package/dist/ensure-site-S25MDDHW.js.map +0 -1
- package/dist/forms-notify-target-TWN4EH6K.js.map +0 -1
- package/dist/github-signals-G4ILZAJ6.js.map +0 -1
- package/dist/header-image-T7XD37JL.js.map +0 -1
- package/dist/migrate-LJUFGXQZ.js +0 -7
- package/dist/orchestrate-KDNXZQX7.js +0 -27
- package/dist/report-6CKSWJNG.js.map +0 -1
- package/dist/reports/header-image/assets/plate.png +0 -0
- package/dist/selftest-CQ6NOX52.js.map +0 -1
- /package/dist/{airtable-HRKSA7V4.js.map → airtable-G7WBQQMI.js.map} +0 -0
- /package/dist/{bump-deps-SWJB6H5V.js.map → bump-deps-EC74CPLS.js.map} +0 -0
- /package/dist/{chunk-SDUJIANJ.js.map → chunk-2NJQLSEU.js.map} +0 -0
- /package/dist/{chunk-ZTPPCHDB.js.map → chunk-3G25KIWW.js.map} +0 -0
- /package/dist/{chunk-Q276MZ7E.js.map → chunk-3VQWSE3F.js.map} +0 -0
- /package/dist/{chunk-THEFTZ7F.js.map → chunk-62CMMGEO.js.map} +0 -0
- /package/dist/{chunk-FDIFTIXC.js.map → chunk-6TWWY7ZU.js.map} +0 -0
- /package/dist/{chunk-UYCAF77E.js.map → chunk-7RJ2HMMJ.js.map} +0 -0
- /package/dist/{chunk-R6FPFMOS.js.map → chunk-BQRFQSGY.js.map} +0 -0
- /package/dist/{chunk-TP5M2NQB.js.map → chunk-F5KM4LEQ.js.map} +0 -0
- /package/dist/{chunk-62VF32IC.js.map → chunk-ILUNZHYV.js.map} +0 -0
- /package/dist/{chunk-LS6QPANO.js.map → chunk-IYSEC543.js.map} +0 -0
- /package/dist/{chunk-DC5H7KEQ.js.map → chunk-JHGHLKFS.js.map} +0 -0
- /package/dist/{chunk-PBWDDDQH.js.map → chunk-KTH5ZODA.js.map} +0 -0
- /package/dist/{chunk-7J53SUT6.js.map → chunk-P2FE5AI6.js.map} +0 -0
- /package/dist/{chunk-GZNBCHAZ.js.map → chunk-TERE5SDF.js.map} +0 -0
- /package/dist/{chunk-ZMIPG6TR.js.map → chunk-TVL5MVUF.js.map} +0 -0
- /package/dist/{chunk-VRWUL4TR.js.map → chunk-XHHMM5HX.js.map} +0 -0
- /package/dist/{chunk-RJB3QUSP.js.map → chunk-XIDUYLSR.js.map} +0 -0
- /package/dist/{chunk-QG6OSL4M.js.map → chunk-XUGSZP7Z.js.map} +0 -0
- /package/dist/{chunk-S56JQJ4F.js.map → chunk-Y53WOQIJ.js.map} +0 -0
- /package/dist/{chunk-ICXV25N7.js.map → chunk-Z3KYFUN6.js.map} +0 -0
- /package/dist/{chunk-WYYQK4D3.js.map → chunk-ZKXSX4LO.js.map} +0 -0
- /package/dist/{client-X5WUJWDE.js.map → client-AUE3XUPO.js.map} +0 -0
- /package/dist/{convert-to-pnpm-AT52NWZB.js.map → convert-to-pnpm-6L6P74WR.js.map} +0 -0
- /package/dist/{health-endpoint-242WG2OJ.js.map → health-endpoint-WJC2UMYS.js.map} +0 -0
- /package/dist/{lighthouse-airtable-BIPM26SZ.js.map → import-airtable-HE43FB4H.js.map} +0 -0
- /package/dist/{migrate-LJUFGXQZ.js.map → lighthouse-VHI77PFB.js.map} +0 -0
- /package/dist/{orchestrate-KDNXZQX7.js.map → lighthouse-airtable-LBVIYYP5.js.map} +0 -0
- /package/dist/{websites-SADZR4XQ.js.map → migrate-GQH237A6.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-7PH75YWY.js.map → notify-C645PGD5.js.map} +0 -0
- /package/dist/{onboard-53CITIM7.js.map → onboard-GK4PHPNI.js.map} +0 -0
- /package/dist/{prismic-ci-UTQ6LRR3.js.map → prismic-ci-3RHOZUUT.js.map} +0 -0
- /package/dist/{prismic-models-PVES6R5V.js.map → prismic-models-QBGUT2Q3.js.map} +0 -0
- /package/dist/{protection-audit-64PLLBLI.js.map → protection-audit-SJO53MEQ.js.map} +0 -0
- /package/dist/{renovate-dispatch-ICJGSFCE.js.map → renovate-dispatch-QJY75ODR.js.map} +0 -0
- /package/dist/{self-updating-PPQVV6UA.js.map → self-updating-6DWI2Z56.js.map} +0 -0
- /package/dist/{smoke-suite-TMCSOYRK.js.map → smoke-suite-VYY2LXM2.js.map} +0 -0
- /package/dist/{submissions-GJ6ITBRH.js.map → submissions-Q7DRFOCD.js.map} +0 -0
- /package/dist/{svelte-codemods-ZOBDHIES.js.map → svelte-codemods-25J2FO22.js.map} +0 -0
- /package/dist/{sync-configs-CJXUX6MD.js.map → sync-configs-3SE6PSCO.js.map} +0 -0
- /package/dist/{upgrade-WLX27UPC.js.map → upgrade-WBRQ62FX.js.map} +0 -0
- /package/dist/{webflow-624FEBFD.js.map → webflow-IRKOIT7S.js.map} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
withRecipe
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-3G25KIWW.js";
|
|
4
4
|
import {
|
|
5
5
|
bumpDep,
|
|
6
6
|
readPackageJson,
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "./chunk-LKGVSM2O.js";
|
|
12
12
|
import {
|
|
13
13
|
defaultSpawn
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-LBYOLBW7.js";
|
|
15
15
|
|
|
16
16
|
// src/util/self-version.ts
|
|
17
17
|
import { readFileSync, existsSync } from "fs";
|
|
@@ -150,4 +150,4 @@ export {
|
|
|
150
150
|
selfCaretRange,
|
|
151
151
|
onboard
|
|
152
152
|
};
|
|
153
|
-
//# sourceMappingURL=chunk-
|
|
153
|
+
//# sourceMappingURL=chunk-7RJ2HMMJ.js.map
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ALL_CHECKLIST_FIELDS
|
|
3
|
+
} from "./chunk-VAD43MOR.js";
|
|
4
|
+
|
|
5
|
+
// src/reports/airtable/reports.ts
|
|
6
|
+
var REPORTS_TABLE = "Reports";
|
|
7
|
+
var REPORT_TYPES = ["Maintenance", "Testing", "Launch", "Announcement"];
|
|
8
|
+
function toReportType(raw) {
|
|
9
|
+
if (raw && REPORT_TYPES.includes(raw)) return raw;
|
|
10
|
+
if (raw)
|
|
11
|
+
console.warn(`[reports] unknown Report type ${JSON.stringify(raw)} \u2014 treating as Maintenance`);
|
|
12
|
+
return "Maintenance";
|
|
13
|
+
}
|
|
14
|
+
function isPendingApproval(r) {
|
|
15
|
+
return r.draftReady && !r.approvedToSend && r.sentAt === null;
|
|
16
|
+
}
|
|
17
|
+
function mapRow(rec) {
|
|
18
|
+
const f = rec.fields;
|
|
19
|
+
const linkSites = f["Site"] ?? [];
|
|
20
|
+
const html = (f["Rendered HTML"] ?? [])[0] ?? null;
|
|
21
|
+
return {
|
|
22
|
+
id: rec.id,
|
|
23
|
+
reportId: String(f["Report ID"] ?? ""),
|
|
24
|
+
siteId: linkSites[0] ?? "",
|
|
25
|
+
reportType: toReportType(f["Report type"]),
|
|
26
|
+
period: f["Period"] ?? null,
|
|
27
|
+
periodStart: f["Period start"] ?? null,
|
|
28
|
+
periodEnd: f["Period end"] ?? null,
|
|
29
|
+
completedOn: f["Completed on"] ?? null,
|
|
30
|
+
lighthouse: lighthouseFromFields(f),
|
|
31
|
+
gaUsersCurrent: f["GA users (period)"] ?? null,
|
|
32
|
+
gaUsersPrevious: f["GA users (prev period)"] ?? null,
|
|
33
|
+
searchFoundPage1: typeof f["Search found page 1"] === "boolean" ? f["Search found page 1"] : null,
|
|
34
|
+
searchPosition: f["Search position"] ?? null,
|
|
35
|
+
lastTestedDate: f["Last tested date"] ?? null,
|
|
36
|
+
commentary: f["Commentary"] ?? null,
|
|
37
|
+
subjectOverride: f["Subject override"] ?? null,
|
|
38
|
+
draftReady: Boolean(f["Draft ready"]),
|
|
39
|
+
approvedToSend: Boolean(f["Approved to send"]),
|
|
40
|
+
sentAt: f["Sent at"] ?? null,
|
|
41
|
+
approvedAt: f["Approved At"] ?? null,
|
|
42
|
+
approvedBy: f["Approved By"] ?? null,
|
|
43
|
+
deliveryStatus: f["Delivery status"] ?? "pending",
|
|
44
|
+
renderedHtmlAttachment: html,
|
|
45
|
+
resendMessageId: f["Resend message ID"] ?? null,
|
|
46
|
+
checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])])),
|
|
47
|
+
autoEvidence: parseAutoEvidence(f["Checklist auto-evidence"]),
|
|
48
|
+
sendOverride: Boolean(f["Send override"]),
|
|
49
|
+
overrideReason: f["Override reason"] ?? null,
|
|
50
|
+
overrideBy: f["Override by"] ?? null,
|
|
51
|
+
overrideAt: f["Override at"] ?? null
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function parseAutoEvidence(raw) {
|
|
55
|
+
if (typeof raw !== "string" || !raw.trim()) return null;
|
|
56
|
+
let parsed;
|
|
57
|
+
try {
|
|
58
|
+
parsed = JSON.parse(raw);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
63
|
+
const out = {};
|
|
64
|
+
for (const [field, v] of Object.entries(parsed)) {
|
|
65
|
+
if (!v || typeof v !== "object") continue;
|
|
66
|
+
const o = v;
|
|
67
|
+
if (o.result !== "pass" && o.result !== "fail" && o.result !== "unknown" && o.result !== "n/a")
|
|
68
|
+
continue;
|
|
69
|
+
out[field] = {
|
|
70
|
+
result: o.result,
|
|
71
|
+
checkedAt: typeof o.checkedAt === "string" ? o.checkedAt : null,
|
|
72
|
+
note: typeof o.note === "string" ? o.note : ""
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
76
|
+
}
|
|
77
|
+
function lighthouseFromFields(f) {
|
|
78
|
+
const p = f["Lighthouse \u2014 Performance"];
|
|
79
|
+
const a = f["Lighthouse \u2014 Accessibility"];
|
|
80
|
+
const b = f["Lighthouse \u2014 Best Practices"];
|
|
81
|
+
const s = f["Lighthouse \u2014 SEO"];
|
|
82
|
+
if (typeof p !== "number" || typeof a !== "number" || typeof b !== "number" || typeof s !== "number")
|
|
83
|
+
return null;
|
|
84
|
+
return { performance: p, accessibility: a, bestPractices: b, seo: s };
|
|
85
|
+
}
|
|
86
|
+
function ymd(d) {
|
|
87
|
+
return d.toISOString().slice(0, 10);
|
|
88
|
+
}
|
|
89
|
+
function escapeFormulaString(s) {
|
|
90
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
91
|
+
}
|
|
92
|
+
async function createDraft(base, input) {
|
|
93
|
+
const fields = {
|
|
94
|
+
"Report ID": input.reportId,
|
|
95
|
+
Site: [input.siteId],
|
|
96
|
+
"Report type": input.reportType,
|
|
97
|
+
"Period start": ymd(input.periodStart),
|
|
98
|
+
"Period end": ymd(input.periodEnd),
|
|
99
|
+
"Completed on": ymd(input.completedOn),
|
|
100
|
+
"Lighthouse \u2014 Performance": input.lighthouse.performance,
|
|
101
|
+
"Lighthouse \u2014 Accessibility": input.lighthouse.accessibility,
|
|
102
|
+
"Lighthouse \u2014 Best Practices": input.lighthouse.bestPractices,
|
|
103
|
+
"Lighthouse \u2014 SEO": input.lighthouse.seo,
|
|
104
|
+
"Delivery status": "pending"
|
|
105
|
+
};
|
|
106
|
+
if (input.lastTestedDate) fields["Last tested date"] = ymd(input.lastTestedDate);
|
|
107
|
+
if (input.gaUsersCurrent !== void 0) fields["GA users (period)"] = input.gaUsersCurrent;
|
|
108
|
+
if (input.gaUsersPrevious !== void 0) fields["GA users (prev period)"] = input.gaUsersPrevious;
|
|
109
|
+
if (input.searchFoundPage1 !== void 0) fields["Search found page 1"] = input.searchFoundPage1;
|
|
110
|
+
if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
|
|
111
|
+
if (input.period !== void 0) fields["Period"] = input.period;
|
|
112
|
+
if (input.subjectOverride !== void 0) fields["Subject override"] = input.subjectOverride;
|
|
113
|
+
for (const field of input.checklistTicks ?? []) fields[field] = true;
|
|
114
|
+
if (input.autoEvidence && Object.keys(input.autoEvidence).length > 0) {
|
|
115
|
+
fields["Checklist auto-evidence"] = JSON.stringify(input.autoEvidence);
|
|
116
|
+
}
|
|
117
|
+
const created = await base(REPORTS_TABLE).create([{ fields }]);
|
|
118
|
+
const rec = created[0];
|
|
119
|
+
if (!rec) throw new Error("Airtable create returned no records");
|
|
120
|
+
return mapRow({ id: rec.id, fields: rec.fields });
|
|
121
|
+
}
|
|
122
|
+
async function setDraftReady(base, recordId, ready) {
|
|
123
|
+
await base(REPORTS_TABLE).update([{ id: recordId, fields: { "Draft ready": ready } }]);
|
|
124
|
+
}
|
|
125
|
+
async function updateReportScores(base, recordId, scores, completedOn, enrichment) {
|
|
126
|
+
const fields = {
|
|
127
|
+
"Lighthouse \u2014 Performance": scores.performance,
|
|
128
|
+
"Lighthouse \u2014 Accessibility": scores.accessibility,
|
|
129
|
+
"Lighthouse \u2014 Best Practices": scores.bestPractices,
|
|
130
|
+
"Lighthouse \u2014 SEO": scores.seo
|
|
131
|
+
};
|
|
132
|
+
if (completedOn) fields["Completed on"] = ymd(completedOn);
|
|
133
|
+
if (enrichment?.gaUsersCurrent !== void 0)
|
|
134
|
+
fields["GA users (period)"] = enrichment.gaUsersCurrent;
|
|
135
|
+
if (enrichment?.gaUsersPrevious !== void 0)
|
|
136
|
+
fields["GA users (prev period)"] = enrichment.gaUsersPrevious;
|
|
137
|
+
if (enrichment?.searchFoundPage1 !== void 0)
|
|
138
|
+
fields["Search found page 1"] = enrichment.searchFoundPage1;
|
|
139
|
+
if (enrichment?.searchPosition !== void 0)
|
|
140
|
+
fields["Search position"] = enrichment.searchPosition;
|
|
141
|
+
await base(REPORTS_TABLE).update([{ id: recordId, fields }]);
|
|
142
|
+
}
|
|
143
|
+
async function listSendableReports(base) {
|
|
144
|
+
const out = [];
|
|
145
|
+
await base(REPORTS_TABLE).select({
|
|
146
|
+
filterByFormula: "AND({Draft ready} = TRUE(), {Approved to send} = TRUE(), {Sent at} = BLANK())",
|
|
147
|
+
pageSize: 100
|
|
148
|
+
}).eachPage((records, fetchNextPage) => {
|
|
149
|
+
for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));
|
|
150
|
+
fetchNextPage();
|
|
151
|
+
});
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
async function listAllReports(base) {
|
|
155
|
+
const out = [];
|
|
156
|
+
await base(REPORTS_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
|
|
157
|
+
for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));
|
|
158
|
+
fetchNextPage();
|
|
159
|
+
});
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
async function listReportsForSite(base, siteId) {
|
|
163
|
+
return (await listAllReports(base)).filter((r) => r.siteId === siteId);
|
|
164
|
+
}
|
|
165
|
+
async function stampSent(base, recordId, sentAt, messageId) {
|
|
166
|
+
const fields = { "Sent at": sentAt.toISOString() };
|
|
167
|
+
if (messageId !== null) fields["Resend message ID"] = messageId;
|
|
168
|
+
await base(REPORTS_TABLE).update([
|
|
169
|
+
{
|
|
170
|
+
id: recordId,
|
|
171
|
+
fields
|
|
172
|
+
}
|
|
173
|
+
]);
|
|
174
|
+
}
|
|
175
|
+
async function findReportByPeriod(base, siteId, reportType, period) {
|
|
176
|
+
const safeType = escapeFormulaString(reportType);
|
|
177
|
+
const safePeriod = escapeFormulaString(period);
|
|
178
|
+
const formula = `AND({Report type} = "${safeType}", {Period} = "${safePeriod}")`;
|
|
179
|
+
const rows = [];
|
|
180
|
+
await base(REPORTS_TABLE).select({ filterByFormula: formula, pageSize: 100 }).eachPage((records, fetchNextPage) => {
|
|
181
|
+
for (const rec of records) rows.push(mapRow({ id: rec.id, fields: rec.fields }));
|
|
182
|
+
fetchNextPage();
|
|
183
|
+
});
|
|
184
|
+
return rows.find((r) => r.siteId === siteId) ?? null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export {
|
|
188
|
+
isPendingApproval,
|
|
189
|
+
createDraft,
|
|
190
|
+
setDraftReady,
|
|
191
|
+
updateReportScores,
|
|
192
|
+
listSendableReports,
|
|
193
|
+
listAllReports,
|
|
194
|
+
listReportsForSite,
|
|
195
|
+
stampSent,
|
|
196
|
+
findReportByPeriod
|
|
197
|
+
};
|
|
198
|
+
//# sourceMappingURL=chunk-ACBRLLFT.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/airtable/reports.ts"],"sourcesContent":["import type { FieldSet, Records } from \"airtable\";\nimport type { AirtableBase } from \"./client.js\";\nimport type { ReportType, LighthouseScores } from \"../types.js\";\nimport { ALL_CHECKLIST_FIELDS } from \"../checklist.js\";\nimport type { EvidenceRecord } from \"../auto-tick.js\";\n\nexport const REPORTS_TABLE = \"Reports\";\n\nconst REPORT_TYPES: readonly ReportType[] = [\"Maintenance\", \"Testing\", \"Launch\", \"Announcement\"];\n\n/** Coerce the Airtable `Report type` (a single-select string) to a known\n * ReportType. A bare `as ReportType` cast is a compile-time lie: if the\n * single-select gains an unexpected option, the bad value flows to render.ts,\n * where `reportType === \"Launch\"` silently falls through to the Maintenance\n * template. Validate at the boundary; warn + default to \"Maintenance\" so an\n * unknown type is VISIBLE in the logs rather than silently mis-templated. */\nexport function toReportType(raw: string | undefined): ReportType {\n if (raw && (REPORT_TYPES as readonly string[]).includes(raw)) return raw as ReportType;\n if (raw)\n console.warn(`[reports] unknown Report type ${JSON.stringify(raw)} — treating as Maintenance`);\n return \"Maintenance\";\n}\n\nexport type DeliveryStatus = \"pending\" | \"delivered\" | \"bounced\" | \"complained\";\n\nexport type ReportRow = {\n id: string;\n reportId: string;\n siteId: string;\n reportType: ReportType;\n /** UTC `YYYY-MM` recurrence key (idempotency for search-before-create). Null on legacy rows. */\n period: string | null;\n periodStart: string | null;\n periodEnd: string | null;\n completedOn: string | null;\n lighthouse: LighthouseScores | null;\n gaUsersCurrent: number | null;\n gaUsersPrevious: number | null;\n searchFoundPage1: boolean | null;\n searchPosition: number | null;\n lastTestedDate: string | null;\n commentary: string | null;\n subjectOverride: string | null;\n draftReady: boolean;\n approvedToSend: boolean;\n sentAt: string | null;\n approvedAt: string | null;\n approvedBy: string | null;\n deliveryStatus: DeliveryStatus;\n renderedHtmlAttachment: { url: string; filename: string } | null;\n /** Read out of the Resend response and stored in a hidden field; needed for webhook reconciliation. */\n resendMessageId: string | null;\n /** The 12 operator-checklist checkboxes, keyed by their Airtable column name (ALL_CHECKLIST_FIELDS);\n * missing/false cells read false. Maintenance/Testing reports gate approve+send on the relevant\n * subset (see src/reports/checklist.ts). */\n checklist: Record<string, boolean>;\n /** Snapshot of the auto-tick evidence at draft time, keyed by checklist field → evidence\n * record. Null when the report predates auto-tick or carried no auto-checked items. This is the\n * health gate's source of truth: `isHealthGateClear`/`gatingHealth` (src/reports/checklist.ts)\n * read these evidence records, NOT the checklist booleans — and also drive the dashboard badges. */\n autoEvidence: Record<string, EvidenceRecord> | null;\n /** Logged send-anyway override (Phase 10). When `sendOverride` is true AND `overrideReason` is\n * non-empty, the health gate is bypassed for THIS report; `overrideBy`/`overrideAt` are the\n * audit trail (parallel to approvedBy/approvedAt). Missing cells read false/null. */\n sendOverride: boolean;\n overrideReason: string | null;\n overrideBy: string | null;\n overrideAt: string | null;\n};\n\n/**\n * The \"Ready for your yes\" gate: Draft ready ∧ ¬Approved to send ∧ Sent at BLANK.\n * The single source of truth for \"pending the operator's approval\" — `listPendingApproval`,\n * `runDigest`'s ready-list, the per-site dashboard, and the fleet cockpit all key off this\n * one predicate so the surfaces can't drift.\n */\nexport function isPendingApproval(r: ReportRow): boolean {\n return r.draftReady && !r.approvedToSend && r.sentAt === null;\n}\n\nexport function mapRow(rec: { id: string; fields: Record<string, unknown> }): ReportRow {\n const f = rec.fields;\n const linkSites = (f[\"Site\"] as string[] | undefined) ?? [];\n const html =\n ((f[\"Rendered HTML\"] as Array<{ url: string; filename: string }> | undefined) ?? [])[0] ?? null;\n return {\n id: rec.id,\n reportId: String(f[\"Report ID\"] ?? \"\"),\n siteId: linkSites[0] ?? \"\",\n reportType: toReportType(f[\"Report type\"] as string | undefined),\n period: (f[\"Period\"] as string | undefined) ?? null,\n periodStart: (f[\"Period start\"] as string | undefined) ?? null,\n periodEnd: (f[\"Period end\"] as string | undefined) ?? null,\n completedOn: (f[\"Completed on\"] as string | undefined) ?? null,\n lighthouse: lighthouseFromFields(f),\n gaUsersCurrent: (f[\"GA users (period)\"] as number | undefined) ?? null,\n gaUsersPrevious: (f[\"GA users (prev period)\"] as number | undefined) ?? null,\n searchFoundPage1:\n typeof f[\"Search found page 1\"] === \"boolean\" ? (f[\"Search found page 1\"] as boolean) : null,\n searchPosition: (f[\"Search position\"] as number | undefined) ?? null,\n lastTestedDate: (f[\"Last tested date\"] as string | undefined) ?? null,\n commentary: (f[\"Commentary\"] as string | undefined) ?? null,\n subjectOverride: (f[\"Subject override\"] as string | undefined) ?? null,\n draftReady: Boolean(f[\"Draft ready\"]),\n approvedToSend: Boolean(f[\"Approved to send\"]),\n sentAt: (f[\"Sent at\"] as string | undefined) ?? null,\n approvedAt: (f[\"Approved At\"] as string | undefined) ?? null,\n approvedBy: (f[\"Approved By\"] as string | undefined) ?? null,\n deliveryStatus: ((f[\"Delivery status\"] as string | undefined) ?? \"pending\") as DeliveryStatus,\n renderedHtmlAttachment: html,\n resendMessageId: (f[\"Resend message ID\"] as string | undefined) ?? null,\n checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])])),\n autoEvidence: parseAutoEvidence(f[\"Checklist auto-evidence\"]),\n sendOverride: Boolean(f[\"Send override\"]),\n overrideReason: (f[\"Override reason\"] as string | undefined) ?? null,\n overrideBy: (f[\"Override by\"] as string | undefined) ?? null,\n overrideAt: (f[\"Override at\"] as string | undefined) ?? null,\n };\n}\n\n/**\n * Parse the `Checklist auto-evidence` JSON field → a field→EvidenceRecord map, or null when\n * absent/malformed. Permissive on inner shape (it's display-only — a bad blob just yields null,\n * never throws), mirroring `parseNotifyRouting`.\n */\nexport function parseAutoEvidence(raw: unknown): Record<string, EvidenceRecord> | null {\n if (typeof raw !== \"string\" || !raw.trim()) return null;\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return null;\n }\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n // Validate each entry's inner shape (it drives display) — drop any record whose `result` isn't\n // one of the four literals, and coerce `note`/`checkedAt` to safe types. A garbage blob yields\n // null rather than a record that would render \"auto: undefined\" on the dashboard.\n const out: Record<string, EvidenceRecord> = {};\n for (const [field, v] of Object.entries(parsed as Record<string, unknown>)) {\n if (!v || typeof v !== \"object\") continue;\n const o = v as Record<string, unknown>;\n if (o.result !== \"pass\" && o.result !== \"fail\" && o.result !== \"unknown\" && o.result !== \"n/a\")\n continue;\n out[field] = {\n result: o.result,\n checkedAt: typeof o.checkedAt === \"string\" ? o.checkedAt : null,\n note: typeof o.note === \"string\" ? o.note : \"\",\n };\n }\n return Object.keys(out).length > 0 ? out : null;\n}\n\nfunction lighthouseFromFields(f: Record<string, unknown>): LighthouseScores | null {\n const p = f[\"Lighthouse — Performance\"];\n const a = f[\"Lighthouse — Accessibility\"];\n const b = f[\"Lighthouse — Best Practices\"];\n const s = f[\"Lighthouse — SEO\"];\n if (\n typeof p !== \"number\" ||\n typeof a !== \"number\" ||\n typeof b !== \"number\" ||\n typeof s !== \"number\"\n )\n return null;\n return { performance: p, accessibility: a, bestPractices: b, seo: s };\n}\n\n/** The GA + Search-presence fields a draft can carry, written to the Reports row.\n * `searchFoundPage1` is written whenever the check ran (true or false — false is the\n * operator-only negative signal); `searchPosition` only when found on page 1. Shared by\n * the create path (DraftInput) and the reuse path (updateReportScores). */\nexport type ReportEnrichment = {\n gaUsersCurrent?: number;\n gaUsersPrevious?: number;\n searchFoundPage1?: boolean;\n searchPosition?: number;\n};\n\nexport type DraftInput = {\n reportId: string;\n siteId: string;\n reportType: ReportType;\n /** UTC `YYYY-MM` recurrence key. Omitted on legacy callers; written only when supplied. */\n period?: string;\n periodStart: Date;\n periodEnd: Date;\n completedOn: Date;\n lighthouse: LighthouseScores;\n lastTestedDate: Date | null;\n /** GA \"Users\" for the period / previous period. Omitted when GA is not configured\n * for the site or the fetch failed — the operator fills the fields manually. */\n gaUsersCurrent?: number;\n gaUsersPrevious?: number;\n /** Search-presence result. `searchFoundPage1` is written whenever the check ran (true or\n * false — false is the operator-only negative signal). `searchPosition` only when found. */\n searchFoundPage1?: boolean;\n searchPosition?: number;\n subjectOverride?: string;\n /** Checklist fields to tick at create time (the auto-tick \"pass\" set). */\n checklistTicks?: string[];\n /** Auto-tick evidence snapshot to persist (keyed by checklist field). */\n autoEvidence?: Record<string, EvidenceRecord>;\n};\n\nfunction ymd(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Escape a string for safe interpolation into an Airtable filterByFormula.\n * Airtable formulas use SQL-like string literals; we escape backslash and\n * double quote. Used wherever an externally-supplied string flows into a\n * formula (e.g. Resend message ids on the webhook path).\n */\nexport function escapeFormulaString(s: string): string {\n return s.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n}\n\nexport async function createDraft(base: AirtableBase, input: DraftInput): Promise<ReportRow> {\n // Set Delivery status to \"pending\" at creation time, NOT at send time. This\n // matters for H4: if stampSent wrote \"pending\" after the webhook had already\n // written \"delivered\" (race), the operator would see a regressed status.\n const fields: FieldSet = {\n \"Report ID\": input.reportId,\n Site: [input.siteId],\n \"Report type\": input.reportType,\n \"Period start\": ymd(input.periodStart),\n \"Period end\": ymd(input.periodEnd),\n \"Completed on\": ymd(input.completedOn),\n \"Lighthouse — Performance\": input.lighthouse.performance,\n \"Lighthouse — Accessibility\": input.lighthouse.accessibility,\n \"Lighthouse — Best Practices\": input.lighthouse.bestPractices,\n \"Lighthouse — SEO\": input.lighthouse.seo,\n \"Delivery status\": \"pending\",\n };\n if (input.lastTestedDate) fields[\"Last tested date\"] = ymd(input.lastTestedDate);\n // GA fields are written only when supplied (GA configured + fetch succeeded). When\n // omitted the row keeps them blank for manual entry — the pre-GA behavior.\n if (input.gaUsersCurrent !== undefined) fields[\"GA users (period)\"] = input.gaUsersCurrent;\n if (input.gaUsersPrevious !== undefined) fields[\"GA users (prev period)\"] = input.gaUsersPrevious;\n if (input.searchFoundPage1 !== undefined) fields[\"Search found page 1\"] = input.searchFoundPage1;\n if (input.searchPosition !== undefined) fields[\"Search position\"] = input.searchPosition;\n if (input.period !== undefined) fields[\"Period\"] = input.period;\n if (input.subjectOverride !== undefined) fields[\"Subject override\"] = input.subjectOverride;\n // Auto-ticked checklist boxes + the evidence snapshot. The booleans are the same columns the\n // operator/dashboard toggle; the JSON is what the health gate reads (isHealthGateClear/gatingHealth)\n // and also drives the dashboard's green/amber badges.\n for (const field of input.checklistTicks ?? []) fields[field] = true;\n if (input.autoEvidence && Object.keys(input.autoEvidence).length > 0) {\n fields[\"Checklist auto-evidence\"] = JSON.stringify(input.autoEvidence);\n }\n const created = (await base(REPORTS_TABLE).create([{ fields }])) as Records<FieldSet>;\n const rec = created[0];\n if (!rec) throw new Error(\"Airtable create returned no records\");\n return mapRow({ id: rec.id, fields: rec.fields });\n}\n\nexport async function setDraftReady(\n base: AirtableBase,\n recordId: string,\n ready: boolean,\n): Promise<void> {\n await base(REPORTS_TABLE).update([{ id: recordId, fields: { \"Draft ready\": ready } }]);\n}\n\n/**\n * Overwrite the four `Lighthouse — *` score cells (and, when supplied, `Completed\n * on`) on an EXISTING Reports row. The launch re-run path uses this: it reuses the\n * already-created Launch row but must refresh its scores to match the freshly-run\n * audit — otherwise the re-rendered preview shows new scores while the row (and the\n * eventually-sent email, which reads the row) keeps the stale ones. The create path\n * already writes fresh scores via `createDraft`; this is its update-side mirror, using\n * the same exact field names so the two stay in lockstep.\n */\nexport async function updateReportScores(\n base: AirtableBase,\n recordId: string,\n scores: LighthouseScores,\n completedOn?: Date,\n enrichment?: ReportEnrichment,\n): Promise<void> {\n const fields: FieldSet = {\n \"Lighthouse — Performance\": scores.performance,\n \"Lighthouse — Accessibility\": scores.accessibility,\n \"Lighthouse — Best Practices\": scores.bestPractices,\n \"Lighthouse — SEO\": scores.seo,\n };\n if (completedOn) fields[\"Completed on\"] = ymd(completedOn);\n if (enrichment?.gaUsersCurrent !== undefined)\n fields[\"GA users (period)\"] = enrichment.gaUsersCurrent;\n if (enrichment?.gaUsersPrevious !== undefined)\n fields[\"GA users (prev period)\"] = enrichment.gaUsersPrevious;\n if (enrichment?.searchFoundPage1 !== undefined)\n fields[\"Search found page 1\"] = enrichment.searchFoundPage1;\n if (enrichment?.searchPosition !== undefined)\n fields[\"Search position\"] = enrichment.searchPosition;\n await base(REPORTS_TABLE).update([{ id: recordId, fields }]);\n}\n\nexport async function listSendableReports(base: AirtableBase): Promise<ReportRow[]> {\n const out: ReportRow[] = [];\n await base(REPORTS_TABLE)\n .select({\n filterByFormula:\n \"AND({Draft ready} = TRUE(), {Approved to send} = TRUE(), {Sent at} = BLANK())\",\n pageSize: 100,\n })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return out;\n}\n\n/**\n * Fetch every Reports row, unfiltered. Site-scoped callers filter the result in\n * memory: the `Site` linked-record field CANNOT be formula-filtered by record id\n * (see findReportByPeriod's doc for why), and the fleet's Reports table is small\n * enough that one paged fetch-all beats N broken-or-per-site queries.\n */\nexport async function listAllReports(base: AirtableBase): Promise<ReportRow[]> {\n const out: ReportRow[] = [];\n await base(REPORTS_TABLE)\n .select({ pageSize: 100 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return out;\n}\n\nexport async function listReportsForSite(base: AirtableBase, siteId: string): Promise<ReportRow[]> {\n // Client-side match on the mapped siteId (mapRow reads the record id from the\n // REST response, where it IS present) — record ids can't appear in formulas.\n return (await listAllReports(base)).filter((r) => r.siteId === siteId);\n}\n\n/**\n * Mark a row as sent: write `Sent at` and (when known) `Resend message ID`.\n * Crucially does NOT touch `Delivery status` — that's set to \"pending\" in\n * createDraft and updated by the webhook from there. If we wrote \"pending\" here\n * we could clobber a \"delivered\" that the webhook raced ahead and wrote first (H4).\n *\n * `messageId` may be `null`: the 409 idempotency-conflict path has no recoverable\n * id (the original send's id was lost when the prior run's stamp failed), so it\n * passes null and we write ONLY `Sent at`. Writing a sentinel string like\n * \"idempotent-conflict\" into the id column would masquerade as a real Resend id\n * and silently orphan `findReportByMessageId` lookups; leaving it null is honest —\n * the row still stops replaying (Sent at is set), delivery tracking for that one\n * report is simply degraded (the id is genuinely unknowable on that path).\n */\nexport async function stampSent(\n base: AirtableBase,\n recordId: string,\n sentAt: Date,\n messageId: string | null,\n): Promise<void> {\n const fields: Record<string, string> = { \"Sent at\": sentAt.toISOString() };\n if (messageId !== null) fields[\"Resend message ID\"] = messageId;\n await base(REPORTS_TABLE).update([\n {\n id: recordId,\n fields,\n },\n ]);\n}\n\nexport async function setDeliveryStatus(\n base: AirtableBase,\n recordId: string,\n status: DeliveryStatus,\n): Promise<void> {\n await base(REPORTS_TABLE).update([{ id: recordId, fields: { \"Delivery status\": status } }]);\n}\n\n/**\n * Stamp the approval on a Reports row: flips `Approved to send` TRUE and records\n * who/when for the audit trail. The caller (approveReport handler) is responsible\n * for idempotency — this is the raw write. Never touches `Sent at`.\n */\nexport async function approveReportRow(\n base: AirtableBase,\n recordId: string,\n approvedAt: Date,\n approvedBy: string,\n): Promise<void> {\n await base(REPORTS_TABLE).update([\n {\n id: recordId,\n fields: {\n \"Approved to send\": true,\n \"Approved At\": approvedAt.toISOString(),\n \"Approved By\": approvedBy,\n },\n },\n ]);\n}\n\n/**\n * Stamp a logged send-anyway override on a Reports row: sets `Send override` TRUE, the reason, and\n * who/when — AND flips `Approved to send` TRUE (with the same Approved At/By stamp) so the daily\n * cron delivers the overridden report. Mirrors {@link approveReportRow}; never touches `Sent at`.\n */\nexport async function overrideReportRow(\n base: AirtableBase,\n recordId: string,\n overrideAt: Date,\n overrideBy: string,\n reason: string,\n): Promise<void> {\n const at = overrideAt.toISOString();\n await base(REPORTS_TABLE).update([\n {\n id: recordId,\n fields: {\n \"Send override\": true,\n \"Override reason\": reason,\n \"Override by\": overrideBy,\n \"Override at\": at,\n \"Approved to send\": true,\n \"Approved At\": at,\n \"Approved By\": overrideBy,\n },\n },\n ]);\n}\n\n/**\n * True when an `.find` rejection is a GENUINE not-found, not a transient failure.\n * The Airtable SDK stamps `.statusCode` (404) and/or `.error` (\"NOT_FOUND\") on\n * its errors. Anything else (429 rate-limit, 500 outage, bad-PAT 401, network\n * error) must NOT be masked as a 404 — see getReportById.\n */\nfunction isNotFoundError(err: unknown): boolean {\n if (typeof err !== \"object\" || err === null) return false;\n const e = err as { statusCode?: unknown; error?: unknown; name?: unknown; message?: unknown };\n if (e.statusCode === 404) return true;\n const tag = String(e.error ?? e.name ?? e.message ?? \"\");\n return tag === \"NOT_FOUND\" || /not found/i.test(tag);\n}\n\n/**\n * Fetch one Reports row by its Airtable record id, or null if it doesn't exist.\n * Only a GENUINE not-found (404 / NOT_FOUND) collapses to null; every other\n * failure (outage, 429, bad PAT, network error) is rethrown so the adapter\n * surfaces a 500 instead of a misleading 404. Swallowing all throws previously\n * turned an Airtable outage into a \"no such report\".\n */\nexport async function getReportById(\n base: AirtableBase,\n recordId: string,\n): Promise<ReportRow | null> {\n try {\n const rec = await base(REPORTS_TABLE).find(recordId);\n return mapRow({ id: rec.id, fields: rec.fields as Record<string, unknown> });\n } catch (err) {\n if (isNotFoundError(err)) return null;\n throw err;\n }\n}\n\nexport async function findReportByMessageId(\n base: AirtableBase,\n messageId: string,\n): Promise<ReportRow | null> {\n const rows: ReportRow[] = [];\n await base(REPORTS_TABLE)\n .select({\n filterByFormula: `{Resend message ID} = \"${escapeFormulaString(messageId)}\"`,\n maxRecords: 1,\n })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return rows[0] ?? null;\n}\n\n/**\n * Find the Reports row for a `(site, reportType, period)` triple, or null. The\n * idempotency lookup behind search-before-create drafting.\n *\n * The site is matched CLIENT-side, never in the formula: Airtable's formula layer\n * renders linked-record fields ({Site}) as the linked rows' PRIMARY-FIELD NAMES,\n * not record ids, so any formula comparing {Site} or ARRAYJOIN({Site}) against a\n * `recXXX` id matches NOTHING (live-proven against the real base — do not\n * reintroduce that idiom). Record ids exist only in the REST response, where\n * mapRow reads them. So the formula filters on the real scalar fields (Report\n * type + Period — escaped, keeping it injection-safe if their source ever\n * changes), and the first mapped row whose siteId matches wins. The candidate\n * set is at most one row per site for the (type, period), so this stays small.\n */\nexport async function findReportByPeriod(\n base: AirtableBase,\n siteId: string,\n reportType: ReportType,\n period: string,\n): Promise<ReportRow | null> {\n const safeType = escapeFormulaString(reportType);\n const safePeriod = escapeFormulaString(period);\n const formula = `AND({Report type} = \"${safeType}\", {Period} = \"${safePeriod}\")`;\n const rows: ReportRow[] = [];\n await base(REPORTS_TABLE)\n .select({ filterByFormula: formula, pageSize: 100 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return rows.find((r) => r.siteId === siteId) ?? null;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SPAM_THRESHOLD
|
|
3
|
+
} from "./chunk-SNSTJLQW.js";
|
|
4
|
+
import {
|
|
5
|
+
SUBMISSION_FORM_TYPES
|
|
6
|
+
} from "./chunk-2OP4JKIQ.js";
|
|
7
|
+
|
|
8
|
+
// src/forms/payload.ts
|
|
9
|
+
var KNOWN_KEYS = /* @__PURE__ */ new Set([
|
|
10
|
+
"formType",
|
|
11
|
+
"name",
|
|
12
|
+
"firstName",
|
|
13
|
+
"lastName",
|
|
14
|
+
"email",
|
|
15
|
+
"phone",
|
|
16
|
+
"message",
|
|
17
|
+
"sourceUrl",
|
|
18
|
+
"utm",
|
|
19
|
+
"extra",
|
|
20
|
+
"_meta"
|
|
21
|
+
]);
|
|
22
|
+
var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
23
|
+
var EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
24
|
+
function str(v) {
|
|
25
|
+
return typeof v === "string" ? v.trim() : "";
|
|
26
|
+
}
|
|
27
|
+
function resolveFormType(raw) {
|
|
28
|
+
const s = str(raw);
|
|
29
|
+
if (s === "") return "contact";
|
|
30
|
+
return SUBMISSION_FORM_TYPES.includes(s) ? s : null;
|
|
31
|
+
}
|
|
32
|
+
function normalizeSubmission(payload) {
|
|
33
|
+
if (typeof payload !== "object" || payload === null) {
|
|
34
|
+
return { ok: false, errors: ["payload must be a JSON object"] };
|
|
35
|
+
}
|
|
36
|
+
const p = payload;
|
|
37
|
+
const name = str(p.name) || [str(p.firstName), str(p.lastName)].filter(Boolean).join(" ");
|
|
38
|
+
const email = str(p.email).toLowerCase();
|
|
39
|
+
const message = str(p.message);
|
|
40
|
+
const formType = resolveFormType(p.formType);
|
|
41
|
+
const errors = [];
|
|
42
|
+
if (!email && !message) errors.push("at least one of email or message is required");
|
|
43
|
+
if (email && !EMAIL_RE.test(email)) errors.push("email is not a valid address");
|
|
44
|
+
if (formType === null) errors.push("formType is not a recognized form type");
|
|
45
|
+
if (errors.length > 0 || formType === null) return { ok: false, errors };
|
|
46
|
+
const extraFields = {};
|
|
47
|
+
const extra = p.extra;
|
|
48
|
+
if (typeof extra === "object" && extra !== null) {
|
|
49
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
50
|
+
if (!DANGEROUS_KEYS.has(k)) extraFields[k] = v;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const [k, v] of Object.entries(p)) {
|
|
54
|
+
if (!KNOWN_KEYS.has(k) && !DANGEROUS_KEYS.has(k)) extraFields[k] = v;
|
|
55
|
+
}
|
|
56
|
+
const value = {
|
|
57
|
+
formType,
|
|
58
|
+
name,
|
|
59
|
+
email,
|
|
60
|
+
extraFields
|
|
61
|
+
};
|
|
62
|
+
const phone = str(p.phone);
|
|
63
|
+
if (phone) value.phone = phone;
|
|
64
|
+
if (message) value.message = message;
|
|
65
|
+
const sourceUrl = str(p.sourceUrl);
|
|
66
|
+
if (sourceUrl) value.sourceUrl = sourceUrl;
|
|
67
|
+
const utm = str(p.utm);
|
|
68
|
+
if (utm) value.utm = utm;
|
|
69
|
+
return { ok: true, value };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/forms/ingest.ts
|
|
73
|
+
var DUPLICATE_WINDOW_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
74
|
+
async function ingestSubmission(deps, slug, rawPayload, turnstileInput = "unverifiable") {
|
|
75
|
+
const verification = typeof turnstileInput === "string" ? { outcome: turnstileInput, hostname: null } : turnstileInput;
|
|
76
|
+
const turnstile = verification.outcome;
|
|
77
|
+
const normalized = normalizeSubmission(rawPayload);
|
|
78
|
+
if (!normalized.ok) {
|
|
79
|
+
return { status: "rejected", reason: "invalid-payload", errors: normalized.errors };
|
|
80
|
+
}
|
|
81
|
+
let site;
|
|
82
|
+
try {
|
|
83
|
+
site = await deps.getWebsiteBySlug(slug);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (!deps.deadLetter || isTestMode(rawPayload)) throw err;
|
|
86
|
+
const dl = await deps.deadLetter({
|
|
87
|
+
siteSlug: slug,
|
|
88
|
+
payload: rawPayload,
|
|
89
|
+
turnstile: verification,
|
|
90
|
+
error: String(err),
|
|
91
|
+
receivedAt: deps.now()
|
|
92
|
+
});
|
|
93
|
+
console.error(
|
|
94
|
+
`[ingest] site lookup failed for '${slug}' \u2014 lead dead-lettered as ${dl.id}: ${String(err)}`
|
|
95
|
+
);
|
|
96
|
+
return { status: "accepted", submissionId: dl.id, notifyStatus: "skipped" };
|
|
97
|
+
}
|
|
98
|
+
if (!site) return { status: "unknown-site", slug };
|
|
99
|
+
if (isTestMode(rawPayload)) {
|
|
100
|
+
return { status: "accepted", submissionId: "test-mode", notifyStatus: "skipped" };
|
|
101
|
+
}
|
|
102
|
+
const n = normalized.value;
|
|
103
|
+
const spamHandlingEnabled = site.status !== "building";
|
|
104
|
+
let verdict = { score: 0, reasons: [] };
|
|
105
|
+
if (spamHandlingEnabled && deps.classifySpam) {
|
|
106
|
+
try {
|
|
107
|
+
verdict = deps.classifySpam(n, turnstile);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error(`[ingest] classifySpam threw: ${String(err)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const reasons = [...verdict.reasons];
|
|
113
|
+
let status = verdict.score >= SPAM_THRESHOLD ? "spam_auto" : "new";
|
|
114
|
+
if (spamHandlingEnabled && site.requireTurnstile && (turnstile === "fail" || turnstile === "absent")) {
|
|
115
|
+
status = "spam_auto";
|
|
116
|
+
const reason = turnstile === "fail" ? "turnstile-required-failed" : "turnstile-required-absent";
|
|
117
|
+
if (!reasons.includes(reason)) reasons.push(reason);
|
|
118
|
+
} else if (spamHandlingEnabled && site.requireTurnstile && turnstile === "pass" && verification.hostname !== null && !turnstileHostnameAcceptable(verification.hostname, site.url)) {
|
|
119
|
+
status = "spam_auto";
|
|
120
|
+
if (!reasons.includes("turnstile-required-hostname"))
|
|
121
|
+
reasons.push("turnstile-required-hostname");
|
|
122
|
+
}
|
|
123
|
+
if (spamHandlingEnabled && n.formType !== "newsletter" && deps.listRecentSubmissionsForEmail) {
|
|
124
|
+
try {
|
|
125
|
+
const since = new Date(deps.now().getTime() - DUPLICATE_WINDOW_MS);
|
|
126
|
+
const prior = await deps.listRecentSubmissionsForEmail(n.email, since);
|
|
127
|
+
const otherSites = prior.filter((p) => p.siteId !== site.id);
|
|
128
|
+
if (otherSites.length > 0) {
|
|
129
|
+
if (status !== "spam_auto") {
|
|
130
|
+
status = "spam_auto";
|
|
131
|
+
if (!reasons.includes("repeat-sender")) reasons.push("repeat-sender");
|
|
132
|
+
}
|
|
133
|
+
const retroIds = otherSites.filter((p) => p.status === "new").map((p) => p.id);
|
|
134
|
+
if (deps.retroBucket && retroIds.length > 0) {
|
|
135
|
+
try {
|
|
136
|
+
await deps.retroBucket(retroIds, "retro:repeat-sender");
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.error(`[ingest] retroBucket (repeat-sender) threw: ${String(err)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
console.error(`[ingest] listRecentSubmissionsForEmail threw: ${String(err)}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (spamHandlingEnabled && n.formType !== "newsletter" && n.message !== void 0 && deps.findRecentDuplicates) {
|
|
147
|
+
try {
|
|
148
|
+
const since = new Date(deps.now().getTime() - DUPLICATE_WINDOW_MS);
|
|
149
|
+
const dupes = await deps.findRecentDuplicates(n.message, since);
|
|
150
|
+
const senderEmail = n.email.trim().toLowerCase();
|
|
151
|
+
const isOwnResend = (m) => m.siteId === site.id && m.email.trim().toLowerCase() === senderEmail;
|
|
152
|
+
const exact = dupes.exact.filter((m) => !isOwnResend(m));
|
|
153
|
+
const similar = dupes.similar.filter((m) => !isOwnResend(m));
|
|
154
|
+
const reason = exact.length > 0 ? "duplicate-body" : similar.length > 0 ? "similar-body" : null;
|
|
155
|
+
if (reason !== null) {
|
|
156
|
+
if (status !== "spam_auto") {
|
|
157
|
+
status = "spam_auto";
|
|
158
|
+
if (!reasons.includes(reason)) reasons.push(reason);
|
|
159
|
+
}
|
|
160
|
+
const retroIds = [...exact, ...similar].filter((m) => m.status === "new").map((m) => m.id);
|
|
161
|
+
if (deps.retroBucket && retroIds.length > 0) {
|
|
162
|
+
try {
|
|
163
|
+
await deps.retroBucket(retroIds, "retro:duplicate-body");
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.error(`[ingest] retroBucket (duplicate-body) threw: ${String(err)}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch (err) {
|
|
170
|
+
console.error(`[ingest] findRecentDuplicates threw: ${String(err)}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const spamReason = reasons.length > 0 ? reasons.join(",") : null;
|
|
174
|
+
const row = await deps.createSubmission({
|
|
175
|
+
siteId: site.id,
|
|
176
|
+
formType: n.formType,
|
|
177
|
+
name: n.name,
|
|
178
|
+
email: n.email,
|
|
179
|
+
extraFields: n.extraFields,
|
|
180
|
+
status,
|
|
181
|
+
spamScore: verdict.score,
|
|
182
|
+
spamReason,
|
|
183
|
+
// Optional fields spread only when present — exactOptionalPropertyTypes
|
|
184
|
+
// forbids assigning `undefined` to an optional `phone?: string` etc.
|
|
185
|
+
...n.phone !== void 0 ? { phone: n.phone } : {},
|
|
186
|
+
...n.message !== void 0 ? { message: n.message } : {},
|
|
187
|
+
...n.sourceUrl !== void 0 ? { sourceUrl: n.sourceUrl } : {},
|
|
188
|
+
...n.utm !== void 0 ? { utm: n.utm } : {},
|
|
189
|
+
submittedAt: deps.now()
|
|
190
|
+
});
|
|
191
|
+
const isSpam = row.status === "spam_auto" || row.status === "spam";
|
|
192
|
+
const runTail = async () => {
|
|
193
|
+
let notify;
|
|
194
|
+
if (isSpam) {
|
|
195
|
+
notify = { status: "skipped", messageId: null };
|
|
196
|
+
} else {
|
|
197
|
+
try {
|
|
198
|
+
notify = await deps.notify(site, row);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
console.error(`[ingest] notify threw: ${String(err)}`);
|
|
201
|
+
notify = { status: "failed", messageId: null };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
await deps.stampNotified(row.id, notify.status, notify.messageId);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
console.error(`[ingest] stampNotified failed: ${String(err)}`);
|
|
208
|
+
}
|
|
209
|
+
await runFanout();
|
|
210
|
+
return notify.status;
|
|
211
|
+
};
|
|
212
|
+
const runFanout = async () => {
|
|
213
|
+
const fanout = [];
|
|
214
|
+
if (n.formType === "newsletter" && !isSpam) {
|
|
215
|
+
if (site.newsletterWebhook && deps.forwardNewsletter) {
|
|
216
|
+
try {
|
|
217
|
+
const fwd = await deps.forwardNewsletter(site.newsletterWebhook, row, site);
|
|
218
|
+
if (!fwd.ok)
|
|
219
|
+
console.error(`[ingest] newsletter webhook \u2192 ${fwd.status} for ${site.name}`);
|
|
220
|
+
fanout.push(fwd.ok ? "webhook:ok" : `webhook:${fwd.status}`);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
console.error(`[ingest] newsletter webhook threw: ${String(err)}`);
|
|
223
|
+
fanout.push("webhook:threw");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (site.mailchimpApiKey && site.mailchimpAudienceId && deps.addToMailchimp) {
|
|
227
|
+
try {
|
|
228
|
+
const mc = await deps.addToMailchimp(site, row);
|
|
229
|
+
if (!mc.ok) console.error(`[ingest] mailchimp add \u2192 ${mc.status} for ${site.name}`);
|
|
230
|
+
fanout.push(mc.ok ? "mailchimp:ok" : `mailchimp:${mc.status}`);
|
|
231
|
+
if (mc.ok && mc.tagged === false) {
|
|
232
|
+
console.error(`[ingest] mailchimp tags not applied for ${site.name}`);
|
|
233
|
+
fanout.push("mailchimp-tags:failed");
|
|
234
|
+
}
|
|
235
|
+
} catch (err) {
|
|
236
|
+
console.error(`[ingest] mailchimp add threw: ${String(err)}`);
|
|
237
|
+
fanout.push("mailchimp:threw");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (fanout.length > 0 && deps.stampFanout) {
|
|
242
|
+
try {
|
|
243
|
+
await deps.stampFanout(row.id, fanout.join(","));
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.error(`[ingest] stampFanout failed: ${String(err)}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
if (deps.defer) {
|
|
250
|
+
deps.defer(
|
|
251
|
+
runTail().catch((err) => {
|
|
252
|
+
console.error(`[ingest] deferred tail threw: ${String(err)}`);
|
|
253
|
+
})
|
|
254
|
+
);
|
|
255
|
+
return { status: "accepted", submissionId: row.id, notifyStatus: "deferred" };
|
|
256
|
+
}
|
|
257
|
+
return { status: "accepted", submissionId: row.id, notifyStatus: await runTail() };
|
|
258
|
+
}
|
|
259
|
+
function hostsMatch(a, b) {
|
|
260
|
+
const ha = a.trim().toLowerCase();
|
|
261
|
+
const hb = b.trim().toLowerCase();
|
|
262
|
+
if (ha.length === 0 || hb.length === 0) return false;
|
|
263
|
+
return ha === hb || ha.endsWith(`.${hb}`) || hb.endsWith(`.${ha}`);
|
|
264
|
+
}
|
|
265
|
+
function turnstileHostnameAcceptable(tokenHostname, siteUrl) {
|
|
266
|
+
let siteHost;
|
|
267
|
+
try {
|
|
268
|
+
siteHost = new URL(siteUrl).hostname;
|
|
269
|
+
} catch {
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
if (!siteHost) return true;
|
|
273
|
+
return hostsMatch(tokenHostname, siteHost);
|
|
274
|
+
}
|
|
275
|
+
function isTestMode(rawPayload) {
|
|
276
|
+
if (!rawPayload || typeof rawPayload !== "object") return false;
|
|
277
|
+
return rawPayload.testMode === true;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export {
|
|
281
|
+
ingestSubmission,
|
|
282
|
+
hostsMatch
|
|
283
|
+
};
|
|
284
|
+
//# sourceMappingURL=chunk-BNM5POSJ.js.map
|