@evinvest/kitstart 0.4.0 → 0.6.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 +177 -7
- package/dist/cli/size.d.ts +26 -4
- package/dist/cli/size.js +54 -16
- package/dist/core/accept.d.ts +18 -1
- package/dist/core/accept.js +21 -2
- package/dist/core/analytics.d.ts +22 -3
- package/dist/core/analytics.js +27 -2
- package/dist/core/channels.d.ts +47 -0
- package/dist/core/channels.js +51 -0
- package/dist/core/lead-capture-format.d.ts +6 -0
- package/dist/core/lead-capture-format.js +32 -0
- package/dist/core/lead-capture-text.d.ts +50 -0
- package/dist/core/lead-capture-text.js +57 -0
- package/dist/core/lead.d.ts +59 -4
- package/dist/core/lead.js +38 -4
- package/dist/core/phone.d.ts +19 -0
- package/dist/core/phone.js +39 -0
- package/dist/core/place/hours.d.ts +15 -0
- package/dist/core/place/hours.js +68 -0
- package/dist/core/place/index.d.ts +1 -0
- package/dist/core/place/index.js +1 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +8 -4
- package/dist/next/quote-route.d.ts +10 -2
- package/dist/next/quote-route.js +20 -1
- package/dist/react/AnalyticsBoundary.js +11 -5
- package/dist/react/CallBar.d.ts +17 -2
- package/dist/react/CallBar.js +26 -3
- package/dist/react/LeadCapture.d.ts +61 -0
- package/dist/react/LeadCapture.js +60 -0
- package/dist/react/LeadCaptureChannels.d.ts +54 -0
- package/dist/react/LeadCaptureChannels.js +37 -0
- package/dist/react/LeadCaptureFields.d.ts +32 -0
- package/dist/react/LeadCaptureFields.js +42 -0
- package/dist/react/LeadCaptureNeed.d.ts +27 -0
- package/dist/react/LeadCaptureNeed.js +22 -0
- package/dist/react/analytics-context.d.ts +11 -0
- package/dist/react/analytics-context.js +15 -0
- package/dist/react/index.d.ts +3 -1
- package/dist/react/index.js +2 -1
- package/dist/react/use-lead-context.d.ts +24 -0
- package/dist/react/use-lead-context.js +71 -0
- package/dist/react/use-lead-events.d.ts +23 -0
- package/dist/react/use-lead-events.js +62 -0
- package/dist/server/env.d.ts +7 -1
- package/dist/server/env.js +15 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.js +3 -0
- package/dist/server/lead-store-sqlite.js +12 -4
- package/dist/server/lead-webhook.d.ts +61 -0
- package/dist/server/lead-webhook.js +43 -0
- package/dist/server/notify.d.ts +1 -1
- package/dist/server/notify.js +5 -2
- package/dist/server/webhook-outbox.d.ts +77 -0
- package/dist/server/webhook-outbox.js +257 -0
- package/dist/server/webhook-signature.d.ts +32 -0
- package/dist/server/webhook-signature.js +16 -0
- package/dist/testing/lead-store-contract.js +4 -2
- package/package.json +1 -1
- package/template/README.md +7 -4
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
- package/template/src/entities/content/model/en.ts +2 -2
- package/template/src/entities/content/model/fr.ts +2 -2
- package/template/src/entities/content/model/types.ts +3 -2
- package/template/src/shared/config/lead.ts +2 -0
- package/template/src/views/home/ui/PlaceHome.tsx +25 -40
- package/template/tests/bundle_budget.txt +8 -3
- package/template/tests/e2e/quote-form.spec.ts +114 -13
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
3
|
+
import { EVENTS, experimentProps } from "../core/analytics.js";
|
|
4
|
+
import { useAnalyticsSink } from "./analytics-context.js";
|
|
5
|
+
/** The attribute a lead form's control names its role in, for `lead_form_field_error`. */
|
|
6
|
+
export const FIELD_ATTR = "data-lead-field";
|
|
7
|
+
/**
|
|
8
|
+
* The lead form's own funnel, reported through the boundary's sink: seen
|
|
9
|
+
* (half of it in view, once), started (the first focus inside, once), a
|
|
10
|
+
* field the browser refused (`invalid`, by role — never the value), and the
|
|
11
|
+
* step `qualify-first` moved to. Every event carries the form, the layout and
|
|
12
|
+
* the site's experiment, so the arms of a test compare on one schema.
|
|
13
|
+
*/
|
|
14
|
+
export function useLeadEvents(root, tags) {
|
|
15
|
+
const sink = useAnalyticsSink();
|
|
16
|
+
const { formId, layout } = tags;
|
|
17
|
+
const name = tags.experiment?.name;
|
|
18
|
+
const variant = tags.experiment?.variant;
|
|
19
|
+
const props = useMemo(() => ({ form_id: formId, layout, ...experimentProps(name, variant) }), [formId, layout, name, variant]);
|
|
20
|
+
const started = useRef(false);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const el = root.current;
|
|
23
|
+
if (!sink || !el)
|
|
24
|
+
return;
|
|
25
|
+
let seen = false;
|
|
26
|
+
const view = () => {
|
|
27
|
+
if (seen)
|
|
28
|
+
return;
|
|
29
|
+
seen = true;
|
|
30
|
+
sink.capture(EVENTS.formView, props);
|
|
31
|
+
};
|
|
32
|
+
// Without an observer (an old browser, a test DOM) rendering is seeing.
|
|
33
|
+
const observer = typeof IntersectionObserver === "function" ? new IntersectionObserver(es => es.some(e => e.isIntersecting) && view(), { threshold: 0.5 }) : null;
|
|
34
|
+
if (observer)
|
|
35
|
+
observer.observe(el);
|
|
36
|
+
else
|
|
37
|
+
view();
|
|
38
|
+
const focus = () => {
|
|
39
|
+
if (started.current)
|
|
40
|
+
return;
|
|
41
|
+
started.current = true;
|
|
42
|
+
sink.capture(EVENTS.formStart, props);
|
|
43
|
+
};
|
|
44
|
+
// `invalid` does not bubble: listened to on the way down.
|
|
45
|
+
const invalid = (event) => {
|
|
46
|
+
const field = event.target instanceof Element ? event.target.getAttribute(FIELD_ATTR) : null;
|
|
47
|
+
if (field)
|
|
48
|
+
sink.capture(EVENTS.fieldError, { ...props, field });
|
|
49
|
+
};
|
|
50
|
+
el.addEventListener("focusin", focus);
|
|
51
|
+
el.addEventListener("invalid", invalid, true);
|
|
52
|
+
return () => {
|
|
53
|
+
observer?.disconnect();
|
|
54
|
+
el.removeEventListener("focusin", focus);
|
|
55
|
+
el.removeEventListener("invalid", invalid, true);
|
|
56
|
+
};
|
|
57
|
+
}, [sink, root, props]);
|
|
58
|
+
return useMemo(() => ({
|
|
59
|
+
step: step => sink?.capture(EVENTS.formStep, { ...props, step }),
|
|
60
|
+
fieldError: field => sink?.capture(EVENTS.fieldError, { ...props, field }),
|
|
61
|
+
}), [sink, props]);
|
|
62
|
+
}
|
package/dist/server/env.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { type ProxyTrust } from "./client-key.js";
|
|
|
3
3
|
import { type LeadDb } from "./lead-store.js";
|
|
4
4
|
/**
|
|
5
5
|
* The server's runtime configuration, parsed once. Secrets (`SMTP_URL`,
|
|
6
|
-
* `SMS_TOKEN`, `POSTHOG_KEY`) arrive from the container environment, never
|
|
6
|
+
* `SMS_TOKEN`, `POSTHOG_KEY`, `LEAD_WEBHOOK_SECRET`) arrive from the container environment, never
|
|
7
7
|
* from the image.
|
|
8
8
|
*
|
|
9
9
|
* Production has no default for where state lives: a missing lead store
|
|
@@ -27,6 +27,12 @@ export interface ServerEnv {
|
|
|
27
27
|
/** Whose word the rate limit takes for the client's address; see `ProxyTrust`. */
|
|
28
28
|
trustedProxy: ProxyTrust | null;
|
|
29
29
|
smsToken: string | null;
|
|
30
|
+
/** Where queued leads are POSTed, signed; `null` → the webhook is off. */
|
|
31
|
+
leadWebhook: {
|
|
32
|
+
url: string;
|
|
33
|
+
keyId: string;
|
|
34
|
+
secret: string;
|
|
35
|
+
} | null;
|
|
30
36
|
posthogKey: string | null;
|
|
31
37
|
posthogHost: string;
|
|
32
38
|
}
|
package/dist/server/env.js
CHANGED
|
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { parseProxyTrust } from "./client-key.js";
|
|
5
5
|
import { parseLeadDb } from "./lead-store.js";
|
|
6
|
+
import { checkWebhookUrl } from "./webhook-outbox.js";
|
|
6
7
|
function opt(source, name) {
|
|
7
8
|
const value = source[name]?.trim();
|
|
8
9
|
return value ? value : null;
|
|
@@ -42,6 +43,19 @@ function leadsDb(source, production, brandId) {
|
|
|
42
43
|
}
|
|
43
44
|
return { leadsDb: { kind: "sqlite", path: join(homedir(), ".local/share", brandId, "leads.db") }, leadsDbFrom: "default" };
|
|
44
45
|
}
|
|
46
|
+
/** All three or none: a URL without its key would sign nothing a receiver accepts. */
|
|
47
|
+
function leadWebhook(source) {
|
|
48
|
+
const raw = opt(source, "LEAD_WEBHOOK_URL");
|
|
49
|
+
if (raw === null)
|
|
50
|
+
return null;
|
|
51
|
+
const url = checkWebhookUrl(raw);
|
|
52
|
+
const keyId = opt(source, "LEAD_WEBHOOK_KEY_ID");
|
|
53
|
+
const secret = opt(source, "LEAD_WEBHOOK_SECRET");
|
|
54
|
+
if (keyId === null || secret === null) {
|
|
55
|
+
throw new Error("LEAD_WEBHOOK_URL is set: LEAD_WEBHOOK_KEY_ID and LEAD_WEBHOOK_SECRET are required too");
|
|
56
|
+
}
|
|
57
|
+
return { url, keyId, secret };
|
|
58
|
+
}
|
|
45
59
|
export function parseServerEnv(site, source) {
|
|
46
60
|
const production = source["NODE_ENV"] === "production";
|
|
47
61
|
const trust = opt(source, "TRUSTED_PROXY");
|
|
@@ -57,6 +71,7 @@ export function parseServerEnv(site, source) {
|
|
|
57
71
|
notifyTo: opt(source, "LEAD_NOTIFY_TO"),
|
|
58
72
|
notifyFrom: opt(source, "LEAD_NOTIFY_FROM"),
|
|
59
73
|
smsToken: opt(source, "SMS_TOKEN"),
|
|
74
|
+
leadWebhook: leadWebhook(source),
|
|
60
75
|
posthogKey: opt(source, "POSTHOG_KEY"),
|
|
61
76
|
// Explicit because PostHog rejects a project's events at the other
|
|
62
77
|
// region's host; the audience is European, so EU unless told otherwise.
|
package/dist/server/index.d.ts
CHANGED
|
@@ -13,3 +13,6 @@ export { parseSmtpUrl, sendMail, type Mail, type SendOptions } from "./smtp.js";
|
|
|
13
13
|
export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE, type LeadMail, type LeadNotifier, type NotifyEnv } from "./notify.js";
|
|
14
14
|
export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust, type ProxyTrust } from "./client-key.js";
|
|
15
15
|
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";
|
|
18
|
+
export { signatureHeaders, signWebhook, type WebhookSignatureHeaders, type WebhookSigning } from "./webhook-signature.js";
|
package/dist/server/index.js
CHANGED
|
@@ -13,3 +13,6 @@ export { parseSmtpUrl, sendMail } from "./smtp.js";
|
|
|
13
13
|
export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE } from "./notify.js";
|
|
14
14
|
export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust } from "./client-key.js";
|
|
15
15
|
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";
|
|
18
|
+
export { signatureHeaders, signWebhook } from "./webhook-signature.js";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import "server-only";
|
|
2
2
|
import { mkdirSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
|
-
import { LEAD_SCHEMA_VERSION } from "../core/lead.js";
|
|
4
|
+
import { channelOf, LEAD_SCHEMA_VERSION } from "../core/lead.js";
|
|
5
5
|
// `node:sqlite` is reached through `getBuiltinModule` rather than an import so
|
|
6
6
|
// the bundler never has to resolve a builtin it may not know yet; the type is
|
|
7
7
|
// the module's own.
|
|
@@ -42,6 +42,14 @@ const STEPS = [
|
|
|
42
42
|
db => db.exec("ALTER TABLE leads ADD COLUMN spam_verdict TEXT"),
|
|
43
43
|
// 4 — the brand's extra fields, as a JSON object; `NULL` when there are none.
|
|
44
44
|
db => db.exec("ALTER TABLE leads ADD COLUMN extras TEXT"),
|
|
45
|
+
// 5 — how the lead was asked for (`form`, `callback`), and a callback's
|
|
46
|
+
// consent: when the server accepted it and the sentence shown. Nullable: a
|
|
47
|
+
// row from before is a form lead, and a form lead has no consent to keep.
|
|
48
|
+
db => {
|
|
49
|
+
db.exec("ALTER TABLE leads ADD COLUMN channel TEXT");
|
|
50
|
+
db.exec("ALTER TABLE leads ADD COLUMN consent_at TEXT");
|
|
51
|
+
db.exec("ALTER TABLE leads ADD COLUMN consent_text TEXT");
|
|
52
|
+
},
|
|
45
53
|
];
|
|
46
54
|
if (STEPS.length !== LEAD_SCHEMA_VERSION) {
|
|
47
55
|
// The version belongs to the port, shared by every adapter; this table of
|
|
@@ -66,7 +74,7 @@ function recognise(db) {
|
|
|
66
74
|
throw new Error(`leads: an unrecognised table (${columns.join(", ")}); refusing to migrate it`);
|
|
67
75
|
}
|
|
68
76
|
let at = 1;
|
|
69
|
-
for (const [step, added] of [[2, "location_id"], [3, "spam_verdict"], [4, "extras"]]) {
|
|
77
|
+
for (const [step, added] of [[2, "location_id"], [3, "spam_verdict"], [4, "extras"], [5, "consent_text"]]) {
|
|
70
78
|
if (!columns.includes(added))
|
|
71
79
|
break;
|
|
72
80
|
at = step;
|
|
@@ -130,12 +138,12 @@ export function openSqliteLeadStore(path) {
|
|
|
130
138
|
db.close();
|
|
131
139
|
throw error;
|
|
132
140
|
}
|
|
133
|
-
const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras) VALUES (?, ?, ?, ?, ?, ?) RETURNING id");
|
|
141
|
+
const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id");
|
|
134
142
|
const count = db.prepare("SELECT COUNT(*) AS n FROM leads");
|
|
135
143
|
return {
|
|
136
144
|
async insert(lead) {
|
|
137
145
|
const extras = Object.keys(lead.extras).length > 0 ? JSON.stringify(lead.extras) : null;
|
|
138
|
-
return integer(insert.get(lead.subject, lead.locality, lead.mobile, lead.placeSlug, lead.spamVerdict, extras), "id");
|
|
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");
|
|
139
147
|
},
|
|
140
148
|
async count() {
|
|
141
149
|
return integer(count.get(), "n");
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import type { Lead, LeadChannel } from "../core/lead.js";
|
|
3
|
+
import type { ServerEnv } from "./env.js";
|
|
4
|
+
import { type TickReport, type WebhookOutbox, type WebhookOutboxOptions } from "./webhook-outbox.js";
|
|
5
|
+
import type { WebhookSigning } from "./webhook-signature.js";
|
|
6
|
+
/** What the brand's body builder is told besides the lead. */
|
|
7
|
+
export interface LeadWebhookContext {
|
|
8
|
+
/** The row id in the leads table. */
|
|
9
|
+
leadId: number;
|
|
10
|
+
brandId: string;
|
|
11
|
+
locale: string;
|
|
12
|
+
formId: string;
|
|
13
|
+
/** When the lead was accepted. */
|
|
14
|
+
at: Date;
|
|
15
|
+
/** Fresh per lead and stored with the body, so every retry carries the same one. */
|
|
16
|
+
idempotencyKey: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* What the Service-Arb panel's `lead.created` calls a callback request. Its
|
|
20
|
+
* `properties.channel` is a closed set (`form` | `phone_inbound`) and an event
|
|
21
|
+
* outside it is refused whole — the outbox would park the lead — so until the
|
|
22
|
+
* panel's `sa.funnel.v1` accepts `callback`, a callback travels as `form`.
|
|
23
|
+
* TODO(panel): set to "callback" once the panel accepts it; nothing else changes.
|
|
24
|
+
*/
|
|
25
|
+
declare const PANEL_CALLBACK = "form";
|
|
26
|
+
/** A lead's channel as the panel's `properties.channel` may carry it: `panelChannel(channelOf(lead))`. */
|
|
27
|
+
export declare function panelChannel(channel: LeadChannel): "form" | typeof PANEL_CALLBACK;
|
|
28
|
+
/**
|
|
29
|
+
* The receiver's body, built by the brand: the kit serialises and signs
|
|
30
|
+
* whatever this returns and knows nothing of its shape. Called once, when the
|
|
31
|
+
* lead is queued — the stored body is what every attempt sends.
|
|
32
|
+
*/
|
|
33
|
+
export type BuildWebhookBody = (lead: Lead, ctx: LeadWebhookContext) => unknown;
|
|
34
|
+
export interface LeadWebhook {
|
|
35
|
+
/** Builds, serialises and queues the lead's body; returns the outbox row. */
|
|
36
|
+
enqueue(lead: Lead, id: number, meta: {
|
|
37
|
+
locale: string;
|
|
38
|
+
formId: string;
|
|
39
|
+
}): number;
|
|
40
|
+
tick(): Promise<TickReport>;
|
|
41
|
+
start(intervalMs?: number): void;
|
|
42
|
+
stop(): void;
|
|
43
|
+
close(): void;
|
|
44
|
+
readonly outbox: WebhookOutbox;
|
|
45
|
+
}
|
|
46
|
+
export interface LeadWebhookOptions extends WebhookOutboxOptions {
|
|
47
|
+
/** Absent → the webhook is off, whatever the environment says. */
|
|
48
|
+
buildBody?: BuildWebhookBody | undefined;
|
|
49
|
+
signing: WebhookSigning;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The lead webhook, or `null` when it is off: no `LEAD_WEBHOOK_URL`, or no
|
|
53
|
+
* body builder from the brand. Built at boot by the brand, like the notifier,
|
|
54
|
+
* so a broken setting fails startup rather than a lead.
|
|
55
|
+
*/
|
|
56
|
+
export declare function leadWebhook(site: {
|
|
57
|
+
brand: {
|
|
58
|
+
id: string;
|
|
59
|
+
};
|
|
60
|
+
}, env: Pick<ServerEnv, "leadsDb" | "leadWebhook">, options: LeadWebhookOptions): LeadWebhook | null;
|
|
61
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { openWebhookOutbox } from "./webhook-outbox.js";
|
|
4
|
+
/**
|
|
5
|
+
* What the Service-Arb panel's `lead.created` calls a callback request. Its
|
|
6
|
+
* `properties.channel` is a closed set (`form` | `phone_inbound`) and an event
|
|
7
|
+
* outside it is refused whole — the outbox would park the lead — so until the
|
|
8
|
+
* panel's `sa.funnel.v1` accepts `callback`, a callback travels as `form`.
|
|
9
|
+
* TODO(panel): set to "callback" once the panel accepts it; nothing else changes.
|
|
10
|
+
*/
|
|
11
|
+
const PANEL_CALLBACK = "form";
|
|
12
|
+
/** A lead's channel as the panel's `properties.channel` may carry it: `panelChannel(channelOf(lead))`. */
|
|
13
|
+
export function panelChannel(channel) {
|
|
14
|
+
return channel === "callback" ? PANEL_CALLBACK : "form";
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The lead webhook, or `null` when it is off: no `LEAD_WEBHOOK_URL`, or no
|
|
18
|
+
* body builder from the brand. Built at boot by the brand, like the notifier,
|
|
19
|
+
* so a broken setting fails startup rather than a lead.
|
|
20
|
+
*/
|
|
21
|
+
export function leadWebhook(site, env, options) {
|
|
22
|
+
const { buildBody, signing, ...outboxOptions } = options;
|
|
23
|
+
if (!env.leadWebhook || !buildBody)
|
|
24
|
+
return null;
|
|
25
|
+
if (env.leadsDb.kind !== "sqlite")
|
|
26
|
+
throw new Error("LEAD_WEBHOOK_URL: the webhook outbox needs the sqlite lead store");
|
|
27
|
+
const outbox = openWebhookOutbox(env.leadsDb.path, { ...env.leadWebhook, signing }, outboxOptions);
|
|
28
|
+
const now = options.now ?? Date.now;
|
|
29
|
+
return {
|
|
30
|
+
outbox,
|
|
31
|
+
enqueue(lead, id, meta) {
|
|
32
|
+
const ctx = { leadId: id, brandId: site.brand.id, ...meta, at: new Date(now()), idempotencyKey: randomUUID() };
|
|
33
|
+
const body = JSON.stringify(buildBody(lead, ctx));
|
|
34
|
+
if (typeof body !== "string")
|
|
35
|
+
throw new Error("buildWebhookBody returned nothing JSON can carry");
|
|
36
|
+
return outbox.enqueue(body, `lead:${id}`);
|
|
37
|
+
},
|
|
38
|
+
tick: () => outbox.tick(),
|
|
39
|
+
start: intervalMs => outbox.start(intervalMs),
|
|
40
|
+
stop: () => outbox.stop(),
|
|
41
|
+
close: () => outbox.close(),
|
|
42
|
+
};
|
|
43
|
+
}
|
package/dist/server/notify.d.ts
CHANGED
package/dist/server/notify.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import "server-only";
|
|
2
|
+
import { channelOf } from "../core/lead.js";
|
|
2
3
|
import { parseSmtpUrl, sendMail } from "./smtp.js";
|
|
3
4
|
/** A plain default; a brand passes `format` to write it in its own language. */
|
|
4
5
|
export function defaultLeadMail(brand, lead, id) {
|
|
5
6
|
const place = lead.placeSlug ?? "unknown";
|
|
7
|
+
// A callback request is a promise to ring the customer: it says so first.
|
|
8
|
+
const callback = channelOf(lead) === "callback";
|
|
6
9
|
return {
|
|
7
|
-
subject: `${brand.name} — new lead (${place})`,
|
|
10
|
+
subject: `${brand.name} — ${callback ? "call back" : "new lead"} (${place})`,
|
|
8
11
|
text: [
|
|
9
|
-
`Lead #${id} — place ${place}${lead.spamVerdict ? ` — suspect (${lead.spamVerdict})` : ""}`,
|
|
12
|
+
`Lead #${id} — place ${place}${callback ? " — CALL BACK" : ""}${lead.spamVerdict ? ` — suspect (${lead.spamVerdict})` : ""}`,
|
|
10
13
|
"",
|
|
11
14
|
`Subject : ${lead.subject}`,
|
|
12
15
|
`Locality : ${lead.locality}`,
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { type WebhookSigning } from "./webhook-signature.js";
|
|
3
|
+
/**
|
|
4
|
+
* A durable queue of signed POSTs, kept in the leads file so a body is on disk
|
|
5
|
+
* before the visitor is thanked and survives a restart of the pod. Delivery is
|
|
6
|
+
* at-least-once: a row leaves `pending` only on the receiver's answer, and a
|
|
7
|
+
* crash between the answer and the update sends it again — the receiver
|
|
8
|
+
* deduplicates by whatever id the body carries.
|
|
9
|
+
*
|
|
10
|
+
* Bodies hold PII; they go out in the request and nowhere else. Logs name the
|
|
11
|
+
* row, the attempt and the status — never the body, and never the receiver's
|
|
12
|
+
* words, which may echo it: those stay in the row's `last_error`, beside the
|
|
13
|
+
* body they describe.
|
|
14
|
+
*/
|
|
15
|
+
export interface WebhookTarget {
|
|
16
|
+
url: string;
|
|
17
|
+
keyId: string;
|
|
18
|
+
secret: string;
|
|
19
|
+
signing: WebhookSigning;
|
|
20
|
+
}
|
|
21
|
+
export interface WebhookOutboxOptions {
|
|
22
|
+
/** Tries per row, the first included; past it the row is `dead`. */
|
|
23
|
+
maxAttempts?: number;
|
|
24
|
+
/** The wait after the first failure; doubled after each next one. */
|
|
25
|
+
baseDelayMs?: number;
|
|
26
|
+
maxDelayMs?: number;
|
|
27
|
+
/** Per request, so a hung receiver does not hold the tick. */
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
/** Rows sent per tick. */
|
|
30
|
+
batch?: number;
|
|
31
|
+
fetch?: typeof fetch;
|
|
32
|
+
now?: () => number;
|
|
33
|
+
/** In `[0, 1)`; spreads retries so a receiver back from an outage is not hit at once. */
|
|
34
|
+
random?: () => number;
|
|
35
|
+
log?: Pick<Console, "info" | "warn" | "error">;
|
|
36
|
+
}
|
|
37
|
+
export type OutboxState = "pending" | "delivered" | "dead";
|
|
38
|
+
export interface OutboxRow {
|
|
39
|
+
id: number;
|
|
40
|
+
ref: string | null;
|
|
41
|
+
state: OutboxState;
|
|
42
|
+
attempts: number;
|
|
43
|
+
nextAttemptAt: number;
|
|
44
|
+
lastError: string | null;
|
|
45
|
+
}
|
|
46
|
+
export interface TickReport {
|
|
47
|
+
delivered: number;
|
|
48
|
+
retried: number;
|
|
49
|
+
dead: number;
|
|
50
|
+
}
|
|
51
|
+
export interface WebhookOutbox {
|
|
52
|
+
/** Queues a body; synchronous and local, so it can sit before the response. */
|
|
53
|
+
enqueue(body: string, ref?: string | null): number;
|
|
54
|
+
/** One pass over the rows that are due; concurrent calls share the running one. */
|
|
55
|
+
tick(): Promise<TickReport>;
|
|
56
|
+
/** Ticks every `intervalMs` until `stop`; a second call is a no-op. */
|
|
57
|
+
start(intervalMs?: number): void;
|
|
58
|
+
stop(): void;
|
|
59
|
+
rows(): OutboxRow[];
|
|
60
|
+
close(): void;
|
|
61
|
+
}
|
|
62
|
+
export declare const WEBHOOK_MAX_ATTEMPTS = 12;
|
|
63
|
+
export declare const WEBHOOK_TICK_MS = 10000;
|
|
64
|
+
/**
|
|
65
|
+
* `https:` anywhere; `http:` only where nothing but the cluster sees the
|
|
66
|
+
* bytes — a Service's DNS name or loopback — since the body carries PII.
|
|
67
|
+
* Throws without the value: a URL setting may carry credentials.
|
|
68
|
+
*/
|
|
69
|
+
export declare function checkWebhookUrl(value: string, name?: string): string;
|
|
70
|
+
/**
|
|
71
|
+
* Opens (and creates) the outbox table in the SQLite file at `path` — the
|
|
72
|
+
* leads file, through a connection of its own. Every row is bound to the
|
|
73
|
+
* target URL it was queued for: after a change of `LEAD_WEBHOOK_URL` the old
|
|
74
|
+
* rows stay, are counted at open, and are not sent to a receiver that never
|
|
75
|
+
* agreed to take them.
|
|
76
|
+
*/
|
|
77
|
+
export declare function openWebhookOutbox(path: string, target: WebhookTarget, options?: WebhookOutboxOptions): WebhookOutbox;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { signatureHeaders } from "./webhook-signature.js";
|
|
5
|
+
export const WEBHOOK_MAX_ATTEMPTS = 12;
|
|
6
|
+
export const WEBHOOK_TICK_MS = 10_000;
|
|
7
|
+
const SCHEMA = `
|
|
8
|
+
CREATE TABLE IF NOT EXISTS webhook_outbox (
|
|
9
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
10
|
+
target TEXT NOT NULL,
|
|
11
|
+
ref TEXT,
|
|
12
|
+
body TEXT NOT NULL,
|
|
13
|
+
created_at INTEGER NOT NULL,
|
|
14
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
15
|
+
next_attempt_at INTEGER NOT NULL,
|
|
16
|
+
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'delivered', 'dead')),
|
|
17
|
+
last_error TEXT,
|
|
18
|
+
done_at INTEGER
|
|
19
|
+
);
|
|
20
|
+
CREATE INDEX IF NOT EXISTS webhook_outbox_due ON webhook_outbox (state, target, next_attempt_at);
|
|
21
|
+
`;
|
|
22
|
+
/** How much of a receiver's answer a row keeps. */
|
|
23
|
+
const ERROR_CAP = 500;
|
|
24
|
+
const IN_CLUSTER = /(^|\.)svc(\.cluster\.local)?$/;
|
|
25
|
+
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
26
|
+
/**
|
|
27
|
+
* `https:` anywhere; `http:` only where nothing but the cluster sees the
|
|
28
|
+
* bytes — a Service's DNS name or loopback — since the body carries PII.
|
|
29
|
+
* Throws without the value: a URL setting may carry credentials.
|
|
30
|
+
*/
|
|
31
|
+
export function checkWebhookUrl(value, name = "LEAD_WEBHOOK_URL") {
|
|
32
|
+
let url;
|
|
33
|
+
try {
|
|
34
|
+
url = new URL(value);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new Error(`${name} is not a URL`);
|
|
38
|
+
}
|
|
39
|
+
if (url.protocol === "https:")
|
|
40
|
+
return url.toString();
|
|
41
|
+
if (url.protocol === "http:" && (IN_CLUSTER.test(url.hostname) || LOOPBACK.has(url.hostname)))
|
|
42
|
+
return url.toString();
|
|
43
|
+
throw new Error(`${name}: https, or http to a *.svc / *.svc.cluster.local host or localhost`);
|
|
44
|
+
}
|
|
45
|
+
function sqlite() {
|
|
46
|
+
const mod = process.getBuiltinModule("node:sqlite");
|
|
47
|
+
if (!mod)
|
|
48
|
+
throw new Error("node:sqlite is unavailable — Node ≥ 22.13 is required");
|
|
49
|
+
return mod;
|
|
50
|
+
}
|
|
51
|
+
function column(row, key) {
|
|
52
|
+
return typeof row === "object" && row !== null ? Reflect.get(row, key) : undefined;
|
|
53
|
+
}
|
|
54
|
+
function int(row, key) {
|
|
55
|
+
const value = column(row, key);
|
|
56
|
+
if (typeof value === "number" || typeof value === "bigint")
|
|
57
|
+
return Number(value);
|
|
58
|
+
throw new Error(`webhook_outbox: expected an integer \`${key}\`, got ${typeof value}`);
|
|
59
|
+
}
|
|
60
|
+
function text(row, key) {
|
|
61
|
+
const value = column(row, key);
|
|
62
|
+
return typeof value === "string" ? value : null;
|
|
63
|
+
}
|
|
64
|
+
function state(row) {
|
|
65
|
+
const value = column(row, "state");
|
|
66
|
+
if (value === "pending" || value === "delivered" || value === "dead")
|
|
67
|
+
return value;
|
|
68
|
+
throw new Error(`webhook_outbox: unknown state ${String(value)}`);
|
|
69
|
+
}
|
|
70
|
+
/** `Retry-After` in seconds; the HTTP-date form is rare enough to ignore. */
|
|
71
|
+
function retryAfter(response) {
|
|
72
|
+
const value = Number(response.headers.get("retry-after"));
|
|
73
|
+
return Number.isFinite(value) && value > 0 ? value * 1000 : null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A `207` names a verdict per item of the batch. `rejected` is final — the
|
|
77
|
+
* receiver refused the item for what it is, and sending it again changes
|
|
78
|
+
* nothing — so the row is delivered, with the refusals noted; the other
|
|
79
|
+
* statuses (`accepted`, `duplicate`) need no word.
|
|
80
|
+
*/
|
|
81
|
+
async function multiStatus(response, rowId, log) {
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = await response.json();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
log.warn(`webhook: outbox #${rowId}: a 207 with an unreadable body; taken as delivered`);
|
|
88
|
+
return { kind: "delivered", note: "207 with an unreadable body" };
|
|
89
|
+
}
|
|
90
|
+
const results = column(parsed, "results");
|
|
91
|
+
if (!Array.isArray(results))
|
|
92
|
+
return { kind: "delivered", note: null };
|
|
93
|
+
const rejected = results.filter(r => column(r, "status") === "rejected");
|
|
94
|
+
if (rejected.length === 0)
|
|
95
|
+
return { kind: "delivered", note: null };
|
|
96
|
+
for (const r of rejected) {
|
|
97
|
+
const index = column(r, "index");
|
|
98
|
+
log.warn(`webhook: outbox #${rowId}: item ${typeof index === "number" ? index : "?"} rejected by the receiver; not retried (reason in the row)`);
|
|
99
|
+
}
|
|
100
|
+
const reasons = rejected.map(r => `#${String(column(r, "index"))}: ${String(column(r, "reason") ?? "rejected")}`);
|
|
101
|
+
return { kind: "delivered", note: `rejected ${reasons.join("; ")}` };
|
|
102
|
+
}
|
|
103
|
+
async function judge(response, rowId, log) {
|
|
104
|
+
const status = response.status;
|
|
105
|
+
if (status === 207)
|
|
106
|
+
return multiStatus(response, rowId, log);
|
|
107
|
+
if (status >= 200 && status < 300) {
|
|
108
|
+
await response.body?.cancel();
|
|
109
|
+
return { kind: "delivered", note: null };
|
|
110
|
+
}
|
|
111
|
+
const said = (await response.text().catch(() => "")).slice(0, ERROR_CAP);
|
|
112
|
+
const tag = `HTTP ${status}`;
|
|
113
|
+
const why = said ? `${tag}: ${said}` : tag;
|
|
114
|
+
if (status === 408 || status === 429 || status >= 500)
|
|
115
|
+
return { kind: "retry", tag, why, afterMs: retryAfter(response) };
|
|
116
|
+
// 3xx included: redirects are not followed, since the target was vetted and its Location was not.
|
|
117
|
+
return { kind: "dead", tag, why };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Opens (and creates) the outbox table in the SQLite file at `path` — the
|
|
121
|
+
* leads file, through a connection of its own. Every row is bound to the
|
|
122
|
+
* target URL it was queued for: after a change of `LEAD_WEBHOOK_URL` the old
|
|
123
|
+
* rows stay, are counted at open, and are not sent to a receiver that never
|
|
124
|
+
* agreed to take them.
|
|
125
|
+
*/
|
|
126
|
+
export function openWebhookOutbox(path, target, options = {}) {
|
|
127
|
+
const maxAttempts = options.maxAttempts ?? WEBHOOK_MAX_ATTEMPTS;
|
|
128
|
+
const baseDelay = options.baseDelayMs ?? 5_000;
|
|
129
|
+
const maxDelay = options.maxDelayMs ?? 60 * 60_000;
|
|
130
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
131
|
+
const batch = options.batch ?? 20;
|
|
132
|
+
const send = options.fetch ?? fetch;
|
|
133
|
+
const now = options.now ?? Date.now;
|
|
134
|
+
const random = options.random ?? Math.random;
|
|
135
|
+
const log = options.log ?? console;
|
|
136
|
+
if (path !== ":memory:")
|
|
137
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
138
|
+
const db = new (sqlite().DatabaseSync)(path);
|
|
139
|
+
try {
|
|
140
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
141
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
142
|
+
db.exec(SCHEMA);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
db.close();
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
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 ?");
|
|
150
|
+
// A lease: another process ticking the same file skips a row while it is in flight.
|
|
151
|
+
const claim = db.prepare("UPDATE webhook_outbox SET next_attempt_at = ? WHERE id = ? AND state = 'pending' AND next_attempt_at <= ?");
|
|
152
|
+
const finish = db.prepare("UPDATE webhook_outbox SET state = ?, attempts = ?, last_error = ?, done_at = ? WHERE id = ?");
|
|
153
|
+
const reschedule = db.prepare("UPDATE webhook_outbox SET attempts = ?, next_attempt_at = ?, last_error = ? WHERE id = ?");
|
|
154
|
+
const list = db.prepare("SELECT id, ref, state, attempts, next_attempt_at, last_error FROM webhook_outbox ORDER BY id");
|
|
155
|
+
const stranded = db.prepare("SELECT COUNT(*) AS n FROM webhook_outbox WHERE state = 'pending' AND target <> ?");
|
|
156
|
+
const left = int(stranded.get(target.url), "n");
|
|
157
|
+
if (left > 0)
|
|
158
|
+
log.warn(`webhook: ${left} pending row(s) queued for another target; left in place, not sent`);
|
|
159
|
+
/** Doubling from `baseDelay`, capped, plus up to a fifth more at random. */
|
|
160
|
+
function backoff(attempts, hinted) {
|
|
161
|
+
const plain = Math.min(maxDelay, baseDelay * 2 ** Math.max(0, attempts - 1));
|
|
162
|
+
return Math.min(maxDelay, Math.max(plain + Math.floor(plain * 0.2 * random()), hinted ?? 0));
|
|
163
|
+
}
|
|
164
|
+
async function deliver(id, body) {
|
|
165
|
+
try {
|
|
166
|
+
const response = await send(target.url, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: { "content-type": "application/json", ...signatureHeaders(target.signing, target, body, now()) },
|
|
169
|
+
body,
|
|
170
|
+
redirect: "manual",
|
|
171
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
172
|
+
});
|
|
173
|
+
return await judge(response, id, log);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
// The name and code only: a fetch error's message is safe, but its cause chain is the runtime's to word.
|
|
177
|
+
const name = error instanceof Error ? error.name : "error";
|
|
178
|
+
const code = column(error instanceof Error ? error.cause : undefined, "code");
|
|
179
|
+
const tag = `network ${name}${typeof code === "string" ? ` ${code}` : ""}`;
|
|
180
|
+
return { kind: "retry", tag, why: tag, afterMs: null };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function pass() {
|
|
184
|
+
const report = { delivered: 0, retried: 0, dead: 0 };
|
|
185
|
+
const t = now();
|
|
186
|
+
for (const row of due.all(target.url, t, batch)) {
|
|
187
|
+
const id = int(row, "id");
|
|
188
|
+
const body = text(row, "body") ?? "";
|
|
189
|
+
const attempts = int(row, "attempts") + 1;
|
|
190
|
+
if (Number(claim.run(t + timeoutMs + 5_000, id, t).changes) === 0)
|
|
191
|
+
continue;
|
|
192
|
+
const verdict = await deliver(id, body);
|
|
193
|
+
if (verdict.kind === "delivered") {
|
|
194
|
+
finish.run("delivered", attempts, verdict.note?.slice(0, ERROR_CAP) ?? null, now(), id);
|
|
195
|
+
report.delivered += 1;
|
|
196
|
+
}
|
|
197
|
+
else if (verdict.kind === "dead" || attempts >= maxAttempts) {
|
|
198
|
+
finish.run("dead", attempts, verdict.why, now(), id);
|
|
199
|
+
log.error(`webhook: outbox #${id} dead after ${attempts} attempt(s): ${verdict.tag}`);
|
|
200
|
+
report.dead += 1;
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
const wait = backoff(attempts, verdict.afterMs);
|
|
204
|
+
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`);
|
|
206
|
+
report.retried += 1;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return report;
|
|
210
|
+
}
|
|
211
|
+
let running = null;
|
|
212
|
+
let timer = null;
|
|
213
|
+
const tick = () => {
|
|
214
|
+
running ??= pass().finally(() => {
|
|
215
|
+
running = null;
|
|
216
|
+
});
|
|
217
|
+
return running;
|
|
218
|
+
};
|
|
219
|
+
const stop = () => {
|
|
220
|
+
if (timer)
|
|
221
|
+
clearInterval(timer);
|
|
222
|
+
timer = null;
|
|
223
|
+
};
|
|
224
|
+
return {
|
|
225
|
+
enqueue(body, ref = null) {
|
|
226
|
+
const t = now();
|
|
227
|
+
return int(insert.get(target.url, ref, body, t, t), "id");
|
|
228
|
+
},
|
|
229
|
+
tick,
|
|
230
|
+
start(intervalMs = WEBHOOK_TICK_MS) {
|
|
231
|
+
if (timer)
|
|
232
|
+
return;
|
|
233
|
+
const run = () => {
|
|
234
|
+
tick().catch(error => log.error("webhook: an outbox tick failed", error));
|
|
235
|
+
};
|
|
236
|
+
timer = setInterval(run, intervalMs);
|
|
237
|
+
// The queue must not keep a process alive that is otherwise done.
|
|
238
|
+
timer.unref();
|
|
239
|
+
run();
|
|
240
|
+
},
|
|
241
|
+
stop,
|
|
242
|
+
rows() {
|
|
243
|
+
return list.all().map(row => ({
|
|
244
|
+
id: int(row, "id"),
|
|
245
|
+
ref: text(row, "ref"),
|
|
246
|
+
state: state(row),
|
|
247
|
+
attempts: int(row, "attempts"),
|
|
248
|
+
nextAttemptAt: int(row, "next_attempt_at"),
|
|
249
|
+
lastError: text(row, "last_error"),
|
|
250
|
+
}));
|
|
251
|
+
},
|
|
252
|
+
close() {
|
|
253
|
+
stop();
|
|
254
|
+
db.close();
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|