@reddoorla/maintenance 0.96.0 → 0.98.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 +5 -6
- package/dist/{announce-45HVP4J4.js → announce-2FH2KBQQ.js} +39 -31
- package/dist/announce-2FH2KBQQ.js.map +1 -0
- package/dist/attachments-JFGFQRXQ.js +10 -0
- package/dist/{blux-RUFMNOUJ.js → blux-NXQLMS4W.js} +5 -5
- package/dist/{bump-deps-EL3TKMIL.js → bump-deps-54K4ECP3.js} +6 -6
- package/dist/{chunk-IMYL67JV.js → chunk-3RLDX3Q2.js} +5 -5
- package/dist/{chunk-EQQ4H6L3.js → chunk-3YALLSBF.js} +2 -2
- package/dist/{chunk-LAJ3FF54.js → chunk-5UI4FERC.js} +16 -5
- package/dist/chunk-5UI4FERC.js.map +1 -0
- package/dist/{chunk-CA6ADGOC.js → chunk-6PGXKN6F.js} +2 -2
- package/dist/{chunk-Y5BVR4HW.js → chunk-6THEZQLA.js} +3 -3
- package/dist/chunk-ASLDCWBF.js +41 -0
- package/dist/chunk-ASLDCWBF.js.map +1 -0
- package/dist/{chunk-DVJNYPFR.js → chunk-AYDQQBNG.js} +2 -2
- package/dist/{chunk-OTEVKRAH.js → chunk-B2BRFWJY.js} +7 -16
- package/dist/chunk-B2BRFWJY.js.map +1 -0
- package/dist/{chunk-GEUC5VGM.js → chunk-BINFZQ2T.js} +49 -4
- package/dist/chunk-BINFZQ2T.js.map +1 -0
- package/dist/{chunk-T76MA2FH.js → chunk-CP6MVZ5X.js} +27 -1
- package/dist/{chunk-T76MA2FH.js.map → chunk-CP6MVZ5X.js.map} +1 -1
- package/dist/{chunk-72FJZ6LO.js → chunk-DRNZVORX.js} +26 -22
- package/dist/chunk-DRNZVORX.js.map +1 -0
- package/dist/{chunk-XY7YV2YQ.js → chunk-DXZRWW2F.js} +65 -10
- package/dist/chunk-DXZRWW2F.js.map +1 -0
- package/dist/{chunk-SHGIDTYF.js → chunk-DYTYPKFN.js} +2 -2
- package/dist/chunk-DZZ5KRTB.js +23 -0
- package/dist/chunk-DZZ5KRTB.js.map +1 -0
- package/dist/{chunk-NVLREB5R.js → chunk-EN6CTBUN.js} +7 -7
- package/dist/{chunk-ZH5LOQET.js → chunk-FHFXJ3EM.js} +7 -5
- package/dist/chunk-FHFXJ3EM.js.map +1 -0
- package/dist/{chunk-3EA43S3R.js → chunk-GRF2RYEO.js} +12 -12
- package/dist/{chunk-RRE74VWI.js → chunk-HSMN35LU.js} +2 -2
- package/dist/chunk-ICSRWW5Q.js +148 -0
- package/dist/chunk-ICSRWW5Q.js.map +1 -0
- package/dist/{chunk-XG2A6GIG.js → chunk-IDBPII6G.js} +4 -4
- package/dist/{chunk-XG2A6GIG.js.map → chunk-IDBPII6G.js.map} +1 -1
- package/dist/{chunk-ECDJDHKT.js → chunk-JHJNNJLG.js} +2 -2
- package/dist/{chunk-QFSL2L2B.js → chunk-L5F3WYIY.js} +81 -31
- package/dist/chunk-L5F3WYIY.js.map +1 -0
- package/dist/{chunk-XED2DNAI.js → chunk-LAG6IP7Y.js} +7 -7
- package/dist/{chunk-UIITN37J.js → chunk-LF2TJN4X.js} +2 -2
- package/dist/chunk-LF2TJN4X.js.map +1 -0
- package/dist/{chunk-SQ4FSNYL.js → chunk-LUF52EIY.js} +2 -2
- package/dist/{chunk-MZZDMBDT.js → chunk-LZMAG73U.js} +19 -19
- package/dist/chunk-LZMAG73U.js.map +1 -0
- package/dist/{chunk-6INOBNRO.js → chunk-MNRWHSFS.js} +2 -2
- package/dist/{chunk-CGQZFQAY.js → chunk-MVKNU4QD.js} +2 -3
- package/dist/chunk-MVKNU4QD.js.map +1 -0
- package/dist/{chunk-62YWOFVN.js → chunk-ORTOMEYR.js} +3 -3
- package/dist/{chunk-62YWOFVN.js.map → chunk-ORTOMEYR.js.map} +1 -1
- package/dist/{chunk-44SDGPGQ.js → chunk-PZZ6HM5T.js} +3 -3
- package/dist/chunk-RQJFGK3A.js +51 -0
- package/dist/chunk-RQJFGK3A.js.map +1 -0
- package/dist/{chunk-H445EH33.js → chunk-S2PLX4XK.js} +10 -7
- package/dist/chunk-S2PLX4XK.js.map +1 -0
- package/dist/{chunk-KN52PM63.js → chunk-TC4GANMX.js} +47 -149
- package/dist/chunk-TC4GANMX.js.map +1 -0
- package/dist/{chunk-F3JWOPTG.js → chunk-TEMQPSCM.js} +45 -105
- package/dist/chunk-TEMQPSCM.js.map +1 -0
- package/dist/{chunk-4UTJ4G6P.js → chunk-TJLKWQOX.js} +2 -2
- package/dist/{chunk-A5QKNOXW.js → chunk-TTT6HYPW.js} +47 -10
- package/dist/chunk-TTT6HYPW.js.map +1 -0
- package/dist/{chunk-VEIIUCYL.js → chunk-TV76A6GN.js} +2 -2
- package/dist/{chunk-D3CTOCUR.js → chunk-TVZ6QCOE.js} +3 -3
- package/dist/chunk-UPE6D4AT.js +69 -0
- package/dist/chunk-UPE6D4AT.js.map +1 -0
- package/dist/{chunk-BIYQN7FH.js → chunk-VCPTOZ56.js} +31 -1
- package/dist/{chunk-BIYQN7FH.js.map → chunk-VCPTOZ56.js.map} +1 -1
- package/dist/chunk-VQMAAQ5M.js +53 -0
- package/dist/chunk-VQMAAQ5M.js.map +1 -0
- package/dist/{chunk-EASYCD6F.js → chunk-WMF5S2MB.js} +1 -1
- package/dist/{chunk-EASYCD6F.js.map → chunk-WMF5S2MB.js.map} +1 -1
- package/dist/{chunk-37B4LVB3.js → chunk-X56LAKPR.js} +3 -3
- package/dist/chunk-X56LAKPR.js.map +1 -0
- package/dist/{chunk-IY6256GK.js → chunk-YJBSB4LP.js} +6 -1
- package/dist/chunk-YJBSB4LP.js.map +1 -0
- package/dist/{chunk-P4A6D6HO.js → chunk-ZEE5WQYU.js} +32 -18
- package/dist/chunk-ZEE5WQYU.js.map +1 -0
- package/dist/cli/bin.js +92 -50
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +33 -3
- package/dist/cli/commands/audit.js +26 -23
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/client-3XGSZMLU.js +11 -0
- package/dist/{convert-to-pnpm-4DON7IFS.js → convert-to-pnpm-U2CRNKNY.js} +6 -6
- package/dist/{db-5K4CDEGJ.js → db-7XYTBIPH.js} +25 -18
- package/dist/db-7XYTBIPH.js.map +1 -0
- package/dist/{deadletter-G4M3HSVM.js → deadletter-A7RQVNG3.js} +8 -4
- package/dist/{digest-5AJGWS33.js → digest-AMXRGWZA.js} +34 -33
- package/dist/digest-AMXRGWZA.js.map +1 -0
- package/dist/{digest-collectors-MC5NWEUA.js → digest-collectors-CX2NCGS7.js} +15 -12
- package/dist/ensure-site-F2WFCWWA.js +70 -0
- package/dist/ensure-site-F2WFCWWA.js.map +1 -0
- package/dist/ensure-site-JEKV2ODE.js +105 -0
- package/dist/ensure-site-JEKV2ODE.js.map +1 -0
- package/dist/{ensure-site-XESZHZEB.js → ensure-site-OO4UPWNQ.js} +19 -42
- package/dist/ensure-site-OO4UPWNQ.js.map +1 -0
- package/dist/fleet-events-CkOZpMVC.d.ts +435 -0
- package/dist/{fleet-state-GJWGKTLD.js → fleet-state-RJROYXKO.js} +17 -7
- package/dist/{forms-notify-target-4NPLX2UG.js → forms-notify-target-DK5R326N.js} +17 -13
- package/dist/forms-notify-target-DK5R326N.js.map +1 -0
- package/dist/{github-signals-DMU74SAX.js → github-signals-RGRPYTUB.js} +20 -15
- package/dist/github-signals-RGRPYTUB.js.map +1 -0
- package/dist/{header-image-BRTOLHJF.js → header-image-43ZIEJKP.js} +13 -17
- package/dist/header-image-43ZIEJKP.js.map +1 -0
- package/dist/{health-endpoint-UCLMSSCN.js → health-endpoint-K5LXDPGE.js} +7 -7
- package/dist/{health-mirror-ONRT4DQ5.js → health-mirror-WZUHWCCV.js} +10 -10
- package/dist/{import-airtable-IIK2QUUF.js → import-airtable-UBF5GYAL.js} +3 -3
- package/dist/index.d.ts +102 -494
- package/dist/index.js +62 -52
- package/dist/index.js.map +1 -1
- package/dist/{init-3LMZTCVD.js → init-OFITGMOP.js} +16 -16
- package/dist/{launch-XTDMC6LD.js → launch-AJY3TGTQ.js} +37 -35
- package/dist/launch-AJY3TGTQ.js.map +1 -0
- package/dist/{lighthouse-52WSPZ5D.js → lighthouse-RFMFREEX.js} +2 -2
- package/dist/lighthouse-airtable-M45OPVRV.js +14 -0
- package/dist/{chunk-IHHAYXKY.js → match-harness-CFELJA57.js} +70 -4
- package/dist/match-harness-CFELJA57.js.map +1 -0
- package/dist/migrate-EZL55AWA.js +7 -0
- package/dist/notify-K3WVH3DM.js +1 -1
- package/dist/{onboard-CZYBWVWH.js → onboard-F7RTLMDZ.js} +6 -6
- package/dist/{orchestrate-TGDFGY52.js → orchestrate-SAIFXZIW.js} +13 -10
- package/dist/parity-2JGVUCZD.js +14 -0
- package/dist/{pipeline-UUPXOS2J.js → pipeline-TCSNVDQZ.js} +3 -3
- package/dist/{preflight-POZZI3GN.js → preflight-6XGVVO2U.js} +35 -11
- package/dist/preflight-6XGVVO2U.js.map +1 -0
- package/dist/{prismic-ci-AHY5ULJR.js → prismic-ci-XMX7A5E4.js} +11 -11
- package/dist/prismic-ci-XMX7A5E4.js.map +1 -0
- package/dist/{prismic-models-URYGODNY.js → prismic-models-7GKFAAFA.js} +19 -16
- package/dist/prismic-models-7GKFAAFA.js.map +1 -0
- package/dist/prospect/types.d.ts +44 -2
- package/dist/prospect/types.js +1 -1
- package/dist/{prospect-audit-AJEE3Z4L.js → prospect-audit-KWP5P5U2.js} +37 -5
- package/dist/prospect-audit-KWP5P5U2.js.map +1 -0
- package/dist/{protection-audit-3J3UEOVT.js → protection-audit-YXJKFU6O.js} +3 -3
- package/dist/{protection-audit-3J3UEOVT.js.map → protection-audit-YXJKFU6O.js.map} +1 -1
- package/dist/recipes/sync-configs.js +3 -3
- package/dist/{render-MHUUHPLN.js → render-NWUAX6RN.js} +3 -3
- package/dist/{render-from-row-65DC6ZP3.js → render-from-row-75JTED2Z.js} +6 -4
- package/dist/{renovate-dispatch-ONJMBUHJ.js → renovate-dispatch-ZZQXT7DA.js} +13 -8
- package/dist/{renovate-dispatch-ONJMBUHJ.js.map → renovate-dispatch-ZZQXT7DA.js.map} +1 -1
- package/dist/replay-HB44TAII.js +77 -0
- package/dist/replay-HB44TAII.js.map +1 -0
- package/dist/{report-RC5LGPU3.js → report-MVPWS7WP.js} +102 -58
- package/dist/report-MVPWS7WP.js.map +1 -0
- package/dist/{report-mirror-KKZZ6RMZ.js → report-mirror-O6IDFDWU.js} +28 -10
- package/dist/report-mirror-O6IDFDWU.js.map +1 -0
- package/dist/roster-EJ3ILR52.js +19 -0
- package/dist/roster-EJ3ILR52.js.map +1 -0
- package/dist/{screenouts-7P5GOZMV.js → screenouts-7NQUWHOJ.js} +14 -1
- package/dist/screenouts-7NQUWHOJ.js.map +1 -0
- package/dist/{self-updating-RKKIYNV4.js → self-updating-KX5FMGEO.js} +7 -7
- package/dist/{selftest-AFLR7YV7.js → selftest-KABY2CYJ.js} +21 -17
- package/dist/{selftest-AFLR7YV7.js.map → selftest-KABY2CYJ.js.map} +1 -1
- package/dist/site-create-ZU3ACBTO.js +45 -0
- package/dist/site-create-ZU3ACBTO.js.map +1 -0
- package/dist/{site-mirror-H2FYMN55.js → site-mirror-TKTDWYP3.js} +10 -10
- package/dist/site-mirror-TKTDWYP3.js.map +1 -0
- package/dist/{smoke-suite-BWHR3XX5.js → smoke-suite-QQMV7TNA.js} +7 -7
- package/dist/{submissions-JDZP35JM.js → submissions-4I66W6QK.js} +11 -1
- package/dist/{submissions-JDZP35JM.js.map → submissions-4I66W6QK.js.map} +1 -1
- package/dist/{submissions-II5J35QM.js → submissions-K5UXGZUN.js} +12 -7
- package/dist/submissions-K5UXGZUN.js.map +1 -0
- package/dist/{svelte-codemods-QWGIRNN6.js → svelte-codemods-YFN4TEGV.js} +6 -6
- package/dist/{sync-NKPDCMYW.js → sync-F4USL4K5.js} +6 -6
- package/dist/{sync-configs-HGLQV55H.js → sync-configs-HBXS3D7C.js} +6 -6
- package/dist/turso-KQKBBQCK.js +24 -0
- package/dist/turso-KQKBBQCK.js.map +1 -0
- package/dist/{upgrade-WZ44IARG.js → upgrade-XOJMDIT4.js} +6 -6
- package/dist/util/git.d.ts +1 -1
- package/dist/util/git.js +1 -1
- package/dist/{websites-6DHIHJ2D.js → websites-Y6FN2XFD.js} +26 -21
- package/dist/{write-audits-to-airtable-BIK6LNVC.js → write-audits-to-airtable-TI3JMWSY.js} +6 -4
- package/package.json +1 -1
- package/dist/airtable-SJV4ZSMS.js +0 -9
- package/dist/announce-45HVP4J4.js.map +0 -1
- package/dist/attachments-NLZIJ5NU.js +0 -9
- package/dist/chunk-37B4LVB3.js.map +0 -1
- package/dist/chunk-72FJZ6LO.js.map +0 -1
- package/dist/chunk-74QRWE6G.js +0 -53
- package/dist/chunk-74QRWE6G.js.map +0 -1
- package/dist/chunk-A5QKNOXW.js.map +0 -1
- package/dist/chunk-CGQZFQAY.js.map +0 -1
- package/dist/chunk-F3JWOPTG.js.map +0 -1
- package/dist/chunk-GEUC5VGM.js.map +0 -1
- package/dist/chunk-H445EH33.js.map +0 -1
- package/dist/chunk-IHHAYXKY.js.map +0 -1
- package/dist/chunk-IY6256GK.js.map +0 -1
- package/dist/chunk-KN52PM63.js.map +0 -1
- package/dist/chunk-LAJ3FF54.js.map +0 -1
- package/dist/chunk-MZZDMBDT.js.map +0 -1
- package/dist/chunk-OTEVKRAH.js.map +0 -1
- package/dist/chunk-P4A6D6HO.js.map +0 -1
- package/dist/chunk-QFSL2L2B.js.map +0 -1
- package/dist/chunk-UIITN37J.js.map +0 -1
- package/dist/chunk-XY7YV2YQ.js.map +0 -1
- package/dist/chunk-YFAXOD7Z.js +0 -42
- package/dist/chunk-YFAXOD7Z.js.map +0 -1
- package/dist/chunk-ZH5LOQET.js.map +0 -1
- package/dist/client-L7NAUG6J.js +0 -11
- package/dist/db-5K4CDEGJ.js.map +0 -1
- package/dist/digest-5AJGWS33.js.map +0 -1
- package/dist/ensure-site-XESZHZEB.js.map +0 -1
- package/dist/fleet-events-cF2Om88w.d.ts +0 -24
- package/dist/forms-notify-target-4NPLX2UG.js.map +0 -1
- package/dist/github-signals-DMU74SAX.js.map +0 -1
- package/dist/header-image-BRTOLHJF.js.map +0 -1
- package/dist/launch-XTDMC6LD.js.map +0 -1
- package/dist/lighthouse-airtable-3NOFB42B.js +0 -12
- package/dist/match-harness-OW45CB5H.js +0 -77
- package/dist/match-harness-OW45CB5H.js.map +0 -1
- package/dist/migrate-NE3DUMTM.js +0 -7
- package/dist/parity-AEKYSLN6.js +0 -14
- package/dist/preflight-POZZI3GN.js.map +0 -1
- package/dist/prismic-ci-AHY5ULJR.js.map +0 -1
- package/dist/prismic-models-URYGODNY.js.map +0 -1
- package/dist/prospect-audit-AJEE3Z4L.js.map +0 -1
- package/dist/replay-4FVFOMHH.js +0 -54
- package/dist/replay-4FVFOMHH.js.map +0 -1
- package/dist/report-RC5LGPU3.js.map +0 -1
- package/dist/report-mirror-KKZZ6RMZ.js.map +0 -1
- package/dist/screenouts-7P5GOZMV.js.map +0 -1
- package/dist/site-mirror-H2FYMN55.js.map +0 -1
- package/dist/submissions-II5J35QM.js.map +0 -1
- package/dist/write-audits-to-airtable-BIK6LNVC.js.map +0 -1
- /package/dist/{airtable-SJV4ZSMS.js.map → attachments-JFGFQRXQ.js.map} +0 -0
- /package/dist/{blux-RUFMNOUJ.js.map → blux-NXQLMS4W.js.map} +0 -0
- /package/dist/{bump-deps-EL3TKMIL.js.map → bump-deps-54K4ECP3.js.map} +0 -0
- /package/dist/{chunk-IMYL67JV.js.map → chunk-3RLDX3Q2.js.map} +0 -0
- /package/dist/{chunk-EQQ4H6L3.js.map → chunk-3YALLSBF.js.map} +0 -0
- /package/dist/{chunk-CA6ADGOC.js.map → chunk-6PGXKN6F.js.map} +0 -0
- /package/dist/{chunk-Y5BVR4HW.js.map → chunk-6THEZQLA.js.map} +0 -0
- /package/dist/{chunk-DVJNYPFR.js.map → chunk-AYDQQBNG.js.map} +0 -0
- /package/dist/{chunk-SHGIDTYF.js.map → chunk-DYTYPKFN.js.map} +0 -0
- /package/dist/{chunk-NVLREB5R.js.map → chunk-EN6CTBUN.js.map} +0 -0
- /package/dist/{chunk-3EA43S3R.js.map → chunk-GRF2RYEO.js.map} +0 -0
- /package/dist/{chunk-RRE74VWI.js.map → chunk-HSMN35LU.js.map} +0 -0
- /package/dist/{chunk-ECDJDHKT.js.map → chunk-JHJNNJLG.js.map} +0 -0
- /package/dist/{chunk-XED2DNAI.js.map → chunk-LAG6IP7Y.js.map} +0 -0
- /package/dist/{chunk-SQ4FSNYL.js.map → chunk-LUF52EIY.js.map} +0 -0
- /package/dist/{chunk-6INOBNRO.js.map → chunk-MNRWHSFS.js.map} +0 -0
- /package/dist/{chunk-44SDGPGQ.js.map → chunk-PZZ6HM5T.js.map} +0 -0
- /package/dist/{chunk-4UTJ4G6P.js.map → chunk-TJLKWQOX.js.map} +0 -0
- /package/dist/{chunk-VEIIUCYL.js.map → chunk-TV76A6GN.js.map} +0 -0
- /package/dist/{chunk-D3CTOCUR.js.map → chunk-TVZ6QCOE.js.map} +0 -0
- /package/dist/{attachments-NLZIJ5NU.js.map → client-3XGSZMLU.js.map} +0 -0
- /package/dist/{convert-to-pnpm-4DON7IFS.js.map → convert-to-pnpm-U2CRNKNY.js.map} +0 -0
- /package/dist/{client-L7NAUG6J.js.map → deadletter-A7RQVNG3.js.map} +0 -0
- /package/dist/{deadletter-G4M3HSVM.js.map → digest-collectors-CX2NCGS7.js.map} +0 -0
- /package/dist/{digest-collectors-MC5NWEUA.js.map → fleet-state-RJROYXKO.js.map} +0 -0
- /package/dist/{health-endpoint-UCLMSSCN.js.map → health-endpoint-K5LXDPGE.js.map} +0 -0
- /package/dist/{health-mirror-ONRT4DQ5.js.map → health-mirror-WZUHWCCV.js.map} +0 -0
- /package/dist/{fleet-state-GJWGKTLD.js.map → import-airtable-UBF5GYAL.js.map} +0 -0
- /package/dist/{init-3LMZTCVD.js.map → init-OFITGMOP.js.map} +0 -0
- /package/dist/{import-airtable-IIK2QUUF.js.map → lighthouse-RFMFREEX.js.map} +0 -0
- /package/dist/{lighthouse-52WSPZ5D.js.map → lighthouse-airtable-M45OPVRV.js.map} +0 -0
- /package/dist/{lighthouse-airtable-3NOFB42B.js.map → migrate-EZL55AWA.js.map} +0 -0
- /package/dist/{onboard-CZYBWVWH.js.map → onboard-F7RTLMDZ.js.map} +0 -0
- /package/dist/{migrate-NE3DUMTM.js.map → orchestrate-SAIFXZIW.js.map} +0 -0
- /package/dist/{orchestrate-TGDFGY52.js.map → parity-2JGVUCZD.js.map} +0 -0
- /package/dist/{parity-AEKYSLN6.js.map → pipeline-TCSNVDQZ.js.map} +0 -0
- /package/dist/{render-MHUUHPLN.js.map → render-NWUAX6RN.js.map} +0 -0
- /package/dist/{pipeline-UUPXOS2J.js.map → render-from-row-75JTED2Z.js.map} +0 -0
- /package/dist/{self-updating-RKKIYNV4.js.map → self-updating-KX5FMGEO.js.map} +0 -0
- /package/dist/{smoke-suite-BWHR3XX5.js.map → smoke-suite-QQMV7TNA.js.map} +0 -0
- /package/dist/{svelte-codemods-QWGIRNN6.js.map → svelte-codemods-YFN4TEGV.js.map} +0 -0
- /package/dist/{sync-NKPDCMYW.js.map → sync-F4USL4K5.js.map} +0 -0
- /package/dist/{sync-configs-HGLQV55H.js.map → sync-configs-HBXS3D7C.js.map} +0 -0
- /package/dist/{upgrade-WZ44IARG.js.map → upgrade-XOJMDIT4.js.map} +0 -0
- /package/dist/{render-from-row-65DC6ZP3.js.map → websites-Y6FN2XFD.js.map} +0 -0
- /package/dist/{websites-6DHIHJ2D.js.map → write-audits-to-airtable-TI3JMWSY.js.map} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
selfUpdating
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-IDBPII6G.js";
|
|
4
4
|
import "./chunk-MNDIED4M.js";
|
|
5
5
|
import {
|
|
6
6
|
runRecipeOverSites
|
|
@@ -9,19 +9,19 @@ import {
|
|
|
9
9
|
appendSkipNotice,
|
|
10
10
|
fleetWorkdir,
|
|
11
11
|
prepareFleetSites
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-DYTYPKFN.js";
|
|
13
13
|
import {
|
|
14
14
|
resolveSites
|
|
15
|
-
} from "./chunk-
|
|
16
|
-
import "./chunk-
|
|
15
|
+
} from "./chunk-S2PLX4XK.js";
|
|
16
|
+
import "./chunk-TJLKWQOX.js";
|
|
17
17
|
import "./chunk-SLFG7Y76.js";
|
|
18
|
-
import "./chunk-
|
|
18
|
+
import "./chunk-MVKNU4QD.js";
|
|
19
19
|
import "./chunk-43SW3FGW.js";
|
|
20
20
|
import "./chunk-Z3JXLULD.js";
|
|
21
21
|
import "./chunk-LBYOLBW7.js";
|
|
22
22
|
import "./chunk-RARREDQE.js";
|
|
23
23
|
import "./chunk-XXTZBPUY.js";
|
|
24
|
-
import "./chunk-
|
|
24
|
+
import "./chunk-WMF5S2MB.js";
|
|
25
25
|
|
|
26
26
|
// src/cli/commands/self-updating.ts
|
|
27
27
|
import { resolve } from "path";
|
|
@@ -63,4 +63,4 @@ async function runSelfUpdatingCommand(site, opts) {
|
|
|
63
63
|
export {
|
|
64
64
|
runSelfUpdatingCommand
|
|
65
65
|
};
|
|
66
|
-
//# sourceMappingURL=self-updating-
|
|
66
|
+
//# sourceMappingURL=self-updating-KX5FMGEO.js.map
|
|
@@ -1,24 +1,25 @@
|
|
|
1
|
-
import {
|
|
2
|
-
operatorEmail
|
|
3
|
-
} from "./chunk-5ML7FMBD.js";
|
|
4
1
|
import {
|
|
5
2
|
buildReportDataForSite,
|
|
6
3
|
scoresFromRow
|
|
7
|
-
} from "./chunk-
|
|
8
|
-
import
|
|
9
|
-
|
|
4
|
+
} from "./chunk-PZZ6HM5T.js";
|
|
5
|
+
import {
|
|
6
|
+
operatorEmail
|
|
7
|
+
} from "./chunk-5ML7FMBD.js";
|
|
8
|
+
import "./chunk-DRNZVORX.js";
|
|
9
|
+
import "./chunk-UPE6D4AT.js";
|
|
10
10
|
import {
|
|
11
11
|
ELIGIBLE_STATUSES
|
|
12
12
|
} from "./chunk-PZR4EBPY.js";
|
|
13
13
|
import {
|
|
14
14
|
isProbablyEmail,
|
|
15
15
|
parseAddresses
|
|
16
|
-
} from "./chunk-
|
|
17
|
-
import "./chunk-
|
|
16
|
+
} from "./chunk-ZEE5WQYU.js";
|
|
17
|
+
import "./chunk-TEMQPSCM.js";
|
|
18
|
+
import "./chunk-ASLDCWBF.js";
|
|
18
19
|
import "./chunk-VAD43MOR.js";
|
|
19
20
|
import {
|
|
20
21
|
fetchAttachmentBytes
|
|
21
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-YJBSB4LP.js";
|
|
22
23
|
import {
|
|
23
24
|
openBase,
|
|
24
25
|
readAirtableConfig
|
|
@@ -26,23 +27,26 @@ import {
|
|
|
26
27
|
import {
|
|
27
28
|
prepareHeaderImage,
|
|
28
29
|
renderReportEmail
|
|
29
|
-
} from "./chunk-
|
|
30
|
+
} from "./chunk-6THEZQLA.js";
|
|
30
31
|
import "./chunk-GECESMVS.js";
|
|
31
32
|
import "./chunk-KJGKZLCT.js";
|
|
32
33
|
import "./chunk-BGTYPVLT.js";
|
|
33
34
|
import {
|
|
34
35
|
applyReportTypeHeadline
|
|
35
|
-
} from "./chunk-
|
|
36
|
+
} from "./chunk-LZMAG73U.js";
|
|
37
|
+
import {
|
|
38
|
+
listWebsites
|
|
39
|
+
} from "./chunk-TC4GANMX.js";
|
|
36
40
|
import {
|
|
37
|
-
listWebsites,
|
|
38
41
|
siteSlug
|
|
39
|
-
} from "./chunk-
|
|
42
|
+
} from "./chunk-ICSRWW5Q.js";
|
|
43
|
+
import "./chunk-RQJFGK3A.js";
|
|
40
44
|
import {
|
|
41
45
|
defaultResendClient
|
|
42
46
|
} from "./chunk-WEKL2HYJ.js";
|
|
43
|
-
import "./chunk-
|
|
44
|
-
import "./chunk-
|
|
45
|
-
import "./chunk-
|
|
47
|
+
import "./chunk-3YALLSBF.js";
|
|
48
|
+
import "./chunk-MNRWHSFS.js";
|
|
49
|
+
import "./chunk-CP6MVZ5X.js";
|
|
46
50
|
import "./chunk-RARREDQE.js";
|
|
47
51
|
import "./chunk-6VAL7XJT.js";
|
|
48
52
|
|
|
@@ -172,4 +176,4 @@ async function runSelftestCommand(kind, site, opts) {
|
|
|
172
176
|
export {
|
|
173
177
|
runSelftestCommand
|
|
174
178
|
};
|
|
175
|
-
//# sourceMappingURL=selftest-
|
|
179
|
+
//# sourceMappingURL=selftest-KABY2CYJ.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/recipes/selftest-email.ts","../src/cli/commands/selftest.ts"],"sourcesContent":["import { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport { listWebsites, siteSlug } from \"../reports/airtable/websites.js\";\nimport { ELIGIBLE_STATUSES } from \"../reports/due.js\";\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { fetchAttachmentBytes } from \"../reports/airtable/attachments.js\";\nimport { prepareHeaderImage } from \"../reports/maintenance-email/header-image.js\";\nimport { applyReportTypeHeadline } from \"../reports/header-image/index.js\";\nimport { buildReportDataForSite, scoresFromRow } from \"../reports/report-data.js\";\nimport { renderReportEmail } from \"../reports/send/render-email.js\";\nimport { defaultResendClient, type ResendClient } from \"../reports/send/resend.js\";\nimport { parseAddresses, isProbablyEmail } from \"../reports/send/orchestrate.js\";\nimport { operatorEmail } from \"../util/operator.js\";\nimport type { ReportType } from \"../reports/types.js\";\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\nconst REPLY_TO = \"info@reddoorla.com\";\n\nexport type SelftestEmailDeps = {\n /** Airtable handle (read-only here). Defaults to the live base from credentials. */\n base?: AirtableBase;\n /** Resend client. Defaults to the real client. */\n resend?: ResendClient;\n /** Single-site slug. Mutually exclusive with `all`. */\n site?: string;\n /** All `maintenance` sites (one email each). Mutually exclusive with `site`. */\n all?: boolean;\n /** Report type to preview. Default \"Announcement\". */\n type?: ReportType;\n /** Raw `--to` (comma- or newline-separated). Default: the operator inbox. */\n to?: string;\n /** Render only; write reports/<slug>/selftest-<type>.html, never send. */\n dryRun?: boolean;\n /** Single timestamp driving the window + completedOn. */\n now?: Date;\n};\n\nexport type SelftestEmailSiteResult =\n | { site: string; status: \"sent\" | \"dry-run\"; subject: string; recipients: string[] }\n | { site: string; status: \"skipped\"; reason: string }\n | { site: string; status: \"error\"; message: string };\n\nexport type SelftestEmailResult = { results: SelftestEmailSiteResult[] };\n\n/** Resolve the recipient list: explicit `--to` (validated) else the operator default. */\nfunction resolveRecipients(to: string | undefined): string[] {\n const operator = operatorEmail();\n const parsed = to ? parseAddresses(to) : null;\n const list = parsed ?? [operator];\n for (const addr of list) {\n if (!isProbablyEmail(addr)) {\n throw Object.assign(new Error(`--to has a malformed address: ${addr}`), { exitCode: 2 });\n }\n }\n return list;\n}\n\n/**\n * Send (or dry-render) a single report email per target site to the operator/`--to`, with NO\n * Airtable side effects (no draft, queue, or stamp). Mirrors the production render+send via the\n * shared `renderReportEmail` seam, so the preview matches a real send. One bad site never aborts\n * `--all` (per-site try/catch). Sites missing stored scores or a header image are skipped.\n */\nexport async function selftestEmail(deps: SelftestEmailDeps): Promise<SelftestEmailResult> {\n const base = deps.base ?? openBase(readAirtableConfig());\n const resend = deps.resend ?? defaultResendClient();\n const type: ReportType = deps.type ?? \"Announcement\";\n const now = deps.now ?? new Date();\n const recipients = resolveRecipients(deps.to);\n\n const websites = await listWebsites(base);\n let targets: WebsiteRow[];\n if (deps.all) {\n // The report-eligible set (maintained + hosted-only), not a hard-coded \"maintained\" —\n // the latter silently excluded hosting sites and implied a type↔status coupling that\n // doesn't exist (the requested --type drives the rendered template; single-site mode\n // applies no status filter at all).\n targets = websites.filter((w) => w.status !== null && ELIGIBLE_STATUSES.has(w.status));\n } else if (deps.site) {\n const wanted = siteSlug(deps.site);\n targets = websites.filter((w) => siteSlug(w.name) === wanted);\n } else {\n throw Object.assign(new Error(\"Provide a site slug or --all\"), { exitCode: 2 });\n }\n\n const results: SelftestEmailSiteResult[] = [];\n for (const w of targets) {\n try {\n const scores = scoresFromRow(w);\n if (!scores) {\n results.push({ site: w.name, status: \"skipped\", reason: \"missing Lighthouse scores\" });\n continue;\n }\n if (!w.headerImage) {\n results.push({ site: w.name, status: \"skipped\", reason: \"no Header image\" });\n continue;\n }\n const original = await fetchAttachmentBytes(w.headerImage.url);\n // Mirror orchestrate.ts exactly: the stored header is the CLEAN plate, so the\n // report type's headline has to be stamped on before downscaling. Omitting this\n // step made every selftest preview ship a header with an EMPTY headline band —\n // the one thing a preview exists to catch, silently wrong in the preview itself.\n const withHeadline = await applyReportTypeHeadline(original.bytes, type);\n const header = await prepareHeaderImage(withHeadline);\n const slug = siteSlug(w.name);\n const reportData = await buildReportDataForSite(w, type, now, { scores, header });\n const { html, attachments, subject } = await renderReportEmail(reportData, {\n header,\n cidName: `${slug}-header`,\n });\n\n if (deps.dryRun) {\n const path = `reports/${slug}/selftest-${type.toLowerCase()}.html`;\n await mkdir(dirname(path), { recursive: true });\n await writeFile(path, html, \"utf-8\");\n results.push({ site: w.name, status: \"dry-run\", subject, recipients });\n continue;\n }\n\n await resend.send({\n from: FROM_ADDRESS,\n to: recipients,\n replyTo: REPLY_TO,\n subject,\n html,\n attachments,\n });\n results.push({ site: w.name, status: \"sent\", subject, recipients });\n } catch (err) {\n results.push({\n site: w.name,\n status: \"error\",\n message: err instanceof Error ? err.message : String(err),\n });\n }\n }\n\n return { results };\n}\n","import { selftestEmail, type SelftestEmailSiteResult } from \"../../recipes/selftest-email.js\";\nimport type { ReportType } from \"../../reports/types.js\";\n\nexport type SelftestCommandOptions = {\n type?: string;\n to?: string;\n all?: boolean;\n dryRun?: boolean;\n cwd?: string;\n};\n\nconst TYPES: Record<string, ReportType> = {\n announcement: \"Announcement\",\n maintenance: \"Maintenance\",\n testing: \"Testing\",\n launch: \"Launch\",\n};\n\nfunction formatResult(r: SelftestEmailSiteResult): string {\n if (r.status === \"skipped\") return `[${r.site}] skipped — ${r.reason}`;\n if (r.status === \"error\") return `[${r.site}] error: ${r.message}`;\n return `[${r.site}] ${r.status} — \"${r.subject}\" → ${r.recipients.join(\", \")}`;\n}\n\n/**\n * `selftest <kind> [site]` — operator self-tests. The only kind today is `email`: preview a\n * report email for one site (or `--all` maintenance sites) to the operator/`--to`, with no\n * Airtable side effects. Validates kind/type and the site-xor-all rule before doing any work.\n */\nexport async function runSelftestCommand(\n kind: string,\n site: string | undefined,\n opts: SelftestCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (kind !== \"email\") {\n return { output: `Unknown selftest kind '${kind}'. Supported: email`, code: 2 };\n }\n if (Boolean(site) === Boolean(opts.all)) {\n return { output: \"Provide exactly one of <site> or --all.\", code: 2 };\n }\n const typeKey = (opts.type ?? \"announcement\").toLowerCase();\n const type = TYPES[typeKey];\n if (!type) {\n return {\n output: `Unknown --type '${opts.type}'. Supported: ${Object.keys(TYPES).join(\", \")}`,\n code: 2,\n };\n }\n\n try {\n const { results } = await selftestEmail({\n ...(site ? { site } : {}),\n ...(opts.all ? { all: true } : {}),\n type,\n ...(opts.to ? { to: opts.to } : {}),\n ...(opts.dryRun ? { dryRun: true } : {}),\n });\n const output =\n results.length === 0 ? \"No matching sites.\" : results.map(formatResult).join(\"\\n\");\n const code = results.some((r) => r.status === \"error\") ? 1 : 0;\n return { output, code };\n } catch (err) {\n const e = err as { message?: string; exitCode?: number };\n return { output: e.message ?? String(err), code: e.exitCode ?? 1 };\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/recipes/selftest-email.ts","../src/cli/commands/selftest.ts"],"sourcesContent":["import { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport { listWebsites, siteSlug } from \"../reports/airtable/websites.js\";\nimport { ELIGIBLE_STATUSES } from \"../reports/due.js\";\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { fetchAttachmentBytes } from \"../reports/airtable/attachments.js\";\nimport { prepareHeaderImage } from \"../reports/maintenance-email/header-image.js\";\nimport { applyReportTypeHeadline } from \"../reports/header-image/index.js\";\nimport { buildReportDataForSite, scoresFromRow } from \"../reports/report-data.js\";\nimport { renderReportEmail } from \"../reports/send/render-email.js\";\nimport { defaultResendClient, type ResendClient } from \"../reports/send/resend.js\";\nimport { parseAddresses, isProbablyEmail } from \"../reports/send/orchestrate.js\";\nimport { operatorEmail } from \"../util/operator.js\";\nimport type { ReportType } from \"../reports/types.js\";\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\nconst REPLY_TO = \"info@reddoorla.com\";\n\nexport type SelftestEmailDeps = {\n /** Airtable handle (read-only here). Defaults to the live base from credentials. */\n base?: AirtableBase;\n /** Resend client. Defaults to the real client. */\n resend?: ResendClient;\n /** Single-site slug. Mutually exclusive with `all`. */\n site?: string;\n /** All `maintenance` sites (one email each). Mutually exclusive with `site`. */\n all?: boolean;\n /** Report type to preview. Default \"Announcement\". */\n type?: ReportType;\n /** Raw `--to` (comma- or newline-separated). Default: the operator inbox. */\n to?: string;\n /** Render only; write reports/<slug>/selftest-<type>.html, never send. */\n dryRun?: boolean;\n /** Single timestamp driving the window + completedOn. */\n now?: Date;\n};\n\nexport type SelftestEmailSiteResult =\n | { site: string; status: \"sent\" | \"dry-run\"; subject: string; recipients: string[] }\n | { site: string; status: \"skipped\"; reason: string }\n | { site: string; status: \"error\"; message: string };\n\nexport type SelftestEmailResult = { results: SelftestEmailSiteResult[] };\n\n/** Resolve the recipient list: explicit `--to` (validated) else the operator default. */\nfunction resolveRecipients(to: string | undefined): string[] {\n const operator = operatorEmail();\n const parsed = to ? parseAddresses(to) : null;\n const list = parsed ?? [operator];\n for (const addr of list) {\n if (!isProbablyEmail(addr)) {\n throw Object.assign(new Error(`--to has a malformed address: ${addr}`), { exitCode: 2 });\n }\n }\n return list;\n}\n\n/**\n * Send (or dry-render) a single report email per target site to the operator/`--to`, with NO\n * Airtable side effects (no draft, queue, or stamp). Mirrors the production render+send via the\n * shared `renderReportEmail` seam, so the preview matches a real send. One bad site never aborts\n * `--all` (per-site try/catch). Sites missing stored scores or a header image are skipped.\n */\nexport async function selftestEmail(deps: SelftestEmailDeps): Promise<SelftestEmailResult> {\n const base = deps.base ?? openBase(readAirtableConfig());\n const resend = deps.resend ?? defaultResendClient();\n const type: ReportType = deps.type ?? \"Announcement\";\n const now = deps.now ?? new Date();\n const recipients = resolveRecipients(deps.to);\n\n const websites = await listWebsites(base);\n let targets: WebsiteRow[];\n if (deps.all) {\n // The report-eligible set (maintained + hosted-only), not a hard-coded \"maintained\" —\n // the latter silently excluded hosting sites and implied a type↔status coupling that\n // doesn't exist (the requested --type drives the rendered template; single-site mode\n // applies no status filter at all).\n targets = websites.filter((w) => w.status !== null && ELIGIBLE_STATUSES.has(w.status));\n } else if (deps.site) {\n const wanted = siteSlug(deps.site);\n targets = websites.filter((w) => siteSlug(w.name) === wanted);\n } else {\n throw Object.assign(new Error(\"Provide a site slug or --all\"), { exitCode: 2 });\n }\n\n const results: SelftestEmailSiteResult[] = [];\n for (const w of targets) {\n try {\n const scores = scoresFromRow(w);\n if (!scores) {\n results.push({ site: w.name, status: \"skipped\", reason: \"missing Lighthouse scores\" });\n continue;\n }\n if (!w.headerImage) {\n results.push({ site: w.name, status: \"skipped\", reason: \"no Header image\" });\n continue;\n }\n const original = await fetchAttachmentBytes(w.headerImage.url);\n // Mirror orchestrate.ts exactly: the stored header is the CLEAN plate, so the\n // report type's headline has to be stamped on before downscaling. Omitting this\n // step made every selftest preview ship a header with an EMPTY headline band —\n // the one thing a preview exists to catch, silently wrong in the preview itself.\n const withHeadline = await applyReportTypeHeadline(original.bytes, type);\n const header = await prepareHeaderImage(withHeadline);\n const slug = siteSlug(w.name);\n const reportData = await buildReportDataForSite(w, type, now, { scores, header });\n const { html, attachments, subject } = await renderReportEmail(reportData, {\n header,\n cidName: `${slug}-header`,\n });\n\n if (deps.dryRun) {\n const path = `reports/${slug}/selftest-${type.toLowerCase()}.html`;\n await mkdir(dirname(path), { recursive: true });\n await writeFile(path, html, \"utf-8\");\n results.push({ site: w.name, status: \"dry-run\", subject, recipients });\n continue;\n }\n\n await resend.send({\n from: FROM_ADDRESS,\n to: recipients,\n replyTo: REPLY_TO,\n subject,\n html,\n attachments,\n });\n results.push({ site: w.name, status: \"sent\", subject, recipients });\n } catch (err) {\n results.push({\n site: w.name,\n status: \"error\",\n message: err instanceof Error ? err.message : String(err),\n });\n }\n }\n\n return { results };\n}\n","import { selftestEmail, type SelftestEmailSiteResult } from \"../../recipes/selftest-email.js\";\nimport type { ReportType } from \"../../reports/types.js\";\n\nexport type SelftestCommandOptions = {\n type?: string;\n to?: string;\n all?: boolean;\n dryRun?: boolean;\n cwd?: string;\n};\n\nconst TYPES: Record<string, ReportType> = {\n announcement: \"Announcement\",\n maintenance: \"Maintenance\",\n testing: \"Testing\",\n launch: \"Launch\",\n};\n\nfunction formatResult(r: SelftestEmailSiteResult): string {\n if (r.status === \"skipped\") return `[${r.site}] skipped — ${r.reason}`;\n if (r.status === \"error\") return `[${r.site}] error: ${r.message}`;\n return `[${r.site}] ${r.status} — \"${r.subject}\" → ${r.recipients.join(\", \")}`;\n}\n\n/**\n * `selftest <kind> [site]` — operator self-tests. The only kind today is `email`: preview a\n * report email for one site (or `--all` maintenance sites) to the operator/`--to`, with no\n * Airtable side effects. Validates kind/type and the site-xor-all rule before doing any work.\n */\nexport async function runSelftestCommand(\n kind: string,\n site: string | undefined,\n opts: SelftestCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (kind !== \"email\") {\n return { output: `Unknown selftest kind '${kind}'. Supported: email`, code: 2 };\n }\n if (Boolean(site) === Boolean(opts.all)) {\n return { output: \"Provide exactly one of <site> or --all.\", code: 2 };\n }\n const typeKey = (opts.type ?? \"announcement\").toLowerCase();\n const type = TYPES[typeKey];\n if (!type) {\n return {\n output: `Unknown --type '${opts.type}'. Supported: ${Object.keys(TYPES).join(\", \")}`,\n code: 2,\n };\n }\n\n try {\n const { results } = await selftestEmail({\n ...(site ? { site } : {}),\n ...(opts.all ? { all: true } : {}),\n type,\n ...(opts.to ? { to: opts.to } : {}),\n ...(opts.dryRun ? { dryRun: true } : {}),\n });\n const output =\n results.length === 0 ? \"No matching sites.\" : results.map(formatResult).join(\"\\n\");\n const code = results.some((r) => r.status === \"error\") ? 1 : 0;\n return { output, code };\n } catch (err) {\n const e = err as { message?: string; exitCode?: number };\n return { output: e.message ?? String(err), code: e.exitCode ?? 1 };\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getSiteBySlug,
|
|
3
|
+
insertSiteRows,
|
|
4
|
+
updateSiteIdentity
|
|
5
|
+
} from "./chunk-BINFZQ2T.js";
|
|
6
|
+
import "./chunk-LF2TJN4X.js";
|
|
7
|
+
import "./chunk-ASLDCWBF.js";
|
|
8
|
+
import "./chunk-VAD43MOR.js";
|
|
9
|
+
import "./chunk-ICSRWW5Q.js";
|
|
10
|
+
|
|
11
|
+
// src/db/site-create.ts
|
|
12
|
+
var SLUG_TAKEN = /UNIQUE constraint failed: sites\.slug/;
|
|
13
|
+
function makeSiteStore(db, now = () => /* @__PURE__ */ new Date()) {
|
|
14
|
+
return {
|
|
15
|
+
findBySlug: async (slug) => {
|
|
16
|
+
const row = await getSiteBySlug(db, slug);
|
|
17
|
+
if (!row) return null;
|
|
18
|
+
return {
|
|
19
|
+
id: row.id,
|
|
20
|
+
name: row.name,
|
|
21
|
+
// `rowFromJoined` maps a null url to "" — undo that, so "blank" means one thing.
|
|
22
|
+
url: row.url === "" ? null : row.url,
|
|
23
|
+
pointOfContact: row.pointOfContact,
|
|
24
|
+
gitRepo: row.gitRepo
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
create: async (site) => {
|
|
28
|
+
try {
|
|
29
|
+
await db.transaction().execute((trx) => insertSiteRows(trx, site, now().toISOString()));
|
|
30
|
+
} catch (err) {
|
|
31
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
32
|
+
const causeMessage = err instanceof Error && err.cause instanceof Error ? err.cause.message : "";
|
|
33
|
+
if (SLUG_TAKEN.test(message) || SLUG_TAKEN.test(causeMessage)) {
|
|
34
|
+
throw Object.assign(new Error(message, { cause: err }), { code: "SLUG_TAKEN" });
|
|
35
|
+
}
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
updateIdentity: (siteId, patch) => updateSiteIdentity(db, siteId, patch)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export {
|
|
43
|
+
makeSiteStore
|
|
44
|
+
};
|
|
45
|
+
//# sourceMappingURL=site-create-ZU3ACBTO.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/db/site-create.ts"],"sourcesContent":["/** #646 step 3: the Turso half of the Turso-native `ensure-site`.\n *\n * Turso is the source of truth for a new site, so this is NOT a mirror: nothing\n * shadows it and nothing converges it. It adapts `fleet-state`'s gated queries\n * to the `SiteStore` the creator (`src/fleet/ensure-site.ts`) drives, and adds\n * the one thing those queries cannot express on their own — the transaction.\n *\n * It lives in its own module, exempt from the EXPLAIN gate, for a concrete\n * reason: the gate runs every scenario against ONE shared `:memory:` libSQL\n * client, and `client.transaction()` on an in-memory client hands its connection\n * to the transaction and opens a fresh, EMPTY database for the next statement\n * (verified 2026-09-17: the statement after a committed transaction failed with\n * `no such table`). Every SQL statement this module issues is `insertSiteRows` /\n * `updateSiteIdentity` / `getSiteBySlug`, which the gate plans directly.\n *\n * The same hazard means any test driving `create` must use a `file:` database.\n */\nimport type { Db } from \"./client.js\";\nimport { getSiteBySlug, insertSiteRows, updateSiteIdentity } from \"./fleet-state.js\";\nimport type { SiteStore } from \"../fleet/ensure-site.js\";\n\n/** SQLite's message for the `sites.slug` UNIQUE index, as libSQL surfaces it. */\nconst SLUG_TAKEN = /UNIQUE constraint failed: sites\\.slug/;\n\nexport function makeSiteStore(db: Db, now: () => Date = () => new Date()): SiteStore {\n return {\n findBySlug: async (slug) => {\n const row = await getSiteBySlug(db, slug);\n if (!row) return null;\n return {\n id: row.id,\n name: row.name,\n // `rowFromJoined` maps a null url to \"\" — undo that, so \"blank\" means one thing.\n url: row.url === \"\" ? null : row.url,\n pointOfContact: row.pointOfContact,\n gitRepo: row.gitRepo,\n };\n },\n create: async (site) => {\n try {\n await db.transaction().execute((trx) => insertSiteRows(trx, site, now().toISOString()));\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n const causeMessage =\n err instanceof Error && err.cause instanceof Error ? err.cause.message : \"\";\n if (SLUG_TAKEN.test(message) || SLUG_TAKEN.test(causeMessage)) {\n throw Object.assign(new Error(message, { cause: err }), { code: \"SLUG_TAKEN\" });\n }\n throw err;\n }\n },\n updateIdentity: (siteId, patch) => updateSiteIdentity(db, siteId, patch),\n };\n}\n"],"mappings":";;;;;;;;;;;AAsBA,IAAM,aAAa;AAEZ,SAAS,cAAc,IAAQ,MAAkB,MAAM,oBAAI,KAAK,GAAc;AACnF,SAAO;AAAA,IACL,YAAY,OAAO,SAAS;AAC1B,YAAM,MAAM,MAAM,cAAc,IAAI,IAAI;AACxC,UAAI,CAAC,IAAK,QAAO;AACjB,aAAO;AAAA,QACL,IAAI,IAAI;AAAA,QACR,MAAM,IAAI;AAAA;AAAA,QAEV,KAAK,IAAI,QAAQ,KAAK,OAAO,IAAI;AAAA,QACjC,gBAAgB,IAAI;AAAA,QACpB,SAAS,IAAI;AAAA,MACf;AAAA,IACF;AAAA,IACA,QAAQ,OAAO,SAAS;AACtB,UAAI;AACF,cAAM,GAAG,YAAY,EAAE,QAAQ,CAAC,QAAQ,eAAe,KAAK,MAAM,IAAI,EAAE,YAAY,CAAC,CAAC;AAAA,MACxF,SAAS,KAAK;AACZ,cAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAC/D,cAAM,eACJ,eAAe,SAAS,IAAI,iBAAiB,QAAQ,IAAI,MAAM,UAAU;AAC3E,YAAI,WAAW,KAAK,OAAO,KAAK,WAAW,KAAK,YAAY,GAAG;AAC7D,gBAAM,OAAO,OAAO,IAAI,MAAM,SAAS,EAAE,OAAO,IAAI,CAAC,GAAG,EAAE,MAAM,aAAa,CAAC;AAAA,QAChF;AACA,cAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,gBAAgB,CAAC,QAAQ,UAAU,mBAAmB,IAAI,QAAQ,KAAK;AAAA,EACzE;AACF;","names":[]}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TURSO_IS_AUTHORITATIVE
|
|
3
|
+
} from "./chunk-WQLNUEZL.js";
|
|
1
4
|
import {
|
|
2
5
|
mirrorHealthFields,
|
|
3
6
|
mirrorSiteFields,
|
|
4
7
|
mirrorSiteInsert,
|
|
5
8
|
siteRowExists
|
|
6
|
-
} from "./chunk-
|
|
7
|
-
import "./chunk-
|
|
8
|
-
import
|
|
9
|
-
TURSO_IS_AUTHORITATIVE
|
|
10
|
-
} from "./chunk-WQLNUEZL.js";
|
|
11
|
-
import "./chunk-F3JWOPTG.js";
|
|
9
|
+
} from "./chunk-BINFZQ2T.js";
|
|
10
|
+
import "./chunk-LF2TJN4X.js";
|
|
11
|
+
import "./chunk-ASLDCWBF.js";
|
|
12
12
|
import "./chunk-VAD43MOR.js";
|
|
13
|
-
import "./chunk-
|
|
13
|
+
import "./chunk-ICSRWW5Q.js";
|
|
14
14
|
import {
|
|
15
15
|
openDb,
|
|
16
16
|
readDbConfig
|
|
17
|
-
} from "./chunk-
|
|
18
|
-
import "./chunk-
|
|
17
|
+
} from "./chunk-MNRWHSFS.js";
|
|
18
|
+
import "./chunk-CP6MVZ5X.js";
|
|
19
19
|
import "./chunk-6VAL7XJT.js";
|
|
20
20
|
|
|
21
21
|
// src/db/site-mirror.ts
|
|
@@ -73,4 +73,4 @@ async function makeSiteMirror(open = () => openDb(readDbConfig()), strict = TURS
|
|
|
73
73
|
export {
|
|
74
74
|
makeSiteMirror
|
|
75
75
|
};
|
|
76
|
-
//# sourceMappingURL=site-mirror-
|
|
76
|
+
//# sourceMappingURL=site-mirror-TKTDWYP3.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/db/site-mirror.ts"],"sourcesContent":["/** #539 Phase 5: the Turso write-through for the ONE-OFF Websites writers.\n *\n * Phase 3 mirrored the nightly sweep — the audit write-back, github-signals and\n * the next-due dates. It did not touch the writers that run on their own\n * schedule or on demand: the analytics soft-fail stamp, the Renovate\n * auto-fix counter, the prismic-models verdict, `updateLaunched`, the\n * forms notify target, and the SINGLE-SITE audit write-back (only the fleet\n * path ever passed a mirror). Each of those reached Turso solely via the hourly\n * sync, which stops existing at the freeze.\n *\n * It lives in `src/db` rather than a feature folder because its callers span\n * audits, recipes, CLI commands and the send path — no one feature owns it.\n *\n * Deliberately UNLIKE `makeHealthMirrorBestEffort`, this never returns null.\n * #585 is the reason: that factory returned null without creds and the\n * dual-write silently no-opped in production for weeks, because a dead mirror\n * and a healthy one produced identical output — the only tell was an ABSENT log\n * suffix nobody was watching for. Here creds-absent is a state the mirror\n * REPORTS, so a missing SITE_MIRROR line means the wiring is gone.\n */\nimport { openDb, readDbConfig, type Db } from \"./client.js\";\nimport {\n mirrorHealthFields,\n mirrorSiteFields,\n mirrorSiteInsert,\n siteRowExists,\n} from \"./fleet-state.js\";\nimport { TURSO_IS_AUTHORITATIVE } from \"./freeze.js\";\n\n/** Two ops because the Websites row is split across two Turso tables. Callers\n * pass the EXACT FieldSet the Airtable writer returned, so the mirror can never\n * carry a different payload than the write it shadows. */\nexport type SiteMirror = {\n /** A row Airtable just CREATED, as Airtable echoed it back. `ensure-site` used\n * this until #646 step 3; it now creates sites in Turso directly\n * (`src/fleet/ensure-site.ts`), and neither this nor `hasRow` has a caller left\n * outside tests — both go with the Airtable layer in step 6.\n * Every other op is an UPDATE, which does nothing for a row that does not\n * exist yet — so without this a bootstrapped site is invisible to Turso and\n * every mirror the rest of the bootstrap fires reports `mirrored=missed`. */\n created: (rec: { id: string; fields: Record<string, unknown> }) => Promise<void>;\n /** Columns that live in `site_health`. */\n health: (siteId: string, fields: Record<string, unknown>) => Promise<void>;\n /** Columns that live in `sites`. */\n site: (siteId: string, fields: Record<string, unknown>) => Promise<void>;\n /** #645. Does Turso hold a row for this site id? A pure READ, so it is the one\n * op here that neither logs a SITE_MIRROR line nor throws under the freeze —\n * `ensure-site` uses it to decide whether to heal, and a probe that threw\n * would sink the very command that repairs the gap. Creds-absent answers\n * `true` (\"assume present\"), so a mirror that cannot read can never provoke\n * a blind insert. */\n hasRow: (siteId: string) => Promise<boolean>;\n};\n\n/** Build the one-off writers' mirror. Never throws and never returns null:\n * Airtable is still authoritative through Phase 5, so a mirror problem must not\n * cost the write it shadows — the hourly sync converges whatever this misses.\n * `open` is injectable for tests. */\nexport async function makeSiteMirror(\n open: () => Promise<Db> = () => openDb(readDbConfig()),\n /** #612. `true` = Turso is the store that must succeed, so every failure\n * throws instead of being logged and swallowed. Defaulted from the shipped\n * constant and injected by tests, so both sides stay proven. */\n strict: boolean = TURSO_IS_AUTHORITATIVE,\n): Promise<SiteMirror> {\n let db: Db | null = null;\n let why = \"\";\n try {\n db = await open();\n } catch (e) {\n why = (e as Error).message;\n }\n // Frozen: refuse to hand back a mirror that cannot write. Failing at\n // CONSTRUCTION rather than per write matters — a caller holding a\n // working-looking mirror would run its whole batch before anyone noticed that\n // nothing had persisted.\n if (strict && !db) throw new Error(`SITE_MIRROR unavailable: ${why}`);\n\n const run = async (siteId: string, op: string, work: (db: Db) => Promise<boolean>) => {\n if (!db) {\n console.log(`SITE_MIRROR site=${siteId} op=${op} mirrored=absent reason=${why}`);\n return;\n }\n // Every path below logs EXACTLY ONE line, then strict adds a throw. Logging\n // first matters: a frozen run that fails should still leave the same trace\n // in the run log that an unfrozen one would, not just a stack.\n let matched: boolean;\n try {\n matched = await work(db);\n } catch (e) {\n console.log(`SITE_MIRROR site=${siteId} op=${op} mirrored=0 error=${(e as Error).message}`);\n if (strict) throw e;\n return;\n }\n // `missed` is its own outcome: the UPDATE matched no row. Before the freeze\n // that means the hourly sync has not imported this site yet — a transient,\n // and reporting it as mirrored=1 would claim a write that never landed.\n // After the freeze no importer exists, so an absent row stays absent: it is\n // a bug, not a wait.\n console.log(`SITE_MIRROR site=${siteId} op=${op} mirrored=${matched ? \"1\" : \"missed\"}`);\n if (strict && !matched) {\n throw new Error(`SITE_MIRROR site=${siteId} op=${op}: no such row in Turso`);\n }\n };\n\n return {\n // An INSERT always lands, so it reports 1 rather than routing a row count\n // through `missed` — there was nothing to match in the first place.\n created: (rec) =>\n run(rec.id, \"created\", async (d) => {\n await mirrorSiteInsert(d, rec, new Date().toISOString());\n return true;\n }),\n health: (siteId, fields) => run(siteId, \"health\", (d) => mirrorHealthFields(d, siteId, fields)),\n site: (siteId, fields) => run(siteId, \"site\", (d) => mirrorSiteFields(d, siteId, fields)),\n // Deliberately NOT routed through `run`: that helper logs a mirrored= line\n // and, under strict, throws on anything but a clean write — both wrong for a\n // read whose whole job is to answer a question. A failed probe answers\n // \"present\" so the caller heals nothing it cannot see.\n hasRow: async (siteId) => {\n if (!db) return true;\n try {\n return await siteRowExists(db, siteId);\n } catch (e) {\n console.log(`SITE_MIRROR site=${siteId} op=hasRow unknown error=${(e as Error).message}`);\n return true;\n }\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AA0DA,eAAsB,eACpB,OAA0B,MAAM,OAAO,aAAa,CAAC,GAIrD,SAAkB,wBACG;AACrB,MAAI,KAAgB;AACpB,MAAI,MAAM;AACV,MAAI;AACF,SAAK,MAAM,KAAK;AAAA,EAClB,SAAS,GAAG;AACV,UAAO,EAAY;AAAA,EACrB;AAKA,MAAI,UAAU,CAAC,GAAI,OAAM,IAAI,MAAM,4BAA4B,GAAG,EAAE;AAEpE,QAAM,MAAM,OAAO,QAAgB,IAAY,SAAuC;AACpF,QAAI,CAAC,IAAI;AACP,cAAQ,IAAI,oBAAoB,MAAM,OAAO,EAAE,2BAA2B,GAAG,EAAE;AAC/E;AAAA,IACF;AAIA,QAAI;AACJ,QAAI;AACF,gBAAU,MAAM,KAAK,EAAE;AAAA,IACzB,SAAS,GAAG;AACV,cAAQ,IAAI,oBAAoB,MAAM,OAAO,EAAE,qBAAsB,EAAY,OAAO,EAAE;AAC1F,UAAI,OAAQ,OAAM;AAClB;AAAA,IACF;AAMA,YAAQ,IAAI,oBAAoB,MAAM,OAAO,EAAE,aAAa,UAAU,MAAM,QAAQ,EAAE;AACtF,QAAI,UAAU,CAAC,SAAS;AACtB,YAAM,IAAI,MAAM,oBAAoB,MAAM,OAAO,EAAE,wBAAwB;AAAA,IAC7E;AAAA,EACF;AAEA,SAAO;AAAA;AAAA;AAAA,IAGL,SAAS,CAAC,QACR,IAAI,IAAI,IAAI,WAAW,OAAO,MAAM;AAClC,YAAM,iBAAiB,GAAG,MAAK,oBAAI,KAAK,GAAE,YAAY,CAAC;AACvD,aAAO;AAAA,IACT,CAAC;AAAA,IACH,QAAQ,CAAC,QAAQ,WAAW,IAAI,QAAQ,UAAU,CAAC,MAAM,mBAAmB,GAAG,QAAQ,MAAM,CAAC;AAAA,IAC9F,MAAM,CAAC,QAAQ,WAAW,IAAI,QAAQ,QAAQ,CAAC,MAAM,iBAAiB,GAAG,QAAQ,MAAM,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA,IAKxF,QAAQ,OAAO,WAAW;AACxB,UAAI,CAAC,GAAI,QAAO;AAChB,UAAI;AACF,eAAO,MAAM,cAAc,IAAI,MAAM;AAAA,MACvC,SAAS,GAAG;AACV,gBAAQ,IAAI,oBAAoB,MAAM,4BAA6B,EAAY,OAAO,EAAE;AACxF,eAAO;AAAA,MACT;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
|
|
@@ -5,21 +5,21 @@ import {
|
|
|
5
5
|
appendSkipNotice,
|
|
6
6
|
fleetWorkdir,
|
|
7
7
|
prepareFleetSites
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-DYTYPKFN.js";
|
|
9
9
|
import {
|
|
10
10
|
resolveSites
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-S2PLX4XK.js";
|
|
12
12
|
import {
|
|
13
13
|
smokeSuite
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-EN6CTBUN.js";
|
|
15
|
+
import "./chunk-TJLKWQOX.js";
|
|
15
16
|
import "./chunk-5CJVUPJU.js";
|
|
16
|
-
import "./chunk-
|
|
17
|
-
import "./chunk-SQ4FSNYL.js";
|
|
17
|
+
import "./chunk-LUF52EIY.js";
|
|
18
18
|
import "./chunk-Z3JXLULD.js";
|
|
19
19
|
import "./chunk-LBYOLBW7.js";
|
|
20
20
|
import "./chunk-RARREDQE.js";
|
|
21
21
|
import "./chunk-XXTZBPUY.js";
|
|
22
|
-
import "./chunk-
|
|
22
|
+
import "./chunk-WMF5S2MB.js";
|
|
23
23
|
|
|
24
24
|
// src/cli/commands/smoke-suite.ts
|
|
25
25
|
import { resolve } from "path";
|
|
@@ -52,4 +52,4 @@ async function runSmokeSuiteCommand(site, opts) {
|
|
|
52
52
|
export {
|
|
53
53
|
runSmokeSuiteCommand
|
|
54
54
|
};
|
|
55
|
-
//# sourceMappingURL=smoke-suite-
|
|
55
|
+
//# sourceMappingURL=smoke-suite-QQMV7TNA.js.map
|
|
@@ -299,6 +299,15 @@ async function countNotifyBouncedBySite(db, sinceDate) {
|
|
|
299
299
|
])
|
|
300
300
|
);
|
|
301
301
|
}
|
|
302
|
+
async function countNotifyBouncedForSite(db, siteId, sinceDate) {
|
|
303
|
+
const row = await db.selectFrom("submissions").select([
|
|
304
|
+
(eb) => eb.fn.countAll().as("n"),
|
|
305
|
+
sql`sum(case when lower(bounce_type) = 'permanent' then 1 else 0 end)`.as(
|
|
306
|
+
"permanent_n"
|
|
307
|
+
)
|
|
308
|
+
]).where("site_id", "=", siteId).where("notify_status", "=", "bounced").where("submitted_at", ">=", sinceDate).where("bounce_ack_at", "is", null).executeTakeFirst();
|
|
309
|
+
return { total: Number(row?.n) || 0, permanent: Number(row?.permanent_n) || 0 };
|
|
310
|
+
}
|
|
302
311
|
export {
|
|
303
312
|
MIN_DUP_BODY_LEN,
|
|
304
313
|
MIN_SIMILAR_TOKENS,
|
|
@@ -307,6 +316,7 @@ export {
|
|
|
307
316
|
backfillSubmission,
|
|
308
317
|
countAutoSpamSince,
|
|
309
318
|
countNotifyBouncedBySite,
|
|
319
|
+
countNotifyBouncedForSite,
|
|
310
320
|
countSubmissionsFiltered,
|
|
311
321
|
countSubmissionsSinceBySite,
|
|
312
322
|
createSubmission,
|
|
@@ -326,4 +336,4 @@ export {
|
|
|
326
336
|
stampFanout,
|
|
327
337
|
stampNotified
|
|
328
338
|
};
|
|
329
|
-
//# sourceMappingURL=submissions-
|
|
339
|
+
//# sourceMappingURL=submissions-4I66W6QK.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/db/submissions.ts"],"sourcesContent":["import { sql } from \"kysely\";\nimport type { Selectable, SqlBool } from \"kysely\";\nimport type { Db } from \"./client.js\";\nimport type { SubmissionsTable } from \"./schema.js\";\nimport {\n type SubmissionRow,\n type SubmissionInput,\n type BounceDetail,\n toFormType,\n toStatus,\n toNotifyStatus,\n} from \"../reports/submission-row.js\";\nimport type { FormType, SubmissionStatus } from \"../reports/submission-row.js\";\nimport { SIGNUP_FORM_TYPES } from \"../forms/types.js\";\n\nexport type SubmissionFilter = {\n siteId?: string;\n formType?: FormType;\n status?: SubmissionStatus;\n /** LIKE %q% across name/email/message/phone, case-insensitive */\n search?: string;\n /** submitted_at >= from (ISO string) */\n from?: string;\n /** submitted_at <= to (ISO string) */\n to?: string;\n /** exact spam_reason token (comma-boundary match; a stored \":N\" count suffix on\n * the token still matches) */\n reason?: string;\n};\n\n/** Map a raw DB row to the canonical SubmissionRow, narrowing the enum columns\n * with the SAME validators the Airtable mapRow uses — SQLite stores TEXT, so a\n * bad stored value must still be defended against. */\nfunction rowFromDb(r: Selectable<SubmissionsTable>): SubmissionRow {\n return {\n id: r.id,\n submissionId: r.submission_id,\n siteId: r.site_id,\n formType: toFormType(r.form_type),\n name: r.name,\n email: r.email,\n phone: r.phone,\n message: r.message,\n extraFields: r.extra_fields,\n sourceUrl: r.source_url,\n utm: r.utm,\n submittedAt: r.submitted_at,\n status: toStatus(r.status),\n notifyStatus: toNotifyStatus(r.notify_status),\n resendMessageId: r.resend_message_id,\n spamScore: typeof r.spam_score === \"number\" ? r.spam_score : null,\n spamReason: r.spam_reason,\n fanoutStatus: r.fanout_status,\n bounceType: r.bounce_type,\n bounceSubType: r.bounce_subtype,\n bounceMessage: r.bounce_message,\n bounceAckAt: r.bounce_ack_at,\n };\n}\n\n/** Opaque, collision-free id. crypto is a Node 20 global — no new dep. */\nexport function newSubmissionId(): string {\n return `sub_${crypto.randomUUID()}`;\n}\n\nexport async function getSubmissionById(db: Db, id: string): Promise<SubmissionRow | null> {\n const r = await db.selectFrom(\"submissions\").selectAll().where(\"id\", \"=\", id).executeTakeFirst();\n return r ? rowFromDb(r) : null;\n}\n\nexport async function createSubmission(db: Db, input: SubmissionInput): Promise<SubmissionRow> {\n const id = newSubmissionId();\n const extra =\n input.extraFields !== undefined && Object.keys(input.extraFields).length > 0\n ? JSON.stringify(input.extraFields)\n : null;\n await db\n .insertInto(\"submissions\")\n .values({\n id,\n // Display number: MAX+1 in a single statement. libSQL is single-writer, so\n // writes serialize and this is race-free.\n submission_id: sql<number>`(SELECT COALESCE(MAX(submission_id), 0) + 1 FROM submissions)`,\n site_id: input.siteId,\n form_type: input.formType,\n name: input.name,\n email: input.email,\n phone: input.phone ?? null,\n message: input.message ?? null,\n extra_fields: extra,\n source_url: input.sourceUrl ?? null,\n utm: input.utm ?? null,\n submitted_at: input.submittedAt.toISOString(),\n status: input.status ?? \"new\",\n notify_status: \"skipped\",\n resend_message_id: null,\n spam_score: input.spamScore ?? null,\n spam_reason: input.spamReason ?? null,\n // Always null at insert: the fan-out runs after the row exists (stampFanout).\n fanout_status: null,\n // Always null at insert too: a bounce is reported by a webhook minutes\n // later, and the ack is an operator gesture after that (#783).\n bounce_type: null,\n bounce_subtype: null,\n bounce_message: null,\n bounce_ack_at: null,\n })\n .execute();\n const created = await getSubmissionById(db, id);\n if (!created) throw new Error(\"createSubmission: row vanished after insert\");\n return created;\n}\n\nimport type { NotifyStatus } from \"../reports/submission-row.js\";\n\nexport async function listNewSubmissions(db: Db, max = 200): Promise<SubmissionRow[]> {\n // Bound the fetch (matches listSubmissionsForSite's default): the cockpit loads this whole\n // array on every render. Unbounded, it deserializes every unread submission fleet-wide. The\n // badge count therefore caps at `max` — acceptable, since >200 unread fleet-wide is itself a\n // triage emergency, and the /submissions page is the unbounded, filterable view.\n const rows = await db\n .selectFrom(\"submissions\")\n .selectAll()\n .where(\"status\", \"=\", \"new\")\n .orderBy(\"submitted_at\", \"desc\")\n .limit(max)\n .execute();\n return rows.map(rowFromDb);\n}\n\n/** Same signature shape as the Airtable version (takes `{ id, name }`) so the\n * composition-root swap is import-only — but here we filter by id directly, with\n * no linked-field/primary-field workaround and no JS-confirm pass. */\nexport async function listSubmissionsForSite(\n db: Db,\n site: { id: string; name: string },\n max = 200,\n): Promise<SubmissionRow[]> {\n const rows = await db\n .selectFrom(\"submissions\")\n .selectAll()\n .where(\"site_id\", \"=\", site.id)\n .orderBy(\"submitted_at\", \"desc\")\n .limit(max)\n .execute();\n return rows.map(rowFromDb);\n}\n\nexport async function setSubmissionStatusRow(\n db: Db,\n id: string,\n status: SubmissionStatus,\n): Promise<void> {\n await db.updateTable(\"submissions\").set({ status }).where(\"id\", \"=\", id).execute();\n}\n\nexport async function stampNotified(\n db: Db,\n id: string,\n status: NotifyStatus,\n messageId: string | null,\n): Promise<void> {\n const patch =\n messageId !== null\n ? { notify_status: status, resend_message_id: messageId }\n : { notify_status: status };\n await db.updateTable(\"submissions\").set(patch).where(\"id\", \"=\", id).execute();\n}\n\n/** Record the newsletter fan-out outcome on a row (comma-joined destination tokens,\n * see ingest.ts). REPLACES any previous value — the fan-out runs once per submission,\n * so the last write is the whole truth; unlike markSubmissionsSpamRetro there is no\n * earlier trail worth appending to. No status guard: this is provenance about what\n * the pipeline did, not a triage decision, so it stays accurate even for a row the\n * operator has already read or archived. */\nexport async function stampFanout(db: Db, id: string, fanoutStatus: string): Promise<void> {\n await db\n .updateTable(\"submissions\")\n .set({ fanout_status: fanoutStatus })\n .where(\"id\", \"=\", id)\n .execute();\n}\n\n// NOTE: listSubmissionsFiltered and countSubmissionsFiltered share identical filter\n// logic via applySubmissionFilter — always update both through that helper.\nfunction applySubmissionFilter<O>(\n qb: import(\"kysely\").SelectQueryBuilder<import(\"./schema.js\").Database, \"submissions\", O>,\n f: SubmissionFilter,\n) {\n let q = qb;\n if (f.siteId !== undefined) q = q.where(\"site_id\", \"=\", f.siteId);\n if (f.formType !== undefined) q = q.where(\"form_type\", \"=\", f.formType);\n if (f.status !== undefined) q = q.where(\"status\", \"=\", f.status);\n if (f.from !== undefined) q = q.where(\"submitted_at\", \">=\", f.from);\n if (f.to !== undefined) q = q.where(\"submitted_at\", \"<=\", f.to);\n if (f.search !== undefined && f.search.trim() !== \"\") {\n // Escape LIKE metacharacters so a user's literal `%`/`_` (or the escape char `\\`) match\n // literally rather than as wildcards: searching `john_doe` must not also hit `johnXdoe`,\n // and a bare `%` must not match everything. `\\` is escaped first (it's the ESCAPE char).\n // Parameterized regardless (no injection) — this is a correctness fix. Uses sql`` because\n // kysely's `like` operator can't attach an ESCAPE clause.\n const term = f.search\n .trim()\n .toLowerCase()\n .replace(/[\\\\%_]/g, \"\\\\$&\");\n const like = `%${term}%`;\n q = q.where((eb) =>\n eb.or([\n sql<SqlBool>`lower(${eb.ref(\"name\")}) like ${like} escape '\\\\'`,\n sql<SqlBool>`lower(${eb.ref(\"email\")}) like ${like} escape '\\\\'`,\n sql<SqlBool>`lower(${eb.ref(\"message\")}) like ${like} escape '\\\\'`,\n sql<SqlBool>`lower(${eb.ref(\"phone\")}) like ${like} escape '\\\\'`,\n ]),\n );\n }\n if (f.reason !== undefined && f.reason.trim() !== \"\") {\n // spam_reason is a comma-joined token list (ingest.ts joins \",\"; retro appends\n // \",<reason>\"). Comma-boundary match so \"links\" never matches \"backlinks\" and\n // \"turnstile-required-absent\" never matches \"-failed\"; the second branch accepts\n // a per-token count suffix (\"keywords:4\" matches reason=keywords). LIKE\n // metacharacters escaped exactly like the search branch. lower() both sides is\n // safe: tokens are ASCII classifier codes.\n const token = f.reason\n .trim()\n .toLowerCase()\n .replace(/[\\\\%_]/g, \"\\\\$&\");\n q = q.where((eb) =>\n eb.or([\n sql<SqlBool>`',' || lower(${eb.ref(\"spam_reason\")}) || ',' like ${`%,${token},%`} escape '\\\\'`,\n sql<SqlBool>`',' || lower(${eb.ref(\"spam_reason\")}) || ',' like ${`%,${token}:%`} escape '\\\\'`,\n ]),\n );\n }\n return q;\n}\n\nexport async function listSubmissionsFiltered(\n db: Db,\n filter: SubmissionFilter,\n opts: { limit: number; offset: number },\n): Promise<SubmissionRow[]> {\n const base = db.selectFrom(\"submissions\").selectAll();\n const rows = await applySubmissionFilter(base, filter)\n .orderBy(\"submitted_at\", \"desc\")\n .limit(opts.limit)\n .offset(opts.offset)\n .execute();\n return rows.map(rowFromDb);\n}\n\nexport async function countSubmissionsFiltered(db: Db, filter: SubmissionFilter): Promise<number> {\n const base = db.selectFrom(\"submissions\").select((eb) => eb.fn.countAll<number>().as(\"n\"));\n const res = await applySubmissionFilter(base, filter).executeTakeFirstOrThrow();\n return Number(res.n);\n}\n\n/** Fleet-wide count of auto-filtered spam (`status = 'spam_auto'`) submitted on/after\n * `sinceDate` (ISO). Powers the cockpit \"N auto-filtered this week — review\" affordance;\n * the caller picks the window (like `listScreenOutsSince`), keeping this query pure. */\nexport async function countAutoSpamSince(db: Db, sinceDate: string): Promise<number> {\n const res = await db\n .selectFrom(\"submissions\")\n .select((eb) => eb.fn.countAll<number>().as(\"n\"))\n .where(\"status\", \"=\", \"spam_auto\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .executeTakeFirstOrThrow();\n return Number(res.n);\n}\n\nexport type SiteSubmissionCounts = { leads: number; signups: number; spamAuto: number };\n\n/** Per-site submission counts on/after `sinceIso` (full ISO timestamp) for the\n * digest telemetry line. Grouped one-query: leads/signups = rows NOT auto- or\n * operator-bucketed as spam (status NOT IN ('spam_auto','spam')) split by\n * isLeadFormType; spamAuto = status='spam_auto'. Keyed by Websites record id. */\nexport async function countSubmissionsSinceBySite(\n db: Db,\n sinceIso: string,\n): Promise<Map<string, SiteSubmissionCounts>> {\n const signupList = sql.join(SIGNUP_FORM_TYPES.map((t) => sql`${t}`));\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\n \"site_id\",\n sql<number>`sum(case when status not in ('spam_auto','spam') and form_type not in (${signupList}) then 1 else 0 end)`.as(\n \"leads_n\",\n ),\n sql<number>`sum(case when status not in ('spam_auto','spam') and form_type in (${signupList}) then 1 else 0 end)`.as(\n \"signups_n\",\n ),\n sql<number>`sum(case when status = 'spam_auto' then 1 else 0 end)`.as(\"spam_auto_n\"),\n ])\n .where(\"submitted_at\", \">=\", sinceIso)\n .groupBy(\"site_id\")\n .execute();\n return new Map(\n rows.map((r) => [\n r.site_id,\n {\n leads: Number(r.leads_n) || 0,\n signups: Number(r.signups_n) || 0,\n spamAuto: Number(r.spam_auto_n) || 0,\n },\n ]),\n );\n}\n\n/** A normalized body shorter than this never triggers the exact-duplicate signal —\n * short/boilerplate lines (a newsletter \"subscribe\", \"hi\") legitimately repeat\n * across real people. */\nexport const MIN_DUP_BODY_LEN = 40;\n\n/** Both token sets must be at least this large before the Jaccard tier can fire —\n * two short genuine messages (\"what are your hours?\" variants) share most of their\n * few tokens and would otherwise collide. */\nexport const MIN_SIMILAR_TOKENS = 25;\n\n/** Jaccard similarity (token sets) at or above this marks a near-duplicate. 0.9 is\n * deliberately strict: a template spray with one substituted greeting/target still\n * clears it, while two independently-written enquiries about the same business\n * never do. */\nexport const SIMILARITY_THRESHOLD = 0.9;\n\n/** Fold a message body down to its template skeleton. Full-Unicode lowercase first\n * (JS `toLowerCase()` — SQLite's `lower()` is ASCII-only, which is why this whole\n * comparison lives in JS, folding BOTH sides identically so byte-identical Cyrillic\n * copies keep matching), then strip everything a spray substitutes per target —\n * URLs, emails, bare domains, digit runs — and collapse whitespace. Over-stripping\n * is safe: it happens symmetrically on both sides. */\nfunction normalizeBody(body: string): string {\n return body\n .toLowerCase()\n .replace(/https?:\\/\\/\\S+/gu, \" \") // full URLs\n .replace(/www\\.\\S+/gu, \" \") // scheme-less www. links\n .replace(/\\S+@\\S+/gu, \" \") // email addresses\n .replace(/\\b[a-z0-9-]+(\\.[a-z0-9-]+)+\\b/gu, \" \") // bare domain tokens (word.tld)\n .replace(/\\d+/gu, \" \") // digit runs (phone numbers, prices, years)\n .replace(/\\s+/gu, \" \")\n .trim();\n}\n\n/** Letter-only token set of a normalized body (punctuation/symbols are separators). */\nfunction tokenSet(normalized: string): Set<string> {\n return new Set(normalized.split(/[^\\p{L}]+/u).filter((t) => t.length > 0));\n}\n\nfunction jaccard(a: Set<string>, b: Set<string>): number {\n let intersection = 0;\n for (const t of a) if (b.has(t)) intersection++;\n const union = a.size + b.size - intersection;\n return union === 0 ? 0 : intersection / union;\n}\n\n/** Fleet-wide duplicate/near-duplicate lookup for the spray spam signal — the same\n * pitch blasted across sites shows up as identical OR near-identical bodies (the\n * live dog-harness spray differed ONLY in greeting; SEO sprays substitute the\n * target domain per site — exact match alone missed both). Scans rows submitted\n * on/after `sinceDate` and compares in JS (see normalizeBody for why not SQL):\n * - `exact`: normalized equality, only when the normalized incoming body is at\n * least MIN_DUP_BODY_LEN chars.\n * - `similar`: token-set Jaccard >= SIMILARITY_THRESHOLD, only when BOTH sets have\n * at least MIN_SIMILAR_TOKENS tokens. A row matched as exact never re-appears here.\n * Returned statuses let the caller retro-bucket still-'new' prior copies. The 2000-row\n * LIMIT is a safety bound only — current volume is ~130/month, so a 30-day window is\n * always a full scan. */\n/** One matched prior row from the duplicate scan. Carries siteId + email so the\n * ingest caller can exempt a GENUINE same-sender resubmission on the same site\n * (a real visitor double-submitting / resending after silence) — only cross-site\n * or different-sender copies are spray evidence. */\nexport type DuplicateMatch = { id: string; status: string; siteId: string; email: string };\n\nexport async function findRecentDuplicateSubmissions(\n db: Db,\n message: string,\n sinceDate: string,\n): Promise<{\n exact: DuplicateMatch[];\n similar: DuplicateMatch[];\n}> {\n const incoming = normalizeBody(message);\n const incomingTokens = tokenSet(incoming);\n const exactEligible = incoming.length >= MIN_DUP_BODY_LEN;\n const similarEligible = incomingTokens.size >= MIN_SIMILAR_TOKENS;\n // Too short for either tier — skip the scan entirely (nothing could match).\n if (!exactEligible && !similarEligible) return { exact: [], similar: [] };\n\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\"id\", \"status\", \"message\", \"site_id\", \"email\"])\n .where(\"message\", \"is not\", null)\n .where(\"submitted_at\", \">=\", sinceDate)\n .orderBy(\"submitted_at\", \"desc\")\n .limit(2000)\n .execute();\n\n const exact: DuplicateMatch[] = [];\n const similar: DuplicateMatch[] = [];\n for (const row of rows) {\n const stored = normalizeBody(row.message ?? \"\");\n const match: DuplicateMatch = {\n id: row.id,\n status: row.status,\n siteId: row.site_id,\n email: row.email,\n };\n if (exactEligible && stored === incoming) {\n exact.push(match);\n continue; // an exact match must not also appear in similar\n }\n if (!similarEligible) continue;\n const storedTokens = tokenSet(stored);\n if (storedTokens.size < MIN_SIMILAR_TOKENS) continue;\n if (jaccard(incomingTokens, storedTokens) >= SIMILARITY_THRESHOLD) {\n similar.push(match);\n }\n }\n return { exact, similar };\n}\n\n/** Recent non-newsletter submissions from `email` (case/whitespace-folded), on/after\n * `sinceDate` (ISO). Powers the cross-site repeat-sender signal: the fleet's sites\n * are UNRELATED businesses, so the same address writing to 2+ of them inside a month\n * is a solicitation tell — the caller compares siteIds (same-site repeats are genuine\n * follow-ups). Newsletter rows are excluded: one person subscribing on two sites is\n * legitimate. Both sides fold in SQL (same-side folding, see the normalizeBody note). */\nexport async function listRecentSubmissionsForEmail(\n db: Db,\n email: string,\n sinceDate: string,\n): Promise<Array<{ id: string; siteId: string; status: string }>> {\n if (email.trim() === \"\") return [];\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\"id\", \"site_id\", \"status\"])\n .where(\"form_type\", \"!=\", \"newsletter\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .where((eb) => sql<SqlBool>`lower(trim(${eb.ref(\"email\")})) = lower(trim(${email}))`)\n .execute();\n return rows.map((r) => ({ id: r.id, siteId: r.site_id, status: r.status }));\n}\n\n/** Retroactively re-bucket prior spray copies once a later copy identifies the spray\n * (the FIRST copy is always delivered by design — only a repeat reveals it). Appends\n * `retroReason` to any existing spam_reason so the original classifier trail survives.\n * The `status = 'new'` guard is load-bearing: rows the operator already read/replied/\n * archived/marked are NEVER touched — this only cleans the unread queue. */\nexport async function markSubmissionsSpamRetro(\n db: Db,\n ids: string[],\n retroReason: string,\n): Promise<void> {\n if (ids.length === 0) return;\n await db\n .updateTable(\"submissions\")\n .set({\n status: \"spam_auto\",\n spam_reason: sql<string>`CASE WHEN spam_reason IS NULL OR spam_reason = '' THEN ${retroReason} ELSE spam_reason || ',' || ${retroReason} END`,\n })\n .where(\"id\", \"in\", ids)\n .where(\"status\", \"=\", \"new\")\n .execute();\n}\n\n/** spam_reason strings for EVERY row matching `filter` (not just one page). Powers the\n * /submissions per-reason facet line, which must tally the WHOLE filtered bucket — the\n * rollout runbook directs the operator to judge the canary from it, and a page-scoped\n * tally under the full-bucket total silently misread once the bucket passed one page.\n * Capped (default 2000) as a safety bound far above any real bucket. */\nexport async function listSpamReasonsFiltered(\n db: Db,\n filter: SubmissionFilter,\n cap = 2000,\n): Promise<string[]> {\n const base = db.selectFrom(\"submissions\").select([\"spam_reason\"]);\n const rows = await applySubmissionFilter(base, filter)\n .where(\"spam_reason\", \"is not\", null)\n .limit(cap)\n .execute();\n return rows.map((r) => r.spam_reason).filter((r): r is string => r !== null && r !== \"\");\n}\n\n/** Insert a SubmissionRow verbatim, preserving its id, display number, and status.\n * ON CONFLICT(id) DO NOTHING makes the whole backfill re-runnable. */\nexport async function backfillSubmission(db: Db, row: SubmissionRow): Promise<void> {\n await db\n .insertInto(\"submissions\")\n .values({\n id: row.id,\n submission_id: row.submissionId,\n site_id: row.siteId,\n form_type: row.formType,\n name: row.name,\n email: row.email,\n phone: row.phone,\n message: row.message,\n extra_fields: row.extraFields,\n source_url: row.sourceUrl,\n utm: row.utm,\n submitted_at: row.submittedAt,\n status: row.status,\n notify_status: row.notifyStatus,\n resend_message_id: row.resendMessageId,\n spam_score: row.spamScore ?? null,\n spam_reason: row.spamReason ?? null,\n fanout_status: row.fanoutStatus ?? null,\n bounce_type: row.bounceType ?? null,\n bounce_subtype: row.bounceSubType ?? null,\n bounce_message: row.bounceMessage ?? null,\n bounce_ack_at: row.bounceAckAt ?? null,\n })\n .onConflict((oc) => oc.column(\"id\").doNothing())\n .execute();\n}\n\n/** Re-score write for the `submissions rescore` CLI (2026-07-16): flip ONE still-'new'\n * row to spam_auto with the CURRENT classifier's verdict. Unlike markSubmissionsSpamRetro\n * (which appends to the old trail), this REPLACES spam_score/spam_reason — the point of a\n * re-score is that today's verdict supersedes the stale ingest-time one; the caller bakes\n * its provenance marker (retro-rescore) into `reason`. The status='new' guard is\n * load-bearing: rows the operator already read/replied/archived/marked are NEVER\n * re-bucketed. Returns true when the row was written (false = no longer 'new'). */\nexport async function rescoreSubmissionSpam(\n db: Db,\n id: string,\n score: number,\n reason: string,\n): Promise<boolean> {\n const res = await db\n .updateTable(\"submissions\")\n .set({ status: \"spam_auto\", spam_score: score, spam_reason: reason })\n .where(\"id\", \"=\", id)\n .where(\"status\", \"=\", \"new\")\n .executeTakeFirst();\n return Number(res.numUpdatedRows) > 0;\n}\n\n/** Bulk triage for the /submissions page (2026-07-16): flip EVERY still-'new' row\n * matching `filter` to 'read', server-side, so a 100-row backlog is one click instead\n * of 100. Reuses applySubmissionFilter (via an id-subquery — kysely's update builder\n * can't take the select-only helper directly) so the POST flips exactly the bucket the\n * GET rendered. The outer status='new' guard is load-bearing: spam/archived/read rows\n * are never touched even when the filter matches them (bulk-\"reading\" a spam_auto row\n * would resurrect it into the per-site strip). Returns the number of rows flipped. */\nexport async function markFilteredAsRead(db: Db, filter: SubmissionFilter): Promise<number> {\n const res = await db\n .updateTable(\"submissions\")\n .set({ status: \"read\" })\n .where(\"status\", \"=\", \"new\")\n .where(\"id\", \"in\", (eb) =>\n applySubmissionFilter(eb.selectFrom(\"submissions\").select(\"id\"), filter),\n )\n .executeTakeFirst();\n return Number(res.numUpdatedRows);\n}\n\n/** Flip a submission's notify_status to 'bounced' by its Resend message id — the\n * resend-webhook's mapping from a bounce/complaint event back onto the lead whose\n * notification it was (2026-07-16, the Espada failure mode: 'sent' only means Resend\n * ACCEPTED the email). Returns whether any row matched, so the webhook can tell a\n * submission notification apart from a report email (unknown id → false → the report\n * path handles it). Idempotent: a svix replay re-writes the same terminal value. In\n * practice only 'sent' rows can match — stampNotified stamps the message id and the\n * 'sent' status together, and failed/skipped rows carry no id. */\nexport async function markNotifyBouncedByMessageId(\n db: Db,\n messageId: string,\n /** Resend's `data.bounce`, parsed (#783). Null for a complaint, which carries\n * no bounce object, and for any payload whose shape we did not recognize.\n *\n * A null detail deliberately does NOT clear a classification already stored:\n * a svix replay of the same event, or a later complaint on a message that\n * already bounced, must not erase the diagnosis the first delivery carried. */\n detail: BounceDetail | null = null,\n): Promise<boolean> {\n if (messageId === \"\") return false;\n const res = await db\n .updateTable(\"submissions\")\n .set(\n detail === null\n ? { notify_status: \"bounced\" }\n : {\n notify_status: \"bounced\",\n bounce_type: detail.type,\n bounce_subtype: detail.subType,\n bounce_message: detail.message,\n },\n )\n .where(\"resend_message_id\", \"=\", messageId)\n .executeTakeFirst();\n if (Number(res.numUpdatedRows) > 0) return true;\n // A replayed webhook must still report \"this was a submission\" (200, stop svix\n // retrying) even though the terminal value is already written — matched-but-\n // unchanged rows still count in numUpdatedRows on SQLite, but don't rely on it.\n const existing = await db\n .selectFrom(\"submissions\")\n .select(\"id\")\n .where(\"resend_message_id\", \"=\", messageId)\n .executeTakeFirst();\n return existing !== undefined;\n}\n\n/** Acknowledge ONE bounced notification as \"not a dead address\" (#783).\n *\n * The row keeps its bounce record — status, type, sub-type and the receiving\n * server's message all stay — and simply stops counting toward the alarm. That\n * is the difference from the production workaround this replaces, which flipped\n * `bounced` back to `sent` by id and destroyed the evidence along with the\n * alarm.\n *\n * Guarded on `notify_status = 'bounced'` (there is nothing to acknowledge on a\n * healthy row) and on `bounce_ack_at IS NULL`, which makes it idempotent and\n * keeps the FIRST acknowledgement's timestamp — the honest one, since that is\n * when the operator actually looked. Returns whether this call was the one that\n * wrote. A PERMANENT bounce is deliberately still ackable: the ack means \"I\n * have looked at this\", not \"this was a false alarm\", and refusing it would\n * leave no way to close the case after genuinely fixing the mailbox. */\nexport async function ackNotifyBounce(db: Db, id: string, now: Date): Promise<boolean> {\n const res = await db\n .updateTable(\"submissions\")\n .set({ bounce_ack_at: now.toISOString() })\n .where(\"id\", \"=\", id)\n .where(\"notify_status\", \"=\", \"bounced\")\n .where(\"bounce_ack_at\", \"is\", null)\n .executeTakeFirst();\n return Number(res.numUpdatedRows) > 0;\n}\n\n/** Per-site bounce counts, split by whether the payload said the ADDRESS is bad. */\nexport type NotifyBounceCounts = {\n /** Unacknowledged bounced notifications in the window. */\n total: number;\n /** How many of those Resend classified `Permanent` (#783). Counted ONLY where\n * the payload actually said so: a null classification — every row written\n * before migration 0018, and every complaint — counts toward `total` and\n * never toward this. Reading null as permanent would keep accusing the\n * address on exactly the rows that caused the false alarm. */\n permanent: number;\n};\n\n/** Per-site counts of bounced lead notifications on/after `sinceDate` (ISO), keyed by\n * the Websites record id (`site_id`). Powers the notify-bounce attention collector —\n * the caller picks the window (like `countAutoSpamSince`), keeping this query pure.\n * Windowed on `submitted_at`: the bounce lands minutes after the submission, and\n * submissions carry no bounce timestamp column (append-only schema, 2026-07-16).\n *\n * Acknowledged rows are excluded outright (#783), which is what lets a diagnosed\n * false alarm clear the same day instead of aging out over fourteen. A site whose\n * every bounce is acked drops out of the map entirely, exactly like a site that\n * never bounced — both are \"nothing to raise\". */\nexport async function countNotifyBouncedBySite(\n db: Db,\n sinceDate: string,\n): Promise<Map<string, NotifyBounceCounts>> {\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\n \"site_id\",\n (eb) => eb.fn.countAll<number>().as(\"n\"),\n // lower() is ASCII-only in SQLite, which is all this needs: the value is a\n // Resend enum (\"Permanent\"/\"Transient\"/\"Undetermined\"). Matching it\n // case-insensitively here keeps the SQL agreeing with isPermanentBounce.\n sql<number>`sum(case when lower(bounce_type) = 'permanent' then 1 else 0 end)`.as(\n \"permanent_n\",\n ),\n ])\n .where(\"notify_status\", \"=\", \"bounced\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .where(\"bounce_ack_at\", \"is\", null)\n .groupBy(\"site_id\")\n .execute();\n return new Map(\n rows.map((r) => [\n r.site_id,\n { total: Number(r.n) || 0, permanent: Number(r.permanent_n) || 0 },\n ]),\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/db/submissions.ts"],"sourcesContent":["import { sql } from \"kysely\";\nimport type { Selectable, SqlBool } from \"kysely\";\nimport type { Db } from \"./client.js\";\nimport type { SubmissionsTable } from \"./schema.js\";\nimport {\n type SubmissionRow,\n type SubmissionInput,\n type BounceDetail,\n toFormType,\n toStatus,\n toNotifyStatus,\n} from \"../reports/submission-row.js\";\nimport type { FormType, SubmissionStatus } from \"../reports/submission-row.js\";\nimport { SIGNUP_FORM_TYPES } from \"../forms/types.js\";\n\nexport type SubmissionFilter = {\n siteId?: string;\n formType?: FormType;\n status?: SubmissionStatus;\n /** LIKE %q% across name/email/message/phone, case-insensitive */\n search?: string;\n /** submitted_at >= from (ISO string) */\n from?: string;\n /** submitted_at <= to (ISO string) */\n to?: string;\n /** exact spam_reason token (comma-boundary match; a stored \":N\" count suffix on\n * the token still matches) */\n reason?: string;\n};\n\n/** Map a raw DB row to the canonical SubmissionRow, narrowing the enum columns\n * with the SAME validators the Airtable mapRow uses — SQLite stores TEXT, so a\n * bad stored value must still be defended against. */\nfunction rowFromDb(r: Selectable<SubmissionsTable>): SubmissionRow {\n return {\n id: r.id,\n submissionId: r.submission_id,\n siteId: r.site_id,\n formType: toFormType(r.form_type),\n name: r.name,\n email: r.email,\n phone: r.phone,\n message: r.message,\n extraFields: r.extra_fields,\n sourceUrl: r.source_url,\n utm: r.utm,\n submittedAt: r.submitted_at,\n status: toStatus(r.status),\n notifyStatus: toNotifyStatus(r.notify_status),\n resendMessageId: r.resend_message_id,\n spamScore: typeof r.spam_score === \"number\" ? r.spam_score : null,\n spamReason: r.spam_reason,\n fanoutStatus: r.fanout_status,\n bounceType: r.bounce_type,\n bounceSubType: r.bounce_subtype,\n bounceMessage: r.bounce_message,\n bounceAckAt: r.bounce_ack_at,\n };\n}\n\n/** Opaque, collision-free id. crypto is a Node 20 global — no new dep. */\nexport function newSubmissionId(): string {\n return `sub_${crypto.randomUUID()}`;\n}\n\nexport async function getSubmissionById(db: Db, id: string): Promise<SubmissionRow | null> {\n const r = await db.selectFrom(\"submissions\").selectAll().where(\"id\", \"=\", id).executeTakeFirst();\n return r ? rowFromDb(r) : null;\n}\n\nexport async function createSubmission(db: Db, input: SubmissionInput): Promise<SubmissionRow> {\n const id = newSubmissionId();\n const extra =\n input.extraFields !== undefined && Object.keys(input.extraFields).length > 0\n ? JSON.stringify(input.extraFields)\n : null;\n await db\n .insertInto(\"submissions\")\n .values({\n id,\n // Display number: MAX+1 in a single statement. libSQL is single-writer, so\n // writes serialize and this is race-free.\n submission_id: sql<number>`(SELECT COALESCE(MAX(submission_id), 0) + 1 FROM submissions)`,\n site_id: input.siteId,\n form_type: input.formType,\n name: input.name,\n email: input.email,\n phone: input.phone ?? null,\n message: input.message ?? null,\n extra_fields: extra,\n source_url: input.sourceUrl ?? null,\n utm: input.utm ?? null,\n submitted_at: input.submittedAt.toISOString(),\n status: input.status ?? \"new\",\n notify_status: \"skipped\",\n resend_message_id: null,\n spam_score: input.spamScore ?? null,\n spam_reason: input.spamReason ?? null,\n // Always null at insert: the fan-out runs after the row exists (stampFanout).\n fanout_status: null,\n // Always null at insert too: a bounce is reported by a webhook minutes\n // later, and the ack is an operator gesture after that (#783).\n bounce_type: null,\n bounce_subtype: null,\n bounce_message: null,\n bounce_ack_at: null,\n })\n .execute();\n const created = await getSubmissionById(db, id);\n if (!created) throw new Error(\"createSubmission: row vanished after insert\");\n return created;\n}\n\nimport type { NotifyStatus } from \"../reports/submission-row.js\";\n\nexport async function listNewSubmissions(db: Db, max = 200): Promise<SubmissionRow[]> {\n // Bound the fetch (matches listSubmissionsForSite's default): the cockpit loads this whole\n // array on every render. Unbounded, it deserializes every unread submission fleet-wide. The\n // badge count therefore caps at `max` — acceptable, since >200 unread fleet-wide is itself a\n // triage emergency, and the /submissions page is the unbounded, filterable view.\n const rows = await db\n .selectFrom(\"submissions\")\n .selectAll()\n .where(\"status\", \"=\", \"new\")\n .orderBy(\"submitted_at\", \"desc\")\n .limit(max)\n .execute();\n return rows.map(rowFromDb);\n}\n\n/** Same signature shape as the Airtable version (takes `{ id, name }`) so the\n * composition-root swap is import-only — but here we filter by id directly, with\n * no linked-field/primary-field workaround and no JS-confirm pass. */\nexport async function listSubmissionsForSite(\n db: Db,\n site: { id: string; name: string },\n max = 200,\n): Promise<SubmissionRow[]> {\n const rows = await db\n .selectFrom(\"submissions\")\n .selectAll()\n .where(\"site_id\", \"=\", site.id)\n .orderBy(\"submitted_at\", \"desc\")\n .limit(max)\n .execute();\n return rows.map(rowFromDb);\n}\n\nexport async function setSubmissionStatusRow(\n db: Db,\n id: string,\n status: SubmissionStatus,\n): Promise<void> {\n await db.updateTable(\"submissions\").set({ status }).where(\"id\", \"=\", id).execute();\n}\n\nexport async function stampNotified(\n db: Db,\n id: string,\n status: NotifyStatus,\n messageId: string | null,\n): Promise<void> {\n const patch =\n messageId !== null\n ? { notify_status: status, resend_message_id: messageId }\n : { notify_status: status };\n await db.updateTable(\"submissions\").set(patch).where(\"id\", \"=\", id).execute();\n}\n\n/** Record the newsletter fan-out outcome on a row (comma-joined destination tokens,\n * see ingest.ts). REPLACES any previous value — the fan-out runs once per submission,\n * so the last write is the whole truth; unlike markSubmissionsSpamRetro there is no\n * earlier trail worth appending to. No status guard: this is provenance about what\n * the pipeline did, not a triage decision, so it stays accurate even for a row the\n * operator has already read or archived. */\nexport async function stampFanout(db: Db, id: string, fanoutStatus: string): Promise<void> {\n await db\n .updateTable(\"submissions\")\n .set({ fanout_status: fanoutStatus })\n .where(\"id\", \"=\", id)\n .execute();\n}\n\n// NOTE: listSubmissionsFiltered and countSubmissionsFiltered share identical filter\n// logic via applySubmissionFilter — always update both through that helper.\nfunction applySubmissionFilter<O>(\n qb: import(\"kysely\").SelectQueryBuilder<import(\"./schema.js\").Database, \"submissions\", O>,\n f: SubmissionFilter,\n) {\n let q = qb;\n if (f.siteId !== undefined) q = q.where(\"site_id\", \"=\", f.siteId);\n if (f.formType !== undefined) q = q.where(\"form_type\", \"=\", f.formType);\n if (f.status !== undefined) q = q.where(\"status\", \"=\", f.status);\n if (f.from !== undefined) q = q.where(\"submitted_at\", \">=\", f.from);\n if (f.to !== undefined) q = q.where(\"submitted_at\", \"<=\", f.to);\n if (f.search !== undefined && f.search.trim() !== \"\") {\n // Escape LIKE metacharacters so a user's literal `%`/`_` (or the escape char `\\`) match\n // literally rather than as wildcards: searching `john_doe` must not also hit `johnXdoe`,\n // and a bare `%` must not match everything. `\\` is escaped first (it's the ESCAPE char).\n // Parameterized regardless (no injection) — this is a correctness fix. Uses sql`` because\n // kysely's `like` operator can't attach an ESCAPE clause.\n const term = f.search\n .trim()\n .toLowerCase()\n .replace(/[\\\\%_]/g, \"\\\\$&\");\n const like = `%${term}%`;\n q = q.where((eb) =>\n eb.or([\n sql<SqlBool>`lower(${eb.ref(\"name\")}) like ${like} escape '\\\\'`,\n sql<SqlBool>`lower(${eb.ref(\"email\")}) like ${like} escape '\\\\'`,\n sql<SqlBool>`lower(${eb.ref(\"message\")}) like ${like} escape '\\\\'`,\n sql<SqlBool>`lower(${eb.ref(\"phone\")}) like ${like} escape '\\\\'`,\n ]),\n );\n }\n if (f.reason !== undefined && f.reason.trim() !== \"\") {\n // spam_reason is a comma-joined token list (ingest.ts joins \",\"; retro appends\n // \",<reason>\"). Comma-boundary match so \"links\" never matches \"backlinks\" and\n // \"turnstile-required-absent\" never matches \"-failed\"; the second branch accepts\n // a per-token count suffix (\"keywords:4\" matches reason=keywords). LIKE\n // metacharacters escaped exactly like the search branch. lower() both sides is\n // safe: tokens are ASCII classifier codes.\n const token = f.reason\n .trim()\n .toLowerCase()\n .replace(/[\\\\%_]/g, \"\\\\$&\");\n q = q.where((eb) =>\n eb.or([\n sql<SqlBool>`',' || lower(${eb.ref(\"spam_reason\")}) || ',' like ${`%,${token},%`} escape '\\\\'`,\n sql<SqlBool>`',' || lower(${eb.ref(\"spam_reason\")}) || ',' like ${`%,${token}:%`} escape '\\\\'`,\n ]),\n );\n }\n return q;\n}\n\nexport async function listSubmissionsFiltered(\n db: Db,\n filter: SubmissionFilter,\n opts: { limit: number; offset: number },\n): Promise<SubmissionRow[]> {\n const base = db.selectFrom(\"submissions\").selectAll();\n const rows = await applySubmissionFilter(base, filter)\n .orderBy(\"submitted_at\", \"desc\")\n .limit(opts.limit)\n .offset(opts.offset)\n .execute();\n return rows.map(rowFromDb);\n}\n\nexport async function countSubmissionsFiltered(db: Db, filter: SubmissionFilter): Promise<number> {\n const base = db.selectFrom(\"submissions\").select((eb) => eb.fn.countAll<number>().as(\"n\"));\n const res = await applySubmissionFilter(base, filter).executeTakeFirstOrThrow();\n return Number(res.n);\n}\n\n/** Fleet-wide count of auto-filtered spam (`status = 'spam_auto'`) submitted on/after\n * `sinceDate` (ISO). Powers the cockpit \"N auto-filtered this week — review\" affordance;\n * the caller picks the window (like `listScreenOutsSince`), keeping this query pure. */\nexport async function countAutoSpamSince(db: Db, sinceDate: string): Promise<number> {\n const res = await db\n .selectFrom(\"submissions\")\n .select((eb) => eb.fn.countAll<number>().as(\"n\"))\n .where(\"status\", \"=\", \"spam_auto\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .executeTakeFirstOrThrow();\n return Number(res.n);\n}\n\nexport type SiteSubmissionCounts = { leads: number; signups: number; spamAuto: number };\n\n/** Per-site submission counts on/after `sinceIso` (full ISO timestamp) for the\n * digest telemetry line. Grouped one-query: leads/signups = rows NOT auto- or\n * operator-bucketed as spam (status NOT IN ('spam_auto','spam')) split by\n * isLeadFormType; spamAuto = status='spam_auto'. Keyed by Websites record id. */\nexport async function countSubmissionsSinceBySite(\n db: Db,\n sinceIso: string,\n): Promise<Map<string, SiteSubmissionCounts>> {\n const signupList = sql.join(SIGNUP_FORM_TYPES.map((t) => sql`${t}`));\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\n \"site_id\",\n sql<number>`sum(case when status not in ('spam_auto','spam') and form_type not in (${signupList}) then 1 else 0 end)`.as(\n \"leads_n\",\n ),\n sql<number>`sum(case when status not in ('spam_auto','spam') and form_type in (${signupList}) then 1 else 0 end)`.as(\n \"signups_n\",\n ),\n sql<number>`sum(case when status = 'spam_auto' then 1 else 0 end)`.as(\"spam_auto_n\"),\n ])\n .where(\"submitted_at\", \">=\", sinceIso)\n .groupBy(\"site_id\")\n .execute();\n return new Map(\n rows.map((r) => [\n r.site_id,\n {\n leads: Number(r.leads_n) || 0,\n signups: Number(r.signups_n) || 0,\n spamAuto: Number(r.spam_auto_n) || 0,\n },\n ]),\n );\n}\n\n/** A normalized body shorter than this never triggers the exact-duplicate signal —\n * short/boilerplate lines (a newsletter \"subscribe\", \"hi\") legitimately repeat\n * across real people. */\nexport const MIN_DUP_BODY_LEN = 40;\n\n/** Both token sets must be at least this large before the Jaccard tier can fire —\n * two short genuine messages (\"what are your hours?\" variants) share most of their\n * few tokens and would otherwise collide. */\nexport const MIN_SIMILAR_TOKENS = 25;\n\n/** Jaccard similarity (token sets) at or above this marks a near-duplicate. 0.9 is\n * deliberately strict: a template spray with one substituted greeting/target still\n * clears it, while two independently-written enquiries about the same business\n * never do. */\nexport const SIMILARITY_THRESHOLD = 0.9;\n\n/** Fold a message body down to its template skeleton. Full-Unicode lowercase first\n * (JS `toLowerCase()` — SQLite's `lower()` is ASCII-only, which is why this whole\n * comparison lives in JS, folding BOTH sides identically so byte-identical Cyrillic\n * copies keep matching), then strip everything a spray substitutes per target —\n * URLs, emails, bare domains, digit runs — and collapse whitespace. Over-stripping\n * is safe: it happens symmetrically on both sides. */\nfunction normalizeBody(body: string): string {\n return body\n .toLowerCase()\n .replace(/https?:\\/\\/\\S+/gu, \" \") // full URLs\n .replace(/www\\.\\S+/gu, \" \") // scheme-less www. links\n .replace(/\\S+@\\S+/gu, \" \") // email addresses\n .replace(/\\b[a-z0-9-]+(\\.[a-z0-9-]+)+\\b/gu, \" \") // bare domain tokens (word.tld)\n .replace(/\\d+/gu, \" \") // digit runs (phone numbers, prices, years)\n .replace(/\\s+/gu, \" \")\n .trim();\n}\n\n/** Letter-only token set of a normalized body (punctuation/symbols are separators). */\nfunction tokenSet(normalized: string): Set<string> {\n return new Set(normalized.split(/[^\\p{L}]+/u).filter((t) => t.length > 0));\n}\n\nfunction jaccard(a: Set<string>, b: Set<string>): number {\n let intersection = 0;\n for (const t of a) if (b.has(t)) intersection++;\n const union = a.size + b.size - intersection;\n return union === 0 ? 0 : intersection / union;\n}\n\n/** Fleet-wide duplicate/near-duplicate lookup for the spray spam signal — the same\n * pitch blasted across sites shows up as identical OR near-identical bodies (the\n * live dog-harness spray differed ONLY in greeting; SEO sprays substitute the\n * target domain per site — exact match alone missed both). Scans rows submitted\n * on/after `sinceDate` and compares in JS (see normalizeBody for why not SQL):\n * - `exact`: normalized equality, only when the normalized incoming body is at\n * least MIN_DUP_BODY_LEN chars.\n * - `similar`: token-set Jaccard >= SIMILARITY_THRESHOLD, only when BOTH sets have\n * at least MIN_SIMILAR_TOKENS tokens. A row matched as exact never re-appears here.\n * Returned statuses let the caller retro-bucket still-'new' prior copies. The 2000-row\n * LIMIT is a safety bound only — current volume is ~130/month, so a 30-day window is\n * always a full scan. */\n/** One matched prior row from the duplicate scan. Carries siteId + email so the\n * ingest caller can exempt a GENUINE same-sender resubmission on the same site\n * (a real visitor double-submitting / resending after silence) — only cross-site\n * or different-sender copies are spray evidence. */\nexport type DuplicateMatch = { id: string; status: string; siteId: string; email: string };\n\nexport async function findRecentDuplicateSubmissions(\n db: Db,\n message: string,\n sinceDate: string,\n): Promise<{\n exact: DuplicateMatch[];\n similar: DuplicateMatch[];\n}> {\n const incoming = normalizeBody(message);\n const incomingTokens = tokenSet(incoming);\n const exactEligible = incoming.length >= MIN_DUP_BODY_LEN;\n const similarEligible = incomingTokens.size >= MIN_SIMILAR_TOKENS;\n // Too short for either tier — skip the scan entirely (nothing could match).\n if (!exactEligible && !similarEligible) return { exact: [], similar: [] };\n\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\"id\", \"status\", \"message\", \"site_id\", \"email\"])\n .where(\"message\", \"is not\", null)\n .where(\"submitted_at\", \">=\", sinceDate)\n .orderBy(\"submitted_at\", \"desc\")\n .limit(2000)\n .execute();\n\n const exact: DuplicateMatch[] = [];\n const similar: DuplicateMatch[] = [];\n for (const row of rows) {\n const stored = normalizeBody(row.message ?? \"\");\n const match: DuplicateMatch = {\n id: row.id,\n status: row.status,\n siteId: row.site_id,\n email: row.email,\n };\n if (exactEligible && stored === incoming) {\n exact.push(match);\n continue; // an exact match must not also appear in similar\n }\n if (!similarEligible) continue;\n const storedTokens = tokenSet(stored);\n if (storedTokens.size < MIN_SIMILAR_TOKENS) continue;\n if (jaccard(incomingTokens, storedTokens) >= SIMILARITY_THRESHOLD) {\n similar.push(match);\n }\n }\n return { exact, similar };\n}\n\n/** Recent non-newsletter submissions from `email` (case/whitespace-folded), on/after\n * `sinceDate` (ISO). Powers the cross-site repeat-sender signal: the fleet's sites\n * are UNRELATED businesses, so the same address writing to 2+ of them inside a month\n * is a solicitation tell — the caller compares siteIds (same-site repeats are genuine\n * follow-ups). Newsletter rows are excluded: one person subscribing on two sites is\n * legitimate. Both sides fold in SQL (same-side folding, see the normalizeBody note). */\nexport async function listRecentSubmissionsForEmail(\n db: Db,\n email: string,\n sinceDate: string,\n): Promise<Array<{ id: string; siteId: string; status: string }>> {\n if (email.trim() === \"\") return [];\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\"id\", \"site_id\", \"status\"])\n .where(\"form_type\", \"!=\", \"newsletter\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .where((eb) => sql<SqlBool>`lower(trim(${eb.ref(\"email\")})) = lower(trim(${email}))`)\n .execute();\n return rows.map((r) => ({ id: r.id, siteId: r.site_id, status: r.status }));\n}\n\n/** Retroactively re-bucket prior spray copies once a later copy identifies the spray\n * (the FIRST copy is always delivered by design — only a repeat reveals it). Appends\n * `retroReason` to any existing spam_reason so the original classifier trail survives.\n * The `status = 'new'` guard is load-bearing: rows the operator already read/replied/\n * archived/marked are NEVER touched — this only cleans the unread queue. */\nexport async function markSubmissionsSpamRetro(\n db: Db,\n ids: string[],\n retroReason: string,\n): Promise<void> {\n if (ids.length === 0) return;\n await db\n .updateTable(\"submissions\")\n .set({\n status: \"spam_auto\",\n spam_reason: sql<string>`CASE WHEN spam_reason IS NULL OR spam_reason = '' THEN ${retroReason} ELSE spam_reason || ',' || ${retroReason} END`,\n })\n .where(\"id\", \"in\", ids)\n .where(\"status\", \"=\", \"new\")\n .execute();\n}\n\n/** spam_reason strings for EVERY row matching `filter` (not just one page). Powers the\n * /submissions per-reason facet line, which must tally the WHOLE filtered bucket — the\n * rollout runbook directs the operator to judge the canary from it, and a page-scoped\n * tally under the full-bucket total silently misread once the bucket passed one page.\n * Capped (default 2000) as a safety bound far above any real bucket. */\nexport async function listSpamReasonsFiltered(\n db: Db,\n filter: SubmissionFilter,\n cap = 2000,\n): Promise<string[]> {\n const base = db.selectFrom(\"submissions\").select([\"spam_reason\"]);\n const rows = await applySubmissionFilter(base, filter)\n .where(\"spam_reason\", \"is not\", null)\n .limit(cap)\n .execute();\n return rows.map((r) => r.spam_reason).filter((r): r is string => r !== null && r !== \"\");\n}\n\n/** Insert a SubmissionRow verbatim, preserving its id, display number, and status.\n * ON CONFLICT(id) DO NOTHING makes the whole backfill re-runnable. */\nexport async function backfillSubmission(db: Db, row: SubmissionRow): Promise<void> {\n await db\n .insertInto(\"submissions\")\n .values({\n id: row.id,\n submission_id: row.submissionId,\n site_id: row.siteId,\n form_type: row.formType,\n name: row.name,\n email: row.email,\n phone: row.phone,\n message: row.message,\n extra_fields: row.extraFields,\n source_url: row.sourceUrl,\n utm: row.utm,\n submitted_at: row.submittedAt,\n status: row.status,\n notify_status: row.notifyStatus,\n resend_message_id: row.resendMessageId,\n spam_score: row.spamScore ?? null,\n spam_reason: row.spamReason ?? null,\n fanout_status: row.fanoutStatus ?? null,\n bounce_type: row.bounceType ?? null,\n bounce_subtype: row.bounceSubType ?? null,\n bounce_message: row.bounceMessage ?? null,\n bounce_ack_at: row.bounceAckAt ?? null,\n })\n .onConflict((oc) => oc.column(\"id\").doNothing())\n .execute();\n}\n\n/** Re-score write for the `submissions rescore` CLI (2026-07-16): flip ONE still-'new'\n * row to spam_auto with the CURRENT classifier's verdict. Unlike markSubmissionsSpamRetro\n * (which appends to the old trail), this REPLACES spam_score/spam_reason — the point of a\n * re-score is that today's verdict supersedes the stale ingest-time one; the caller bakes\n * its provenance marker (retro-rescore) into `reason`. The status='new' guard is\n * load-bearing: rows the operator already read/replied/archived/marked are NEVER\n * re-bucketed. Returns true when the row was written (false = no longer 'new'). */\nexport async function rescoreSubmissionSpam(\n db: Db,\n id: string,\n score: number,\n reason: string,\n): Promise<boolean> {\n const res = await db\n .updateTable(\"submissions\")\n .set({ status: \"spam_auto\", spam_score: score, spam_reason: reason })\n .where(\"id\", \"=\", id)\n .where(\"status\", \"=\", \"new\")\n .executeTakeFirst();\n return Number(res.numUpdatedRows) > 0;\n}\n\n/** Bulk triage for the /submissions page (2026-07-16): flip EVERY still-'new' row\n * matching `filter` to 'read', server-side, so a 100-row backlog is one click instead\n * of 100. Reuses applySubmissionFilter (via an id-subquery — kysely's update builder\n * can't take the select-only helper directly) so the POST flips exactly the bucket the\n * GET rendered. The outer status='new' guard is load-bearing: spam/archived/read rows\n * are never touched even when the filter matches them (bulk-\"reading\" a spam_auto row\n * would resurrect it into the per-site strip). Returns the number of rows flipped. */\nexport async function markFilteredAsRead(db: Db, filter: SubmissionFilter): Promise<number> {\n const res = await db\n .updateTable(\"submissions\")\n .set({ status: \"read\" })\n .where(\"status\", \"=\", \"new\")\n .where(\"id\", \"in\", (eb) =>\n applySubmissionFilter(eb.selectFrom(\"submissions\").select(\"id\"), filter),\n )\n .executeTakeFirst();\n return Number(res.numUpdatedRows);\n}\n\n/** Flip a submission's notify_status to 'bounced' by its Resend message id — the\n * resend-webhook's mapping from a bounce/complaint event back onto the lead whose\n * notification it was (2026-07-16, the Espada failure mode: 'sent' only means Resend\n * ACCEPTED the email). Returns whether any row matched, so the webhook can tell a\n * submission notification apart from a report email (unknown id → false → the report\n * path handles it). Idempotent: a svix replay re-writes the same terminal value. In\n * practice only 'sent' rows can match — stampNotified stamps the message id and the\n * 'sent' status together, and failed/skipped rows carry no id. */\nexport async function markNotifyBouncedByMessageId(\n db: Db,\n messageId: string,\n /** Resend's `data.bounce`, parsed (#783). Null for a complaint, which carries\n * no bounce object, and for any payload whose shape we did not recognize.\n *\n * A null detail deliberately does NOT clear a classification already stored:\n * a svix replay of the same event, or a later complaint on a message that\n * already bounced, must not erase the diagnosis the first delivery carried. */\n detail: BounceDetail | null = null,\n): Promise<boolean> {\n if (messageId === \"\") return false;\n const res = await db\n .updateTable(\"submissions\")\n .set(\n detail === null\n ? { notify_status: \"bounced\" }\n : {\n notify_status: \"bounced\",\n bounce_type: detail.type,\n bounce_subtype: detail.subType,\n bounce_message: detail.message,\n },\n )\n .where(\"resend_message_id\", \"=\", messageId)\n .executeTakeFirst();\n if (Number(res.numUpdatedRows) > 0) return true;\n // A replayed webhook must still report \"this was a submission\" (200, stop svix\n // retrying) even though the terminal value is already written — matched-but-\n // unchanged rows still count in numUpdatedRows on SQLite, but don't rely on it.\n const existing = await db\n .selectFrom(\"submissions\")\n .select(\"id\")\n .where(\"resend_message_id\", \"=\", messageId)\n .executeTakeFirst();\n return existing !== undefined;\n}\n\n/** Acknowledge ONE bounced notification as \"not a dead address\" (#783).\n *\n * The row keeps its bounce record — status, type, sub-type and the receiving\n * server's message all stay — and simply stops counting toward the alarm. That\n * is the difference from the production workaround this replaces, which flipped\n * `bounced` back to `sent` by id and destroyed the evidence along with the\n * alarm.\n *\n * Guarded on `notify_status = 'bounced'` (there is nothing to acknowledge on a\n * healthy row) and on `bounce_ack_at IS NULL`, which makes it idempotent and\n * keeps the FIRST acknowledgement's timestamp — the honest one, since that is\n * when the operator actually looked. Returns whether this call was the one that\n * wrote. A PERMANENT bounce is deliberately still ackable: the ack means \"I\n * have looked at this\", not \"this was a false alarm\", and refusing it would\n * leave no way to close the case after genuinely fixing the mailbox. */\nexport async function ackNotifyBounce(db: Db, id: string, now: Date): Promise<boolean> {\n const res = await db\n .updateTable(\"submissions\")\n .set({ bounce_ack_at: now.toISOString() })\n .where(\"id\", \"=\", id)\n .where(\"notify_status\", \"=\", \"bounced\")\n .where(\"bounce_ack_at\", \"is\", null)\n .executeTakeFirst();\n return Number(res.numUpdatedRows) > 0;\n}\n\n/** Per-site bounce counts, split by whether the payload said the ADDRESS is bad. */\nexport type NotifyBounceCounts = {\n /** Unacknowledged bounced notifications in the window. */\n total: number;\n /** How many of those Resend classified `Permanent` (#783). Counted ONLY where\n * the payload actually said so: a null classification — every row written\n * before migration 0018, and every complaint — counts toward `total` and\n * never toward this. Reading null as permanent would keep accusing the\n * address on exactly the rows that caused the false alarm. */\n permanent: number;\n};\n\n/** Per-site counts of bounced lead notifications on/after `sinceDate` (ISO), keyed by\n * the Websites record id (`site_id`). Powers the notify-bounce attention collector —\n * the caller picks the window (like `countAutoSpamSince`), keeping this query pure.\n * Windowed on `submitted_at`: the bounce lands minutes after the submission, and\n * submissions carry no bounce timestamp column (append-only schema, 2026-07-16).\n *\n * Acknowledged rows are excluded outright (#783), which is what lets a diagnosed\n * false alarm clear the same day instead of aging out over fourteen. A site whose\n * every bounce is acked drops out of the map entirely, exactly like a site that\n * never bounced — both are \"nothing to raise\". */\nexport async function countNotifyBouncedBySite(\n db: Db,\n sinceDate: string,\n): Promise<Map<string, NotifyBounceCounts>> {\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\n \"site_id\",\n (eb) => eb.fn.countAll<number>().as(\"n\"),\n // lower() is ASCII-only in SQLite, which is all this needs: the value is a\n // Resend enum (\"Permanent\"/\"Transient\"/\"Undetermined\"). Matching it\n // case-insensitively here keeps the SQL agreeing with isPermanentBounce.\n sql<number>`sum(case when lower(bounce_type) = 'permanent' then 1 else 0 end)`.as(\n \"permanent_n\",\n ),\n ])\n .where(\"notify_status\", \"=\", \"bounced\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .where(\"bounce_ack_at\", \"is\", null)\n .groupBy(\"site_id\")\n .execute();\n return new Map(\n rows.map((r) => [\n r.site_id,\n { total: Number(r.n) || 0, permanent: Number(r.permanent_n) || 0 },\n ]),\n );\n}\n\n/** The same counts for ONE site — the `/s/:slug` page's bounce chip (MED-11).\n *\n * Identical predicates to `countNotifyBouncedBySite`, plus `site_id = ?` and no\n * GROUP BY, so `idx_submissions_site_submitted (site_id, submitted_at DESC)`\n * serves it as a SEARCH rather than a traversal of the whole table. The site\n * page used to call the fleet-wide version and `.get(site.id)` the answer out:\n * one site's chip cost a GROUP BY over every lead the fleet has ever received,\n * on the one table that grows without bound, on every page load.\n *\n * A site with nothing to raise returns zeroes rather than dropping out of a\n * map — the single-site caller has no map to be absent from, and `{0, 0}` and\n * \"no row\" mean the same thing to the collector. */\nexport async function countNotifyBouncedForSite(\n db: Db,\n siteId: string,\n sinceDate: string,\n): Promise<NotifyBounceCounts> {\n const row = await db\n .selectFrom(\"submissions\")\n .select([\n (eb) => eb.fn.countAll<number>().as(\"n\"),\n sql<number>`sum(case when lower(bounce_type) = 'permanent' then 1 else 0 end)`.as(\n \"permanent_n\",\n ),\n ])\n .where(\"site_id\", \"=\", siteId)\n .where(\"notify_status\", \"=\", \"bounced\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .where(\"bounce_ack_at\", \"is\", null)\n .executeTakeFirst();\n return { total: Number(row?.n) || 0, permanent: Number(row?.permanent_n) || 0 };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -10,7 +10,7 @@ function parseExtraFields(raw) {
|
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
async function rescoreNewSubmissions(db, opts) {
|
|
13
|
-
const { listSubmissionsFiltered, rescoreSubmissionSpam } = await import("./submissions-
|
|
13
|
+
const { listSubmissionsFiltered, rescoreSubmissionSpam } = await import("./submissions-4I66W6QK.js");
|
|
14
14
|
const { classifySpam, SPAM_THRESHOLD } = await import("./spam-classifier-T5H4VTP6.js");
|
|
15
15
|
const rows = [];
|
|
16
16
|
const PAGE = 500;
|
|
@@ -40,10 +40,15 @@ async function rescoreNewSubmissions(db, opts) {
|
|
|
40
40
|
}
|
|
41
41
|
async function loadSiteNames() {
|
|
42
42
|
try {
|
|
43
|
-
const {
|
|
44
|
-
const {
|
|
45
|
-
const
|
|
46
|
-
|
|
43
|
+
const { openDb, readDbConfig } = await import("./client-3XGSZMLU.js");
|
|
44
|
+
const { listSites } = await import("./fleet-state-RJROYXKO.js");
|
|
45
|
+
const db = await openDb(readDbConfig());
|
|
46
|
+
try {
|
|
47
|
+
const sites = await listSites(db);
|
|
48
|
+
return new Map(sites.map((s) => [s.id, s.name]));
|
|
49
|
+
} finally {
|
|
50
|
+
await db.destroy();
|
|
51
|
+
}
|
|
47
52
|
} catch (err) {
|
|
48
53
|
console.error(`[submissions] site names unavailable \u2014 printing raw site ids: ${String(err)}`);
|
|
49
54
|
return /* @__PURE__ */ new Map();
|
|
@@ -78,7 +83,7 @@ async function runSubmissionsCommand(action, opts) {
|
|
|
78
83
|
return { output: "--apply and --dry-run conflict. Pick one.", code: 2 };
|
|
79
84
|
}
|
|
80
85
|
const apply = opts.apply === true;
|
|
81
|
-
const { openDb, readDbConfig } = await import("./client-
|
|
86
|
+
const { openDb, readDbConfig } = await import("./client-3XGSZMLU.js");
|
|
82
87
|
const { SPAM_THRESHOLD } = await import("./spam-classifier-T5H4VTP6.js");
|
|
83
88
|
const cfg = opts.url ? { url: opts.url } : readDbConfig();
|
|
84
89
|
const db = await openDb(cfg);
|
|
@@ -119,4 +124,4 @@ export {
|
|
|
119
124
|
rescoreNewSubmissions,
|
|
120
125
|
runSubmissionsCommand
|
|
121
126
|
};
|
|
122
|
-
//# sourceMappingURL=submissions-
|
|
127
|
+
//# sourceMappingURL=submissions-K5UXGZUN.js.map
|