@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.
- package/README.md +178 -25
- package/dist/cli/kitstart-outbox.d.ts +2 -0
- package/dist/cli/kitstart-outbox.js +10 -0
- package/dist/cli/outbox.d.ts +5 -0
- package/dist/cli/outbox.js +77 -0
- package/dist/core/accept.d.ts +44 -3
- package/dist/core/accept.js +96 -15
- package/dist/core/analytics.d.ts +4 -1
- package/dist/core/analytics.js +14 -0
- package/dist/core/lead-capture-format.d.ts +6 -0
- package/dist/core/lead-capture-format.js +13 -0
- package/dist/core/lead-capture-text.d.ts +56 -2
- package/dist/core/lead-capture-text.js +63 -0
- package/dist/core/lead.d.ts +101 -11
- package/dist/core/lead.js +66 -11
- package/dist/core/phone.d.ts +20 -9
- package/dist/core/phone.js +43 -19
- package/dist/core/pricing/flow.d.ts +27 -0
- package/dist/core/pricing/flow.js +37 -0
- package/dist/core/pricing/index.d.ts +4 -0
- package/dist/core/pricing/index.js +4 -0
- package/dist/core/pricing/model.d.ts +129 -0
- package/dist/core/pricing/model.js +34 -0
- package/dist/core/pricing/price.d.ts +34 -0
- package/dist/core/pricing/price.js +105 -0
- package/dist/core/pricing/validate.d.ts +27 -0
- package/dist/core/pricing/validate.js +244 -0
- package/dist/core/site.d.ts +7 -0
- package/dist/core/site.js +11 -0
- package/dist/core/status.d.ts +13 -0
- package/dist/core/status.js +19 -2
- package/dist/index.d.ts +7 -6
- package/dist/index.js +7 -6
- package/dist/next/quote-route.d.ts +22 -2
- package/dist/next/quote-route.js +72 -10
- package/dist/react/LeadCapture.d.ts +37 -7
- package/dist/react/LeadCapture.js +34 -9
- package/dist/react/LeadCaptureChannels.d.ts +28 -3
- package/dist/react/LeadCaptureChannels.js +21 -5
- package/dist/react/LeadCaptureEstimate.d.ts +46 -0
- package/dist/react/LeadCaptureEstimate.js +69 -0
- package/dist/react/LeadCaptureFields.d.ts +39 -7
- package/dist/react/LeadCaptureFields.js +91 -21
- package/dist/react/LeadCaptureNeed.d.ts +5 -0
- package/dist/react/LeadCaptureNeed.js +38 -2
- package/dist/react/LeadCapturePriced.d.ts +19 -0
- package/dist/react/LeadCapturePriced.js +14 -0
- package/dist/react/LeadCaptureSubmit.d.ts +16 -0
- package/dist/react/LeadCaptureSubmit.js +13 -0
- package/dist/react/QuoteFormShell.d.ts +2 -0
- package/dist/react/QuoteFormShell.js +3 -2
- package/dist/react/use-lead-error.d.ts +24 -0
- package/dist/react/use-lead-error.js +73 -0
- package/dist/react/use-lead-events.d.ts +5 -0
- package/dist/react/use-lead-events.js +13 -2
- package/dist/react/use-lead-submit.d.ts +48 -9
- package/dist/react/use-lead-submit.js +129 -13
- package/dist/react/use-validity.d.ts +17 -0
- package/dist/react/use-validity.js +38 -0
- package/dist/server/env.d.ts +9 -0
- package/dist/server/env.js +12 -0
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.js +3 -2
- package/dist/server/lead-store-sqlite.d.ts +5 -1
- package/dist/server/lead-store-sqlite.js +63 -2
- package/dist/server/lead-webhook.d.ts +68 -1
- package/dist/server/lead-webhook.js +34 -2
- package/dist/server/pricing-source.d.ts +25 -0
- package/dist/server/pricing-source.js +48 -0
- package/dist/server/webhook-outbox.d.ts +27 -1
- package/dist/server/webhook-outbox.js +33 -6
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/landing-contract.js +6 -0
- package/dist/testing/lead-rules.d.ts +18 -0
- package/dist/testing/lead-rules.js +50 -0
- package/dist/testing/lead-store-contract.js +28 -1
- package/package.json +3 -2
- package/template/app/[locale]/[location]/page.tsx +4 -2
- package/template/app/quote/route.ts +2 -1
- package/template/flake.nix +1 -1
- package/template/package.json +1 -1
- package/template/src/entities/content/model/en.ts +1 -1
- package/template/src/entities/content/model/fr.ts +1 -1
- package/template/src/shared/config/env.ts +4 -1
- package/template/src/shared/config/lead.ts +17 -4
- package/template/src/shared/config/pricing.ts +49 -0
- package/template/src/shared/config/site.ts +3 -0
- package/template/src/views/home/ui/PlaceHome.tsx +6 -3
- package/template/tests/bundle_budget.txt +3 -1
- package/template/tests/e2e/flows.spec.ts +106 -0
- package/template/tests/e2e/mock-panel.mjs +20 -0
- package/template/tests/e2e/playwright.config.ts +25 -9
- package/template/tests/e2e/quote-form.spec.ts +86 -1
package/dist/server/env.js
CHANGED
|
@@ -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. */
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
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();
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/testing/index.js
CHANGED
|
@@ -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[];
|