@evinvest/kitstart 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +178 -25
  2. package/dist/cli/kitstart-outbox.d.ts +2 -0
  3. package/dist/cli/kitstart-outbox.js +10 -0
  4. package/dist/cli/outbox.d.ts +5 -0
  5. package/dist/cli/outbox.js +77 -0
  6. package/dist/core/accept.d.ts +44 -3
  7. package/dist/core/accept.js +96 -15
  8. package/dist/core/analytics.d.ts +4 -1
  9. package/dist/core/analytics.js +14 -0
  10. package/dist/core/lead-capture-format.d.ts +6 -0
  11. package/dist/core/lead-capture-format.js +13 -0
  12. package/dist/core/lead-capture-text.d.ts +56 -2
  13. package/dist/core/lead-capture-text.js +63 -0
  14. package/dist/core/lead.d.ts +101 -11
  15. package/dist/core/lead.js +66 -11
  16. package/dist/core/phone.d.ts +20 -9
  17. package/dist/core/phone.js +43 -19
  18. package/dist/core/pricing/flow.d.ts +27 -0
  19. package/dist/core/pricing/flow.js +37 -0
  20. package/dist/core/pricing/index.d.ts +4 -0
  21. package/dist/core/pricing/index.js +4 -0
  22. package/dist/core/pricing/model.d.ts +129 -0
  23. package/dist/core/pricing/model.js +34 -0
  24. package/dist/core/pricing/price.d.ts +34 -0
  25. package/dist/core/pricing/price.js +105 -0
  26. package/dist/core/pricing/validate.d.ts +27 -0
  27. package/dist/core/pricing/validate.js +244 -0
  28. package/dist/core/site.d.ts +7 -0
  29. package/dist/core/site.js +11 -0
  30. package/dist/core/status.d.ts +13 -0
  31. package/dist/core/status.js +19 -2
  32. package/dist/index.d.ts +7 -6
  33. package/dist/index.js +7 -6
  34. package/dist/next/quote-route.d.ts +22 -2
  35. package/dist/next/quote-route.js +72 -10
  36. package/dist/react/LeadCapture.d.ts +37 -7
  37. package/dist/react/LeadCapture.js +34 -9
  38. package/dist/react/LeadCaptureChannels.d.ts +28 -3
  39. package/dist/react/LeadCaptureChannels.js +21 -5
  40. package/dist/react/LeadCaptureEstimate.d.ts +46 -0
  41. package/dist/react/LeadCaptureEstimate.js +69 -0
  42. package/dist/react/LeadCaptureFields.d.ts +39 -7
  43. package/dist/react/LeadCaptureFields.js +91 -21
  44. package/dist/react/LeadCaptureNeed.d.ts +5 -0
  45. package/dist/react/LeadCaptureNeed.js +38 -2
  46. package/dist/react/LeadCapturePriced.d.ts +19 -0
  47. package/dist/react/LeadCapturePriced.js +14 -0
  48. package/dist/react/LeadCaptureSubmit.d.ts +16 -0
  49. package/dist/react/LeadCaptureSubmit.js +13 -0
  50. package/dist/react/QuoteFormShell.d.ts +2 -0
  51. package/dist/react/QuoteFormShell.js +3 -2
  52. package/dist/react/use-lead-error.d.ts +24 -0
  53. package/dist/react/use-lead-error.js +73 -0
  54. package/dist/react/use-lead-events.d.ts +5 -0
  55. package/dist/react/use-lead-events.js +13 -2
  56. package/dist/react/use-lead-submit.d.ts +48 -9
  57. package/dist/react/use-lead-submit.js +129 -13
  58. package/dist/react/use-validity.d.ts +17 -0
  59. package/dist/react/use-validity.js +38 -0
  60. package/dist/server/env.d.ts +9 -0
  61. package/dist/server/env.js +12 -0
  62. package/dist/server/index.d.ts +3 -2
  63. package/dist/server/index.js +3 -2
  64. package/dist/server/lead-store-sqlite.d.ts +5 -1
  65. package/dist/server/lead-store-sqlite.js +63 -2
  66. package/dist/server/lead-webhook.d.ts +68 -1
  67. package/dist/server/lead-webhook.js +34 -2
  68. package/dist/server/pricing-source.d.ts +25 -0
  69. package/dist/server/pricing-source.js +48 -0
  70. package/dist/server/webhook-outbox.d.ts +27 -1
  71. package/dist/server/webhook-outbox.js +33 -6
  72. package/dist/testing/index.d.ts +1 -0
  73. package/dist/testing/index.js +1 -0
  74. package/dist/testing/landing-contract.js +6 -0
  75. package/dist/testing/lead-rules.d.ts +18 -0
  76. package/dist/testing/lead-rules.js +50 -0
  77. package/dist/testing/lead-store-contract.js +28 -1
  78. package/package.json +3 -2
  79. package/template/app/[locale]/[location]/page.tsx +4 -2
  80. package/template/app/quote/route.ts +2 -1
  81. package/template/flake.nix +1 -1
  82. package/template/package.json +1 -1
  83. package/template/src/entities/content/model/en.ts +1 -1
  84. package/template/src/entities/content/model/fr.ts +1 -1
  85. package/template/src/shared/config/env.ts +4 -1
  86. package/template/src/shared/config/lead.ts +17 -4
  87. package/template/src/shared/config/pricing.ts +49 -0
  88. package/template/src/shared/config/site.ts +3 -0
  89. package/template/src/views/home/ui/PlaceHome.tsx +6 -3
  90. package/template/tests/bundle_budget.txt +3 -1
  91. package/template/tests/e2e/flows.spec.ts +106 -0
  92. package/template/tests/e2e/mock-panel.mjs +20 -0
  93. package/template/tests/e2e/playwright.config.ts +25 -9
  94. package/template/tests/e2e/quote-form.spec.ts +86 -1
