@reddoorla/maintenance 0.97.0 → 0.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/dist/{announce-5ZD2SBGD.js → announce-2FH2KBQQ.js} +4 -4
  2. package/dist/{bump-deps-775VLDYB.js → bump-deps-54K4ECP3.js} +2 -2
  3. package/dist/{chunk-YXWBM26P.js → chunk-3YALLSBF.js} +2 -2
  4. package/dist/{chunk-LAJ3FF54.js → chunk-5UI4FERC.js} +16 -5
  5. package/dist/chunk-5UI4FERC.js.map +1 -0
  6. package/dist/{chunk-VMGHQUTV.js → chunk-B2BRFWJY.js} +2 -2
  7. package/dist/{chunk-IOTOOTII.js → chunk-CP6MVZ5X.js} +16 -1
  8. package/dist/{chunk-IOTOOTII.js.map → chunk-CP6MVZ5X.js.map} +1 -1
  9. package/dist/{chunk-L3S3QZXL.js → chunk-DXZRWW2F.js} +64 -9
  10. package/dist/chunk-DXZRWW2F.js.map +1 -0
  11. package/dist/chunk-DZZ5KRTB.js +23 -0
  12. package/dist/chunk-DZZ5KRTB.js.map +1 -0
  13. package/dist/{chunk-U2ZI6G7H.js → chunk-GRF2RYEO.js} +2 -2
  14. package/dist/{chunk-QFSL2L2B.js → chunk-L5F3WYIY.js} +81 -31
  15. package/dist/chunk-L5F3WYIY.js.map +1 -0
  16. package/dist/{chunk-MMSMZC7S.js → chunk-MNRWHSFS.js} +2 -2
  17. package/dist/{chunk-PARKXDXI.js → chunk-ORTOMEYR.js} +2 -2
  18. package/dist/{chunk-FP6HOW6J.js → chunk-S2PLX4XK.js} +2 -2
  19. package/dist/{chunk-A5QKNOXW.js → chunk-TTT6HYPW.js} +47 -10
  20. package/dist/chunk-TTT6HYPW.js.map +1 -0
  21. package/dist/{chunk-BIYQN7FH.js → chunk-VCPTOZ56.js} +31 -1
  22. package/dist/{chunk-BIYQN7FH.js.map → chunk-VCPTOZ56.js.map} +1 -1
  23. package/dist/{chunk-LDHRIQUW.js → chunk-ZEE5WQYU.js} +2 -2
  24. package/dist/cli/bin.js +27 -27
  25. package/dist/cli/commands/audit.js +10 -10
  26. package/dist/client-3XGSZMLU.js +11 -0
  27. package/dist/{convert-to-pnpm-KLWA53N5.js → convert-to-pnpm-U2CRNKNY.js} +2 -2
  28. package/dist/{db-II5WAK4D.js → db-7XYTBIPH.js} +20 -13
  29. package/dist/db-7XYTBIPH.js.map +1 -0
  30. package/dist/{deadletter-G4M3HSVM.js → deadletter-A7RQVNG3.js} +8 -4
  31. package/dist/{digest-CWKK2DMR.js → digest-AMXRGWZA.js} +20 -20
  32. package/dist/{digest-collectors-CYTN2EUX.js → digest-collectors-CX2NCGS7.js} +7 -7
  33. package/dist/{ensure-site-2QZHFVQ3.js → ensure-site-F2WFCWWA.js} +2 -2
  34. package/dist/{forms-notify-target-X6UEOI2M.js → forms-notify-target-DK5R326N.js} +3 -3
  35. package/dist/{github-signals-QIRTHHET.js → github-signals-RGRPYTUB.js} +6 -6
  36. package/dist/{header-image-H7ZJAV26.js → header-image-43ZIEJKP.js} +3 -3
  37. package/dist/{health-endpoint-BJ6BKRHF.js → health-endpoint-K5LXDPGE.js} +2 -2
  38. package/dist/{health-mirror-KU3G7V2Y.js → health-mirror-WZUHWCCV.js} +3 -3
  39. package/dist/index.js +10 -11
  40. package/dist/index.js.map +1 -1
  41. package/dist/{init-GOWDA5QS.js → init-OFITGMOP.js} +5 -5
  42. package/dist/{launch-7N3X2MXB.js → launch-AJY3TGTQ.js} +7 -7
  43. package/dist/{lighthouse-52WSPZ5D.js → lighthouse-RFMFREEX.js} +2 -2
  44. package/dist/{match-harness-GBCDSVPS.js → match-harness-CFELJA57.js} +2 -2
  45. package/dist/migrate-EZL55AWA.js +7 -0
  46. package/dist/{onboard-JQQIMQW7.js → onboard-F7RTLMDZ.js} +2 -2
  47. package/dist/{orchestrate-L2A5BZFY.js → orchestrate-SAIFXZIW.js} +5 -5
  48. package/dist/{pipeline-UUPXOS2J.js → pipeline-TCSNVDQZ.js} +3 -3
  49. package/dist/{preflight-O7Q6FWHU.js → preflight-6XGVVO2U.js} +7 -7
  50. package/dist/{prismic-ci-NAPWOXXD.js → prismic-ci-XMX7A5E4.js} +2 -2
  51. package/dist/{prismic-models-LL6ORTQ2.js → prismic-models-7GKFAAFA.js} +4 -4
  52. package/dist/prospect/types.d.ts +44 -2
  53. package/dist/prospect/types.js +1 -1
  54. package/dist/{prospect-audit-FMPGQIRQ.js → prospect-audit-KWP5P5U2.js} +37 -5
  55. package/dist/prospect-audit-KWP5P5U2.js.map +1 -0
  56. package/dist/{render-MHUUHPLN.js → render-NWUAX6RN.js} +3 -3
  57. package/dist/{renovate-dispatch-2IHAZDVA.js → renovate-dispatch-ZZQXT7DA.js} +2 -2
  58. package/dist/replay-HB44TAII.js +77 -0
  59. package/dist/replay-HB44TAII.js.map +1 -0
  60. package/dist/{report-IAHXN5CW.js → report-MVPWS7WP.js} +13 -13
  61. package/dist/{report-mirror-4QM57LMS.js → report-mirror-O6IDFDWU.js} +3 -3
  62. package/dist/{roster-GDNSIRAK.js → roster-EJ3ILR52.js} +2 -2
  63. package/dist/{screenouts-7P5GOZMV.js → screenouts-7NQUWHOJ.js} +14 -1
  64. package/dist/screenouts-7NQUWHOJ.js.map +1 -0
  65. package/dist/{self-updating-ZUWPGYUP.js → self-updating-KX5FMGEO.js} +2 -2
  66. package/dist/{selftest-G4W3LIBQ.js → selftest-KABY2CYJ.js} +5 -5
  67. package/dist/{site-mirror-WR7ACXOV.js → site-mirror-TKTDWYP3.js} +3 -3
  68. package/dist/{smoke-suite-WAUPI6EJ.js → smoke-suite-QQMV7TNA.js} +2 -2
  69. package/dist/{submissions-JDZP35JM.js → submissions-4I66W6QK.js} +11 -1
  70. package/dist/{submissions-JDZP35JM.js.map → submissions-4I66W6QK.js.map} +1 -1
  71. package/dist/{submissions-2SHCFOKD.js → submissions-K5UXGZUN.js} +4 -4
  72. package/dist/{svelte-codemods-VOLOKKFI.js → svelte-codemods-YFN4TEGV.js} +2 -2
  73. package/dist/{sync-configs-OXSWKVZ2.js → sync-configs-HBXS3D7C.js} +2 -2
  74. package/dist/{upgrade-EVAL3JTO.js → upgrade-XOJMDIT4.js} +2 -2
  75. package/package.json +1 -1
  76. package/dist/chunk-A5QKNOXW.js.map +0 -1
  77. package/dist/chunk-L3S3QZXL.js.map +0 -1
  78. package/dist/chunk-LAJ3FF54.js.map +0 -1
  79. package/dist/chunk-QFSL2L2B.js.map +0 -1
  80. package/dist/client-DGTO65WB.js +0 -11
  81. package/dist/db-II5WAK4D.js.map +0 -1
  82. package/dist/migrate-XZ7U5DFC.js +0 -7
  83. package/dist/prospect-audit-FMPGQIRQ.js.map +0 -1
  84. package/dist/replay-4FVFOMHH.js +0 -54
  85. package/dist/replay-4FVFOMHH.js.map +0 -1
  86. package/dist/screenouts-7P5GOZMV.js.map +0 -1
  87. /package/dist/{announce-5ZD2SBGD.js.map → announce-2FH2KBQQ.js.map} +0 -0
  88. /package/dist/{bump-deps-775VLDYB.js.map → bump-deps-54K4ECP3.js.map} +0 -0
  89. /package/dist/{chunk-YXWBM26P.js.map → chunk-3YALLSBF.js.map} +0 -0
  90. /package/dist/{chunk-VMGHQUTV.js.map → chunk-B2BRFWJY.js.map} +0 -0
  91. /package/dist/{chunk-U2ZI6G7H.js.map → chunk-GRF2RYEO.js.map} +0 -0
  92. /package/dist/{chunk-MMSMZC7S.js.map → chunk-MNRWHSFS.js.map} +0 -0
  93. /package/dist/{chunk-PARKXDXI.js.map → chunk-ORTOMEYR.js.map} +0 -0
  94. /package/dist/{chunk-FP6HOW6J.js.map → chunk-S2PLX4XK.js.map} +0 -0
  95. /package/dist/{chunk-LDHRIQUW.js.map → chunk-ZEE5WQYU.js.map} +0 -0
  96. /package/dist/{client-DGTO65WB.js.map → client-3XGSZMLU.js.map} +0 -0
  97. /package/dist/{convert-to-pnpm-KLWA53N5.js.map → convert-to-pnpm-U2CRNKNY.js.map} +0 -0
  98. /package/dist/{deadletter-G4M3HSVM.js.map → deadletter-A7RQVNG3.js.map} +0 -0
  99. /package/dist/{digest-CWKK2DMR.js.map → digest-AMXRGWZA.js.map} +0 -0
  100. /package/dist/{digest-collectors-CYTN2EUX.js.map → digest-collectors-CX2NCGS7.js.map} +0 -0
  101. /package/dist/{ensure-site-2QZHFVQ3.js.map → ensure-site-F2WFCWWA.js.map} +0 -0
  102. /package/dist/{forms-notify-target-X6UEOI2M.js.map → forms-notify-target-DK5R326N.js.map} +0 -0
  103. /package/dist/{github-signals-QIRTHHET.js.map → github-signals-RGRPYTUB.js.map} +0 -0
  104. /package/dist/{header-image-H7ZJAV26.js.map → header-image-43ZIEJKP.js.map} +0 -0
  105. /package/dist/{health-endpoint-BJ6BKRHF.js.map → health-endpoint-K5LXDPGE.js.map} +0 -0
  106. /package/dist/{health-mirror-KU3G7V2Y.js.map → health-mirror-WZUHWCCV.js.map} +0 -0
  107. /package/dist/{init-GOWDA5QS.js.map → init-OFITGMOP.js.map} +0 -0
  108. /package/dist/{launch-7N3X2MXB.js.map → launch-AJY3TGTQ.js.map} +0 -0
  109. /package/dist/{lighthouse-52WSPZ5D.js.map → lighthouse-RFMFREEX.js.map} +0 -0
  110. /package/dist/{match-harness-GBCDSVPS.js.map → match-harness-CFELJA57.js.map} +0 -0
  111. /package/dist/{migrate-XZ7U5DFC.js.map → migrate-EZL55AWA.js.map} +0 -0
  112. /package/dist/{onboard-JQQIMQW7.js.map → onboard-F7RTLMDZ.js.map} +0 -0
  113. /package/dist/{orchestrate-L2A5BZFY.js.map → orchestrate-SAIFXZIW.js.map} +0 -0
  114. /package/dist/{pipeline-UUPXOS2J.js.map → pipeline-TCSNVDQZ.js.map} +0 -0
  115. /package/dist/{preflight-O7Q6FWHU.js.map → preflight-6XGVVO2U.js.map} +0 -0
  116. /package/dist/{prismic-ci-NAPWOXXD.js.map → prismic-ci-XMX7A5E4.js.map} +0 -0
  117. /package/dist/{prismic-models-LL6ORTQ2.js.map → prismic-models-7GKFAAFA.js.map} +0 -0
  118. /package/dist/{render-MHUUHPLN.js.map → render-NWUAX6RN.js.map} +0 -0
  119. /package/dist/{renovate-dispatch-2IHAZDVA.js.map → renovate-dispatch-ZZQXT7DA.js.map} +0 -0
  120. /package/dist/{report-IAHXN5CW.js.map → report-MVPWS7WP.js.map} +0 -0
  121. /package/dist/{report-mirror-4QM57LMS.js.map → report-mirror-O6IDFDWU.js.map} +0 -0
  122. /package/dist/{roster-GDNSIRAK.js.map → roster-EJ3ILR52.js.map} +0 -0
  123. /package/dist/{self-updating-ZUWPGYUP.js.map → self-updating-KX5FMGEO.js.map} +0 -0
  124. /package/dist/{selftest-G4W3LIBQ.js.map → selftest-KABY2CYJ.js.map} +0 -0
  125. /package/dist/{site-mirror-WR7ACXOV.js.map → site-mirror-TKTDWYP3.js.map} +0 -0
  126. /package/dist/{smoke-suite-WAUPI6EJ.js.map → smoke-suite-QQMV7TNA.js.map} +0 -0
  127. /package/dist/{submissions-2SHCFOKD.js.map → submissions-K5UXGZUN.js.map} +0 -0
  128. /package/dist/{svelte-codemods-VOLOKKFI.js.map → svelte-codemods-YFN4TEGV.js.map} +0 -0
  129. /package/dist/{sync-configs-OXSWKVZ2.js.map → sync-configs-HBXS3D7C.js.map} +0 -0
  130. /package/dist/{upgrade-EVAL3JTO.js.map → upgrade-XOJMDIT4.js.map} +0 -0
