@reddoorla/maintenance 0.90.1 → 0.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/dist/{announce-NDTLRQCN.js → announce-6OS4C57D.js} +3 -3
  2. package/dist/{blux-YGBGS24U.js → blux-NXQLMS4W.js} +4 -4
  3. package/dist/blux-NXQLMS4W.js.map +1 -0
  4. package/dist/{bump-deps-KGH5UYSF.js → bump-deps-GI7PHMOC.js} +6 -6
  5. package/dist/{chunk-WS6NU475.js → chunk-22GKJO5F.js} +2 -2
  6. package/dist/{chunk-A6T2R63Z.js → chunk-3HS6LPFX.js} +2 -2
  7. package/dist/{chunk-UXJZU23T.js → chunk-4NUKMPOO.js} +9 -9
  8. package/dist/{chunk-BQRFQSGY.js → chunk-4R3YAQNV.js} +2 -2
  9. package/dist/{chunk-JRHIFA5R.js → chunk-5XMKJX74.js} +10 -4
  10. package/dist/chunk-5XMKJX74.js.map +1 -0
  11. package/dist/{chunk-XIDUYLSR.js → chunk-7CJ3DVZW.js} +2 -2
  12. package/dist/{chunk-NYUYNYQL.js → chunk-7MKT4I5T.js} +15 -1
  13. package/dist/chunk-7MKT4I5T.js.map +1 -0
  14. package/dist/{chunk-Z3KYFUN6.js → chunk-B6NUZ2ZX.js} +2 -2
  15. package/dist/chunk-BJQCIJOY.js +3534 -0
  16. package/dist/chunk-BJQCIJOY.js.map +1 -0
  17. package/dist/{chunk-Y53WOQIJ.js → chunk-CVJF2IC5.js} +2 -2
  18. package/dist/{chunk-W45FZEGE.js → chunk-DMPHP7UP.js} +2 -2
  19. package/dist/{chunk-KGUKL3CV.js → chunk-E22WAIF2.js} +2 -2
  20. package/dist/{chunk-VBEKL445.js → chunk-FCS36FDT.js} +2 -2
  21. package/dist/{chunk-QD427NIO.js → chunk-JB4Q3C3M.js} +7 -7
  22. package/dist/{chunk-IYSEC543.js → chunk-LS6QPANO.js} +2 -2
  23. package/dist/{chunk-SJOXWJ5Y.js → chunk-M2XF6JAI.js} +80 -9
  24. package/dist/chunk-M2XF6JAI.js.map +1 -0
  25. package/dist/{chunk-ZOCJBJQV.js → chunk-MVDMKKBN.js} +2 -2
  26. package/dist/{chunk-7RJ2HMMJ.js → chunk-NTLSMJHW.js} +2 -2
  27. package/dist/{chunk-MFHS7IQ2.js → chunk-OCNSIBMP.js} +27 -1
  28. package/dist/{chunk-MFHS7IQ2.js.map → chunk-OCNSIBMP.js.map} +1 -1
  29. package/dist/chunk-PJ23RDHT.js +48 -0
  30. package/dist/chunk-PJ23RDHT.js.map +1 -0
  31. package/dist/{chunk-2NJQLSEU.js → chunk-PPIZHDJL.js} +2 -2
  32. package/dist/chunk-T7ZUUOZ2.js +496 -0
  33. package/dist/chunk-T7ZUUOZ2.js.map +1 -0
  34. package/dist/{chunk-TVL5MVUF.js → chunk-TCF6S7AD.js} +4 -4
  35. package/dist/{chunk-GSWTT2J6.js → chunk-WHWVYL7B.js} +4 -4
  36. package/dist/{chunk-3G25KIWW.js → chunk-ZTPPCHDB.js} +4 -4
  37. package/dist/cli/bin.js +30 -27
  38. package/dist/cli/bin.js.map +1 -1
  39. package/dist/cli/commands/audit.js +14 -14
  40. package/dist/client-M2V6FHH5.js +11 -0
  41. package/dist/{convert-to-pnpm-NWB5FCQ2.js → convert-to-pnpm-ONFP3JKF.js} +6 -6
  42. package/dist/{db-LOSJ74ZJ.js → db-DJVW6ZSE.js} +11 -11
  43. package/dist/{digest-CS6KLCZB.js → digest-EOA4JLTV.js} +13 -13
  44. package/dist/{digest-collectors-NI2M3UTC.js → digest-collectors-ZA3BTS3R.js} +7 -7
  45. package/dist/{email-LJQT5J7F.js → email-I4BCETI4.js} +16 -64
  46. package/dist/email-I4BCETI4.js.map +1 -0
  47. package/dist/{ensure-site-FFDUMY6E.js → ensure-site-SF6KICTN.js} +2 -2
  48. package/dist/forms/index.d.ts +5 -1
  49. package/dist/forms/index.js +1 -1
  50. package/dist/forms/index.js.map +1 -1
  51. package/dist/forms/prismic.d.ts +69 -0
  52. package/dist/forms/prismic.js +75 -0
  53. package/dist/forms/prismic.js.map +1 -0
  54. package/dist/{forms-notify-target-IHJX7NWA.js → forms-notify-target-MNNOGSJ7.js} +5 -4
  55. package/dist/{forms-notify-target-IHJX7NWA.js.map → forms-notify-target-MNNOGSJ7.js.map} +1 -1
  56. package/dist/{github-signals-FCLNHKMF.js → github-signals-3WLIJ5RR.js} +5 -5
  57. package/dist/{header-image-7A7XLVEU.js → header-image-DOCDFFRO.js} +2 -2
  58. package/dist/{health-endpoint-UVX6R5N5.js → health-endpoint-DHKJUPRM.js} +6 -6
  59. package/dist/{health-mirror-GMSF3Z5R.js → health-mirror-55RRU6BI.js} +3 -3
  60. package/dist/index.js +36 -29
  61. package/dist/index.js.map +1 -1
  62. package/dist/{init-NXZ4NXTL.js → init-ZRKWYCKA.js} +15 -15
  63. package/dist/{launch-5V7RLUV7.js → launch-6X5OXPF2.js} +13 -13
  64. package/dist/migrate-K4JETR36.js +7 -0
  65. package/dist/{notify-4HP4GZ2H.js → notify-FYSOWKMJ.js} +4 -3
  66. package/dist/{onboard-BKCYBAVW.js → onboard-6NHGCKGB.js} +6 -6
  67. package/dist/{orchestrate-6HROYTM6.js → orchestrate-KMQIUP3H.js} +5 -5
  68. package/dist/pipeline-AQBFG5WM.js +31 -0
  69. package/dist/{preflight-LIN5NCYU.js → preflight-VPHQYWTY.js} +6 -6
  70. package/dist/{prismic-ci-6YGRIOHT.js → prismic-ci-A2HI7NNJ.js} +8 -8
  71. package/dist/{prismic-models-VNGIKS2B.js → prismic-models-QTKIMFBC.js} +5 -5
  72. package/dist/prospect/types.d.ts +809 -2
  73. package/dist/prospect/types.js +8 -0
  74. package/dist/{prospect-audit-JCWTKJTS.js → prospect-audit-RKUVSTLN.js} +27 -6
  75. package/dist/prospect-audit-RKUVSTLN.js.map +1 -0
  76. package/dist/{prospect-audits-3PMIO73D.js → prospect-audits-RVYEA4KH.js} +6 -4
  77. package/dist/recipes/sync-configs.js +3 -3
  78. package/dist/{chunk-5PWB3JHJ.js → render-7G43BSK5.js} +28 -9
  79. package/dist/render-7G43BSK5.js.map +1 -0
  80. package/dist/{replay-4S6ENYCH.js → replay-HV7MGKBU.js} +3 -2
  81. package/dist/{replay-4S6ENYCH.js.map → replay-HV7MGKBU.js.map} +1 -1
  82. package/dist/{report-AAY2HZZR.js → report-Y4LXDHRV.js} +11 -11
  83. package/dist/{report-mirror-GMNSH5HF.js → report-mirror-6ZDCAQZB.js} +3 -3
  84. package/dist/{schema-75PJECAE.js → schema-2V7BGYVT.js} +1 -1
  85. package/dist/{schema-75PJECAE.js.map → schema-2V7BGYVT.js.map} +1 -1
  86. package/dist/{self-updating-VQIW24RZ.js → self-updating-D5XOWS7V.js} +5 -5
  87. package/dist/{selftest-CUX2FIQD.js → selftest-IFGSJP4X.js} +5 -5
  88. package/dist/{site-mirror-GCFLBBWK.js → site-mirror-3E7JYTJG.js} +3 -3
  89. package/dist/{smoke-suite-4JFZZYVW.js → smoke-suite-GXC5JZPH.js} +6 -6
  90. package/dist/{submissions-7LGJJSDL.js → submissions-UMJS3YST.js} +2 -2
  91. package/dist/{svelte-codemods-QAPDZC6F.js → svelte-codemods-A3EARSRP.js} +6 -6
  92. package/dist/{sync-configs-PVRLZRKL.js → sync-configs-KSESOHFF.js} +6 -6
  93. package/dist/{upgrade-IR2OO3KP.js → upgrade-ZLNVEIEA.js} +6 -6
  94. package/dist/{webflow-624FEBFD.js → webflow-IRKOIT7S.js} +4 -4
  95. package/package.json +6 -1
  96. package/dist/blux-YGBGS24U.js.map +0 -1
  97. package/dist/chunk-5PWB3JHJ.js.map +0 -1
  98. package/dist/chunk-DXKF552B.js +0 -1630
  99. package/dist/chunk-DXKF552B.js.map +0 -1
  100. package/dist/chunk-JRHIFA5R.js.map +0 -1
  101. package/dist/chunk-NYUYNYQL.js.map +0 -1
  102. package/dist/chunk-SJOXWJ5Y.js.map +0 -1
  103. package/dist/client-JYKPVCJX.js +0 -11
  104. package/dist/email-LJQT5J7F.js.map +0 -1
  105. package/dist/migrate-YITCXBLS.js +0 -7
  106. package/dist/pipeline-MQJD4CKN.js +0 -16
  107. package/dist/prospect-audit-JCWTKJTS.js.map +0 -1
  108. package/dist/render-P3SBUFVB.js +0 -10
  109. package/dist/render-P3SBUFVB.js.map +0 -1
  110. /package/dist/{announce-NDTLRQCN.js.map → announce-6OS4C57D.js.map} +0 -0
  111. /package/dist/{bump-deps-KGH5UYSF.js.map → bump-deps-GI7PHMOC.js.map} +0 -0
  112. /package/dist/{chunk-WS6NU475.js.map → chunk-22GKJO5F.js.map} +0 -0
  113. /package/dist/{chunk-A6T2R63Z.js.map → chunk-3HS6LPFX.js.map} +0 -0
  114. /package/dist/{chunk-UXJZU23T.js.map → chunk-4NUKMPOO.js.map} +0 -0
  115. /package/dist/{chunk-BQRFQSGY.js.map → chunk-4R3YAQNV.js.map} +0 -0
  116. /package/dist/{chunk-XIDUYLSR.js.map → chunk-7CJ3DVZW.js.map} +0 -0
  117. /package/dist/{chunk-Z3KYFUN6.js.map → chunk-B6NUZ2ZX.js.map} +0 -0
  118. /package/dist/{chunk-Y53WOQIJ.js.map → chunk-CVJF2IC5.js.map} +0 -0
  119. /package/dist/{chunk-W45FZEGE.js.map → chunk-DMPHP7UP.js.map} +0 -0
  120. /package/dist/{chunk-KGUKL3CV.js.map → chunk-E22WAIF2.js.map} +0 -0
  121. /package/dist/{chunk-VBEKL445.js.map → chunk-FCS36FDT.js.map} +0 -0
  122. /package/dist/{chunk-QD427NIO.js.map → chunk-JB4Q3C3M.js.map} +0 -0
  123. /package/dist/{chunk-IYSEC543.js.map → chunk-LS6QPANO.js.map} +0 -0
  124. /package/dist/{chunk-ZOCJBJQV.js.map → chunk-MVDMKKBN.js.map} +0 -0
  125. /package/dist/{chunk-7RJ2HMMJ.js.map → chunk-NTLSMJHW.js.map} +0 -0
  126. /package/dist/{chunk-2NJQLSEU.js.map → chunk-PPIZHDJL.js.map} +0 -0
  127. /package/dist/{chunk-TVL5MVUF.js.map → chunk-TCF6S7AD.js.map} +0 -0
  128. /package/dist/{chunk-GSWTT2J6.js.map → chunk-WHWVYL7B.js.map} +0 -0
  129. /package/dist/{chunk-3G25KIWW.js.map → chunk-ZTPPCHDB.js.map} +0 -0
  130. /package/dist/{client-JYKPVCJX.js.map → client-M2V6FHH5.js.map} +0 -0
  131. /package/dist/{convert-to-pnpm-NWB5FCQ2.js.map → convert-to-pnpm-ONFP3JKF.js.map} +0 -0
  132. /package/dist/{db-LOSJ74ZJ.js.map → db-DJVW6ZSE.js.map} +0 -0
  133. /package/dist/{digest-CS6KLCZB.js.map → digest-EOA4JLTV.js.map} +0 -0
  134. /package/dist/{digest-collectors-NI2M3UTC.js.map → digest-collectors-ZA3BTS3R.js.map} +0 -0
  135. /package/dist/{ensure-site-FFDUMY6E.js.map → ensure-site-SF6KICTN.js.map} +0 -0
  136. /package/dist/{github-signals-FCLNHKMF.js.map → github-signals-3WLIJ5RR.js.map} +0 -0
  137. /package/dist/{header-image-7A7XLVEU.js.map → header-image-DOCDFFRO.js.map} +0 -0
  138. /package/dist/{health-endpoint-UVX6R5N5.js.map → health-endpoint-DHKJUPRM.js.map} +0 -0
  139. /package/dist/{health-mirror-GMSF3Z5R.js.map → health-mirror-55RRU6BI.js.map} +0 -0
  140. /package/dist/{init-NXZ4NXTL.js.map → init-ZRKWYCKA.js.map} +0 -0
  141. /package/dist/{launch-5V7RLUV7.js.map → launch-6X5OXPF2.js.map} +0 -0
  142. /package/dist/{migrate-YITCXBLS.js.map → migrate-K4JETR36.js.map} +0 -0
  143. /package/dist/{notify-4HP4GZ2H.js.map → notify-FYSOWKMJ.js.map} +0 -0
  144. /package/dist/{onboard-BKCYBAVW.js.map → onboard-6NHGCKGB.js.map} +0 -0
  145. /package/dist/{orchestrate-6HROYTM6.js.map → orchestrate-KMQIUP3H.js.map} +0 -0
  146. /package/dist/{pipeline-MQJD4CKN.js.map → pipeline-AQBFG5WM.js.map} +0 -0
  147. /package/dist/{preflight-LIN5NCYU.js.map → preflight-VPHQYWTY.js.map} +0 -0
  148. /package/dist/{prismic-ci-6YGRIOHT.js.map → prismic-ci-A2HI7NNJ.js.map} +0 -0
  149. /package/dist/{prismic-models-VNGIKS2B.js.map → prismic-models-QTKIMFBC.js.map} +0 -0
  150. /package/dist/{prospect-audits-3PMIO73D.js.map → prospect-audits-RVYEA4KH.js.map} +0 -0
  151. /package/dist/{report-AAY2HZZR.js.map → report-Y4LXDHRV.js.map} +0 -0
  152. /package/dist/{report-mirror-GMNSH5HF.js.map → report-mirror-6ZDCAQZB.js.map} +0 -0
  153. /package/dist/{self-updating-VQIW24RZ.js.map → self-updating-D5XOWS7V.js.map} +0 -0
  154. /package/dist/{selftest-CUX2FIQD.js.map → selftest-IFGSJP4X.js.map} +0 -0
  155. /package/dist/{site-mirror-GCFLBBWK.js.map → site-mirror-3E7JYTJG.js.map} +0 -0
  156. /package/dist/{smoke-suite-4JFZZYVW.js.map → smoke-suite-GXC5JZPH.js.map} +0 -0
  157. /package/dist/{submissions-7LGJJSDL.js.map → submissions-UMJS3YST.js.map} +0 -0
  158. /package/dist/{svelte-codemods-QAPDZC6F.js.map → svelte-codemods-A3EARSRP.js.map} +0 -0
  159. /package/dist/{sync-configs-PVRLZRKL.js.map → sync-configs-KSESOHFF.js.map} +0 -0
  160. /package/dist/{upgrade-IR2OO3KP.js.map → upgrade-ZLNVEIEA.js.map} +0 -0
  161. /package/dist/{webflow-624FEBFD.js.map → webflow-IRKOIT7S.js.map} +0 -0