@@ -56,6 +56,17 @@ function leadWebhook(source) {
56
56
  }
57
57
  return { url, keyId, secret };
58
58
  }
59
+ function rateLimit(source, production) {
60
+ const raw = opt(source, "LEAD_RATE_LIMIT");
61
+ if (raw === null)
62
+ return { limit: production ? 5 : 100, windowMs: 600_000 };
63
+ const parsed = /^(\d+)\/(\d+)$/.exec(raw);
64
+ const limit = Number(parsed?.[1]);
65
+ const seconds = Number(parsed?.[2]);
66
+ if (!parsed || limit < 1 || seconds < 1)
67
+ throw new Error("LEAD_RATE_LIMIT: <count>/<seconds>, both at least 1, e.g. 5/600");
68
+ return { limit, windowMs: seconds * 1000 };
69
+ }
59
70
  export function parseServerEnv(site, source) {
60
71
  const production = source["NODE_ENV"] === "production";
61
72
  const trust = opt(source, "TRUSTED_PROXY");
@@ -76,6 +87,7 @@ export function parseServerEnv(site, source) {
76
87
  // Explicit because PostHog rejects a project's events at the other
77
88
  // region's host; the audience is European, so EU unless told otherwise.
78
89
  posthogHost: url(source, "POSTHOG_HOST") ?? "https://eu.i.posthog.com",
90
+ leadRateLimit: rateLimit(source, production),
79
91
  };
80
92
  }
81
93
  /** Lazy and cached: `next build` imports the brand's env module and must not need prod secrets. */
@@ -6,6 +6,7 @@
6
6
  import "server-only";
7
7
  export { createServerEnv, parseServerEnv, type EnvSource, type ServerEnv } from "./env.js";
8
8
  export { createPlaceSource, PLACE_REVALIDATE_SECONDS, PlaceSourceError, type PlaceSource, type PlaceSourceOptions } from "./place-source.js";
9
+ export { createPricingSource, type PricingSource, type PricingSourceOptions } from "./pricing-source.js";
9
10
  export { describeLeadDb, openLeadStore, parseLeadDb, type LeadDb } from "./lead-store.js";
10
11
  export { openSqliteLeadStore, type SqliteLeadStore } from "./lead-store-sqlite.js";
11
12
  export { LeadStoreNotImplemented, openPostgresLeadStore } from "./lead-store-postgres.js";
@@ -13,6 +14,6 @@ export { parseSmtpUrl, sendMail, type Mail, type SendOptions } from "./smtp.js";
13
14
  export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE, type LeadMail, type LeadNotifier, type NotifyEnv } from "./notify.js";
14
15
  export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust, type ProxyTrust } from "./client-key.js";
15
16
  export { checkLeadStore } from "./boot.js";
16
- export { leadWebhook, panelChannel, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions } from "./lead-webhook.js";
17
- export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_TICK_MS, type OutboxRow, type OutboxState, type TickReport, type WebhookOutbox, type WebhookOutboxOptions, type WebhookTarget, } from "./webhook-outbox.js";
17
+ export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions, type PanelFlow, type PanelFlowProperties, } from "./lead-webhook.js";
18
+ export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_HORIZON_MS, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_MAX_DELAY_MS, WEBHOOK_TICK_MS, type DeadRow, type OutboxRow, type OutboxState, type TickReport, type WebhookOutbox, type WebhookOutboxOptions, type WebhookTarget, } from "./webhook-outbox.js";
18
19
  export { signatureHeaders, signWebhook, type WebhookSignatureHeaders, type WebhookSigning } from "./webhook-signature.js";
@@ -6,6 +6,7 @@
6
6
  import "server-only";
7
7
  export { createServerEnv, parseServerEnv } from "./env.js";
8
8
  export { createPlaceSource, PLACE_REVALIDATE_SECONDS, PlaceSourceError } from "./place-source.js";
9
+ export { createPricingSource } from "./pricing-source.js";
9
10
  export { describeLeadDb, openLeadStore, parseLeadDb } from "./lead-store.js";
10
11
  export { openSqliteLeadStore } from "./lead-store-sqlite.js";
11
12
  export { LeadStoreNotImplemented, openPostgresLeadStore } from "./lead-store-postgres.js";
@@ -13,6 +14,6 @@ export { parseSmtpUrl, sendMail } from "./smtp.js";
13
14
  export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE } from "./notify.js";
14
15
  export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust } from "./client-key.js";
15
16
  export { checkLeadStore } from "./boot.js";
16
- export { leadWebhook, panelChannel } from "./lead-webhook.js";
17
- export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_TICK_MS, } from "./webhook-outbox.js";
17
+ export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, } from "./lead-webhook.js";
18
+ export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_HORIZON_MS, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_MAX_DELAY_MS, WEBHOOK_TICK_MS, } from "./webhook-outbox.js";
18
19
  export { signatureHeaders, signWebhook } from "./webhook-signature.js";
@@ -1,5 +1,5 @@
1
1
  import "server-only";
