@reddoorla/maintenance 0.85.2 → 0.87.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.
Files changed (265) hide show
  1. package/README.md +8 -0
  2. package/dist/airtable-ZBUPQJSC.js +9 -0
  3. package/dist/{announce-GIAYN67S.js → announce-I2Z4JN3I.js} +58 -30
  4. package/dist/announce-I2Z4JN3I.js.map +1 -0
  5. package/dist/attachments-NLZIJ5NU.js +9 -0
  6. package/dist/{bump-deps-SWJB6H5V.js → bump-deps-CGVELRLK.js} +9 -9
  7. package/dist/{chunk-SDUJIANJ.js → chunk-2NJQLSEU.js} +3 -3
  8. package/dist/chunk-2NQUCS7O.js +243 -0
  9. package/dist/chunk-2NQUCS7O.js.map +1 -0
  10. package/dist/{chunk-ZTPPCHDB.js → chunk-3G25KIWW.js} +4 -4
  11. package/dist/{chunk-DTTIFYAM.js → chunk-3JQ2FO3D.js} +44 -405
  12. package/dist/chunk-3JQ2FO3D.js.map +1 -0
  13. package/dist/chunk-3SF7D4DY.js +359 -0
  14. package/dist/chunk-3SF7D4DY.js.map +1 -0
  15. package/dist/{chunk-37S6L6G4.js → chunk-5UINJNAJ.js} +72 -2
  16. package/dist/chunk-5UINJNAJ.js.map +1 -0
  17. package/dist/{chunk-THEFTZ7F.js → chunk-62CMMGEO.js} +5 -5
  18. package/dist/{chunk-FDIFTIXC.js → chunk-6TWWY7ZU.js} +2 -2
  19. package/dist/{chunk-PBWDDDQH.js → chunk-7BZUOZPU.js} +2 -2
  20. package/dist/{chunk-577HCGZO.js → chunk-7NYN2P6I.js} +44 -26
  21. package/dist/chunk-7NYN2P6I.js.map +1 -0
  22. package/dist/{chunk-UYCAF77E.js → chunk-7RJ2HMMJ.js} +3 -3
  23. package/dist/{chunk-Q276MZ7E.js → chunk-7UAYMAKR.js} +2 -2
  24. package/dist/{chunk-MGR2H7F4.js → chunk-BBUSGEES.js} +58 -45
  25. package/dist/chunk-BBUSGEES.js.map +1 -0
  26. package/dist/chunk-BNM5POSJ.js +284 -0
  27. package/dist/chunk-BNM5POSJ.js.map +1 -0
  28. package/dist/{chunk-R6FPFMOS.js → chunk-BQRFQSGY.js} +3 -3
  29. package/dist/{chunk-NWB4O5EY.js → chunk-C6CLRKPV.js} +5 -5
  30. package/dist/chunk-C6CLRKPV.js.map +1 -0
  31. package/dist/{chunk-7J53SUT6.js → chunk-CROKUHJB.js} +4 -4
  32. package/dist/chunk-DEWJMMG3.js +274 -0
  33. package/dist/chunk-DEWJMMG3.js.map +1 -0
  34. package/dist/{chunk-SEEA6RXF.js → chunk-DJZWYIPP.js} +2 -2
  35. package/dist/chunk-DJZWYIPP.js.map +1 -0
  36. package/dist/chunk-EVBCK2KC.js +68 -0
  37. package/dist/chunk-EVBCK2KC.js.map +1 -0
  38. package/dist/chunk-F3JWOPTG.js +202 -0
  39. package/dist/chunk-F3JWOPTG.js.map +1 -0
  40. package/dist/{chunk-GZNBCHAZ.js → chunk-HEWSRXYV.js} +4 -4
  41. package/dist/chunk-HHMMUR4P.js +335 -0
  42. package/dist/chunk-HHMMUR4P.js.map +1 -0
  43. package/dist/chunk-HSI6Q7MI.js +1238 -0
  44. package/dist/chunk-HSI6Q7MI.js.map +1 -0
  45. package/dist/{chunk-MPBXOJ3N.js → chunk-IY6256GK.js} +33 -2
  46. package/dist/chunk-IY6256GK.js.map +1 -0
  47. package/dist/{chunk-LS6QPANO.js → chunk-IYSEC543.js} +2 -2
  48. package/dist/{chunk-DC5H7KEQ.js → chunk-JHGHLKFS.js} +2 -2
  49. package/dist/{chunk-4DHQCN5H.js → chunk-LBYOLBW7.js} +17 -2
  50. package/dist/chunk-LBYOLBW7.js.map +1 -0
  51. package/dist/{chunk-6QRLRT3P.js → chunk-MT2JINSS.js} +16 -265
  52. package/dist/chunk-MT2JINSS.js.map +1 -0
  53. package/dist/{chunk-TP5M2NQB.js → chunk-NCMZMLY7.js} +3 -3
  54. package/dist/{chunk-COZPL62X.js → chunk-NDWYJLRF.js} +24 -139
  55. package/dist/chunk-NDWYJLRF.js.map +1 -0
  56. package/dist/{chunk-3YXDSCM6.js → chunk-NXNPDCF3.js} +29 -11
  57. package/dist/chunk-NXNPDCF3.js.map +1 -0
  58. package/dist/chunk-NYUYNYQL.js +48 -0
  59. package/dist/chunk-NYUYNYQL.js.map +1 -0
  60. package/dist/chunk-P6AORG32.js +415 -0
  61. package/dist/chunk-P6AORG32.js.map +1 -0
  62. package/dist/chunk-PTDMVGKL.js +31 -0
  63. package/dist/chunk-PTDMVGKL.js.map +1 -0
  64. package/dist/{chunk-D4FVAIEJ.js → chunk-PZR4EBPY.js} +5 -2
  65. package/dist/chunk-PZR4EBPY.js.map +1 -0
  66. package/dist/{chunk-LTB6KVJW.js → chunk-SDDPUAWH.js} +13 -11
  67. package/dist/{chunk-LTB6KVJW.js.map → chunk-SDDPUAWH.js.map} +1 -1
  68. package/dist/{chunk-WYYQK4D3.js → chunk-TD2NNXLZ.js} +13 -13
  69. package/dist/{chunk-ZMIPG6TR.js → chunk-TVL5MVUF.js} +4 -4
  70. package/dist/chunk-UDRHAQT7.js +172 -0
  71. package/dist/chunk-UDRHAQT7.js.map +1 -0
  72. package/dist/chunk-VAD43MOR.js +72 -0
  73. package/dist/chunk-VAD43MOR.js.map +1 -0
  74. package/dist/chunk-VSBFM7DS.js +229 -0
  75. package/dist/chunk-VSBFM7DS.js.map +1 -0
  76. package/dist/{chunk-VRWUL4TR.js → chunk-XHHMM5HX.js} +2 -2
  77. package/dist/{chunk-RJB3QUSP.js → chunk-XIDUYLSR.js} +3 -3
  78. package/dist/{chunk-QG6OSL4M.js → chunk-XUGSZP7Z.js} +2 -2
  79. package/dist/{chunk-S56JQJ4F.js → chunk-Y53WOQIJ.js} +3 -3
  80. package/dist/{chunk-62VF32IC.js → chunk-YFAXOD7Z.js} +10 -6
  81. package/dist/chunk-YFAXOD7Z.js.map +1 -0
  82. package/dist/{chunk-ICXV25N7.js → chunk-Z3KYFUN6.js} +3 -3
  83. package/dist/chunk-ZBDZTJUO.js +129 -0
  84. package/dist/chunk-ZBDZTJUO.js.map +1 -0
  85. package/dist/chunk-ZGBEIWSC.js +146 -0
  86. package/dist/chunk-ZGBEIWSC.js.map +1 -0
  87. package/dist/cli/bin.js +51 -28
  88. package/dist/cli/bin.js.map +1 -1
  89. package/dist/cli/commands/audit.d.ts +30 -1
  90. package/dist/cli/commands/audit.js +69 -33
  91. package/dist/cli/commands/audit.js.map +1 -1
  92. package/dist/client-AUE3XUPO.js +11 -0
  93. package/dist/{convert-to-pnpm-AT52NWZB.js → convert-to-pnpm-QFJOEP5V.js} +9 -9
  94. package/dist/db-UXNIZ2RG.js +217 -0
  95. package/dist/db-UXNIZ2RG.js.map +1 -0
  96. package/dist/{digest-UVHYZUYE.js → digest-FW6QMZZJ.js} +26 -22
  97. package/dist/{digest-UVHYZUYE.js.map → digest-FW6QMZZJ.js.map} +1 -1
  98. package/dist/dump-P6EBSIOH.js +68 -0
  99. package/dist/dump-P6EBSIOH.js.map +1 -0
  100. package/dist/email-KJXASVH5.js +195 -0
  101. package/dist/email-KJXASVH5.js.map +1 -0
  102. package/dist/{ensure-site-S25MDDHW.js → ensure-site-4DCEBAPM.js} +25 -16
  103. package/dist/ensure-site-4DCEBAPM.js.map +1 -0
  104. package/dist/fleet-events-cF2Om88w.d.ts +24 -0
  105. package/dist/fleet-state-7TJ2GYT2.js +39 -0
  106. package/dist/{forms-notify-target-TWN4EH6K.js → forms-notify-target-2EAWCWBW.js} +42 -133
  107. package/dist/forms-notify-target-2EAWCWBW.js.map +1 -0
  108. package/dist/{github-signals-G4ILZAJ6.js → github-signals-E2WPYTBK.js} +42 -21
  109. package/dist/github-signals-E2WPYTBK.js.map +1 -0
  110. package/dist/{header-image-T7XD37JL.js → header-image-6IC53W27.js} +36 -13
  111. package/dist/header-image-6IC53W27.js.map +1 -0
  112. package/dist/header-images-SQBCE5OD.js +88 -0
  113. package/dist/header-images-SQBCE5OD.js.map +1 -0
  114. package/dist/{health-endpoint-242WG2OJ.js → health-endpoint-RFXL2F4A.js} +9 -9
  115. package/dist/health-mirror-L5JBEJVA.js +41 -0
  116. package/dist/health-mirror-L5JBEJVA.js.map +1 -0
  117. package/dist/import-airtable-OHVY344G.js +37 -0
  118. package/dist/index.d.ts +324 -21
  119. package/dist/index.js +215 -76
  120. package/dist/index.js.map +1 -1
  121. package/dist/{init-JN26NTWF.js → init-XRAQZYID.js} +21 -18
  122. package/dist/{init-JN26NTWF.js.map → init-XRAQZYID.js.map} +1 -1
  123. package/dist/{launch-JA7Z4CIM.js → launch-IX4Y5JJC.js} +65 -30
  124. package/dist/launch-IX4Y5JJC.js.map +1 -0
  125. package/dist/lighthouse-VHI77PFB.js +11 -0
  126. package/dist/{lighthouse-airtable-BIPM26SZ.js → lighthouse-airtable-3GUWHETW.js} +3 -3
  127. package/dist/mailchimp-XYCTEEJY.js +72 -0
  128. package/dist/mailchimp-XYCTEEJY.js.map +1 -0
  129. package/dist/migrate-GQH237A6.js +7 -0
  130. package/dist/migrate-GQH237A6.js.map +1 -0
  131. package/dist/notify-C645PGD5.js +22 -0
  132. package/dist/notify-C645PGD5.js.map +1 -0
  133. package/dist/{onboard-53CITIM7.js → onboard-O4IAHJMO.js} +9 -9
  134. package/dist/orchestrate-YLWQ6KK2.js +31 -0
  135. package/dist/orchestrate-YLWQ6KK2.js.map +1 -0
  136. package/dist/parity-56KA2DGR.js +12 -0
  137. package/dist/parity-56KA2DGR.js.map +1 -0
  138. package/dist/pipeline-3AANZFCR.js +12 -0
  139. package/dist/pipeline-3AANZFCR.js.map +1 -0
  140. package/dist/{preflight-3XPDUHFJ.js → preflight-TNORKGCX.js} +17 -13
  141. package/dist/{preflight-3XPDUHFJ.js.map → preflight-TNORKGCX.js.map} +1 -1
  142. package/dist/{prismic-ci-UTQ6LRR3.js → prismic-ci-G2AK53VL.js} +12 -12
  143. package/dist/{prismic-models-PVES6R5V.js → prismic-models-O6Z3OK3A.js} +18 -13
  144. package/dist/prismic-models-O6Z3OK3A.js.map +1 -0
  145. package/dist/prospect/types.d.ts +267 -0
  146. package/dist/prospect/types.js +1 -0
  147. package/dist/prospect/types.js.map +1 -0
  148. package/dist/prospect-audit-KSXUNQ5J.js +174 -0
  149. package/dist/prospect-audit-KSXUNQ5J.js.map +1 -0
  150. package/dist/prospect-audits-3PMIO73D.js +19 -0
  151. package/dist/prospect-audits-3PMIO73D.js.map +1 -0
  152. package/dist/{protection-audit-64PLLBLI.js → protection-audit-SJO53MEQ.js} +3 -3
  153. package/dist/recipes/sync-configs.js +3 -3
  154. package/dist/render-IFOGFB5T.js +10 -0
  155. package/dist/render-IFOGFB5T.js.map +1 -0
  156. package/dist/render-from-row-BWSOIYDS.js +17 -0
  157. package/dist/render-from-row-BWSOIYDS.js.map +1 -0
  158. package/dist/{renovate-dispatch-ICJGSFCE.js → renovate-dispatch-6GQWTRKX.js} +12 -12
  159. package/dist/{renovate-dispatch-ICJGSFCE.js.map → renovate-dispatch-6GQWTRKX.js.map} +1 -1
  160. package/dist/replay-C2LXMJZP.js +66 -0
  161. package/dist/replay-C2LXMJZP.js.map +1 -0
  162. package/dist/{report-6CKSWJNG.js → report-R3E3Y7HR.js} +100 -32
  163. package/dist/report-R3E3Y7HR.js.map +1 -0
  164. package/dist/report-mirror-4MNORYQ4.js +49 -0
  165. package/dist/report-mirror-4MNORYQ4.js.map +1 -0
  166. package/dist/reports/header-image/assets/headline-announcement.png +0 -0
  167. package/dist/reports/header-image/assets/headline-launch.png +0 -0
  168. package/dist/reports/header-image/assets/headline-maintenance.png +0 -0
  169. package/dist/reports/header-image/assets/headline-testing.png +0 -0
  170. package/dist/reports/header-image/assets/plate-clean.png +0 -0
  171. package/dist/rerender-3FM6DNRH.js +32 -0
  172. package/dist/rerender-3FM6DNRH.js.map +1 -0
  173. package/dist/{self-updating-PPQVV6UA.js → self-updating-UCIWESAX.js} +9 -9
  174. package/dist/{selftest-CQ6NOX52.js → selftest-VGHWPN72.js} +31 -24
  175. package/dist/selftest-VGHWPN72.js.map +1 -0
  176. package/dist/site-mirror-NIREX5ME.js +52 -0
  177. package/dist/site-mirror-NIREX5ME.js.map +1 -0
  178. package/dist/smoke-B2GQOSIZ.js +20 -0
  179. package/dist/smoke-B2GQOSIZ.js.map +1 -0
  180. package/dist/{smoke-suite-TMCSOYRK.js → smoke-suite-U3UKD3NS.js} +9 -9
  181. package/dist/{submissions-GJ6ITBRH.js → submissions-IQEJX6FK.js} +3 -3
  182. package/dist/{svelte-codemods-ZOBDHIES.js → svelte-codemods-GOKXTBHW.js} +9 -9
  183. package/dist/sync-CTUVXMCK.js +44 -0
  184. package/dist/sync-CTUVXMCK.js.map +1 -0
  185. package/dist/{sync-configs-CJXUX6MD.js → sync-configs-SPTLFJCC.js} +9 -9
  186. package/dist/{upgrade-WLX27UPC.js → upgrade-W6ZR3FJH.js} +9 -9
  187. package/dist/webhook-VOGOW3W6.js +38 -0
  188. package/dist/webhook-VOGOW3W6.js.map +1 -0
  189. package/dist/{websites-SADZR4XQ.js → websites-D6WYFAES.js} +6 -2
  190. package/dist/websites-D6WYFAES.js.map +1 -0
  191. package/dist/{write-audits-to-airtable-7PH75YWY.js → write-audits-to-airtable-CMKHGJNQ.js} +4 -4
  192. package/dist/write-audits-to-airtable-CMKHGJNQ.js.map +1 -0
  193. package/package.json +9 -2
  194. package/dist/airtable-HRKSA7V4.js +0 -9
  195. package/dist/announce-GIAYN67S.js.map +0 -1
  196. package/dist/chunk-37S6L6G4.js.map +0 -1
  197. package/dist/chunk-3YXDSCM6.js.map +0 -1
  198. package/dist/chunk-4DHQCN5H.js.map +0 -1
  199. package/dist/chunk-4SOCPK7A.js +0 -108
  200. package/dist/chunk-4SOCPK7A.js.map +0 -1
  201. package/dist/chunk-577HCGZO.js.map +0 -1
  202. package/dist/chunk-62VF32IC.js.map +0 -1
  203. package/dist/chunk-6QRLRT3P.js.map +0 -1
  204. package/dist/chunk-COZPL62X.js.map +0 -1
  205. package/dist/chunk-D4FVAIEJ.js.map +0 -1
  206. package/dist/chunk-DTTIFYAM.js.map +0 -1
  207. package/dist/chunk-MGR2H7F4.js.map +0 -1
  208. package/dist/chunk-MPBXOJ3N.js.map +0 -1
  209. package/dist/chunk-NWB4O5EY.js.map +0 -1
  210. package/dist/chunk-SEEA6RXF.js.map +0 -1
  211. package/dist/chunk-U5SZ4FOP.js +0 -26
  212. package/dist/chunk-U5SZ4FOP.js.map +0 -1
  213. package/dist/client-X5WUJWDE.js +0 -11
  214. package/dist/db-JSIZYT23.js +0 -23
  215. package/dist/db-JSIZYT23.js.map +0 -1
  216. package/dist/ensure-site-S25MDDHW.js.map +0 -1
  217. package/dist/forms-notify-target-TWN4EH6K.js.map +0 -1
  218. package/dist/github-signals-G4ILZAJ6.js.map +0 -1
  219. package/dist/header-image-T7XD37JL.js.map +0 -1
  220. package/dist/launch-JA7Z4CIM.js.map +0 -1
  221. package/dist/migrate-LJUFGXQZ.js +0 -7
  222. package/dist/orchestrate-KDNXZQX7.js +0 -27
  223. package/dist/prismic-models-PVES6R5V.js.map +0 -1
  224. package/dist/report-6CKSWJNG.js.map +0 -1
  225. package/dist/reports/header-image/assets/plate.png +0 -0
  226. package/dist/selftest-CQ6NOX52.js.map +0 -1
  227. /package/dist/{airtable-HRKSA7V4.js.map → airtable-ZBUPQJSC.js.map} +0 -0
  228. /package/dist/{client-X5WUJWDE.js.map → attachments-NLZIJ5NU.js.map} +0 -0
  229. /package/dist/{bump-deps-SWJB6H5V.js.map → bump-deps-CGVELRLK.js.map} +0 -0
  230. /package/dist/{chunk-SDUJIANJ.js.map → chunk-2NJQLSEU.js.map} +0 -0
  231. /package/dist/{chunk-ZTPPCHDB.js.map → chunk-3G25KIWW.js.map} +0 -0
  232. /package/dist/{chunk-THEFTZ7F.js.map → chunk-62CMMGEO.js.map} +0 -0
  233. /package/dist/{chunk-FDIFTIXC.js.map → chunk-6TWWY7ZU.js.map} +0 -0
  234. /package/dist/{chunk-PBWDDDQH.js.map → chunk-7BZUOZPU.js.map} +0 -0
  235. /package/dist/{chunk-UYCAF77E.js.map → chunk-7RJ2HMMJ.js.map} +0 -0
  236. /package/dist/{chunk-Q276MZ7E.js.map → chunk-7UAYMAKR.js.map} +0 -0
  237. /package/dist/{chunk-R6FPFMOS.js.map → chunk-BQRFQSGY.js.map} +0 -0
  238. /package/dist/{chunk-7J53SUT6.js.map → chunk-CROKUHJB.js.map} +0 -0
  239. /package/dist/{chunk-GZNBCHAZ.js.map → chunk-HEWSRXYV.js.map} +0 -0
  240. /package/dist/{chunk-LS6QPANO.js.map → chunk-IYSEC543.js.map} +0 -0
  241. /package/dist/{chunk-DC5H7KEQ.js.map → chunk-JHGHLKFS.js.map} +0 -0
  242. /package/dist/{chunk-TP5M2NQB.js.map → chunk-NCMZMLY7.js.map} +0 -0
  243. /package/dist/{chunk-WYYQK4D3.js.map → chunk-TD2NNXLZ.js.map} +0 -0
  244. /package/dist/{chunk-ZMIPG6TR.js.map → chunk-TVL5MVUF.js.map} +0 -0
  245. /package/dist/{chunk-VRWUL4TR.js.map → chunk-XHHMM5HX.js.map} +0 -0
  246. /package/dist/{chunk-RJB3QUSP.js.map → chunk-XIDUYLSR.js.map} +0 -0
  247. /package/dist/{chunk-QG6OSL4M.js.map → chunk-XUGSZP7Z.js.map} +0 -0
  248. /package/dist/{chunk-S56JQJ4F.js.map → chunk-Y53WOQIJ.js.map} +0 -0
  249. /package/dist/{chunk-ICXV25N7.js.map → chunk-Z3KYFUN6.js.map} +0 -0
  250. /package/dist/{lighthouse-airtable-BIPM26SZ.js.map → client-AUE3XUPO.js.map} +0 -0
  251. /package/dist/{convert-to-pnpm-AT52NWZB.js.map → convert-to-pnpm-QFJOEP5V.js.map} +0 -0
  252. /package/dist/{migrate-LJUFGXQZ.js.map → fleet-state-7TJ2GYT2.js.map} +0 -0
  253. /package/dist/{health-endpoint-242WG2OJ.js.map → health-endpoint-RFXL2F4A.js.map} +0 -0
  254. /package/dist/{orchestrate-KDNXZQX7.js.map → import-airtable-OHVY344G.js.map} +0 -0
  255. /package/dist/{websites-SADZR4XQ.js.map → lighthouse-VHI77PFB.js.map} +0 -0
  256. /package/dist/{write-audits-to-airtable-7PH75YWY.js.map → lighthouse-airtable-3GUWHETW.js.map} +0 -0
  257. /package/dist/{onboard-53CITIM7.js.map → onboard-O4IAHJMO.js.map} +0 -0
  258. /package/dist/{prismic-ci-UTQ6LRR3.js.map → prismic-ci-G2AK53VL.js.map} +0 -0
  259. /package/dist/{protection-audit-64PLLBLI.js.map → protection-audit-SJO53MEQ.js.map} +0 -0
  260. /package/dist/{self-updating-PPQVV6UA.js.map → self-updating-UCIWESAX.js.map} +0 -0
  261. /package/dist/{smoke-suite-TMCSOYRK.js.map → smoke-suite-U3UKD3NS.js.map} +0 -0
  262. /package/dist/{submissions-GJ6ITBRH.js.map → submissions-IQEJX6FK.js.map} +0 -0
  263. /package/dist/{svelte-codemods-ZOBDHIES.js.map → svelte-codemods-GOKXTBHW.js.map} +0 -0
  264. /package/dist/{sync-configs-CJXUX6MD.js.map → sync-configs-SPTLFJCC.js.map} +0 -0
  265. /package/dist/{upgrade-WLX27UPC.js.map → upgrade-W6ZR3FJH.js.map} +0 -0
