@reddoorla/maintenance 0.87.0 → 0.88.2

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 (186) hide show
  1. package/dist/airtable-QG5ICI3Y.js +9 -0
  2. package/dist/{announce-I2Z4JN3I.js → announce-GKD5YJNB.js} +9 -9
  3. package/dist/{blux-ZGDSHI3U.js → blux-DN4MI532.js} +5 -5
  4. package/dist/{bump-deps-CGVELRLK.js → bump-deps-KGH5UYSF.js} +4 -4
  5. package/dist/{chunk-SDDPUAWH.js → chunk-6I6L3CMX.js} +2 -2
  6. package/dist/{chunk-ZBDZTJUO.js → chunk-7FFUBF4I.js} +5 -2
  7. package/dist/chunk-7FFUBF4I.js.map +1 -0
  8. package/dist/{chunk-HSI6Q7MI.js → chunk-7TMWBXX7.js} +159 -29
  9. package/dist/chunk-7TMWBXX7.js.map +1 -0
  10. package/dist/{chunk-DEWJMMG3.js → chunk-A2UNJIKH.js} +15 -7
  11. package/dist/chunk-A2UNJIKH.js.map +1 -0
  12. package/dist/{chunk-NCMZMLY7.js → chunk-ANJNTAMM.js} +3 -69
  13. package/dist/chunk-ANJNTAMM.js.map +1 -0
  14. package/dist/{chunk-3JQ2FO3D.js → chunk-B23FFWU4.js} +2 -2
  15. package/dist/{chunk-DJZWYIPP.js → chunk-BGTYPVLT.js} +1 -1
  16. package/dist/chunk-BGTYPVLT.js.map +1 -0
  17. package/dist/chunk-BRYXFH77.js +70 -0
  18. package/dist/chunk-BRYXFH77.js.map +1 -0
  19. package/dist/{chunk-NDWYJLRF.js → chunk-DGZWFI2G.js} +3 -3
  20. package/dist/{chunk-HHMMUR4P.js → chunk-GQDDWYU2.js} +45 -8
  21. package/dist/chunk-GQDDWYU2.js.map +1 -0
  22. package/dist/{chunk-BBUSGEES.js → chunk-J3WEZGIN.js} +6 -6
  23. package/dist/{chunk-BNM5POSJ.js → chunk-JRHIFA5R.js} +2 -2
  24. package/dist/{chunk-TD2NNXLZ.js → chunk-K6SLF6GI.js} +5 -5
  25. package/dist/{chunk-MT2JINSS.js → chunk-KJGKZLCT.js} +3 -3
  26. package/dist/{chunk-NXNPDCF3.js → chunk-KMCZA7CH.js} +11 -1
  27. package/dist/chunk-KMCZA7CH.js.map +1 -0
  28. package/dist/{chunk-3SF7D4DY.js → chunk-MFHS7IQ2.js} +36 -1
  29. package/dist/{chunk-3SF7D4DY.js.map → chunk-MFHS7IQ2.js.map} +1 -1
  30. package/dist/{chunk-SNSTJLQW.js → chunk-NO74DXDP.js} +48 -2
  31. package/dist/chunk-NO74DXDP.js.map +1 -0
  32. package/dist/{chunk-C6B4RHXR.js → chunk-O3GT46R2.js} +8 -2
  33. package/dist/chunk-O3GT46R2.js.map +1 -0
  34. package/dist/{chunk-C6CLRKPV.js → chunk-PR343K7F.js} +2 -2
  35. package/dist/{chunk-CROKUHJB.js → chunk-Q47DU255.js} +4 -4
  36. package/dist/{chunk-EVBCK2KC.js → chunk-RARREDQE.js} +2 -1
  37. package/dist/{chunk-EVBCK2KC.js.map → chunk-RARREDQE.js.map} +1 -1
  38. package/dist/chunk-RUPPWAD6.js +7 -0
  39. package/dist/chunk-RUPPWAD6.js.map +1 -0
  40. package/dist/{chunk-VSBFM7DS.js → chunk-SJOXWJ5Y.js} +3 -3
  41. package/dist/{chunk-HEWSRXYV.js → chunk-ULHPALI5.js} +3 -3
  42. package/dist/{chunk-XUGSZP7Z.js → chunk-W45FZEGE.js} +2 -2
  43. package/dist/{chunk-JHGHLKFS.js → chunk-WS6NU475.js} +2 -2
  44. package/dist/{chunk-ZGBEIWSC.js → chunk-XP5WM2LV.js} +5 -5
  45. package/dist/{chunk-6TWWY7ZU.js → chunk-Z3JXLULD.js} +2 -2
  46. package/dist/cli/bin.js +27 -27
  47. package/dist/cli/bin.js.map +1 -1
  48. package/dist/cli/commands/audit.d.ts +4 -0
  49. package/dist/cli/commands/audit.js +16 -13
  50. package/dist/cli/commands/audit.js.map +1 -1
  51. package/dist/client-JYKPVCJX.js +11 -0
  52. package/dist/configs/playwright-a11y.js +1 -1
  53. package/dist/{convert-to-pnpm-QFJOEP5V.js → convert-to-pnpm-NWB5FCQ2.js} +4 -4
  54. package/dist/{db-UXNIZ2RG.js → db-V6UIS433.js} +121 -26
  55. package/dist/db-V6UIS433.js.map +1 -0
  56. package/dist/{digest-FW6QMZZJ.js → digest-2JEAEJK6.js} +53 -29
  57. package/dist/digest-2JEAEJK6.js.map +1 -0
  58. package/dist/digest-state-CRZRNGDI.js +25 -0
  59. package/dist/digest-state-CRZRNGDI.js.map +1 -0
  60. package/dist/digest-state-F37XUCS5.js +13 -0
  61. package/dist/{dump-P6EBSIOH.js → dump-EPL7AZFR.js} +38 -13
  62. package/dist/dump-EPL7AZFR.js.map +1 -0
  63. package/dist/{email-KJXASVH5.js → email-SL7464YI.js} +16 -6
  64. package/dist/{email-KJXASVH5.js.map → email-SL7464YI.js.map} +1 -1
  65. package/dist/{ensure-site-4DCEBAPM.js → ensure-site-FU6527OU.js} +2 -2
  66. package/dist/{fleet-state-7TJ2GYT2.js → fleet-state-R5LU7IC3.js} +4 -2
  67. package/dist/{forms-notify-target-2EAWCWBW.js → forms-notify-target-SGDGXXK5.js} +6 -6
  68. package/dist/{github-signals-E2WPYTBK.js → github-signals-GWGCKEE2.js} +7 -6
  69. package/dist/{github-signals-E2WPYTBK.js.map → github-signals-GWGCKEE2.js.map} +1 -1
  70. package/dist/{header-image-6IC53W27.js → header-image-7A7XLVEU.js} +2 -2
  71. package/dist/{health-endpoint-RFXL2F4A.js → health-endpoint-UVX6R5N5.js} +4 -4
  72. package/dist/{health-mirror-L5JBEJVA.js → health-mirror-GCDN7BJL.js} +11 -6
  73. package/dist/health-mirror-GCDN7BJL.js.map +1 -0
  74. package/dist/index.js +128 -88
  75. package/dist/index.js.map +1 -1
  76. package/dist/{init-XRAQZYID.js → init-FGTYNGUI.js} +9 -9
  77. package/dist/{launch-IX4Y5JJC.js → launch-5H7LPEGP.js} +12 -11
  78. package/dist/{launch-IX4Y5JJC.js.map → launch-5H7LPEGP.js.map} +1 -1
  79. package/dist/migrate-YITCXBLS.js +7 -0
  80. package/dist/{notify-C645PGD5.js → notify-4HP4GZ2H.js} +5 -5
  81. package/dist/{onboard-O4IAHJMO.js → onboard-BKCYBAVW.js} +4 -4
  82. package/dist/{orchestrate-YLWQ6KK2.js → orchestrate-JNKCWU5K.js} +9 -9
  83. package/dist/{parity-56KA2DGR.js → parity-NBYZUBFU.js} +4 -2
  84. package/dist/pdf-N5QLQ5ZQ.js +38 -0
  85. package/dist/pdf-N5QLQ5ZQ.js.map +1 -0
  86. package/dist/{pipeline-3AANZFCR.js → pipeline-WYTXSLHX.js} +3 -3
  87. package/dist/{preflight-TNORKGCX.js → preflight-IMTYDDD4.js} +10 -10
  88. package/dist/{prismic-ci-G2AK53VL.js → prismic-ci-MY2U6NN7.js} +4 -4
  89. package/dist/{prismic-models-O6Z3OK3A.js → prismic-models-NJN5JORF.js} +6 -6
  90. package/dist/prospect/types.d.ts +14 -0
  91. package/dist/{prospect-audit-KSXUNQ5J.js → prospect-audit-JMBRVXSG.js} +32 -13
  92. package/dist/prospect-audit-JMBRVXSG.js.map +1 -0
  93. package/dist/render-VX72AESY.js +10 -0
  94. package/dist/{render-from-row-BWSOIYDS.js → render-from-row-DOYJDO2L.js} +5 -5
  95. package/dist/{replay-C2LXMJZP.js → replay-4S6ENYCH.js} +3 -3
  96. package/dist/{report-R3E3Y7HR.js → report-TALY5P3L.js} +17 -17
  97. package/dist/{report-mirror-4MNORYQ4.js → report-mirror-AAK3MXHY.js} +12 -6
  98. package/dist/report-mirror-AAK3MXHY.js.map +1 -0
  99. package/dist/schema-75PJECAE.js +18 -0
  100. package/dist/schema-75PJECAE.js.map +1 -0
  101. package/dist/{self-updating-UCIWESAX.js → self-updating-NTCCWJYZ.js} +4 -4
  102. package/dist/{selftest-VGHWPN72.js → selftest-NZTR4SM2.js} +11 -11
  103. package/dist/{site-mirror-NIREX5ME.js → site-mirror-Q763PLGG.js} +17 -7
  104. package/dist/site-mirror-Q763PLGG.js.map +1 -0
  105. package/dist/{smoke-suite-U3UKD3NS.js → smoke-suite-4JFZZYVW.js} +4 -4
  106. package/dist/{spam-classifier-XLBFCO7G.js → spam-classifier-T5H4VTP6.js} +4 -2
  107. package/dist/{submissions-IQEJX6FK.js → submissions-TEHPBSZJ.js} +4 -4
  108. package/dist/{svelte-codemods-GOKXTBHW.js → svelte-codemods-QAPDZC6F.js} +4 -4
  109. package/dist/{sync-CTUVXMCK.js → sync-GBFV45WX.js} +4 -2
  110. package/dist/{sync-CTUVXMCK.js.map → sync-GBFV45WX.js.map} +1 -1
  111. package/dist/{sync-configs-SPTLFJCC.js → sync-configs-34JIA6KZ.js} +4 -4
  112. package/dist/{upgrade-W6ZR3FJH.js → upgrade-IR2OO3KP.js} +4 -4
  113. package/dist/{webhook-VOGOW3W6.js → webhook-XM6EV4XT.js} +2 -2
  114. package/dist/{write-audits-to-airtable-CMKHGJNQ.js → write-audits-to-airtable-OBHGR7DQ.js} +5 -2
  115. package/dist/write-audits-to-airtable-OBHGR7DQ.js.map +1 -0
  116. package/package.json +1 -1
  117. package/dist/airtable-ZBUPQJSC.js +0 -9
  118. package/dist/chunk-C6B4RHXR.js.map +0 -1
  119. package/dist/chunk-DEWJMMG3.js.map +0 -1
  120. package/dist/chunk-DJZWYIPP.js.map +0 -1
  121. package/dist/chunk-HHMMUR4P.js.map +0 -1
  122. package/dist/chunk-HSI6Q7MI.js.map +0 -1
  123. package/dist/chunk-NCMZMLY7.js.map +0 -1
  124. package/dist/chunk-NXNPDCF3.js.map +0 -1
  125. package/dist/chunk-SNSTJLQW.js.map +0 -1
  126. package/dist/chunk-ZBDZTJUO.js.map +0 -1
  127. package/dist/client-AUE3XUPO.js +0 -11
  128. package/dist/db-UXNIZ2RG.js.map +0 -1
  129. package/dist/digest-FW6QMZZJ.js.map +0 -1
  130. package/dist/dump-P6EBSIOH.js.map +0 -1
  131. package/dist/health-mirror-L5JBEJVA.js.map +0 -1
  132. package/dist/migrate-GQH237A6.js +0 -7
  133. package/dist/prospect-audit-KSXUNQ5J.js.map +0 -1
  134. package/dist/render-IFOGFB5T.js +0 -10
  135. package/dist/report-mirror-4MNORYQ4.js.map +0 -1
  136. package/dist/site-mirror-NIREX5ME.js.map +0 -1
  137. /package/dist/{airtable-ZBUPQJSC.js.map → airtable-QG5ICI3Y.js.map} +0 -0
  138. /package/dist/{announce-I2Z4JN3I.js.map → announce-GKD5YJNB.js.map} +0 -0
  139. /package/dist/{blux-ZGDSHI3U.js.map → blux-DN4MI532.js.map} +0 -0
  140. /package/dist/{bump-deps-CGVELRLK.js.map → bump-deps-KGH5UYSF.js.map} +0 -0
  141. /package/dist/{chunk-SDDPUAWH.js.map → chunk-6I6L3CMX.js.map} +0 -0
  142. /package/dist/{chunk-3JQ2FO3D.js.map → chunk-B23FFWU4.js.map} +0 -0
  143. /package/dist/{chunk-NDWYJLRF.js.map → chunk-DGZWFI2G.js.map} +0 -0
  144. /package/dist/{chunk-BBUSGEES.js.map → chunk-J3WEZGIN.js.map} +0 -0
  145. /package/dist/{chunk-BNM5POSJ.js.map → chunk-JRHIFA5R.js.map} +0 -0
  146. /package/dist/{chunk-TD2NNXLZ.js.map → chunk-K6SLF6GI.js.map} +0 -0
  147. /package/dist/{chunk-MT2JINSS.js.map → chunk-KJGKZLCT.js.map} +0 -0
  148. /package/dist/{chunk-C6CLRKPV.js.map → chunk-PR343K7F.js.map} +0 -0
  149. /package/dist/{chunk-CROKUHJB.js.map → chunk-Q47DU255.js.map} +0 -0
  150. /package/dist/{chunk-VSBFM7DS.js.map → chunk-SJOXWJ5Y.js.map} +0 -0
  151. /package/dist/{chunk-HEWSRXYV.js.map → chunk-ULHPALI5.js.map} +0 -0
  152. /package/dist/{chunk-XUGSZP7Z.js.map → chunk-W45FZEGE.js.map} +0 -0
  153. /package/dist/{chunk-JHGHLKFS.js.map → chunk-WS6NU475.js.map} +0 -0
  154. /package/dist/{chunk-ZGBEIWSC.js.map → chunk-XP5WM2LV.js.map} +0 -0
  155. /package/dist/{chunk-6TWWY7ZU.js.map → chunk-Z3JXLULD.js.map} +0 -0
  156. /package/dist/{client-AUE3XUPO.js.map → client-JYKPVCJX.js.map} +0 -0
  157. /package/dist/{convert-to-pnpm-QFJOEP5V.js.map → convert-to-pnpm-NWB5FCQ2.js.map} +0 -0
  158. /package/dist/{fleet-state-7TJ2GYT2.js.map → digest-state-F37XUCS5.js.map} +0 -0
  159. /package/dist/{ensure-site-4DCEBAPM.js.map → ensure-site-FU6527OU.js.map} +0 -0
  160. /package/dist/{migrate-GQH237A6.js.map → fleet-state-R5LU7IC3.js.map} +0 -0
  161. /package/dist/{forms-notify-target-2EAWCWBW.js.map → forms-notify-target-SGDGXXK5.js.map} +0 -0
  162. /package/dist/{header-image-6IC53W27.js.map → header-image-7A7XLVEU.js.map} +0 -0
  163. /package/dist/{health-endpoint-RFXL2F4A.js.map → health-endpoint-UVX6R5N5.js.map} +0 -0
  164. /package/dist/{init-XRAQZYID.js.map → init-FGTYNGUI.js.map} +0 -0
  165. /package/dist/{notify-C645PGD5.js.map → migrate-YITCXBLS.js.map} +0 -0
  166. /package/dist/{orchestrate-YLWQ6KK2.js.map → notify-4HP4GZ2H.js.map} +0 -0
  167. /package/dist/{onboard-O4IAHJMO.js.map → onboard-BKCYBAVW.js.map} +0 -0
  168. /package/dist/{parity-56KA2DGR.js.map → orchestrate-JNKCWU5K.js.map} +0 -0
  169. /package/dist/{pipeline-3AANZFCR.js.map → parity-NBYZUBFU.js.map} +0 -0
  170. /package/dist/{render-IFOGFB5T.js.map → pipeline-WYTXSLHX.js.map} +0 -0
  171. /package/dist/{preflight-TNORKGCX.js.map → preflight-IMTYDDD4.js.map} +0 -0
  172. /package/dist/{prismic-ci-G2AK53VL.js.map → prismic-ci-MY2U6NN7.js.map} +0 -0
  173. /package/dist/{prismic-models-O6Z3OK3A.js.map → prismic-models-NJN5JORF.js.map} +0 -0
  174. /package/dist/{render-from-row-BWSOIYDS.js.map → render-VX72AESY.js.map} +0 -0
  175. /package/dist/{spam-classifier-XLBFCO7G.js.map → render-from-row-DOYJDO2L.js.map} +0 -0
  176. /package/dist/{replay-C2LXMJZP.js.map → replay-4S6ENYCH.js.map} +0 -0
  177. /package/dist/{report-R3E3Y7HR.js.map → report-TALY5P3L.js.map} +0 -0
  178. /package/dist/{self-updating-UCIWESAX.js.map → self-updating-NTCCWJYZ.js.map} +0 -0
  179. /package/dist/{selftest-VGHWPN72.js.map → selftest-NZTR4SM2.js.map} +0 -0
  180. /package/dist/{smoke-suite-U3UKD3NS.js.map → smoke-suite-4JFZZYVW.js.map} +0 -0
  181. /package/dist/{write-audits-to-airtable-CMKHGJNQ.js.map → spam-classifier-T5H4VTP6.js.map} +0 -0
  182. /package/dist/{submissions-IQEJX6FK.js.map → submissions-TEHPBSZJ.js.map} +0 -0
  183. /package/dist/{svelte-codemods-GOKXTBHW.js.map → svelte-codemods-QAPDZC6F.js.map} +0 -0
  184. /package/dist/{sync-configs-SPTLFJCC.js.map → sync-configs-34JIA6KZ.js.map} +0 -0
  185. /package/dist/{upgrade-W6ZR3FJH.js.map → upgrade-IR2OO3KP.js.map} +0 -0
  186. /package/dist/{webhook-VOGOW3W6.js.map → webhook-XM6EV4XT.js.map} +0 -0