2
- import { type LeadStore } from "../core/lead.js";
2
+ import { type Lead, type LeadStore } from "../core/lead.js";
3
3
  /**
4
4
  * The SQLite adapter of the `LeadStore` port: one file on the pod's volume.
5
5
  *
@@ -9,6 +9,10 @@ import { type LeadStore } from "../core/lead.js";
9
9
  * nothing — and map to the lead's `subject`, `locality` and `mobile`.
10
10
  */
11
11
  export interface SqliteLeadStore extends LeadStore {
12
+ findSubmission(submissionId: string): Promise<{
13
+ id: number;
14
+ lead: Lead;
15
+ } | null>;
12
16
  /** The schema version the file is at after opening. */
13
17
  version(): number;
14
18
  }
@@ -2,6 +2,7 @@ import "server-only";
2
2
  import { mkdirSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { channelOf, LEAD_SCHEMA_VERSION } from "../core/lead.js";
5
+ import { LEAD_FLOWS } from "../core/pricing/flow.js";
5
6
  // `node:sqlite` is reached through `getBuiltinModule` rather than an import so
6
7
  // the bundler never has to resolve a builtin it may not know yet; the type is
7
8
  // the module's own.
@@ -50,6 +51,22 @@ const STEPS = [
50
51
  db.exec("ALTER TABLE leads ADD COLUMN consent_at TEXT");
51
52
  db.exec("ALTER TABLE leads ADD COLUMN consent_text TEXT");
52
53
  },
54
+ // 6 — the script's id for a submission, so a resend after a lost answer is
55
+ // the same row. Unique where present; a plain post has none, and NULLs
56
+ // never collide.
57
+ db => {
58
+ db.exec("ALTER TABLE leads ADD COLUMN submission_id TEXT");
59
+ db.exec("CREATE UNIQUE INDEX leads_submission_id ON leads (submission_id) WHERE submission_id IS NOT NULL");
60
+ },
61
+ // 7 — how the need was sold (`quote`, `estimate`, `fixed`), and for the two
62
+ // priced flows the server's price, the model's date and an estimate's
63
+ // answers (JSON). Nullable: a row from before, a callback, a quote.
64
+ db => {
65
+ db.exec("ALTER TABLE leads ADD COLUMN flow TEXT");
66
+ db.exec("ALTER TABLE leads ADD COLUMN quoted_cents INTEGER");
67
+ db.exec("ALTER TABLE leads ADD COLUMN pricing_valid_from TEXT");
68
+ db.exec("ALTER TABLE leads ADD COLUMN estimate_inputs TEXT");
69
+ },
53
70
  ];
54
71
  if (STEPS.length !== LEAD_SCHEMA_VERSION) {
55
72
  // The version belongs to the port, shared by every adapter; this table of
@@ -57,6 +74,45 @@ if (STEPS.length !== LEAD_SCHEMA_VERSION) {
57
74
  throw new Error(`leads: ${STEPS.length} sqlite steps for schema version ${LEAD_SCHEMA_VERSION}`);
58
75
  }
59
76
  const RUST_COLUMNS = ["id", "job", "zip", "mobile", "at"];
77
+ function str(row, key) {
78
+ const value = column(row, key);
79
+ return typeof value === "string" ? value : null;
80
+ }
81
+ const VERDICTS = ["honeypot", "too-fast", "rate-limited"];
82
+ /** A JSON object of strings, or `{}`: what `extras` and `estimate_inputs` hold. */
83
+ function strings(text) {
84
+ const value = JSON.parse(text ?? "{}");
85
+ return Object.fromEntries(Object.entries(typeof value === "object" && value !== null ? value : {}).filter((e) => typeof e[1] === "string"));
86
+ }
87
+ function priceOf(row) {
88
+ const cents = column(row, "quoted_cents");
89
+ const validFrom = str(row, "pricing_valid_from");
90
+ if ((typeof cents !== "number" && typeof cents !== "bigint") || validFrom === null)
91
+ return null;
92
+ const inputs = str(row, "estimate_inputs");
93
+ return { cents: Number(cents), validFrom, ...(inputs !== null ? { inputs: strings(inputs) } : {}) };
94
+ }
95
+ /** A stored row read back as the lead it was written from. */
96
+ function leadOf(row) {
97
+ const verdict = str(row, "spam_verdict");
98
+ const consentAt = str(row, "consent_at");
99
+ const submissionId = str(row, "submission_id");
100
+ const flow = LEAD_FLOWS.find(f => f === str(row, "flow"));
101
+ const price = priceOf(row);
102
+ return {
103
+ subject: str(row, "job") ?? "",
104
+ locality: str(row, "zip") ?? "",
105
+ mobile: str(row, "mobile") ?? "",
106
+ extras: strings(str(row, "extras")),
107
+ placeSlug: str(row, "location_id"),
108
+ spamVerdict: verdict !== null && VERDICTS.includes(verdict) ? verdict : null,
109
+ channel: str(row, "channel") === "callback" ? "callback" : "form",
110
+ ...(consentAt !== null ? { consent: { at: consentAt, text: str(row, "consent_text") ?? "" } } : {}),
111
+ ...(submissionId !== null ? { submissionId } : {}),
112
+ ...(flow !== undefined ? { flow } : {}),
113
+ ...(price !== null ? { price } : {}),
114
+ };
115
+ }
60
116
  function userVersion(db) {
61
117
  return integer(db.prepare("PRAGMA user_version").get(), "user_version");
62
118
  }
@@ -138,12 +194,17 @@ export function openSqliteLeadStore(path) {
138
194
  db.close();
139
195
  throw error;
140
196
  }
141
- const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id");
197
+ const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id, flow, quoted_cents, pricing_valid_from, estimate_inputs) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id");
198
+ const bySubmission = db.prepare("SELECT id, job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id, flow, quoted_cents, pricing_valid_from, estimate_inputs FROM leads WHERE submission_id = ?");
142
199
  const count = db.prepare("SELECT COUNT(*) AS n FROM leads");