@@ -0,0 +1,229 @@
1
+ import {
2
+ hostsMatch
3
+ } from "./chunk-BNM5POSJ.js";
4
+ import {
5
+ operatorEmail
6
+ } from "./chunk-5ML7FMBD.js";
7
+ import {
8
+ escapeHtml
9
+ } from "./chunk-DJZWYIPP.js";
10
+
11
+ // src/forms/notify.ts
12
+ var FORMS_FROM = "forms@reddoorla.com";
13
+ var FALLBACK_REPLY_TO = "info@reddoorla.com";
14
+ function displayName(raw) {
15
+ return raw.replace(/["\r\n]/g, "").trim() || "Reddoor";
16
+ }
17
+ function pocAddress(site) {
18
+ return site.pointOfContact ?? site.reportRecipientsTo ?? null;
19
+ }
20
+ function normalizeRecipients(v) {
21
+ const arr = Array.isArray(v) ? v : v ? [v] : [];
22
+ const seen = /* @__PURE__ */ new Set();
23
+ const out = [];
24
+ for (const raw of arr) {
25
+ if (typeof raw !== "string") continue;
26
+ const t = raw.trim();
27
+ if (t && !seen.has(t)) {
28
+ seen.add(t);
29
+ out.push(t);
30
+ }
31
+ }
32
+ return out;
33
+ }
34
+ function resolveRecipients(site, submission) {
35
+ if (site.status !== "maintained") {
36
+ return { to: [operatorEmail()], cc: [] };
37
+ }
38
+ const routing = site.notifyRouting;
39
+ if (routing) {
40
+ const value = parseExtraFields(submission.extraFields)[routing.field];
41
+ const match = typeof value === "string" ? routing.routes[value] : void 0;
42
+ const to = normalizeRecipients(match ?? routing.default);
43
+ if (to.length > 0) return { to, cc: normalizeRecipients(routing.cc) };
44
+ }
45
+ const poc = pocAddress(site);
46
+ return poc ? { to: [poc], cc: [] } : null;
47
+ }
48
+ function probeSubmission(extraFields) {
49
+ return {
50
+ id: "probe",
51
+ submissionId: null,
52
+ siteId: "probe",
53
+ formType: "contact",
54
+ name: "probe",
55
+ email: "probe@example.com",
56
+ phone: null,
57
+ message: null,
58
+ extraFields,
59
+ sourceUrl: null,
60
+ utm: null,
61
+ submittedAt: null,
62
+ status: "new",
63
+ notifyStatus: "pending",
64
+ resendMessageId: null
65
+ };
66
+ }
67
+ function describeNotifyTarget(site) {
68
+ if (site.status !== "maintained") {
69
+ const guarded = resolveRecipients(site, probeSubmission(null));
70
+ return {
71
+ audience: "operator",
72
+ to: guarded?.to ?? [],
73
+ cc: [],
74
+ reason: `Status is ${site.status === null ? "blank" : `"${site.status}"`}, not "maintained" \u2014 the pre-launch guard routes every notification to the operator.`
75
+ };
76
+ }
77
+ const routing = site.notifyRouting;
78
+ if (!routing) {
79
+ const direct = resolveRecipients(site, probeSubmission(null));
80
+ if (!direct || direct.to.length === 0) {
81
+ return {
82
+ audience: "nobody",
83
+ to: [],
84
+ cc: [],
85
+ reason: `Status is "maintained" but the site has no Point of Contact and no Notify Routing \u2014 the lead is stored and no notification is sent.`
86
+ };
87
+ }
88
+ return {
89
+ audience: "client",
90
+ to: direct.to,
91
+ cc: direct.cc,
92
+ reason: `Status is "maintained" \u2014 notifications go to the site's Point of Contact.`
93
+ };
94
+ }
95
+ const to = /* @__PURE__ */ new Set();
96
+ const cc = /* @__PURE__ */ new Set();
97
+ const values = [...Object.keys(routing.routes), "\0no-such-route"];
98
+ for (const value of values) {
99
+ const r = resolveRecipients(site, probeSubmission(JSON.stringify({ [routing.field]: value })));
100
+ r?.to.forEach((a) => to.add(a));
101
+ r?.cc.forEach((a) => cc.add(a));
102
+ }
103
+ if (to.size === 0) {
104
+ return {
105
+ audience: "nobody",
106
+ to: [],
107
+ cc: [],
108
+ reason: `Status is "maintained" with Notify Routing on "${routing.field}", but no route, default or Point of Contact resolves to an address.`
109
+ };
110
+ }
111
+ return {
112
+ audience: "client",
113
+ to: [...to],
114
+ cc: [...cc],
115
+ reason: `Status is "maintained" with Notify Routing on "${routing.field}" \u2014 the recipient depends on that field's value; every address it could reach is listed.`
116
+ };
117
+ }
118
+ function humanizeKey(k) {
119
+ const spaced = k.replace(/[_-]+/g, " ").trim();
120
+ return spaced ? spaced.charAt(0).toUpperCase() + spaced.slice(1) : k;
121
+ }
122
+ function formatValue(v) {
123
+ if (v === null || v === void 0) return "\u2014";
124
+ if (typeof v === "string") return v;
125
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
126
+ return JSON.stringify(v);
127
+ }
128
+ function parseExtraFields(raw) {
129
+ if (!raw) return {};
130
+ try {
131
+ const parsed = JSON.parse(raw);
132
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
133
+ return parsed;
134
+ }
135
+ } catch {
136
+ }
137
+ return {};
138
+ }
139
+ function extraFieldRows(raw) {
140
+ return Object.entries(parseExtraFields(raw)).filter(([, v]) => !(typeof v === "string" && v.trim() === "")).map(([k, v]) => [humanizeKey(k), formatValue(v)]);
141
+ }
142
+ function fieldsTable(submission) {
143
+ const rows = [
144
+ ["Form", submission.formType],
145
+ ["Name", submission.name || "\u2014"],
146
+ ["Email", submission.email || "\u2014"]
147
+ ];
148
+ if (submission.phone) rows.push(["Phone", submission.phone]);
149
+ rows.push(...extraFieldRows(submission.extraFields));
150
+ if (submission.sourceUrl) rows.push(["Page", submission.sourceUrl]);
151
+ if (submission.utm) rows.push(["UTM", submission.utm]);
152
+ const body = rows.map(([k, v]) => `<tr><td><strong>${escapeHtml(k)}</strong></td><td>${escapeHtml(v)}</td></tr>`).join("");
153
+ const message = submission.message ? `<p style="white-space:pre-wrap">${escapeHtml(submission.message)}</p>` : "";
154
+ return `<table>${body}</table>${message}`;
155
+ }
156
+ function buildPocNotification(site, submission) {
157
+ if (submission.status === "spam_auto" || submission.status === "spam") return null;
158
+ const recipients = resolveRecipients(site, submission);
159
+ if (!recipients || recipients.to.length === 0) return null;
160
+ const input = {
161
+ from: `${displayName(site.name)} Forms <${FORMS_FROM}>`,
162
+ to: recipients.to,
163
+ subject: `New ${submission.formType} from ${site.name}`,
164
+ html: `<h2>New ${escapeHtml(submission.formType)} submission \u2014 ${escapeHtml(
165
+ site.name
166
+ )}</h2>${fieldsTable(submission)}`
167
+ };
168
+ if (recipients.cc.length > 0) input.cc = recipients.cc;
169
+ if (submission.email) input.replyTo = submission.email;
170
+ return input;
171
+ }
172
+ function buildAutoresponder(site, submission) {
173
+ if (submission.status === "spam_auto" || submission.status === "spam") return null;
174
+ if (!submission.email) return null;
175
+ const emailDomain = submission.email.split("@").pop() ?? "";
176
+ let siteHost = "";
177
+ try {
178
+ siteHost = new URL(site.url).hostname;
179
+ } catch {
180
+ }
181
+ if (siteHost && hostsMatch(emailDomain, siteHost)) return null;
182
+ const intro = site.copyIntro ?? `Thanks for reaching out to ${site.name}.`;
183
+ const contact = site.copyContact ?? "We've received your message and will be in touch soon.";
184
+ const footer = site.copyFooter ?? site.name;
185
+ return {
186
+ from: `${displayName(site.name)} <${FORMS_FROM}>`,
187
+ to: [submission.email],
188
+ replyTo: resolveRecipients(site, submission)?.to[0] ?? FALLBACK_REPLY_TO,
189
+ subject: "We got your message",
190
+ html: `<p>${escapeHtml(intro)}</p><p>${escapeHtml(contact)}</p><p>${escapeHtml(footer)}</p>`
191
+ };
192
+ }
193
+ async function notifySubmission(deps, site, submission) {
194
+ const poc = buildPocNotification(site, submission);
195
+ let outcome;
196
+ if (!poc) {
197
+ outcome = { status: "skipped", messageId: null };
198
+ } else {
199
+ try {
200
+ const { messageId } = await deps.send(poc);
201
+ outcome = { status: "sent", messageId };
202
+ } catch (err) {
203
+ console.error(`[submissions] POC notification failed: ${String(err)}`);
204
+ outcome = { status: "failed", messageId: null };
205
+ }
206
+ }
207
+ const auto = buildAutoresponder(site, submission);
208
+ if (auto) {
209
+ try {
210
+ await deps.send(auto);
211
+ } catch (err) {
212
+ console.error(`[submissions] autoresponder failed: ${String(err)}`);
213
+ }
214
+ }
215
+ return outcome;
216
+ }
217
+ function makeNotify(send) {
218
+ return (site, submission) => send ? notifySubmission({ send }, site, submission) : Promise.resolve({ status: "failed", messageId: null });
219
+ }
220
+
221
+ export {
222
+ resolveRecipients,
223
+ describeNotifyTarget,
224
+ buildPocNotification,
225
+ buildAutoresponder,
226
+ notifySubmission,
227
+ makeNotify
228
+ };
229
+ //# sourceMappingURL=chunk-VSBFM7DS.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/forms/notify.ts"],"sourcesContent":["import type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport type { SubmissionRow, NotifyStatus } from \"../reports/submission-row.js\";\nimport type { ResendSendInput } from \"../reports/send/resend.js\";\nimport { hostsMatch } from \"./ingest.js\";\nimport { escapeHtml } from \"../util/html.js\";\nimport { operatorEmail } from \"../util/operator.js\";\n\nconst FORMS_FROM = \"forms@reddoorla.com\";\n// Reply-To only: a client replying to a lead notification should reach the\n// shared inbox. The RECIPIENT fallback is the operator's own — see util/operator.\nconst FALLBACK_REPLY_TO = \"info@reddoorla.com\";\n\n/** Strip characters that would break an RFC 5322 display name. */\nfunction displayName(raw: string): string {\n return raw.replace(/[\"\\r\\n]/g, \"\").trim() || \"Reddoor\";\n}\n\nfunction pocAddress(site: WebsiteRow): string | null {\n return site.pointOfContact ?? site.reportRecipientsTo ?? null;\n}\n\n/** Coerce a string|string[] recipient config into a clean, de-duped address list. */\nfunction normalizeRecipients(v: string | string[] | undefined): string[] {\n const arr = Array.isArray(v) ? v : v ? [v] : [];\n const seen = new Set<string>();\n const out: string[] = [];\n for (const raw of arr) {\n if (typeof raw !== \"string\") continue;\n const t = raw.trim();\n if (t && !seen.has(t)) {\n seen.add(t);\n out.push(t);\n }\n }\n return out;\n}\n\nexport type Recipients = { to: string[]; cc: string[] };\n\n/**\n * Where a submission notification goes.\n * - Pre-launch (status !== \"maintained\"): the operator only — no routing, no CC.\n * Preserves the verify guard (flip a site to \"launching\" to route tests to\n * yourself).\n * - Maintained + a `Notify Routing` config: address by the routing field's value\n * (`extraFields[field]`) → its matched route, else the config `default`; CC from\n * the config. If nothing resolves, fall through to the single POC.\n * - Maintained, no routing: the single site POC (pointOfContact ?? reportRecipientsTo).\n * Returns null only when nothing resolves — the lead is still persisted; notify skips.\n */\nexport function resolveRecipients(site: WebsiteRow, submission: SubmissionRow): Recipients | null {\n if (site.status !== \"maintained\") {\n return { to: [operatorEmail()], cc: [] };\n }\n const routing = site.notifyRouting;\n if (routing) {\n const value = parseExtraFields(submission.extraFields)[routing.field];\n const match = typeof value === \"string\" ? routing.routes[value] : undefined;\n const to = normalizeRecipients(match ?? routing.default);\n if (to.length > 0) return { to, cc: normalizeRecipients(routing.cc) };\n // routing matched nothing → fall through to the POC below\n }\n const poc = pocAddress(site);\n return poc ? { to: [poc], cc: [] } : null;\n}\n\n/** Who a submission to this site would notify, answered WITHOUT sending one. */\nexport type NotifyTarget = {\n /** `client` means a real point of contact receives it. `operator` means the\n * pre-launch guard is holding and only you do. `nobody` means nothing\n * resolves — the lead is still stored, but no notification goes out. */\n audience: \"client\" | \"operator\" | \"nobody\";\n /** Every address a submission could reach, across ALL routing branches. */\n to: string[];\n cc: string[];\n /** Plain-language reason, naming the field that decided it. */\n reason: string;\n};\n\n/** A submission that exists only to ask resolveRecipients a question. */\nfunction probeSubmission(extraFields: string | null): SubmissionRow {\n return {\n id: \"probe\",\n submissionId: null,\n siteId: \"probe\",\n formType: \"contact\" as SubmissionRow[\"formType\"],\n name: \"probe\",\n email: \"probe@example.com\",\n phone: null,\n message: null,\n extraFields,\n sourceUrl: null,\n utm: null,\n submittedAt: null,\n status: \"new\" as SubmissionRow[\"status\"],\n notifyStatus: \"pending\" as NotifyStatus,\n resendMessageId: null,\n };\n}\n\n/**\n * Resolve who a form submission would notify, for a site, right now.\n *\n * This exists because the pre-launch guard had no feedback. Its whole state is\n * one Airtable `Status` cell nobody is looking at while testing, the site\n * reports nothing about it, and the only way to learn the answer was to submit\n * a form and read the `resend_message_id` afterwards. On 2026-08-03 an intended\n * flip never landed and a real client received a test lead — an email cannot be\n * recalled. A guard whose state you cannot see is a guard you will eventually\n * assume is on.\n *\n * Every address here comes back out of `resolveRecipients` itself rather than\n * from a second reading of the same rules — for a routed site each branch is\n * probed with a synthetic submission and the results unioned. A copy of the\n * routing logic that drifted would be worse than no answer at all, because it\n * would be a confident wrong one.\n */\nexport function describeNotifyTarget(site: WebsiteRow): NotifyTarget {\n if (site.status !== \"maintained\") {\n const guarded = resolveRecipients(site, probeSubmission(null));\n return {\n audience: \"operator\",\n to: guarded?.to ?? [],\n cc: [],\n reason: `Status is ${site.status === null ? \"blank\" : `\"${site.status}\"`}, not \"maintained\" — the pre-launch guard routes every notification to the operator.`,\n };\n }\n\n const routing = site.notifyRouting;\n if (!routing) {\n const direct = resolveRecipients(site, probeSubmission(null));\n if (!direct || direct.to.length === 0) {\n return {\n audience: \"nobody\",\n to: [],\n cc: [],\n reason: `Status is \"maintained\" but the site has no Point of Contact and no Notify Routing — the lead is stored and no notification is sent.`,\n };\n }\n return {\n audience: \"client\",\n to: direct.to,\n cc: direct.cc,\n reason: `Status is \"maintained\" — notifications go to the site's Point of Contact.`,\n };\n }\n\n // Probe every declared route value, plus one value that matches none so the\n // `default` (and the POC fall-through behind it) is covered too.\n const to = new Set<string>();\n const cc = new Set<string>();\n const values = [...Object.keys(routing.routes), \"\u0000no-such-route\"];\n for (const value of values) {\n const r = resolveRecipients(site, probeSubmission(JSON.stringify({ [routing.field]: value })));\n r?.to.forEach((a) => to.add(a));\n r?.cc.forEach((a) => cc.add(a));\n }\n if (to.size === 0) {\n return {\n audience: \"nobody\",\n to: [],\n cc: [],\n reason: `Status is \"maintained\" with Notify Routing on \"${routing.field}\", but no route, default or Point of Contact resolves to an address.`,\n };\n }\n return {\n audience: \"client\",\n to: [...to],\n cc: [...cc],\n reason: `Status is \"maintained\" with Notify Routing on \"${routing.field}\" — the recipient depends on that field's value; every address it could reach is listed.`,\n };\n}\n\n/** Humanize an extraFields key for display: \"appointment_date\" → \"Appointment date\". */\nfunction humanizeKey(k: string): string {\n const spaced = k.replace(/[_-]+/g, \" \").trim();\n return spaced ? spaced.charAt(0).toUpperCase() + spaced.slice(1) : k;\n}\n\nfunction formatValue(v: unknown): string {\n if (v === null || v === undefined) return \"—\";\n if (typeof v === \"string\") return v;\n if (typeof v === \"number\" || typeof v === \"boolean\") return String(v);\n return JSON.stringify(v);\n}\n\n/** Parse the stored `extraFields` JSON into a plain object — bad JSON or a\n * non-object yields {} (never throws). Shared by the email renderer and routing. */\nfunction parseExtraFields(raw: string | null): Record<string, unknown> {\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n } catch {\n /* malformed JSON → no fields */\n }\n return {};\n}\n\nfunction extraFieldRows(raw: string | null): Array<[string, string]> {\n return Object.entries(parseExtraFields(raw))\n .filter(([, v]) => !(typeof v === \"string\" && v.trim() === \"\"))\n .map(([k, v]) => [humanizeKey(k), formatValue(v)] as [string, string]);\n}\n\nfunction fieldsTable(submission: SubmissionRow): string {\n const rows: Array<[string, string]> = [\n [\"Form\", submission.formType],\n [\"Name\", submission.name || \"—\"],\n [\"Email\", submission.email || \"—\"],\n ];\n if (submission.phone) rows.push([\"Phone\", submission.phone]);\n // Site-specific context (the artwork an inquiry is about, the event an rsvp is\n // for, etc.) lives in extraFields — surface it so the recipient sees what the\n // submitter was looking at, not just their name and email.\n rows.push(...extraFieldRows(submission.extraFields));\n if (submission.sourceUrl) rows.push([\"Page\", submission.sourceUrl]);\n if (submission.utm) rows.push([\"UTM\", submission.utm]);\n const body = rows\n .map(([k, v]) => `<tr><td><strong>${escapeHtml(k)}</strong></td><td>${escapeHtml(v)}</td></tr>`)\n .join(\"\");\n const message = submission.message\n ? `<p style=\"white-space:pre-wrap\">${escapeHtml(submission.message)}</p>`\n : \"\";\n return `<table>${body}</table>${message}`;\n}\n\n/** POC notification — the primary email; null when the site has no contact address. */\nexport function buildPocNotification(\n site: WebsiteRow,\n submission: SubmissionRow,\n): ResendSendInput | null {\n if (submission.status === \"spam_auto\" || submission.status === \"spam\") return null;\n const recipients = resolveRecipients(site, submission);\n if (!recipients || recipients.to.length === 0) return null;\n const input: ResendSendInput = {\n from: `${displayName(site.name)} Forms <${FORMS_FROM}>`,\n to: recipients.to,\n subject: `New ${submission.formType} from ${site.name}`,\n html: `<h2>New ${escapeHtml(submission.formType)} submission — ${escapeHtml(\n site.name,\n )}</h2>${fieldsTable(submission)}`,\n };\n if (recipients.cc.length > 0) input.cc = recipients.cc;\n // Reply straight to the lead.\n if (submission.email) input.replyTo = submission.email;\n return input;\n}\n\n/** Autoresponder to the submitter — null when there's no submitter email. */\nexport function buildAutoresponder(\n site: WebsiteRow,\n submission: SubmissionRow,\n): ResendSendInput | null {\n if (submission.status === \"spam_auto\" || submission.status === \"spam\") return null;\n if (!submission.email) return null;\n // A submitter email on the site's OWN domain is never a real lead wanting a\n // confirmation — it is the spoofed-sender backscatter case (a bot writes the\n // site's info@ as its email and the \"We got your message\" lands in the\n // client's inbox). The POC notification still sends; only this email is\n // suppressed. Unparseable/blank site url → fail open and send, same\n // philosophy as turnstileHostnameAcceptable.\n const emailDomain = submission.email.split(\"@\").pop() ?? \"\";\n let siteHost = \"\";\n try {\n siteHost = new URL(site.url).hostname;\n } catch {\n /* fail open */\n }\n if (siteHost && hostsMatch(emailDomain, siteHost)) return null;\n const intro = site.copyIntro ?? `Thanks for reaching out to ${site.name}.`;\n const contact = site.copyContact ?? \"We've received your message and will be in touch soon.\";\n const footer = site.copyFooter ?? site.name;\n return {\n from: `${displayName(site.name)} <${FORMS_FROM}>`,\n to: [submission.email],\n replyTo: resolveRecipients(site, submission)?.to[0] ?? FALLBACK_REPLY_TO,\n subject: \"We got your message\",\n html: `<p>${escapeHtml(intro)}</p><p>${escapeHtml(contact)}</p><p>${escapeHtml(footer)}</p>`,\n };\n}\n\nexport type NotifyDeps = {\n send: (input: ResendSendInput) => Promise<{ messageId: string }>;\n};\n\nexport type NotifyOutcome = { status: NotifyStatus; messageId: string | null };\n\n/**\n * Send the POC notification (primary — drives notifyStatus) then the submitter\n * autoresponder (best-effort — logged, never changes the outcome). The submission\n * is already persisted before this runs, so a Resend outage degrades to\n * notifyStatus=\"failed\", never a lost lead.\n */\nexport async function notifySubmission(\n deps: NotifyDeps,\n site: WebsiteRow,\n submission: SubmissionRow,\n): Promise<NotifyOutcome> {\n const poc = buildPocNotification(site, submission);\n let outcome: NotifyOutcome;\n if (!poc) {\n outcome = { status: \"skipped\", messageId: null };\n } else {\n try {\n const { messageId } = await deps.send(poc);\n outcome = { status: \"sent\", messageId };\n } catch (err) {\n console.error(`[submissions] POC notification failed: ${String(err)}`);\n outcome = { status: \"failed\", messageId: null };\n }\n }\n const auto = buildAutoresponder(site, submission);\n if (auto) {\n try {\n await deps.send(auto);\n } catch (err) {\n console.error(`[submissions] autoresponder failed: ${String(err)}`);\n }\n }\n return outcome;\n}\n\n/**\n * Build the ingest `notify` dependency from a Resend send fn — or `null` when the\n * Resend client couldn't even be constructed (e.g. `RESEND_API_KEY` unset). A null\n * send marks the notification `failed` WITHOUT attempting it, so a Resend\n * misconfiguration degrades to a captured-but-unemailed lead rather than aborting\n * ingest and losing it. Mirrors the in-flight failure isolation in notifySubmission.\n */\nexport function makeNotify(\n send: NotifyDeps[\"send\"] | null,\n): (site: WebsiteRow, submission: SubmissionRow) => Promise<NotifyOutcome> {\n return (site, submission) =>\n send\n ? notifySubmission({ send }, site, submission)\n : Promise.resolve({ status: \"failed\", messageId: null });\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  defaultSpawn
3
- } from "./chunk-4DHQCN5H.js";
3
+ } from "./chunk-LBYOLBW7.js";
4
4
 
