@reddoorla/maintenance 0.91.0 → 0.92.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{bump-deps-KGH5UYSF.js → bump-deps-GI7PHMOC.js} +6 -6
- package/dist/{chunk-A6T2R63Z.js → chunk-3HS6LPFX.js} +2 -2
- package/dist/{chunk-UXJZU23T.js → chunk-4NUKMPOO.js} +9 -9
- package/dist/{chunk-BQRFQSGY.js → chunk-4R3YAQNV.js} +2 -2
- package/dist/{chunk-JRHIFA5R.js → chunk-5XMKJX74.js} +10 -4
- package/dist/chunk-5XMKJX74.js.map +1 -0
- package/dist/{chunk-XIDUYLSR.js → chunk-7CJ3DVZW.js} +2 -2
- package/dist/{chunk-Z3KYFUN6.js → chunk-B6NUZ2ZX.js} +2 -2
- package/dist/{chunk-WVVT6SJK.js → chunk-BJQCIJOY.js} +169 -17
- package/dist/chunk-BJQCIJOY.js.map +1 -0
- package/dist/{chunk-Y53WOQIJ.js → chunk-CVJF2IC5.js} +2 -2
- package/dist/{chunk-QD427NIO.js → chunk-JB4Q3C3M.js} +7 -7
- package/dist/{chunk-IYSEC543.js → chunk-LS6QPANO.js} +2 -2
- package/dist/{chunk-SJOXWJ5Y.js → chunk-M2XF6JAI.js} +80 -9
- package/dist/chunk-M2XF6JAI.js.map +1 -0
- package/dist/{chunk-7RJ2HMMJ.js → chunk-NTLSMJHW.js} +2 -2
- package/dist/chunk-PJ23RDHT.js +48 -0
- package/dist/chunk-PJ23RDHT.js.map +1 -0
- package/dist/{chunk-2NJQLSEU.js → chunk-PPIZHDJL.js} +2 -2
- package/dist/{chunk-RQLXEMEE.js → chunk-T7ZUUOZ2.js} +2 -2
- package/dist/chunk-T7ZUUOZ2.js.map +1 -0
- package/dist/{chunk-TVL5MVUF.js → chunk-TCF6S7AD.js} +4 -4
- package/dist/{chunk-GSWTT2J6.js → chunk-WHWVYL7B.js} +4 -4
- package/dist/{chunk-3G25KIWW.js → chunk-ZTPPCHDB.js} +4 -4
- package/dist/cli/bin.js +17 -17
- package/dist/cli/commands/audit.js +10 -10
- package/dist/{convert-to-pnpm-NWB5FCQ2.js → convert-to-pnpm-ONFP3JKF.js} +6 -6
- package/dist/{db-IMMQSRAB.js → db-DJVW6ZSE.js} +3 -3
- package/dist/digest-EOA4JLTV.js +1 -1
- package/dist/digest-collectors-ZA3BTS3R.js +1 -1
- package/dist/{email-PLPDJUDH.js → email-I4BCETI4.js} +16 -65
- package/dist/email-I4BCETI4.js.map +1 -0
- package/dist/forms/index.d.ts +5 -1
- package/dist/forms/index.js +1 -1
- package/dist/forms/index.js.map +1 -1
- package/dist/forms/prismic.d.ts +69 -0
- package/dist/forms/prismic.js +75 -0
- package/dist/forms/prismic.js.map +1 -0
- package/dist/{forms-notify-target-YCZUAHTY.js → forms-notify-target-MNNOGSJ7.js} +4 -3
- package/dist/{forms-notify-target-YCZUAHTY.js.map → forms-notify-target-MNNOGSJ7.js.map} +1 -1
- package/dist/{health-endpoint-UVX6R5N5.js → health-endpoint-DHKJUPRM.js} +6 -6
- package/dist/index.js +30 -23
- package/dist/index.js.map +1 -1
- package/dist/{init-NXZ4NXTL.js → init-ZRKWYCKA.js} +15 -15
- package/dist/{launch-WPEQ3TY2.js → launch-6X5OXPF2.js} +11 -11
- package/dist/{notify-4HP4GZ2H.js → notify-FYSOWKMJ.js} +4 -3
- package/dist/{onboard-BKCYBAVW.js → onboard-6NHGCKGB.js} +6 -6
- package/dist/orchestrate-KMQIUP3H.js +1 -1
- package/dist/{pipeline-HQJYZ443.js → pipeline-AQBFG5WM.js} +5 -3
- package/dist/preflight-VPHQYWTY.js +1 -1
- package/dist/{prismic-ci-6YGRIOHT.js → prismic-ci-A2HI7NNJ.js} +8 -8
- package/dist/{prismic-models-FPDSUXDO.js → prismic-models-QTKIMFBC.js} +4 -4
- package/dist/prospect/types.d.ts +17 -0
- package/dist/prospect/types.js +1 -1
- package/dist/{prospect-audit-TFIXSTE5.js → prospect-audit-RKUVSTLN.js} +4 -4
- package/dist/recipes/sync-configs.js +3 -3
- package/dist/{chunk-H6BLTEFN.js → render-7G43BSK5.js} +3 -3
- package/dist/{chunk-H6BLTEFN.js.map → render-7G43BSK5.js.map} +1 -1
- package/dist/{replay-4S6ENYCH.js → replay-HV7MGKBU.js} +3 -2
- package/dist/{replay-4S6ENYCH.js.map → replay-HV7MGKBU.js.map} +1 -1
- package/dist/{self-updating-VQIW24RZ.js → self-updating-D5XOWS7V.js} +5 -5
- package/dist/selftest-IFGSJP4X.js +1 -1
- package/dist/{smoke-suite-4JFZZYVW.js → smoke-suite-GXC5JZPH.js} +6 -6
- package/dist/{svelte-codemods-QAPDZC6F.js → svelte-codemods-A3EARSRP.js} +6 -6
- package/dist/{sync-configs-PVRLZRKL.js → sync-configs-KSESOHFF.js} +6 -6
- package/dist/{upgrade-IR2OO3KP.js → upgrade-ZLNVEIEA.js} +6 -6
- package/dist/{webflow-624FEBFD.js → webflow-IRKOIT7S.js} +4 -4
- package/package.json +5 -1
- package/dist/chunk-JRHIFA5R.js.map +0 -1
- package/dist/chunk-RQLXEMEE.js.map +0 -1
- package/dist/chunk-SJOXWJ5Y.js.map +0 -1
- package/dist/chunk-WVVT6SJK.js.map +0 -1
- package/dist/email-PLPDJUDH.js.map +0 -1
- package/dist/render-3GYW2J34.js +0 -11
- package/dist/render-3GYW2J34.js.map +0 -1
- /package/dist/{bump-deps-KGH5UYSF.js.map → bump-deps-GI7PHMOC.js.map} +0 -0
- /package/dist/{chunk-A6T2R63Z.js.map → chunk-3HS6LPFX.js.map} +0 -0
- /package/dist/{chunk-UXJZU23T.js.map → chunk-4NUKMPOO.js.map} +0 -0
- /package/dist/{chunk-BQRFQSGY.js.map → chunk-4R3YAQNV.js.map} +0 -0
- /package/dist/{chunk-XIDUYLSR.js.map → chunk-7CJ3DVZW.js.map} +0 -0
- /package/dist/{chunk-Z3KYFUN6.js.map → chunk-B6NUZ2ZX.js.map} +0 -0
- /package/dist/{chunk-Y53WOQIJ.js.map → chunk-CVJF2IC5.js.map} +0 -0
- /package/dist/{chunk-QD427NIO.js.map → chunk-JB4Q3C3M.js.map} +0 -0
- /package/dist/{chunk-IYSEC543.js.map → chunk-LS6QPANO.js.map} +0 -0
- /package/dist/{chunk-7RJ2HMMJ.js.map → chunk-NTLSMJHW.js.map} +0 -0
- /package/dist/{chunk-2NJQLSEU.js.map → chunk-PPIZHDJL.js.map} +0 -0
- /package/dist/{chunk-TVL5MVUF.js.map → chunk-TCF6S7AD.js.map} +0 -0
- /package/dist/{chunk-GSWTT2J6.js.map → chunk-WHWVYL7B.js.map} +0 -0
- /package/dist/{chunk-3G25KIWW.js.map → chunk-ZTPPCHDB.js.map} +0 -0
- /package/dist/{convert-to-pnpm-NWB5FCQ2.js.map → convert-to-pnpm-ONFP3JKF.js.map} +0 -0
- /package/dist/{db-IMMQSRAB.js.map → db-DJVW6ZSE.js.map} +0 -0
- /package/dist/{health-endpoint-UVX6R5N5.js.map → health-endpoint-DHKJUPRM.js.map} +0 -0
- /package/dist/{init-NXZ4NXTL.js.map → init-ZRKWYCKA.js.map} +0 -0
- /package/dist/{launch-WPEQ3TY2.js.map → launch-6X5OXPF2.js.map} +0 -0
- /package/dist/{notify-4HP4GZ2H.js.map → notify-FYSOWKMJ.js.map} +0 -0
- /package/dist/{onboard-BKCYBAVW.js.map → onboard-6NHGCKGB.js.map} +0 -0
- /package/dist/{pipeline-HQJYZ443.js.map → pipeline-AQBFG5WM.js.map} +0 -0
- /package/dist/{prismic-ci-6YGRIOHT.js.map → prismic-ci-A2HI7NNJ.js.map} +0 -0
- /package/dist/{prismic-models-FPDSUXDO.js.map → prismic-models-QTKIMFBC.js.map} +0 -0
- /package/dist/{prospect-audit-TFIXSTE5.js.map → prospect-audit-RKUVSTLN.js.map} +0 -0
- /package/dist/{self-updating-VQIW24RZ.js.map → self-updating-D5XOWS7V.js.map} +0 -0
- /package/dist/{smoke-suite-4JFZZYVW.js.map → smoke-suite-GXC5JZPH.js.map} +0 -0
- /package/dist/{svelte-codemods-QAPDZC6F.js.map → svelte-codemods-A3EARSRP.js.map} +0 -0
- /package/dist/{sync-configs-PVRLZRKL.js.map → sync-configs-KSESOHFF.js.map} +0 -0
- /package/dist/{upgrade-IR2OO3KP.js.map → upgrade-ZLNVEIEA.js.map} +0 -0
- /package/dist/{webflow-624FEBFD.js.map → webflow-IRKOIT7S.js.map} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/db/deadletter.ts","../src/forms/replay.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 .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,\n * unknown-site). A replay whose lookup threw again is left untouched so the\n * next run retries it. */\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","import type { Db } from \"../db/client.js\";\nimport {\n listUnreplayedDeadLetters,\n markDeadLetterReplayed,\n type DeadLetterRow,\n} from \"../db/deadletter.js\";\nimport { ingestSubmission, type IngestDeps } from \"./ingest.js\";\n\nexport type ReplayResult = {\n /** Terminal outcomes this run: the row will never be picked up again. */\n replayed: Array<{ id: string; outcome: string; submissionId: string | null }>;\n /** Rows whose lookup threw AGAIN — left unreplayed for the next run. */\n stillFailing: Array<{ id: string; error: string }>;\n};\n\n/**\n * Run every unreplayed dead-letter back through `ingestSubmission` — the SAME\n * pipeline a live submission takes, so a replayed lead gets real spam\n * classification, notify, and fan-out, not a bespoke re-implementation.\n *\n * Each row replays with the Turnstile verification computed at receipt (tokens\n * expire in 300s; re-verifying is impossible by design, see deadletter.ts).\n *\n * `deps` must NOT wire `deadLetter`: a lookup that throws during replay would\n * otherwise dead-letter the SAME lead a second time while the original row stays\n * unreplayed — every retry would mint a duplicate. Enforced here by stripping the\n * key rather than trusting the caller. A thrown lookup leaves the row untouched\n * for the next run; every RESOLVED outcome (accepted, rejected, unknown-site) is\n * terminal — re-running cannot improve on an answer the store actually gave.\n *\n * Oldest-first (listUnreplayedDeadLetters orders by received_at) so the\n * duplicate/velocity spam signals see submissions in arrival order.\n */\nexport async function replayDeadLetters(\n db: Db,\n deps: Omit<IngestDeps, \"deadLetter\">,\n): Promise<ReplayResult> {\n const rows = await listUnreplayedDeadLetters(db);\n const result: ReplayResult = { replayed: [], stillFailing: [] };\n\n for (const row of rows) {\n const outcome = await replayOne(db, deps, row);\n if (outcome.terminal) {\n result.replayed.push({\n id: row.id,\n outcome: outcome.outcome,\n submissionId: outcome.submissionId,\n });\n } else {\n result.stillFailing.push({ id: row.id, error: outcome.error });\n }\n }\n return result;\n}\n\nasync function replayOne(\n db: Db,\n deps: Omit<IngestDeps, \"deadLetter\">,\n row: DeadLetterRow,\n): Promise<\n | { terminal: true; outcome: string; submissionId: string | null }\n | { terminal: false; error: string }\n> {\n // Strip deadLetter defensively even though the type forbids it — a structurally\n // wider object passed through `as` must not re-arm the duplicate loop.\n const { deadLetter: _stripped, ...safeDeps } = deps as IngestDeps;\n void _stripped;\n let res;\n try {\n res = await ingestSubmission(safeDeps, row.siteSlug, row.payload, row.turnstile);\n } catch (err) {\n return { terminal: false, error: String(err) };\n }\n const now = deps.now();\n if (res.status === \"accepted\") {\n await markDeadLetterReplayed(db, row.id, \"accepted\", res.submissionId, now);\n return { terminal: true, outcome: \"accepted\", submissionId: res.submissionId };\n }\n const outcome = res.status === \"rejected\" ? `rejected:${res.reason}` : \"unknown-site\";\n await markDeadLetterReplayed(db, row.id, outcome, null, now);\n return { terminal: true, outcome, submissionId: null };\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"sources":["../src/db/deadletter.ts","../src/forms/replay.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 .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,\n * unknown-site). A replay whose lookup threw again is left untouched so the\n * next run retries it. */\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","import type { Db } from \"../db/client.js\";\nimport {\n listUnreplayedDeadLetters,\n markDeadLetterReplayed,\n type DeadLetterRow,\n} from \"../db/deadletter.js\";\nimport { ingestSubmission, type IngestDeps } from \"./ingest.js\";\n\nexport type ReplayResult = {\n /** Terminal outcomes this run: the row will never be picked up again. */\n replayed: Array<{ id: string; outcome: string; submissionId: string | null }>;\n /** Rows whose lookup threw AGAIN — left unreplayed for the next run. */\n stillFailing: Array<{ id: string; error: string }>;\n};\n\n/**\n * Run every unreplayed dead-letter back through `ingestSubmission` — the SAME\n * pipeline a live submission takes, so a replayed lead gets real spam\n * classification, notify, and fan-out, not a bespoke re-implementation.\n *\n * Each row replays with the Turnstile verification computed at receipt (tokens\n * expire in 300s; re-verifying is impossible by design, see deadletter.ts).\n *\n * `deps` must NOT wire `deadLetter`: a lookup that throws during replay would\n * otherwise dead-letter the SAME lead a second time while the original row stays\n * unreplayed — every retry would mint a duplicate. Enforced here by stripping the\n * key rather than trusting the caller. A thrown lookup leaves the row untouched\n * for the next run; every RESOLVED outcome (accepted, rejected, unknown-site) is\n * terminal — re-running cannot improve on an answer the store actually gave.\n *\n * Oldest-first (listUnreplayedDeadLetters orders by received_at) so the\n * duplicate/velocity spam signals see submissions in arrival order.\n */\nexport async function replayDeadLetters(\n db: Db,\n deps: Omit<IngestDeps, \"deadLetter\">,\n): Promise<ReplayResult> {\n const rows = await listUnreplayedDeadLetters(db);\n const result: ReplayResult = { replayed: [], stillFailing: [] };\n\n for (const row of rows) {\n const outcome = await replayOne(db, deps, row);\n if (outcome.terminal) {\n result.replayed.push({\n id: row.id,\n outcome: outcome.outcome,\n submissionId: outcome.submissionId,\n });\n } else {\n result.stillFailing.push({ id: row.id, error: outcome.error });\n }\n }\n return result;\n}\n\nasync function replayOne(\n db: Db,\n deps: Omit<IngestDeps, \"deadLetter\">,\n row: DeadLetterRow,\n): Promise<\n | { terminal: true; outcome: string; submissionId: string | null }\n | { terminal: false; error: string }\n> {\n // Strip deadLetter defensively even though the type forbids it — a structurally\n // wider object passed through `as` must not re-arm the duplicate loop.\n const { deadLetter: _stripped, ...safeDeps } = deps as IngestDeps;\n void _stripped;\n let res;\n try {\n res = await ingestSubmission(safeDeps, row.siteSlug, row.payload, row.turnstile);\n } catch (err) {\n return { terminal: false, error: String(err) };\n }\n const now = deps.now();\n if (res.status === \"accepted\") {\n await markDeadLetterReplayed(db, row.id, \"accepted\", res.submissionId, now);\n return { terminal: true, outcome: \"accepted\", submissionId: res.submissionId };\n }\n const outcome = res.status === \"rejected\" ? `rejected:${res.reason}` : \"unknown-site\";\n await markDeadLetterReplayed(db, row.id, outcome, null, now);\n return { terminal: true, outcome, submissionId: null };\n}\n"],"mappings":";;;;;;;;AAsDA,eAAsB,0BAA0B,IAAkC;AAChF,QAAM,OAAO,MAAM,GAChB,WAAW,uBAAuB,EAClC,OAAO,CAAC,MAAM,aAAa,WAAW,aAAa,SAAS,aAAa,CAAC,EAC1E,MAAM,eAAe,MAAM,IAAI,EAC/B,QAAQ,eAAe,KAAK,EAC5B,QAAQ;AACX,SAAO,KAAK,IAAI,CAAC,OAAO;AAAA,IACtB,IAAI,EAAE;AAAA,IACN,UAAU,EAAE;AAAA,IACZ,SAAS,KAAK,MAAM,EAAE,OAAO;AAAA,IAC7B,WAAW,KAAK,MAAM,EAAE,SAAS;AAAA,IACjC,OAAO,EAAE;AAAA,IACT,YAAY,EAAE;AAAA,EAChB,EAAE;AACJ;AAMA,eAAsB,uBACpB,IACA,IACA,SACA,cACA,KACe;AACf,QAAM,GACH,YAAY,uBAAuB,EACnC,IAAI;AAAA,IACH,aAAa,IAAI,YAAY;AAAA,IAC7B,gBAAgB;AAAA,IAChB,sBAAsB;AAAA,EACxB,CAAC,EACA,MAAM,MAAM,KAAK,EAAE,EACnB,QAAQ;AACb;;;AC1DA,eAAsB,kBACpB,IACA,MACuB;AACvB,QAAM,OAAO,MAAM,0BAA0B,EAAE;AAC/C,QAAM,SAAuB,EAAE,UAAU,CAAC,GAAG,cAAc,CAAC,EAAE;AAE9D,aAAW,OAAO,MAAM;AACtB,UAAM,UAAU,MAAM,UAAU,IAAI,MAAM,GAAG;AAC7C,QAAI,QAAQ,UAAU;AACpB,aAAO,SAAS,KAAK;AAAA,QACnB,IAAI,IAAI;AAAA,QACR,SAAS,QAAQ;AAAA,QACjB,cAAc,QAAQ;AAAA,MACxB,CAAC;AAAA,IACH,OAAO;AACL,aAAO,aAAa,KAAK,EAAE,IAAI,IAAI,IAAI,OAAO,QAAQ,MAAM,CAAC;AAAA,IAC/D;AAAA,EACF;AACA,SAAO;AACT;AAEA,eAAe,UACb,IACA,MACA,KAIA;AAGA,QAAM,EAAE,YAAY,WAAW,GAAG,SAAS,IAAI;AAC/C,OAAK;AACL,MAAI;AACJ,MAAI;AACF,UAAM,MAAM,iBAAiB,UAAU,IAAI,UAAU,IAAI,SAAS,IAAI,SAAS;AAAA,EACjF,SAAS,KAAK;AACZ,WAAO,EAAE,UAAU,OAAO,OAAO,OAAO,GAAG,EAAE;AAAA,EAC/C;AACA,QAAM,MAAM,KAAK,IAAI;AACrB,MAAI,IAAI,WAAW,YAAY;AAC7B,UAAM,uBAAuB,IAAI,IAAI,IAAI,YAAY,IAAI,cAAc,GAAG;AAC1E,WAAO,EAAE,UAAU,MAAM,SAAS,YAAY,cAAc,IAAI,aAAa;AAAA,EAC/E;AACA,QAAM,UAAU,IAAI,WAAW,aAAa,YAAY,IAAI,MAAM,KAAK;AACvE,QAAM,uBAAuB,IAAI,IAAI,IAAI,SAAS,MAAM,GAAG;AAC3D,SAAO,EAAE,UAAU,MAAM,SAAS,cAAc,KAAK;AACvD;","names":[]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
selfUpdating
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-WHWVYL7B.js";
|
|
4
4
|
import "./chunk-MNDIED4M.js";
|
|
5
5
|
import {
|
|
6
6
|
runRecipeOverSites
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
appendSkipNotice,
|
|
10
10
|
fleetWorkdir,
|
|
11
11
|
prepareFleetSites
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-TCF6S7AD.js";
|
|
13
13
|
import {
|
|
14
14
|
resolveSites
|
|
15
15
|
} from "./chunk-Q47DU255.js";
|
|
@@ -17,10 +17,10 @@ import "./chunk-MLT4QY5X.js";
|
|
|
17
17
|
import "./chunk-GASBX52O.js";
|
|
18
18
|
import "./chunk-XHHMM5HX.js";
|
|
19
19
|
import "./chunk-Z3JXLULD.js";
|
|
20
|
-
import "./chunk-RARREDQE.js";
|
|
21
|
-
import "./chunk-XTK5VIQB.js";
|
|
22
20
|
import "./chunk-LBYOLBW7.js";
|
|
21
|
+
import "./chunk-RARREDQE.js";
|
|
23
22
|
import "./chunk-XXTZBPUY.js";
|
|
23
|
+
import "./chunk-XTK5VIQB.js";
|
|
24
24
|
|
|
25
25
|
// src/cli/commands/self-updating.ts
|
|
26
26
|
import { resolve } from "path";
|
|
@@ -62,4 +62,4 @@ async function runSelfUpdatingCommand(site, opts) {
|
|
|
62
62
|
export {
|
|
63
63
|
runSelfUpdatingCommand
|
|
64
64
|
};
|
|
65
|
-
//# sourceMappingURL=self-updating-
|
|
65
|
+
//# sourceMappingURL=self-updating-D5XOWS7V.js.map
|
|
@@ -42,8 +42,8 @@ import {
|
|
|
42
42
|
} from "./chunk-WEKL2HYJ.js";
|
|
43
43
|
import "./chunk-DMPHP7UP.js";
|
|
44
44
|
import "./chunk-22GKJO5F.js";
|
|
45
|
-
import "./chunk-RARREDQE.js";
|
|
46
45
|
import "./chunk-OCNSIBMP.js";
|
|
46
|
+
import "./chunk-RARREDQE.js";
|
|
47
47
|
import "./chunk-6VAL7XJT.js";
|
|
48
48
|
|
|
49
49
|
// src/recipes/selftest-email.ts
|
|
@@ -5,20 +5,20 @@ import {
|
|
|
5
5
|
appendSkipNotice,
|
|
6
6
|
fleetWorkdir,
|
|
7
7
|
prepareFleetSites
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-TCF6S7AD.js";
|
|
9
9
|
import {
|
|
10
10
|
resolveSites
|
|
11
11
|
} from "./chunk-Q47DU255.js";
|
|
12
12
|
import {
|
|
13
13
|
smokeSuite
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-CVJF2IC5.js";
|
|
15
15
|
import "./chunk-R53VL3RI.js";
|
|
16
|
-
import "./chunk-
|
|
16
|
+
import "./chunk-ZTPPCHDB.js";
|
|
17
17
|
import "./chunk-Z3JXLULD.js";
|
|
18
|
-
import "./chunk-RARREDQE.js";
|
|
19
|
-
import "./chunk-XTK5VIQB.js";
|
|
20
18
|
import "./chunk-LBYOLBW7.js";
|
|
19
|
+
import "./chunk-RARREDQE.js";
|
|
21
20
|
import "./chunk-XXTZBPUY.js";
|
|
21
|
+
import "./chunk-XTK5VIQB.js";
|
|
22
22
|
|
|
23
23
|
// src/cli/commands/smoke-suite.ts
|
|
24
24
|
import { resolve } from "path";
|
|
@@ -51,4 +51,4 @@ async function runSmokeSuiteCommand(site, opts) {
|
|
|
51
51
|
export {
|
|
52
52
|
runSmokeSuiteCommand
|
|
53
53
|
};
|
|
54
|
-
//# sourceMappingURL=smoke-suite-
|
|
54
|
+
//# sourceMappingURL=smoke-suite-GXC5JZPH.js.map
|
|
@@ -5,20 +5,20 @@ import {
|
|
|
5
5
|
appendSkipNotice,
|
|
6
6
|
fleetWorkdir,
|
|
7
7
|
prepareFleetSites
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-TCF6S7AD.js";
|
|
9
9
|
import {
|
|
10
10
|
resolveSites
|
|
11
11
|
} from "./chunk-Q47DU255.js";
|
|
12
12
|
import {
|
|
13
13
|
svelteCodemods
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-LS6QPANO.js";
|
|
15
15
|
import "./chunk-J7SZQUCW.js";
|
|
16
|
-
import "./chunk-
|
|
16
|
+
import "./chunk-ZTPPCHDB.js";
|
|
17
17
|
import "./chunk-Z3JXLULD.js";
|
|
18
|
-
import "./chunk-RARREDQE.js";
|
|
19
|
-
import "./chunk-XTK5VIQB.js";
|
|
20
18
|
import "./chunk-LBYOLBW7.js";
|
|
19
|
+
import "./chunk-RARREDQE.js";
|
|
21
20
|
import "./chunk-XXTZBPUY.js";
|
|
21
|
+
import "./chunk-XTK5VIQB.js";
|
|
22
22
|
|
|
23
23
|
// src/cli/commands/svelte-codemods.ts
|
|
24
24
|
import { resolve } from "path";
|
|
@@ -51,4 +51,4 @@ async function runSvelteCodemodsCommand(site, opts) {
|
|
|
51
51
|
export {
|
|
52
52
|
runSvelteCodemodsCommand
|
|
53
53
|
};
|
|
54
|
-
//# sourceMappingURL=svelte-codemods-
|
|
54
|
+
//# sourceMappingURL=svelte-codemods-A3EARSRP.js.map
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
appendSkipNotice,
|
|
6
6
|
fleetWorkdir,
|
|
7
7
|
prepareFleetSites
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-TCF6S7AD.js";
|
|
9
9
|
import {
|
|
10
10
|
resolveSites
|
|
11
11
|
} from "./chunk-Q47DU255.js";
|
|
@@ -16,17 +16,17 @@ import {
|
|
|
16
16
|
mergeGitignore,
|
|
17
17
|
planTemplateDiffs,
|
|
18
18
|
syncConfigs
|
|
19
|
-
} from "./chunk-
|
|
20
|
-
import "./chunk-
|
|
19
|
+
} from "./chunk-3HS6LPFX.js";
|
|
20
|
+
import "./chunk-ZTPPCHDB.js";
|
|
21
21
|
import {
|
|
22
22
|
ALL_TEMPLATES,
|
|
23
23
|
templatesByName
|
|
24
24
|
} from "./chunk-MLT4QY5X.js";
|
|
25
25
|
import "./chunk-Z3JXLULD.js";
|
|
26
|
-
import "./chunk-RARREDQE.js";
|
|
27
|
-
import "./chunk-XTK5VIQB.js";
|
|
28
26
|
import "./chunk-LBYOLBW7.js";
|
|
27
|
+
import "./chunk-RARREDQE.js";
|
|
29
28
|
import "./chunk-XXTZBPUY.js";
|
|
29
|
+
import "./chunk-XTK5VIQB.js";
|
|
30
30
|
|
|
31
31
|
// src/cli/commands/sync-configs.ts
|
|
32
32
|
import { readFile } from "fs/promises";
|
|
@@ -107,4 +107,4 @@ async function runSyncConfigsCommand(site, opts) {
|
|
|
107
107
|
export {
|
|
108
108
|
runSyncConfigsCommand
|
|
109
109
|
};
|
|
110
|
-
//# sourceMappingURL=sync-configs-
|
|
110
|
+
//# sourceMappingURL=sync-configs-KSESOHFF.js.map
|
|
@@ -5,21 +5,21 @@ import {
|
|
|
5
5
|
appendSkipNotice,
|
|
6
6
|
fleetWorkdir,
|
|
7
7
|
prepareFleetSites
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-TCF6S7AD.js";
|
|
9
9
|
import {
|
|
10
10
|
resolveSites
|
|
11
11
|
} from "./chunk-Q47DU255.js";
|
|
12
12
|
import {
|
|
13
13
|
upgradeSvelte4to5
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-B6NUZ2ZX.js";
|
|
15
15
|
import "./chunk-J7SZQUCW.js";
|
|
16
|
-
import "./chunk-
|
|
16
|
+
import "./chunk-ZTPPCHDB.js";
|
|
17
17
|
import "./chunk-OTGB6YQO.js";
|
|
18
18
|
import "./chunk-Z3JXLULD.js";
|
|
19
|
-
import "./chunk-RARREDQE.js";
|
|
20
|
-
import "./chunk-XTK5VIQB.js";
|
|
21
19
|
import "./chunk-LBYOLBW7.js";
|
|
20
|
+
import "./chunk-RARREDQE.js";
|
|
22
21
|
import "./chunk-XXTZBPUY.js";
|
|
22
|
+
import "./chunk-XTK5VIQB.js";
|
|
23
23
|
|
|
24
24
|
// src/cli/commands/upgrade.ts
|
|
25
25
|
import { resolve } from "path";
|
|
@@ -60,4 +60,4 @@ async function runUpgradeCommand(upgradeName, site, opts = {}) {
|
|
|
60
60
|
export {
|
|
61
61
|
runUpgradeCommand
|
|
62
62
|
};
|
|
63
|
-
//# sourceMappingURL=upgrade-
|
|
63
|
+
//# sourceMappingURL=upgrade-ZLNVEIEA.js.map
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
runMigration
|
|
3
|
-
} from "./chunk-6DOFNDXU.js";
|
|
4
1
|
import {
|
|
5
2
|
assetRef
|
|
6
3
|
} from "./chunk-UWJZXL2D.js";
|
|
4
|
+
import {
|
|
5
|
+
runMigration
|
|
6
|
+
} from "./chunk-6DOFNDXU.js";
|
|
7
7
|
|
|
8
8
|
// src/cli/commands/webflow.ts
|
|
9
9
|
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
@@ -545,4 +545,4 @@ async function runWebflowCommand(action, target, opts = {}) {
|
|
|
545
545
|
export {
|
|
546
546
|
runWebflowCommand
|
|
547
547
|
};
|
|
548
|
-
//# sourceMappingURL=webflow-
|
|
548
|
+
//# sourceMappingURL=webflow-IRKOIT7S.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reddoorla/maintenance",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.92.0",
|
|
4
4
|
"description": "Canonical maintenance configs, audits, and recipes for the reddoor stack.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
"types": "./dist/forms/index.d.ts",
|
|
26
26
|
"import": "./dist/forms/index.js"
|
|
27
27
|
},
|
|
28
|
+
"./forms/prismic": {
|
|
29
|
+
"types": "./dist/forms/prismic.d.ts",
|
|
30
|
+
"import": "./dist/forms/prismic.js"
|
|
31
|
+
},
|
|
28
32
|
"./audit": {
|
|
29
33
|
"types": "./dist/prospect/types.d.ts",
|
|
30
34
|
"import": "./dist/prospect/types.js"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/forms/payload.ts","../src/forms/ingest.ts"],"sourcesContent":["import { SUBMISSION_FORM_TYPES, type FormType } from \"./types.js\";\nimport type { SubmissionMeta } from \"./meta.js\";\n\n/** The JSON wire format a fleet site forwards to the ingest endpoint.\n *\n * Underscore-prefixed keys are RESERVED for transport concerns and are never\n * lead data: `_meta` (below) and `_screenOut` (the bot screen-out beacon —\n * a `{ _screenOut: \"honeypot\" | \"too-fast\" }` body is a counter ping, not a\n * submission; see ingest.ts `parseScreenOut`). Site field names must not\n * start with `_`. */\nexport type SubmissionPayload = {\n formType?: string;\n name?: string;\n firstName?: string;\n lastName?: string;\n email?: string;\n phone?: string;\n message?: string;\n sourceUrl?: string;\n utm?: string;\n /** Any additional, site-specific fields. */\n extra?: Record<string, unknown>;\n /** Reserved transport envelope (token/IP/UA); stripped by normalizeSubmission, never persisted. */\n _meta?: SubmissionMeta;\n};\n\nexport type NormalizedSubmission = {\n formType: FormType;\n name: string;\n email: string;\n phone?: string;\n message?: string;\n sourceUrl?: string;\n utm?: string;\n extraFields: Record<string, unknown>;\n};\n\nexport type NormalizeResult =\n { ok: true; value: NormalizedSubmission } | { ok: false; errors: string[] };\n\nconst KNOWN_KEYS = new Set([\n \"formType\",\n \"name\",\n \"firstName\",\n \"lastName\",\n \"email\",\n \"phone\",\n \"message\",\n \"sourceUrl\",\n \"utm\",\n \"extra\",\n \"_meta\",\n]);\n\n// Keys that, copied as own-properties into the captured object, could surprise a\n// downstream consumer (template/DB layer). This is an untrusted boundary, so the\n// extraFields merge drops them rather than trusting the caller.\nconst DANGEROUS_KEYS = new Set([\"__proto__\", \"constructor\", \"prototype\"]);\n\nconst EMAIL_RE = /^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/;\n\nfunction str(v: unknown): string {\n return typeof v === \"string\" ? v.trim() : \"\";\n}\n\n/**\n * Resolve the submission's formType. An ABSENT/blank value defaults to \"contact\"\n * (the long-standing minimal-form contract). A PRESENT but unrecognized value\n * (e.g. a typo'd \"news\") returns null → the caller rejects it, rather than\n * silently storing it as contact — which would, for a newsletter signup, drop the\n * Mailchimp fan-out. Mirrors createIngestEndpoint's reject-invalid behavior.\n */\nfunction resolveFormType(raw: unknown): FormType | null {\n const s = str(raw);\n if (s === \"\") return \"contact\";\n return (SUBMISSION_FORM_TYPES as readonly string[]).includes(s) ? (s as FormType) : null;\n}\n\n/**\n * Defensively normalize an untrusted ingest payload into typed fields. Folds\n * name/first+last, lowercases email, and dumps every unclaimed key into\n * extraFields so no site-specific data is lost. Rejects only when there's\n * nothing to act on (no email AND no message) or a present email is malformed.\n */\nexport function normalizeSubmission(payload: unknown): NormalizeResult {\n if (typeof payload !== \"object\" || payload === null) {\n return { ok: false, errors: [\"payload must be a JSON object\"] };\n }\n const p = payload as Record<string, unknown>;\n const name = str(p.name) || [str(p.firstName), str(p.lastName)].filter(Boolean).join(\" \");\n const email = str(p.email).toLowerCase();\n const message = str(p.message);\n\n const formType = resolveFormType(p.formType);\n const errors: string[] = [];\n if (!email && !message) errors.push(\"at least one of email or message is required\");\n if (email && !EMAIL_RE.test(email)) errors.push(\"email is not a valid address\");\n if (formType === null) errors.push(\"formType is not a recognized form type\");\n // The `|| formType === null` is redundant for control flow (the push above\n // already made errors non-empty) but narrows formType to FormType past this line.\n if (errors.length > 0 || formType === null) return { ok: false, errors };\n\n const extraFields: Record<string, unknown> = {};\n const extra = p.extra;\n if (typeof extra === \"object\" && extra !== null) {\n for (const [k, v] of Object.entries(extra)) {\n if (!DANGEROUS_KEYS.has(k)) extraFields[k] = v;\n }\n }\n for (const [k, v] of Object.entries(p)) {\n if (!KNOWN_KEYS.has(k) && !DANGEROUS_KEYS.has(k)) extraFields[k] = v;\n }\n\n const value: NormalizedSubmission = {\n formType,\n name,\n email,\n extraFields,\n };\n const phone = str(p.phone);\n if (phone) value.phone = phone;\n if (message) value.message = message;\n const sourceUrl = str(p.sourceUrl);\n if (sourceUrl) value.sourceUrl = sourceUrl;\n const utm = str(p.utm);\n if (utm) value.utm = utm;\n return { ok: true, value };\n}\n","import type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport type {\n SubmissionRow,\n SubmissionInput,\n NotifyStatus,\n SubmissionStatus,\n} from \"../reports/submission-row.js\";\nimport { normalizeSubmission, type NormalizedSubmission } from \"./payload.js\";\nimport type { TurnstileOutcome, TurnstileVerification } from \"./turnstile.js\";\nimport { SPAM_THRESHOLD, type SpamVerdict } from \"./spam-classifier.js\";\n\n/** How far back the duplicate-spray AND repeat-sender lookups scan. Sprays arrive in\n * bursts but bots also re-run for weeks; 30 days catches repeats without an unbounded\n * table scan. */\nconst DUPLICATE_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;\n\nexport type IngestDeps = {\n getWebsiteBySlug: (slug: string) => Promise<WebsiteRow | null>;\n createSubmission: (input: SubmissionInput) => Promise<SubmissionRow>;\n /** Optional last-resort capture for a lead whose site lookup THREW (a store\n * outage, not an unknown slug). When present, the lead is persisted here and\n * the visitor still gets \"accepted\"; `db replay-deadletters` re-runs it once\n * the lookup recovers. Absent → the error propagates exactly as before, so\n * callers that never wired it are byte-for-byte unchanged. */\n deadLetter?: (input: {\n siteSlug: string;\n payload: unknown;\n turnstile: TurnstileVerification;\n error: string;\n receivedAt: Date;\n }) => Promise<{ id: string }>;\n notify: (\n site: WebsiteRow,\n submission: SubmissionRow,\n ) => Promise<{ status: NotifyStatus; messageId: string | null }>;\n stampNotified: (id: string, status: NotifyStatus, messageId: string | null) => Promise<void>;\n now: () => Date;\n /** Optional spam classifier. When present, its verdict drives the stored\n * status/score/reason; absent → every submission scores 0 (fail-open, clean).\n * Injected so the classifier stays a pure, transport-agnostic leaf. */\n classifySpam?: (n: NormalizedSubmission, turnstile: TurnstileOutcome) => SpamVerdict;\n /** Optional: fleet-wide duplicate/near-duplicate body lookup since `since`. Drives\n * the duplicate-spray signal — the same pitch blasted to many sites shows up as\n * identical (`exact`) or template-substituted (`similar`, token-set Jaccard) bodies.\n * Statuses let the retro re-bucket pick still-'new' prior copies; siteId+email let\n * the caller exempt a genuine same-sender resubmission on the same site. Absent →\n * the check is skipped (fail-open clean). */\n findRecentDuplicates?: (\n message: string,\n since: Date,\n ) => Promise<{\n exact: Array<{ id: string; status: string; siteId: string; email: string }>;\n similar: Array<{ id: string; status: string; siteId: string; email: string }>;\n }>;\n /** Optional: recent non-newsletter submissions from the same email since `since`.\n * Drives the cross-site repeat-sender signal — the fleet's sites are unrelated\n * businesses, so one address contacting 2+ of them within the window is a\n * solicitation tell. Absent → the check is skipped (fail-open clean). */\n listRecentSubmissionsForEmail?: (\n email: string,\n since: Date,\n ) => Promise<Array<{ id: string; siteId: string; status: string }>>;\n /** Optional: retroactively re-bucket prior still-'new' rows once a later copy\n * identifies a spray (the first copy is delivered by design). Implementations\n * must only touch status='new' rows. Best-effort — failures are swallowed. */\n retroBucket?: (ids: string[], reason: string) => Promise<void>;\n /** Optional: POST a newsletter submission to the site's configured webhook\n * (best-effort). Omitted in tests/handlers that don't need it. */\n forwardNewsletter?: (\n webhookUrl: string,\n submission: SubmissionRow,\n site: WebsiteRow,\n ) => Promise<{ ok: boolean; status: number }>;\n /** Optional: add a newsletter submitter to the site's Mailchimp audience\n * (best-effort). Omitted where unused. `tagged:false` reports a member who was\n * upserted but could NOT be tagged — recorded separately, see FANOUT below. */\n addToMailchimp?: (\n site: WebsiteRow,\n submission: SubmissionRow,\n ) => Promise<{ ok: boolean; status: number; tagged?: boolean }>;\n /** Optional: persist the newsletter fan-out outcome on the row (see FANOUT below).\n * Best-effort — failures are swallowed. Absent → the fan-out still runs, just\n * unrecorded (older callers and most tests). */\n stampFanout?: (id: string, fanoutStatus: string) => Promise<void>;\n /** Optional: run the best-effort tail (notify → stamp → fan-out) AFTER the\n * response instead of before it. Production passes Netlify's\n * `context.waitUntil`, which keeps the invocation alive until the promise\n * settles. Absent → the tail runs inline, exactly as it always did; this is a\n * latency change, never a behavioural one.\n *\n * Why it matters: the submitting site waits on this response under an abort\n * budget (forms/client.ts `INGEST_TIMEOUT_MS`). Once the row is written the\n * lead is captured, so every further second spent on email/webhook providers\n * is time in which a captured lead can still be reported to the visitor as a\n * failed submission — which is exactly what happened to 1836dig on\n * 2026-08-03. */\n defer?: (work: Promise<unknown>) => void;\n};\n\nexport type IngestResult =\n | { status: \"accepted\"; submissionId: string; notifyStatus: NotifyStatus | \"deferred\" }\n | { status: \"rejected\"; reason: \"invalid-payload\"; errors: string[] }\n | { status: \"unknown-site\"; slug: string };\n\nexport type ScreenOutDeps = {\n getWebsiteBySlug: (slug: string) => Promise<WebsiteRow | null>;\n recordScreenOut: (siteId: string, reason: \"honeypot\" | \"too-fast\") => Promise<void>;\n};\n\nexport type ScreenOutResult =\n { status: \"recorded\"; slug: string } | { status: \"unknown-site\"; slug: string };\n\n/** Extract the screen-out reason from a beacon body, or null if it isn't one.\n * The beacon key is the reserved `_screenOut` (underscore-namespaced like `_meta`,\n * see payload.ts). The bare `screenOut` key is the DEPRECATED pre-namespacing wire\n * shape — sites run older package versions for a while, so the central receiver\n * keeps accepting it. `_screenOut` wins when both are present. */\nexport function parseScreenOut(payload: unknown): \"honeypot\" | \"too-fast\" | null {\n if (!payload || typeof payload !== \"object\") return null;\n const body = payload as Record<string, unknown>;\n const v = \"_screenOut\" in body ? body[\"_screenOut\"] : body[\"screenOut\"];\n return v === \"honeypot\" || v === \"too-fast\" ? v : null;\n}\n\n/** Resolve the site and record a caught screen-out. Best-effort: a record failure is\n * the caller's to swallow — a missed count must never error a screened bot. */\nexport async function ingestScreenOut(\n deps: ScreenOutDeps,\n slug: string,\n reason: \"honeypot\" | \"too-fast\",\n): Promise<ScreenOutResult> {\n const site = await deps.getWebsiteBySlug(slug);\n if (!site) return { status: \"unknown-site\", slug };\n await deps.recordScreenOut(site.id, reason);\n return { status: \"recorded\", slug };\n}\n\n/**\n * Normalize → resolve site → persist → notify → stamp. The order is load-bearing:\n * the row is written BEFORE notify, and notify/stamp failures are swallowed (logged)\n * so a Resend or Airtable-write-back hiccup can never turn an accepted lead into a 502.\n */\nexport async function ingestSubmission(\n deps: IngestDeps,\n slug: string,\n rawPayload: unknown,\n turnstileInput: TurnstileOutcome | TurnstileVerification = \"unverifiable\",\n): Promise<IngestResult> {\n // Accept either the full verification (the production handler) or a bare outcome\n // string (older callers and virtually every test) — a string simply has no solved-\n // hostname to check. Normalized once so the body has a single shape to reason about.\n const verification: TurnstileVerification =\n typeof turnstileInput === \"string\"\n ? { outcome: turnstileInput, hostname: null }\n : turnstileInput;\n const turnstile = verification.outcome;\n const normalized = normalizeSubmission(rawPayload);\n if (!normalized.ok) {\n return { status: \"rejected\", reason: \"invalid-payload\", errors: normalized.errors };\n }\n // Persist before enrich (#539 Phase 0). The lookup THROWING is a store outage,\n // not an answer — and until 2026-08-23 it was the one await that could cost a\n // lead: the row write comes later, so a thrown lookup 502'd the visitor with\n // nothing recorded anywhere (the 2026-08-17 Airtable quota outage did exactly\n // that, while the submissions store itself was healthy the whole time). With\n // `deadLetter` wired, the lead lands there and the visitor gets an honest\n // \"accepted\"; `db replay-deadletters` runs it through this same function once\n // the lookup recovers, producing a normal row with real classification+notify.\n //\n // Three deliberate boundaries:\n // - A lookup that RESOLVES to null is still `unknown-site` — the store answered,\n // and a junk slug is a rejection, not a lead to save.\n // - A testMode probe rethrows: it persists nothing by design, so there is\n // nothing to save, and swallowing the outage would green the form-e2e audit\n // precisely when central ingest is degraded.\n // - `deadLetter` itself throwing propagates: both stores are down, and the 502\n // is honest — there is nowhere left to put the lead.\n let site: WebsiteRow | null;\n try {\n site = await deps.getWebsiteBySlug(slug);\n } catch (err) {\n if (!deps.deadLetter || isTestMode(rawPayload)) throw err;\n const dl = await deps.deadLetter({\n siteSlug: slug,\n payload: rawPayload,\n turnstile: verification,\n error: String(err),\n receivedAt: deps.now(),\n });\n console.error(\n `[ingest] site lookup failed for '${slug}' — lead dead-lettered as ${dl.id}: ${String(err)}`,\n );\n return { status: \"accepted\", submissionId: dl.id, notifyStatus: \"skipped\" };\n }\n if (!site) return { status: \"unknown-site\", slug };\n\n // Synthetic end-to-end probe (the `form-e2e` fleet audit). A central-only marker\n // on the payload routes the submission away from EVERY real sink: no row is\n // persisted, no spam classification, no operator/autoresponder email, no\n // newsletter fan-out — and Turnstile enforcement is bypassed (the short-circuit\n // sits before that check). The marker therefore grants a bot NO benefit: it\n // reaches no inbox/DB/webhook, so skipping Turnstile costs nothing. This\n // suppression MUST be central — the submitting site alone cannot stop the real\n // inbox firing. Validity + site resolution are still enforced above (a junk body\n // is rejected, an unknown slug is unknown-site), so the marker can't smuggle\n // anything through. Return accepted+skipped so the probe asserts success.\n if (isTestMode(rawPayload)) {\n return { status: \"accepted\", submissionId: \"test-mode\", notifyStatus: \"skipped\" };\n }\n\n const n = normalized.value;\n\n // A site still under construction is where the operator tests their OWN forms:\n // one address, several unrelated sites, minutes apart. That is byte-for-byte the\n // cross-site repeat-sender signature below, so a site in development reliably\n // auto-spams its own builder's test submissions — the row lands `spam_auto`,\n // notify is skipped and the cockpit hides it, which reads exactly like \"the form\n // is broken\" (it did, on 2026-08-17). Spam handling on a site with no real\n // visitors protects nothing, so skip ALL of it: content scoring, the Turnstile\n // escalation, the repeat-sender scan and the duplicate/spray scan — including\n // their retroactive re-bucketing of OTHER sites' rows, which a test submission\n // has no business triggering. Re-enabled the moment the site leaves development.\n const spamHandlingEnabled = site.status !== \"building\";\n\n // Fold the content signals + the Turnstile verdict into ONE spam decision.\n // Absent classifier → treat as clean (fail-open). A throwing classifier is\n // swallowed the same way — a bug in the heuristic must never turn an\n // otherwise-good lead into a 500; it just scores clean. On a `requireTurnstile`\n // site, both an ACTUAL \"fail\" (forged token) AND an \"absent\" token escalate to\n // auto-spam regardless of score — a real browser that renders the widget ALWAYS\n // sends a token, so a completely missing one is the direct-POST-bot signature. A\n // present-but-\"unverifiable\" token (expired/duplicate — a real browser DID render\n // the widget) stays neutral, as does anything on a site that hasn't opted in.\n let verdict: SpamVerdict = { score: 0, reasons: [] };\n if (spamHandlingEnabled && deps.classifySpam) {\n try {\n verdict = deps.classifySpam(n, turnstile);\n } catch (err) {\n console.error(`[ingest] classifySpam threw: ${String(err)}`);\n }\n }\n const reasons = [...verdict.reasons];\n let status: SubmissionStatus = verdict.score >= SPAM_THRESHOLD ? \"spam_auto\" : \"new\";\n if (\n spamHandlingEnabled &&\n site.requireTurnstile &&\n (turnstile === \"fail\" || turnstile === \"absent\")\n ) {\n status = \"spam_auto\";\n const reason = turnstile === \"fail\" ? \"turnstile-required-failed\" : \"turnstile-required-absent\";\n if (!reasons.includes(reason)) reasons.push(reason);\n } else if (\n spamHandlingEnabled &&\n site.requireTurnstile &&\n turnstile === \"pass\" &&\n verification.hostname !== null &&\n !turnstileHostnameAcceptable(verification.hostname, site.url)\n ) {\n // Defense-in-depth vs token farming: the token PASSED, but Cloudflare says it was\n // solved on a host unrelated to this site. Cloudflare domain-binds sitekeys, so\n // this only trips on a loose widget allowlist — still, a passing token from a\n // foreign host accompanying a gated site's submission is not a real visitor. A\n // null hostname (older responses) or an unparseable site.url skips the check\n // entirely (fail-open); subdomains of the site's host (www., previews) match.\n status = \"spam_auto\";\n if (!reasons.includes(\"turnstile-required-hostname\"))\n reasons.push(\"turnstile-required-hostname\");\n }\n\n // Cross-site repeat-sender signal: the fleet's sites are UNRELATED businesses\n // (art gallery, realtor, home builder…), so the same email writing to 2+ different\n // sites within the window is a solicitation tell no single-message scan can see.\n // Same-site repeats alone must NOT trigger — those are genuine follow-ups. The\n // operator explicitly accepts overblocking here (spam_auto is recoverable). Prior\n // still-'new' rows on OTHER sites are retro-bucketed too: the first copy of a\n // spray is delivered by design, so the copy that identifies it also cleans the\n // queue. The scan runs EVEN WHEN the incoming copy is already spam_auto — after the\n // classifier began catching whole spray families, an already-bucketed copy was\n // skipping this scan and the retro cleanup never fired for exactly the sprays it\n // was built for; escalation/reason still only applies when not already spam.\n // Best-effort — a lookup failure never blocks a lead.\n if (spamHandlingEnabled && n.formType !== \"newsletter\" && deps.listRecentSubmissionsForEmail) {\n try {\n const since = new Date(deps.now().getTime() - DUPLICATE_WINDOW_MS);\n const prior = await deps.listRecentSubmissionsForEmail(n.email, since);\n const otherSites = prior.filter((p) => p.siteId !== site.id);\n if (otherSites.length > 0) {\n if (status !== \"spam_auto\") {\n status = \"spam_auto\";\n if (!reasons.includes(\"repeat-sender\")) reasons.push(\"repeat-sender\");\n }\n const retroIds = otherSites.filter((p) => p.status === \"new\").map((p) => p.id);\n if (deps.retroBucket && retroIds.length > 0) {\n try {\n await deps.retroBucket(retroIds, \"retro:repeat-sender\");\n } catch (err) {\n console.error(`[ingest] retroBucket (repeat-sender) threw: ${String(err)}`);\n }\n }\n }\n } catch (err) {\n console.error(`[ingest] listRecentSubmissionsForEmail threw: ${String(err)}`);\n }\n }\n\n // Duplicate/spray signal: the same pitch blasted across the fleet (or repeated) is\n // a bot tell a lone content scan can't see. An identical body ('exact') OR a\n // template-substituted near-copy ('similar' — the live dog-harness spray differed\n // only in greeting; SEO sprays swap the target domain per site) → auto-spam\n // (recoverable). Prior still-'new' copies are retro-bucketed for the same reason\n // as above, and the scan runs even when the incoming copy is already spam_auto\n // (see the repeat-sender note). GENUINE-RESUBMIT EXEMPTION: a match from the SAME\n // sender on the SAME site is a real visitor double-submitting or resending after\n // silence, not spray evidence — without this exemption the resend was silently\n // bucketed AND retro-flipped the delivered original, vanishing an active lead\n // with no signal. Only cross-site or different-sender copies count. Guarded:\n // non-newsletter forms with a real body; the db helper ignores short bodies /\n // small token sets. Best-effort — a lookup failure never blocks a lead.\n if (\n spamHandlingEnabled &&\n n.formType !== \"newsletter\" &&\n n.message !== undefined &&\n deps.findRecentDuplicates\n ) {\n try {\n const since = new Date(deps.now().getTime() - DUPLICATE_WINDOW_MS);\n const dupes = await deps.findRecentDuplicates(n.message, since);\n const senderEmail = n.email.trim().toLowerCase();\n const isOwnResend = (m: { siteId: string; email: string }) =>\n m.siteId === site.id && m.email.trim().toLowerCase() === senderEmail;\n const exact = dupes.exact.filter((m) => !isOwnResend(m));\n const similar = dupes.similar.filter((m) => !isOwnResend(m));\n const reason =\n exact.length > 0 ? \"duplicate-body\" : similar.length > 0 ? \"similar-body\" : null;\n if (reason !== null) {\n if (status !== \"spam_auto\") {\n status = \"spam_auto\";\n if (!reasons.includes(reason)) reasons.push(reason);\n }\n const retroIds = [...exact, ...similar].filter((m) => m.status === \"new\").map((m) => m.id);\n if (deps.retroBucket && retroIds.length > 0) {\n try {\n await deps.retroBucket(retroIds, \"retro:duplicate-body\");\n } catch (err) {\n console.error(`[ingest] retroBucket (duplicate-body) threw: ${String(err)}`);\n }\n }\n }\n } catch (err) {\n console.error(`[ingest] findRecentDuplicates threw: ${String(err)}`);\n }\n }\n const spamReason = reasons.length > 0 ? reasons.join(\",\") : null;\n\n const row = await deps.createSubmission({\n siteId: site.id,\n formType: n.formType,\n name: n.name,\n email: n.email,\n extraFields: n.extraFields,\n status,\n spamScore: verdict.score,\n spamReason,\n // Optional fields spread only when present — exactOptionalPropertyTypes\n // forbids assigning `undefined` to an optional `phone?: string` etc.\n ...(n.phone !== undefined ? { phone: n.phone } : {}),\n ...(n.message !== undefined ? { message: n.message } : {}),\n ...(n.sourceUrl !== undefined ? { sourceUrl: n.sourceUrl } : {}),\n ...(n.utm !== undefined ? { utm: n.utm } : {}),\n submittedAt: deps.now(),\n });\n\n // Auto-spam (and operator-marked spam) is captured but silent: no operator\n // email, no autoresponder, no newsletter fan-out. Skip notify entirely and\n // record the honest \"skipped\" stamp. notify.ts also nulls both builders for\n // these statuses (defense in depth), but short-circuiting here means the\n // injected notify dep is never even invoked for a spam row.\n const isSpam = row.status === \"spam_auto\" || row.status === \"spam\";\n\n // ── best-effort tail ───────────────────────────────────────────────────────\n // notify → stamp → newsletter fan-out. NOTHING below can cost the lead: the\n // row is already durable, and every step here is swallowed+logged rather than\n // allowed to turn an accepted lead into a 502. That is precisely why it does\n // not belong on the visitor's critical path — the submitting site waits on\n // this response under an abort budget (forms/client.ts INGEST_TIMEOUT_MS), so\n // Resend latency used to be able to make a captured lead read as a failed\n // submission in the visitor's browser (1836dig, 2026-08-03).\n //\n // `deps.defer` (Netlify's `context.waitUntil` in production) runs the tail\n // AFTER the response goes out. Absent — every test, and any runtime without\n // post-response execution — it runs inline exactly as before, so this is a\n // latency change and never a behavioural one.\n const runTail = async (): Promise<NotifyStatus> => {\n let notify: { status: NotifyStatus; messageId: string | null };\n if (isSpam) {\n notify = { status: \"skipped\", messageId: null };\n } else {\n try {\n notify = await deps.notify(site, row);\n } catch (err) {\n console.error(`[ingest] notify threw: ${String(err)}`);\n notify = { status: \"failed\", messageId: null };\n }\n }\n try {\n await deps.stampNotified(row.id, notify.status, notify.messageId);\n } catch (err) {\n console.error(`[ingest] stampNotified failed: ${String(err)}`);\n }\n\n await runFanout();\n return notify.status;\n };\n\n // Newsletter fan-out: each configured destination fires best-effort and is\n // swallowed+logged — the lead is already persisted; never turn it into a 502.\n // Guarded on the row status so a spam signup is never forwarded to a site\n // webhook or added to a Mailchimp audience.\n //\n // FANOUT provenance (2026-07-31): the outcome is ALSO recorded on the row, one\n // `<destination>:<outcome>` token per attempt, comma-joined in attempt order —\n // `webhook:ok,mailchimp:401`. Outcome is `ok`, the HTTP status of a non-2xx\n // (`0` for a network error / misconfiguration, see mailchimp.ts), or `threw`.\n // `mailchimp-tags:failed` is appended when the member was upserted but the tag\n // write did not land. Until this existed the results were console.error-only, so\n // an expired Mailchimp key stopped signups reaching the audience while the row\n // still read `notify=sent` — healthy-looking, with the failure visible nowhere the\n // operator ever looks. Nothing attempted → nothing stamped (null stays \"no\n // destination configured\", NOT \"we tried and lost the answer\"). The stamp is\n // best-effort like everything else here: a provenance write must never cost a lead.\n // A const arrow, not a hoisted declaration: `site` is narrowed non-null above,\n // and that narrowing only survives into a closure created after it. Declared\n // below runTail (which calls it) but before either call site, so there is no TDZ.\n const runFanout = async (): Promise<void> => {\n const fanout: string[] = [];\n if (n.formType === \"newsletter\" && !isSpam) {\n if (site.newsletterWebhook && deps.forwardNewsletter) {\n try {\n const fwd = await deps.forwardNewsletter(site.newsletterWebhook, row, site);\n if (!fwd.ok)\n console.error(`[ingest] newsletter webhook → ${fwd.status} for ${site.name}`);\n fanout.push(fwd.ok ? \"webhook:ok\" : `webhook:${fwd.status}`);\n } catch (err) {\n console.error(`[ingest] newsletter webhook threw: ${String(err)}`);\n fanout.push(\"webhook:threw\");\n }\n }\n if (site.mailchimpApiKey && site.mailchimpAudienceId && deps.addToMailchimp) {\n try {\n const mc = await deps.addToMailchimp(site, row);\n if (!mc.ok) console.error(`[ingest] mailchimp add → ${mc.status} for ${site.name}`);\n fanout.push(mc.ok ? \"mailchimp:ok\" : `mailchimp:${mc.status}`);\n // Only meaningful alongside a successful add — a failed add was never tagged\n // either, and one token per real problem keeps the row readable.\n if (mc.ok && mc.tagged === false) {\n console.error(`[ingest] mailchimp tags not applied for ${site.name}`);\n fanout.push(\"mailchimp-tags:failed\");\n }\n } catch (err) {\n console.error(`[ingest] mailchimp add threw: ${String(err)}`);\n fanout.push(\"mailchimp:threw\");\n }\n }\n }\n if (fanout.length > 0 && deps.stampFanout) {\n try {\n await deps.stampFanout(row.id, fanout.join(\",\"));\n } catch (err) {\n console.error(`[ingest] stampFanout failed: ${String(err)}`);\n }\n }\n };\n\n if (deps.defer) {\n // A rejection escaping here would land as an unhandled rejection in the\n // platform's post-response context, where nothing is left to catch it — so\n // the boundary is made unrejectable. Everything inside is already swallowed;\n // this guards the shape, not a known throw.\n deps.defer(\n runTail().catch((err) => {\n console.error(`[ingest] deferred tail threw: ${String(err)}`);\n }),\n );\n // The lead is durable and the response can go out now. The real notify\n // outcome lands on the ROW via stampNotified moments later — this field is\n // only ever read by callers wanting the in-request outcome, which by\n // definition does not exist once the tail is deferred.\n return { status: \"accepted\", submissionId: row.id, notifyStatus: \"deferred\" };\n }\n return { status: \"accepted\", submissionId: row.id, notifyStatus: await runTail() };\n}\n\n/** True when `a` and `b` are the same host or one is a subdomain of the other\n * (case-insensitive): `www.reddoorla.com` vs `reddoorla.com` matches both ways.\n * Exported for tests. PURE. */\nexport function hostsMatch(a: string, b: string): boolean {\n const ha = a.trim().toLowerCase();\n const hb = b.trim().toLowerCase();\n if (ha.length === 0 || hb.length === 0) return false;\n return ha === hb || ha.endsWith(`.${hb}`) || hb.endsWith(`.${ha}`);\n}\n\n/** Whether a passing token's solved-hostname is acceptable for the site at `siteUrl`.\n * An unparseable/hostless `siteUrl` returns TRUE — the check self-disables rather\n * than punishing a possibly-real visitor for an operator data problem (fail-open,\n * same philosophy as verifyTurnstile). PURE. */\nexport function turnstileHostnameAcceptable(tokenHostname: string, siteUrl: string): boolean {\n let siteHost: string;\n try {\n siteHost = new URL(siteUrl).hostname;\n } catch {\n return true;\n }\n if (!siteHost) return true;\n return hostsMatch(tokenHostname, siteHost);\n}\n\n/** True when an untrusted ingest payload carries the synthetic-probe marker\n * (top-level `testMode: true`). Read from the RAW payload so the branch never\n * depends on normalization internals; any non-`true` value is ignored (a real\n * visitor's form never sets it — the starter only forwards it when the submitted\n * form field `testMode` equals \"true\"). */\nexport function isTestMode(rawPayload: unknown): boolean {\n if (!rawPayload || typeof rawPayload !== \"object\") return false;\n return (rawPayload as Record<string, unknown>).testMode === true;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/prospect/goals.ts"],"sourcesContent":["import type { ChecksResult, CrawlResult, JourneyMap, PageCapture, PageExtract } from \"./types.js\";\n\n/**\n * Can a visitor do the one thing this site needs them to do?\n *\n * Every other check in this audit is generic — crawlable, readable, not broken.\n * Those are worth measuring and they are the same questions for every site,\n * which is exactly the problem: a dentist's site succeeds when somebody books an\n * appointment and a branding studio's succeeds when a qualified enquiry arrives\n * with a budget attached. Grading both against one template scores neither\n * against what it is for.\n *\n * So one goal is named, and the findings are read against it. The goal is\n * inferred from the site by the analyze stage and may be overridden by the\n * operator — and an inference we get wrong is itself worth reporting, because if\n * a model that has just read twenty pages cannot tell what the site is for,\n * neither can a visitor.\n *\n * Findings carry a `scope`, and the report orders by it. That ordering is the\n * only place the commercial ladder appears: a stale phone link is an afternoon,\n * \"you have never published a price and every buyer asks\" is a content\n * engagement, and \"there is no way to publish or update any of this\" is a\n * platform conversation. The report must never say those words. Ordering does\n * the work, and a prospect who reads to the bottom arrives at the conversation\n * on their own.\n */\n\nexport type SiteGoal =\n \"book\" | \"enquire\" | \"call\" | \"visit\" | \"buy\" | \"demo\" | \"partner\" | \"unknown\";\n\nexport const GOAL_LABELS: Record<SiteGoal, string> = {\n book: \"book an appointment\",\n enquire: \"start a project or ask for a quote\",\n call: \"pick up the phone\",\n visit: \"come to you in person\",\n buy: \"buy something\",\n demo: \"talk to your sales team\",\n partner: \"ask about distribution or partnership\",\n unknown: \"— we could not tell\",\n};\n\n/** How much work putting one thing right is. Orders the report; never printed\n * as a tier, and never priced. */\nexport type Scope = \"quick\" | \"content\" | \"structural\";\n\n/**\n * Three states, not two — and the third is the one that matters.\n *\n * Caught on live data: `reachable` and `tappable-phone` both read their input\n * from `checks`, which older stored reports do not carry. With a boolean they\n * came back `false`, and the report said \"no way to reach you from where they\n * land\" about sites that have one — turning our own missing measurement into\n * the prospect's defect. That is the single error this whole codebase is built\n * not to make, and a two-state field makes it the default.\n */\nexport type RequirementStatus = \"met\" | \"missing\" | \"unmeasured\";\n\nexport type GoalRequirement = {\n key: string;\n /** What the visitor needs, in the visitor's terms. */\n label: string;\n status: RequirementStatus;\n /** Where we found it — the receipt. Null when we did not. */\n evidence: string | null;\n /** Why this one matters for THIS goal, not in general. */\n why: string;\n scope: Scope;\n};\n\nexport type GoalFit = {\n goal: SiteGoal;\n /** \"operator\" when supplied at dispatch, \"inferred\" when the analyze stage\n * read it off the site. A reader is owed that distinction. */\n source: \"inferred\" | \"operator\";\n requirements: GoalRequirement[];\n met: number;\n /** Requirements we could actually judge — `met` + `missing`. Excludes\n * `unmeasured`, so \"3 of 4\" never counts something we did not look at. */\n total: number;\n};\n\n/**\n * Third-party scheduling a visitor can actually complete. Deliberately a list\n * of hosts rather than a guess at markup: a booking widget is nearly always an\n * outbound link or an iframe to one of these, and inferring \"booking\" from a\n * form containing a date field catches every event RSVP on the web.\n *\n * Matched on the HOSTNAME, never on the raw href — see `linksTo`. A substring\n * test for \"cal.com\" credited a booking system to medical.com, surgical.com,\n * socal.com and every `mailto:` address at any of them, and printed that URL\n * back to the client as the receipt. Medtech and clinic prospects link to\n * *medical.com constantly.\n */\nconst BOOKING_HOSTS = [\n \"zocdoc.com\",\n \"calendly.com\",\n \"acuityscheduling.com\",\n \"squarespacescheduling.com\",\n \"nexhealth.com\",\n \"localmed.com\",\n \"setmore.com\",\n \"simplybook.me\",\n \"appointy.com\",\n \"cal.com\",\n \"savvycal.com\",\n \"youcanbook.me\",\n \"meetings.hubspot.com\",\n \"chilipiper.com\",\n \"patientpop.com\",\n \"doctolib\",\n \"getweave.com\",\n \"flexbooker.com\",\n \"bookem\",\n \"vagaro.com\",\n \"mindbodyonline.com\",\n];\n\n/**\n * A link that shows the visitor where you are. An entry may carry a path prefix\n * when the host alone is not the signal — google.com is not a map, but\n * google.com/maps is.\n *\n * `maps.google.com` and `bing.com/maps` were both missed while the substring\n * test was crediting *medical.com with a booking system: the check was loose\n * where it should have been strict and strict where it should have been loose.\n */\nconst MAP_HOSTS = [\n \"google.com/maps\",\n \"maps.google.com\",\n \"goo.gl/maps\",\n \"maps.app.goo.gl\",\n \"maps.apple.com\",\n \"bing.com/maps\",\n];\n\n/** Day-of-week followed by a time, which is what an opening-hours line looks\n * like in prose regardless of how it is formatted. */\nconst HOURS_IN_TEXT =\n /\\b(mon|tue|wed|thu|fri|sat|sun)[a-z]*\\.?\\s*[-–—:]?\\s*(?:[a-z]*\\s*)?\\d{1,2}\\s*(?::\\d{2})?\\s*(?:am|pm)/i;\n\n/** A currency amount. Anchored on the symbol so \"17% revenue lift\" in a case\n * study is not read as a price. */\nconst MONEY = /[$£€]\\s?\\d[\\d,]*(?:\\.\\d{2})?(?:\\s?[kKmM]\\b)?/;\n\n/**\n * The words a site uses when it is about to tell you what something costs.\n *\n * \"starting at|starts at\" missed the two commonest real phrasings outright:\n * \"Treatment plans start at $250\" (the bare infinitive) and \"$99 new patient\n * special\" (no price word at all until \"special\" was added). Both came back\n * \"you have never published a price\" against sites that publish one on the home\n * page, which is the same class of error as a false yes and lands in front of\n * the person who wrote the sentence.\n */\nconst PRICE_WORDS =\n /\\b(pricing|price list|start(?:s|ing)? (?:at|from)|begin(?:s|ning)? at|from \\$|per project|per month|per hour|retainer|packages?|rates?|fees?|budget starts|as (?:low|little) as|specials?|promotion)\\b/i;\n\n/**\n * Content checks match an ANSWER, never a topic.\n *\n * The distinction is the whole reliability of this section, and live data has\n * now caught it twice. Ludlow Kingsley scored a published price off the word\n * \"package\" in a sentence about photographing packaging. Beachfront Dentistry\n * scored \"taking new clients\" off a staff bio — \"building relationships with our\n * new patients is what Michelle likes best\" — which mentions new patients and\n * says nothing whatever about whether they can become one.\n *\n * A bare topic noun is the failure mode: every dental site on earth contains the\n * words \"new patients\", so matching them measures nothing except that the site\n * is about dentistry. Each pattern below therefore requires language that could\n * only appear in an actual answer — a verb of availability beside the noun, a\n * policy beside the topic, a number beside the term.\n *\n * This matters more here than anywhere else in the audit, because these findings\n * are the ones that quote the site back to its owner. A \"No\" we got wrong is a\n * conversation. A \"Yes\" quoting a sentence that plainly does not support it\n * tells the reader our checks do not mean what they say, and they would be\n * right. `tests/prospect/goals.test.ts` holds a decoy corpus of sentences that\n * mention every one of these topics without answering any of them; nothing here\n * may fire on it.\n *\n * Replaying the checks over the stored audits found six of the eight patterns\n * still firing on a bare topic noun — fourteen \"met\" receipts across ten real\n * sites, quoting back to the client: placeholder lorem ipsum, the country list\n * inside a phone-input widget (\"British Indian Ocean Territory\"), a client's\n * name on a portfolio page (\"Meridian Distribution\"), a COMPETITOR's\n * money-back promise on a comparison page, and a legal disclaimer. Every\n * pattern below now asks for the answer beside the noun: parking with a place\n * or a price, shipping with a cost or a window, a return with a policy verb, a\n * territory that is exclusive or available. The test for whether an alternative\n * belongs here is the receipt: the sentence it matches, printed on its own,\n * has to answer the question the requirement asks.\n */\n\n/** Alternatives, one per line, because a pattern nobody can read is a pattern\n * nobody can check. */\nconst anyOf = (...alternatives: string[]): RegExp => new RegExp(alternatives.join(\"|\"), \"i\");\n\nconst ANSWERS = {\n /** A verb of availability beside the noun. \"not accepting new patients\" is a\n * perfectly good answer and matches too — the requirement is that the site\n * says, not that the answer is yes. */\n acceptingClients:\n /\\b(?:accepting|welcoming|taking(?:\\s+on)?|seeing|onboarding)\\s+(?:any\\s+)?new\\s+(?:patients|clients|customers|members|students)\\b|\\bnew\\s+(?:patients|clients|customers|members)\\s+(?:are\\s+)?(?:welcome|accepted)\\b/i,\n\n /**\n * \"same day\" alone matched anniversaries and opening dates; it has to be the\n * same day as something a caller would want. Bare \"no obligation\" matched a\n * terms-of-use line — \"we are under no obligation to update this page\" —\n * which is the site disclaiming a duty to itself, not offering the visitor\n * anything, so the phrase now has to attach to the visitor's decision.\n */\n whatToExpect: anyOf(\n String.raw`\\b(?:free|complimentary|no[- ]cost|no[- ]charge|no[- ]obligation)\\s+(?:initial\\s+|first\\s+|\\d+[- ]minute\\s+)?(?:consultations?|consults?|quotes?|quotations?|estimates?|assessments?|evaluations?|exams?)\\b`,\n String.raw`\\b(?:consultations?|consults?|quotes?|estimates?|assessments?)\\s+(?:are|is)\\s+(?:always\\s+)?(?:free|complimentary|on us|no charge)\\b`,\n String.raw`\\bwith\\s+no\\s+obligation\\b`,\n String.raw`\\b(?:there(?:'s| is)|and)\\s+(?:is\\s+)?no\\s+obligation\\b`,\n String.raw`\\bno\\s+obligation\\s+to\\s+(?:buy|book|purchase|proceed|continue|commit|hire)\\b`,\n // \"What to expect\" as a promise the sentence then keeps, rather than a\n // slogan that leaves the visitor exactly where they were.\n String.raw`\\b(?:here(?:'s| is)|this is|below is)\\s+what\\s+to\\s+expect\\b`,\n String.raw`\\bwhat\\s+to\\s+expect\\s+(?:at|on|during|from|when|before|after|in)\\b`,\n String.raw`\\bwe(?:'ll| will)\\s+call\\s+you\\s+back\\b`,\n String.raw`\\bsame[- ]day\\s+(?:appointments?|service|visit|care|callback|response)\\b`,\n ),\n\n /**\n * \"garage\" on its own matched an auto shop describing itself, and bare\n * \"parking\" matched placeholder copy promising a parking page \"coming soon\".\n * A visitor asking about parking wants a price, a place or a promise.\n */\n gettingThere: anyOf(\n String.raw`\\b(?:free|street|on[- ]?site|off[- ]?street|validated|complimentary|covered|underground|gated|ample|dedicated|reserved|metered|paid|patient|client|customer|visitor|garage|lot)\\s+parking\\b`,\n String.raw`\\bparking\\s+(?:lot|garage|structure|deck|spaces?|is|are|can\\s+be|will\\s+be|available|validated|included)\\b`,\n String.raw`\\bparking\\s+(?:in|on|at|behind|beneath|below|under|out\\s+(?:back|front)|across|next\\s+to|opposite)\\b`,\n String.raw`\\bwe\\s+(?:have|offer|provide|validate)\\s+(?:\\w+\\s+){0,2}parking\\b`,\n String.raw`\\bvalet\\s+(?:parking|service)\\b`,\n // Transit, with a distance or a direction beside it. A station named and\n // left there tells a visitor nothing about getting from it to you.\n String.raw`\\b(?:bus stop|subway|light rail|metro station|train station|nearest station|public transit|park[- ]and[- ]ride)\\b[^.!?]{0,60}?\\b(?:is|are|stops?|serves?|steps|blocks?|minutes?|walk|away|outside|across|adjacent|nearby|north|south|east|west|corner|line)\\b`,\n String.raw`\\b(?:steps|blocks?|minutes?|short walk|walking distance|directly across)\\b[^.!?]{0,60}?\\b(?:bus stop|subway|light rail|metro station|train station|station)\\b`,\n ),\n\n /**\n * Bare \"delivery\" matched \"delivery of care\" and \"delivery of the project\",\n * and bare \"shipping\" matched a placeholder and a \"prices, shipping and\n * availability subject to change\" line. The buyer's question is what it costs\n * and when it arrives, so the pattern asks for one of those.\n */\n shipping: anyOf(\n String.raw`\\b(?:free|flat[- ]rate|expedited|overnight|standard|express|next[- ]day|same[- ]day|two[- ]day|international|domestic|worldwide)\\s+(?:shipping|delivery|postage)\\b`,\n String.raw`\\bshipping\\s+(?:is|costs?|rates?|charges?|fees?|options?|times?|policy|starts|and\\s+handling|within|on\\s+(?:all\\s+)?orders?)\\b`,\n String.raw`\\bdelivery\\s+(?:times?|options?|charges?|fees?|costs?|rates?|window)\\b`,\n String.raw`\\b(?:ships?|shipped|shipping|delivers?|delivered|dispatch(?:ed)?)\\s+(?:within|in)\\s+(?:\\d|one|two|three|four|five|six|seven|ten|a\\s+few)`,\n String.raw`\\bships?\\s+(?:free|the\\s+same\\s+day|next\\s+day|worldwide|nationwide)\\b`,\n String.raw`\\borders?\\s+(?:over|above)\\s+[$£€]\\s?\\d[\\d,]*\\s+ship`,\n ),\n\n /**\n * Bare \"return\" matched \"return to our office\" and \"returning patients\" — the\n * latter on exactly the kind of site this check runs against — and bare\n * \"refunds\" / \"money-back\" matched a legal disclaimer and a COMPETITOR's\n * guarantee quoted on a comparison page. A returns policy is a promise the\n * seller makes, so the pattern asks for the promise, and `NOT_OUR_ANSWER`\n * throws out the ones made by somebody else.\n */\n returns: anyOf(\n String.raw`\\b(?:returns?|refunds?|exchanges?)\\s+(?:and\\s+(?:exchanges?|refunds?)\\s+)?polic(?:y|ies)\\b`,\n String.raw`\\breturns?\\s+(?:and|&)\\s+exchanges?\\b`,\n String.raw`\\b\\d+[- ]day\\s+(?:returns?|refunds?|exchanges?|money[- ]back|guarantee|return\\s+window)\\b`,\n String.raw`\\b(?:returns?|refunds?|exchanges?)\\s+(?:are\\s+)?(?:accepted\\s+)?within\\s+\\d`,\n String.raw`\\bfree\\s+returns?\\b`,\n String.raw`\\b(?:you|we)\\s+(?:can|may|will|do|must)?\\s*(?:accept|offer|issue|process|request|return|refund)\\w*(?:\\s+(?:a|an|any|all|your|the|full|partial|unopened|unused|original|damaged))*\\s+(?:returns?|refunds?|exchanges?|items?|orders?|purchases?|products?)\\b`,\n String.raw`\\b(?:full|partial|store[- ]credit|prorated)\\s+refunds?\\b`,\n String.raw`\\b(?:our|a)\\s+(?:no[- ]questions[- ]asked\\s+)?money[- ]back\\s+guarantee\\b`,\n ),\n\n /**\n * Bare \"distributor\" / \"distribution\" / \"wholesale\" matched a client's name\n * on a portfolio page and \"distribution rights\" in a legal footer. The\n * requirement is a ROUTE for a partner, not the word for one, so the pattern\n * asks for the invitation: become, apply, programme, enquiry.\n */\n partnerRoute: anyOf(\n String.raw`\\b(?:become|becoming|apply\\s+to\\s+be(?:come)?|interested\\s+in\\s+becoming|join\\s+us\\s+as)\\s+(?:a|an|our)?\\s*(?:partner|distributor|dealer|reseller|stockist|retailer|affiliate)s?\\b`,\n String.raw`\\b(?:distribut(?:or|ion)|dealer|reseller|wholesale|partner(?:ship)?|stockist|trade)\\s+(?:program(?:me)?s?|applications?|enquir(?:y|ies)|inquir(?:y|ies)|opportunit(?:y|ies)|accounts?|portal|pricing|terms|network)\\b`,\n String.raw`\\bapply\\s+(?:now\\s+)?(?:for|to\\s+join)\\s+(?:a\\s+|our\\s+)?(?:wholesale|dealer|distributor|reseller|partner|trade)\\b`,\n String.raw`\\b(?:partner|distribute|resell|wholesale)\\s+with\\s+us\\b`,\n String.raw`\\bjoin\\s+our\\s+(?:dealer|partner|reseller|distributor|stockist)\\s+(?:network|program(?:me)?|team)\\b`,\n String.raw`\\b(?:looking|searching)\\s+for\\s+(?:new\\s+)?(?:distributors|dealers|resellers|partners|stockists)\\b`,\n String.raw`\\b(?:signing|recruiting|appointing|onboarding)\\s+(?:new\\s+)?(?:distributors|dealers|resellers|partners|stockists)\\b`,\n ),\n\n /**\n * Bare \"requirements\" matched \"requirements for your first visit\", bare\n * \"volume\" matched \"volume of work\", and bare \"territory\" matched the country\n * list inside a phone-input widget on three separate sites. A territory\n * answers the question only when the site says what its status is.\n */\n partnerTerms: anyOf(\n String.raw`\\b(?:exclusive|protected|open|available|assigned|unassigned|defined|remaining|new)\\s+territor(?:y|ies)\\b`,\n String.raw`\\bterritor(?:y|ies)\\s+(?:is|are)\\s+(?:exclusive|protected|open|available|assigned|awarded|granted|allocated|still)\\b`,\n String.raw`\\bterritor(?:y|ies)\\s+(?:rights|protection|exclusivity|agreement|restrictions)\\b`,\n String.raw`\\bminimum\\s+(?:order|volume|purchase|commitment|spend|quantity)\\b`,\n String.raw`\\bvolume\\s+commitment\\b`,\n String.raw`\\b(?:partner|dealer|reseller|distributor|stockist)\\s+(?:requirements|criteria|qualifications)\\b`,\n String.raw`\\bqualif(?:ication|ying)\\s+criteria\\b`,\n String.raw`\\blicens(?:e|ing)\\s+requirements?\\b`,\n ),\n} as const;\n\n/**\n * A promise made by somebody else is not this site's answer.\n *\n * Caught by replaying over the stored audits: a comparison page quoting a\n * competitor's money-back guarantee scored the prospect a returns policy, and\n * the competitor's own sentence was printed to them as the receipt.\n *\n * Applied to a WINDOW around the match rather than to the sentence, because\n * extracted page text is frequently headings and table cells joined with no\n * punctuation at all. There the whole site is one \"sentence\", and a\n * sentence-wide veto would silence every content check on the site because of\n * one word somewhere else on the page — our parsing limit reported as their\n * defect, in the other direction.\n */\nconst NOT_OUR_ANSWER =\n /\\b(?:competitors?|competing|the competition|unlike|versus|vs\\.?|compared (?:to|with)|other (?:agencies|firms|providers|companies|clinics|shops|practices|studios|brands)|most (?:agencies|firms|providers|companies|clinics|shops|practices|studios)|elsewhere|they (?:offer|charge|advertise|promise))\\b/i;\n\n/** How far either side of a match counts as its surroundings. */\nconst NOT_OUR_ANSWER_WINDOW = 90;\n\nfunction extractOf(page: PageCapture): PageExtract | null {\n return page.rendered ?? page.raw;\n}\n\n/**\n * All visible text across every readable page, in the case the site wrote it.\n *\n * It used to be lower-cased once here, which was free for matching — every\n * pattern below is `/i` — but the quotes are pulled out of this same string and\n * shown to the client as the receipt. A site that says \"what Michelle likes\n * best\" came back quoted as \"what michelle likes best\", which reads as though we\n * mangled their copy. Evidence has to look like evidence.\n */\nfunction siteText(pages: PageCapture[]): string {\n return pages.map((p) => extractOf(p)?.text ?? \"\").join(\" \");\n}\n\nfunction allAnchors(pages: PageCapture[]): { href: string; text: string }[] {\n const out: { href: string; text: string }[] = [];\n for (const page of pages) {\n for (const a of extractOf(page)?.anchors ?? []) out.push({ href: a.href, text: a.text });\n }\n return out;\n}\n\n/** A short quotation around one match, so a finding can show its work. */\nfunction quoteAt(haystack: string, at: number, length: number): string {\n const start = Math.max(0, at - 40);\n return `…${haystack\n .slice(start, at + length + 40)\n .replace(/\\s+/g, \" \")\n .trim()}…`;\n}\n\n/** A short quotation around the first match, so a finding can show its work. */\nfunction quote(haystack: string, pattern: RegExp): string | null {\n const m = pattern.exec(haystack);\n if (!m || m.index === undefined) return null;\n return quoteAt(haystack, m.index, m[0].length);\n}\n\n/**\n * The first match whose surroundings do not disown it.\n *\n * Every match here becomes a receipt, so the scan keeps going past one that\n * sits inside a comparison with somebody else's offer rather than giving up at\n * the first hit: a site that mentions a competitor's guarantee in one paragraph\n * and states its own in the next has answered the question.\n */\nfunction answerIn(text: string, pattern: RegExp): string | null {\n const scan = new RegExp(\n pattern.source,\n pattern.flags.includes(\"g\") ? pattern.flags : `${pattern.flags}g`,\n );\n for (let m = scan.exec(text); m !== null; m = scan.exec(text)) {\n if (m[0].length === 0) {\n scan.lastIndex += 1;\n continue;\n }\n const from = Math.max(0, m.index - NOT_OUR_ANSWER_WINDOW);\n const around = text.slice(from, m.index + m[0].length + NOT_OUR_ANSWER_WINDOW);\n if (NOT_OUR_ANSWER.test(around)) continue;\n return quoteAt(text, m.index, m[0].length);\n }\n return null;\n}\n\ntype Signals = {\n text: string;\n anchors: { href: string; text: string }[];\n schemaTypes: string[];\n /** Best enquiry form on the site, by field count. */\n bestEnquiryFields: number;\n /** False when no page carried a `forms` array at all — the field is optional\n * on reports stored before form extraction existed, and there a count of\n * zero means we never looked, not that the site has no form. */\n formsMeasured: boolean;\n /** Any phone written as a `tel:` link anywhere. */\n hasTappablePhone: boolean;\n /** False when there is no phone number at all, or when the report predates\n * `linked` being recorded — either way the tappable check cannot be judged. */\n phoneLinkMeasured: boolean;\n /** The whole journey map, not just its headline number. The headline is\n * computed over pages that HAVE a path, so reading it alone said \"yes, a\n * route from wherever they land\" about sites whose dead ends were listed\n * three inches further down the same report. */\n journey: JourneyMap | null;\n};\n\n/**\n * A price word and an actual amount, in the same breath.\n *\n * Ludlow Kingsley scored a published price off the word \"package\" in a sentence\n * about photographing product packaging, with the `$` coming from a case study.\n * The first fix required the two within 120 characters of each other, and the\n * decoy corpus then caught that this had not fixed anything — it passed only\n * because the fixture happened to put 400 characters between them. At the\n * distance the two actually occur on a portfolio site, a case-study figure still\n * scored as a price.\n *\n * Proximity was the wrong rule. What separates \"packages starting at $12,000\"\n * from \"we photographed each package. That campaign delivered $2.4M\" is not\n * distance — it is that the second pair spans a sentence boundary. A price and\n * the word introducing it are always in one clause; a coincidence usually is\n * not. So the two must share a sentence AND sit close within it, which also\n * degrades safely: extracted page text is often headings and table cells joined\n * with no punctuation at all, and there the proximity rule still applies.\n */\nconst PRICE_WINDOW = 60;\n\n/** Sentence-ish. A false split (on \"Dr.\" or \"Inc.\") can only make this check\n * stricter, never more permissive, so a naive rule is the safe one. */\nconst SENTENCE_SPLIT = /(?<=[.!?])\\s+/;\n\n/**\n * Money in a sentence about results is a result, not a price.\n *\n * Caught by replaying this check across the stored audits: our OWN site scored a\n * published price off \"double sales year over year for five straight years, from\n * $1M to $16M\". The amount is in the same sentence as the price word (\"from $\"),\n * so neither proximity nor the sentence rule saves it — the sentence is simply\n * about an outcome. Every agency and B2B site on the prospect list leads with\n * numbers like this, which makes it the single most likely way for this check to\n * embarrass us in front of the person who wrote the sentence.\n *\n * Deliberately excluded: \"save\"/\"savings\", which belong to real offers (\"save\n * $50 on your first order\"), and \"delivered\", which collides with shipping.\n */\nconst OUTCOME_CONTEXT =\n /\\b(revenue|sales|raised|funding|valuation|arr|mrr|grew|growth|profit|roi|year[- ]over[- ]year|yoy|increased|lift(?:ed)?|generated|worth|acquisition)\\b/i;\n\n/**\n * A charge for not turning up is not a price.\n *\n * \"A $50 cancellation fee applies to appointments missed without notice\" was\n * quoted to a client as proof they publish what their work costs. It is the\n * case-study bug wearing a different hat — a real amount in a sentence that\n * answers a different question — and a buyer reading it learns nothing about\n * what the actual service costs.\n *\n * Tested against the WINDOW the amount was found in rather than the whole\n * sentence, so a rate card that happens to end with a cancellation note still\n * scores the rates.\n */\nconst PENALTY_CONTEXT =\n /\\b(?:cancellation|no[- ]shows?|missed (?:appointments?|visits?)|late (?:fees?|payments?|cancellations?)|reschedul(?:e|ing)|returned (?:check|cheque)|bounced|overdue|penalt(?:y|ies)|interest charge|restocking)\\b/i;\n\nfunction priceSignal(text: string): string | null {\n let offset = 0;\n for (const sentence of text.split(SENTENCE_SPLIT)) {\n const base = offset;\n offset += sentence.length + 1;\n if (OUTCOME_CONTEXT.test(sentence)) continue;\n\n const words = new RegExp(PRICE_WORDS.source, \"gi\");\n for (let m = words.exec(sentence); m !== null; m = words.exec(sentence)) {\n const from = Math.max(0, m.index - PRICE_WINDOW);\n const window = sentence.slice(from, m.index + m[0].length + PRICE_WINDOW);\n const money = MONEY.exec(window);\n if (!money || money.index === undefined) continue;\n if (PENALTY_CONTEXT.test(window)) continue;\n // Centre the receipt on the AMOUNT, not on the word that led us to it. The\n // client reads this quote as the proof, and a quote containing \"packages\"\n // but no number does not look like evidence of a price. Quoted from the\n // full text so the sentence split does not clip the receipt.\n const at = base + from + money.index;\n const quoted = text.slice(Math.max(0, at - 70), at + money[0].length + 70);\n return `…${quoted.replace(/\\s+/g, \" \").trim()}…`;\n }\n }\n return null;\n}\n\nfunction gather(crawl: CrawlResult, checks: ChecksResult | null): Signals {\n const text = siteText(crawl.pages);\n const phones = checks?.consistency?.phones ?? [];\n let bestEnquiryFields = 0;\n let formsMeasured = false;\n for (const page of crawl.pages) {\n const forms = extractOf(page)?.forms;\n if (forms === undefined) continue;\n formsMeasured = true;\n for (const form of forms) {\n if (form.kind === \"enquiry\") bestEnquiryFields = Math.max(bestEnquiryFields, form.fieldCount);\n }\n }\n return {\n text,\n anchors: allAnchors(crawl.pages),\n schemaTypes: checks?.schema.typesFound ?? [],\n bestEnquiryFields,\n formsMeasured,\n // `linked` is optional on reports stored before it existed; only an explicit\n // true counts as tappable, and only an explicit false makes the question\n // answerable at all.\n hasTappablePhone: phones.some((p) => p.linked === true),\n phoneLinkMeasured: phones.length > 0 && phones.some((p) => p.linked !== undefined),\n journey: checks?.journey ?? null,\n };\n}\n\n/**\n * Does this hostname belong to the target?\n *\n * Registrable-domain matching without a public-suffix list: exact host, or a\n * subdomain of it. An entry with no dot (\"doctolib\") is a brand rather than a\n * domain — the same product answers on .fr, .de and .it — so it is matched\n * against the hostname's own labels instead.\n */\nfunction hostMatches(hostname: string, target: string): boolean {\n const host = hostname.replace(/^www\\./, \"\");\n if (!target.includes(\".\")) {\n const labels = host.split(\".\");\n return labels[0] === target || labels[labels.length - 2] === target;\n }\n return host === target || host.endsWith(`.${target}`);\n}\n\n/**\n * The first anchor pointing at one of these targets, or null.\n *\n * The href is PARSED, and the hostname compared — a substring test credited\n * \"cal.com\" to medical.com, surgical.com and socal.com, and to `mailto:`\n * addresses at all three, then printed the address to the client as proof they\n * had online booking. A target may carry a path prefix (\"google.com/maps\"),\n * which is checked against the path and nothing else.\n */\nconst linksTo = (s: Signals, targets: string[]): string | null => {\n for (const a of s.anchors) {\n let url: URL;\n try {\n url = new URL(a.href);\n } catch {\n // A relative href addresses this site, and nothing on this site is a\n // third-party booking system or somebody else's map.\n continue;\n }\n // `mailto:`, `tel:` and `javascript:` carry text that can look like a\n // domain and link to nowhere.\n if (url.protocol !== \"http:\" && url.protocol !== \"https:\") continue;\n const host = url.hostname.toLowerCase();\n const path = url.pathname.toLowerCase();\n for (const target of targets) {\n const slash = target.indexOf(\"/\");\n const wantHost = slash === -1 ? target : target.slice(0, slash);\n const wantPath = slash === -1 ? null : target.slice(slash);\n if (!hostMatches(host, wantHost)) continue;\n if (wantPath !== null && !path.startsWith(wantPath)) continue;\n return a.href;\n }\n }\n return null;\n};\n\nconst has = (s: Signals, re: RegExp): string | null => quote(s.text, re);\n\n/** A content answer: matched, then checked against what surrounds it. */\nconst answers = (s: Signals, re: RegExp): string | null => answerIn(s.text, re);\n\n/** One requirement, phrased once. `evidence` non-null means met; null means\n * missing. Pass `measured: false` for a check whose INPUT was unavailable —\n * that is a gap in our data, not a finding about the site. */\nfunction req(\n key: string,\n label: string,\n why: string,\n scope: Scope,\n evidence: string | null,\n measured = true,\n): GoalRequirement {\n return {\n key,\n label,\n status: !measured ? \"unmeasured\" : evidence !== null ? \"met\" : \"missing\",\n evidence,\n why,\n scope,\n };\n}\n\n/**\n * \"A route to you from wherever they land\", read off the whole journey map.\n *\n * This used to be `worstClicksToContact <= 2`, and journey.ts computes that\n * number only over pages that HAVE a path — a page in `deadEnds` never moves\n * it. So the goal section printed \"Yes, a route from wherever they land\" at the\n * top of the report while the site-health section listed the stranded pages\n * further down the same page. Two sections of one document contradicting each\n * other in front of the client is worse than either being wrong alone, because\n * it is visible without checking anything.\n *\n * One stranded page IS the failure this requirement is named for, so any dead\n * end makes it missing — and the evidence names the pages and the size of the\n * sample, because \"no path among the five pages we looked at\" and \"no path\n * exists\" are different claims and only one of them is ours to make.\n *\n * The evidence is a `missing` receipt, which is why this is built by hand\n * rather than through `req` — there, evidence means \"we found it\".\n */\nfunction reachability(s: Signals): GoalRequirement {\n const shape = (status: RequirementStatus, evidence: string | null): GoalRequirement => ({\n key: \"reachable\",\n label: \"A route to you from wherever they land\",\n status,\n evidence,\n why: \"Search engines and AI answers send people to whichever page answers their question, not to your home page.\",\n scope: \"quick\",\n });\n\n const journey = s.journey;\n // `anchorsMeasured` is absent on reports stored before it existed, and those\n // journeys were computed by reading a missing anchor list as \"this page links\n // nowhere\" — which is how every page of a working site becomes a dead end in\n // our data and nowhere else. Only an explicit true is a measurement.\n if (!journey || journey.anchorsMeasured !== true || journey.pagesExamined === 0) {\n return shape(\"unmeasured\", null);\n }\n\n if (journey.deadEnds.length > 0) {\n const shown = journey.deadEnds.slice(0, 3);\n const rest = journey.deadEnds.length - shown.length;\n return shape(\n \"missing\",\n `${journey.deadEnds.length} of ${journey.pagesExamined} pages we examined have no route to a way of contacting you: ${shown.join(\", \")}${rest > 0 ? `, and ${rest} more` : \"\"}`,\n );\n }\n\n const worst = journey.worstClicksToContact;\n // No dead ends and no worst distance means no page carried a distance at all,\n // which is a shape we cannot read rather than a site we can judge.\n if (worst === null) return shape(\"unmeasured\", null);\n if (worst > 2) return shape(\"missing\", null);\n return shape(\n \"met\",\n worst === 0\n ? `a way to make contact on every one of the ${journey.pagesExamined} pages we examined`\n : `at most ${worst} ${worst === 1 ? \"click\" : \"clicks\"} away, across ${journey.pagesExamined} pages examined`,\n );\n}\n\n/**\n * Requirements shared by every goal that ends in a human conversation. Pulled\n * out because four of the seven goals need exactly these and duplicating them\n * is how they drift apart.\n */\nfunction contactBasics(s: Signals): GoalRequirement[] {\n return [\n req(\n \"tappable-phone\",\n \"A phone number they can tap\",\n \"Most of your visitors are on a phone. A number written as text has to be memorised and retyped, at the exact moment they had decided to call.\",\n \"quick\",\n s.hasTappablePhone ? \"tel: link found\" : null,\n // A site with no phone number anywhere has nothing to make tappable, and\n // a report stored before `linked` was recorded cannot answer this at all.\n s.phoneLinkMeasured,\n ),\n reachability(s),\n ];\n}\n\nexport function checkGoal(\n goal: SiteGoal,\n source: \"inferred\" | \"operator\",\n crawl: CrawlResult,\n checks: ChecksResult | null,\n): GoalFit {\n const s = gather(crawl, checks);\n let requirements: GoalRequirement[] = [];\n\n const hours = req(\n \"hours\",\n \"Your opening hours, in text\",\n \"It is the second thing anyone checks and the first thing that goes stale. When your site does not say, an AI answer will take it from a directory listing you do not control.\",\n \"quick\",\n has(s, HOURS_IN_TEXT) ??\n (s.schemaTypes.some((t) => /openinghours/i.test(t)) ? \"openingHours in schema\" : null),\n );\n\n const address = req(\n \"address\",\n \"An address and a map link\",\n \"Somebody deciding whether to come to you needs to know where you are without leaving your site to find out.\",\n \"quick\",\n linksTo(s, MAP_HOSTS) ??\n (s.schemaTypes.includes(\"PostalAddress\") ? \"PostalAddress schema\" : null),\n );\n\n const price = req(\n \"price-signal\",\n \"Some signal of what it costs\",\n \"It is the most common question buyers ask before making contact, and the one most sites never answer. A range, a starting point or a worked example is enough — silence sends them to someone who does say.\",\n \"content\",\n priceSignal(s.text),\n );\n\n const qualifying = req(\n \"qualifying-form\",\n \"A form that asks enough to have a real first conversation\",\n \"A name-and-email box produces enquiries you have to qualify by hand. Three or four more fields turn a lead into a briefed conversation.\",\n \"content\",\n s.bestEnquiryFields >= 4 ? `enquiry form with ${s.bestEnquiryFields} fields` : null,\n s.formsMeasured,\n );\n\n switch (goal) {\n case \"book\":\n requirements = [\n req(\n \"booking\",\n \"A way to book without calling\",\n \"A caller has to reach you during your hours. A booking link works at 11pm, which is when people schedule.\",\n \"structural\",\n linksTo(s, BOOKING_HOSTS),\n ),\n hours,\n ...contactBasics(s),\n req(\n \"new-clients\",\n \"Whether you are taking new clients\",\n \"Nobody wants to fill in a form to find out you are full.\",\n \"content\",\n answers(s, ANSWERS.acceptingClients),\n ),\n address,\n ];\n break;\n\n case \"enquire\":\n requirements = [qualifying, price, ...contactBasics(s)];\n break;\n\n case \"call\":\n requirements = [\n ...contactBasics(s),\n hours,\n req(\n \"what-to-expect\",\n \"What happens when they call\",\n \"A first call is easier to make when the visitor knows who picks up and what it costs them.\",\n \"content\",\n answers(s, ANSWERS.whatToExpect),\n ),\n ];\n break;\n\n case \"visit\":\n requirements = [\n address,\n hours,\n ...contactBasics(s),\n req(\n \"getting-there\",\n \"Parking or transit\",\n \"The last hundred metres are where a visit gets abandoned.\",\n \"content\",\n answers(s, ANSWERS.gettingThere),\n ),\n ];\n break;\n\n case \"buy\":\n requirements = [\n req(\n \"shipping\",\n \"Shipping cost and timing\",\n \"Unexpected shipping cost is the single most common reason a cart is abandoned.\",\n \"content\",\n answers(s, ANSWERS.shipping),\n ),\n req(\n \"returns\",\n \"A returns policy\",\n \"Buyers look for it before their first order from a company they do not know.\",\n \"content\",\n answers(s, ANSWERS.returns),\n ),\n price,\n ...contactBasics(s),\n ];\n break;\n\n case \"demo\":\n requirements = [\n req(\n \"demo-path\",\n \"A demo request that is not the general contact form\",\n \"Someone ready for a demo is further along than someone with a question, and sending both to one box loses the difference.\",\n \"structural\",\n linksTo(s, BOOKING_HOSTS) ??\n (s.anchors.some((a) =>\n /\\b(book a demo|get a demo|request a demo|see it in action)\\b/i.test(a.text),\n )\n ? \"demo link in navigation\"\n : null),\n ),\n qualifying,\n price,\n ...contactBasics(s),\n ];\n break;\n\n case \"partner\":\n requirements = [\n req(\n \"partner-route\",\n \"A named route for partners, separate from customers\",\n \"A distributor who lands on a customer contact form usually leaves.\",\n \"content\",\n answers(s, ANSWERS.partnerRoute),\n ),\n req(\n \"requirements\",\n \"What you are looking for in a partner\",\n \"Territory, volume or credentials — enough for someone to tell whether it is worth writing to you.\",\n \"content\",\n answers(s, ANSWERS.partnerTerms),\n ),\n ...contactBasics(s),\n ];\n break;\n\n case \"unknown\":\n // Deliberately no requirements. Inventing a checklist for a purpose we\n // could not identify would grade the site against our guess and report\n // the result as their failing. The finding IS that we could not tell.\n requirements = [];\n break;\n }\n\n return {\n goal,\n source,\n requirements,\n met: requirements.filter((r) => r.status === \"met\").length,\n // Only what we could judge. A denominator that counts unmeasured checks\n // would report \"3 of 6\" for a site where we looked at four things.\n total: requirements.filter((r) => r.status !== \"unmeasured\").length,\n };\n}\n\n/** Report order: unmet before met, then by how much work it is. A reader should\n * hit the afternoon's work first and arrive at the structural questions having\n * already agreed with everything above them. */\nconst SCOPE_ORDER: Record<Scope, number> = { quick: 0, content: 1, structural: 2 };\n/** Missing first, then met, then the ones we could not judge — which belong at\n * the bottom because they are a note about our measurement, not about them. */\nconst STATUS_ORDER: Record<RequirementStatus, number> = { missing: 0, met: 1, unmeasured: 2 };\n\nexport function orderRequirements(reqs: GoalRequirement[]): GoalRequirement[] {\n return [...reqs].sort(\n (a, b) =>\n STATUS_ORDER[a.status] - STATUS_ORDER[b.status] ||\n SCOPE_ORDER[a.scope] - SCOPE_ORDER[b.scope],\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/forms/notify.ts"],"sourcesContent":["import type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport type { SubmissionRow, NotifyStatus } from \"../reports/submission-row.js\";\nimport type { ResendSendInput } from \"../reports/send/resend.js\";\nimport { hostsMatch } from \"./ingest.js\";\nimport { escapeHtml } from \"../util/html.js\";\nimport { operatorEmail } from \"../util/operator.js\";\n\nconst FORMS_FROM = \"forms@reddoorla.com\";\n// Reply-To only: a client replying to a lead notification should reach the\n// shared inbox. The RECIPIENT fallback is the operator's own — see util/operator.\nconst FALLBACK_REPLY_TO = \"info@reddoorla.com\";\n\n/** Strip characters that would break an RFC 5322 display name. */\nfunction displayName(raw: string): string {\n return raw.replace(/[\"\\r\\n]/g, \"\").trim() || \"Reddoor\";\n}\n\nfunction pocAddress(site: WebsiteRow): string | null {\n return site.pointOfContact ?? site.reportRecipientsTo ?? null;\n}\n\n/** Coerce a string|string[] recipient config into a clean, de-duped address list. */\nfunction normalizeRecipients(v: string | string[] | undefined): string[] {\n const arr = Array.isArray(v) ? v : v ? [v] : [];\n const seen = new Set<string>();\n const out: string[] = [];\n for (const raw of arr) {\n if (typeof raw !== \"string\") continue;\n const t = raw.trim();\n if (t && !seen.has(t)) {\n seen.add(t);\n out.push(t);\n }\n }\n return out;\n}\n\nexport type Recipients = { to: string[]; cc: string[] };\n\n/**\n * Where a submission notification goes.\n * - Pre-launch (status !== \"maintained\"): the operator only — no routing, no CC.\n * Preserves the verify guard (flip a site to \"launching\" to route tests to\n * yourself).\n * - Maintained + a `Notify Routing` config: address by the routing field's value\n * (`extraFields[field]`) → its matched route, else the config `default`; CC from\n * the config. If nothing resolves, fall through to the single POC.\n * - Maintained, no routing: the single site POC (pointOfContact ?? reportRecipientsTo).\n * Returns null only when nothing resolves — the lead is still persisted; notify skips.\n */\nexport function resolveRecipients(site: WebsiteRow, submission: SubmissionRow): Recipients | null {\n if (site.status !== \"maintained\") {\n return { to: [operatorEmail()], cc: [] };\n }\n const routing = site.notifyRouting;\n if (routing) {\n const value = parseExtraFields(submission.extraFields)[routing.field];\n const match = typeof value === \"string\" ? routing.routes[value] : undefined;\n const to = normalizeRecipients(match ?? routing.default);\n if (to.length > 0) return { to, cc: normalizeRecipients(routing.cc) };\n // routing matched nothing → fall through to the POC below\n }\n const poc = pocAddress(site);\n return poc ? { to: [poc], cc: [] } : null;\n}\n\n/** Who a submission to this site would notify, answered WITHOUT sending one. */\nexport type NotifyTarget = {\n /** `client` means a real point of contact receives it. `operator` means the\n * pre-launch guard is holding and only you do. `nobody` means nothing\n * resolves — the lead is still stored, but no notification goes out. */\n audience: \"client\" | \"operator\" | \"nobody\";\n /** Every address a submission could reach, across ALL routing branches. */\n to: string[];\n cc: string[];\n /** Plain-language reason, naming the field that decided it. */\n reason: string;\n};\n\n/** A submission that exists only to ask resolveRecipients a question. */\nfunction probeSubmission(extraFields: string | null): SubmissionRow {\n return {\n id: \"probe\",\n submissionId: null,\n siteId: \"probe\",\n formType: \"contact\" as SubmissionRow[\"formType\"],\n name: \"probe\",\n email: \"probe@example.com\",\n phone: null,\n message: null,\n extraFields,\n sourceUrl: null,\n utm: null,\n submittedAt: null,\n status: \"new\" as SubmissionRow[\"status\"],\n notifyStatus: \"pending\" as NotifyStatus,\n resendMessageId: null,\n };\n}\n\n/**\n * Resolve who a form submission would notify, for a site, right now.\n *\n * This exists because the pre-launch guard had no feedback. Its whole state is\n * one Airtable `Status` cell nobody is looking at while testing, the site\n * reports nothing about it, and the only way to learn the answer was to submit\n * a form and read the `resend_message_id` afterwards. On 2026-08-03 an intended\n * flip never landed and a real client received a test lead — an email cannot be\n * recalled. A guard whose state you cannot see is a guard you will eventually\n * assume is on.\n *\n * Every address here comes back out of `resolveRecipients` itself rather than\n * from a second reading of the same rules — for a routed site each branch is\n * probed with a synthetic submission and the results unioned. A copy of the\n * routing logic that drifted would be worse than no answer at all, because it\n * would be a confident wrong one.\n */\nexport function describeNotifyTarget(site: WebsiteRow): NotifyTarget {\n if (site.status !== \"maintained\") {\n const guarded = resolveRecipients(site, probeSubmission(null));\n return {\n audience: \"operator\",\n to: guarded?.to ?? [],\n cc: [],\n reason: `Status is ${site.status === null ? \"blank\" : `\"${site.status}\"`}, not \"maintained\" — the pre-launch guard routes every notification to the operator.`,\n };\n }\n\n const routing = site.notifyRouting;\n if (!routing) {\n const direct = resolveRecipients(site, probeSubmission(null));\n if (!direct || direct.to.length === 0) {\n return {\n audience: \"nobody\",\n to: [],\n cc: [],\n reason: `Status is \"maintained\" but the site has no Point of Contact and no Notify Routing — the lead is stored and no notification is sent.`,\n };\n }\n return {\n audience: \"client\",\n to: direct.to,\n cc: direct.cc,\n reason: `Status is \"maintained\" — notifications go to the site's Point of Contact.`,\n };\n }\n\n // Probe every declared route value, plus one value that matches none so the\n // `default` (and the POC fall-through behind it) is covered too.\n const to = new Set<string>();\n const cc = new Set<string>();\n const values = [...Object.keys(routing.routes), \"\u0000no-such-route\"];\n for (const value of values) {\n const r = resolveRecipients(site, probeSubmission(JSON.stringify({ [routing.field]: value })));\n r?.to.forEach((a) => to.add(a));\n r?.cc.forEach((a) => cc.add(a));\n }\n if (to.size === 0) {\n return {\n audience: \"nobody\",\n to: [],\n cc: [],\n reason: `Status is \"maintained\" with Notify Routing on \"${routing.field}\", but no route, default or Point of Contact resolves to an address.`,\n };\n }\n return {\n audience: \"client\",\n to: [...to],\n cc: [...cc],\n reason: `Status is \"maintained\" with Notify Routing on \"${routing.field}\" — the recipient depends on that field's value; every address it could reach is listed.`,\n };\n}\n\n/** Humanize an extraFields key for display: \"appointment_date\" → \"Appointment date\". */\nfunction humanizeKey(k: string): string {\n const spaced = k.replace(/[_-]+/g, \" \").trim();\n return spaced ? spaced.charAt(0).toUpperCase() + spaced.slice(1) : k;\n}\n\nfunction formatValue(v: unknown): string {\n if (v === null || v === undefined) return \"—\";\n if (typeof v === \"string\") return v;\n if (typeof v === \"number\" || typeof v === \"boolean\") return String(v);\n return JSON.stringify(v);\n}\n\n/** Parse the stored `extraFields` JSON into a plain object — bad JSON or a\n * non-object yields {} (never throws). Shared by the email renderer and routing. */\nfunction parseExtraFields(raw: string | null): Record<string, unknown> {\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n } catch {\n /* malformed JSON → no fields */\n }\n return {};\n}\n\nfunction extraFieldRows(raw: string | null): Array<[string, string]> {\n return Object.entries(parseExtraFields(raw))\n .filter(([, v]) => !(typeof v === \"string\" && v.trim() === \"\"))\n .map(([k, v]) => [humanizeKey(k), formatValue(v)] as [string, string]);\n}\n\nfunction fieldsTable(submission: SubmissionRow): string {\n const rows: Array<[string, string]> = [\n [\"Form\", submission.formType],\n [\"Name\", submission.name || \"—\"],\n [\"Email\", submission.email || \"—\"],\n ];\n if (submission.phone) rows.push([\"Phone\", submission.phone]);\n // Site-specific context (the artwork an inquiry is about, the event an rsvp is\n // for, etc.) lives in extraFields — surface it so the recipient sees what the\n // submitter was looking at, not just their name and email.\n rows.push(...extraFieldRows(submission.extraFields));\n if (submission.sourceUrl) rows.push([\"Page\", submission.sourceUrl]);\n if (submission.utm) rows.push([\"UTM\", submission.utm]);\n const body = rows\n .map(([k, v]) => `<tr><td><strong>${escapeHtml(k)}</strong></td><td>${escapeHtml(v)}</td></tr>`)\n .join(\"\");\n const message = submission.message\n ? `<p style=\"white-space:pre-wrap\">${escapeHtml(submission.message)}</p>`\n : \"\";\n return `<table>${body}</table>${message}`;\n}\n\n/** POC notification — the primary email; null when the site has no contact address. */\nexport function buildPocNotification(\n site: WebsiteRow,\n submission: SubmissionRow,\n): ResendSendInput | null {\n if (submission.status === \"spam_auto\" || submission.status === \"spam\") return null;\n const recipients = resolveRecipients(site, submission);\n if (!recipients || recipients.to.length === 0) return null;\n const input: ResendSendInput = {\n from: `${displayName(site.name)} Forms <${FORMS_FROM}>`,\n to: recipients.to,\n subject: `New ${submission.formType} from ${site.name}`,\n html: `<h2>New ${escapeHtml(submission.formType)} submission — ${escapeHtml(\n site.name,\n )}</h2>${fieldsTable(submission)}`,\n };\n if (recipients.cc.length > 0) input.cc = recipients.cc;\n // Reply straight to the lead.\n if (submission.email) input.replyTo = submission.email;\n return input;\n}\n\n/** Autoresponder to the submitter — null when there's no submitter email. */\nexport function buildAutoresponder(\n site: WebsiteRow,\n submission: SubmissionRow,\n): ResendSendInput | null {\n if (submission.status === \"spam_auto\" || submission.status === \"spam\") return null;\n if (!submission.email) return null;\n // A submitter email on the site's OWN domain is never a real lead wanting a\n // confirmation — it is the spoofed-sender backscatter case (a bot writes the\n // site's info@ as its email and the \"We got your message\" lands in the\n // client's inbox). The POC notification still sends; only this email is\n // suppressed. Unparseable/blank site url → fail open and send, same\n // philosophy as turnstileHostnameAcceptable.\n const emailDomain = submission.email.split(\"@\").pop() ?? \"\";\n let siteHost = \"\";\n try {\n siteHost = new URL(site.url).hostname;\n } catch {\n /* fail open */\n }\n if (siteHost && hostsMatch(emailDomain, siteHost)) return null;\n const intro = site.copyIntro ?? `Thanks for reaching out to ${site.name}.`;\n const contact = site.copyContact ?? \"We've received your message and will be in touch soon.\";\n const footer = site.copyFooter ?? site.name;\n return {\n from: `${displayName(site.name)} <${FORMS_FROM}>`,\n to: [submission.email],\n replyTo: resolveRecipients(site, submission)?.to[0] ?? FALLBACK_REPLY_TO,\n subject: \"We got your message\",\n html: `<p>${escapeHtml(intro)}</p><p>${escapeHtml(contact)}</p><p>${escapeHtml(footer)}</p>`,\n };\n}\n\nexport type NotifyDeps = {\n send: (input: ResendSendInput) => Promise<{ messageId: string }>;\n};\n\nexport type NotifyOutcome = { status: NotifyStatus; messageId: string | null };\n\n/**\n * Send the POC notification (primary — drives notifyStatus) then the submitter\n * autoresponder (best-effort — logged, never changes the outcome). The submission\n * is already persisted before this runs, so a Resend outage degrades to\n * notifyStatus=\"failed\", never a lost lead.\n */\nexport async function notifySubmission(\n deps: NotifyDeps,\n site: WebsiteRow,\n submission: SubmissionRow,\n): Promise<NotifyOutcome> {\n const poc = buildPocNotification(site, submission);\n let outcome: NotifyOutcome;\n if (!poc) {\n outcome = { status: \"skipped\", messageId: null };\n } else {\n try {\n const { messageId } = await deps.send(poc);\n outcome = { status: \"sent\", messageId };\n } catch (err) {\n console.error(`[submissions] POC notification failed: ${String(err)}`);\n outcome = { status: \"failed\", messageId: null };\n }\n }\n const auto = buildAutoresponder(site, submission);\n if (auto) {\n try {\n await deps.send(auto);\n } catch (err) {\n console.error(`[submissions] autoresponder failed: ${String(err)}`);\n }\n }\n return outcome;\n}\n\n/**\n * Build the ingest `notify` dependency from a Resend send fn — or `null` when the\n * Resend client couldn't even be constructed (e.g. `RESEND_API_KEY` unset). A null\n * send marks the notification `failed` WITHOUT attempting it, so a Resend\n * misconfiguration degrades to a captured-but-unemailed lead rather than aborting\n * ingest and losing it. Mirrors the in-flight failure isolation in notifySubmission.\n */\nexport function makeNotify(\n send: NotifyDeps[\"send\"] | null,\n): (site: WebsiteRow, submission: SubmissionRow) => Promise<NotifyOutcome> {\n return (site, submission) =>\n send\n ? notifySubmission({ send }, site, submission)\n : Promise.resolve({ status: \"failed\", messageId: null });\n}\n"],"mappings":"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","names":[]}
|