143
200
  return {
144
201
  async insert(lead) {
145
202
  const extras = Object.keys(lead.extras).length > 0 ? JSON.stringify(lead.extras) : null;
146
- return integer(insert.get(lead.subject, lead.locality, lead.mobile, lead.placeSlug, lead.spamVerdict, extras, channelOf(lead), lead.consent?.at ?? null, lead.consent?.text ?? null), "id");
203
+ return integer(insert.get(lead.subject, lead.locality, lead.mobile, lead.placeSlug, lead.spamVerdict, extras, channelOf(lead), lead.consent?.at ?? null, lead.consent?.text ?? null, lead.submissionId ?? null, lead.flow ?? null, lead.price?.cents ?? null, lead.price?.validFrom ?? null, lead.price?.inputs ? JSON.stringify(lead.price.inputs) : null), "id");
204
+ },
205
+ async findSubmission(submissionId) {
206
+ const row = bySubmission.get(submissionId);
207
+ return row === undefined ? null : { id: integer(row, "id"), lead: leadOf(row) };
147
208
  },
148
209
  async count() {
149
210
  return integer(count.get(), "n");
@@ -1,5 +1,6 @@
1
1
  import "server-only";
2
- import type { Lead, LeadChannel } from "../core/lead.js";
2
+ import { type Lead, type LeadChannel, type LeadSuspect } from "../core/lead.js";
3
+ import type { LeadFlow } from "../core/pricing/flow.js";
3
4
  import type { ServerEnv } from "./env.js";
4
5
  import { type TickReport, type WebhookOutbox, type WebhookOutboxOptions } from "./webhook-outbox.js";
5
6
  import type { WebhookSigning } from "./webhook-signature.js";
@@ -14,7 +15,50 @@ export interface LeadWebhookContext {
14
15
  at: Date;
15
16
  /** Fresh per lead and stored with the body, so every retry carries the same one. */
16
17
  idempotencyKey: string;
18
+ /**
19
+ * Why the lead is suspect (`suspectOf`), for the panel's `suspect`
20
+ * property — only under `panelSuspect`; otherwise always absent, and the
21
+ * body must not carry the property at all.
22
+ */
23
+ suspect?: LeadSuspect;
24
+ /**
25
+ * The lead's public reference (`leadRef`, `lead-<row>-<8 hex>`): the one
26
+ * the page was answered with (`LeadSent.lead`), so a later step on the page
27
+ * can name the lead the panel knows. Absent only on a context built by hand.
28
+ */
29
+ leadRef?: string;
30
+ /**
31
+ * How the need was sold and at what price — only under `panelFlow`, and
32
+ * only on a lead with a flow; otherwise absent, and the body must not carry
33
+ * the properties. `panelFlowProperties` writes them as the panel reads them.
34
+ */
35
+ flow?: PanelFlow;
17
36
  }
37
+ /** A lead's sale as the panel's `lead.created` may carry it. */
38
+ export interface PanelFlow {
39
+ flow: LeadFlow;
40
+ /** With `estimate` and `fixed` only, always with `pricingValidFrom`. */
41
+ quotedCents?: number;
42
+ /** The price list's date, `YYYY-MM-DD`. */
43
+ pricingValidFrom?: string;
44
+ /** With `estimate` only, and only when it asked anything: input id → option id, slugs. */
45
+ estimateInputs?: Readonly<Record<string, string>>;
46
+ }
47
+ /** The sale `ctx.flow` describes, or nothing for a lead without a flow. */
48
+ export declare function panelFlowOf(lead: Pick<Lead, "flow" | "price">): PanelFlow | undefined;
49
+ /**
50
+ * `ctx.flow` as `lead.created`'s properties: `flow`, then `quoted_cents` and
51
+ * `pricing_valid_from` together (estimate and fixed), then `estimate_inputs`
52
+ * (estimate) — the panel's closed vocabulary. Spread into the body's
53
+ * `properties`; `{}` without a flow.
54
+ */
55
+ export interface PanelFlowProperties {
56
+ flow: LeadFlow;
57
+ quoted_cents?: number;
58
+ pricing_valid_from?: string;
59
+ estimate_inputs?: Readonly<Record<string, string>>;
60
+ }
61
+ export declare function panelFlowProperties(flow: PanelFlow | undefined): PanelFlowProperties | Record<string, never>;
18
62
  /**
19
63
  * What the Service-Arb panel's `lead.created` calls a callback request. Its
20
64
  * `properties.channel` is a closed set (`form` | `phone_inbound`) and an event
@@ -36,17 +80,40 @@ export interface LeadWebhook {
36
80
  enqueue(lead: Lead, id: number, meta: {
37
81
  locale: string;
38
82
  formId: string;
83
+ leadRef?: string;
39
84
  }): number;
40
85
  tick(): Promise<TickReport>;
41
86
  start(intervalMs?: number): void;
42
87
  stop(): void;
43
88
  close(): void;
89
+ /** The outbox's `requeueDead`: this target's dead rows back in the queue. */
90
+ requeueDead(): number;
91
+ /** Whether a suspect lead is queued, marked (`LeadWebhookOptions.panelSuspect`). */
92
+ readonly panelSuspect: boolean;
93
+ /** Whether `ctx.flow` is filled in (`LeadWebhookOptions.panelFlow`). */
94
+ readonly panelFlow: boolean;
44
95
  readonly outbox: WebhookOutbox;
45
96
  }
46
97
  export interface LeadWebhookOptions extends WebhookOutboxOptions {
47
98
  /** Absent → the webhook is off, whatever the environment says. */
48
99
  buildBody?: BuildWebhookBody | undefined;
49
100
  signing: WebhookSigning;
101
+ /**
102
+ * The one switch for the panel's `suspect` marker. Off (the default) until
103
+ * the panel's `lead.created` accepts the property — it refuses an unknown
104
+ * one, and the outbox would park the lead: a rate-limited lead stays in the
105
+ * table and `ctx.suspect` stays absent, as before. On: a rate-limited lead
106
+ * is queued too, and every queued lead's `ctx.suspect` says why it is
107
+ * suspect, for the brand's body to carry.
108
+ */
109
+ panelSuspect?: boolean;
110
+ /**
111
+ * The switch for the sale's properties (`ctx.flow`: `flow`, `quoted_cents`,
112
+ * `pricing_valid_from`, `estimate_inputs`). Off (the default) until the
113
+ * panel's `lead.created` accepts them — it refuses an unknown property, and
114
+ * the outbox would park the lead. The leads table keeps them either way.
115
+ */
116
+ panelFlow?: boolean;
50
117
  }
51
118
  /**
52
119
  * The lead webhook, or `null` when it is off: no `LEAD_WEBHOOK_URL`, or no
@@ -1,6 +1,25 @@
1
1
  import "server-only";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { suspectOf } from "../core/lead.js";
3
4
  import { openWebhookOutbox } from "./webhook-outbox.js";
5
+ /** The sale `ctx.flow` describes, or nothing for a lead without a flow. */
6
+ export function panelFlowOf(lead) {
7
+ if (!lead.flow)
8
+ return undefined;
9
+ if (lead.flow === "quote" || !lead.price)
10
+ return { flow: "quote" };
11
+ const inputs = lead.flow === "estimate" && lead.price.inputs && Object.keys(lead.price.inputs).length > 0 ? { estimateInputs: lead.price.inputs } : {};
12
+ return { flow: lead.flow, quotedCents: lead.price.cents, pricingValidFrom: lead.price.validFrom, ...inputs };
13
+ }
14
+ export function panelFlowProperties(flow) {
15
+ if (!flow)
16
+ return {};
17
+ return {
18
+ flow: flow.flow,
19
+ ...(flow.quotedCents !== undefined && flow.pricingValidFrom !== undefined ? { quoted_cents: flow.quotedCents, pricing_valid_from: flow.pricingValidFrom } : {}),
20
+ ...(flow.estimateInputs ? { estimate_inputs: flow.estimateInputs } : {}),
21
+ };
22
+ }
4
23
  /**
5
24
  * What the Service-Arb panel's `lead.created` calls a callback request. Its
6
25
  * `properties.channel` is a closed set (`form` | `phone_inbound`) and an event
@@ -19,7 +38,7 @@ export function panelChannel(channel) {
19
38
  * so a broken setting fails startup rather than a lead.
20
39
  */
21
40
  export function leadWebhook(site, env, options) {
22
- const { buildBody, signing, ...outboxOptions } = options;
41
+ const { buildBody, signing, panelSuspect = false, panelFlow = false, ...outboxOptions } = options;
23
42
  if (!env.leadWebhook || !buildBody)
24
43
  return null;
25
44
  if (env.leadsDb.kind !== "sqlite")
@@ -28,8 +47,20 @@ export function leadWebhook(site, env, options) {
28
47
  const now = options.now ?? Date.now;
29
48
  return {
30
49
  outbox,
50
+ panelSuspect,
51
+ panelFlow,
31
52
  enqueue(lead, id, meta) {
32
- const ctx = { leadId: id, brandId: site.brand.id, ...meta, at: new Date(now()), idempotencyKey: randomUUID() };
53
+ const suspect = panelSuspect ? suspectOf(lead) : undefined;
54
+ const flow = panelFlow ? panelFlowOf(lead) : undefined;
55
+ const ctx = {
56
+ leadId: id,
57
+ brandId: site.brand.id,
58
+ ...meta,
59
+ at: new Date(now()),
60
+ idempotencyKey: randomUUID(),
61
+ ...(suspect ? { suspect } : {}),
62
+ ...(flow ? { flow } : {}),
63
+ };
33
64
  const body = JSON.stringify(buildBody(lead, ctx));
34
65
  if (typeof body !== "string")
35
66
  throw new Error("buildWebhookBody returned nothing JSON can carry");
@@ -39,5 +70,6 @@ export function leadWebhook(site, env, options) {
39
70
  start: intervalMs => outbox.start(intervalMs),
40
71
  stop: () => outbox.stop(),
41
72
  close: () => outbox.close(),
73
+ requeueDead: () => outbox.requeueDead(),
42
74
  };
43
75
  }
@@ -0,0 +1,25 @@
1
+ import "server-only";
2
+ import type { PricingModel } from "../core/pricing/model.js";
3
+ import type { Site } from "../core/site.js";
4
+ /**
5
+ * The brand's live price list: `GET <base>/pricing`, the whole model with a
6
+ * label in every locale, over the baked one (`site.pricing`). The same
7
+ * failure semantics as the place source, for the same reason — a page that
8
+ * throws under ISR is a bare 500 with no phone: an unreachable source, a
9
+ * non-200, a body that is not JSON, and a model that does not validate all
10
+ * serve the baked model. Taken whole or not at all: a page never prices from
11
+ * half a model. `{}` is the source's "nothing set": the baked model, quietly.
12
+ */
13
+ export interface PricingSourceOptions {
14
+ /** The source's base URL, read when asked; `null` → the baked model only. */
15
+ baseUrl: () => string | null;
16
+ /** TTL on the fetch itself (see `PlaceSourceOptions`); 600 s. */
17
+ revalidateSeconds?: number;
18
+ timeoutMs?: number;
19
+ log?: Pick<Console, "error">;
20
+ }
21
+ export interface PricingSource {
22
+ /** The model to price with now: live, else baked, else `null` (every need is a `quote`). Never throws. */
23
+ model(): Promise<PricingModel | null>;
24
+ }
25
+ export declare function createPricingSource<L extends string, P extends string>(site: Site<L, P>, options: PricingSourceOptions): PricingSource;
@@ -0,0 +1,48 @@
1
+ import "server-only";
2
+ import { parsePricingModel, pricingProblemsFor } from "../core/pricing/validate.js";
3
+ import { PLACE_REVALIDATE_SECONDS } from "./place-source.js";
4
+ const TIMEOUT_MS = 3_000;
5
+ const isEmptyObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v) && Object.keys(v).length === 0;
6
+ export function createPricingSource(site, options) {
7
+ const revalidate = options.revalidateSeconds ?? PLACE_REVALIDATE_SECONDS;
8
+ const timeoutMs = options.timeoutMs ?? TIMEOUT_MS;
9
+ const log = options.log ?? console;
10
+ const baked = site.pricing ?? null;
11
+ async function live(base) {
12
+ const init = { cache: "force-cache", next: { revalidate }, signal: AbortSignal.timeout(timeoutMs) };
13
+ let response;
14
+ try {
15
+ response = await fetch(`${base}/pricing`, init);
16
+ }
17
+ catch (cause) {
18
+ log.error("pricing: source unreachable, pricing from the baked model", cause);
19
+ return baked;
20
+ }
21
+ if (!response.ok) {
22
+ log.error(`pricing: source answered ${response.status}, pricing from the baked model`);
23
+ return baked;
24
+ }
25
+ let body;
26
+ try {
27
+ body = await response.json();
28
+ }
29
+ catch (cause) {
30
+ log.error("pricing: the source's body is not JSON, pricing from the baked model", cause);
31
+ return baked;
32
+ }
33
+ if (isEmptyObject(body))
34
+ return baked;
35
+ const problems = pricingProblemsFor(body, site.i18n.locales);
36
+ if (problems.length > 0) {
37
+ log.error(`pricing: the source's model is refused whole (${problems.slice(0, 5).join("; ")}), pricing from the baked model`);
38
+ return baked;
39
+ }
40
+ return parsePricingModel(body);
41
+ }
42
+ return {
43
+ async model() {
44
+ const base = options.baseUrl();
45
+ return base ? live(base) : baked;
46
+ },
47
+ };
48
+ }
@@ -18,12 +18,28 @@ export interface WebhookTarget {
18
18
  secret: string;
19
19
  signing: WebhookSigning;
20
20
  }
21
+ /** What `onDead` is told of a row given up on: its error as tagged (`HTTP 503`), never the receiver's words. */
22
+ export interface DeadRow {
23
+ id: number;
24
+ ref: string | null;
25
+ attempts: number;
26
+ error: string;
27
+ }
21
28
  export interface WebhookOutboxOptions {
22
- /** Tries per row, the first included; past it the row is `dead`. */
29
+ /**
30
+ * How long a row is retried, from when it was queued (or requeued); past it
31
+ * a failure makes it `dead`. 48 hours by default: a receiver down over a
32
+ * night and a day still gets its leads.
33
+ */
34
+ horizonMs?: number;
35
+ /** A cap on tries per row besides the horizon, the first included; none by default. */
23
36
  maxAttempts?: number;
24
37
  /** The wait after the first failure; doubled after each next one. */
25
38
  baseDelayMs?: number;
39
+ /** The longest wait between two tries: an hour by default. */
26
40
  maxDelayMs?: number;
41
+ /** Told of every row given up on, after the error log — the alert hook. */
42
+ onDead?: (row: DeadRow) => void;
27
43
  /** Per request, so a hung receiver does not hold the tick. */
28
44
  timeoutMs?: number;
29
45
  /** Rows sent per tick. */
@@ -57,9 +73,19 @@ export interface WebhookOutbox {
57
73
  start(intervalMs?: number): void;
58
74
  stop(): void;
59
75
  rows(): OutboxRow[];
76
+ /**
77
+ * Puts this target's `dead` rows back in the queue, due now, with their
78
+ * tries and horizon fresh — after a key rotation or an outage longer than
79
+ * the horizon. Answers how many. `kitstart-outbox requeue` does the same
80
+ * from a shell.
81
+ */
82
+ requeueDead(): number;
60
83
  close(): void;
61
84
  }
85
+ /** The old default cap on tries; kept for a brand that passes it as `maxAttempts`. The horizon decides now. */
62
86
  export declare const WEBHOOK_MAX_ATTEMPTS = 12;
87
+ export declare const WEBHOOK_HORIZON_MS: number;
88
+ export declare const WEBHOOK_MAX_DELAY_MS: number;
63
89
  export declare const WEBHOOK_TICK_MS = 10000;
64
90
  /**
65
91
  * `https:` anywhere; `http:` only where nothing but the cluster sees the
@@ -2,7 +2,10 @@ import "server-only";
2
2
  import { mkdirSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { signatureHeaders } from "./webhook-signature.js";
5
+ /** The old default cap on tries; kept for a brand that passes it as `maxAttempts`. The horizon decides now. */
5
6
  export const WEBHOOK_MAX_ATTEMPTS = 12;
7
+ export const WEBHOOK_HORIZON_MS = 48 * 60 * 60_000;
8
+ export const WEBHOOK_MAX_DELAY_MS = 60 * 60_000;
6
9
  export const WEBHOOK_TICK_MS = 10_000;
7
10
  const SCHEMA = `
8
11
  CREATE TABLE IF NOT EXISTS webhook_outbox (
@@ -113,6 +116,12 @@ async function judge(response, rowId, log) {
113
116
  const why = said ? `${tag}: ${said}` : tag;
114
117
  if (status === 408 || status === 429 || status >= 500)
115
118
  return { kind: "retry", tag, why, afterMs: retryAfter(response) };
119
+ // A rotated key (401, 403) or a receiver moved or not yet deployed (404):
120
+ // fixed by a person, after which the lead must still arrive.
121
+ if (status === 401 || status === 403)
122
+ return { kind: "retry", tag, why, afterMs: null, alarm: "check LEAD_WEBHOOK_KEY_ID / LEAD_WEBHOOK_SECRET against the receiver's key" };
123
+ if (status === 404)
124
+ return { kind: "retry", tag, why, afterMs: null, alarm: "check LEAD_WEBHOOK_URL" };
116
125
  // 3xx included: redirects are not followed, since the target was vetted and its Location was not.
117
126
  return { kind: "dead", tag, why };
118
127
  }
@@ -124,9 +133,10 @@ async function judge(response, rowId, log) {
124
133
  * agreed to take them.
125
134
  */
126
135
  export function openWebhookOutbox(path, target, options = {}) {
127
- const maxAttempts = options.maxAttempts ?? WEBHOOK_MAX_ATTEMPTS;
136
+ const horizon = options.horizonMs ?? WEBHOOK_HORIZON_MS;
137
+ const maxAttempts = options.maxAttempts ?? Number.POSITIVE_INFINITY;
128
138
  const baseDelay = options.baseDelayMs ?? 5_000;
129
- const maxDelay = options.maxDelayMs ?? 60 * 60_000;
139
+ const maxDelay = options.maxDelayMs ?? WEBHOOK_MAX_DELAY_MS;
130
140
  const timeoutMs = options.timeoutMs ?? 10_000;
131
141
  const batch = options.batch ?? 20;
132
142
  const send = options.fetch ?? fetch;
@@ -146,13 +156,15 @@ export function openWebhookOutbox(path, target, options = {}) {
146
156
  throw error;
147
157
  }
148
158
  const insert = db.prepare("INSERT INTO webhook_outbox (target, ref, body, created_at, next_attempt_at) VALUES (?, ?, ?, ?, ?) RETURNING id");
149
- const due = db.prepare("SELECT id, body, attempts FROM webhook_outbox WHERE state = 'pending' AND target = ? AND next_attempt_at <= ? ORDER BY next_attempt_at, id LIMIT ?");
159
+ const due = db.prepare("SELECT id, ref, body, attempts, created_at FROM webhook_outbox WHERE state = 'pending' AND target = ? AND next_attempt_at <= ? ORDER BY next_attempt_at, id LIMIT ?");
150
160
  // A lease: another process ticking the same file skips a row while it is in flight.
151
161
  const claim = db.prepare("UPDATE webhook_outbox SET next_attempt_at = ? WHERE id = ? AND state = 'pending' AND next_attempt_at <= ?");
152
162
  const finish = db.prepare("UPDATE webhook_outbox SET state = ?, attempts = ?, last_error = ?, done_at = ? WHERE id = ?");
153
163
  const reschedule = db.prepare("UPDATE webhook_outbox SET attempts = ?, next_attempt_at = ?, last_error = ? WHERE id = ?");
154
164
  const list = db.prepare("SELECT id, ref, state, attempts, next_attempt_at, last_error FROM webhook_outbox ORDER BY id");
155
165
  const stranded = db.prepare("SELECT COUNT(*) AS n FROM webhook_outbox WHERE state = 'pending' AND target <> ?");
166
+ // `created_at` restarts the horizon: a requeued row is queued anew.
167
+ const requeue = db.prepare("UPDATE webhook_outbox SET state = 'pending', attempts = 0, created_at = ?, next_attempt_at = ?, done_at = NULL WHERE state = 'dead' AND target = ?");
156
168
  const left = int(stranded.get(target.url), "n");
157
169
  if (left > 0)
158
170
  log.warn(`webhook: ${left} pending row(s) queued for another target; left in place, not sent`);
@@ -187,6 +199,7 @@ export function openWebhookOutbox(path, target, options = {}) {
187
199
  const id = int(row, "id");
188
200
  const body = text(row, "body") ?? "";
189
201
  const attempts = int(row, "attempts") + 1;
202
+ const queuedAt = int(row, "created_at");
190
203
  if (Number(claim.run(t + timeoutMs + 5_000, id, t).changes) === 0)
191
204
  continue;
192
205
  const verdict = await deliver(id, body);
@@ -194,15 +207,25 @@ export function openWebhookOutbox(path, target, options = {}) {
194
207
  finish.run("delivered", attempts, verdict.note?.slice(0, ERROR_CAP) ?? null, now(), id);
195
208
  report.delivered += 1;
196
209
  }
197
- else if (verdict.kind === "dead" || attempts >= maxAttempts) {
210
+ else if (verdict.kind === "dead" || attempts >= maxAttempts || now() - queuedAt >= horizon) {
198
211
  finish.run("dead", attempts, verdict.why, now(), id);
199
- log.error(`webhook: outbox #${id} dead after ${attempts} attempt(s): ${verdict.tag}`);
212
+ log.error(`webhook: outbox #${id} dead after ${attempts} attempt(s): ${verdict.tag}; requeue with requeueDead() or \`kitstart-outbox requeue\``);
200
213
  report.dead += 1;
214
+ try {
215
+ options.onDead?.({ id, ref: text(row, "ref"), attempts, error: verdict.tag });
216
+ }
217
+ catch (error) {
218
+ log.error("webhook: the onDead hook failed", error);
219
+ }
201
220
  }
202
221
  else {
203
222
  const wait = backoff(attempts, verdict.afterMs);
204
223
  reschedule.run(attempts, now() + wait, verdict.why, id);
205
- log.warn(`webhook: outbox #${id} attempt ${attempts} failed (${verdict.tag}); next in ${Math.round(wait / 1000)} s`);
224
+ const line = `webhook: outbox #${id} attempt ${attempts} failed (${verdict.tag}); next in ${Math.round(wait / 1000)} s`;
225
+ if (verdict.alarm)
226
+ log.error(`${line} — ${verdict.alarm}`);
227
+ else
228
+ log.warn(line);
206
229
  report.retried += 1;
207
230
  }
208
231
  }
@@ -249,6 +272,10 @@ export function openWebhookOutbox(path, target, options = {}) {
249
272
  lastError: text(row, "last_error"),
250
273
  }));