5
5
  // src/github/gh.ts
6
6
  import { mkdtemp, rm, writeFile } from "fs/promises";
@@ -442,4 +442,4 @@ export {
442
442
  assertUrlSegment,
443
443
  makeGitHub
444
444
  };
445
- //# sourceMappingURL=chunk-VRWUL4TR.js.map
445
+ //# sourceMappingURL=chunk-XHHMM5HX.js.map
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  withRecipe
3
- } from "./chunk-ZTPPCHDB.js";
3
+ } from "./chunk-3G25KIWW.js";
4
4
  import {
5
5
  readPackageJson,
6
6
  writePackageJson
7
7
  } from "./chunk-OTGB6YQO.js";
8
8
  import {
9
9
  defaultSpawn
10
- } from "./chunk-4DHQCN5H.js";
10
+ } from "./chunk-LBYOLBW7.js";
11
11
 
12
12
  // src/recipes/convert-to-pnpm.ts
13
13
  import { rm, stat } from "fs/promises";
@@ -96,4 +96,4 @@ async function convertToPnpm(site, opts = {}) {
96
96
  export {
97
97
  convertToPnpm
98
98
  };
99
- //# sourceMappingURL=chunk-RJB3QUSP.js.map
99
+ //# sourceMappingURL=chunk-XIDUYLSR.js.map
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  openDb,
3
3
  readDbConfig
4
- } from "./chunk-DC5H7KEQ.js";
4
+ } from "./chunk-JHGHLKFS.js";
5
5
 