@@ -82,7 +82,7 @@ function lazyFleetDb() {
82
82
  return {
83
83
  get: async () => {
84
84
  if (!db) {
85
- const { openDb, readDbConfig } = await import("./client-DGTO65WB.js");
85
+ const { openDb, readDbConfig } = await import("./client-3XGSZMLU.js");
86
86
  db = await openDb(readDbConfig());
87
87
  }
88
88
  return db;
@@ -94,7 +94,7 @@ function lazyFleetDb() {
94
94
  }
95
95
  async function runReportCommand(slug, opts) {
96
96
  if (opts.digest) {
97
- const { runDigest } = await import("./digest-CWKK2DMR.js");
97
+ const { runDigest } = await import("./digest-AMXRGWZA.js");
98
98
  const { listSites, listAllReports } = await import("./fleet-state-RJROYXKO.js");
99
99
  const fleetDb = lazyFleetDb();
100
100
  try {
@@ -108,10 +108,10 @@ async function runReportCommand(slug, opts) {
108
108
  }
109
109
  }
110
110
  if (opts.sendReady) {
111
- const { sendApprovedReports } = await import("./orchestrate-L2A5BZFY.js");
112
- const { makeSiteMirror } = await import("./site-mirror-WR7ACXOV.js");
111
+ const { sendApprovedReports } = await import("./orchestrate-SAIFXZIW.js");
112
+ const { makeSiteMirror } = await import("./site-mirror-TKTDWYP3.js");
113
113
  const { mirrorWrite } = await import("./freeze-GUQ43EAU.js");
114
- const { openDb, readDbConfig } = await import("./client-DGTO65WB.js");
114
+ const { openDb, readDbConfig } = await import("./client-3XGSZMLU.js");
115
115
  const { mirrorReportPatch, listSendableReports, listSites } = await import("./fleet-state-RJROYXKO.js");
116
116
  const { loadHeaderImage } = await import("./header-images-SQBCE5OD.js");
117
117
  const held = { db: null };
@@ -143,7 +143,7 @@ async function runReportCommand(slug, opts) {
143
143
  if (opts.rerender) {
144
144
  const { rerenderReport, formatRerenderResult } = await import("./rerender-3FM6DNRH.js");
145
145
  const { renderReportFromRow } = await import("./render-from-row-75JTED2Z.js");
146
- const { openDb, readDbConfig } = await import("./client-DGTO65WB.js");
146
+ const { openDb, readDbConfig } = await import("./client-3XGSZMLU.js");
147
147
  const { getReportById, getSiteById, storeRenderedHtml } = await import("./fleet-state-RJROYXKO.js");
148
148
  const { loadHeaderImage } = await import("./header-images-SQBCE5OD.js");
149
149
  const { fetchAttachmentBytes } = await import("./attachments-JFGFQRXQ.js");
@@ -183,9 +183,9 @@ async function runReportCommand(slug, opts) {
183
183
  }
184
184
  async function runDueDraft() {
185
185
  const base = openBase(readAirtableConfig());
186
- const { makeScheduleMirrorBestEffort } = await import("./health-mirror-KU3G7V2Y.js");
187
- const { makeReportMirror } = await import("./report-mirror-4QM57LMS.js");
188
- const { makeSiteMirror } = await import("./site-mirror-WR7ACXOV.js");
186
+ const { makeScheduleMirrorBestEffort } = await import("./health-mirror-WZUHWCCV.js");
187
+ const { makeReportMirror } = await import("./report-mirror-O6IDFDWU.js");
188
+ const { makeSiteMirror } = await import("./site-mirror-TKTDWYP3.js");
189
189
  const { listSites, listAllReports } = await import("./fleet-state-RJROYXKO.js");
190
190
  const fleetDb = lazyFleetDb();
191
191
  try {
@@ -377,14 +377,14 @@ async function draftDueReports(base, today, deps) {
377
377
  }
378
378
  async function runSingleSiteDraft(slug, opts) {
379
379
  const base = openBase(readAirtableConfig());
380
- const { readFleetRoster } = await import("./roster-GDNSIRAK.js");
380
+ const { readFleetRoster } = await import("./roster-EJ3ILR52.js");
381
381
  const websites = await readFleetRoster();
382
382
  const site = websites.find((w) => siteSlug(w.name) === slug);
383
383
  if (!site) {
384
384
  throw Object.assign(new Error(`No site matched slug "${slug}"`), { exitCode: 2 });
385
385
  }
386
- const reportMirror = opts.previewOnly ? null : await (await import("./report-mirror-4QM57LMS.js")).makeReportMirror();
387
- const siteMirror = opts.previewOnly ? null : await (await import("./site-mirror-WR7ACXOV.js")).makeSiteMirror();
386
+ const reportMirror = opts.previewOnly ? null : await (await import("./report-mirror-O6IDFDWU.js")).makeReportMirror();
387
+ const siteMirror = opts.previewOnly ? null : await (await import("./site-mirror-TKTDWYP3.js")).makeSiteMirror();
388
388
  const result = await draftReportForSite(opts.previewOnly ? null : base, site, opts.reportType, {
389
389
  previewOnly: opts.previewOnly,
390
390
  // Only forced on. Left undefined, draftReportForSite keeps its default
@@ -412,4 +412,4 @@ export {
412
412
  runReportCommand,
413
413
  writeNextDueDates
414
414
  };
415
- //# sourceMappingURL=report-IAHXN5CW.js.map
415
+ //# sourceMappingURL=report-MVPWS7WP.js.map
@@ -16,8 +16,8 @@ import "./chunk-ICSRWW5Q.js";
16
16
  import {
17
17
  openDb,
18
18
  readDbConfig
19
- } from "./chunk-MMSMZC7S.js";
20
- import "./chunk-IOTOOTII.js";
19
+ } from "./chunk-MNRWHSFS.js";
20
+ import "./chunk-CP6MVZ5X.js";
21
21
  import "./chunk-6VAL7XJT.js";
22
22
 
23
23
  // src/reports/report-mirror.ts
@@ -74,4 +74,4 @@ async function makeReportMirror(open = () => openDb(readDbConfig()), strict = TU
74
74
  export {
75
75
  makeReportMirror
76
76
  };
77
- //# sourceMappingURL=report-mirror-4QM57LMS.js.map
77
+ //# sourceMappingURL=report-mirror-O6IDFDWU.js.map
@@ -3,7 +3,7 @@ async function readFleetRoster(open) {
3
3
  const { listSites } = await import("./fleet-state-RJROYXKO.js");
4
4
  let opener = open;
5
5
  if (!opener) {
6
- const { openDb, readDbConfig } = await import("./client-DGTO65WB.js");
6
+ const { openDb, readDbConfig } = await import("./client-3XGSZMLU.js");
7
7
  opener = () => openDb(readDbConfig());
8
8
  }
9
9
  const db = await opener();
@@ -16,4 +16,4 @@ async function readFleetRoster(open) {
16
16
  export {
17
17
  readFleetRoster
18
18
  };
19
- //# sourceMappingURL=roster-GDNSIRAK.js.map
19
+ //# sourceMappingURL=roster-EJ3ILR52.js.map
@@ -34,6 +34,18 @@ async function listScreenOutsSince(db, sinceDate) {
34
34
  }
35
35
  return out;
36
36
  }
37
+ async function screenOutTotalsForSite(db, siteId, sinceDate) {
38
+ const bucket = await db.selectFrom("spam_screenouts").select((eb) => [
39
+ eb.fn.sum("honeypot").as("honeypot"),
40
+ eb.fn.sum("too_fast").as("too_fast")
41
+ ]).where("site_id", "=", siteId).where("date", ">=", sinceDate).executeTakeFirst();
42
+ const marked = await db.selectFrom("submissions").select((eb) => eb.fn.countAll().as("marked_spam")).where("site_id", "=", siteId).where("status", "=", "spam").where("submitted_at", ">=", sinceDate).executeTakeFirst();
43
+ return {
44
+ honeypot: Number(bucket?.honeypot) || 0,
45
+ tooFast: Number(bucket?.too_fast) || 0,
46
+ markedSpam: Number(marked?.marked_spam) || 0
47
+ };
48
+ }
37
49
  function screenOutsSince(now, days) {
38
50
  const d = new Date(now.getTime() - days * 24 * 60 * 60 * 1e3);
39
51
  return d.toISOString().slice(0, 10);
@@ -52,6 +64,7 @@ export {
52
64
  backfillScreenoutBucket,
53
65
  listScreenOutsSince,
54
66
  recordScreenOut,
67
+ screenOutTotalsForSite,
55
68
  screenOutsSince
56
69
  };
57
- //# sourceMappingURL=screenouts-7P5GOZMV.js.map
70
+ //# sourceMappingURL=screenouts-7NQUWHOJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/db/screenouts.ts"],"sourcesContent":["import { sql } from \"kysely\";\nimport type { Db } from \"./client.js\";\n\nexport type ScreenOutReason = \"honeypot\" | \"too-fast\";\nexport type ScreenOutTotals = { honeypot: number; tooFast: number; markedSpam: number };\n\nconst COLUMN: Record<ScreenOutReason, \"honeypot\" | \"too_fast\"> = {\n honeypot: \"honeypot\",\n \"too-fast\": \"too_fast\",\n};\n\n/** Atomically increment the caught counter for a reason on the (site, date) bucket.\n * ON CONFLICT keeps the count exact — no read-modify-write race, no duplicate\n * buckets. A swallowed failure must never error a screened bot (caller's job). */\nexport async function recordScreenOut(\n db: Db,\n siteId: string,\n reason: ScreenOutReason,\n date: string,\n): Promise<void> {\n const col = COLUMN[reason];\n await sql`\n INSERT INTO spam_screenouts (site_id, date, ${sql.ref(col)})\n VALUES (${siteId}, ${date}, 1)\n ON CONFLICT (site_id, date) DO UPDATE SET ${sql.ref(col)} = ${sql.ref(col)} + 1\n `.execute(db);\n}\n\n/** Per-site spam totals over the window `>= sinceDate`.\n *\n * `honeypot`/`tooFast` are summed from the ingest-time `spam_screenouts` buckets\n * (bots screened BEFORE storage — there's no row to count, so an atomic counter\n * is the right source).\n *\n * `markedSpam` is DERIVED from the submissions table — a live `COUNT(*) WHERE\n * status = 'spam'` — NOT a mutable counter. A counter incremented on every\n * →spam transition double-counts when an operator re-marks a submission\n * (spam → new → spam) and can never self-correct an un-mark; counting the rows\n * themselves is exact and idempotent. Windowed by `submitted_at` so it's\n * arrival-dated, matching the honeypot/too-fast buckets (which key on the\n * screen = arrival day). The legacy `marked_spam` column is no longer read. */\nexport async function listScreenOutsSince(\n db: Db,\n sinceDate: string,\n): Promise<Map<string, ScreenOutTotals>> {\n const screenoutRows = await db\n .selectFrom(\"spam_screenouts\")\n .select((eb) => [\n \"site_id\",\n eb.fn.sum<number>(\"honeypot\").as(\"honeypot\"),\n eb.fn.sum<number>(\"too_fast\").as(\"too_fast\"),\n ])\n .where(\"date\", \">=\", sinceDate)\n .groupBy(\"site_id\")\n .execute();\n\n const markedSpamRows = await db\n .selectFrom(\"submissions\")\n .select((eb) => [\"site_id\", eb.fn.countAll<number>().as(\"marked_spam\")])\n .where(\"status\", \"=\", \"spam\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .groupBy(\"site_id\")\n .execute();\n\n const out = new Map<string, ScreenOutTotals>();\n for (const r of screenoutRows) {\n out.set(r.site_id, {\n honeypot: Number(r.honeypot) || 0,\n tooFast: Number(r.too_fast) || 0,\n markedSpam: 0,\n });\n }\n for (const r of markedSpamRows) {\n const totals = out.get(r.site_id) ?? { honeypot: 0, tooFast: 0, markedSpam: 0 };\n totals.markedSpam = Number(r.marked_spam) || 0;\n out.set(r.site_id, totals);\n }\n return out;\n}\n\n/** The same totals for ONE site — the `/s/:slug` spam panel (MED-11).\n *\n * Same two sources and the same window as `listScreenOutsSince`, each narrowed\n * by `site_id`: the buckets are served by `spam_screenouts`' own (site_id, date)\n * primary key, and the marked-spam count by `idx_submissions_site_submitted`.\n * Both plan as SEARCHes. The site page used to run the fleet-wide version and\n * `.get(site.id)` one entry out of it, which meant a per-request GROUP BY over\n * every bucket and every spam-marked lead in the fleet.\n *\n * Returns zeroes rather than null for a site with nothing screened — `null` is\n * the CALLER's way of saying \"the read failed\", and a quiet site must not be\n * reported as an outage. */\nexport async function screenOutTotalsForSite(\n db: Db,\n siteId: string,\n sinceDate: string,\n): Promise<ScreenOutTotals> {\n const bucket = await db\n .selectFrom(\"spam_screenouts\")\n .select((eb) => [\n eb.fn.sum<number>(\"honeypot\").as(\"honeypot\"),\n eb.fn.sum<number>(\"too_fast\").as(\"too_fast\"),\n ])\n .where(\"site_id\", \"=\", siteId)\n .where(\"date\", \">=\", sinceDate)\n .executeTakeFirst();\n\n const marked = await db\n .selectFrom(\"submissions\")\n .select((eb) => eb.fn.countAll<number>().as(\"marked_spam\"))\n .where(\"site_id\", \"=\", siteId)\n .where(\"status\", \"=\", \"spam\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .executeTakeFirst();\n\n return {\n honeypot: Number(bucket?.honeypot) || 0,\n tooFast: Number(bucket?.too_fast) || 0,\n markedSpam: Number(marked?.marked_spam) || 0,\n };\n}\n\n/** The ISO date (YYYY-MM-DD) `days` before `now`, for the window queries.\n * Verbatim from the Airtable module so the windows match exactly. */\nexport function screenOutsSince(now: Date, days: number): string {\n const d = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);\n return d.toISOString().slice(0, 10);\n}\n\n/** Set the (site, date) bucket to exact totals. Replace-upsert (DO UPDATE SET col\n * = excluded.col) so re-running the backfill is idempotent. The caller pre-sums\n * duplicate same-day Airtable buckets in JS before calling this. */\nexport async function backfillScreenoutBucket(\n db: Db,\n b: { siteId: string; date: string; honeypot: number; tooFast: number; markedSpam: number },\n): Promise<void> {\n await sql`\n INSERT INTO spam_screenouts (site_id, date, honeypot, too_fast, marked_spam)\n VALUES (${b.siteId}, ${b.date}, ${b.honeypot}, ${b.tooFast}, ${b.markedSpam})\n ON CONFLICT (site_id, date) DO UPDATE SET\n honeypot = excluded.honeypot,\n too_fast = excluded.too_fast,\n marked_spam = excluded.marked_spam\n `.execute(db);\n}\n"],"mappings":"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","names":[]}
@@ -12,7 +12,7 @@ import {
12
12
  } from "./chunk-DYTYPKFN.js";
13
13
  import {
14
14
  resolveSites
15
- } from "./chunk-FP6HOW6J.js";
15
+ } from "./chunk-S2PLX4XK.js";
16
16
  import "./chunk-TJLKWQOX.js";
17
17
  import "./chunk-SLFG7Y76.js";
18
18
  import "./chunk-MVKNU4QD.js";
@@ -63,4 +63,4 @@ async function runSelfUpdatingCommand(site, opts) {
63
63
  export {
64
64
  runSelfUpdatingCommand
65
65
  };
66
- //# sourceMappingURL=self-updating-ZUWPGYUP.js.map
66
+ //# sourceMappingURL=self-updating-KX5FMGEO.js.map
@@ -13,7 +13,7 @@ import {
13
13
  import {
14
14
  isProbablyEmail,
15
15
  parseAddresses
16
- } from "./chunk-LDHRIQUW.js";
16
+ } from "./chunk-ZEE5WQYU.js";
17
17
  import "./chunk-TEMQPSCM.js";
18
18
  import "./chunk-ASLDCWBF.js";
19
19
  import "./chunk-VAD43MOR.js";
@@ -44,9 +44,9 @@ import "./chunk-RQJFGK3A.js";
44
44
  import {
45
45
  defaultResendClient
46
46
  } from "./chunk-WEKL2HYJ.js";
47
- import "./chunk-YXWBM26P.js";
48
- import "./chunk-MMSMZC7S.js";
49
- import "./chunk-IOTOOTII.js";
47
+ import "./chunk-3YALLSBF.js";
48
+ import "./chunk-MNRWHSFS.js";
49
+ import "./chunk-CP6MVZ5X.js";
50
50
  import "./chunk-RARREDQE.js";
51
51
  import "./chunk-6VAL7XJT.js";
52
52
 
@@ -176,4 +176,4 @@ async function runSelftestCommand(kind, site, opts) {
176
176
  export {
177
177
  runSelftestCommand
178
178
  };
179
- //# sourceMappingURL=selftest-G4W3LIBQ.js.map
179
+ //# sourceMappingURL=selftest-KABY2CYJ.js.map
@@ -14,8 +14,8 @@ import "./chunk-ICSRWW5Q.js";
14
14
  import {
15
15
  openDb,
16
16
  readDbConfig
17
- } from "./chunk-MMSMZC7S.js";
18
- import "./chunk-IOTOOTII.js";
17
+ } from "./chunk-MNRWHSFS.js";
18
+ import "./chunk-CP6MVZ5X.js";
19
19
  import "./chunk-6VAL7XJT.js";
20
20
 
21
21
  // src/db/site-mirror.ts
@@ -73,4 +73,4 @@ async function makeSiteMirror(open = () => openDb(readDbConfig()), strict = TURS
73
73
  export {
74
74
  makeSiteMirror
75
75
  };
76
- //# sourceMappingURL=site-mirror-WR7ACXOV.js.map
76
+ //# sourceMappingURL=site-mirror-TKTDWYP3.js.map
@@ -8,7 +8,7 @@ import {
8
8
  } from "./chunk-DYTYPKFN.js";
9
9
  import {
10
10
  resolveSites
11
- } from "./chunk-FP6HOW6J.js";
11
+ } from "./chunk-S2PLX4XK.js";
12
12
  import {
13
13
  smokeSuite
14
14
  } from "./chunk-EN6CTBUN.js";
@@ -52,4 +52,4 @@ async function runSmokeSuiteCommand(site, opts) {
52
52
  export {
53
53
  runSmokeSuiteCommand
54
54
  };
55
- //# sourceMappingURL=smoke-suite-WAUPI6EJ.js.map
55
+ //# sourceMappingURL=smoke-suite-QQMV7TNA.js.map
@@ -299,6 +299,15 @@ async function countNotifyBouncedBySite(db, sinceDate) {
299
299
  ])
300
300
  );
301
301
  }
302
+ async function countNotifyBouncedForSite(db, siteId, sinceDate) {
303
+ const row = await db.selectFrom("submissions").select([
304
+ (eb) => eb.fn.countAll().as("n"),
305
+ sql`sum(case when lower(bounce_type) = 'permanent' then 1 else 0 end)`.as(
306
+ "permanent_n"
307
+ )
308
+ ]).where("site_id", "=", siteId).where("notify_status", "=", "bounced").where("submitted_at", ">=", sinceDate).where("bounce_ack_at", "is", null).executeTakeFirst();
309
+ return { total: Number(row?.n) || 0, permanent: Number(row?.permanent_n) || 0 };
310
+ }
302
311
  export {
303
312
  MIN_DUP_BODY_LEN,
304
313
  MIN_SIMILAR_TOKENS,
@@ -307,6 +316,7 @@ export {
307
316
  backfillSubmission,
308
317
  countAutoSpamSince,
309
318
  countNotifyBouncedBySite,
319
+ countNotifyBouncedForSite,
310
320
  countSubmissionsFiltered,
311
321
  countSubmissionsSinceBySite,
312
322
  createSubmission,
@@ -326,4 +336,4 @@ export {
326
336
  stampFanout,
327
337
  stampNotified
328
338
  };
329
- //# sourceMappingURL=submissions-JDZP35JM.js.map
339
+ //# sourceMappingURL=submissions-4I66W6QK.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/db/submissions.ts"],"sourcesContent":["import { sql } from \"kysely\";\nimport type { Selectable, SqlBool } from \"kysely\";\nimport type { Db } from \"./client.js\";\nimport type { SubmissionsTable } from \"./schema.js\";\nimport {\n type SubmissionRow,\n type SubmissionInput,\n type BounceDetail,\n toFormType,\n toStatus,\n toNotifyStatus,\n} from \"../reports/submission-row.js\";\nimport type { FormType, SubmissionStatus } from \"../reports/submission-row.js\";\nimport { SIGNUP_FORM_TYPES } from \"../forms/types.js\";\n\nexport type SubmissionFilter = {\n siteId?: string;\n formType?: FormType;\n status?: SubmissionStatus;\n /** LIKE %q% across name/email/message/phone, case-insensitive */\n search?: string;\n /** submitted_at >= from (ISO string) */\n from?: string;\n /** submitted_at <= to (ISO string) */\n to?: string;\n /** exact spam_reason token (comma-boundary match; a stored \":N\" count suffix on\n * the token still matches) */\n reason?: string;\n};\n\n/** Map a raw DB row to the canonical SubmissionRow, narrowing the enum columns\n * with the SAME validators the Airtable mapRow uses — SQLite stores TEXT, so a\n * bad stored value must still be defended against. */\nfunction rowFromDb(r: Selectable<SubmissionsTable>): SubmissionRow {\n return {\n id: r.id,\n submissionId: r.submission_id,\n siteId: r.site_id,\n formType: toFormType(r.form_type),\n name: r.name,\n email: r.email,\n phone: r.phone,\n message: r.message,\n extraFields: r.extra_fields,\n sourceUrl: r.source_url,\n utm: r.utm,\n submittedAt: r.submitted_at,\n status: toStatus(r.status),\n notifyStatus: toNotifyStatus(r.notify_status),\n resendMessageId: r.resend_message_id,\n spamScore: typeof r.spam_score === \"number\" ? r.spam_score : null,\n spamReason: r.spam_reason,\n fanoutStatus: r.fanout_status,\n bounceType: r.bounce_type,\n bounceSubType: r.bounce_subtype,\n bounceMessage: r.bounce_message,\n bounceAckAt: r.bounce_ack_at,\n };\n}\n\n/** Opaque, collision-free id. crypto is a Node 20 global — no new dep. */\nexport function newSubmissionId(): string {\n return `sub_${crypto.randomUUID()}`;\n}\n\nexport async function getSubmissionById(db: Db, id: string): Promise<SubmissionRow | null> {\n const r = await db.selectFrom(\"submissions\").selectAll().where(\"id\", \"=\", id).executeTakeFirst();\n return r ? rowFromDb(r) : null;\n}\n\nexport async function createSubmission(db: Db, input: SubmissionInput): Promise<SubmissionRow> {\n const id = newSubmissionId();\n const extra =\n input.extraFields !== undefined && Object.keys(input.extraFields).length > 0\n ? JSON.stringify(input.extraFields)\n : null;\n await db\n .insertInto(\"submissions\")\n .values({\n id,\n // Display number: MAX+1 in a single statement. libSQL is single-writer, so\n // writes serialize and this is race-free.\n submission_id: sql<number>`(SELECT COALESCE(MAX(submission_id), 0) + 1 FROM submissions)`,\n site_id: input.siteId,\n form_type: input.formType,\n name: input.name,\n email: input.email,\n phone: input.phone ?? null,\n message: input.message ?? null,\n extra_fields: extra,\n source_url: input.sourceUrl ?? null,\n utm: input.utm ?? null,\n submitted_at: input.submittedAt.toISOString(),\n status: input.status ?? \"new\",\n notify_status: \"skipped\",\n resend_message_id: null,\n spam_score: input.spamScore ?? null,\n spam_reason: input.spamReason ?? null,\n // Always null at insert: the fan-out runs after the row exists (stampFanout).\n fanout_status: null,\n // Always null at insert too: a bounce is reported by a webhook minutes\n // later, and the ack is an operator gesture after that (#783).\n bounce_type: null,\n bounce_subtype: null,\n bounce_message: null,\n bounce_ack_at: null,\n })\n .execute();\n const created = await getSubmissionById(db, id);\n if (!created) throw new Error(\"createSubmission: row vanished after insert\");\n return created;\n}\n\nimport type { NotifyStatus } from \"../reports/submission-row.js\";\n\nexport async function listNewSubmissions(db: Db, max = 200): Promise<SubmissionRow[]> {\n // Bound the fetch (matches listSubmissionsForSite's default): the cockpit loads this whole\n // array on every render. Unbounded, it deserializes every unread submission fleet-wide. The\n // badge count therefore caps at `max` — acceptable, since >200 unread fleet-wide is itself a\n // triage emergency, and the /submissions page is the unbounded, filterable view.\n const rows = await db\n .selectFrom(\"submissions\")\n .selectAll()\n .where(\"status\", \"=\", \"new\")\n .orderBy(\"submitted_at\", \"desc\")\n .limit(max)\n .execute();\n return rows.map(rowFromDb);\n}\n\n/** Same signature shape as the Airtable version (takes `{ id, name }`) so the\n * composition-root swap is import-only — but here we filter by id directly, with\n * no linked-field/primary-field workaround and no JS-confirm pass. */\nexport async function listSubmissionsForSite(\n db: Db,\n site: { id: string; name: string },\n max = 200,\n): Promise<SubmissionRow[]> {\n const rows = await db\n .selectFrom(\"submissions\")\n .selectAll()\n .where(\"site_id\", \"=\", site.id)\n .orderBy(\"submitted_at\", \"desc\")\n .limit(max)\n .execute();\n return rows.map(rowFromDb);\n}\n\nexport async function setSubmissionStatusRow(\n db: Db,\n id: string,\n status: SubmissionStatus,\n): Promise<void> {\n await db.updateTable(\"submissions\").set({ status }).where(\"id\", \"=\", id).execute();\n}\n\nexport async function stampNotified(\n db: Db,\n id: string,\n status: NotifyStatus,\n messageId: string | null,\n): Promise<void> {\n const patch =\n messageId !== null\n ? { notify_status: status, resend_message_id: messageId }\n : { notify_status: status };\n await db.updateTable(\"submissions\").set(patch).where(\"id\", \"=\", id).execute();\n}\n\n/** Record the newsletter fan-out outcome on a row (comma-joined destination tokens,\n * see ingest.ts). REPLACES any previous value — the fan-out runs once per submission,\n * so the last write is the whole truth; unlike markSubmissionsSpamRetro there is no\n * earlier trail worth appending to. No status guard: this is provenance about what\n * the pipeline did, not a triage decision, so it stays accurate even for a row the\n * operator has already read or archived. */\nexport async function stampFanout(db: Db, id: string, fanoutStatus: string): Promise<void> {\n await db\n .updateTable(\"submissions\")\n .set({ fanout_status: fanoutStatus })\n .where(\"id\", \"=\", id)\n .execute();\n}\n\n// NOTE: listSubmissionsFiltered and countSubmissionsFiltered share identical filter\n// logic via applySubmissionFilter — always update both through that helper.\nfunction applySubmissionFilter<O>(\n qb: import(\"kysely\").SelectQueryBuilder<import(\"./schema.js\").Database, \"submissions\", O>,\n f: SubmissionFilter,\n) {\n let q = qb;\n if (f.siteId !== undefined) q = q.where(\"site_id\", \"=\", f.siteId);\n if (f.formType !== undefined) q = q.where(\"form_type\", \"=\", f.formType);\n if (f.status !== undefined) q = q.where(\"status\", \"=\", f.status);\n if (f.from !== undefined) q = q.where(\"submitted_at\", \">=\", f.from);\n if (f.to !== undefined) q = q.where(\"submitted_at\", \"<=\", f.to);\n if (f.search !== undefined && f.search.trim() !== \"\") {\n // Escape LIKE metacharacters so a user's literal `%`/`_` (or the escape char `\\`) match\n // literally rather than as wildcards: searching `john_doe` must not also hit `johnXdoe`,\n // and a bare `%` must not match everything. `\\` is escaped first (it's the ESCAPE char).\n // Parameterized regardless (no injection) — this is a correctness fix. Uses sql`` because\n // kysely's `like` operator can't attach an ESCAPE clause.\n const term = f.search\n .trim()\n .toLowerCase()\n .replace(/[\\\\%_]/g, \"\\\\$&\");\n const like = `%${term}%`;\n q = q.where((eb) =>\n eb.or([\n sql<SqlBool>`lower(${eb.ref(\"name\")}) like ${like} escape '\\\\'`,\n sql<SqlBool>`lower(${eb.ref(\"email\")}) like ${like} escape '\\\\'`,\n sql<SqlBool>`lower(${eb.ref(\"message\")}) like ${like} escape '\\\\'`,\n sql<SqlBool>`lower(${eb.ref(\"phone\")}) like ${like} escape '\\\\'`,\n ]),\n );\n }\n if (f.reason !== undefined && f.reason.trim() !== \"\") {\n // spam_reason is a comma-joined token list (ingest.ts joins \",\"; retro appends\n // \",<reason>\"). Comma-boundary match so \"links\" never matches \"backlinks\" and\n // \"turnstile-required-absent\" never matches \"-failed\"; the second branch accepts\n // a per-token count suffix (\"keywords:4\" matches reason=keywords). LIKE\n // metacharacters escaped exactly like the search branch. lower() both sides is\n // safe: tokens are ASCII classifier codes.\n const token = f.reason\n .trim()\n .toLowerCase()\n .replace(/[\\\\%_]/g, \"\\\\$&\");\n q = q.where((eb) =>\n eb.or([\n sql<SqlBool>`',' || lower(${eb.ref(\"spam_reason\")}) || ',' like ${`%,${token},%`} escape '\\\\'`,\n sql<SqlBool>`',' || lower(${eb.ref(\"spam_reason\")}) || ',' like ${`%,${token}:%`} escape '\\\\'`,\n ]),\n );\n }\n return q;\n}\n\nexport async function listSubmissionsFiltered(\n db: Db,\n filter: SubmissionFilter,\n opts: { limit: number; offset: number },\n): Promise<SubmissionRow[]> {\n const base = db.selectFrom(\"submissions\").selectAll();\n const rows = await applySubmissionFilter(base, filter)\n .orderBy(\"submitted_at\", \"desc\")\n .limit(opts.limit)\n .offset(opts.offset)\n .execute();\n return rows.map(rowFromDb);\n}\n\nexport async function countSubmissionsFiltered(db: Db, filter: SubmissionFilter): Promise<number> {\n const base = db.selectFrom(\"submissions\").select((eb) => eb.fn.countAll<number>().as(\"n\"));\n const res = await applySubmissionFilter(base, filter).executeTakeFirstOrThrow();\n return Number(res.n);\n}\n\n/** Fleet-wide count of auto-filtered spam (`status = 'spam_auto'`) submitted on/after\n * `sinceDate` (ISO). Powers the cockpit \"N auto-filtered this week — review\" affordance;\n * the caller picks the window (like `listScreenOutsSince`), keeping this query pure. */\nexport async function countAutoSpamSince(db: Db, sinceDate: string): Promise<number> {\n const res = await db\n .selectFrom(\"submissions\")\n .select((eb) => eb.fn.countAll<number>().as(\"n\"))\n .where(\"status\", \"=\", \"spam_auto\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .executeTakeFirstOrThrow();\n return Number(res.n);\n}\n\nexport type SiteSubmissionCounts = { leads: number; signups: number; spamAuto: number };\n\n/** Per-site submission counts on/after `sinceIso` (full ISO timestamp) for the\n * digest telemetry line. Grouped one-query: leads/signups = rows NOT auto- or\n * operator-bucketed as spam (status NOT IN ('spam_auto','spam')) split by\n * isLeadFormType; spamAuto = status='spam_auto'. Keyed by Websites record id. */\nexport async function countSubmissionsSinceBySite(\n db: Db,\n sinceIso: string,\n): Promise<Map<string, SiteSubmissionCounts>> {\n const signupList = sql.join(SIGNUP_FORM_TYPES.map((t) => sql`${t}`));\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\n \"site_id\",\n sql<number>`sum(case when status not in ('spam_auto','spam') and form_type not in (${signupList}) then 1 else 0 end)`.as(\n \"leads_n\",\n ),\n sql<number>`sum(case when status not in ('spam_auto','spam') and form_type in (${signupList}) then 1 else 0 end)`.as(\n \"signups_n\",\n ),\n sql<number>`sum(case when status = 'spam_auto' then 1 else 0 end)`.as(\"spam_auto_n\"),\n ])\n .where(\"submitted_at\", \">=\", sinceIso)\n .groupBy(\"site_id\")\n .execute();\n return new Map(\n rows.map((r) => [\n r.site_id,\n {\n leads: Number(r.leads_n) || 0,\n signups: Number(r.signups_n) || 0,\n spamAuto: Number(r.spam_auto_n) || 0,\n },\n ]),\n );\n}\n\n/** A normalized body shorter than this never triggers the exact-duplicate signal —\n * short/boilerplate lines (a newsletter \"subscribe\", \"hi\") legitimately repeat\n * across real people. */\nexport const MIN_DUP_BODY_LEN = 40;\n\n/** Both token sets must be at least this large before the Jaccard tier can fire —\n * two short genuine messages (\"what are your hours?\" variants) share most of their\n * few tokens and would otherwise collide. */\nexport const MIN_SIMILAR_TOKENS = 25;\n\n/** Jaccard similarity (token sets) at or above this marks a near-duplicate. 0.9 is\n * deliberately strict: a template spray with one substituted greeting/target still\n * clears it, while two independently-written enquiries about the same business\n * never do. */\nexport const SIMILARITY_THRESHOLD = 0.9;\n\n/** Fold a message body down to its template skeleton. Full-Unicode lowercase first\n * (JS `toLowerCase()` — SQLite's `lower()` is ASCII-only, which is why this whole\n * comparison lives in JS, folding BOTH sides identically so byte-identical Cyrillic\n * copies keep matching), then strip everything a spray substitutes per target —\n * URLs, emails, bare domains, digit runs — and collapse whitespace. Over-stripping\n * is safe: it happens symmetrically on both sides. */\nfunction normalizeBody(body: string): string {\n return body\n .toLowerCase()\n .replace(/https?:\\/\\/\\S+/gu, \" \") // full URLs\n .replace(/www\\.\\S+/gu, \" \") // scheme-less www. links\n .replace(/\\S+@\\S+/gu, \" \") // email addresses\n .replace(/\\b[a-z0-9-]+(\\.[a-z0-9-]+)+\\b/gu, \" \") // bare domain tokens (word.tld)\n .replace(/\\d+/gu, \" \") // digit runs (phone numbers, prices, years)\n .replace(/\\s+/gu, \" \")\n .trim();\n}\n\n/** Letter-only token set of a normalized body (punctuation/symbols are separators). */\nfunction tokenSet(normalized: string): Set<string> {\n return new Set(normalized.split(/[^\\p{L}]+/u).filter((t) => t.length > 0));\n}\n\nfunction jaccard(a: Set<string>, b: Set<string>): number {\n let intersection = 0;\n for (const t of a) if (b.has(t)) intersection++;\n const union = a.size + b.size - intersection;\n return union === 0 ? 0 : intersection / union;\n}\n\n/** Fleet-wide duplicate/near-duplicate lookup for the spray spam signal — the same\n * pitch blasted across sites shows up as identical OR near-identical bodies (the\n * live dog-harness spray differed ONLY in greeting; SEO sprays substitute the\n * target domain per site — exact match alone missed both). Scans rows submitted\n * on/after `sinceDate` and compares in JS (see normalizeBody for why not SQL):\n * - `exact`: normalized equality, only when the normalized incoming body is at\n * least MIN_DUP_BODY_LEN chars.\n * - `similar`: token-set Jaccard >= SIMILARITY_THRESHOLD, only when BOTH sets have\n * at least MIN_SIMILAR_TOKENS tokens. A row matched as exact never re-appears here.\n * Returned statuses let the caller retro-bucket still-'new' prior copies. The 2000-row\n * LIMIT is a safety bound only — current volume is ~130/month, so a 30-day window is\n * always a full scan. */\n/** One matched prior row from the duplicate scan. Carries siteId + email so the\n * ingest caller can exempt a GENUINE same-sender resubmission on the same site\n * (a real visitor double-submitting / resending after silence) — only cross-site\n * or different-sender copies are spray evidence. */\nexport type DuplicateMatch = { id: string; status: string; siteId: string; email: string };\n\nexport async function findRecentDuplicateSubmissions(\n db: Db,\n message: string,\n sinceDate: string,\n): Promise<{\n exact: DuplicateMatch[];\n similar: DuplicateMatch[];\n}> {\n const incoming = normalizeBody(message);\n const incomingTokens = tokenSet(incoming);\n const exactEligible = incoming.length >= MIN_DUP_BODY_LEN;\n const similarEligible = incomingTokens.size >= MIN_SIMILAR_TOKENS;\n // Too short for either tier — skip the scan entirely (nothing could match).\n if (!exactEligible && !similarEligible) return { exact: [], similar: [] };\n\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\"id\", \"status\", \"message\", \"site_id\", \"email\"])\n .where(\"message\", \"is not\", null)\n .where(\"submitted_at\", \">=\", sinceDate)\n .orderBy(\"submitted_at\", \"desc\")\n .limit(2000)\n .execute();\n\n const exact: DuplicateMatch[] = [];\n const similar: DuplicateMatch[] = [];\n for (const row of rows) {\n const stored = normalizeBody(row.message ?? \"\");\n const match: DuplicateMatch = {\n id: row.id,\n status: row.status,\n siteId: row.site_id,\n email: row.email,\n };\n if (exactEligible && stored === incoming) {\n exact.push(match);\n continue; // an exact match must not also appear in similar\n }\n if (!similarEligible) continue;\n const storedTokens = tokenSet(stored);\n if (storedTokens.size < MIN_SIMILAR_TOKENS) continue;\n if (jaccard(incomingTokens, storedTokens) >= SIMILARITY_THRESHOLD) {\n similar.push(match);\n }\n }\n return { exact, similar };\n}\n\n/** Recent non-newsletter submissions from `email` (case/whitespace-folded), on/after\n * `sinceDate` (ISO). Powers the cross-site repeat-sender signal: the fleet's sites\n * are UNRELATED businesses, so the same address writing to 2+ of them inside a month\n * is a solicitation tell — the caller compares siteIds (same-site repeats are genuine\n * follow-ups). Newsletter rows are excluded: one person subscribing on two sites is\n * legitimate. Both sides fold in SQL (same-side folding, see the normalizeBody note). */\nexport async function listRecentSubmissionsForEmail(\n db: Db,\n email: string,\n sinceDate: string,\n): Promise<Array<{ id: string; siteId: string; status: string }>> {\n if (email.trim() === \"\") return [];\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\"id\", \"site_id\", \"status\"])\n .where(\"form_type\", \"!=\", \"newsletter\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .where((eb) => sql<SqlBool>`lower(trim(${eb.ref(\"email\")})) = lower(trim(${email}))`)\n .execute();\n return rows.map((r) => ({ id: r.id, siteId: r.site_id, status: r.status }));\n}\n\n/** Retroactively re-bucket prior spray copies once a later copy identifies the spray\n * (the FIRST copy is always delivered by design — only a repeat reveals it). Appends\n * `retroReason` to any existing spam_reason so the original classifier trail survives.\n * The `status = 'new'` guard is load-bearing: rows the operator already read/replied/\n * archived/marked are NEVER touched — this only cleans the unread queue. */\nexport async function markSubmissionsSpamRetro(\n db: Db,\n ids: string[],\n retroReason: string,\n): Promise<void> {\n if (ids.length === 0) return;\n await db\n .updateTable(\"submissions\")\n .set({\n status: \"spam_auto\",\n spam_reason: sql<string>`CASE WHEN spam_reason IS NULL OR spam_reason = '' THEN ${retroReason} ELSE spam_reason || ',' || ${retroReason} END`,\n })\n .where(\"id\", \"in\", ids)\n .where(\"status\", \"=\", \"new\")\n .execute();\n}\n\n/** spam_reason strings for EVERY row matching `filter` (not just one page). Powers the\n * /submissions per-reason facet line, which must tally the WHOLE filtered bucket — the\n * rollout runbook directs the operator to judge the canary from it, and a page-scoped\n * tally under the full-bucket total silently misread once the bucket passed one page.\n * Capped (default 2000) as a safety bound far above any real bucket. */\nexport async function listSpamReasonsFiltered(\n db: Db,\n filter: SubmissionFilter,\n cap = 2000,\n): Promise<string[]> {\n const base = db.selectFrom(\"submissions\").select([\"spam_reason\"]);\n const rows = await applySubmissionFilter(base, filter)\n .where(\"spam_reason\", \"is not\", null)\n .limit(cap)\n .execute();\n return rows.map((r) => r.spam_reason).filter((r): r is string => r !== null && r !== \"\");\n}\n\n/** Insert a SubmissionRow verbatim, preserving its id, display number, and status.\n * ON CONFLICT(id) DO NOTHING makes the whole backfill re-runnable. */\nexport async function backfillSubmission(db: Db, row: SubmissionRow): Promise<void> {\n await db\n .insertInto(\"submissions\")\n .values({\n id: row.id,\n submission_id: row.submissionId,\n site_id: row.siteId,\n form_type: row.formType,\n name: row.name,\n email: row.email,\n phone: row.phone,\n message: row.message,\n extra_fields: row.extraFields,\n source_url: row.sourceUrl,\n utm: row.utm,\n submitted_at: row.submittedAt,\n status: row.status,\n notify_status: row.notifyStatus,\n resend_message_id: row.resendMessageId,\n spam_score: row.spamScore ?? null,\n spam_reason: row.spamReason ?? null,\n fanout_status: row.fanoutStatus ?? null,\n bounce_type: row.bounceType ?? null,\n bounce_subtype: row.bounceSubType ?? null,\n bounce_message: row.bounceMessage ?? null,\n bounce_ack_at: row.bounceAckAt ?? null,\n })\n .onConflict((oc) => oc.column(\"id\").doNothing())\n .execute();\n}\n\n/** Re-score write for the `submissions rescore` CLI (2026-07-16): flip ONE still-'new'\n * row to spam_auto with the CURRENT classifier's verdict. Unlike markSubmissionsSpamRetro\n * (which appends to the old trail), this REPLACES spam_score/spam_reason — the point of a\n * re-score is that today's verdict supersedes the stale ingest-time one; the caller bakes\n * its provenance marker (retro-rescore) into `reason`. The status='new' guard is\n * load-bearing: rows the operator already read/replied/archived/marked are NEVER\n * re-bucketed. Returns true when the row was written (false = no longer 'new'). */\nexport async function rescoreSubmissionSpam(\n db: Db,\n id: string,\n score: number,\n reason: string,\n): Promise<boolean> {\n const res = await db\n .updateTable(\"submissions\")\n .set({ status: \"spam_auto\", spam_score: score, spam_reason: reason })\n .where(\"id\", \"=\", id)\n .where(\"status\", \"=\", \"new\")\n .executeTakeFirst();\n return Number(res.numUpdatedRows) > 0;\n}\n\n/** Bulk triage for the /submissions page (2026-07-16): flip EVERY still-'new' row\n * matching `filter` to 'read', server-side, so a 100-row backlog is one click instead\n * of 100. Reuses applySubmissionFilter (via an id-subquery — kysely's update builder\n * can't take the select-only helper directly) so the POST flips exactly the bucket the\n * GET rendered. The outer status='new' guard is load-bearing: spam/archived/read rows\n * are never touched even when the filter matches them (bulk-\"reading\" a spam_auto row\n * would resurrect it into the per-site strip). Returns the number of rows flipped. */\nexport async function markFilteredAsRead(db: Db, filter: SubmissionFilter): Promise<number> {\n const res = await db\n .updateTable(\"submissions\")\n .set({ status: \"read\" })\n .where(\"status\", \"=\", \"new\")\n .where(\"id\", \"in\", (eb) =>\n applySubmissionFilter(eb.selectFrom(\"submissions\").select(\"id\"), filter),\n )\n .executeTakeFirst();\n return Number(res.numUpdatedRows);\n}\n\n/** Flip a submission's notify_status to 'bounced' by its Resend message id — the\n * resend-webhook's mapping from a bounce/complaint event back onto the lead whose\n * notification it was (2026-07-16, the Espada failure mode: 'sent' only means Resend\n * ACCEPTED the email). Returns whether any row matched, so the webhook can tell a\n * submission notification apart from a report email (unknown id → false → the report\n * path handles it). Idempotent: a svix replay re-writes the same terminal value. In\n * practice only 'sent' rows can match — stampNotified stamps the message id and the\n * 'sent' status together, and failed/skipped rows carry no id. */\nexport async function markNotifyBouncedByMessageId(\n db: Db,\n messageId: string,\n /** Resend's `data.bounce`, parsed (#783). Null for a complaint, which carries\n * no bounce object, and for any payload whose shape we did not recognize.\n *\n * A null detail deliberately does NOT clear a classification already stored:\n * a svix replay of the same event, or a later complaint on a message that\n * already bounced, must not erase the diagnosis the first delivery carried. */\n detail: BounceDetail | null = null,\n): Promise<boolean> {\n if (messageId === \"\") return false;\n const res = await db\n .updateTable(\"submissions\")\n .set(\n detail === null\n ? { notify_status: \"bounced\" }\n : {\n notify_status: \"bounced\",\n bounce_type: detail.type,\n bounce_subtype: detail.subType,\n bounce_message: detail.message,\n },\n )\n .where(\"resend_message_id\", \"=\", messageId)\n .executeTakeFirst();\n if (Number(res.numUpdatedRows) > 0) return true;\n // A replayed webhook must still report \"this was a submission\" (200, stop svix\n // retrying) even though the terminal value is already written — matched-but-\n // unchanged rows still count in numUpdatedRows on SQLite, but don't rely on it.\n const existing = await db\n .selectFrom(\"submissions\")\n .select(\"id\")\n .where(\"resend_message_id\", \"=\", messageId)\n .executeTakeFirst();\n return existing !== undefined;\n}\n\n/** Acknowledge ONE bounced notification as \"not a dead address\" (#783).\n *\n * The row keeps its bounce record — status, type, sub-type and the receiving\n * server's message all stay — and simply stops counting toward the alarm. That\n * is the difference from the production workaround this replaces, which flipped\n * `bounced` back to `sent` by id and destroyed the evidence along with the\n * alarm.\n *\n * Guarded on `notify_status = 'bounced'` (there is nothing to acknowledge on a\n * healthy row) and on `bounce_ack_at IS NULL`, which makes it idempotent and\n * keeps the FIRST acknowledgement's timestamp — the honest one, since that is\n * when the operator actually looked. Returns whether this call was the one that\n * wrote. A PERMANENT bounce is deliberately still ackable: the ack means \"I\n * have looked at this\", not \"this was a false alarm\", and refusing it would\n * leave no way to close the case after genuinely fixing the mailbox. */\nexport async function ackNotifyBounce(db: Db, id: string, now: Date): Promise<boolean> {\n const res = await db\n .updateTable(\"submissions\")\n .set({ bounce_ack_at: now.toISOString() })\n .where(\"id\", \"=\", id)\n .where(\"notify_status\", \"=\", \"bounced\")\n .where(\"bounce_ack_at\", \"is\", null)\n .executeTakeFirst();\n return Number(res.numUpdatedRows) > 0;\n}\n\n/** Per-site bounce counts, split by whether the payload said the ADDRESS is bad. */\nexport type NotifyBounceCounts = {\n /** Unacknowledged bounced notifications in the window. */\n total: number;\n /** How many of those Resend classified `Permanent` (#783). Counted ONLY where\n * the payload actually said so: a null classification — every row written\n * before migration 0018, and every complaint — counts toward `total` and\n * never toward this. Reading null as permanent would keep accusing the\n * address on exactly the rows that caused the false alarm. */\n permanent: number;\n};\n\n/** Per-site counts of bounced lead notifications on/after `sinceDate` (ISO), keyed by\n * the Websites record id (`site_id`). Powers the notify-bounce attention collector —\n * the caller picks the window (like `countAutoSpamSince`), keeping this query pure.\n * Windowed on `submitted_at`: the bounce lands minutes after the submission, and\n * submissions carry no bounce timestamp column (append-only schema, 2026-07-16).\n *\n * Acknowledged rows are excluded outright (#783), which is what lets a diagnosed\n * false alarm clear the same day instead of aging out over fourteen. A site whose\n * every bounce is acked drops out of the map entirely, exactly like a site that\n * never bounced — both are \"nothing to raise\". */\nexport async function countNotifyBouncedBySite(\n db: Db,\n sinceDate: string,\n): Promise<Map<string, NotifyBounceCounts>> {\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\n \"site_id\",\n (eb) => eb.fn.countAll<number>().as(\"n\"),\n // lower() is ASCII-only in SQLite, which is all this needs: the value is a\n // Resend enum (\"Permanent\"/\"Transient\"/\"Undetermined\"). Matching it\n // case-insensitively here keeps the SQL agreeing with isPermanentBounce.\n sql<number>`sum(case when lower(bounce_type) = 'permanent' then 1 else 0 end)`.as(\n \"permanent_n\",\n ),\n ])\n .where(\"notify_status\", \"=\", \"bounced\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .where(\"bounce_ack_at\", \"is\", null)\n .groupBy(\"site_id\")\n .execute();\n return new Map(\n rows.map((r) => [\n r.site_id,\n { total: Number(r.n) || 0, permanent: Number(r.permanent_n) || 0 },\n ]),\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/db/submissions.ts"],"sourcesContent":["import { sql } from \"kysely\";\nimport type { Selectable, SqlBool } from \"kysely\";\nimport type { Db } from \"./client.js\";\nimport type { SubmissionsTable } from \"./schema.js\";\nimport {\n type SubmissionRow,\n type SubmissionInput,\n type BounceDetail,\n toFormType,\n toStatus,\n toNotifyStatus,\n} from \"../reports/submission-row.js\";\nimport type { FormType, SubmissionStatus } from \"../reports/submission-row.js\";\nimport { SIGNUP_FORM_TYPES } from \"../forms/types.js\";\n\nexport type SubmissionFilter = {\n siteId?: string;\n formType?: FormType;\n status?: SubmissionStatus;\n /** LIKE %q% across name/email/message/phone, case-insensitive */\n search?: string;\n /** submitted_at >= from (ISO string) */\n from?: string;\n /** submitted_at <= to (ISO string) */\n to?: string;\n /** exact spam_reason token (comma-boundary match; a stored \":N\" count suffix on\n * the token still matches) */\n reason?: string;\n};\n\n/** Map a raw DB row to the canonical SubmissionRow, narrowing the enum columns\n * with the SAME validators the Airtable mapRow uses — SQLite stores TEXT, so a\n * bad stored value must still be defended against. */\nfunction rowFromDb(r: Selectable<SubmissionsTable>): SubmissionRow {\n return {\n id: r.id,\n submissionId: r.submission_id,\n siteId: r.site_id,\n formType: toFormType(r.form_type),\n name: r.name,\n email: r.email,\n phone: r.phone,\n message: r.message,\n extraFields: r.extra_fields,\n sourceUrl: r.source_url,\n utm: r.utm,\n submittedAt: r.submitted_at,\n status: toStatus(r.status),\n notifyStatus: toNotifyStatus(r.notify_status),\n resendMessageId: r.resend_message_id,\n spamScore: typeof r.spam_score === \"number\" ? r.spam_score : null,\n spamReason: r.spam_reason,\n fanoutStatus: r.fanout_status,\n bounceType: r.bounce_type,\n bounceSubType: r.bounce_subtype,\n bounceMessage: r.bounce_message,\n bounceAckAt: r.bounce_ack_at,\n };\n}\n\n/** Opaque, collision-free id. crypto is a Node 20 global — no new dep. */\nexport function newSubmissionId(): string {\n return `sub_${crypto.randomUUID()}`;\n}\n\nexport async function getSubmissionById(db: Db, id: string): Promise<SubmissionRow | null> {\n const r = await db.selectFrom(\"submissions\").selectAll().where(\"id\", \"=\", id).executeTakeFirst();\n return r ? rowFromDb(r) : null;\n}\n\nexport async function createSubmission(db: Db, input: SubmissionInput): Promise<SubmissionRow> {\n const id = newSubmissionId();\n const extra =\n input.extraFields !== undefined && Object.keys(input.extraFields).length > 0\n ? JSON.stringify(input.extraFields)\n : null;\n await db\n .insertInto(\"submissions\")\n .values({\n id,\n // Display number: MAX+1 in a single statement. libSQL is single-writer, so\n // writes serialize and this is race-free.\n submission_id: sql<number>`(SELECT COALESCE(MAX(submission_id), 0) + 1 FROM submissions)`,\n site_id: input.siteId,\n form_type: input.formType,\n name: input.name,\n email: input.email,\n phone: input.phone ?? null,\n message: input.message ?? null,\n extra_fields: extra,\n source_url: input.sourceUrl ?? null,\n utm: input.utm ?? null,\n submitted_at: input.submittedAt.toISOString(),\n status: input.status ?? \"new\",\n notify_status: \"skipped\",\n resend_message_id: null,\n spam_score: input.spamScore ?? null,\n spam_reason: input.spamReason ?? null,\n // Always null at insert: the fan-out runs after the row exists (stampFanout).\n fanout_status: null,\n // Always null at insert too: a bounce is reported by a webhook minutes\n // later, and the ack is an operator gesture after that (#783).\n bounce_type: null,\n bounce_subtype: null,\n bounce_message: null,\n bounce_ack_at: null,\n })\n .execute();\n const created = await getSubmissionById(db, id);\n if (!created) throw new Error(\"createSubmission: row vanished after insert\");\n return created;\n}\n\nimport type { NotifyStatus } from \"../reports/submission-row.js\";\n\nexport async function listNewSubmissions(db: Db, max = 200): Promise<SubmissionRow[]> {\n // Bound the fetch (matches listSubmissionsForSite's default): the cockpit loads this whole\n // array on every render. Unbounded, it deserializes every unread submission fleet-wide. The\n // badge count therefore caps at `max` — acceptable, since >200 unread fleet-wide is itself a\n // triage emergency, and the /submissions page is the unbounded, filterable view.\n const rows = await db\n .selectFrom(\"submissions\")\n .selectAll()\n .where(\"status\", \"=\", \"new\")\n .orderBy(\"submitted_at\", \"desc\")\n .limit(max)\n .execute();\n return rows.map(rowFromDb);\n}\n\n/** Same signature shape as the Airtable version (takes `{ id, name }`) so the\n * composition-root swap is import-only — but here we filter by id directly, with\n * no linked-field/primary-field workaround and no JS-confirm pass. */\nexport async function listSubmissionsForSite(\n db: Db,\n site: { id: string; name: string },\n max = 200,\n): Promise<SubmissionRow[]> {\n const rows = await db\n .selectFrom(\"submissions\")\n .selectAll()\n .where(\"site_id\", \"=\", site.id)\n .orderBy(\"submitted_at\", \"desc\")\n .limit(max)\n .execute();\n return rows.map(rowFromDb);\n}\n\nexport async function setSubmissionStatusRow(\n db: Db,\n id: string,\n status: SubmissionStatus,\n): Promise<void> {\n await db.updateTable(\"submissions\").set({ status }).where(\"id\", \"=\", id).execute();\n}\n\nexport async function stampNotified(\n db: Db,\n id: string,\n status: NotifyStatus,\n messageId: string | null,\n): Promise<void> {\n const patch =\n messageId !== null\n ? { notify_status: status, resend_message_id: messageId }\n : { notify_status: status };\n await db.updateTable(\"submissions\").set(patch).where(\"id\", \"=\", id).execute();\n}\n\n/** Record the newsletter fan-out outcome on a row (comma-joined destination tokens,\n * see ingest.ts). REPLACES any previous value — the fan-out runs once per submission,\n * so the last write is the whole truth; unlike markSubmissionsSpamRetro there is no\n * earlier trail worth appending to. No status guard: this is provenance about what\n * the pipeline did, not a triage decision, so it stays accurate even for a row the\n * operator has already read or archived. */\nexport async function stampFanout(db: Db, id: string, fanoutStatus: string): Promise<void> {\n await db\n .updateTable(\"submissions\")\n .set({ fanout_status: fanoutStatus })\n .where(\"id\", \"=\", id)\n .execute();\n}\n\n// NOTE: listSubmissionsFiltered and countSubmissionsFiltered share identical filter\n// logic via applySubmissionFilter — always update both through that helper.\nfunction applySubmissionFilter<O>(\n qb: import(\"kysely\").SelectQueryBuilder<import(\"./schema.js\").Database, \"submissions\", O>,\n f: SubmissionFilter,\n) {\n let q = qb;\n if (f.siteId !== undefined) q = q.where(\"site_id\", \"=\", f.siteId);\n if (f.formType !== undefined) q = q.where(\"form_type\", \"=\", f.formType);\n if (f.status !== undefined) q = q.where(\"status\", \"=\", f.status);\n if (f.from !== undefined) q = q.where(\"submitted_at\", \">=\", f.from);\n if (f.to !== undefined) q = q.where(\"submitted_at\", \"<=\", f.to);\n if (f.search !== undefined && f.search.trim() !== \"\") {\n // Escape LIKE metacharacters so a user's literal `%`/`_` (or the escape char `\\`) match\n // literally rather than as wildcards: searching `john_doe` must not also hit `johnXdoe`,\n // and a bare `%` must not match everything. `\\` is escaped first (it's the ESCAPE char).\n // Parameterized regardless (no injection) — this is a correctness fix. Uses sql`` because\n // kysely's `like` operator can't attach an ESCAPE clause.\n const term = f.search\n .trim()\n .toLowerCase()\n .replace(/[\\\\%_]/g, \"\\\\$&\");\n const like = `%${term}%`;\n q = q.where((eb) =>\n eb.or([\n sql<SqlBool>`lower(${eb.ref(\"name\")}) like ${like} escape '\\\\'`,\n sql<SqlBool>`lower(${eb.ref(\"email\")}) like ${like} escape '\\\\'`,\n sql<SqlBool>`lower(${eb.ref(\"message\")}) like ${like} escape '\\\\'`,\n sql<SqlBool>`lower(${eb.ref(\"phone\")}) like ${like} escape '\\\\'`,\n ]),\n );\n }\n if (f.reason !== undefined && f.reason.trim() !== \"\") {\n // spam_reason is a comma-joined token list (ingest.ts joins \",\"; retro appends\n // \",<reason>\"). Comma-boundary match so \"links\" never matches \"backlinks\" and\n // \"turnstile-required-absent\" never matches \"-failed\"; the second branch accepts\n // a per-token count suffix (\"keywords:4\" matches reason=keywords). LIKE\n // metacharacters escaped exactly like the search branch. lower() both sides is\n // safe: tokens are ASCII classifier codes.\n const token = f.reason\n .trim()\n .toLowerCase()\n .replace(/[\\\\%_]/g, \"\\\\$&\");\n q = q.where((eb) =>\n eb.or([\n sql<SqlBool>`',' || lower(${eb.ref(\"spam_reason\")}) || ',' like ${`%,${token},%`} escape '\\\\'`,\n sql<SqlBool>`',' || lower(${eb.ref(\"spam_reason\")}) || ',' like ${`%,${token}:%`} escape '\\\\'`,\n ]),\n );\n }\n return q;\n}\n\nexport async function listSubmissionsFiltered(\n db: Db,\n filter: SubmissionFilter,\n opts: { limit: number; offset: number },\n): Promise<SubmissionRow[]> {\n const base = db.selectFrom(\"submissions\").selectAll();\n const rows = await applySubmissionFilter(base, filter)\n .orderBy(\"submitted_at\", \"desc\")\n .limit(opts.limit)\n .offset(opts.offset)\n .execute();\n return rows.map(rowFromDb);\n}\n\nexport async function countSubmissionsFiltered(db: Db, filter: SubmissionFilter): Promise<number> {\n const base = db.selectFrom(\"submissions\").select((eb) => eb.fn.countAll<number>().as(\"n\"));\n const res = await applySubmissionFilter(base, filter).executeTakeFirstOrThrow();\n return Number(res.n);\n}\n\n/** Fleet-wide count of auto-filtered spam (`status = 'spam_auto'`) submitted on/after\n * `sinceDate` (ISO). Powers the cockpit \"N auto-filtered this week — review\" affordance;\n * the caller picks the window (like `listScreenOutsSince`), keeping this query pure. */\nexport async function countAutoSpamSince(db: Db, sinceDate: string): Promise<number> {\n const res = await db\n .selectFrom(\"submissions\")\n .select((eb) => eb.fn.countAll<number>().as(\"n\"))\n .where(\"status\", \"=\", \"spam_auto\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .executeTakeFirstOrThrow();\n return Number(res.n);\n}\n\nexport type SiteSubmissionCounts = { leads: number; signups: number; spamAuto: number };\n\n/** Per-site submission counts on/after `sinceIso` (full ISO timestamp) for the\n * digest telemetry line. Grouped one-query: leads/signups = rows NOT auto- or\n * operator-bucketed as spam (status NOT IN ('spam_auto','spam')) split by\n * isLeadFormType; spamAuto = status='spam_auto'. Keyed by Websites record id. */\nexport async function countSubmissionsSinceBySite(\n db: Db,\n sinceIso: string,\n): Promise<Map<string, SiteSubmissionCounts>> {\n const signupList = sql.join(SIGNUP_FORM_TYPES.map((t) => sql`${t}`));\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\n \"site_id\",\n sql<number>`sum(case when status not in ('spam_auto','spam') and form_type not in (${signupList}) then 1 else 0 end)`.as(\n \"leads_n\",\n ),\n sql<number>`sum(case when status not in ('spam_auto','spam') and form_type in (${signupList}) then 1 else 0 end)`.as(\n \"signups_n\",\n ),\n sql<number>`sum(case when status = 'spam_auto' then 1 else 0 end)`.as(\"spam_auto_n\"),\n ])\n .where(\"submitted_at\", \">=\", sinceIso)\n .groupBy(\"site_id\")\n .execute();\n return new Map(\n rows.map((r) => [\n r.site_id,\n {\n leads: Number(r.leads_n) || 0,\n signups: Number(r.signups_n) || 0,\n spamAuto: Number(r.spam_auto_n) || 0,\n },\n ]),\n );\n}\n\n/** A normalized body shorter than this never triggers the exact-duplicate signal —\n * short/boilerplate lines (a newsletter \"subscribe\", \"hi\") legitimately repeat\n * across real people. */\nexport const MIN_DUP_BODY_LEN = 40;\n\n/** Both token sets must be at least this large before the Jaccard tier can fire —\n * two short genuine messages (\"what are your hours?\" variants) share most of their\n * few tokens and would otherwise collide. */\nexport const MIN_SIMILAR_TOKENS = 25;\n\n/** Jaccard similarity (token sets) at or above this marks a near-duplicate. 0.9 is\n * deliberately strict: a template spray with one substituted greeting/target still\n * clears it, while two independently-written enquiries about the same business\n * never do. */\nexport const SIMILARITY_THRESHOLD = 0.9;\n\n/** Fold a message body down to its template skeleton. Full-Unicode lowercase first\n * (JS `toLowerCase()` — SQLite's `lower()` is ASCII-only, which is why this whole\n * comparison lives in JS, folding BOTH sides identically so byte-identical Cyrillic\n * copies keep matching), then strip everything a spray substitutes per target —\n * URLs, emails, bare domains, digit runs — and collapse whitespace. Over-stripping\n * is safe: it happens symmetrically on both sides. */\nfunction normalizeBody(body: string): string {\n return body\n .toLowerCase()\n .replace(/https?:\\/\\/\\S+/gu, \" \") // full URLs\n .replace(/www\\.\\S+/gu, \" \") // scheme-less www. links\n .replace(/\\S+@\\S+/gu, \" \") // email addresses\n .replace(/\\b[a-z0-9-]+(\\.[a-z0-9-]+)+\\b/gu, \" \") // bare domain tokens (word.tld)\n .replace(/\\d+/gu, \" \") // digit runs (phone numbers, prices, years)\n .replace(/\\s+/gu, \" \")\n .trim();\n}\n\n/** Letter-only token set of a normalized body (punctuation/symbols are separators). */\nfunction tokenSet(normalized: string): Set<string> {\n return new Set(normalized.split(/[^\\p{L}]+/u).filter((t) => t.length > 0));\n}\n\nfunction jaccard(a: Set<string>, b: Set<string>): number {\n let intersection = 0;\n for (const t of a) if (b.has(t)) intersection++;\n const union = a.size + b.size - intersection;\n return union === 0 ? 0 : intersection / union;\n}\n\n/** Fleet-wide duplicate/near-duplicate lookup for the spray spam signal — the same\n * pitch blasted across sites shows up as identical OR near-identical bodies (the\n * live dog-harness spray differed ONLY in greeting; SEO sprays substitute the\n * target domain per site — exact match alone missed both). Scans rows submitted\n * on/after `sinceDate` and compares in JS (see normalizeBody for why not SQL):\n * - `exact`: normalized equality, only when the normalized incoming body is at\n * least MIN_DUP_BODY_LEN chars.\n * - `similar`: token-set Jaccard >= SIMILARITY_THRESHOLD, only when BOTH sets have\n * at least MIN_SIMILAR_TOKENS tokens. A row matched as exact never re-appears here.\n * Returned statuses let the caller retro-bucket still-'new' prior copies. The 2000-row\n * LIMIT is a safety bound only — current volume is ~130/month, so a 30-day window is\n * always a full scan. */\n/** One matched prior row from the duplicate scan. Carries siteId + email so the\n * ingest caller can exempt a GENUINE same-sender resubmission on the same site\n * (a real visitor double-submitting / resending after silence) — only cross-site\n * or different-sender copies are spray evidence. */\nexport type DuplicateMatch = { id: string; status: string; siteId: string; email: string };\n\nexport async function findRecentDuplicateSubmissions(\n db: Db,\n message: string,\n sinceDate: string,\n): Promise<{\n exact: DuplicateMatch[];\n similar: DuplicateMatch[];\n}> {\n const incoming = normalizeBody(message);\n const incomingTokens = tokenSet(incoming);\n const exactEligible = incoming.length >= MIN_DUP_BODY_LEN;\n const similarEligible = incomingTokens.size >= MIN_SIMILAR_TOKENS;\n // Too short for either tier — skip the scan entirely (nothing could match).\n if (!exactEligible && !similarEligible) return { exact: [], similar: [] };\n\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\"id\", \"status\", \"message\", \"site_id\", \"email\"])\n .where(\"message\", \"is not\", null)\n .where(\"submitted_at\", \">=\", sinceDate)\n .orderBy(\"submitted_at\", \"desc\")\n .limit(2000)\n .execute();\n\n const exact: DuplicateMatch[] = [];\n const similar: DuplicateMatch[] = [];\n for (const row of rows) {\n const stored = normalizeBody(row.message ?? \"\");\n const match: DuplicateMatch = {\n id: row.id,\n status: row.status,\n siteId: row.site_id,\n email: row.email,\n };\n if (exactEligible && stored === incoming) {\n exact.push(match);\n continue; // an exact match must not also appear in similar\n }\n if (!similarEligible) continue;\n const storedTokens = tokenSet(stored);\n if (storedTokens.size < MIN_SIMILAR_TOKENS) continue;\n if (jaccard(incomingTokens, storedTokens) >= SIMILARITY_THRESHOLD) {\n similar.push(match);\n }\n }\n return { exact, similar };\n}\n\n/** Recent non-newsletter submissions from `email` (case/whitespace-folded), on/after\n * `sinceDate` (ISO). Powers the cross-site repeat-sender signal: the fleet's sites\n * are UNRELATED businesses, so the same address writing to 2+ of them inside a month\n * is a solicitation tell — the caller compares siteIds (same-site repeats are genuine\n * follow-ups). Newsletter rows are excluded: one person subscribing on two sites is\n * legitimate. Both sides fold in SQL (same-side folding, see the normalizeBody note). */\nexport async function listRecentSubmissionsForEmail(\n db: Db,\n email: string,\n sinceDate: string,\n): Promise<Array<{ id: string; siteId: string; status: string }>> {\n if (email.trim() === \"\") return [];\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\"id\", \"site_id\", \"status\"])\n .where(\"form_type\", \"!=\", \"newsletter\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .where((eb) => sql<SqlBool>`lower(trim(${eb.ref(\"email\")})) = lower(trim(${email}))`)\n .execute();\n return rows.map((r) => ({ id: r.id, siteId: r.site_id, status: r.status }));\n}\n\n/** Retroactively re-bucket prior spray copies once a later copy identifies the spray\n * (the FIRST copy is always delivered by design — only a repeat reveals it). Appends\n * `retroReason` to any existing spam_reason so the original classifier trail survives.\n * The `status = 'new'` guard is load-bearing: rows the operator already read/replied/\n * archived/marked are NEVER touched — this only cleans the unread queue. */\nexport async function markSubmissionsSpamRetro(\n db: Db,\n ids: string[],\n retroReason: string,\n): Promise<void> {\n if (ids.length === 0) return;\n await db\n .updateTable(\"submissions\")\n .set({\n status: \"spam_auto\",\n spam_reason: sql<string>`CASE WHEN spam_reason IS NULL OR spam_reason = '' THEN ${retroReason} ELSE spam_reason || ',' || ${retroReason} END`,\n })\n .where(\"id\", \"in\", ids)\n .where(\"status\", \"=\", \"new\")\n .execute();\n}\n\n/** spam_reason strings for EVERY row matching `filter` (not just one page). Powers the\n * /submissions per-reason facet line, which must tally the WHOLE filtered bucket — the\n * rollout runbook directs the operator to judge the canary from it, and a page-scoped\n * tally under the full-bucket total silently misread once the bucket passed one page.\n * Capped (default 2000) as a safety bound far above any real bucket. */\nexport async function listSpamReasonsFiltered(\n db: Db,\n filter: SubmissionFilter,\n cap = 2000,\n): Promise<string[]> {\n const base = db.selectFrom(\"submissions\").select([\"spam_reason\"]);\n const rows = await applySubmissionFilter(base, filter)\n .where(\"spam_reason\", \"is not\", null)\n .limit(cap)\n .execute();\n return rows.map((r) => r.spam_reason).filter((r): r is string => r !== null && r !== \"\");\n}\n\n/** Insert a SubmissionRow verbatim, preserving its id, display number, and status.\n * ON CONFLICT(id) DO NOTHING makes the whole backfill re-runnable. */\nexport async function backfillSubmission(db: Db, row: SubmissionRow): Promise<void> {\n await db\n .insertInto(\"submissions\")\n .values({\n id: row.id,\n submission_id: row.submissionId,\n site_id: row.siteId,\n form_type: row.formType,\n name: row.name,\n email: row.email,\n phone: row.phone,\n message: row.message,\n extra_fields: row.extraFields,\n source_url: row.sourceUrl,\n utm: row.utm,\n submitted_at: row.submittedAt,\n status: row.status,\n notify_status: row.notifyStatus,\n resend_message_id: row.resendMessageId,\n spam_score: row.spamScore ?? null,\n spam_reason: row.spamReason ?? null,\n fanout_status: row.fanoutStatus ?? null,\n bounce_type: row.bounceType ?? null,\n bounce_subtype: row.bounceSubType ?? null,\n bounce_message: row.bounceMessage ?? null,\n bounce_ack_at: row.bounceAckAt ?? null,\n })\n .onConflict((oc) => oc.column(\"id\").doNothing())\n .execute();\n}\n\n/** Re-score write for the `submissions rescore` CLI (2026-07-16): flip ONE still-'new'\n * row to spam_auto with the CURRENT classifier's verdict. Unlike markSubmissionsSpamRetro\n * (which appends to the old trail), this REPLACES spam_score/spam_reason — the point of a\n * re-score is that today's verdict supersedes the stale ingest-time one; the caller bakes\n * its provenance marker (retro-rescore) into `reason`. The status='new' guard is\n * load-bearing: rows the operator already read/replied/archived/marked are NEVER\n * re-bucketed. Returns true when the row was written (false = no longer 'new'). */\nexport async function rescoreSubmissionSpam(\n db: Db,\n id: string,\n score: number,\n reason: string,\n): Promise<boolean> {\n const res = await db\n .updateTable(\"submissions\")\n .set({ status: \"spam_auto\", spam_score: score, spam_reason: reason })\n .where(\"id\", \"=\", id)\n .where(\"status\", \"=\", \"new\")\n .executeTakeFirst();\n return Number(res.numUpdatedRows) > 0;\n}\n\n/** Bulk triage for the /submissions page (2026-07-16): flip EVERY still-'new' row\n * matching `filter` to 'read', server-side, so a 100-row backlog is one click instead\n * of 100. Reuses applySubmissionFilter (via an id-subquery — kysely's update builder\n * can't take the select-only helper directly) so the POST flips exactly the bucket the\n * GET rendered. The outer status='new' guard is load-bearing: spam/archived/read rows\n * are never touched even when the filter matches them (bulk-\"reading\" a spam_auto row\n * would resurrect it into the per-site strip). Returns the number of rows flipped. */\nexport async function markFilteredAsRead(db: Db, filter: SubmissionFilter): Promise<number> {\n const res = await db\n .updateTable(\"submissions\")\n .set({ status: \"read\" })\n .where(\"status\", \"=\", \"new\")\n .where(\"id\", \"in\", (eb) =>\n applySubmissionFilter(eb.selectFrom(\"submissions\").select(\"id\"), filter),\n )\n .executeTakeFirst();\n return Number(res.numUpdatedRows);\n}\n\n/** Flip a submission's notify_status to 'bounced' by its Resend message id — the\n * resend-webhook's mapping from a bounce/complaint event back onto the lead whose\n * notification it was (2026-07-16, the Espada failure mode: 'sent' only means Resend\n * ACCEPTED the email). Returns whether any row matched, so the webhook can tell a\n * submission notification apart from a report email (unknown id → false → the report\n * path handles it). Idempotent: a svix replay re-writes the same terminal value. In\n * practice only 'sent' rows can match — stampNotified stamps the message id and the\n * 'sent' status together, and failed/skipped rows carry no id. */\nexport async function markNotifyBouncedByMessageId(\n db: Db,\n messageId: string,\n /** Resend's `data.bounce`, parsed (#783). Null for a complaint, which carries\n * no bounce object, and for any payload whose shape we did not recognize.\n *\n * A null detail deliberately does NOT clear a classification already stored:\n * a svix replay of the same event, or a later complaint on a message that\n * already bounced, must not erase the diagnosis the first delivery carried. */\n detail: BounceDetail | null = null,\n): Promise<boolean> {\n if (messageId === \"\") return false;\n const res = await db\n .updateTable(\"submissions\")\n .set(\n detail === null\n ? { notify_status: \"bounced\" }\n : {\n notify_status: \"bounced\",\n bounce_type: detail.type,\n bounce_subtype: detail.subType,\n bounce_message: detail.message,\n },\n )\n .where(\"resend_message_id\", \"=\", messageId)\n .executeTakeFirst();\n if (Number(res.numUpdatedRows) > 0) return true;\n // A replayed webhook must still report \"this was a submission\" (200, stop svix\n // retrying) even though the terminal value is already written — matched-but-\n // unchanged rows still count in numUpdatedRows on SQLite, but don't rely on it.\n const existing = await db\n .selectFrom(\"submissions\")\n .select(\"id\")\n .where(\"resend_message_id\", \"=\", messageId)\n .executeTakeFirst();\n return existing !== undefined;\n}\n\n/** Acknowledge ONE bounced notification as \"not a dead address\" (#783).\n *\n * The row keeps its bounce record — status, type, sub-type and the receiving\n * server's message all stay — and simply stops counting toward the alarm. That\n * is the difference from the production workaround this replaces, which flipped\n * `bounced` back to `sent` by id and destroyed the evidence along with the\n * alarm.\n *\n * Guarded on `notify_status = 'bounced'` (there is nothing to acknowledge on a\n * healthy row) and on `bounce_ack_at IS NULL`, which makes it idempotent and\n * keeps the FIRST acknowledgement's timestamp — the honest one, since that is\n * when the operator actually looked. Returns whether this call was the one that\n * wrote. A PERMANENT bounce is deliberately still ackable: the ack means \"I\n * have looked at this\", not \"this was a false alarm\", and refusing it would\n * leave no way to close the case after genuinely fixing the mailbox. */\nexport async function ackNotifyBounce(db: Db, id: string, now: Date): Promise<boolean> {\n const res = await db\n .updateTable(\"submissions\")\n .set({ bounce_ack_at: now.toISOString() })\n .where(\"id\", \"=\", id)\n .where(\"notify_status\", \"=\", \"bounced\")\n .where(\"bounce_ack_at\", \"is\", null)\n .executeTakeFirst();\n return Number(res.numUpdatedRows) > 0;\n}\n\n/** Per-site bounce counts, split by whether the payload said the ADDRESS is bad. */\nexport type NotifyBounceCounts = {\n /** Unacknowledged bounced notifications in the window. */\n total: number;\n /** How many of those Resend classified `Permanent` (#783). Counted ONLY where\n * the payload actually said so: a null classification — every row written\n * before migration 0018, and every complaint — counts toward `total` and\n * never toward this. Reading null as permanent would keep accusing the\n * address on exactly the rows that caused the false alarm. */\n permanent: number;\n};\n\n/** Per-site counts of bounced lead notifications on/after `sinceDate` (ISO), keyed by\n * the Websites record id (`site_id`). Powers the notify-bounce attention collector —\n * the caller picks the window (like `countAutoSpamSince`), keeping this query pure.\n * Windowed on `submitted_at`: the bounce lands minutes after the submission, and\n * submissions carry no bounce timestamp column (append-only schema, 2026-07-16).\n *\n * Acknowledged rows are excluded outright (#783), which is what lets a diagnosed\n * false alarm clear the same day instead of aging out over fourteen. A site whose\n * every bounce is acked drops out of the map entirely, exactly like a site that\n * never bounced — both are \"nothing to raise\". */\nexport async function countNotifyBouncedBySite(\n db: Db,\n sinceDate: string,\n): Promise<Map<string, NotifyBounceCounts>> {\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\n \"site_id\",\n (eb) => eb.fn.countAll<number>().as(\"n\"),\n // lower() is ASCII-only in SQLite, which is all this needs: the value is a\n // Resend enum (\"Permanent\"/\"Transient\"/\"Undetermined\"). Matching it\n // case-insensitively here keeps the SQL agreeing with isPermanentBounce.\n sql<number>`sum(case when lower(bounce_type) = 'permanent' then 1 else 0 end)`.as(\n \"permanent_n\",\n ),\n ])\n .where(\"notify_status\", \"=\", \"bounced\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .where(\"bounce_ack_at\", \"is\", null)\n .groupBy(\"site_id\")\n .execute();\n return new Map(\n rows.map((r) => [\n r.site_id,\n { total: Number(r.n) || 0, permanent: Number(r.permanent_n) || 0 },\n ]),\n );\n}\n\n/** The same counts for ONE site — the `/s/:slug` page's bounce chip (MED-11).\n *\n * Identical predicates to `countNotifyBouncedBySite`, plus `site_id = ?` and no\n * GROUP BY, so `idx_submissions_site_submitted (site_id, submitted_at DESC)`\n * serves it as a SEARCH rather than a traversal of the whole table. The site\n * page used to call the fleet-wide version and `.get(site.id)` the answer out:\n * one site's chip cost a GROUP BY over every lead the fleet has ever received,\n * on the one table that grows without bound, on every page load.\n *\n * A site with nothing to raise returns zeroes rather than dropping out of a\n * map — the single-site caller has no map to be absent from, and `{0, 0}` and\n * \"no row\" mean the same thing to the collector. */\nexport async function countNotifyBouncedForSite(\n db: Db,\n siteId: string,\n sinceDate: string,\n): Promise<NotifyBounceCounts> {\n const row = await db\n .selectFrom(\"submissions\")\n .select([\n (eb) => eb.fn.countAll<number>().as(\"n\"),\n sql<number>`sum(case when lower(bounce_type) = 'permanent' then 1 else 0 end)`.as(\n \"permanent_n\",\n ),\n ])\n .where(\"site_id\", \"=\", siteId)\n .where(\"notify_status\", \"=\", \"bounced\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .where(\"bounce_ack_at\", \"is\", null)\n .executeTakeFirst();\n return { total: Number(row?.n) || 0, permanent: Number(row?.permanent_n) || 0 };\n}\n"],"mappings":"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","names":[]}
@@ -10,7 +10,7 @@ function parseExtraFields(raw) {
10
10
  }
11
11
  }
12
12
  async function rescoreNewSubmissions(db, opts) {
13
- const { listSubmissionsFiltered, rescoreSubmissionSpam } = await import("./submissions-JDZP35JM.js");
13
+ const { listSubmissionsFiltered, rescoreSubmissionSpam } = await import("./submissions-4I66W6QK.js");
14
14
  const { classifySpam, SPAM_THRESHOLD } = await import("./spam-classifier-T5H4VTP6.js");
15
15
  const rows = [];
16
16
  const PAGE = 500;
@@ -40,7 +40,7 @@ async function rescoreNewSubmissions(db, opts) {
40
40
  }
41
41
  async function loadSiteNames() {
42
42
  try {
43
- const { openDb, readDbConfig } = await import("./client-DGTO65WB.js");
43
+ const { openDb, readDbConfig } = await import("./client-3XGSZMLU.js");
44
44
  const { listSites } = await import("./fleet-state-RJROYXKO.js");
45
45
  const db = await openDb(readDbConfig());
46
46
  try {
@@ -83,7 +83,7 @@ async function runSubmissionsCommand(action, opts) {
83
83
  return { output: "--apply and --dry-run conflict. Pick one.", code: 2 };
84
84
  }
85
85
  const apply = opts.apply === true;
86
- const { openDb, readDbConfig } = await import("./client-DGTO65WB.js");
86
+ const { openDb, readDbConfig } = await import("./client-3XGSZMLU.js");
87
87
  const { SPAM_THRESHOLD } = await import("./spam-classifier-T5H4VTP6.js");
88
88
  const cfg = opts.url ? { url: opts.url } : readDbConfig();
89
89
  const db = await openDb(cfg);
@@ -124,4 +124,4 @@ export {
124
124
  rescoreNewSubmissions,
125
125
  runSubmissionsCommand
126
126
  };
127
- //# sourceMappingURL=submissions-2SHCFOKD.js.map
127
+ //# sourceMappingURL=submissions-K5UXGZUN.js.map
@@ -8,7 +8,7 @@ import {
8
8
  } from "./chunk-DYTYPKFN.js";
9
9
  import {
10
10
  resolveSites
11
- } from "./chunk-FP6HOW6J.js";
11
+ } from "./chunk-S2PLX4XK.js";
12
12
  import {
13
13
  svelteCodemods
14
14
  } from "./chunk-HSMN35LU.js";
@@ -51,4 +51,4 @@ async function runSvelteCodemodsCommand(site, opts) {
51
51
  export {
52
52
  runSvelteCodemodsCommand
53
53
  };
54
- //# sourceMappingURL=svelte-codemods-VOLOKKFI.js.map
54
+ //# sourceMappingURL=svelte-codemods-YFN4TEGV.js.map
@@ -8,7 +8,7 @@ import {
8
8
  } from "./chunk-DYTYPKFN.js";
9
9
  import {
10
10
  resolveSites
11
- } from "./chunk-FP6HOW6J.js";
11
+ } from "./chunk-S2PLX4XK.js";
12
12
  import {
13
13
  ALL_CONFIG_NAMES,
14
14
  CANONICAL_GITIGNORE_ENTRIES,
@@ -107,4 +107,4 @@ async function runSyncConfigsCommand(site, opts) {
107
107
  export {
108
108
  runSyncConfigsCommand
109
109
  };
110
- //# sourceMappingURL=sync-configs-OXSWKVZ2.js.map
110
+ //# sourceMappingURL=sync-configs-HBXS3D7C.js.map
@@ -8,7 +8,7 @@ import {
8
8
  } from "./chunk-DYTYPKFN.js";
9
9
  import {
10
10
  resolveSites
11
- } from "./chunk-FP6HOW6J.js";
11
+ } from "./chunk-S2PLX4XK.js";
12
12
  import {
13
13
  upgradeSvelte4to5
14
14
  } from "./chunk-JHJNNJLG.js";
@@ -60,4 +60,4 @@ async function runUpgradeCommand(upgradeName, site, opts = {}) {
60
60
  export {
61
61
  runUpgradeCommand
62
62
  };
63
- //# sourceMappingURL=upgrade-EVAL3JTO.js.map
63
+ //# sourceMappingURL=upgrade-XOJMDIT4.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reddoorla/maintenance",
3
- "version": "0.97.0",
3
+ "version": "0.98.0",
4
4
  "description": "Canonical maintenance configs, audits, and recipes for the reddoor stack.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/db/deadletter.ts"],"sourcesContent":["import type { Db } from \"./client.js\";\nimport type { TurnstileVerification } from \"../forms/turnstile.js\";\n\n/** One captured-but-unresolved lead: the site lookup threw before the normal\n * pipeline could run (Phase 0 of #539 — persist before enrich). */\nexport type DeadLetterRow = {\n id: string;\n siteSlug: string;\n /** The raw wire payload, exactly as the handler received it. */\n payload: unknown;\n /** The Turnstile verification computed at receipt — tokens expire in 300s,\n * so replay reuses this answer rather than re-verifying. */\n turnstile: TurnstileVerification;\n error: string;\n receivedAt: string;\n};\n\nexport type DeadLetterInput = {\n siteSlug: string;\n payload: unknown;\n turnstile: TurnstileVerification;\n error: string;\n receivedAt: Date;\n};\n\n/** Opaque, collision-free id (mirrors newSubmissionId). */\nexport function newDeadLetterId(): string {\n return `dl_${crypto.randomUUID()}`;\n}\n\n/** Persist a lead the normal pipeline could not place. This is the LAST writer\n * on the ingest path — if it throws too, both stores are down and the caller's\n * 502 is honest. */\nexport async function createDeadLetter(db: Db, input: DeadLetterInput): Promise<{ id: string }> {\n const id = newDeadLetterId();\n await db\n .insertInto(\"submission_deadletter\")\n .values({\n id,\n site_slug: input.siteSlug,\n payload: JSON.stringify(input.payload),\n turnstile: JSON.stringify(input.turnstile),\n error: input.error,\n received_at: input.receivedAt.toISOString(),\n replayed_at: null,\n replay_outcome: null,\n replay_submission_id: null,\n })\n .execute();\n return { id };\n}\n\n/** Rows not yet replayed, oldest first — replay preserves arrival order so the\n * duplicate/velocity signals see submissions in the order they happened. */\nexport async function listUnreplayedDeadLetters(db: Db): Promise<DeadLetterRow[]> {\n const rows = await db\n .selectFrom(\"submission_deadletter\")\n .select([\"id\", \"site_slug\", \"payload\", \"turnstile\", \"error\", \"received_at\"])\n .where(\"replayed_at\", \"is\", null)\n // #786: an abandoned row is terminal by decision — it must leave the replay\n // queue, or the command it was meant to release stays at exit 1 forever.\n .where(\"abandoned_at\", \"is\", null)\n .orderBy(\"received_at\", \"asc\")\n .execute();\n return rows.map((r) => ({\n id: r.id,\n siteSlug: r.site_slug,\n payload: JSON.parse(r.payload) as unknown,\n turnstile: JSON.parse(r.turnstile) as TurnstileVerification,\n error: r.error,\n receivedAt: r.received_at,\n }));\n}\n\n/** Mark a row's replay TERMINAL — it will never be picked up again. Only call\n * for outcomes that re-running cannot improve (accepted, rejected). A replay\n * whose lookup threw again is left untouched so the next run retries it — and\n * since #645 so is `unknown-site`, because `ensure-site` can heal the missing\n * Turso row and make the same replay succeed. */\nexport async function markDeadLetterReplayed(\n db: Db,\n id: string,\n outcome: string,\n submissionId: string | null,\n now: Date,\n): Promise<void> {\n await db\n .updateTable(\"submission_deadletter\")\n .set({\n replayed_at: now.toISOString(),\n replay_outcome: outcome,\n replay_submission_id: submissionId,\n })\n .where(\"id\", \"=\", id)\n .execute();\n}\n\n/** Unreplayed rows per site slug — the input to the `deadletter` attention\n * collector (#645). Grouped in SQL rather than by listing and counting in JS:\n * the rows carry full lead payloads, and an alarm must never pull a client's\n * PII into a dashboard request just to learn how many rows there are. */\nexport async function countUnreplayedDeadLettersBySlug(\n db: Db,\n): Promise<ReadonlyMap<string, number>> {\n const rows = await db\n .selectFrom(\"submission_deadletter\")\n .select((eb) => [\"site_slug\", eb.fn.countAll<number>().as(\"n\")])\n .where(\"replayed_at\", \"is\", null)\n // #786: and out of the alarm count, which is the standing CRITICAL item.\n .where(\"abandoned_at\", \"is\", null)\n .groupBy(\"site_slug\")\n .execute();\n return new Map(rows.map((r) => [r.site_slug, Number(r.n)]));\n}\n\n/** Abandon dead letters as resolved-by-DECISION (#786).\n *\n * The escape hatch #785 left open. `unknown-site` is deliberately non-terminal\n * on replay so that healing and replaying are safe in either order — but that\n * means a slug which is genuinely dead, and still deployed and posting, grows\n * the queue without bound, holds `db replay-deadletters` at exit 1, and leaves\n * a standing CRITICAL cockpit item. The only alternative today is deleting\n * rows by hand, which destroys the very leads the decision was made about.\n *\n * Targets ONE slug or ONE row id, never \"everything\": a mistyped invocation\n * must not be able to write off the whole queue. Only rows that are still live\n * (neither replayed nor already abandoned) are touched, which makes the call\n * idempotent and keeps the FIRST decision — the one actually made — in place.\n * `reason` is required and non-blank, because an undocumented write-off is the\n * thing this exists to stop being necessary.\n *\n * Returns the ids it abandoned, so the CLI can name them rather than print a\n * bare count.\n */\nexport async function abandonDeadLetters(\n db: Db,\n opts: { slug?: string; id?: string; by: string; reason: string; now: Date },\n): Promise<string[]> {\n const bySlug = opts.slug !== undefined;\n const byId = opts.id !== undefined;\n if (bySlug === byId) {\n throw new Error(\"abandonDeadLetters: pass exactly one of slug or id\");\n }\n const reason = opts.reason.trim();\n if (reason === \"\") {\n throw new Error(\"abandonDeadLetters: a non-blank reason is required\");\n }\n\n // Read the target ids first so the caller can be told exactly what was\n // abandoned; the same predicate then guards the write.\n let q = db\n .selectFrom(\"submission_deadletter\")\n .select(\"id\")\n .where(\"replayed_at\", \"is\", null)\n .where(\"abandoned_at\", \"is\", null);\n q = bySlug ? q.where(\"site_slug\", \"=\", opts.slug!) : q.where(\"id\", \"=\", opts.id!);\n const ids = (await q.execute()).map((r) => r.id);\n if (ids.length === 0) return [];\n\n await db\n .updateTable(\"submission_deadletter\")\n .set({\n abandoned_at: opts.now.toISOString(),\n abandoned_by: opts.by,\n abandoned_reason: reason,\n })\n .where(\"id\", \"in\", ids)\n // Re-asserted on the write: between the read and here nothing else runs\n // (libSQL is single-writer), but the guard costs nothing and keeps the\n // write's meaning legible on its own.\n .where(\"replayed_at\", \"is\", null)\n .where(\"abandoned_at\", \"is\", null)\n .execute();\n return ids;\n}\n"],"mappings":"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","names":[]}