251
274
  },
275
+ requeueDead() {
276
+ const t = now();
277
+ return Number(requeue.run(t, t, target.url).changes);
278
+ },
252
279
  close() {
253
280
  stop();
254
281
  db.close();
@@ -9,4 +9,5 @@
9
9
  export { describeLandingContract, type LandingContractOptions } from "./landing-contract.js";
10
10
  export { servedPaths } from "./served-files.js";
11
11
  export { describeLeadStoreContract, type LeadStoreHarness } from "./lead-store-contract.js";
12
+ export { leadRuleDisagreements, PHONE_MATRIX } from "./lead-rules.js";
12
13
  export { serviceAreaPlace, storefrontPlace, testLead } from "./fixtures.js";
@@ -9,4 +9,5 @@
9
9
  export { describeLandingContract } from "./landing-contract.js";
10
10
  export { servedPaths } from "./served-files.js";
11
11
  export { describeLeadStoreContract } from "./lead-store-contract.js";
12
+ export { leadRuleDisagreements, PHONE_MATRIX } from "./lead-rules.js";
12
13
  export { serviceAreaPlace, storefrontPlace, testLead } from "./fixtures.js";
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import { isPublished, publicationGaps } from "../core/place/publication.js";
3
3
  import { createRouting, NON_PAGE_ROUTES, pointSuffixes, THANKS } from "../core/routing.js";
4
4
  import { openLaunchBlockers } from "../core/site.js";
5
+ import { leadRuleDisagreements } from "./lead-rules.js";
5
6
  import { servedPaths } from "./served-files.js";
6
7
  const SOURCE_LINE = /@source\s+["'][^"']*node_modules\/@evinvest\/kitstart\/dist["']/;
7
8
  const MATCHER = "/((?!_next/).*)";
@@ -42,6 +43,11 @@ export function describeLandingContract(site, options = {}) {
42
43
  expect(moves && suffixes.includes(redirect.from), redirect.from).toBe(false);
43
44
  }
44
45
  });