@@ -12,16 +12,17 @@ import {
12
12
  collectPrismicDriftAlerts,
13
13
  collectRenovateAlerts,
14
14
  collectTurnstileGuardrailAlerts,
15
- collectVulnAlerts,
16
- diffAttention,
17
- readDigestState,
18
- writeDigestState
19
- } from "./chunk-NCMZMLY7.js";
20
- import "./chunk-SDDPUAWH.js";
15
+ collectVulnAlerts
16
+ } from "./chunk-ANJNTAMM.js";
17
+ import "./chunk-6I6L3CMX.js";
21
18
  import "./chunk-PZR4EBPY.js";
22
19
  import {
23
20
  isIdempotencyConflict
24
- } from "./chunk-NDWYJLRF.js";
21
+ } from "./chunk-DGZWFI2G.js";
22
+ import {
23
+ diffAttention,
24
+ writeDigestState
25
+ } from "./chunk-BRYXFH77.js";
25
26
  import {
26
27
  isPendingApproval,
27
28
  listAllReports
@@ -32,13 +33,13 @@ import {
32
33
  openBase,
33
34
  readAirtableConfig
34
35
  } from "./chunk-HN6I2XWI.js";
35
- import "./chunk-ZGBEIWSC.js";
36
+ import "./chunk-XP5WM2LV.js";
36
37
  import "./chunk-GECESMVS.js";
37
- import "./chunk-MT2JINSS.js";
38
+ import "./chunk-5UINJNAJ.js";
39
+ import "./chunk-KJGKZLCT.js";
38
40
  import {
39
41
  escapeHtml
40
- } from "./chunk-DJZWYIPP.js";
41
- import "./chunk-5UINJNAJ.js";
42
+ } from "./chunk-BGTYPVLT.js";
42
43
  import {
43
44
  listWebsites,
44
45
  siteSlug
@@ -46,10 +47,10 @@ import {
46
47
  import {
47
48
  defaultResendClient
48
49
  } from "./chunk-WEKL2HYJ.js";
49
- import "./chunk-XUGSZP7Z.js";
50
- import "./chunk-EVBCK2KC.js";
51
- import "./chunk-JHGHLKFS.js";
52
- import "./chunk-3SF7D4DY.js";
50
+ import "./chunk-W45FZEGE.js";
51
+ import "./chunk-RARREDQE.js";
52
+ import "./chunk-WS6NU475.js";
53
+ import "./chunk-MFHS7IQ2.js";
53
54
  import "./chunk-6VAL7XJT.js";
54
55
 
55
56
  // src/reports/digest.ts
@@ -148,7 +149,7 @@ async function listPendingApproval(base) {
148
149
  async function fetchNotifyBounceCounts(now) {
149
150
  try {
150
151
  const [{ openDb, readDbConfig }, { countNotifyBouncedBySite }, { screenOutsSince }] = await Promise.all([
151
- import("./client-AUE3XUPO.js"),
152
+ import("./client-JYKPVCJX.js"),
152
153
  import("./submissions-3I3GAQAX.js"),
153
154
  import("./screenouts-7P5GOZMV.js")
154
155
  ]);
@@ -159,11 +160,42 @@ async function fetchNotifyBounceCounts(now) {
159
160
  return /* @__PURE__ */ new Map();
160
161
  }
161
162
  }
163
+ async function readDigestStateFromDb() {
164
+ const [{ openDb, readDbConfig }, { readDigestState }] = await Promise.all([
165
+ import("./client-JYKPVCJX.js"),
166
+ import("./digest-state-CRZRNGDI.js")
167
+ ]);
168
+ return readDigestState(await openDb(readDbConfig()));
169
+ }
170
+ async function writeDigestStateToDb(next) {
171
+ const [{ openDb, readDbConfig }, { writeDigestState: writeDigestState2 }] = await Promise.all([
172
+ import("./client-JYKPVCJX.js"),
173
+ import("./digest-state-CRZRNGDI.js")
174
+ ]);
175
+ await writeDigestState2(await openDb(readDbConfig()), next);
176
+ }
177
+ async function persistDigestState(base, next, writeState) {
178
+ let turso = 0;
179
+ let airtable = 0;
180
+ try {
181
+ await (writeState ?? writeDigestStateToDb)(next);
182
+ turso = 1;
183
+ } catch (e) {
184
+ console.warn(`\u26A0 digest state write failed (turso): ${e.message}`);
185
+ }
186
+ try {
187
+ await writeDigestState(base, next);
188
+ airtable = 1;
189
+ } catch (e) {
190
+ console.warn(`\u26A0 digest state write failed (airtable): ${e.message}`);
191
+ }
192
+ console.log(`DIGEST_STATE_WRITE turso=${turso} airtable=${airtable}`);
193
+ }
162
194
  var SUBMISSIONS_DIGEST_WINDOW_MS = 24 * 60 * 60 * 1e3;
163
195
  async function fetchSubmissionsDigestCounts(now) {
164
196
  try {
165
197
  const [{ openDb, readDbConfig }, { countSubmissionsSinceBySite }] = await Promise.all([
166
- import("./client-AUE3XUPO.js"),
198
+ import("./client-JYKPVCJX.js"),
167
199
  import("./submissions-3I3GAQAX.js")
168
200
  ]);
169
201
  const db = await openDb(readDbConfig());
@@ -260,17 +292,13 @@ async function runDigest(options) {
260
292
  reports,
261
293
  now: today
262
294
  });
263
- const prior = await readDigestState(base);
295
+ const prior = await (options.digestState?.read ?? readDigestStateFromDb)();
264
296
  const { tagged, next } = diffAttention(collected, prior, digestDateKey(today));
265
297
  const needsAttention = tagged.filter(
266
298
  (it) => it.kind !== "vuln" || it.autoFixExhausted === true
267
299
  );
268
300
  if (readyForYourYes.length === 0 && needsAttention.length === 0) {
269
- try {
270
- await writeDigestState(base, next);
271
- } catch (e) {
272
- console.warn(`\u26A0 digest state write failed: ${e.message}`);
273
- }
301
+ await persistDigestState(base, next, options.digestState?.write);
274
302
  return { output: "Digest skipped (nothing ready, nothing needs attention).", code: 0 };
275
303
  }
276
304
  const counts = options.submissionCounts !== void 0 ? options.submissionCounts : await fetchSubmissionsDigestCounts(today);
@@ -298,11 +326,7 @@ async function runDigest(options) {
298
326
  }
299
327
  throw err;
300
328
  }
301
- try {
302
- await writeDigestState(base, next);
303
- } catch (e) {
304
- console.warn(`\u26A0 digest state write failed: ${e.message}`);
305
- }
329
+ await persistDigestState(base, next, options.digestState?.write);
306
330
  return { output: `Digest sent to ${to.join(", ")} (${result.messageId})`, code: 0 };
307
331
  } catch (err) {
308
332
  if (typeof err.exitCode === "number") {
@@ -343,4 +367,4 @@ export {
343
367
  renderDigestHtml,
344
368
  runDigest
345
369
  };
346
- //# sourceMappingURL=digest-FW6QMZZJ.js.map
370
+ //# sourceMappingURL=digest-2JEAEJK6.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/digest.ts"],"sourcesContent":["// src/reports/digest.ts\nimport { openBase, readAirtableConfig, type AirtableBase } from \"./airtable/client.js\";\nimport { listAllReports, isPendingApproval } from \"./airtable/reports.js\";\nimport type { ReportRow } from \"./airtable/reports.js\";\nimport { listWebsites, siteSlug, type WebsiteRow } from \"./airtable/websites.js\";\nimport { defaultResendClient, type ResendClient } from \"./send/resend.js\";\nimport { isIdempotencyConflict } from \"./send/idempotency.js\";\nimport {\n collectVulnAlerts,\n collectDeliveryFailures,\n collectPreflightBlocked,\n collectLighthouseAlerts,\n collectRenovateAlerts,\n collectCiAlerts,\n collectAnalyticsFailures,\n collectTurnstileGuardrailAlerts,\n collectNotifyBounceAlerts,\n collectPrismicDriftAlerts,\n NOTIFY_BOUNCE_WINDOW_DAYS,\n} from \"../alerts/digest-collectors.js\";\nimport { diffAttention } from \"../alerts/digest-state.js\";\nimport {\n writeDigestState as writeAirtableDigestState,\n type DigestSnapshot,\n} from \"../alerts/digest-state.js\";\nimport { escapeHtml as esc } from \"../util/html.js\";\nimport { operatorEmail } from \"../util/operator.js\";\nimport type {\n AttentionItem,\n AttentionSeverity,\n AttentionStatus,\n ReadyItem,\n DigestSections,\n SubmissionsDigestSection,\n} from \"../alerts/attention.js\";\n// Type-only: erased at compile time, so the kysely/libsql devDependency never loads\n// in a consuming fleet site (the runtime read is the dynamic import in\n// fetchSubmissionsDigestCounts, same rule as fetchNotifyBounceCounts).\nimport type { SiteSubmissionCounts } from \"../db/submissions.js\";\n\n// The attention/digest contract lives in `../alerts/attention.ts` (a dependency-free\n// types module) so the `alerts/*` collectors can depend on it without importing back\n// from this renderer/IO module — see attention.ts for the cycle it breaks. Re-exported\n// here so existing `from \"./digest.js\"` type importers keep resolving.\nexport type {\n AttentionItem,\n AttentionSeverity,\n AttentionStatus,\n ReadyItem,\n DigestSections,\n SubmissionsDigestSection,\n} from \"../alerts/attention.js\";\n\nconst GREY = \"#757575\";\nconst RED = \"#C00\";\n\n/** Shared anchor style — Gmail does not inherit font-family into <a> tags. */\nconst ANCHOR_STYLE = `color:${RED};font-family:helvetica,sans-serif`;\n\nfunction readySection(items: ReadyItem[]): string {\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Ready for your yes</h2>`;\n if (items.length === 0) {\n return `${heading}<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">Nothing waiting on you.</p>`;\n }\n const rows = items\n .map((it) => {\n const safeUrl = it.dashboardUrl.startsWith(\"https://\") ? it.dashboardUrl : undefined;\n const link = safeUrl\n ? `<a href=\"${esc(safeUrl)}\" style=\"${ANCHOR_STYLE}\">review &amp; approve</a>`\n : `review &amp; approve`;\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">\n <strong style=\"color:#222\">${esc(it.siteName)}</strong> — ${esc(it.reportType)} (${esc(it.period)})\n — ${link}\n </td>\n </tr>`;\n })\n .join(\"\");\n return `${heading}<table role=\"presentation\" style=\"border-collapse:collapse;margin:0\">${rows}</table>`;\n}\n\nconst SEVERITY_ORDER: Record<AttentionSeverity, number> = { critical: 0, warning: 1 };\n\n/** Render the per-item status badge (\"NEW\"/\"WORSE\"); standing items get nothing. */\nfunction attentionBadge(status?: AttentionStatus): string {\n if (status === \"new\")\n return `<strong style=\"color:${RED};font-family:helvetica,sans-serif\">NEW</strong> `;\n if (status === \"worse\")\n return `<strong style=\"color:${RED};font-family:helvetica,sans-serif\">WORSE</strong> `;\n return \"\";\n}\n\nfunction attentionSection(items: AttentionItem[]): string {\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Needs attention</h2>`;\n if (items.length === 0) {\n return `${heading}<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">All clear — nothing needs attention.</p>`;\n }\n\n // Group by siteName, preserving first-seen site order; sort within a site by\n // severity (critical first).\n const bySite = new Map<string, AttentionItem[]>();\n for (const it of items) {\n const bucket = bySite.get(it.siteName);\n if (bucket) bucket.push(it);\n else bySite.set(it.siteName, [it]);\n }\n\n const groups = [...bySite.entries()]\n .map(([siteName, siteItems]) => {\n const sorted = [...siteItems].sort(\n (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity],\n );\n const rows = sorted\n .map((it) => {\n const safeUrl = it.url?.startsWith(\"https://\") ? it.url : undefined;\n const titleHtml = safeUrl\n ? `<a href=\"${esc(safeUrl)}\" style=\"${ANCHOR_STYLE}\">${esc(it.title)}</a>`\n : esc(it.title);\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">${attentionBadge(it.status)}${titleHtml}</td>\n </tr>`;\n })\n .join(\"\");\n return `\n <tr>\n <td style=\"color:#222;font-family:helvetica,sans-serif;font-size:16px;font-weight:700;padding:8px 0 4px\">${esc(siteName)}</td>\n </tr>\n ${rows}`;\n })\n .join(\"\");\n\n return `${heading}<table role=\"presentation\" style=\"border-collapse:collapse;margin:0\">${groups}</table>`;\n}\n\n/** The \"Submissions (24h)\" telemetry block: fleet totals + a per-site glance line.\n * Context, not an alarm — absent/null section or all-zero totals render nothing\n * (no-noise), and the section never triggers a send on its own (see runDigest). */\nfunction submissionsSection(s: SubmissionsDigestSection | null | undefined): string {\n if (!s) return \"\";\n if (s.leads === 0 && s.signups === 0 && s.spamAuto === 0) return \"\";\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Submissions (24h)</h2>`;\n // All three terms always render together — omitting a zero term next to a nonzero\n // one reads as \"not measured\" rather than \"none\"; simplest honest form.\n const totals = [\n `${s.leads} new lead${s.leads === 1 ? \"\" : \"s\"}`,\n `${s.signups} newsletter/RSVP signup${s.signups === 1 ? \"\" : \"s\"}`,\n `${s.spamAuto} auto-filtered spam`,\n ].join(\" · \");\n const totalLine = `<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">${totals}</p>`;\n if (s.bySite.length === 0) return `${heading}${totalLine}`;\n const rows = s.bySite\n .map((site) => {\n const parts: string[] = [];\n if (site.leads > 0) parts.push(`${site.leads} lead${site.leads === 1 ? \"\" : \"s\"}`);\n if (site.signups > 0) parts.push(`${site.signups} signup${site.signups === 1 ? \"\" : \"s\"}`);\n if (site.spamAuto > 0) parts.push(`${site.spamAuto} auto-filtered`);\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">\n <strong style=\"color:#222\">${esc(site.siteName)}</strong> — ${parts.join(\" · \")}\n </td>\n </tr>`;\n })\n .join(\"\");\n return `${heading}${totalLine}<table role=\"presentation\" style=\"border-collapse:collapse;margin:8px 0 0\">${rows}</table>`;\n}\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\n\n/** UTC \"YYYY-MM-DD\" — the Resend idempotency key suffix, so a same-day cron re-fire dedupes. */\nfunction digestDateKey(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * The gate for \"Ready for your yes\": Draft ready ∧ ¬Approved to send ∧ Sent at BLANK.\n *\n * Implemented as `listAllReports(base).filter(...)` (the authorized deviation from the plan's\n * draft, which pre-dated Slice 1's merged fix): `listAllReports` already calls `mapRow`, which\n * handles `Period` correctly, so no local `rawToReportRow` duplicate is needed. The JS filter\n * does real work — the test fake does NOT evaluate `filterByFormula` — so correctness is\n * test-provable here.\n *\n * Exported: the fleet homepage (Task 3.5b) reuses it for the pending-approval count.\n */\nexport async function listPendingApproval(base: AirtableBase): Promise<ReportRow[]> {\n return (await listAllReports(base)).filter(isPendingApproval);\n}\n\n// ── collectAttention (IO wrapper, sibling to runDigest) ──────────────────────\n\nexport type CollectAttentionDeps = {\n base: AirtableBase;\n /** Same baseUrl value runDigest threads; used for the /s/<slug> links. */\n baseUrl: string;\n /** Pre-fetched Websites rows. When supplied (runDigest already read them),\n * collectAttention reuses them instead of issuing a second `listWebsites`. */\n websites?: WebsiteRow[];\n /** Pre-fetched Reports rows. When supplied (runDigest already read them),\n * collectAttention reuses them instead of issuing a second `listAllReports`. */\n reports?: ReportRow[];\n /** Clock for the GitHub-signals staleness gate (collectCiAlerts /\n * collectRenovateAlerts skip a >3-day-stale sweep). Defaults to wall-clock;\n * runDigest threads its run-start `today` so a quiet repo's frozen CI/Renovate\n * signal stops badging once its sweep goes stale. */\n now?: Date;\n /** Pre-fetched per-site bounced-lead-notification counts (tests, or a caller with\n * an open db). When omitted, collectAttention reads them from libSQL itself —\n * defensively, so a missing TURSO env or a Turso blip drops just this signal. */\n notifyBounces?: ReadonlyMap<string, number>;\n};\n\n/** Per-site bounced-notification counts for the notify-bounce collector, read from\n * libSQL over the collector's own window. The db modules are imported DYNAMICALLY:\n * src/db/submissions.ts pulls kysely at top level, and kysely/libsql live in\n * devDependencies consuming fleet sites don't install — a static import here would\n * crash their CLI at require time (same rule as openDb's own lazy loads). Any\n * failure (no TURSO env, Turso down) logs and yields an empty map: the digest\n * must never blank over a missing optional signal. */\nasync function fetchNotifyBounceCounts(now: Date): Promise<ReadonlyMap<string, number>> {\n try {\n const [{ openDb, readDbConfig }, { countNotifyBouncedBySite }, { screenOutsSince }] =\n await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/submissions.js\"),\n import(\"../db/screenouts.js\"),\n ]);\n const db = await openDb(readDbConfig());\n return await countNotifyBouncedBySite(db, screenOutsSince(now, NOTIFY_BOUNCE_WINDOW_DAYS));\n } catch (e) {\n console.warn(`⚠ notify-bounce counts unavailable (libSQL): ${(e as Error).message}`);\n return new Map();\n }\n}\n\n/** The prior snapshot, from Turso (#609). Dynamically imported for the same\n * reason as fetchNotifyBounceCounts: kysely/libsql are devDependencies that\n * consuming fleet sites do not install, so a static import would crash their\n * CLI at require time.\n *\n * NOT defensive, deliberately — the Airtable read it replaces was not either.\n * Swallowing a failure here would silently badge every item NEW, which reads\n * as \"the whole fleet degraded overnight\" in the operator's inbox. A thrown\n * error is the honest outcome. */\nasync function readDigestStateFromDb(): Promise<DigestSnapshot> {\n const [{ openDb, readDbConfig }, { readDigestState }] = await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/digest-state.js\"),\n ]);\n return readDigestState(await openDb(readDbConfig()));\n}\n\n/** The write half of {@link readDigestStateFromDb}, same lazy-import rule. */\nasync function writeDigestStateToDb(next: DigestSnapshot): Promise<void> {\n const [{ openDb, readDbConfig }, { writeDigestState }] = await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/digest-state.js\"),\n ]);\n await writeDigestState(await openDb(readDbConfig()), next);\n}\n\n/** Persist the next snapshot to BOTH stores (#609 dual-write).\n *\n * Turso is the read side, so a failure there is the one that actually costs\n * correct NEW badges tomorrow — but Airtable keeps being written so the move\n * stays reversible while Phase 5 is in flight. Both are best-effort: the\n * digest has already been sent (or deliberately skipped) by the time this\n * runs, and a snapshot write failure re-news at worst.\n *\n * The DIGEST_STATE_WRITE line exists because of #585: a dual-write that\n * silently stopped running looked identical to a healthy one for weeks. One\n * line, always emitted, naming both halves. */\nasync function persistDigestState(\n base: AirtableBase,\n next: DigestSnapshot,\n writeState?: (snap: DigestSnapshot) => Promise<void>,\n): Promise<void> {\n let turso = 0;\n let airtable = 0;\n try {\n await (writeState ?? writeDigestStateToDb)(next);\n turso = 1;\n } catch (e) {\n console.warn(`⚠ digest state write failed (turso): ${(e as Error).message}`);\n }\n try {\n await writeAirtableDigestState(base, next);\n airtable = 1;\n } catch (e) {\n console.warn(`⚠ digest state write failed (airtable): ${(e as Error).message}`);\n }\n console.log(`DIGEST_STATE_WRITE turso=${turso} airtable=${airtable}`);\n}\n\n/** The \"Submissions (24h)\" telemetry window — a precise 24h ISO-timestamp compare\n * (unlike screenOutsSince's date-only strings). */\nconst SUBMISSIONS_DIGEST_WINDOW_MS = 24 * 60 * 60 * 1000;\n\n/** Per-site submission counts for the digest telemetry section, read from libSQL.\n * Dynamic imports + fail-soft for the same reason as fetchNotifyBounceCounts:\n * kysely/libsql are devDependencies consuming fleet sites don't install, and a\n * Turso blip must drop just this section, never the digest. Returns null (not an\n * empty map) on failure so the renderer can omit the section rather than claim a\n * quiet fleet. */\nasync function fetchSubmissionsDigestCounts(\n now: Date,\n): Promise<ReadonlyMap<string, SiteSubmissionCounts> | null> {\n try {\n const [{ openDb, readDbConfig }, { countSubmissionsSinceBySite }] = await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/submissions.js\"),\n ]);\n const db = await openDb(readDbConfig());\n return await countSubmissionsSinceBySite(\n db,\n new Date(now.getTime() - SUBMISSIONS_DIGEST_WINDOW_MS).toISOString(),\n );\n } catch (e) {\n console.warn(`⚠ submissions telemetry unavailable (libSQL): ${(e as Error).message}`);\n return null;\n }\n}\n\n/** Assemble the \"Submissions (24h)\" section from the per-site counts. PURE (exported\n * for direct tests). Totals sum ALL map entries — an orphan site id still counts in\n * the fleet totals. bySite lists only ids that resolve in `sitesById` AND carry any\n * nonzero count, sorted by total volume desc then siteName A-Z. null counts (libSQL\n * unavailable) → null (section omitted). */\nexport function buildSubmissionsDigestSection(\n counts: ReadonlyMap<string, SiteSubmissionCounts> | null,\n sitesById: ReadonlyMap<string, WebsiteRow>,\n): SubmissionsDigestSection | null {\n if (counts === null) return null;\n let leads = 0;\n let signups = 0;\n let spamAuto = 0;\n const bySite: SubmissionsDigestSection[\"bySite\"] = [];\n for (const [siteId, c] of counts) {\n leads += c.leads;\n signups += c.signups;\n spamAuto += c.spamAuto;\n const site = sitesById.get(siteId);\n if (site && c.leads + c.signups + c.spamAuto > 0) {\n bySite.push({ siteName: site.name, ...c });\n }\n }\n bySite.sort(\n (a, b) =>\n b.leads + b.signups + b.spamAuto - (a.leads + a.signups + a.spamAuto) ||\n a.siteName.localeCompare(b.siteName),\n );\n return { leads, signups, spamAuto, bySite };\n}\n\n/** Run a single collector under a try/catch: a thrown collector logs and yields []\n * so one broken signal never blanks the whole \"Needs attention\" section. */\nfunction runCollector(label: string, fn: () => AttentionItem[]): AttentionItem[] {\n try {\n return fn();\n } catch (e) {\n console.warn(`⚠ attention collector \"${label}\" failed: ${(e as Error).message}`);\n return [];\n }\n}\n\n/**\n * Fetch the free signals once (listAllReports + listWebsites) — or reuse the\n * `reports`/`websites` arrays runDigest already read, so a single run reads each\n * table once — build the sitesById map the delivery collector needs, and run each\n * pure collector isolated. Returns the union of items; diffing/badging happens in\n * runDigest.\n *\n * The Renovate + CI signals come from the SAME persisted collectors the operator\n * cockpit (`buildCockpitModel`) runs — `collectRenovateAlerts` (key\n * `renovate:<siteId>`) and `collectCiAlerts` (key `ci:<siteId>`), reading the\n * nightly-persisted `renovateFailingCis`/`defaultBranchCi` fields. This is the\n * key-space unification: because the digest writes the shared Digest State\n * snapshot with these same keys, the cockpit's NEW/WORSE diff finds them and the\n * two surfaces agree (the prior live per-PR `renovate:<repo>#<n>` sweep never\n * matched the cockpit's keys, so its cards badged NEW forever).\n */\nexport async function collectAttention(deps: CollectAttentionDeps): Promise<AttentionItem[]> {\n const reports = deps.reports ?? (await listAllReports(deps.base));\n const websites = deps.websites ?? (await listWebsites(deps.base));\n const now = deps.now ?? new Date();\n const notifyBounces = deps.notifyBounces ?? (await fetchNotifyBounceCounts(now));\n const sitesById = new Map<string, WebsiteRow>(websites.map((w) => [w.id, w]));\n return [\n ...runCollector(\"vuln\", () => collectVulnAlerts(websites, deps.baseUrl)),\n ...runCollector(\"delivery\", () => collectDeliveryFailures(reports, sitesById, deps.baseUrl)),\n ...runCollector(\"preflight\", () => collectPreflightBlocked(reports, sitesById, deps.baseUrl)),\n ...runCollector(\"lighthouse\", () => collectLighthouseAlerts(websites, deps.baseUrl)),\n ...runCollector(\"renovate\", () => collectRenovateAlerts(websites, deps.baseUrl, now)),\n ...runCollector(\"ci\", () => collectCiAlerts(websites, deps.baseUrl, now)),\n ...runCollector(\"analytics\", () => collectAnalyticsFailures(websites, deps.baseUrl, now)),\n ...runCollector(\"turnstile\", () =>\n collectTurnstileGuardrailAlerts(websites, deps.baseUrl, now),\n ),\n ...runCollector(\"notify-bounce\", () =>\n collectNotifyBounceAlerts(websites, notifyBounces, deps.baseUrl),\n ),\n // Same collector the cockpit runs, over the same persisted `Prismic Models`\n // columns and the same keys — so the snapshot this digest writes is the one\n // the cockpit diffs NEW/WORSE against, and the two surfaces agree.\n ...runCollector(\"prismic-drift\", () => collectPrismicDriftAlerts(websites, deps.baseUrl, now)),\n ];\n}\n\nexport type DigestRunOptions = {\n resend?: ResendClient;\n /** Dashboard origin for the /s/<slug> links, e.g. \"https://reddoor-maintenance.netlify.app\". */\n baseUrl: string;\n /**\n * Inject a pre-opened Airtable base (tests, server handlers).\n * When omitted, `openBase(readAirtableConfig())` is called from the environment.\n */\n base?: AirtableBase;\n /** Per-site submission counts for the \"Submissions (24h)\" section. undefined =\n * fetch from libSQL; null = simulate unavailable (section omitted); a Map =\n * injected (tests). */\n submissionCounts?: ReadonlyMap<string, SiteSubmissionCounts> | null;\n /**\n * The prior-run snapshot store (#609). Production omits it and gets Turso.\n *\n * Injectable because the read is deliberately NOT defensive: swallowing a\n * failure would badge every item NEW, which lands in the operator's inbox\n * reading as \"the whole fleet degraded overnight\". That makes Turso a hard\n * requirement of a real run — so tests, which have no libSQL, need a seam\n * rather than an env var.\n */\n digestState?: {\n read: () => Promise<DigestSnapshot>;\n write: (snap: DigestSnapshot) => Promise<void>;\n };\n /**\n * The run clock. Production omits it and gets `new Date()`.\n *\n * Injectable because several collectors this run feeds are AGE-SENSITIVE —\n * `collectPrismicDriftAlerts` alone changes an item's key and its wording once\n * a verdict crosses `PRISMIC_DRIFT_STALE_DAYS` — so a digest test that cannot\n * fix the clock is asserting on the day it happens to run. Two did, and both\n * went red four days after they were written, reporting the staleness wording\n * as if the drift wiring had broken. `collectAttention` already takes a `now`\n * for exactly this reason; this threads the same lever one layer out.\n */\n now?: Date;\n};\n\nexport async function runDigest(\n options: DigestRunOptions,\n): Promise<{ output: string; code: number }> {\n // Capture clock BEFORE any await so the idempotency key can't roll past midnight mid-run.\n const today = options.now ?? new Date();\n try {\n const base = options.base ?? openBase(readAirtableConfig());\n // Read each table ONCE for the whole run, then thread the arrays into\n // collectAttention so it doesn't re-fetch (was: listWebsites ×2, listAllReports\n // ×2). Pending is derived in-line with listPendingApproval's exact predicate.\n const reports = await listAllReports(base);\n const websites = await listWebsites(base);\n const sites = new Map(websites.map((w) => [w.id, w]));\n\n const pending = reports.filter(isPendingApproval);\n\n const readyForYourYes: ReadyItem[] = [];\n const baseUrl = options.baseUrl.replace(/\\/$/, \"\");\n for (const r of pending) {\n const site = sites.get(r.siteId);\n if (!site) continue; // orphan report → skip rather than render a broken link\n // An empty Name slugs to \"\" → `/s/` is a dead link (getWebsiteBySlug can't\n // match it). Fall back to the fleet homepage so the operator still lands\n // somewhere usable instead of a 404.\n const slug = siteSlug(site.name);\n readyForYourYes.push({\n siteName: site.name,\n reportType: r.reportType,\n period: r.period ?? \"—\",\n dashboardUrl: slug ? `${baseUrl}/s/${slug}` : baseUrl,\n });\n }\n\n // M5: collect the free signals (isolated), diff against yesterday's snapshot.\n // Renovate + CI come from the persisted collectors (the same ones the cockpit\n // runs), so the snapshot this digest writes carries the `renovate:<siteId>` /\n // `ci:<siteId>` keys the cockpit diffs against — no live GitHub sweep here.\n const collected = await collectAttention({\n base,\n baseUrl: options.baseUrl,\n websites,\n reports,\n now: today,\n });\n // #609: Turso is the read side now. Airtable keeps being WRITTEN below\n // (dual-write, Phase 5's pattern everywhere else) so the move stays\n // reversible, but nothing reads it any more.\n const prior = await (options.digestState?.read ?? readDigestStateFromDb)();\n const { tagged, next } = diffAttention(collected, prior, digestDateKey(today));\n // The operator only HEARS about a vuln once Renovate's auto-fix is exhausted\n // (tried and failed a couple of nightly cycles) — before that the fleet is\n // still self-patching and the cockpit's amber Watch band is the only surface.\n // Filter the EMAIL list only: `next` (written below) must keep every vuln key\n // so the cockpit's diff against this same snapshot stays consistent, and so\n // the exhausted-flip diffs as WORSE instead of arriving pre-badged-away.\n const needsAttention = tagged.filter(\n (it) => it.kind !== \"vuln\" || it.autoFixExhausted === true,\n );\n\n // No-noise default: skip entirely when there's nothing to report.\n if (readyForYourYes.length === 0 && needsAttention.length === 0) {\n // Persist `next` even on a skip: a key that resolved on a quiet day clears so a\n // later recurrence diffs as NEW (spec §10), and — since the skip can now fire\n // with muted pre-exhaustion vulns collected — their keys keep snapshotting so\n // the eventual exhausted-flip diffs as WORSE. Wrapped: a write failure can't\n // fail the skip.\n await persistDigestState(base, next, options.digestState?.write);\n return { output: \"Digest skipped (nothing ready, nothing needs attention).\", code: 0 };\n }\n\n // Submissions telemetry rides only on a digest that is ALREADY sending — the\n // skip predicate above is deliberately unchanged (leads already fire their own\n // ingest-time notification; this is context, not an alarm), and fetching AFTER\n // the skip check keeps quiet days from touching libSQL at all.\n const counts =\n options.submissionCounts !== undefined\n ? options.submissionCounts\n : await fetchSubmissionsDigestCounts(today);\n const submissions = buildSubmissionsDigestSection(counts, sites);\n\n const html = renderDigestHtml({ readyForYourYes, needsAttention, submissions });\n const client = options.resend ?? defaultResendClient();\n const to = [operatorEmail()];\n const n = readyForYourYes.length;\n const reportWord = n === 1 ? \"report\" : \"reports\";\n let result: Awaited<ReturnType<typeof client.send>>;\n try {\n result = await client.send({\n from: FROM_ADDRESS,\n to,\n subject: `Your fleet — ${digestDateKey(today)}: ${n} ${reportWord} ready for your yes`,\n html,\n idempotencyKey: `digest-${digestDateKey(today)}`,\n });\n } catch (err) {\n // A same-UTC-day re-run whose content changed re-sends with the same\n // `digest-<date>` idempotency key but a DIFFERENT body. Resend rejects that\n // with a 409 (`invalid_idempotent_request`) — \"This idempotency key has been\n // used ... but the request body was modified ...\". The operator already got\n // today's digest on the first send, so re-sending a changed version same-day\n // would just be a duplicate: treat it as an \"already sent today\" no-op.\n //\n // ResendClient (send/resend.ts) wraps the API error as a plain Error and only\n // preserves the *message* string (no name/statusCode), so the message\n // substring is the only reliable discriminator — match defensively on it.\n // Any OTHER send error re-throws to the outer catch → {code:1}, so a genuine\n // Resend/network failure still fails loudly.\n if (isIdempotencyConflict(err)) {\n // Do NOT write the snapshot: the first send already persisted it; writing\n // this run's `next` would diff against the first run's snapshot and mis-badge.\n return {\n output:\n \"Digest already sent today (content changed since the first send) — skipped to avoid a duplicate.\",\n code: 0,\n };\n }\n throw err;\n }\n // Persist the next snapshot AFTER a successful send. A write failure is caught +\n // logged: the digest already went out, tomorrow re-news at worst. (The send-FAILURE\n // path never reaches here — the outer catch returns code 1 with no write, preserving\n // the NEW badge for the retry.)\n await persistDigestState(base, next, options.digestState?.write);\n return { output: `Digest sent to ${to.join(\", \")} (${result.messageId})`, code: 0 };\n } catch (err) {\n // Re-throw config errors (exitCode=2: missing env vars, bad config) so runOrExit\n // surfaces them with the correct process exit code rather than collapsing to 1.\n if (typeof (err as { exitCode?: unknown }).exitCode === \"number\") {\n throw err;\n }\n const message = err instanceof Error ? err.message : String(err);\n return { output: `digest failed: ${message}`, code: 1 };\n }\n}\n\n/** Pure render of the unified daily operator digest. No IO — the caller (runDigest)\n * collects the rows and decides whether to send. */\nexport function renderDigestHtml(sections: DigestSections): string {\n return `<!doctype html>\n<html>\n <head><meta charset=\"utf-8\"></head>\n <body style=\"margin:0;padding:0;background:#ffffff\">\n <table width=\"100%\" style=\"border-collapse:collapse\">\n <tr>\n <td align=\"center\" style=\"padding:24px\">\n <table width=\"600\" style=\"border-collapse:collapse\">\n <tr>\n <td>\n <h1 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px\">Your fleet today</h1>\n ${readySection(sections.readyForYourYes)}\n ${attentionSection(sections.needsAttention)}\n ${submissionsSection(sections.submissions)}\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n </body>\n</html>`;\n}\n"],"mappings":"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","names":["writeDigestState"]}
@@ -0,0 +1,25 @@
1
+ // src/db/digest-state.ts
2
+ var DIGEST_STATE_ID = "default";
3
+ async function readDigestState(db) {
4
+ const row = await db.selectFrom("digest_state").select("snapshot").where("id", "=", DIGEST_STATE_ID).executeTakeFirst();
5
+ if (!row) return {};
6
+ try {
7
+ return JSON.parse(row.snapshot);
8
+ } catch {
9
+ return {};
10
+ }
11
+ }
12
+ async function writeDigestState(db, snap, updatedAt = (/* @__PURE__ */ new Date()).toISOString()) {
13
+ const row = {
14
+ id: DIGEST_STATE_ID,
15
+ snapshot: JSON.stringify(snap),
16
+ updated_at: updatedAt
17
+ };
18
+ await db.insertInto("digest_state").values(row).onConflict((oc) => oc.column("id").doUpdateSet(row)).execute();
19
+ }
20
+ export {
21
+ DIGEST_STATE_ID,
22
+ readDigestState,
23
+ writeDigestState
24
+ };
25
+ //# sourceMappingURL=digest-state-CRZRNGDI.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/db/digest-state.ts"],"sourcesContent":["/** #609 (#539 Phase 5): the digest's prior-run snapshot, on Turso.\n *\n * Unlike every other Phase 5 slice this is a MIGRATION, not a mirror — there\n * was no Turso table to dual-write into, so these REPLACE the Airtable pair in\n * `src/alerts/digest-state.ts` rather than shadowing them. Nothing writes the\n * Airtable \"Digest State\" table after this, so parity does not cover the new\n * one: it has no counterpart to compare against.\n *\n * The pure half — `diffAttention`, and the `DigestSnapshot` type it is written\n * against — stays exactly where it is. Only the IO moved.\n *\n * Stored as ONE row holding the whole snapshot as JSON. Both readers\n * (`runDigest`'s diff and the fleet homepage's NEW badges) need the entire map,\n * so a keyed table would buy nothing on reads and its \"give me everything\"\n * query would be a raw scan needing a justified entry in the EXPLAIN gate's\n * allowlist. One row by primary key needs neither.\n *\n * Worth stating plainly: the homepage's read of this used to be an AIRTABLE\n * call on a request path — a Phase 2 leftover, since digest state was never in\n * that phase's scope. Moving it removes a live dependency, which is the same\n * failure class as the 2026-08-17 quota outage.\n */\nimport type { Db } from \"./client.js\";\nimport type { DigestSnapshot } from \"../alerts/digest-state.js\";\n\n/** The singleton row's key. A constant rather than a magic string at each call\n * site, so the \"there is exactly one row\" invariant is visible in one place. */\nexport const DIGEST_STATE_ID = \"default\";\n\n/**\n * The prior snapshot, or `{}` when there is none.\n *\n * A missing row and a malformed blob BOTH read as `{}` — the Airtable reader's\n * contract, kept verbatim. The digest runs unattended on a cron, so a bad blob\n * must cost accurate NEW badges for one run, never the whole send.\n */\nexport async function readDigestState(db: Db): Promise<DigestSnapshot> {\n const row = await db\n .selectFrom(\"digest_state\")\n .select(\"snapshot\")\n .where(\"id\", \"=\", DIGEST_STATE_ID)\n .executeTakeFirst();\n if (!row) return {};\n try {\n return JSON.parse(row.snapshot) as DigestSnapshot;\n } catch {\n return {};\n }\n}\n\n/**\n * Persist the next snapshot, replacing whatever was there.\n *\n * An upsert on the constant id rather than a get-then-create: the Airtable\n * version needed two round-trips to decide, and here the singleton invariant is\n * enforced by the primary key instead of by the code that happens to run.\n */\nexport async function writeDigestState(\n db: Db,\n snap: DigestSnapshot,\n updatedAt: string = new Date().toISOString(),\n): Promise<void> {\n const row = {\n id: DIGEST_STATE_ID,\n snapshot: JSON.stringify(snap),\n updated_at: updatedAt,\n };\n await db\n .insertInto(\"digest_state\")\n .values(row)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(row))\n .execute();\n}\n"],"mappings":";AA2BO,IAAM,kBAAkB;AAS/B,eAAsB,gBAAgB,IAAiC;AACrE,QAAM,MAAM,MAAM,GACf,WAAW,cAAc,EACzB,OAAO,UAAU,EACjB,MAAM,MAAM,KAAK,eAAe,EAChC,iBAAiB;AACpB,MAAI,CAAC,IAAK,QAAO,CAAC;AAClB,MAAI;AACF,WAAO,KAAK,MAAM,IAAI,QAAQ;AAAA,EAChC,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AASA,eAAsB,iBACpB,IACA,MACA,aAAoB,oBAAI,KAAK,GAAE,YAAY,GAC5B;AACf,QAAM,MAAM;AAAA,IACV,IAAI;AAAA,IACJ,UAAU,KAAK,UAAU,IAAI;AAAA,IAC7B,YAAY;AAAA,EACd;AACA,QAAM,GACH,WAAW,cAAc,EACzB,OAAO,GAAG,EACV,WAAW,CAAC,OAAO,GAAG,OAAO,IAAI,EAAE,YAAY,GAAG,CAAC,EACnD,QAAQ;AACb;","names":[]}
@@ -0,0 +1,13 @@
1
+ import {
2
+ DIGEST_STATE_TABLE,
3
+ diffAttention,
4
+ readDigestState,
5
+ writeDigestState
6
+ } from "./chunk-BRYXFH77.js";
7
+ export {
8
+ DIGEST_STATE_TABLE,
9
+ diffAttention,
10
+ readDigestState,
11
+ writeDigestState
12
+ };
13
+ //# sourceMappingURL=digest-state-F37XUCS5.js.map
@@ -13,10 +13,27 @@ function sqlLiteral(v) {
13
13
  for (const b of bytes) hex += b.toString(16).padStart(2, "0");
14
14
  return `X'${hex}'`;
15
15
  }
16
- return `'${String(v).replaceAll("\0", "").replaceAll("'", "''")}'`;
16
+ const str = String(v);
17
+ if (str.includes("\0")) {
18
+ const bytes = new TextEncoder().encode(str);
19
+ let hex = "";
20
+ for (const b of bytes) hex += b.toString(16).padStart(2, "0");
21
+ return `CAST(X'${hex}' AS TEXT)`;
22
+ }
23
+ return `'${str.replaceAll("'", "''")}'`;
17
24
  }
18
- async function dumpDatabase(db) {
19
- const out = ["PRAGMA foreign_keys=OFF;", "BEGIN TRANSACTION;"];
25
+ async function dumpDatabase(db, generatedAt) {
26
+ const manifest = {
27
+ tables: await tableCounts(db),
28
+ blobBytes: await headerImageBytes(db),
29
+ generatedAt
30
+ };
31
+ const out = [
32
+ // First line, so a truncated dump still carries what it CLAIMED to hold.
33
+ `${MANIFEST_PREFIX}${JSON.stringify(manifest)}`,
34
+ "PRAGMA foreign_keys=OFF;",
35
+ "BEGIN TRANSACTION;"
36
+ ];
20
37
  const schema = await db.execute(
21
38
  "SELECT name, type, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY CASE type WHEN 'table' THEN 0 ELSE 1 END, name"
22
39
  );
@@ -37,15 +54,21 @@ async function dumpDatabase(db) {
37
54
  out.push("COMMIT;");
38
55
  return out.join("\n") + "\n";
39
56
  }
40
- function countInsertsInDump(sql) {
41
- const counts = {};
42
- const re = /^INSERT INTO ("(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_]*) /gm;
43
- for (const m of sql.matchAll(re)) {
44
- const raw = m[1];
45
- const name = raw.startsWith('"') ? raw.slice(1, -1).replaceAll('""', '"') : raw;
46
- counts[name] = (counts[name] ?? 0) + 1;
57
+ var MANIFEST_PREFIX = "-- REDDOOR_DUMP_MANIFEST ";
58
+ async function headerImageBytes(db) {
59
+ const r = await db.execute(
60
+ "SELECT COALESCE(SUM(LENGTH(header_image)), 0) AS n FROM sites WHERE header_image IS NOT NULL"
61
+ );
62
+ return Number(r.rows[0]?.n ?? 0);
63
+ }
64
+ function parseDumpManifest(sql) {
65
+ const line = sql.split("\n").find((l) => l.startsWith(MANIFEST_PREFIX));
66
+ if (!line) return null;
67
+ try {
68
+ return JSON.parse(line.slice(MANIFEST_PREFIX.length));
69
+ } catch {
70
+ return null;
47
71
  }
48
- return counts;
49
72
  }
50
73
  async function tableCounts(db) {
51
74
  const schema = await db.execute(
@@ -60,9 +83,11 @@ async function tableCounts(db) {
60
83
  return counts;
61
84
  }
62
85
  export {
63
- countInsertsInDump,
86
+ MANIFEST_PREFIX,
64
87
  dumpDatabase,
88
+ headerImageBytes,
89
+ parseDumpManifest,
65
90
  sqlLiteral,
66
91
  tableCounts
67
92
  };
68
- //# sourceMappingURL=dump-P6EBSIOH.js.map
93
+ //# sourceMappingURL=dump-EPL7AZFR.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/db/dump.ts"],"sourcesContent":["/**\n * Phase 1.5 of #539: a platform-auth-free SQL dump of the whole database.\n *\n * `turso db dump` needs a PLATFORM login (browser OAuth) — a nightly workflow\n * has only the DATABASE-level url+token it already holds for every other job.\n * So the dump speaks plain SQL through the same client: schema straight from\n * `sqlite_master`, then every row as an INSERT. The output loads into stock\n * `sqlite3` (libSQL IS SQLite), which is exactly how the rehearsed restore\n * proves it — and how a real disaster would replay it into a fresh database.\n *\n * Determinism: tables and rows are emitted in stable order (name, then rowid)\n * so two dumps of an unchanged database are byte-identical — a diffable backup.\n */\n\n/** Minimal execute surface: the @libsql/client `execute` we need. Injectable so\n * tests run against :memory: without the real network client. */\nexport type SqlExecutor = {\n execute: (sql: string) => Promise<{\n columns: string[];\n rows: Array<Record<string, unknown>>;\n }>;\n};\n\nconst IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;\n\nfunction quoteIdent(name: string): string {\n return IDENT.test(name) ? name : `\"${name.replaceAll('\"', '\"\"')}\"`;\n}\n\nexport function sqlLiteral(v: unknown): string {\n if (v === null || v === undefined) return \"NULL\";\n if (typeof v === \"number\") return Number.isFinite(v) ? String(v) : \"NULL\";\n if (typeof v === \"bigint\") return v.toString();\n if (v instanceof Uint8Array || v instanceof ArrayBuffer) {\n const bytes = v instanceof ArrayBuffer ? new Uint8Array(v) : v;\n let hex = \"\";\n for (const b of bytes) hex += b.toString(16).padStart(2, \"0\");\n return `X'${hex}'`;\n }\n const str = String(v);\n // A NUL cannot ride a single-quoted SQL literal. This used to strip them,\n // justified as \"they cannot legitimately appear in this schema's TEXT\n // columns\" — but `submissions` free text is attacker-supplied and SQLite\n // stores NUL inside TEXT quite happily, so the backup would have silently\n // differed from the origin with no signal. Emit the whole value as a hex blob\n // cast back to text instead: lossless, and it round-trips through the\n // rehearsal like any other literal.\n if (str.includes(\"\\u0000\")) {\n const bytes = new TextEncoder().encode(str);\n let hex = \"\";\n for (const b of bytes) hex += b.toString(16).padStart(2, \"0\");\n return `CAST(X'${hex}' AS TEXT)`;\n }\n // Strings: standard SQL escaping — double the single quotes.\n return `'${str.replaceAll(\"'\", \"''\")}'`;\n}\n\n/**\n * Dump schema + data as executable SQL. Skips SQLite's internal tables\n * (`sqlite_*`); includes indexes. Wrapped in a transaction so a partial load\n * fails atomically instead of leaving a half-restored scratch that could be\n * mistaken for a good one.\n */\nexport async function dumpDatabase(db: SqlExecutor, generatedAt: string): Promise<string> {\n // Read the ORIGIN's own numbers BEFORE serialising anything. This is the\n // whole point: it is the only measurement that does not come from the dump,\n // so it is the only one that can notice the dump is short.\n const manifest: DumpManifest = {\n tables: await tableCounts(db),\n blobBytes: await headerImageBytes(db),\n generatedAt,\n };\n const out: string[] = [\n // First line, so a truncated dump still carries what it CLAIMED to hold.\n `${MANIFEST_PREFIX}${JSON.stringify(manifest)}`,\n \"PRAGMA foreign_keys=OFF;\",\n \"BEGIN TRANSACTION;\",\n ];\n\n const schema = await db.execute(\n \"SELECT name, type, sql FROM sqlite_master \" +\n \"WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' \" +\n \"ORDER BY CASE type WHEN 'table' THEN 0 ELSE 1 END, name\",\n );\n\n const tables: string[] = [];\n for (const row of schema.rows) {\n out.push(`${String(row.sql).trim().replace(/;?$/, \"\")};`);\n if (row.type === \"table\") tables.push(String(row.name));\n }\n\n for (const table of tables) {\n const data = await db.execute(`SELECT * FROM ${quoteIdent(table)} ORDER BY rowid`);\n if (data.rows.length === 0) continue;\n const cols = data.columns.map(quoteIdent).join(\", \");\n for (const row of data.rows) {\n const values = data.columns.map((c) => sqlLiteral(row[c])).join(\", \");\n out.push(`INSERT INTO ${quoteIdent(table)} (${cols}) VALUES (${values});`);\n }\n }\n\n out.push(\"COMMIT;\");\n return out.join(\"\\n\") + \"\\n\";\n}\n\n/** Marker for the origin manifest line, first line of every dump. */\nexport const MANIFEST_PREFIX = \"-- REDDOOR_DUMP_MANIFEST \";\n\n/** What the ORIGIN database held at dump time.\n *\n * This exists because the restore rehearsal used to compare the dump against\n * ITSELF: expected counts were parsed out of the dump text, and actual counts\n * came from loading that same text. Both sides moved together, so a dump that\n * collected 5 of 44 sites verified clean. A manifest read from the LIVE\n * database before any rows are serialised is the only thing that can catch\n * under-dumping.\n *\n * `blobBytes` is the cheap content check. Row counts alone pass a dump in\n * which every `header_image` came back NULL — and those bytes exist in no\n * other store once Airtable is frozen. */\nexport type DumpManifest = {\n tables: Record<string, number>;\n blobBytes: number;\n generatedAt: string;\n};\n\n/** Total stored header-image bytes, the one content signal cheap enough to\n * check on every nightly run. */\nexport async function headerImageBytes(db: SqlExecutor): Promise<number> {\n const r = await db.execute(\n \"SELECT COALESCE(SUM(LENGTH(header_image)), 0) AS n FROM sites WHERE header_image IS NOT NULL\",\n );\n return Number(r.rows[0]?.n ?? 0);\n}\n\n/** Parse the manifest line from a dump, or null when the dump predates it. */\nexport function parseDumpManifest(sql: string): DumpManifest | null {\n const line = sql.split(\"\\n\").find((l) => l.startsWith(MANIFEST_PREFIX));\n if (!line) return null;\n try {\n return JSON.parse(line.slice(MANIFEST_PREFIX.length)) as DumpManifest;\n } catch {\n return null;\n }\n}\n\n/** Per-table row counts — the cheap integrity check the restore rehearsal\n * compares across original vs restored. */\nexport async function tableCounts(db: SqlExecutor): Promise<Record<string, number>> {\n const schema = await db.execute(\n \"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name\",\n );\n const counts: Record<string, number> = {};\n for (const row of schema.rows) {\n const name = String(row.name);\n const r = await db.execute(`SELECT COUNT(*) AS c FROM ${quoteIdent(name)}`);\n counts[name] = Number(r.rows[0]?.c ?? 0);\n }\n return counts;\n}\n"],"mappings":"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","names":[]}
@@ -1,17 +1,17 @@
1
1
  import {
2
2
  renderProspectReport
3
- } from "./chunk-DEWJMMG3.js";
4
- import "./chunk-HSI6Q7MI.js";
3
+ } from "./chunk-A2UNJIKH.js";
4
+ import "./chunk-7TMWBXX7.js";
5
5
  import {
6
6
  escapeHtml,
7
7
  safeUrl
8
- } from "./chunk-DJZWYIPP.js";
8
+ } from "./chunk-BGTYPVLT.js";
9
9
  import {
10
10
  defaultResendClient
11
11
  } from "./chunk-WEKL2HYJ.js";
12
12
  import {
13
13
  hostnameOf
14
- } from "./chunk-EVBCK2KC.js";
14
+ } from "./chunk-RARREDQE.js";
15
15
 
16
16
  // src/prospect/email.ts
17
17
  var FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
@@ -181,7 +181,17 @@ async function sendAuditEmail(result, opts) {
181
181
  filename,
182
182
  content: Buffer.from(attachmentHtml, "utf-8").toString("base64"),
183
183
  contentType: "text/html"
184
- }
184
+ },
185
+ // The PDF goes second so the HTML sheet stays the first attachment it has
186
+ // always been, and appears only when one was actually rendered — an empty
187
+ // or broken attachment is worse than none.
188
+ ...opts.pdf ? [
189
+ {
190
+ filename: filename.replace(/\.html$/, ".pdf"),
191
+ content: opts.pdf.toString("base64"),
192
+ contentType: "application/pdf"
193
+ }
194
+ ] : []
185
195
  ],
186
196
  idempotencyKey
187
197
  });
@@ -192,4 +202,4 @@ export {
192
202
  parseProspectAuditRecipients,
193
203
  sendAuditEmail
194
204
  };
195
- //# sourceMappingURL=email-KJXASVH5.js.map
205
+ //# sourceMappingURL=email-SL7464YI.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/prospect/email.ts"],"sourcesContent":["import { escapeHtml, safeUrl } from \"../util/html.js\";\nimport { hostnameOf } from \"../util/url.js\";\nimport { renderProspectReport } from \"./render.js\";\nimport { defaultResendClient, type ResendClient } from \"../reports/send/resend.js\";\nimport type { AnalyzeResult, Fix, ProspectAuditResult, StageResult } from \"./types.js\";\n\n/**\n * Mirrors `reports/send/orchestrate.ts`'s `FROM_ADDRESS` (its line 18) — copied,\n * not imported, exactly like `digest.ts` and `recipes/selftest-email.ts` already\n * do. orchestrate.ts's own `FROM_ADDRESS` isn't exported, and importing it would\n * drag its whole Airtable/MJML dependency chain into this module's graph, which\n * stays lazy-loaded on purpose (see bin.ts's central-dep-blocker comment — this\n * module reaches `resend`, a devDependency, and must only ever be reached via a\n * dynamic `import()` from the CLI).\n */\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\n\nconst IMPACT_ORDER: Record<Fix[\"impact\"], number> = { high: 0, medium: 1, low: 2 };\n\n/** \"top few fixes\" per the design — enough to be useful on an internal sheet\n * without turning it into the full client report. */\nconst TOP_FIX_COUNT = 3;\n\nexport type AuditEmailContent = {\n subject: string;\n html: string;\n /** The FULL client-facing report (render.ts's `renderProspectReport`, pure\n * and deterministic like this function), so `sendAuditEmail` can attach it\n * without a second render call on the default path. */\n attachmentHtml: string;\n};\n\n/** The searchable business name when the pipeline resolved one, else the\n * hostname — mirrors render.ts's identical fallback (types.ts's businessName\n * doc: \"Null when no name was ever resolved... itself a finding, not an\n * error\"). Used for both the subject line and the sheet's own heading. */\nfunction resolveName(result: ProspectAuditResult): string {\n return result.businessName && result.businessName.trim()\n ? result.businessName\n : hostnameOf(result.url);\n}\n\n/**\n * Mail subject headers are plain text, never HTML-rendered by a client — no\n * `escapeHtml` needed there (that would wrongly show literal \"&lt;\" etc. in\n * the subject line). A raw newline/control character IS worth collapsing:\n * a business name that embeds one (scraped page title, model output) would\n * otherwise visibly break the subject, and a stray CR/LF in a header value\n * is the classic email-header-injection vector — defense-in-depth even\n * though the Resend API's JSON transport already neutralizes it here.\n */\nfunction subjectSafe(s: string): string {\n return s.replace(/\\s+/g, \" \").trim();\n}\n\n/** Local copy of render.ts's date formatter — small enough that importing it\n * (render.ts doesn't export it) isn't worth the coupling. */\nfunction formatIsoDate(iso: string): string {\n const d = new Date(iso);\n if (Number.isNaN(d.getTime())) return iso;\n return d.toLocaleDateString(\"en-US\", {\n year: \"numeric\",\n month: \"long\",\n day: \"numeric\",\n timeZone: \"UTC\",\n });\n}\n\ntype NotMeasuredLine = { label: string; reason: string };\n\n/**\n * Everything on the sheet that reads \"not measured\", and why — in plain\n * words. Unlike render.ts's client-facing report (which collapses every\n * stage failure to STAGE_FAILED_MESSAGE so a stranger never sees operator\n * vocabulary), this email goes to Tucker/Tim/Erik: the real diagnostic\n * (a fetch error, an LLM timeout) is more useful to them than a sanitized\n * phrase, so stage `error` strings are surfaced verbatim (still escaped —\n * they can embed arbitrary text from a fetch response).\n *\n * The four-Scores branches mirror checks.ts's `computeScores` exactly: which\n * stage feeds which score, and the cases where that stage's OWN data still\n * nulls the score even though the stage itself succeeded (crawlerAccessMeasured\n * false, avgMissing null, empty buyerQuestions, a null visibilityScore). If\n * computeScores' conditions change, this needs the matching update.\n */\nfunction notMeasuredLines(result: ProspectAuditResult): NotMeasuredLine[] {\n const lines: NotMeasuredLine[] = [];\n\n if (!result.checks.ok) {\n lines.push({\n label: \"Findability\",\n reason: `the checks stage failed — ${result.checks.error}`,\n });\n lines.push({\n label: \"Readability\",\n reason: `the checks stage failed — ${result.checks.error}`,\n });\n } else {\n const c = result.checks.data;\n if (result.scores.findability === null) {\n lines.push({\n label: \"Findability\",\n reason: !c.crawlerAccessMeasured\n ? \"robots.txt could not be fetched, so crawler access wasn't measured\"\n : \"every crawled page failed to produce readable content\",\n });\n }\n if (result.scores.readability === null) {\n lines.push({\n label: \"Readability\",\n reason: \"no page produced a comparable raw/rendered pair to measure JavaScript dependence\",\n });\n }\n }\n\n if (!result.lighthouse.ok) {\n lines.push({ label: \"Lighthouse\", reason: result.lighthouse.error });\n }\n\n if (!result.analyze.ok) {\n lines.push({ label: \"Answers\", reason: result.analyze.error });\n } else if (result.scores.answers === null) {\n lines.push({ label: \"Answers\", reason: \"the model found no buyer questions to check\" });\n }\n\n if (!result.probes.ok) {\n lines.push({ label: \"AI Visibility\", reason: result.probes.error });\n } else if (result.scores.aiVisibility === null) {\n lines.push({ label: \"AI Visibility\", reason: \"no category (buyer-question) query ran\" });\n }\n\n return lines;\n}\n\nconst SCORE_FIELDS: { key: keyof ProspectAuditResult[\"scores\"]; label: string }[] = [\n { key: \"findability\", label: \"Findability\" },\n { key: \"readability\", label: \"Readability\" },\n { key: \"answers\", label: \"Answers\" },\n { key: \"aiVisibility\", label: \"AI Visibility\" },\n];\n\nfunction scoresTableHtml(scores: ProspectAuditResult[\"scores\"]): string {\n const cells = SCORE_FIELDS.map(({ key, label }) => {\n const value = scores[key];\n const display = value === null ? \"Not measured\" : String(value);\n const valueStyle =\n value === null ? \"color:#8a857e;font-size:14px;\" : \"color:#d71920;font-size:28px;\";\n return `<td style=\"padding:12px 16px;border:1px solid #e6e1da;text-align:center;\">\n <div style=\"font-weight:600;${valueStyle}\">${escapeHtml(display)}</div>\n <div style=\"font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:#57544f;margin-top:4px;\">${escapeHtml(label)}</div>\n </td>`;\n }).join(\"\");\n return `<table role=\"presentation\" style=\"border-collapse:collapse;margin:16px 0;\"><tr>${cells}</tr></table>`;\n}\n\nfunction notMeasuredHtml(lines: NotMeasuredLine[]): string {\n if (lines.length === 0) return \"\";\n const items = lines\n .map((l) => `<li><strong>${escapeHtml(l.label)}</strong> — ${escapeHtml(l.reason)}</li>`)\n .join(\"\");\n return `<h2 style=\"font-size:16px;margin:24px 0 8px;\">Not measured</h2><ul style=\"margin:0;padding-left:20px;\">${items}</ul>`;\n}\n\n/** analyze.error and fix titles/why are untrusted (LLM output, or text lifted\n * from the prospect's own site) — every interpolation below is escaped. */\nfunction fixesHtml(analyze: StageResult<AnalyzeResult>): string {\n if (!analyze.ok) {\n return `<p style=\"color:#8a857e;margin:8px 0 0;\">Fixes not available — ${escapeHtml(analyze.error)}</p>`;\n }\n const top = [...analyze.data.fixes]\n .sort((a, b) => IMPACT_ORDER[a.impact] - IMPACT_ORDER[b.impact])\n .slice(0, TOP_FIX_COUNT);\n if (top.length === 0) {\n return `<p style=\"margin:8px 0 0;\">Nothing urgent — the essentials are already in place.</p>`;\n }\n const items = top\n .map(\n (f) =>\n `<li><strong>${escapeHtml(f.title)}</strong> — ${escapeHtml(f.why)} <span style=\"color:#8a857e;\">(${escapeHtml(f.impact)} impact, ${escapeHtml(f.effort)} effort)</span></li>`,\n )\n .join(\"\");\n return `<ol style=\"margin:8px 0 0;padding-left:20px;\">${items}</ol>`;\n}\n\nfunction linkHtml(link: string | null): string {\n if (!link) {\n return `<p style=\"margin:24px 0 0;color:#8a857e;\">No shareable link — the audit could not be saved to the database. The full report is attached to this email instead.</p>`;\n }\n const safe = safeUrl(link);\n return `<p style=\"margin:24px 0 0;\"><strong>Full report:</strong> <a href=\"${escapeHtml(safe)}\">${escapeHtml(link)}</a></p>`;\n}\n\n/** This sheet is for us, and it says so. It carries the real reasons a stage did\n * not run — useful internally, wrong in front of a prospect — so it has to be\n * obvious that the shareable artefact is the attachment and the link, not this\n * body. Without the label, the natural thing to do with a useful email is\n * forward it. */\nconst INTERNAL_NOTE = `<p style=\"margin:24px 0 0;padding:12px 14px;background:#f4f2ef;border-left:3px solid #d71920;color:#57544f;font-size:14px;\">\n Internal sheet — it names the reasons a section could not be measured. The\n version to send a prospect is the linked report, also attached to this email.\n</p>`;\n\n/**\n * Build the internal sheet — a SHORT summary for Tucker/Tim/Erik, distinct\n * from the client-facing report render.ts builds. Pure: no IO, so it's safe\n * to call from a test with no network. `attachmentHtml` is the full report\n * (render.ts's `renderProspectReport`, itself pure), included here so\n * `sendAuditEmail`'s default path doesn't need a second render call.\n *\n * Every value that can trace back to the prospect's site or the model —\n * the business name, fixes, stage error text, the narrative-adjacent bits —\n * is escaped with `escapeHtml`/`safeUrl`, the same honesty-and-safety rule\n * render.ts follows for the client-facing report.\n */\nexport function buildAuditEmail(\n result: ProspectAuditResult,\n opts: { link: string | null },\n): AuditEmailContent {\n const name = resolveName(result);\n const subject = `Prospect audit — ${subjectSafe(name)}`;\n\n const html = `<!doctype html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /></head>\n<body style=\"margin:0;padding:24px;background:#faf8f5;color:#1a1a1a;font:15px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;\">\n <h1 style=\"font-size:22px;margin:0 0 4px;\">Prospect audit — ${escapeHtml(name)}</h1>\n <p style=\"margin:0 0 16px;color:#57544f;\">\n <a href=\"${escapeHtml(safeUrl(result.url))}\">${escapeHtml(result.url)}</a> · audited ${escapeHtml(formatIsoDate(result.generatedAt))}\n </p>\n ${scoresTableHtml(result.scores)}\n ${notMeasuredHtml(notMeasuredLines(result))}\n <h2 style=\"font-size:16px;margin:24px 0 8px;\">Top fixes</h2>\n ${fixesHtml(result.analyze)}\n ${linkHtml(opts.link)}\n ${INTERNAL_NOTE}\n</body>\n</html>`;\n\n return { subject, html, attachmentHtml: renderProspectReport(result) };\n}\n\n/**\n * Parse `PROSPECT_AUDIT_RECIPIENTS` (comma-separated) into a clean address\n * list: trimmed, empties dropped, case-insensitively deduped — mirrors\n * `reports/send/orchestrate.ts`'s `parseAddresses` for the same reasons (an\n * operator-typed env var, same typo surface). No address is ever hardcoded\n * anywhere in this module; an unset or empty var yields `[]`, which\n * `sendAuditEmail` treats as \"skip, and say why\" rather than an error.\n */\nexport function parseProspectAuditRecipients(raw: string | undefined | null): string[] {\n if (!raw) return [];\n const seen = new Set<string>();\n const list: string[] = [];\n for (const part of raw.split(\",\")) {\n const trimmed = part.trim().toLowerCase();\n if (!trimmed) continue;\n if (seen.has(trimmed)) continue;\n seen.add(trimmed);\n list.push(trimmed);\n }\n return list;\n}\n\n/** Filesystem-safe attachment-filename fragment. Tiny local copy of the CLI's\n * private `slugify` (prospect-audit.ts) — not worth exporting/sharing one\n * three-line predicate across a module boundary. */\nfunction slugify(s: string): string {\n const slug = s\n .toLowerCase()\n .replace(/[^a-z0-9.-]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n return slug || \"prospect\";\n}\n\nexport type SendAuditEmailOptions = {\n link: string | null;\n recipients: string[];\n /**\n * The persisted `prospect_audits` row id (from `createProspectAudit`), when\n * persistence succeeded. Mirrors orchestrate.ts's\n * `idempotencyKey: \\`report:${report.id}\\`` pattern, so re-sending for the\n * SAME already-persisted audit dedupes at Resend instead of double-mailing.\n * Null when persistence failed — the sheet can still be sent (it's built to\n * survive independently of Turso), but then there is no stable row id, so\n * the key falls back to `url`+`generatedAt`: that still protects a\n * same-process double-call, though — unlike a DB id — it can't survive an\n * entire pipeline rerun, which mints genuinely new content anyway.\n */\n auditId?: string | null;\n /** Defaults to `defaultResendClient()`. */\n client?: ResendClient;\n /** Test seam for the ATTACHMENT content only (production omits it and gets\n * the real `renderProspectReport` output `buildAuditEmail` already\n * computed) — lets a test of this function's sending mechanics (recipients,\n * idempotency key, attachment shape) skip re-deriving the full report. */\n renderReport?: (result: ProspectAuditResult) => string;\n};\n\nexport type SendAuditEmailResult =\n { sent: true; messageId: string; recipients: string[] } | { sent: false; reason: string };\n\n/**\n * Send the audit sheet with the full rendered report attached as an `.html`\n * file (base64), so the sheet survives independently of the link and of\n * Turso. Empty `recipients` is a legitimate, non-exceptional outcome (no\n * `PROSPECT_AUDIT_RECIPIENTS` configured) — returns `{sent:false}` rather\n * than throwing, so a caller can report it as a warning; a genuine send\n * failure (a throwing `client.send`) still propagates as a real exception.\n */\nexport async function sendAuditEmail(\n result: ProspectAuditResult,\n opts: SendAuditEmailOptions,\n): Promise<SendAuditEmailResult> {\n if (opts.recipients.length === 0) {\n return {\n sent: false,\n reason: \"no recipients configured (PROSPECT_AUDIT_RECIPIENTS is unset or empty)\",\n };\n }\n\n const built = buildAuditEmail(result, { link: opts.link });\n const attachmentHtml = opts.renderReport ? opts.renderReport(result) : built.attachmentHtml;\n const client = opts.client ?? defaultResendClient();\n const idempotencyKey = `prospect-audit:${opts.auditId ?? `${result.url}#${result.generatedAt}`}`;\n const filename = `prospect-audit-${slugify(hostnameOf(result.url))}.html`;\n\n const { messageId } = await client.send({\n from: FROM_ADDRESS,\n to: opts.recipients,\n subject: built.subject,\n html: built.html,\n attachments: [\n {\n filename,\n content: Buffer.from(attachmentHtml, \"utf-8\").toString(\"base64\"),\n contentType: \"text/html\",\n },\n ],\n idempotencyKey,\n });\n\n return { sent: true, messageId, recipients: opts.recipients };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/prospect/email.ts"],"sourcesContent":["import { escapeHtml, safeUrl } from \"../util/html.js\";\nimport { hostnameOf } from \"../util/url.js\";\nimport { renderProspectReport } from \"./render.js\";\nimport { defaultResendClient, type ResendClient } from \"../reports/send/resend.js\";\nimport type { AnalyzeResult, Fix, ProspectAuditResult, StageResult } from \"./types.js\";\n\n/**\n * Mirrors `reports/send/orchestrate.ts`'s `FROM_ADDRESS` (its line 18) — copied,\n * not imported, exactly like `digest.ts` and `recipes/selftest-email.ts` already\n * do. orchestrate.ts's own `FROM_ADDRESS` isn't exported, and importing it would\n * drag its whole Airtable/MJML dependency chain into this module's graph, which\n * stays lazy-loaded on purpose (see bin.ts's central-dep-blocker comment — this\n * module reaches `resend`, a devDependency, and must only ever be reached via a\n * dynamic `import()` from the CLI).\n */\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\n\nconst IMPACT_ORDER: Record<Fix[\"impact\"], number> = { high: 0, medium: 1, low: 2 };\n\n/** \"top few fixes\" per the design — enough to be useful on an internal sheet\n * without turning it into the full client report. */\nconst TOP_FIX_COUNT = 3;\n\nexport type AuditEmailContent = {\n subject: string;\n html: string;\n /** The FULL client-facing report (render.ts's `renderProspectReport`, pure\n * and deterministic like this function), so `sendAuditEmail` can attach it\n * without a second render call on the default path. */\n attachmentHtml: string;\n};\n\n/** The searchable business name when the pipeline resolved one, else the\n * hostname — mirrors render.ts's identical fallback (types.ts's businessName\n * doc: \"Null when no name was ever resolved... itself a finding, not an\n * error\"). Used for both the subject line and the sheet's own heading. */\nfunction resolveName(result: ProspectAuditResult): string {\n return result.businessName && result.businessName.trim()\n ? result.businessName\n : hostnameOf(result.url);\n}\n\n/**\n * Mail subject headers are plain text, never HTML-rendered by a client — no\n * `escapeHtml` needed there (that would wrongly show literal \"&lt;\" etc. in\n * the subject line). A raw newline/control character IS worth collapsing:\n * a business name that embeds one (scraped page title, model output) would\n * otherwise visibly break the subject, and a stray CR/LF in a header value\n * is the classic email-header-injection vector — defense-in-depth even\n * though the Resend API's JSON transport already neutralizes it here.\n */\nfunction subjectSafe(s: string): string {\n return s.replace(/\\s+/g, \" \").trim();\n}\n\n/** Local copy of render.ts's date formatter — small enough that importing it\n * (render.ts doesn't export it) isn't worth the coupling. */\nfunction formatIsoDate(iso: string): string {\n const d = new Date(iso);\n if (Number.isNaN(d.getTime())) return iso;\n return d.toLocaleDateString(\"en-US\", {\n year: \"numeric\",\n month: \"long\",\n day: \"numeric\",\n timeZone: \"UTC\",\n });\n}\n\ntype NotMeasuredLine = { label: string; reason: string };\n\n/**\n * Everything on the sheet that reads \"not measured\", and why — in plain\n * words. Unlike render.ts's client-facing report (which collapses every\n * stage failure to STAGE_FAILED_MESSAGE so a stranger never sees operator\n * vocabulary), this email goes to Tucker/Tim/Erik: the real diagnostic\n * (a fetch error, an LLM timeout) is more useful to them than a sanitized\n * phrase, so stage `error` strings are surfaced verbatim (still escaped —\n * they can embed arbitrary text from a fetch response).\n *\n * The four-Scores branches mirror checks.ts's `computeScores` exactly: which\n * stage feeds which score, and the cases where that stage's OWN data still\n * nulls the score even though the stage itself succeeded (crawlerAccessMeasured\n * false, avgMissing null, empty buyerQuestions, a null visibilityScore). If\n * computeScores' conditions change, this needs the matching update.\n */\nfunction notMeasuredLines(result: ProspectAuditResult): NotMeasuredLine[] {\n const lines: NotMeasuredLine[] = [];\n\n if (!result.checks.ok) {\n lines.push({\n label: \"Findability\",\n reason: `the checks stage failed — ${result.checks.error}`,\n });\n lines.push({\n label: \"Readability\",\n reason: `the checks stage failed — ${result.checks.error}`,\n });\n } else {\n const c = result.checks.data;\n if (result.scores.findability === null) {\n lines.push({\n label: \"Findability\",\n reason: !c.crawlerAccessMeasured\n ? \"robots.txt could not be fetched, so crawler access wasn't measured\"\n : \"every crawled page failed to produce readable content\",\n });\n }\n if (result.scores.readability === null) {\n lines.push({\n label: \"Readability\",\n reason: \"no page produced a comparable raw/rendered pair to measure JavaScript dependence\",\n });\n }\n }\n\n if (!result.lighthouse.ok) {\n lines.push({ label: \"Lighthouse\", reason: result.lighthouse.error });\n }\n\n if (!result.analyze.ok) {\n lines.push({ label: \"Answers\", reason: result.analyze.error });\n } else if (result.scores.answers === null) {\n lines.push({ label: \"Answers\", reason: \"the model found no buyer questions to check\" });\n }\n\n if (!result.probes.ok) {\n lines.push({ label: \"AI Visibility\", reason: result.probes.error });\n } else if (result.scores.aiVisibility === null) {\n lines.push({ label: \"AI Visibility\", reason: \"no category (buyer-question) query ran\" });\n }\n\n return lines;\n}\n\nconst SCORE_FIELDS: { key: keyof ProspectAuditResult[\"scores\"]; label: string }[] = [\n { key: \"findability\", label: \"Findability\" },\n { key: \"readability\", label: \"Readability\" },\n { key: \"answers\", label: \"Answers\" },\n { key: \"aiVisibility\", label: \"AI Visibility\" },\n];\n\nfunction scoresTableHtml(scores: ProspectAuditResult[\"scores\"]): string {\n const cells = SCORE_FIELDS.map(({ key, label }) => {\n const value = scores[key];\n const display = value === null ? \"Not measured\" : String(value);\n const valueStyle =\n value === null ? \"color:#8a857e;font-size:14px;\" : \"color:#d71920;font-size:28px;\";\n return `<td style=\"padding:12px 16px;border:1px solid #e6e1da;text-align:center;\">\n <div style=\"font-weight:600;${valueStyle}\">${escapeHtml(display)}</div>\n <div style=\"font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:#57544f;margin-top:4px;\">${escapeHtml(label)}</div>\n </td>`;\n }).join(\"\");\n return `<table role=\"presentation\" style=\"border-collapse:collapse;margin:16px 0;\"><tr>${cells}</tr></table>`;\n}\n\nfunction notMeasuredHtml(lines: NotMeasuredLine[]): string {\n if (lines.length === 0) return \"\";\n const items = lines\n .map((l) => `<li><strong>${escapeHtml(l.label)}</strong> — ${escapeHtml(l.reason)}</li>`)\n .join(\"\");\n return `<h2 style=\"font-size:16px;margin:24px 0 8px;\">Not measured</h2><ul style=\"margin:0;padding-left:20px;\">${items}</ul>`;\n}\n\n/** analyze.error and fix titles/why are untrusted (LLM output, or text lifted\n * from the prospect's own site) — every interpolation below is escaped. */\nfunction fixesHtml(analyze: StageResult<AnalyzeResult>): string {\n if (!analyze.ok) {\n return `<p style=\"color:#8a857e;margin:8px 0 0;\">Fixes not available — ${escapeHtml(analyze.error)}</p>`;\n }\n const top = [...analyze.data.fixes]\n .sort((a, b) => IMPACT_ORDER[a.impact] - IMPACT_ORDER[b.impact])\n .slice(0, TOP_FIX_COUNT);\n if (top.length === 0) {\n return `<p style=\"margin:8px 0 0;\">Nothing urgent — the essentials are already in place.</p>`;\n }\n const items = top\n .map(\n (f) =>\n `<li><strong>${escapeHtml(f.title)}</strong> — ${escapeHtml(f.why)} <span style=\"color:#8a857e;\">(${escapeHtml(f.impact)} impact, ${escapeHtml(f.effort)} effort)</span></li>`,\n )\n .join(\"\");\n return `<ol style=\"margin:8px 0 0;padding-left:20px;\">${items}</ol>`;\n}\n\nfunction linkHtml(link: string | null): string {\n if (!link) {\n return `<p style=\"margin:24px 0 0;color:#8a857e;\">No shareable link — the audit could not be saved to the database. The full report is attached to this email instead.</p>`;\n }\n const safe = safeUrl(link);\n return `<p style=\"margin:24px 0 0;\"><strong>Full report:</strong> <a href=\"${escapeHtml(safe)}\">${escapeHtml(link)}</a></p>`;\n}\n\n/** This sheet is for us, and it says so. It carries the real reasons a stage did\n * not run — useful internally, wrong in front of a prospect — so it has to be\n * obvious that the shareable artefact is the attachment and the link, not this\n * body. Without the label, the natural thing to do with a useful email is\n * forward it. */\nconst INTERNAL_NOTE = `<p style=\"margin:24px 0 0;padding:12px 14px;background:#f4f2ef;border-left:3px solid #d71920;color:#57544f;font-size:14px;\">\n Internal sheet — it names the reasons a section could not be measured. The\n version to send a prospect is the linked report, also attached to this email.\n</p>`;\n\n/**\n * Build the internal sheet — a SHORT summary for Tucker/Tim/Erik, distinct\n * from the client-facing report render.ts builds. Pure: no IO, so it's safe\n * to call from a test with no network. `attachmentHtml` is the full report\n * (render.ts's `renderProspectReport`, itself pure), included here so\n * `sendAuditEmail`'s default path doesn't need a second render call.\n *\n * Every value that can trace back to the prospect's site or the model —\n * the business name, fixes, stage error text, the narrative-adjacent bits —\n * is escaped with `escapeHtml`/`safeUrl`, the same honesty-and-safety rule\n * render.ts follows for the client-facing report.\n */\nexport function buildAuditEmail(\n result: ProspectAuditResult,\n opts: { link: string | null },\n): AuditEmailContent {\n const name = resolveName(result);\n const subject = `Prospect audit — ${subjectSafe(name)}`;\n\n const html = `<!doctype html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /></head>\n<body style=\"margin:0;padding:24px;background:#faf8f5;color:#1a1a1a;font:15px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;\">\n <h1 style=\"font-size:22px;margin:0 0 4px;\">Prospect audit — ${escapeHtml(name)}</h1>\n <p style=\"margin:0 0 16px;color:#57544f;\">\n <a href=\"${escapeHtml(safeUrl(result.url))}\">${escapeHtml(result.url)}</a> · audited ${escapeHtml(formatIsoDate(result.generatedAt))}\n </p>\n ${scoresTableHtml(result.scores)}\n ${notMeasuredHtml(notMeasuredLines(result))}\n <h2 style=\"font-size:16px;margin:24px 0 8px;\">Top fixes</h2>\n ${fixesHtml(result.analyze)}\n ${linkHtml(opts.link)}\n ${INTERNAL_NOTE}\n</body>\n</html>`;\n\n return { subject, html, attachmentHtml: renderProspectReport(result) };\n}\n\n/**\n * Parse `PROSPECT_AUDIT_RECIPIENTS` (comma-separated) into a clean address\n * list: trimmed, empties dropped, case-insensitively deduped — mirrors\n * `reports/send/orchestrate.ts`'s `parseAddresses` for the same reasons (an\n * operator-typed env var, same typo surface). No address is ever hardcoded\n * anywhere in this module; an unset or empty var yields `[]`, which\n * `sendAuditEmail` treats as \"skip, and say why\" rather than an error.\n */\nexport function parseProspectAuditRecipients(raw: string | undefined | null): string[] {\n if (!raw) return [];\n const seen = new Set<string>();\n const list: string[] = [];\n for (const part of raw.split(\",\")) {\n const trimmed = part.trim().toLowerCase();\n if (!trimmed) continue;\n if (seen.has(trimmed)) continue;\n seen.add(trimmed);\n list.push(trimmed);\n }\n return list;\n}\n\n/** Filesystem-safe attachment-filename fragment. Tiny local copy of the CLI's\n * private `slugify` (prospect-audit.ts) — not worth exporting/sharing one\n * three-line predicate across a module boundary. */\nfunction slugify(s: string): string {\n const slug = s\n .toLowerCase()\n .replace(/[^a-z0-9.-]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n return slug || \"prospect\";\n}\n\nexport type SendAuditEmailOptions = {\n link: string | null;\n recipients: string[];\n /**\n * The persisted `prospect_audits` row id (from `createProspectAudit`), when\n * persistence succeeded. Mirrors orchestrate.ts's\n * `idempotencyKey: \\`report:${report.id}\\`` pattern, so re-sending for the\n * SAME already-persisted audit dedupes at Resend instead of double-mailing.\n * Null when persistence failed — the sheet can still be sent (it's built to\n * survive independently of Turso), but then there is no stable row id, so\n * the key falls back to `url`+`generatedAt`: that still protects a\n * same-process double-call, though — unlike a DB id — it can't survive an\n * entire pipeline rerun, which mints genuinely new content anyway.\n */\n auditId?: string | null;\n /**\n * The print-designed PDF leave-behind, when one was rendered.\n *\n * Optional on purpose: rendering it needs a live page and a headless browser,\n * and neither is worth losing a delivered report over. Absent, the email goes\n * exactly as it did before — the HTML sheet and the link — and the caller has\n * already recorded a warning saying why.\n */\n pdf?: Buffer | null;\n /** Defaults to `defaultResendClient()`. */\n client?: ResendClient;\n /** Test seam for the ATTACHMENT content only (production omits it and gets\n * the real `renderProspectReport` output `buildAuditEmail` already\n * computed) — lets a test of this function's sending mechanics (recipients,\n * idempotency key, attachment shape) skip re-deriving the full report. */\n renderReport?: (result: ProspectAuditResult) => string;\n};\n\nexport type SendAuditEmailResult =\n { sent: true; messageId: string; recipients: string[] } | { sent: false; reason: string };\n\n/**\n * Send the audit sheet with the full rendered report attached as an `.html`\n * file (base64), so the sheet survives independently of the link and of\n * Turso. Empty `recipients` is a legitimate, non-exceptional outcome (no\n * `PROSPECT_AUDIT_RECIPIENTS` configured) — returns `{sent:false}` rather\n * than throwing, so a caller can report it as a warning; a genuine send\n * failure (a throwing `client.send`) still propagates as a real exception.\n */\nexport async function sendAuditEmail(\n result: ProspectAuditResult,\n opts: SendAuditEmailOptions,\n): Promise<SendAuditEmailResult> {\n if (opts.recipients.length === 0) {\n return {\n sent: false,\n reason: \"no recipients configured (PROSPECT_AUDIT_RECIPIENTS is unset or empty)\",\n };\n }\n\n const built = buildAuditEmail(result, { link: opts.link });\n const attachmentHtml = opts.renderReport ? opts.renderReport(result) : built.attachmentHtml;\n const client = opts.client ?? defaultResendClient();\n const idempotencyKey = `prospect-audit:${opts.auditId ?? `${result.url}#${result.generatedAt}`}`;\n const filename = `prospect-audit-${slugify(hostnameOf(result.url))}.html`;\n\n const { messageId } = await client.send({\n from: FROM_ADDRESS,\n to: opts.recipients,\n subject: built.subject,\n html: built.html,\n attachments: [\n {\n filename,\n content: Buffer.from(attachmentHtml, \"utf-8\").toString(\"base64\"),\n contentType: \"text/html\",\n },\n // The PDF goes second so the HTML sheet stays the first attachment it has\n // always been, and appears only when one was actually rendered — an empty\n // or broken attachment is worse than none.\n ...(opts.pdf\n ? [\n {\n filename: filename.replace(/\\.html$/, \".pdf\"),\n content: opts.pdf.toString(\"base64\"),\n contentType: \"application/pdf\",\n },\n ]\n : []),\n ],\n idempotencyKey,\n });\n\n return { sent: true, messageId, recipients: opts.recipients };\n}\n"],"mappings":"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","names":[]}
@@ -66,7 +66,7 @@ async function runEnsureSiteCommand(slug, opts) {
66
66
  if (!slug) return { output: "Provide a <slug> (e.g. `ensure-site roalson`).", code: 2 };
67
67
  try {
68
68
  const base = openBase(readAirtableConfig());
69
- const { makeSiteMirror } = await import("./site-mirror-NIREX5ME.js");
69
+ const { makeSiteMirror } = await import("./site-mirror-Q763PLGG.js");
70
70
  const result = await ensureSite(
71
71
  base,
72
72
  {
@@ -93,4 +93,4 @@ async function runEnsureSiteCommand(slug, opts) {
93
93
  export {
94
94
  runEnsureSiteCommand
95
95
  };
96
- //# sourceMappingURL=ensure-site-4DCEBAPM.js.map
96
+ //# sourceMappingURL=ensure-site-FU6527OU.js.map
@@ -1,4 +1,5 @@
1
1
  import {
2
+ REPORT_LIST_COLUMNS,
2
3
  getReportById,
3
4
  getReportHtml,
4
5
  getSiteById,
@@ -14,12 +15,13 @@ import {
14
15
  mirrorSiteFields,
15
16
  mirrorSiteInsert,
16
17
  storeRenderedHtml
17
- } from "./chunk-HHMMUR4P.js";
18
+ } from "./chunk-GQDDWYU2.js";
18
19
  import "./chunk-P6AORG32.js";
19
20
  import "./chunk-F3JWOPTG.js";
20
21
  import "./chunk-VAD43MOR.js";
21
22
  import "./chunk-7NYN2P6I.js";
22
23
  export {
24
+ REPORT_LIST_COLUMNS,
23
25
  getReportById,
24
26
  getReportHtml,
25
27
  getSiteById,
@@ -36,4 +38,4 @@ export {
36
38
  mirrorSiteInsert,
37
39
  storeRenderedHtml
38
40
  };
39
- //# sourceMappingURL=fleet-state-7TJ2GYT2.js.map
41
+ //# sourceMappingURL=fleet-state-R5LU7IC3.js.map
@@ -1,14 +1,14 @@
1
1
  import {
2
2
  describeNotifyTarget
3
- } from "./chunk-VSBFM7DS.js";
4
- import "./chunk-BNM5POSJ.js";
5
- import "./chunk-SNSTJLQW.js";
3
+ } from "./chunk-SJOXWJ5Y.js";
4
+ import "./chunk-JRHIFA5R.js";
5
+ import "./chunk-NO74DXDP.js";
6
6
  import "./chunk-5ML7FMBD.js";
7
7
  import {
8
8
  openBase,
9
9
  readAirtableConfig
10
10
  } from "./chunk-HN6I2XWI.js";
11
- import "./chunk-DJZWYIPP.js";
11
+ import "./chunk-BGTYPVLT.js";
12
12
  import {
13
13
  canonicalizeStatus,
14
14
  listWebsites,
@@ -143,7 +143,7 @@ async function runFormsNotifyTargetCommand(site, opts) {
143
143
  return { output: `--set must be 'on' or 'off' (got '${opts.set}')`, code: 2 };
144
144
  }
145
145
  try {
146
- const { makeSiteMirror } = await import("./site-mirror-NIREX5ME.js");
146
+ const { makeSiteMirror } = await import("./site-mirror-Q763PLGG.js");
147
147
  const result = await formsNotifyTarget({
148
148
  siteMirror: await makeSiteMirror(),
149
149
  site: site.trim(),
@@ -163,4 +163,4 @@ export {
163
163
  formatNotifyTarget,
164
164
  runFormsNotifyTargetCommand
165
165
  };
166
- //# sourceMappingURL=forms-notify-target-2EAWCWBW.js.map
166
+ //# sourceMappingURL=forms-notify-target-SGDGXXK5.js.map