@@ -4,7 +4,7 @@ import {
4
4
  } from "./chunk-R53VL3RI.js";
5
5
  import {
6
6
  withRecipe
7
- } from "./chunk-3G25KIWW.js";
7
+ } from "./chunk-ZTPPCHDB.js";
8
8
  import {
9
9
  defaultSpawn
10
10
  } from "./chunk-LBYOLBW7.js";
@@ -315,4 +315,4 @@ async function smokeSuite(site, deps = { spawn: defaultSpawn }) {
315
315
  export {
316
316
  smokeSuite
317
317
  };
318
- //# sourceMappingURL=chunk-Y53WOQIJ.js.map
318
+ //# sourceMappingURL=chunk-CVJF2IC5.js.map
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  openDb,
3
3
  readDbConfig
4
- } from "./chunk-WS6NU475.js";
4
+ } from "./chunk-22GKJO5F.js";
5
5
 
6
6
  // src/db/fleet-events.ts
7
7
  async function recordFleetEvent(db, e) {
@@ -47,4 +47,4 @@ async function recordFleetEventsBestEffort(events, now, open = () => openDb(read
47
47
  export {
48
48
  recordFleetEventsBestEffort
49
49
  };
50
- //# sourceMappingURL=chunk-W45FZEGE.js.map
50
+ //# sourceMappingURL=chunk-DMPHP7UP.js.map
@@ -5,7 +5,7 @@ import {
5
5
  import {
6
6
  isProbablyEmail,
7
7
  parseAddresses
8
- } from "./chunk-VBEKL445.js";
8
+ } from "./chunk-FCS36FDT.js";
9
9
  import {
10
10
  listAllReports
11
11
  } from "./chunk-F3JWOPTG.js";
@@ -341,4 +341,4 @@ export {
341
341
  preflight,
342
342
  approveBlockers
343
343
  };
344
- //# sourceMappingURL=chunk-KGUKL3CV.js.map
344
+ //# sourceMappingURL=chunk-E22WAIF2.js.map
@@ -27,7 +27,7 @@ import {
27
27
  } from "./chunk-WEKL2HYJ.js";
28
28
  import {
29
29
  recordFleetEventsBestEffort
30
- } from "./chunk-W45FZEGE.js";
30
+ } from "./chunk-DMPHP7UP.js";
31
31
 
32
32
  // src/reports/send/idempotency.ts
33
33
  function isIdempotencyConflict(err) {
@@ -227,4 +227,4 @@ export {
227
227
  parseAddresses,
228
228
  isProbablyEmail
229
229
  };
230
- //# sourceMappingURL=chunk-VBEKL445.js.map
230
+ //# sourceMappingURL=chunk-FCS36FDT.js.map
@@ -7,22 +7,22 @@ import {
7
7
  import {
8
8
  assertUrlSegment
9
9
  } from "./chunk-XHHMM5HX.js";
10
- import {
11
- smokeAudit
12
- } from "./chunk-UDRHAQT7.js";
13
- import {
14
- INGEST_TIMEOUT_MS
15
- } from "./chunk-A3WDWNN5.js";
16
10
  import {
17
11
  lighthouseAudit,
18
12
  readSiteConfig
19
13
  } from "./chunk-2NQUCS7O.js";
14
+ import {
15
+ smokeAudit
16
+ } from "./chunk-UDRHAQT7.js";
20
17
  import {
21
18
  findFreePort
22
19
  } from "./chunk-PTDMVGKL.js";
23
20
  import {
24
21
  defaultSpawn
25
22
  } from "./chunk-LBYOLBW7.js";
23
+ import {
24
+ INGEST_TIMEOUT_MS
25
+ } from "./chunk-A3WDWNN5.js";
26
26
  import {
27
27
  siteLabel
28
28
  } from "./chunk-XXTZBPUY.js";
@@ -1979,4 +1979,4 @@ export {
1979
1979
  runAudits,
1980
1980
  runAuditsAcross
1981
1981
  };
1982
- //# sourceMappingURL=chunk-QD427NIO.js.map
1982
+ //# sourceMappingURL=chunk-JB4Q3C3M.js.map
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-J7SZQUCW.js";
4
4
  import {
5
5
  withRecipe
6
- } from "./chunk-3G25KIWW.js";
6
+ } from "./chunk-ZTPPCHDB.js";
7
7
 
8
8
  // src/recipes/svelte-codemods.ts
9
9
  import { writeFile } from "fs/promises";
@@ -32,4 +32,4 @@ async function svelteCodemods(site) {
32
32
  export {
33
33
  svelteCodemods
34
34
  };
35
- //# sourceMappingURL=chunk-IYSEC543.js.map
35
+ //# sourceMappingURL=chunk-LS6QPANO.js.map
@@ -1,13 +1,65 @@
1
1
  import {
2
2
  hostsMatch
3
- } from "./chunk-JRHIFA5R.js";
3
+ } from "./chunk-5XMKJX74.js";
4
4
  import {
5
5
  operatorEmail
6
6
  } from "./chunk-5ML7FMBD.js";
7
+ import {
8
+ parseReplyCopy
9
+ } from "./chunk-PJ23RDHT.js";
7
10
  import {
8
11
  escapeHtml
9
12
  } from "./chunk-BGTYPVLT.js";
10
13
 
14
+ // src/forms/ics.ts
15
+ var DEFAULT_DURATION_MS = 2 * 60 * 60 * 1e3;
16
+ function stamp(iso) {
17
+ return new Date(iso).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
18
+ }
19
+ function endStamp(e) {
20
+ if (e.end) return stamp(e.end);
21
+ return stamp(new Date(Date.parse(e.start) + DEFAULT_DURATION_MS).toISOString());
22
+ }
23
+ function esc(v) {
24
+ return v.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");
25
+ }
26
+ function uid(e) {
27
+ const slug = e.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
28
+ return `${stamp(e.start)}-${slug || "event"}@reddoorla.com`;
29
+ }
30
+ function buildIcs(e, now = /* @__PURE__ */ new Date()) {
31
+ const lines = [
32
+ "BEGIN:VCALENDAR",
33
+ "VERSION:2.0",
34
+ "PRODID:-//Reddoor Creative//Form Auto-Reply//EN",
35
+ "CALSCALE:GREGORIAN",
36
+ "METHOD:PUBLISH",
37
+ "BEGIN:VEVENT",
38
+ `UID:${uid(e)}`,
39
+ `DTSTAMP:${stamp(now.toISOString())}`,
40
+ `DTSTART:${stamp(e.start)}`,
41
+ `DTEND:${endStamp(e)}`,
42
+ `SUMMARY:${esc(e.title)}`
43
+ ];
44
+ if (e.location) lines.push(`LOCATION:${esc(e.location)}`);
45
+ const description = [e.description, e.url].filter(Boolean).join("\n\n");
46
+ if (description) lines.push(`DESCRIPTION:${esc(description)}`);
47
+ if (e.url) lines.push(`URL:${e.url}`);
48
+ lines.push("END:VEVENT", "END:VCALENDAR");
49
+ return lines.join("\r\n") + "\r\n";
50
+ }
51
+ function googleCalendarUrl(e) {
52
+ const params = new URLSearchParams({
53
+ action: "TEMPLATE",
54
+ text: e.title,
55
+ dates: `${stamp(e.start)}/${endStamp(e)}`
56
+ });
57
+ if (e.location) params.set("location", e.location);
58
+ const details = [e.description, e.url].filter(Boolean).join("\n\n");
59
+ if (details) params.set("details", details);
60
+ return `https://calendar.google.com/calendar/render?${params.toString()}`;
61
+ }
62
+
11
63
  // src/forms/notify.ts
12
64
  var FORMS_FROM = "forms@reddoorla.com";
13
65
  var FALLBACK_REPLY_TO = "info@reddoorla.com";
@@ -137,7 +189,7 @@ function parseExtraFields(raw) {
137
189
  return {};
138
190
  }
139
191
  function extraFieldRows(raw) {
140
- return Object.entries(parseExtraFields(raw)).filter(([, v]) => !(typeof v === "string" && v.trim() === "")).map(([k, v]) => [humanizeKey(k), formatValue(v)]);
192
+ return Object.entries(parseExtraFields(raw)).filter(([k]) => !k.startsWith("_")).filter(([, v]) => !(typeof v === "string" && v.trim() === "")).map(([k, v]) => [humanizeKey(k), formatValue(v)]);
141
193
  }
142
194
  function fieldsTable(submission) {
143
195
  const rows = [
@@ -179,16 +231,35 @@ function buildAutoresponder(site, submission) {
179
231
  } catch {
180
232
  }
181
233
  if (siteHost && hostsMatch(emailDomain, siteHost)) return null;
182
- const intro = site.copyIntro ?? `Thanks for reaching out to ${site.name}.`;
183
- const contact = site.copyContact ?? "We've received your message and will be in touch soon.";
184
- const footer = site.copyFooter ?? site.name;
185
- return {
234
+ const extra = parseExtraFields(submission.extraFields);
235
+ const reply = parseReplyCopy(extra._reply);
236
+ const eventName = typeof extra.event === "string" ? extra.event.trim() : "";
237
+ const subject = reply?.subject ?? (eventName ? `You're on the list for ${eventName}` : "We got your message");
238
+ const paragraphs = reply?.paragraphs ?? [
239
+ site.copyIntro ?? `Thanks for reaching out to ${site.name}.`,
240
+ site.copyContact ?? "We've received your message and will be in touch soon."
241
+ ];
242
+ const signature = reply?.signature ?? site.copyFooter ?? site.name;
243
+ const body = paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`).join("");
244
+ const calendar = reply?.calendar;
245
+ const calendarBlock = calendar ? `<p>Add it to your calendar: <a href="${escapeHtml(googleCalendarUrl(calendar))}">Google Calendar</a>. The attached invite works in Apple Calendar and Outlook.</p>` : "";
246
+ const input = {
186
247
  from: `${displayName(site.name)} <${FORMS_FROM}>`,
187
248
  to: [submission.email],
188
249
  replyTo: resolveRecipients(site, submission)?.to[0] ?? FALLBACK_REPLY_TO,
189
- subject: "We got your message",
190
- html: `<p>${escapeHtml(intro)}</p><p>${escapeHtml(contact)}</p><p>${escapeHtml(footer)}</p>`
250
+ subject,
251
+ html: `${body}${calendarBlock}<p>${escapeHtml(signature)}</p>`
191
252
  };
253
+ if (calendar) {
254
+ input.attachments = [
255
+ {
256
+ filename: "event.ics",
257
+ content: Buffer.from(buildIcs(calendar), "utf8").toString("base64"),
258
+ contentType: "text/calendar"
259
+ }
260
+ ];
261
+ }
262
+ return input;
192
263
  }
193
264
  async function notifySubmission(deps, site, submission) {
194
265
  const poc = buildPocNotification(site, submission);
@@ -226,4 +297,4 @@ export {
226
297
  notifySubmission,
227
298
  makeNotify
228
299
  };
229
- //# sourceMappingURL=chunk-SJOXWJ5Y.js.map
300
+ //# sourceMappingURL=chunk-M2XF6JAI.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/forms/ics.ts","../src/forms/notify.ts"],"sourcesContent":["/**\n * Calendar formats for the confirmation email: an RFC 5545 VEVENT and a Google\n * Calendar template URL. Kept apart from notify.ts because the escaping rules\n * are fiddly enough to deserve their own tests, and because a calendar format\n * has nothing to do with email.\n */\nimport type { ReplyCalendar } from \"./reply-copy.js\";\n\nconst DEFAULT_DURATION_MS = 2 * 60 * 60 * 1000;\n\n/** `2026-09-12T18:00:00-07:00` → `20260913T010000Z`. Callers have already proven\n * the string parses (parseReplyCopy), so this never sees NaN. */\nfunction stamp(iso: string): string {\n return new Date(iso)\n .toISOString()\n .replace(/[-:]/g, \"\")\n .replace(/\\.\\d{3}/, \"\");\n}\n\nfunction endStamp(e: ReplyCalendar): string {\n if (e.end) return stamp(e.end);\n return stamp(new Date(Date.parse(e.start) + DEFAULT_DURATION_MS).toISOString());\n}\n\n/** RFC 5545 §3.3.11. Backslash FIRST — escaping it after the others would\n * double-escape the backslashes they just introduced. */\nfunction esc(v: string): string {\n return v\n .replace(/\\\\/g, \"\\\\\\\\\")\n .replace(/;/g, \"\\\\;\")\n .replace(/,/g, \"\\\\,\")\n .replace(/\\r?\\n/g, \"\\\\n\");\n}\n\n/** Stable per event, so a guest who receives a second copy sees their calendar\n * entry UPDATE rather than gain a duplicate. Derived from the fields that\n * identify the event, never from the send. */\nfunction uid(e: ReplyCalendar): string {\n const slug = e.title\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-|-$/g, \"\")\n .slice(0, 40);\n return `${stamp(e.start)}-${slug || \"event\"}@reddoorla.com`;\n}\n\n/** A complete single-event calendar, CRLF-delimited per the spec. */\nexport function buildIcs(e: ReplyCalendar, now: Date = new Date()): string {\n const lines = [\n \"BEGIN:VCALENDAR\",\n \"VERSION:2.0\",\n \"PRODID:-//Reddoor Creative//Form Auto-Reply//EN\",\n \"CALSCALE:GREGORIAN\",\n \"METHOD:PUBLISH\",\n \"BEGIN:VEVENT\",\n `UID:${uid(e)}`,\n `DTSTAMP:${stamp(now.toISOString())}`,\n `DTSTART:${stamp(e.start)}`,\n `DTEND:${endStamp(e)}`,\n `SUMMARY:${esc(e.title)}`,\n ];\n if (e.location) lines.push(`LOCATION:${esc(e.location)}`);\n const description = [e.description, e.url].filter(Boolean).join(\"\\n\\n\");\n if (description) lines.push(`DESCRIPTION:${esc(description)}`);\n if (e.url) lines.push(`URL:${e.url}`);\n lines.push(\"END:VEVENT\", \"END:VCALENDAR\");\n return lines.join(\"\\r\\n\") + \"\\r\\n\";\n}\n\n/** The \"Add to Google Calendar\" link. Google reads the same UTC stamps. */\nexport function googleCalendarUrl(e: ReplyCalendar): string {\n const params = new URLSearchParams({\n action: \"TEMPLATE\",\n text: e.title,\n dates: `${stamp(e.start)}/${endStamp(e)}`,\n });\n if (e.location) params.set(\"location\", e.location);\n const details = [e.description, e.url].filter(Boolean).join(\"\\n\\n\");\n if (details) params.set(\"details\", details);\n return `https://calendar.google.com/calendar/render?${params.toString()}`;\n}\n","import type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport type { SubmissionRow, NotifyStatus } from \"../reports/submission-row.js\";\nimport type { ResendSendInput } from \"../reports/send/resend.js\";\nimport { hostsMatch } from \"./ingest.js\";\nimport { escapeHtml } from \"../util/html.js\";\nimport { parseReplyCopy } from \"./reply-copy.js\";\nimport { buildIcs, googleCalendarUrl } from \"./ics.js\";\nimport { operatorEmail } from \"../util/operator.js\";\n\nconst FORMS_FROM = \"forms@reddoorla.com\";\n// Reply-To only: a client replying to a lead notification should reach the\n// shared inbox. The RECIPIENT fallback is the operator's own — see util/operator.\nconst FALLBACK_REPLY_TO = \"info@reddoorla.com\";\n\n/** Strip characters that would break an RFC 5322 display name. */\nfunction displayName(raw: string): string {\n return raw.replace(/[\"\\r\\n]/g, \"\").trim() || \"Reddoor\";\n}\n\nfunction pocAddress(site: WebsiteRow): string | null {\n return site.pointOfContact ?? site.reportRecipientsTo ?? null;\n}\n\n/** Coerce a string|string[] recipient config into a clean, de-duped address list. */\nfunction normalizeRecipients(v: string | string[] | undefined): string[] {\n const arr = Array.isArray(v) ? v : v ? [v] : [];\n const seen = new Set<string>();\n const out: string[] = [];\n for (const raw of arr) {\n if (typeof raw !== \"string\") continue;\n const t = raw.trim();\n if (t && !seen.has(t)) {\n seen.add(t);\n out.push(t);\n }\n }\n return out;\n}\n\nexport type Recipients = { to: string[]; cc: string[] };\n\n/**\n * Where a submission notification goes.\n * - Pre-launch (status !== \"maintained\"): the operator only — no routing, no CC.\n * Preserves the verify guard (flip a site to \"launching\" to route tests to\n * yourself).\n * - Maintained + a `Notify Routing` config: address by the routing field's value\n * (`extraFields[field]`) → its matched route, else the config `default`; CC from\n * the config. If nothing resolves, fall through to the single POC.\n * - Maintained, no routing: the single site POC (pointOfContact ?? reportRecipientsTo).\n * Returns null only when nothing resolves — the lead is still persisted; notify skips.\n */\nexport function resolveRecipients(site: WebsiteRow, submission: SubmissionRow): Recipients | null {\n if (site.status !== \"maintained\") {\n return { to: [operatorEmail()], cc: [] };\n }\n const routing = site.notifyRouting;\n if (routing) {\n const value = parseExtraFields(submission.extraFields)[routing.field];\n const match = typeof value === \"string\" ? routing.routes[value] : undefined;\n const to = normalizeRecipients(match ?? routing.default);\n if (to.length > 0) return { to, cc: normalizeRecipients(routing.cc) };\n // routing matched nothing → fall through to the POC below\n }\n const poc = pocAddress(site);\n return poc ? { to: [poc], cc: [] } : null;\n}\n\n/** Who a submission to this site would notify, answered WITHOUT sending one. */\nexport type NotifyTarget = {\n /** `client` means a real point of contact receives it. `operator` means the\n * pre-launch guard is holding and only you do. `nobody` means nothing\n * resolves — the lead is still stored, but no notification goes out. */\n audience: \"client\" | \"operator\" | \"nobody\";\n /** Every address a submission could reach, across ALL routing branches. */\n to: string[];\n cc: string[];\n /** Plain-language reason, naming the field that decided it. */\n reason: string;\n};\n\n/** A submission that exists only to ask resolveRecipients a question. */\nfunction probeSubmission(extraFields: string | null): SubmissionRow {\n return {\n id: \"probe\",\n submissionId: null,\n siteId: \"probe\",\n formType: \"contact\" as SubmissionRow[\"formType\"],\n name: \"probe\",\n email: \"probe@example.com\",\n phone: null,\n message: null,\n extraFields,\n sourceUrl: null,\n utm: null,\n submittedAt: null,\n status: \"new\" as SubmissionRow[\"status\"],\n notifyStatus: \"pending\" as NotifyStatus,\n resendMessageId: null,\n };\n}\n\n/**\n * Resolve who a form submission would notify, for a site, right now.\n *\n * This exists because the pre-launch guard had no feedback. Its whole state is\n * one Airtable `Status` cell nobody is looking at while testing, the site\n * reports nothing about it, and the only way to learn the answer was to submit\n * a form and read the `resend_message_id` afterwards. On 2026-08-03 an intended\n * flip never landed and a real client received a test lead — an email cannot be\n * recalled. A guard whose state you cannot see is a guard you will eventually\n * assume is on.\n *\n * Every address here comes back out of `resolveRecipients` itself rather than\n * from a second reading of the same rules — for a routed site each branch is\n * probed with a synthetic submission and the results unioned. A copy of the\n * routing logic that drifted would be worse than no answer at all, because it\n * would be a confident wrong one.\n */\nexport function describeNotifyTarget(site: WebsiteRow): NotifyTarget {\n if (site.status !== \"maintained\") {\n const guarded = resolveRecipients(site, probeSubmission(null));\n return {\n audience: \"operator\",\n to: guarded?.to ?? [],\n cc: [],\n reason: `Status is ${site.status === null ? \"blank\" : `\"${site.status}\"`}, not \"maintained\" — the pre-launch guard routes every notification to the operator.`,\n };\n }\n\n const routing = site.notifyRouting;\n if (!routing) {\n const direct = resolveRecipients(site, probeSubmission(null));\n if (!direct || direct.to.length === 0) {\n return {\n audience: \"nobody\",\n to: [],\n cc: [],\n reason: `Status is \"maintained\" but the site has no Point of Contact and no Notify Routing — the lead is stored and no notification is sent.`,\n };\n }\n return {\n audience: \"client\",\n to: direct.to,\n cc: direct.cc,\n reason: `Status is \"maintained\" — notifications go to the site's Point of Contact.`,\n };\n }\n\n // Probe every declared route value, plus one value that matches none so the\n // `default` (and the POC fall-through behind it) is covered too.\n const to = new Set<string>();\n const cc = new Set<string>();\n const values = [...Object.keys(routing.routes), \"\u0000no-such-route\"];\n for (const value of values) {\n const r = resolveRecipients(site, probeSubmission(JSON.stringify({ [routing.field]: value })));\n r?.to.forEach((a) => to.add(a));\n r?.cc.forEach((a) => cc.add(a));\n }\n if (to.size === 0) {\n return {\n audience: \"nobody\",\n to: [],\n cc: [],\n reason: `Status is \"maintained\" with Notify Routing on \"${routing.field}\", but no route, default or Point of Contact resolves to an address.`,\n };\n }\n return {\n audience: \"client\",\n to: [...to],\n cc: [...cc],\n reason: `Status is \"maintained\" with Notify Routing on \"${routing.field}\" — the recipient depends on that field's value; every address it could reach is listed.`,\n };\n}\n\n/** Humanize an extraFields key for display: \"appointment_date\" → \"Appointment date\". */\nfunction humanizeKey(k: string): string {\n const spaced = k.replace(/[_-]+/g, \" \").trim();\n return spaced ? spaced.charAt(0).toUpperCase() + spaced.slice(1) : k;\n}\n\nfunction formatValue(v: unknown): string {\n if (v === null || v === undefined) return \"—\";\n if (typeof v === \"string\") return v;\n if (typeof v === \"number\" || typeof v === \"boolean\") return String(v);\n return JSON.stringify(v);\n}\n\n/** Parse the stored `extraFields` JSON into a plain object — bad JSON or a\n * non-object yields {} (never throws). Shared by the email renderer and routing. */\nfunction parseExtraFields(raw: string | null): Record<string, unknown> {\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n } catch {\n /* malformed JSON → no fields */\n }\n return {};\n}\n\nfunction extraFieldRows(raw: string | null): Array<[string, string]> {\n return (\n Object.entries(parseExtraFields(raw))\n // Underscore keys are reserved transport (see payload.ts), never lead data.\n // `_reply` is a whole confirmation email; rendering it here would put the\n // copy in the client's notification as an unreadable JSON row.\n .filter(([k]) => !k.startsWith(\"_\"))\n .filter(([, v]) => !(typeof v === \"string\" && v.trim() === \"\"))\n .map(([k, v]) => [humanizeKey(k), formatValue(v)] as [string, string])\n );\n}\n\nfunction fieldsTable(submission: SubmissionRow): string {\n const rows: Array<[string, string]> = [\n [\"Form\", submission.formType],\n [\"Name\", submission.name || \"—\"],\n [\"Email\", submission.email || \"—\"],\n ];\n if (submission.phone) rows.push([\"Phone\", submission.phone]);\n // Site-specific context (the artwork an inquiry is about, the event an rsvp is\n // for, etc.) lives in extraFields — surface it so the recipient sees what the\n // submitter was looking at, not just their name and email.\n rows.push(...extraFieldRows(submission.extraFields));\n if (submission.sourceUrl) rows.push([\"Page\", submission.sourceUrl]);\n if (submission.utm) rows.push([\"UTM\", submission.utm]);\n const body = rows\n .map(([k, v]) => `<tr><td><strong>${escapeHtml(k)}</strong></td><td>${escapeHtml(v)}</td></tr>`)\n .join(\"\");\n const message = submission.message\n ? `<p style=\"white-space:pre-wrap\">${escapeHtml(submission.message)}</p>`\n : \"\";\n return `<table>${body}</table>${message}`;\n}\n\n/** POC notification — the primary email; null when the site has no contact address. */\nexport function buildPocNotification(\n site: WebsiteRow,\n submission: SubmissionRow,\n): ResendSendInput | null {\n if (submission.status === \"spam_auto\" || submission.status === \"spam\") return null;\n const recipients = resolveRecipients(site, submission);\n if (!recipients || recipients.to.length === 0) return null;\n const input: ResendSendInput = {\n from: `${displayName(site.name)} Forms <${FORMS_FROM}>`,\n to: recipients.to,\n subject: `New ${submission.formType} from ${site.name}`,\n html: `<h2>New ${escapeHtml(submission.formType)} submission — ${escapeHtml(\n site.name,\n )}</h2>${fieldsTable(submission)}`,\n };\n if (recipients.cc.length > 0) input.cc = recipients.cc;\n // Reply straight to the lead.\n if (submission.email) input.replyTo = submission.email;\n return input;\n}\n\n/** Autoresponder to the submitter — null when there's no submitter email. */\nexport function buildAutoresponder(\n site: WebsiteRow,\n submission: SubmissionRow,\n): ResendSendInput | null {\n if (submission.status === \"spam_auto\" || submission.status === \"spam\") return null;\n if (!submission.email) return null;\n // A submitter email on the site's OWN domain is never a real lead wanting a\n // confirmation — it is the spoofed-sender backscatter case (a bot writes the\n // site's info@ as its email and the \"We got your message\" lands in the\n // client's inbox). The POC notification still sends; only this email is\n // suppressed. Unparseable/blank site url → fail open and send, same\n // philosophy as turnstileHostnameAcceptable.\n const emailDomain = submission.email.split(\"@\").pop() ?? \"\";\n let siteHost = \"\";\n try {\n siteHost = new URL(site.url).hostname;\n } catch {\n /* fail open */\n }\n if (siteHost && hostsMatch(emailDomain, siteHost)) return null;\n const extra = parseExtraFields(submission.extraFields);\n const reply = parseReplyCopy(extra._reply);\n const eventName = typeof extra.event === \"string\" ? extra.event.trim() : \"\";\n\n // Subject, best available first. The middle tier is why an RSVP reads like a\n // confirmation even before anyone writes a word of copy: the event name is\n // already on every submission that has one.\n const subject =\n reply?.subject ?? (eventName ? `You're on the list for ${eventName}` : \"We got your message\");\n\n // Body. The per-site trio is the last-resort net for sites that author\n // nothing — it has no editor since the Airtable freeze, so anything a client\n // can actually change now comes through the envelope.\n const paragraphs = reply?.paragraphs ?? [\n site.copyIntro ?? `Thanks for reaching out to ${site.name}.`,\n site.copyContact ?? \"We've received your message and will be in touch soon.\",\n ];\n const signature = reply?.signature ?? site.copyFooter ?? site.name;\n\n const body = paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`).join(\"\");\n // The one block assembled rather than escaped wholesale, because it carries an\n // anchor we build ourselves. Its only interpolated value is the Google URL,\n // escaped on the way in — and parseReplyCopy already refused any non-https\n // `url` that feeds it.\n const calendar = reply?.calendar;\n const calendarBlock = calendar\n ? `<p>Add it to your calendar: <a href=\"${escapeHtml(googleCalendarUrl(calendar))}\">Google Calendar</a>. ` +\n `The attached invite works in Apple Calendar and Outlook.</p>`\n : \"\";\n\n const input: ResendSendInput = {\n from: `${displayName(site.name)} <${FORMS_FROM}>`,\n to: [submission.email],\n replyTo: resolveRecipients(site, submission)?.to[0] ?? FALLBACK_REPLY_TO,\n subject,\n html: `${body}${calendarBlock}<p>${escapeHtml(signature)}</p>`,\n };\n if (calendar) {\n // A bare Google link strands every Apple Mail reader; an .ics alone is a\n // file most people on a phone will not open. Both, once.\n input.attachments = [\n {\n filename: \"event.ics\",\n content: Buffer.from(buildIcs(calendar), \"utf8\").toString(\"base64\"),\n contentType: \"text/calendar\",\n },\n ];\n }\n return input;\n}\n\nexport type NotifyDeps = {\n send: (input: ResendSendInput) => Promise<{ messageId: string }>;\n};\n\nexport type NotifyOutcome = { status: NotifyStatus; messageId: string | null };\n\n/**\n * Send the POC notification (primary — drives notifyStatus) then the submitter\n * autoresponder (best-effort — logged, never changes the outcome). The submission\n * is already persisted before this runs, so a Resend outage degrades to\n * notifyStatus=\"failed\", never a lost lead.\n */\nexport async function notifySubmission(\n deps: NotifyDeps,\n site: WebsiteRow,\n submission: SubmissionRow,\n): Promise<NotifyOutcome> {\n const poc = buildPocNotification(site, submission);\n let outcome: NotifyOutcome;\n if (!poc) {\n outcome = { status: \"skipped\", messageId: null };\n } else {\n try {\n const { messageId } = await deps.send(poc);\n outcome = { status: \"sent\", messageId };\n } catch (err) {\n console.error(`[submissions] POC notification failed: ${String(err)}`);\n outcome = { status: \"failed\", messageId: null };\n }\n }\n const auto = buildAutoresponder(site, submission);\n if (auto) {\n try {\n await deps.send(auto);\n } catch (err) {\n console.error(`[submissions] autoresponder failed: ${String(err)}`);\n }\n }\n return outcome;\n}\n\n/**\n * Build the ingest `notify` dependency from a Resend send fn — or `null` when the\n * Resend client couldn't even be constructed (e.g. `RESEND_API_KEY` unset). A null\n * send marks the notification `failed` WITHOUT attempting it, so a Resend\n * misconfiguration degrades to a captured-but-unemailed lead rather than aborting\n * ingest and losing it. Mirrors the in-flight failure isolation in notifySubmission.\n */\nexport function makeNotify(\n send: NotifyDeps[\"send\"] | null,\n): (site: WebsiteRow, submission: SubmissionRow) => Promise<NotifyOutcome> {\n return (site, submission) =>\n send\n ? notifySubmission({ send }, site, submission)\n : Promise.resolve({ status: \"failed\", messageId: null });\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  approveBlockers
3
- } from "./chunk-KGUKL3CV.js";
3
+ } from "./chunk-E22WAIF2.js";
4
4
  import {
5
5
  ACTIVE_STATUSES,
6
6
  allActionableVulnsTransitive,
@@ -299,4 +299,4 @@ export {
299
299
  prismicAckIsLive,
300
300
  collectPrismicDriftAlerts
301
301
  };
302
- //# sourceMappingURL=chunk-ZOCJBJQV.js.map
302
+ //# sourceMappingURL=chunk-MVDMKKBN.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  withRecipe
3
- } from "./chunk-3G25KIWW.js";
3
+ } from "./chunk-ZTPPCHDB.js";
4
4
  import {
5
5
  bumpDep,
6
6
  readPackageJson,
@@ -150,4 +150,4 @@ export {
150
150
  selfCaretRange,
151
151
  onboard
152
152
  };
153
- //# sourceMappingURL=chunk-7RJ2HMMJ.js.map
153
+ //# sourceMappingURL=chunk-NTLSMJHW.js.map
@@ -357,6 +357,32 @@ var MIGRATIONS = [
357
357
  CREATE INDEX IF NOT EXISTS idx_submissions_form_type
358
358
  ON submissions (form_type);
359
359
  `
360
+ },
361
+ {
362
+ // `url` is stored exactly as audited, which makes it a record of the run and
363
+ // a bad thing to group by: the table already holds beachfrontdentistry.com
364
+ // and beachfrontdentistry.com/ as two histories of one practice, and both
365
+ // spellings of ludlowkingsley.com. That costs nothing today because nothing
366
+ // compares two audits of one site — and becomes a returning client with no
367
+ // history the day the report offers a before and an after.
368
+ //
369
+ // Deliberately no SQL backfill. Doing it with lower()/replace()/rtrim()
370
+ // would mangle any URL containing "www." or "http://" away from its start,
371
+ // and a key that is quietly wrong is worse than one that is null. Existing
372
+ // rows are backfilled once with the real siteKey().
373
+ // Same ADD COLUMN caveat as 0003: no IF NOT EXISTS in SQLite, so the runner
374
+ // treats "duplicate column name" as already-applied. Kept as its own
375
+ // migration, separate from its index below, so that recovery path stays a
376
+ // single statement.
377
+ id: "0013_prospect_audits_site_key",
378
+ sql: `ALTER TABLE prospect_audits ADD COLUMN site_key TEXT;`
379
+ },
380
+ {
381
+ id: "0014_prospect_audits_site_key_index",
382
+ sql: `
383
+ CREATE INDEX IF NOT EXISTS idx_prospect_audits_site_key
384
+ ON prospect_audits (site_key, created_at DESC);
385
+ `
360
386
  }
361
387
  ];
362
388
 
@@ -391,4 +417,4 @@ async function runMigrations(client) {
391
417
  export {
392
418
  runMigrations
393
419
  };
394
- //# sourceMappingURL=chunk-MFHS7IQ2.js.map
420
+ //# sourceMappingURL=chunk-OCNSIBMP.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/db/migrations.ts","../src/db/migrate.ts"],"sourcesContent":["/** Ordered, append-only list of standard-SQL migration scripts. Each runs once,\n * tracked by `id` in the `_migrations` table (see migrate.ts). Statements use\n * IF NOT EXISTS so even a partial re-apply is safe. Never edit a shipped script —\n * add a new one. Every statement in a script MUST be independently idempotent:\n * `migrate.ts` runs each script via `executeMultiple`, which is NOT transactional,\n * so a mid-script failure can leave earlier statements applied and the id unrecorded\n * — a re-run then re-executes the whole script. Standard SQLite SQL only (no\n * Turso-specific syntax) so the host stays a connection-string swap. */\nexport type Migration = { id: string; sql: string };\n\nexport const MIGRATIONS: Migration[] = [\n {\n id: \"0001_init\",\n sql: `\n CREATE TABLE IF NOT EXISTS submissions (\n id TEXT PRIMARY KEY,\n submission_id INTEGER,\n site_id TEXT NOT NULL,\n form_type TEXT NOT NULL,\n name TEXT NOT NULL,\n email TEXT NOT NULL,\n phone TEXT,\n message TEXT,\n extra_fields TEXT,\n source_url TEXT,\n utm TEXT,\n submitted_at TEXT,\n status TEXT NOT NULL DEFAULT 'new',\n notify_status TEXT NOT NULL DEFAULT 'skipped',\n resend_message_id TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_submissions_site_submitted\n ON submissions (site_id, submitted_at DESC);\n CREATE INDEX IF NOT EXISTS idx_submissions_status\n ON submissions (status);\n CREATE TABLE IF NOT EXISTS spam_screenouts (\n site_id TEXT NOT NULL,\n date TEXT NOT NULL,\n honeypot INTEGER NOT NULL DEFAULT 0,\n too_fast INTEGER NOT NULL DEFAULT 0,\n marked_spam INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (site_id, date)\n );\n `,\n },\n {\n id: \"0002_fleet_events\",\n sql: `\n CREATE TABLE IF NOT EXISTS fleet_events (\n id TEXT PRIMARY KEY,\n ts TEXT NOT NULL,\n type TEXT NOT NULL,\n site_id TEXT,\n site_name TEXT,\n summary TEXT NOT NULL,\n data TEXT,\n created_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS idx_fleet_events_ts ON fleet_events (ts);\n `,\n },\n {\n id: \"0003_add_spam_score\",\n // SQLite `ADD COLUMN` has no `IF NOT EXISTS`, so it is NOT self-idempotent: if the\n // `_migrations` marker is lost after the column is added (crash between statement\n // and marker write), a re-run would throw `duplicate column name`. `migrate.ts`\n // recognizes that error as already-applied and records the marker, keeping the\n // runner idempotent (see `isAlreadyAppliedError`).\n sql: `ALTER TABLE submissions ADD COLUMN spam_score REAL;`,\n },\n {\n id: \"0004_add_spam_reason\",\n sql: `ALTER TABLE submissions ADD COLUMN spam_reason TEXT;`,\n },\n {\n // Outcome of the newsletter fan-out (site webhook + Mailchimp). Before this the\n // results were console.error-only, so a rotated Mailchimp key or an outage would\n // silently stop signups reaching the audience with no trace anywhere the operator\n // looks — the row still read `notify=sent`, healthy. Same non-idempotent\n // ADD COLUMN caveat as 0003.\n id: \"0005_add_fanout_status\",\n sql: `ALTER TABLE submissions ADD COLUMN fanout_status TEXT;`,\n },\n {\n // Phase 0 of the Airtable → Turso migration (#539): a lead whose SITE LOOKUP\n // fails must still land somewhere durable. On 2026-08-17 the Airtable quota\n // outage made `getWebsiteBySlug` throw before `createSubmission` ran, so every\n // lead in the window 502'd away unrecorded — while THIS store (which holds\n // submissions) was healthy the whole time. Rows here are written only on that\n // path and replayed through the normal pipeline once the lookup recovers\n // (`db replay-deadletters`), producing an ordinary submissions row with real\n // spam classification and notify. Deliberately a separate table rather than a\n // nullable `submissions.site_id`: site_id is NOT NULL, every reader assumes a\n // real rec id, and a sentinel would collapse dashboard grouping (the \"\" key\n // hazard audit.ts already documents).\n //\n // `turnstile` stores the verification COMPUTED AT RECEIPT ({outcome, hostname}\n // JSON) — tokens expire in 300s, so replay could never re-verify; it replays\n // with the answer we already had.\n id: \"0006_submission_deadletter\",\n sql: `\n CREATE TABLE IF NOT EXISTS submission_deadletter (\n id TEXT PRIMARY KEY,\n site_slug TEXT NOT NULL,\n payload TEXT NOT NULL,\n turnstile TEXT NOT NULL,\n error TEXT NOT NULL,\n received_at TEXT NOT NULL,\n replayed_at TEXT,\n replay_outcome TEXT,\n replay_submission_id TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_deadletter_unreplayed\n ON submission_deadletter (replayed_at);\n `,\n },\n {\n // Phase 1.2 of the Airtable → Turso migration (#539): the fleet-state tables.\n // Split by WRITER, not topic (design D2, confirmed by the 2026-08-23 writer\n // map — every code-written column has exactly one writer):\n // sites — operator (dashboard editor / console / launch flow)\n // site_health — the nightly audit write-back, one batched upsert\n // site_schedule — updateNextDueDates (report cron, derived)\n // reports — report drafting + the operator approve flow\n // PKs are the Airtable `rec…` ids (design D1): 278+ submissions rows already\n // reference them, and the parity harness diffs the two stores row-for-row.\n //\n // Naming: snake_case throughout; Airtable's misspellings (\"maintenence\") and\n // display quirks die here. `sites.legacy` is a JSON object holding the 33\n // populated-but-code-unreferenced columns (launch-era checklist, hosting\n // reference cells) keyed by their original Airtable column name — data kept,\n // schema not fossilized. The plaintext DNS/cms credential cells deliberately\n // do NOT migrate at all (operator ruling 2026-08-23); they live on only in\n // the frozen base. `site_health.analytics_soft_fail_at` is the column code\n // always wanted (\"Analytics soft-fail at\") but no operator ever created in\n // Airtable — it ships real here.\n //\n // reports.checklist is JSON keyed by the STABLE checklist key (\"deploy\",\n // \"cms\", …, from src/reports/checklist.ts), not the Airtable column name —\n // the importer translates, so \"Test: Verified After Updates\" stops leaking\n // its legacy name into a second store.\n id: \"0007_fleet_state\",\n sql: `\n CREATE TABLE IF NOT EXISTS sites (\n id TEXT PRIMARY KEY,\n slug TEXT NOT NULL UNIQUE,\n name TEXT NOT NULL,\n url TEXT,\n status TEXT,\n point_of_contact TEXT,\n maintenance_freq TEXT,\n testing_freq TEXT,\n maintenance_day TEXT,\n testing_day TEXT,\n ga4_property_id TEXT,\n search_query TEXT,\n search_console_property TEXT,\n git_repo TEXT,\n netlify_id TEXT,\n report_recipients_to TEXT,\n report_recipients_cc TEXT,\n copy_intro TEXT,\n copy_contact TEXT,\n copy_footer TEXT,\n newsletter_webhook TEXT,\n mailchimp_api_key TEXT,\n mailchimp_audience_id TEXT,\n notify_routing TEXT,\n require_turnstile INTEGER NOT NULL DEFAULT 0,\n accepted_watch_conditions TEXT,\n prismic_ack_until TEXT,\n launched_at TEXT,\n header_image BLOB,\n header_image_filename TEXT,\n header_image_type TEXT,\n header_image_generated_at TEXT,\n legacy TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_sites_status ON sites (status);\n\n CREATE TABLE IF NOT EXISTS site_health (\n site_id TEXT PRIMARY KEY,\n p_score INTEGER,\n r_score INTEGER,\n bp_score INTEGER,\n seo_score INTEGER,\n lighthouse_at TEXT,\n a11y_violations INTEGER,\n deps_drifted INTEGER,\n deps_major_behind INTEGER,\n deps_outdated INTEGER,\n deps_major_outdated INTEGER,\n vulns_critical INTEGER,\n vulns_high INTEGER,\n vulns_moderate INTEGER,\n vulns_low INTEGER,\n security_audit_at TEXT,\n security_advisories TEXT,\n auto_fix_attempts INTEGER,\n analytics_soft_fail_at TEXT,\n cert_days_remaining INTEGER,\n domain_checked_at TEXT,\n deploy_status TEXT,\n last_deploy_at TEXT,\n deploy_log_url TEXT,\n deploy_checked_at TEXT,\n function_health TEXT,\n cms_reachable TEXT,\n turnstile_widget TEXT,\n function_health_checked_at TEXT,\n crossbrowser_ok INTEGER,\n mobile_ok INTEGER,\n links_ok INTEGER,\n broken_links INTEGER,\n browser_checked_at TEXT,\n uptime_reachable TEXT,\n titles_meta_ok TEXT,\n smoke_ok TEXT,\n last_smoke_at TEXT,\n form_e2e_ok TEXT,\n form_e2e_checked_at TEXT,\n renovate_failing_cis INTEGER,\n default_branch_ci TEXT,\n last_commit_at TEXT,\n github_signals_at TEXT,\n prismic_models TEXT,\n prismic_models_checked_at TEXT,\n prismic_models_drift TEXT\n );\n\n CREATE TABLE IF NOT EXISTS site_schedule (\n site_id TEXT PRIMARY KEY,\n next_maintenance_at TEXT,\n next_testing_at TEXT,\n computed_at TEXT\n );\n\n CREATE TABLE IF NOT EXISTS reports (\n id TEXT PRIMARY KEY,\n site_id TEXT,\n report_id TEXT,\n report_type TEXT,\n period TEXT,\n period_start TEXT,\n period_end TEXT,\n completed_on TEXT,\n lighthouse_performance INTEGER,\n lighthouse_accessibility INTEGER,\n lighthouse_best_practices INTEGER,\n lighthouse_seo INTEGER,\n ga_users_current INTEGER,\n ga_users_previous INTEGER,\n search_found_page1 INTEGER,\n search_position REAL,\n last_tested_date TEXT,\n commentary TEXT,\n subject_override TEXT,\n draft_ready INTEGER NOT NULL DEFAULT 0,\n approved_to_send INTEGER NOT NULL DEFAULT 0,\n approved_at TEXT,\n approved_by TEXT,\n send_override INTEGER NOT NULL DEFAULT 0,\n override_reason TEXT,\n override_by TEXT,\n override_at TEXT,\n sent_at TEXT,\n delivery_status TEXT,\n resend_message_id TEXT,\n checklist TEXT,\n checklist_auto_evidence TEXT,\n rendered_html TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_reports_site ON reports (site_id, period_start DESC);\n `,\n },\n {\n // Indexes the EXPLAIN-query-plan gate (tests/db/query-plans.test.ts) proved the\n // hot paths need — each serves a named query shape that otherwise full-scans\n // submissions, the one unbounded-growth table:\n // submitted_at — every windowed read (/submissions default page, digest\n // per-site counts, duplicate/repeat-sender scans)\n // resend_message_id — the resend-webhook bounce lookup (markNotifyBouncedByMessageId)\n // submission_id — createSubmission's MAX(submission_id)+1 display number,\n // O(1) off the index instead of a scan per insert\n // spam_reason (partial, covering) — listSpamReasonsFiltered's facet tally\n // reads ONLY spam_reason WHERE spam_reason IS NOT NULL;\n // partial keeps it to the spam subset\n // spam_screenouts(date) — listScreenOutsSince windows on date, which the\n // (site_id, date) PK can't serve\n id: \"0008_query_plan_indexes\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_submissions_submitted\n ON submissions (submitted_at DESC);\n CREATE INDEX IF NOT EXISTS idx_submissions_spam_reason\n ON submissions (spam_reason) WHERE spam_reason IS NOT NULL;\n CREATE INDEX IF NOT EXISTS idx_submissions_resend_message\n ON submissions (resend_message_id);\n CREATE INDEX IF NOT EXISTS idx_submissions_display_number\n ON submissions (submission_id);\n CREATE INDEX IF NOT EXISTS idx_screenouts_date\n ON spam_screenouts (date);\n `,\n },\n {\n // Prospect audits (spec 2026-08-24): one row per `prospect-audit` CLI run.\n // `token` is the unguessable public handle for GET /r/{token}; `result_json`\n // is the full ProspectAuditResult, re-rendered on every request so report\n // styling updates apply to already-shared links.\n id: \"0009_prospect_audits\",\n sql: `\n CREATE TABLE IF NOT EXISTS prospect_audits (\n id TEXT PRIMARY KEY,\n token TEXT NOT NULL UNIQUE,\n url TEXT NOT NULL,\n business TEXT,\n created_at TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'complete',\n result_json TEXT NOT NULL\n );\n `,\n },\n {\n // Cockpit /audits listing page (spec 2026-08-25, Piece 5):\n // listRecentProspectAudits orders by created_at DESC with no WHERE clause —\n // without this index that's a raw SCAN of prospect_audits followed by a temp\n // b-tree sort, which the EXPLAIN-query-plan gate (tests/db/query-plans.test.ts)\n // fails the build on. DESC matches the query's own ORDER BY so SQLite can walk\n // the index directly instead of sorting.\n id: \"0010_prospect_audits_created_index\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_prospect_audits_created\n ON prospect_audits (created_at DESC);\n `,\n },\n {\n // #609 (#539 Phase 5): the digest's prior-run snapshot moves off Airtable.\n // Unlike the rest of Phase 5 this is a MIGRATION, not a dual-write — there\n // is no Airtable counterpart left afterwards, so parity does not cover it.\n //\n // A single row holding the whole snapshot as JSON, keyed by a constant id.\n // Both readers (runDigest's diff and the fleet homepage's NEW badges) need\n // the ENTIRE map, so a keyed table would buy nothing on reads and its\n // \"give me everything\" query would be a raw scan needing a justified entry\n // in the EXPLAIN gate's allowlist. One row by primary key needs neither.\n id: \"0011_digest_state\",\n sql: `\n CREATE TABLE IF NOT EXISTS digest_state (\n id TEXT PRIMARY KEY,\n snapshot TEXT NOT NULL,\n updated_at TEXT\n );\n `,\n },\n {\n // form_type was the one SubmissionFilter key with an equality predicate and no\n // index — countSubmissionsFiltered({formType}) raw-scanned `submissions`, twice\n // per load of the submissions page. It shipped green because the EXPLAIN gate\n // named its scenarios by hand and never wrote one for this shape; the gate is\n // now driven by a filter matrix, which is what surfaced it.\n //\n // `submissions` is the one unbounded-growth table in the schema — append-only,\n // one row per fleet lead forever — and Turso meters ROW SCANS, so this is the\n // cost that grows without a ceiling.\n id: \"0012_submissions_form_type_index\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_submissions_form_type\n ON submissions (form_type);\n `,\n },\n];\n","import type { Client } from \"@libsql/client\";\nimport { MIGRATIONS } from \"./migrations.js\";\n\n/** True when an error means \"this DDL was already applied\" and re-applying is a\n * no-op we can safely ignore. `CREATE ... IF NOT EXISTS` never throws, but SQLite\n * `ALTER TABLE ... ADD COLUMN` has no `IF NOT EXISTS`, so a re-run after a lost\n * `_migrations` marker throws `duplicate column name: …`. Treating that as\n * already-applied keeps `runMigrations` idempotent for `ADD COLUMN` migrations —\n * without it a lost marker would boot-loop `openDb()` on every call. */\nfunction isAlreadyAppliedError(err: unknown): boolean {\n const message = err instanceof Error ? err.message : String(err);\n return /duplicate column name/i.test(message);\n}\n\n/** Apply every not-yet-applied migration in order against a raw libSQL client,\n * tracking applied ids in `_migrations`. Idempotent: ids already present are\n * skipped, the DDL uses IF NOT EXISTS where it can, and an already-applied\n * `ADD COLUMN` (recognized by its error) is swallowed so the marker is still\n * recorded. Returns the ids applied this run. Runs on every openDb() — cheap\n * (one indexed SELECT) and the gate every fresh Turso database needs before its\n * first write. */\nexport async function runMigrations(client: Client): Promise<string[]> {\n await client.execute(\n \"CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)\",\n );\n const existing = await client.execute(\"SELECT id FROM _migrations\");\n const applied = new Set(existing.rows.map((r) => String(r.id)));\n const ran: string[] = [];\n for (const m of MIGRATIONS) {\n if (applied.has(m.id)) continue;\n try {\n await client.executeMultiple(m.sql);\n } catch (err) {\n // A re-run after a lost marker can re-execute a non-idempotent `ADD COLUMN`;\n // treat \"already applied\" as success so we still record the marker below and\n // never boot-loop. Any other error propagates unchanged.\n if (!isAlreadyAppliedError(err)) throw err;\n }\n await client.execute({\n sql: \"INSERT INTO _migrations (id, applied_at) VALUES (?, ?)\",\n args: [m.id, new Date().toISOString()],\n });\n ran.push(m.id);\n }\n return ran;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/db/migrations.ts","../src/db/migrate.ts"],"sourcesContent":["/** Ordered, append-only list of standard-SQL migration scripts. Each runs once,\n * tracked by `id` in the `_migrations` table (see migrate.ts). Statements use\n * IF NOT EXISTS so even a partial re-apply is safe. Never edit a shipped script —\n * add a new one. Every statement in a script MUST be independently idempotent:\n * `migrate.ts` runs each script via `executeMultiple`, which is NOT transactional,\n * so a mid-script failure can leave earlier statements applied and the id unrecorded\n * — a re-run then re-executes the whole script. Standard SQLite SQL only (no\n * Turso-specific syntax) so the host stays a connection-string swap. */\nexport type Migration = { id: string; sql: string };\n\nexport const MIGRATIONS: Migration[] = [\n {\n id: \"0001_init\",\n sql: `\n CREATE TABLE IF NOT EXISTS submissions (\n id TEXT PRIMARY KEY,\n submission_id INTEGER,\n site_id TEXT NOT NULL,\n form_type TEXT NOT NULL,\n name TEXT NOT NULL,\n email TEXT NOT NULL,\n phone TEXT,\n message TEXT,\n extra_fields TEXT,\n source_url TEXT,\n utm TEXT,\n submitted_at TEXT,\n status TEXT NOT NULL DEFAULT 'new',\n notify_status TEXT NOT NULL DEFAULT 'skipped',\n resend_message_id TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_submissions_site_submitted\n ON submissions (site_id, submitted_at DESC);\n CREATE INDEX IF NOT EXISTS idx_submissions_status\n ON submissions (status);\n CREATE TABLE IF NOT EXISTS spam_screenouts (\n site_id TEXT NOT NULL,\n date TEXT NOT NULL,\n honeypot INTEGER NOT NULL DEFAULT 0,\n too_fast INTEGER NOT NULL DEFAULT 0,\n marked_spam INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (site_id, date)\n );\n `,\n },\n {\n id: \"0002_fleet_events\",\n sql: `\n CREATE TABLE IF NOT EXISTS fleet_events (\n id TEXT PRIMARY KEY,\n ts TEXT NOT NULL,\n type TEXT NOT NULL,\n site_id TEXT,\n site_name TEXT,\n summary TEXT NOT NULL,\n data TEXT,\n created_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS idx_fleet_events_ts ON fleet_events (ts);\n `,\n },\n {\n id: \"0003_add_spam_score\",\n // SQLite `ADD COLUMN` has no `IF NOT EXISTS`, so it is NOT self-idempotent: if the\n // `_migrations` marker is lost after the column is added (crash between statement\n // and marker write), a re-run would throw `duplicate column name`. `migrate.ts`\n // recognizes that error as already-applied and records the marker, keeping the\n // runner idempotent (see `isAlreadyAppliedError`).\n sql: `ALTER TABLE submissions ADD COLUMN spam_score REAL;`,\n },\n {\n id: \"0004_add_spam_reason\",\n sql: `ALTER TABLE submissions ADD COLUMN spam_reason TEXT;`,\n },\n {\n // Outcome of the newsletter fan-out (site webhook + Mailchimp). Before this the\n // results were console.error-only, so a rotated Mailchimp key or an outage would\n // silently stop signups reaching the audience with no trace anywhere the operator\n // looks — the row still read `notify=sent`, healthy. Same non-idempotent\n // ADD COLUMN caveat as 0003.\n id: \"0005_add_fanout_status\",\n sql: `ALTER TABLE submissions ADD COLUMN fanout_status TEXT;`,\n },\n {\n // Phase 0 of the Airtable → Turso migration (#539): a lead whose SITE LOOKUP\n // fails must still land somewhere durable. On 2026-08-17 the Airtable quota\n // outage made `getWebsiteBySlug` throw before `createSubmission` ran, so every\n // lead in the window 502'd away unrecorded — while THIS store (which holds\n // submissions) was healthy the whole time. Rows here are written only on that\n // path and replayed through the normal pipeline once the lookup recovers\n // (`db replay-deadletters`), producing an ordinary submissions row with real\n // spam classification and notify. Deliberately a separate table rather than a\n // nullable `submissions.site_id`: site_id is NOT NULL, every reader assumes a\n // real rec id, and a sentinel would collapse dashboard grouping (the \"\" key\n // hazard audit.ts already documents).\n //\n // `turnstile` stores the verification COMPUTED AT RECEIPT ({outcome, hostname}\n // JSON) — tokens expire in 300s, so replay could never re-verify; it replays\n // with the answer we already had.\n id: \"0006_submission_deadletter\",\n sql: `\n CREATE TABLE IF NOT EXISTS submission_deadletter (\n id TEXT PRIMARY KEY,\n site_slug TEXT NOT NULL,\n payload TEXT NOT NULL,\n turnstile TEXT NOT NULL,\n error TEXT NOT NULL,\n received_at TEXT NOT NULL,\n replayed_at TEXT,\n replay_outcome TEXT,\n replay_submission_id TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_deadletter_unreplayed\n ON submission_deadletter (replayed_at);\n `,\n },\n {\n // Phase 1.2 of the Airtable → Turso migration (#539): the fleet-state tables.\n // Split by WRITER, not topic (design D2, confirmed by the 2026-08-23 writer\n // map — every code-written column has exactly one writer):\n // sites — operator (dashboard editor / console / launch flow)\n // site_health — the nightly audit write-back, one batched upsert\n // site_schedule — updateNextDueDates (report cron, derived)\n // reports — report drafting + the operator approve flow\n // PKs are the Airtable `rec…` ids (design D1): 278+ submissions rows already\n // reference them, and the parity harness diffs the two stores row-for-row.\n //\n // Naming: snake_case throughout; Airtable's misspellings (\"maintenence\") and\n // display quirks die here. `sites.legacy` is a JSON object holding the 33\n // populated-but-code-unreferenced columns (launch-era checklist, hosting\n // reference cells) keyed by their original Airtable column name — data kept,\n // schema not fossilized. The plaintext DNS/cms credential cells deliberately\n // do NOT migrate at all (operator ruling 2026-08-23); they live on only in\n // the frozen base. `site_health.analytics_soft_fail_at` is the column code\n // always wanted (\"Analytics soft-fail at\") but no operator ever created in\n // Airtable — it ships real here.\n //\n // reports.checklist is JSON keyed by the STABLE checklist key (\"deploy\",\n // \"cms\", …, from src/reports/checklist.ts), not the Airtable column name —\n // the importer translates, so \"Test: Verified After Updates\" stops leaking\n // its legacy name into a second store.\n id: \"0007_fleet_state\",\n sql: `\n CREATE TABLE IF NOT EXISTS sites (\n id TEXT PRIMARY KEY,\n slug TEXT NOT NULL UNIQUE,\n name TEXT NOT NULL,\n url TEXT,\n status TEXT,\n point_of_contact TEXT,\n maintenance_freq TEXT,\n testing_freq TEXT,\n maintenance_day TEXT,\n testing_day TEXT,\n ga4_property_id TEXT,\n search_query TEXT,\n search_console_property TEXT,\n git_repo TEXT,\n netlify_id TEXT,\n report_recipients_to TEXT,\n report_recipients_cc TEXT,\n copy_intro TEXT,\n copy_contact TEXT,\n copy_footer TEXT,\n newsletter_webhook TEXT,\n mailchimp_api_key TEXT,\n mailchimp_audience_id TEXT,\n notify_routing TEXT,\n require_turnstile INTEGER NOT NULL DEFAULT 0,\n accepted_watch_conditions TEXT,\n prismic_ack_until TEXT,\n launched_at TEXT,\n header_image BLOB,\n header_image_filename TEXT,\n header_image_type TEXT,\n header_image_generated_at TEXT,\n legacy TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_sites_status ON sites (status);\n\n CREATE TABLE IF NOT EXISTS site_health (\n site_id TEXT PRIMARY KEY,\n p_score INTEGER,\n r_score INTEGER,\n bp_score INTEGER,\n seo_score INTEGER,\n lighthouse_at TEXT,\n a11y_violations INTEGER,\n deps_drifted INTEGER,\n deps_major_behind INTEGER,\n deps_outdated INTEGER,\n deps_major_outdated INTEGER,\n vulns_critical INTEGER,\n vulns_high INTEGER,\n vulns_moderate INTEGER,\n vulns_low INTEGER,\n security_audit_at TEXT,\n security_advisories TEXT,\n auto_fix_attempts INTEGER,\n analytics_soft_fail_at TEXT,\n cert_days_remaining INTEGER,\n domain_checked_at TEXT,\n deploy_status TEXT,\n last_deploy_at TEXT,\n deploy_log_url TEXT,\n deploy_checked_at TEXT,\n function_health TEXT,\n cms_reachable TEXT,\n turnstile_widget TEXT,\n function_health_checked_at TEXT,\n crossbrowser_ok INTEGER,\n mobile_ok INTEGER,\n links_ok INTEGER,\n broken_links INTEGER,\n browser_checked_at TEXT,\n uptime_reachable TEXT,\n titles_meta_ok TEXT,\n smoke_ok TEXT,\n last_smoke_at TEXT,\n form_e2e_ok TEXT,\n form_e2e_checked_at TEXT,\n renovate_failing_cis INTEGER,\n default_branch_ci TEXT,\n last_commit_at TEXT,\n github_signals_at TEXT,\n prismic_models TEXT,\n prismic_models_checked_at TEXT,\n prismic_models_drift TEXT\n );\n\n CREATE TABLE IF NOT EXISTS site_schedule (\n site_id TEXT PRIMARY KEY,\n next_maintenance_at TEXT,\n next_testing_at TEXT,\n computed_at TEXT\n );\n\n CREATE TABLE IF NOT EXISTS reports (\n id TEXT PRIMARY KEY,\n site_id TEXT,\n report_id TEXT,\n report_type TEXT,\n period TEXT,\n period_start TEXT,\n period_end TEXT,\n completed_on TEXT,\n lighthouse_performance INTEGER,\n lighthouse_accessibility INTEGER,\n lighthouse_best_practices INTEGER,\n lighthouse_seo INTEGER,\n ga_users_current INTEGER,\n ga_users_previous INTEGER,\n search_found_page1 INTEGER,\n search_position REAL,\n last_tested_date TEXT,\n commentary TEXT,\n subject_override TEXT,\n draft_ready INTEGER NOT NULL DEFAULT 0,\n approved_to_send INTEGER NOT NULL DEFAULT 0,\n approved_at TEXT,\n approved_by TEXT,\n send_override INTEGER NOT NULL DEFAULT 0,\n override_reason TEXT,\n override_by TEXT,\n override_at TEXT,\n sent_at TEXT,\n delivery_status TEXT,\n resend_message_id TEXT,\n checklist TEXT,\n checklist_auto_evidence TEXT,\n rendered_html TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_reports_site ON reports (site_id, period_start DESC);\n `,\n },\n {\n // Indexes the EXPLAIN-query-plan gate (tests/db/query-plans.test.ts) proved the\n // hot paths need — each serves a named query shape that otherwise full-scans\n // submissions, the one unbounded-growth table:\n // submitted_at — every windowed read (/submissions default page, digest\n // per-site counts, duplicate/repeat-sender scans)\n // resend_message_id — the resend-webhook bounce lookup (markNotifyBouncedByMessageId)\n // submission_id — createSubmission's MAX(submission_id)+1 display number,\n // O(1) off the index instead of a scan per insert\n // spam_reason (partial, covering) — listSpamReasonsFiltered's facet tally\n // reads ONLY spam_reason WHERE spam_reason IS NOT NULL;\n // partial keeps it to the spam subset\n // spam_screenouts(date) — listScreenOutsSince windows on date, which the\n // (site_id, date) PK can't serve\n id: \"0008_query_plan_indexes\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_submissions_submitted\n ON submissions (submitted_at DESC);\n CREATE INDEX IF NOT EXISTS idx_submissions_spam_reason\n ON submissions (spam_reason) WHERE spam_reason IS NOT NULL;\n CREATE INDEX IF NOT EXISTS idx_submissions_resend_message\n ON submissions (resend_message_id);\n CREATE INDEX IF NOT EXISTS idx_submissions_display_number\n ON submissions (submission_id);\n CREATE INDEX IF NOT EXISTS idx_screenouts_date\n ON spam_screenouts (date);\n `,\n },\n {\n // Prospect audits (spec 2026-08-24): one row per `prospect-audit` CLI run.\n // `token` is the unguessable public handle for GET /r/{token}; `result_json`\n // is the full ProspectAuditResult, re-rendered on every request so report\n // styling updates apply to already-shared links.\n id: \"0009_prospect_audits\",\n sql: `\n CREATE TABLE IF NOT EXISTS prospect_audits (\n id TEXT PRIMARY KEY,\n token TEXT NOT NULL UNIQUE,\n url TEXT NOT NULL,\n business TEXT,\n created_at TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'complete',\n result_json TEXT NOT NULL\n );\n `,\n },\n {\n // Cockpit /audits listing page (spec 2026-08-25, Piece 5):\n // listRecentProspectAudits orders by created_at DESC with no WHERE clause —\n // without this index that's a raw SCAN of prospect_audits followed by a temp\n // b-tree sort, which the EXPLAIN-query-plan gate (tests/db/query-plans.test.ts)\n // fails the build on. DESC matches the query's own ORDER BY so SQLite can walk\n // the index directly instead of sorting.\n id: \"0010_prospect_audits_created_index\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_prospect_audits_created\n ON prospect_audits (created_at DESC);\n `,\n },\n {\n // #609 (#539 Phase 5): the digest's prior-run snapshot moves off Airtable.\n // Unlike the rest of Phase 5 this is a MIGRATION, not a dual-write — there\n // is no Airtable counterpart left afterwards, so parity does not cover it.\n //\n // A single row holding the whole snapshot as JSON, keyed by a constant id.\n // Both readers (runDigest's diff and the fleet homepage's NEW badges) need\n // the ENTIRE map, so a keyed table would buy nothing on reads and its\n // \"give me everything\" query would be a raw scan needing a justified entry\n // in the EXPLAIN gate's allowlist. One row by primary key needs neither.\n id: \"0011_digest_state\",\n sql: `\n CREATE TABLE IF NOT EXISTS digest_state (\n id TEXT PRIMARY KEY,\n snapshot TEXT NOT NULL,\n updated_at TEXT\n );\n `,\n },\n {\n // form_type was the one SubmissionFilter key with an equality predicate and no\n // index — countSubmissionsFiltered({formType}) raw-scanned `submissions`, twice\n // per load of the submissions page. It shipped green because the EXPLAIN gate\n // named its scenarios by hand and never wrote one for this shape; the gate is\n // now driven by a filter matrix, which is what surfaced it.\n //\n // `submissions` is the one unbounded-growth table in the schema — append-only,\n // one row per fleet lead forever — and Turso meters ROW SCANS, so this is the\n // cost that grows without a ceiling.\n id: \"0012_submissions_form_type_index\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_submissions_form_type\n ON submissions (form_type);\n `,\n },\n {\n // `url` is stored exactly as audited, which makes it a record of the run and\n // a bad thing to group by: the table already holds beachfrontdentistry.com\n // and beachfrontdentistry.com/ as two histories of one practice, and both\n // spellings of ludlowkingsley.com. That costs nothing today because nothing\n // compares two audits of one site — and becomes a returning client with no\n // history the day the report offers a before and an after.\n //\n // Deliberately no SQL backfill. Doing it with lower()/replace()/rtrim()\n // would mangle any URL containing \"www.\" or \"http://\" away from its start,\n // and a key that is quietly wrong is worse than one that is null. Existing\n // rows are backfilled once with the real siteKey().\n // Same ADD COLUMN caveat as 0003: no IF NOT EXISTS in SQLite, so the runner\n // treats \"duplicate column name\" as already-applied. Kept as its own\n // migration, separate from its index below, so that recovery path stays a\n // single statement.\n id: \"0013_prospect_audits_site_key\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN site_key TEXT;`,\n },\n {\n id: \"0014_prospect_audits_site_key_index\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_prospect_audits_site_key\n ON prospect_audits (site_key, created_at DESC);\n `,\n },\n];\n","import type { Client } from \"@libsql/client\";\nimport { MIGRATIONS } from \"./migrations.js\";\n\n/** True when an error means \"this DDL was already applied\" and re-applying is a\n * no-op we can safely ignore. `CREATE ... IF NOT EXISTS` never throws, but SQLite\n * `ALTER TABLE ... ADD COLUMN` has no `IF NOT EXISTS`, so a re-run after a lost\n * `_migrations` marker throws `duplicate column name: …`. Treating that as\n * already-applied keeps `runMigrations` idempotent for `ADD COLUMN` migrations —\n * without it a lost marker would boot-loop `openDb()` on every call. */\nfunction isAlreadyAppliedError(err: unknown): boolean {\n const message = err instanceof Error ? err.message : String(err);\n return /duplicate column name/i.test(message);\n}\n\n/** Apply every not-yet-applied migration in order against a raw libSQL client,\n * tracking applied ids in `_migrations`. Idempotent: ids already present are\n * skipped, the DDL uses IF NOT EXISTS where it can, and an already-applied\n * `ADD COLUMN` (recognized by its error) is swallowed so the marker is still\n * recorded. Returns the ids applied this run. Runs on every openDb() — cheap\n * (one indexed SELECT) and the gate every fresh Turso database needs before its\n * first write. */\nexport async function runMigrations(client: Client): Promise<string[]> {\n await client.execute(\n \"CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)\",\n );\n const existing = await client.execute(\"SELECT id FROM _migrations\");\n const applied = new Set(existing.rows.map((r) => String(r.id)));\n const ran: string[] = [];\n for (const m of MIGRATIONS) {\n if (applied.has(m.id)) continue;\n try {\n await client.executeMultiple(m.sql);\n } catch (err) {\n // A re-run after a lost marker can re-execute a non-idempotent `ADD COLUMN`;\n // treat \"already applied\" as success so we still record the marker below and\n // never boot-loop. Any other error propagates unchanged.\n if (!isAlreadyAppliedError(err)) throw err;\n }\n await client.execute({\n sql: \"INSERT INTO _migrations (id, applied_at) VALUES (?, ?)\",\n args: [m.id, new Date().toISOString()],\n });\n ran.push(m.id);\n }\n return ran;\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,48 @@
1
+ // src/forms/reply-copy.ts
2
+ function str(v) {
3
+ if (typeof v !== "string") return void 0;
4
+ const t = v.trim();
5
+ return t === "" ? void 0 : t;
6
+ }
7
+ function date(v) {
8
+ const s = str(v);
9
+ return s && !Number.isNaN(Date.parse(s)) ? s : void 0;
10
+ }
11
+ function parseCalendar(raw) {
12
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
13
+ const c = raw;
14
+ const title = str(c.title);
15
+ const start = date(c.start);
16
+ if (!title || !start) return void 0;
17
+ const out = { title, start };
18
+ const end = date(c.end);
19
+ if (end) out.end = end;
20
+ const location = str(c.location);
21
+ if (location) out.location = location;
22
+ const url = str(c.url);
23
+ if (url && /^https:\/\//i.test(url)) out.url = url;
24
+ const description = str(c.description);
25
+ if (description) out.description = description;
26
+ return out;
27
+ }
28
+ function parseReplyCopy(raw) {
29
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
30
+ const r = raw;
31
+ const out = {};
32
+ const subject = str(r.subject);
33
+ if (subject) out.subject = subject;
34
+ const signature = str(r.signature);
35
+ if (signature) out.signature = signature;
36
+ if (Array.isArray(r.paragraphs)) {
37
+ const ps = r.paragraphs.map(str).filter((p) => p !== void 0);
38
+ if (ps.length > 0) out.paragraphs = ps;
39
+ }
40
+ const calendar = parseCalendar(r.calendar);
41
+ if (calendar) out.calendar = calendar;
42
+ return Object.keys(out).length > 0 ? out : void 0;
43
+ }
44
+
45
+ export {
46
+ parseReplyCopy
47
+ };
48
+ //# sourceMappingURL=chunk-PJ23RDHT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/forms/reply-copy.ts"],"sourcesContent":["/**\n * Copy for the submitter's confirmation email, resolved by the SITE from its own\n * CMS and forwarded in the reserved `_reply` envelope.\n *\n * Everything here arrives over an untrusted boundary twice — once off the wire,\n * once back out of the persisted `extraFields` JSON — so `parseReplyCopy` is the\n * single gate both paths go through. It drops field by field rather than\n * rejecting whole: a usable subject should still improve the email when the\n * calendar block is malformed.\n */\nexport type ReplyCalendar = {\n title: string;\n /** ISO 8601. Validated as parseable, not as any particular shape. */\n start: string;\n end?: string;\n location?: string;\n url?: string;\n description?: string;\n};\n\nexport type ReplyCopy = {\n subject?: string;\n paragraphs?: string[];\n signature?: string;\n calendar?: ReplyCalendar;\n};\n\nfunction str(v: unknown): string | undefined {\n if (typeof v !== \"string\") return undefined;\n const t = v.trim();\n return t === \"\" ? undefined : t;\n}\n\nfunction date(v: unknown): string | undefined {\n const s = str(v);\n return s && !Number.isNaN(Date.parse(s)) ? s : undefined;\n}\n\nfunction parseCalendar(raw: unknown): ReplyCalendar | undefined {\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) return undefined;\n const c = raw as Record<string, unknown>;\n const title = str(c.title);\n const start = date(c.start);\n // Both are load-bearing: an event with no name or no start is not an event,\n // and half of one in a calendar client is worse than none.\n if (!title || !start) return undefined;\n const out: ReplyCalendar = { title, start };\n const end = date(c.end);\n if (end) out.end = end;\n const location = str(c.location);\n if (location) out.location = location;\n // https only. This becomes an href in an email we send; a javascript: or\n // data: URL arriving through a CMS field is not a link anyone meant to write.\n const url = str(c.url);\n if (url && /^https:\\/\\//i.test(url)) out.url = url;\n const description = str(c.description);\n if (description) out.description = description;\n return out;\n}\n\n/** Validate untrusted envelope data. Undefined means \"nothing usable\" — callers\n * then fall back to the site's own copy rather than sending a blank email. */\nexport function parseReplyCopy(raw: unknown): ReplyCopy | undefined {\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) return undefined;\n const r = raw as Record<string, unknown>;\n const out: ReplyCopy = {};\n const subject = str(r.subject);\n if (subject) out.subject = subject;\n const signature = str(r.signature);\n if (signature) out.signature = signature;\n if (Array.isArray(r.paragraphs)) {\n const ps = r.paragraphs.map(str).filter((p): p is string => p !== undefined);\n if (ps.length > 0) out.paragraphs = ps;\n }\n const calendar = parseCalendar(r.calendar);\n if (calendar) out.calendar = calendar;\n return Object.keys(out).length > 0 ? out : undefined;\n}\n"],"mappings":";AA2BA,SAAS,IAAI,GAAgC;AAC3C,MAAI,OAAO,MAAM,SAAU,QAAO;AAClC,QAAM,IAAI,EAAE,KAAK;AACjB,SAAO,MAAM,KAAK,SAAY;AAChC;AAEA,SAAS,KAAK,GAAgC;AAC5C,QAAM,IAAI,IAAI,CAAC;AACf,SAAO,KAAK,CAAC,OAAO,MAAM,KAAK,MAAM,CAAC,CAAC,IAAI,IAAI;AACjD;AAEA,SAAS,cAAc,KAAyC;AAC9D,MAAI,CAAC,OAAO,OAAO,QAAQ,YAAY,MAAM,QAAQ,GAAG,EAAG,QAAO;AAClE,QAAM,IAAI;AACV,QAAM,QAAQ,IAAI,EAAE,KAAK;AACzB,QAAM,QAAQ,KAAK,EAAE,KAAK;AAG1B,MAAI,CAAC,SAAS,CAAC,MAAO,QAAO;AAC7B,QAAM,MAAqB,EAAE,OAAO,MAAM;AAC1C,QAAM,MAAM,KAAK,EAAE,GAAG;AACtB,MAAI,IAAK,KAAI,MAAM;AACnB,QAAM,WAAW,IAAI,EAAE,QAAQ;AAC/B,MAAI,SAAU,KAAI,WAAW;AAG7B,QAAM,MAAM,IAAI,EAAE,GAAG;AACrB,MAAI,OAAO,eAAe,KAAK,GAAG,EAAG,KAAI,MAAM;AAC/C,QAAM,cAAc,IAAI,EAAE,WAAW;AACrC,MAAI,YAAa,KAAI,cAAc;AACnC,SAAO;AACT;AAIO,SAAS,eAAe,KAAqC;AAClE,MAAI,CAAC,OAAO,OAAO,QAAQ,YAAY,MAAM,QAAQ,GAAG,EAAG,QAAO;AAClE,QAAM,IAAI;AACV,QAAM,MAAiB,CAAC;AACxB,QAAM,UAAU,IAAI,EAAE,OAAO;AAC7B,MAAI,QAAS,KAAI,UAAU;AAC3B,QAAM,YAAY,IAAI,EAAE,SAAS;AACjC,MAAI,UAAW,KAAI,YAAY;AAC/B,MAAI,MAAM,QAAQ,EAAE,UAAU,GAAG;AAC/B,UAAM,KAAK,EAAE,WAAW,IAAI,GAAG,EAAE,OAAO,CAAC,MAAmB,MAAM,MAAS;AAC3E,QAAI,GAAG,SAAS,EAAG,KAAI,aAAa;AAAA,EACtC;AACA,QAAM,WAAW,cAAc,EAAE,QAAQ;AACzC,MAAI,SAAU,KAAI,WAAW;AAC7B,SAAO,OAAO,KAAK,GAAG,EAAE,SAAS,IAAI,MAAM;AAC7C;","names":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  withRecipe
3
- } from "./chunk-3G25KIWW.js";
3
+ } from "./chunk-ZTPPCHDB.js";
4
4
  import {
5
5
  defaultSpawn
6
6
  } from "./chunk-LBYOLBW7.js";
@@ -76,4 +76,4 @@ async function bumpDeps(site, opts = {}) {
76
76
  export {
77
77
  bumpDeps
78
78
  };
79
- //# sourceMappingURL=chunk-2NJQLSEU.js.map
79
+ //# sourceMappingURL=chunk-PPIZHDJL.js.map