46
+ // A number the form lets through and the server refuses sends the visitor
47
+ // back for nothing they can see; `validateLead` is the shared rule.
48
+ it("refuses exactly the phone numbers the form blocks", () => {
49
+ expect(leadRuleDisagreements(site.lead)).toEqual([]);
50
+ });
45
51
  it("publishes nothing without a domain", () => {
46
52
  if (site.brand.domain !== null)
47
53
  return;
@@ -0,0 +1,18 @@
1
+ import { type LeadSchema } from "../core/lead.js";
2
+ /**
3
+ * Numbers people type, and whether each is a way to reach them — the
4
+ * review's matrix (LEAD-FORMS-REVIEW-2026-10-03) and the spellings that must
5
+ * keep working.
6
+ */
7
+ export declare const PHONE_MATRIX: readonly {
8
+ mobile: string;
9
+ valid: boolean;
10
+ }[];
11
+ /**
12
+ * Where a brand's form rule disagrees with the form's own phone check: a
13
+ * number the form lets through that the server refuses (the visitor is sent
14
+ * back for nothing they can see), or one the form blocks that the rule would
15
+ * take. Every other field is filled as the form fills it — the first subject,
16
+ * a postcode — so only the phone is judged. Empty is agreement.
17
+ */
18
+ export declare function leadRuleDisagreements(schema: LeadSchema<string>): string[];