6
6
  // src/db/fleet-events.ts
7
7
  async function recordFleetEvent(db, e) {
@@ -47,4 +47,4 @@ async function recordFleetEventsBestEffort(events, now, open = () => openDb(read
47
47
  export {
48
48
  recordFleetEventsBestEffort
49
49
  };
50
- //# sourceMappingURL=chunk-QG6OSL4M.js.map
50
+ //# sourceMappingURL=chunk-XUGSZP7Z.js.map
@@ -4,10 +4,10 @@ import {
4
4
  } from "./chunk-R53VL3RI.js";
5
5
  import {
6
6
  withRecipe
7
- } from "./chunk-ZTPPCHDB.js";
7
+ } from "./chunk-3G25KIWW.js";
8
8
  import {
9
9
  defaultSpawn
10
- } from "./chunk-4DHQCN5H.js";
10
+ } from "./chunk-LBYOLBW7.js";
11
11
 
12
12
  // src/recipes/smoke-suite/index.ts
13
13
  import { access, mkdir, readdir, readFile, writeFile } from "fs/promises";
@@ -315,4 +315,4 @@ async function smokeSuite(site, deps = { spawn: defaultSpawn }) {
315
315
  export {
316
316
  smokeSuite
317
317
  };
318
- //# sourceMappingURL=chunk-S56JQJ4F.js.map
318
+ //# sourceMappingURL=chunk-Y53WOQIJ.js.map
@@ -2,7 +2,7 @@ import {
2
2
  isPendingApproval,
3
3
  listReportsForSite,
4
4
  setDraftReady
5
- } from "./chunk-6QRLRT3P.js";
5
+ } from "./chunk-F3JWOPTG.js";
6
6
 
7
7
  // src/reports/queue.ts
8
8
  var REPORT_TIER = {
@@ -14,20 +14,24 @@ var REPORT_TIER = {
14
14
  function reportTier(type) {
15
15
  return REPORT_TIER[type];
16
16
  }
17
- async function queueDraft(base, report) {
17
+ async function queueDraft(base, report, mirror) {
18
+ const setReady = async (id, ready) => {
19
+ await setDraftReady(base, id, ready);
20
+ await mirror?.patch(id, { draft_ready: ready ? 1 : 0 });
21
+ };
18
22
  const newTier = reportTier(report.reportType);
19
23
  const others = (await listReportsForSite(base, report.siteId)).filter(isPendingApproval).filter((r) => r.id !== report.id);
20
24
  const blocker = others.find((r) => reportTier(r.reportType) >= newTier);
21
25
  if (blocker) {
22
- await setDraftReady(base, report.id, false);
26
+ await setReady(report.id, false);
23
27
  return { queued: false, blockedBy: blocker.reportType, supersededIds: [] };
24
28
  }
25
29
  const supersededIds = [];
26
30
  for (const r of others) {
27
- await setDraftReady(base, r.id, false);
31
+ await setReady(r.id, false);
28
32
  supersededIds.push(r.id);
29
33
  }
30
- await setDraftReady(base, report.id, true);
34
+ await setReady(report.id, true);
31
35
  return { queued: true, supersededIds };
32
36
  }
33
37
 
@@ -35,4 +39,4 @@ export {
35
39
  reportTier,
36
40
  queueDraft
37
41
  };
38
- //# sourceMappingURL=chunk-62VF32IC.js.map
42
+ //# sourceMappingURL=chunk-YFAXOD7Z.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/queue.ts"],"sourcesContent":["import type { ReportType } from \"./types.js\";\nimport type { AirtableBase } from \"./airtable/client.js\";\nimport { listReportsForSite, isPendingApproval, setDraftReady } from \"./airtable/reports.js\";\nimport type { ReportMirror } from \"./report-mirror.js\";\n\n/**\n * Report tiers — a higher tier is a SUPERSET of the lower ones, so only the highest-tier\n * un-sent draft is worth keeping in a site's approve queue. Maintenance ⊂ Testing ⊂\n * {Announcement, Launch}. Announcement and Launch share tier 3 (neither supersets the other).\n */\nexport const REPORT_TIER: Record<ReportType, number> = {\n Maintenance: 1,\n Testing: 2,\n Announcement: 3,\n Launch: 3,\n};\n\nexport function reportTier(type: ReportType): number {\n return REPORT_TIER[type];\n}\n\nexport type QueueOutcome = {\n /** Whether `report` was placed in (kept in) the approve queue. */\n queued: boolean;\n /** When `queued` is false, the queued report type that subsumes this one. */\n blockedBy?: ReportType;\n /** Ids of lower-tier queued reports that this one superseded (un-queued). */\n supersededIds: string[];\n};\n\n/**\n * Enforce \"one report queued per site, highest tier wins\" at draft time. Given a freshly\n * created/reused report, look at the OTHER reports already pending approval for the same site\n * (Draft ready ∧ ¬Approved ∧ ¬Sent) and:\n *\n * - If any is of an EQUAL-OR-HIGHER tier → the new report is subsumed: leave it un-queued\n * (Draft ready = false) and report `blockedBy`. (Equal-tier, e.g. a queued Launch when an\n * Announcement is drafted, keeps the existing one rather than silently replacing it.)\n * - Otherwise the new report is strictly the highest → un-queue every (strictly lower) pending\n * report for the site (superseded, not deleted — the row is kept) and queue the new one.\n *\n * Returns what happened so the caller can surface it. PURE side effects are all `setDraftReady`.\n *\n * #539 Phase 5: each flag is mirrored into Turso. The SUPERSEDED rows matter as\n * much as the new one — un-queueing them is the whole point of this function, so\n * a mirror covering only `report.id` would leave the console showing a site with\n * two queued reports until the next hourly sync.\n */\nexport async function queueDraft(\n base: AirtableBase,\n report: { id: string; siteId: string; reportType: ReportType },\n mirror?: ReportMirror,\n): Promise<QueueOutcome> {\n const setReady = async (id: string, ready: boolean): Promise<void> => {\n await setDraftReady(base, id, ready);\n await mirror?.patch(id, { draft_ready: ready ? 1 : 0 });\n };\n const newTier = reportTier(report.reportType);\n const others = (await listReportsForSite(base, report.siteId))\n .filter(isPendingApproval)\n .filter((r) => r.id !== report.id);\n\n const blocker = others.find((r) => reportTier(r.reportType) >= newTier);\n if (blocker) {\n // A queued report already covers this one. Make sure the new draft is NOT queued (the reuse\n // path may hand us a row that was Draft-ready from a prior run) and stand down.\n await setReady(report.id, false);\n return { queued: false, blockedBy: blocker.reportType, supersededIds: [] };\n }\n\n // The new report is strictly the highest-tier pending — supersede the rest (un-queue), keep it.\n const supersededIds: string[] = [];\n for (const r of others) {\n await setReady(r.id, false);\n supersededIds.push(r.id);\n }\n await setReady(report.id, true);\n return { queued: true, supersededIds };\n}\n"],"mappings":";;;;;;;AAUO,IAAM,cAA0C;AAAA,EACrD,aAAa;AAAA,EACb,SAAS;AAAA,EACT,cAAc;AAAA,EACd,QAAQ;AACV;AAEO,SAAS,WAAW,MAA0B;AACnD,SAAO,YAAY,IAAI;AACzB;AA6BA,eAAsB,WACpB,MACA,QACA,QACuB;AACvB,QAAM,WAAW,OAAO,IAAY,UAAkC;AACpE,UAAM,cAAc,MAAM,IAAI,KAAK;AACnC,UAAM,QAAQ,MAAM,IAAI,EAAE,aAAa,QAAQ,IAAI,EAAE,CAAC;AAAA,EACxD;AACA,QAAM,UAAU,WAAW,OAAO,UAAU;AAC5C,QAAM,UAAU,MAAM,mBAAmB,MAAM,OAAO,MAAM,GACzD,OAAO,iBAAiB,EACxB,OAAO,CAAC,MAAM,EAAE,OAAO,OAAO,EAAE;AAEnC,QAAM,UAAU,OAAO,KAAK,CAAC,MAAM,WAAW,EAAE,UAAU,KAAK,OAAO;AACtE,MAAI,SAAS;AAGX,UAAM,SAAS,OAAO,IAAI,KAAK;AAC/B,WAAO,EAAE,QAAQ,OAAO,WAAW,QAAQ,YAAY,eAAe,CAAC,EAAE;AAAA,EAC3E;AAGA,QAAM,gBAA0B,CAAC;AACjC,aAAW,KAAK,QAAQ;AACtB,UAAM,SAAS,EAAE,IAAI,KAAK;AAC1B,kBAAc,KAAK,EAAE,EAAE;AAAA,EACzB;AACA,QAAM,SAAS,OAAO,IAAI,IAAI;AAC9B,SAAO,EAAE,QAAQ,MAAM,cAAc;AACvC;","names":[]}
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-J7SZQUCW.js";
4
4
  import {
5
5
  withRecipe
6
- } from "./chunk-ZTPPCHDB.js";
6
+ } from "./chunk-3G25KIWW.js";
7
7
  import {
8
8
  bumpDep,
9
9
  readPackageJson,
@@ -11,7 +11,7 @@ import {
11
11
  } from "./chunk-OTGB6YQO.js";
12
12
  import {
13
13
  defaultSpawn
14
- } from "./chunk-4DHQCN5H.js";
14
+ } from "./chunk-LBYOLBW7.js";
15
15
 
16
16
  // src/recipes/svelte-5/index.ts
17
17
  import { join as join5 } from "path";
@@ -243,4 +243,4 @@ async function upgradeSvelte4to5(site, opts = {}) {
243
243
  export {
244
244
  upgradeSvelte4to5
245
245
  };
246
- //# sourceMappingURL=chunk-ICXV25N7.js.map
246
+ //# sourceMappingURL=chunk-Z3KYFUN6.js.map
@@ -0,0 +1,129 @@
1
+ import {
2
+ mapReportRecord,
3
+ mapWebsiteRecord
4
+ } from "./chunk-P6AORG32.js";
5
+
6
+ // src/db/parity.ts
7
+ var SKIP_COLUMNS = /* @__PURE__ */ new Set([
8
+ "computed_at",
9
+ "header_image",
10
+ "header_image_filename",
11
+ "header_image_type",
12
+ "header_image_generated_at",
13
+ "rendered_html"
14
+ ]);
15
+ var show = (v) => {
16
+ if (v === null || v === void 0) return "\u2205";
17
+ const str = typeof v === "string" ? v : JSON.stringify(v);
18
+ return str.length > 80 ? `${str.slice(0, 77)}\u2026` : str;
19
+ };
20
+ function diffRows(table, id, expected, actual, out) {
21
+ if (!actual) {
22
+ out.push({ table, id, column: "(row)", airtable: "present", turso: "ABSENT" });
23
+ return;
24
+ }
25
+ for (const [col, want] of Object.entries(expected)) {
26
+ if (SKIP_COLUMNS.has(col)) continue;
27
+ const got = actual[col] ?? null;
28
+ const wantNorm = want ?? null;
29
+ if (String(wantNorm ?? "") !== String(got ?? "") && !(wantNorm === null && got === null)) {
30
+ out.push({ table, id, column: col, airtable: show(wantNorm), turso: show(got) });
31
+ }
32
+ }
33
+ }
34
+ async function checkFleetParity(db, io) {
35
+ const computedAt = io.now().toISOString();
36
+ const mismatches = [];
37
+ const websites = await io.listWebsiteRecords();
38
+ const mapped = websites.map((r) => mapWebsiteRecord(r, computedAt));
39
+ const [sites, health, schedule] = await Promise.all([
40
+ db.selectFrom("sites").selectAll().execute(),
41
+ db.selectFrom("site_health").selectAll().execute(),
42
+ db.selectFrom("site_schedule").selectAll().execute()
43
+ ]);
44
+ const byId = (rows, key) => new Map(rows.map((r) => [String(r[key]), r]));
45
+ const sitesById = byId(sites, "id");
46
+ const healthById = byId(health, "site_id");
47
+ const scheduleById = byId(schedule, "site_id");
48
+ for (const m of mapped) {
49
+ diffRows(
50
+ "sites",
51
+ m.site.id,
52
+ m.site,
53
+ sitesById.get(m.site.id),
54
+ mismatches
55
+ );
56
+ diffRows(
57
+ "site_health",
58
+ m.health.site_id,
59
+ m.health,
60
+ healthById.get(m.health.site_id),
61
+ mismatches
62
+ );
63
+ diffRows(
64
+ "site_schedule",
65
+ m.schedule.site_id,
66
+ m.schedule,
67
+ scheduleById.get(m.schedule.site_id),
68
+ mismatches
69
+ );
70
+ }
71
+ const airtableIds = new Set(mapped.map((m) => m.site.id));
72
+ for (const row of sitesById.keys()) {
73
+ if (!airtableIds.has(row)) {
74
+ mismatches.push({
75
+ table: "sites",
76
+ id: row,
77
+ column: "(row)",
78
+ airtable: "ABSENT",
79
+ turso: "present"
80
+ });
81
+ }
82
+ }
83
+ const reports = await io.listReportRecords();
84
+ const reportRows = byId(
85
+ await db.selectFrom("reports").selectAll().execute(),
86
+ "id"
87
+ );
88
+ for (const rec of reports) {
89
+ const expected = mapReportRecord(rec, null);
90
+ diffRows("reports", rec.id, expected, reportRows.get(rec.id), mismatches);
91
+ }
92
+ const reportIds = new Set(reports.map((r) => r.id));
93
+ for (const row of reportRows.keys()) {
94
+ if (!reportIds.has(row)) {
95
+ mismatches.push({
96
+ table: "reports",
97
+ id: row,
98
+ column: "(row)",
99
+ airtable: "ABSENT",
100
+ turso: "present"
101
+ });
102
+ }
103
+ }
104
+ return {
105
+ compared: {
106
+ sites: mapped.length,
107
+ site_health: mapped.length,
108
+ site_schedule: mapped.length,
109
+ reports: reports.length
110
+ },
111
+ mismatches
112
+ };
113
+ }
114
+ function formatParityResult(r) {
115
+ const lines = [];
116
+ for (const m of r.mismatches) {
117
+ lines.push(`\u2717 ${m.table} ${m.id} ${m.column}: airtable=${m.airtable} turso=${m.turso}`);
118
+ }
119
+ lines.push(
120
+ `FLEET_PARITY sites=${r.compared.sites} health=${r.compared.site_health} schedule=${r.compared.site_schedule} reports=${r.compared.reports} mismatches=${r.mismatches.length}`
121
+ );
122
+ return lines.join("\n");
123
+ }
124
+
125
+ export {
126
+ checkFleetParity,
127
+ formatParityResult
128
+ };
129
+ //# sourceMappingURL=chunk-ZBDZTJUO.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/db/parity.ts"],"sourcesContent":["import type { Db } from \"./client.js\";\nimport { mapWebsiteRecord, mapReportRecord, type RawRecord } from \"./import-airtable.js\";\n\n/**\n * Phase 1.4 of #539: the instrument the cutover depends on. Reads BOTH stores\n * and diffs field-by-field, using the importer's own mapping functions — so\n * \"what parity expects\" is definitionally \"what the importer writes\", and the\n * two cannot drift apart.\n *\n * Per the repo's standing rule, this harness must be shown to PASS GREEN on the\n * pre-cutover state (immediately after an import, when the stores genuinely\n * agree) before any mismatch it reports is treated as a finding. A harness that\n * has only ever failed is not evidence.\n *\n * Deliberately NOT compared:\n * - `site_schedule.computed_at` — an import stamp, different by construction.\n * - `sites.header_image*` — Airtable stopped being the source (design D5).\n * - `reports.rendered_html` — comparing it would re-download every attachment\n * on every run against URLs that expire; presence is checked at import time\n * and the body never changes after a send.\n */\n\nexport type ParityMismatch = {\n table: \"sites\" | \"site_health\" | \"site_schedule\" | \"reports\";\n id: string;\n column: string;\n airtable: string;\n turso: string;\n};\n\nexport type ParityResult = {\n compared: Record<\"sites\" | \"site_health\" | \"site_schedule\" | \"reports\", number>;\n mismatches: ParityMismatch[];\n};\n\nconst SKIP_COLUMNS: ReadonlySet<string> = new Set([\n \"computed_at\",\n \"header_image\",\n \"header_image_filename\",\n \"header_image_type\",\n \"header_image_generated_at\",\n \"rendered_html\",\n]);\n\nconst show = (v: unknown): string => {\n if (v === null || v === undefined) return \"∅\";\n const str = typeof v === \"string\" ? v : JSON.stringify(v);\n return str.length > 80 ? `${str.slice(0, 77)}…` : str;\n};\n\nfunction diffRows(\n table: ParityMismatch[\"table\"],\n id: string,\n expected: Record<string, unknown>,\n actual: Record<string, unknown> | undefined,\n out: ParityMismatch[],\n): void {\n if (!actual) {\n out.push({ table, id, column: \"(row)\", airtable: \"present\", turso: \"ABSENT\" });\n return;\n }\n for (const [col, want] of Object.entries(expected)) {\n if (SKIP_COLUMNS.has(col)) continue;\n const got = actual[col] ?? null;\n const wantNorm = want ?? null;\n if (String(wantNorm ?? \"\") !== String(got ?? \"\") && !(wantNorm === null && got === null)) {\n out.push({ table, id, column: col, airtable: show(wantNorm), turso: show(got) });\n }\n }\n}\n\nexport type ParityIo = {\n listWebsiteRecords: () => Promise<RawRecord[]>;\n listReportRecords: () => Promise<RawRecord[]>;\n now: () => Date;\n};\n\nexport async function checkFleetParity(db: Db, io: ParityIo): Promise<ParityResult> {\n const computedAt = io.now().toISOString();\n const mismatches: ParityMismatch[] = [];\n\n const websites = await io.listWebsiteRecords();\n const mapped = websites.map((r) => mapWebsiteRecord(r, computedAt));\n\n const [sites, health, schedule] = await Promise.all([\n db.selectFrom(\"sites\").selectAll().execute(),\n db.selectFrom(\"site_health\").selectAll().execute(),\n db.selectFrom(\"site_schedule\").selectAll().execute(),\n ]);\n const byId = <T extends { [k: string]: unknown }>(rows: T[], key: string): Map<string, T> =>\n new Map(rows.map((r) => [String(r[key]), r]));\n const sitesById = byId(sites as Array<Record<string, unknown>>, \"id\");\n const healthById = byId(health as Array<Record<string, unknown>>, \"site_id\");\n const scheduleById = byId(schedule as Array<Record<string, unknown>>, \"site_id\");\n\n for (const m of mapped) {\n diffRows(\n \"sites\",\n m.site.id,\n m.site as unknown as Record<string, unknown>,\n sitesById.get(m.site.id),\n mismatches,\n );\n diffRows(\n \"site_health\",\n m.health.site_id,\n m.health as unknown as Record<string, unknown>,\n healthById.get(m.health.site_id),\n mismatches,\n );\n diffRows(\n \"site_schedule\",\n m.schedule.site_id,\n m.schedule as unknown as Record<string, unknown>,\n scheduleById.get(m.schedule.site_id),\n mismatches,\n );\n }\n // A Turso site Airtable no longer has is as much a divergence as a missing one.\n const airtableIds = new Set(mapped.map((m) => m.site.id));\n for (const row of sitesById.keys()) {\n if (!airtableIds.has(row)) {\n mismatches.push({\n table: \"sites\",\n id: row,\n column: \"(row)\",\n airtable: \"ABSENT\",\n turso: \"present\",\n });\n }\n }\n\n const reports = await io.listReportRecords();\n const reportRows = byId(\n (await db.selectFrom(\"reports\").selectAll().execute()) as Array<Record<string, unknown>>,\n \"id\",\n );\n for (const rec of reports) {\n const expected = mapReportRecord(rec, null) as unknown as Record<string, unknown>;\n diffRows(\"reports\", rec.id, expected, reportRows.get(rec.id), mismatches);\n }\n const reportIds = new Set(reports.map((r) => r.id));\n for (const row of reportRows.keys()) {\n if (!reportIds.has(row)) {\n mismatches.push({\n table: \"reports\",\n id: row,\n column: \"(row)\",\n airtable: \"ABSENT\",\n turso: \"present\",\n });\n }\n }\n\n return {\n compared: {\n sites: mapped.length,\n site_health: mapped.length,\n site_schedule: mapped.length,\n reports: reports.length,\n },\n mismatches,\n };\n}\n\n/** Render for CLI/CI. Emits the machine-parseable `FLEET_PARITY` line on every\n * run, count=0 included — an absent line must mean \"the harness never ran\",\n * never \"it ran clean\" (same contract as FLEET_SMOKE_UNMEASURED, same reason). */\nexport function formatParityResult(r: ParityResult): string {\n const lines: string[] = [];\n for (const m of r.mismatches) {\n lines.push(`✗ ${m.table} ${m.id} ${m.column}: airtable=${m.airtable} turso=${m.turso}`);\n }\n lines.push(\n `FLEET_PARITY sites=${r.compared.sites} health=${r.compared.site_health} ` +\n `schedule=${r.compared.site_schedule} reports=${r.compared.reports} ` +\n `mismatches=${r.mismatches.length}`,\n );\n return lines.join(\"\\n\");\n}\n"],"mappings":"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","names":[]}