@evinvest/kitstart 0.1.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 +245 -0
- package/dist/cli/kitstart-size.d.ts +2 -0
- package/dist/cli/kitstart-size.js +11 -0
- package/dist/cli/size.d.ts +31 -0
- package/dist/cli/size.js +99 -0
- package/dist/core/accept.d.ts +49 -0
- package/dist/core/accept.js +78 -0
- package/dist/core/analytics.d.ts +43 -0
- package/dist/core/analytics.js +42 -0
- package/dist/core/antispam.d.ts +60 -0
- package/dist/core/antispam.js +90 -0
- package/dist/core/content.d.ts +82 -0
- package/dist/core/content.js +12 -0
- package/dist/core/lead.d.ts +88 -0
- package/dist/core/lead.js +42 -0
- package/dist/core/memo.d.ts +8 -0
- package/dist/core/memo.js +19 -0
- package/dist/core/place/index.d.ts +5 -0
- package/dist/core/place/index.js +5 -0
- package/dist/core/place/live.d.ts +30 -0
- package/dist/core/place/live.js +132 -0
- package/dist/core/place/publication.d.ts +29 -0
- package/dist/core/place/publication.js +42 -0
- package/dist/core/place/rating.d.ts +16 -0
- package/dist/core/place/rating.js +33 -0
- package/dist/core/place/types.d.ts +91 -0
- package/dist/core/place/types.js +27 -0
- package/dist/core/place/view.d.ts +56 -0
- package/dist/core/place/view.js +43 -0
- package/dist/core/routing.d.ts +121 -0
- package/dist/core/routing.js +198 -0
- package/dist/core/seo/ld.d.ts +58 -0
- package/dist/core/seo/ld.js +161 -0
- package/dist/core/seo/sitemap.d.ts +52 -0
- package/dist/core/seo/sitemap.js +60 -0
- package/dist/core/site.d.ts +118 -0
- package/dist/core/site.js +84 -0
- package/dist/core/status.d.ts +47 -0
- package/dist/core/status.js +62 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +20 -0
- package/dist/next/config/build-env.d.ts +32 -0
- package/dist/next/config/build-env.js +101 -0
- package/dist/next/config/index.d.ts +30 -0
- package/dist/next/config/index.js +38 -0
- package/dist/next/index.d.ts +12 -0
- package/dist/next/index.js +12 -0
- package/dist/next/load.d.ts +24 -0
- package/dist/next/load.js +36 -0
- package/dist/next/metadata.d.ts +31 -0
- package/dist/next/metadata.js +66 -0
- package/dist/next/og-palette.d.ts +14 -0
- package/dist/next/og-palette.js +14 -0
- package/dist/next/og.d.ts +31 -0
- package/dist/next/og.js +42 -0
- package/dist/next/quote-route.d.ts +36 -0
- package/dist/next/quote-route.js +124 -0
- package/dist/next/routes.d.ts +15 -0
- package/dist/next/routes.js +50 -0
- package/dist/proxy/index.d.ts +31 -0
- package/dist/proxy/index.js +108 -0
- package/dist/react/AnalyticsBoundary.d.ts +14 -0
- package/dist/react/AnalyticsBoundary.js +55 -0
- package/dist/react/AreaChips.d.ts +8 -0
- package/dist/react/AreaChips.js +11 -0
- package/dist/react/CallBar.d.ts +29 -0
- package/dist/react/CallBar.js +7 -0
- package/dist/react/Coverage.d.ts +24 -0
- package/dist/react/Coverage.js +10 -0
- package/dist/react/Faq.d.ts +17 -0
- package/dist/react/Faq.js +5 -0
- package/dist/react/LangSwitch.d.ts +24 -0
- package/dist/react/LangSwitch.js +17 -0
- package/dist/react/MapFacade.d.ts +18 -0
- package/dist/react/MapFacade.js +8 -0
- package/dist/react/PlaceDirectory.d.ts +25 -0
- package/dist/react/PlaceDirectory.js +13 -0
- package/dist/react/QuoteFormShell.d.ts +34 -0
- package/dist/react/QuoteFormShell.js +10 -0
- package/dist/react/StatusScreen.d.ts +36 -0
- package/dist/react/StatusScreen.js +22 -0
- package/dist/react/index.d.ts +27 -0
- package/dist/react/index.js +29 -0
- package/dist/react/parts.d.ts +7 -0
- package/dist/react/parts.js +1 -0
- package/dist/server/boot.d.ts +11 -0
- package/dist/server/boot.js +20 -0
- package/dist/server/client-key.d.ts +22 -0
- package/dist/server/client-key.js +20 -0
- package/dist/server/env.d.ts +44 -0
- package/dist/server/env.js +73 -0
- package/dist/server/index.d.ts +15 -0
- package/dist/server/index.js +15 -0
- package/dist/server/lead-store-postgres.d.ts +14 -0
- package/dist/server/lead-store-postgres.js +23 -0
- package/dist/server/lead-store-sqlite.d.ts +21 -0
- package/dist/server/lead-store-sqlite.js +158 -0
- package/dist/server/lead-store.d.ts +20 -0
- package/dist/server/lead-store.js +43 -0
- package/dist/server/notify.d.ts +37 -0
- package/dist/server/notify.js +79 -0
- package/dist/server/place-source.d.ts +48 -0
- package/dist/server/place-source.js +107 -0
- package/dist/server/smtp.d.ts +38 -0
- package/dist/server/smtp.js +197 -0
- package/dist/testing/e2e.d.ts +52 -0
- package/dist/testing/e2e.js +57 -0
- package/dist/testing/fixtures.d.ts +11 -0
- package/dist/testing/fixtures.js +53 -0
- package/dist/testing/index.d.ts +12 -0
- package/dist/testing/index.js +12 -0
- package/dist/testing/landing-contract.d.ts +33 -0
- package/dist/testing/landing-contract.js +95 -0
- package/dist/testing/lead-store-contract.d.ts +13 -0
- package/dist/testing/lead-store-contract.js +55 -0
- package/dist/testing/served-files.d.ts +7 -0
- package/dist/testing/served-files.js +41 -0
- package/package.json +129 -0
- package/template/README.md +55 -0
- package/template/app/[locale]/[location]/layout.tsx +29 -0
- package/template/app/[locale]/[location]/page.tsx +18 -0
- package/template/app/[locale]/[location]/prices/page.tsx +26 -0
- package/template/app/[locale]/[location]/thanks/page.tsx +20 -0
- package/template/app/[locale]/error.tsx +14 -0
- package/template/app/[locale]/layout.tsx +28 -0
- package/template/app/[locale]/not-found.tsx +18 -0
- package/template/app/[locale]/page.tsx +40 -0
- package/template/app/[locale]/thanks/page.tsx +21 -0
- package/template/app/global-not-found.tsx +28 -0
- package/template/app/globals.css +18 -0
- package/template/app/health/route.ts +6 -0
- package/template/app/og/route.tsx +27 -0
- package/template/app/quote/route.ts +17 -0
- package/template/app/robots.ts +6 -0
- package/template/app/sitemap.ts +8 -0
- package/template/assets/brand.toml +68 -0
- package/template/assets/card.toml +8 -0
- package/template/assets/fonts/Inter-OFL.txt +93 -0
- package/template/assets/fonts/Inter-Regular.ttf +0 -0
- package/template/assets/fonts/Inter-Regular.woff2 +0 -0
- package/template/assets/mark.svg +1 -0
- package/template/deploy/config.nix +24 -0
- package/template/eslint.config.mjs +28 -0
- package/template/flake.nix +46 -0
- package/template/instrumentation.ts +13 -0
- package/template/next.config.ts +5 -0
- package/template/package.json +44 -0
- package/template/postcss.config.mjs +3 -0
- package/template/proxy.ts +11 -0
- package/template/src/entities/content/index.ts +12 -0
- package/template/src/entities/content/model/en.ts +47 -0
- package/template/src/entities/content/model/fr.ts +47 -0
- package/template/src/entities/content/model/types.ts +37 -0
- package/template/src/entities/place/api/load.ts +12 -0
- package/template/src/entities/place/index.ts +3 -0
- package/template/src/entities/place/server.ts +1 -0
- package/template/src/shared/config/env.ts +17 -0
- package/template/src/shared/config/i18n.ts +15 -0
- package/template/src/shared/config/lead.ts +15 -0
- package/template/src/shared/config/places.ts +20 -0
- package/template/src/shared/config/public.ts +11 -0
- package/template/src/shared/config/site.ts +39 -0
- package/template/src/shared/ui/fonts.ts +15 -0
- package/template/src/views/home/index.ts +1 -0
- package/template/src/views/home/ui/PlaceHome.tsx +76 -0
- package/template/src/views/not-found/index.ts +1 -0
- package/template/src/views/not-found/server.ts +1 -0
- package/template/src/views/not-found/ui/Gone.tsx +29 -0
- package/template/src/views/not-found/ui/NotFound.tsx +27 -0
- package/template/src/views/place/server.ts +13 -0
- package/template/src/views/server-error/index.ts +1 -0
- package/template/src/views/server-error/ui/ServerError.tsx +24 -0
- package/template/steiger.config.ts +26 -0
- package/template/tests/bundle_budget.txt +8 -0
- package/template/tests/contract.test.ts +14 -0
- package/template/tests/e2e/playwright.config.ts +26 -0
- package/template/tests/e2e/screenshot-section.css +5 -0
- package/template/tests/e2e/sections.spec.ts +12 -0
- package/template/tests/e2e/tsconfig.json +11 -0
- package/template/tests/empty.ts +1 -0
- package/template/tsconfig.json +23 -0
- package/template/vitest.config.ts +20 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@evinvest/kitstart/react` — the structural widgets every landing shares,
|
|
3
|
+
* where behaviour matters more than look. Server components by default; only
|
|
4
|
+
* `MapFacade` and `AnalyticsBoundary` are client modules, each its own file
|
|
5
|
+
* with its own `"use client"`, so a page pays for what it renders.
|
|
6
|
+
*
|
|
7
|
+
* Styled with the kit's token roles only (`text-ink`, `border-border`,
|
|
8
|
+
* `bg-card`…); a brand restyles through `className`, a widget's named parts
|
|
9
|
+
* (`classNames={{ list: "gap-2" }}`) and its palette. Tailwind
|
|
10
|
+
* must scan the package: `@source "<path to>/node_modules/@evinvest/kitstart/dist";`,
|
|
11
|
+
* relative to the stylesheet (`../../node_modules/…` from `src/app/`).
|
|
12
|
+
*/
|
|
13
|
+
export { AnalyticsBoundary } from "./AnalyticsBoundary.js";
|
|
14
|
+
export { AreaChips } from "./AreaChips.js";
|
|
15
|
+
export { CallBar, type CallBarPart, type CallBarProps } from "./CallBar.js";
|
|
16
|
+
export { Coverage, type CoveragePart, type CoverageProps } from "./Coverage.js";
|
|
17
|
+
export { Faq, type FaqPart, type FaqProps } from "./Faq.js";
|
|
18
|
+
export { LangSwitch, withLang, type LangSwitchProps } from "./LangSwitch.js";
|
|
19
|
+
export { MapFacade, type MapFacadeProps } from "./MapFacade.js";
|
|
20
|
+
export { PlaceDirectory, type PlaceDirectoryPart, type PlaceDirectoryProps } from "./PlaceDirectory.js";
|
|
21
|
+
export { PHONE_INPUT_PROPS, QuoteFormShell, type QuoteFormShellProps } from "./QuoteFormShell.js";
|
|
22
|
+
export { StatusScreen, type StatusScreenPart, type StatusScreenProps } from "./StatusScreen.js";
|
|
23
|
+
export type { PartClassNames } from "./parts.js";
|
|
24
|
+
export { Badge, Button, buttonVariants, Check, cn, Display, Eyebrow, Field, FieldLabel, Input, NativeSelect, NativeSelectOption, Prose, Section, SectionHead, Separator, Textarea, } from "@evinvest/uikit";
|
|
25
|
+
export { JsonLd } from "@evinvest/marketing";
|
|
26
|
+
export { ClickToLoad } from "@evinvest/marketing/click-to-load";
|
|
27
|
+
export { ContactLinkTracker } from "@evinvest/marketing/tracker";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@evinvest/kitstart/react` — the structural widgets every landing shares,
|
|
3
|
+
* where behaviour matters more than look. Server components by default; only
|
|
4
|
+
* `MapFacade` and `AnalyticsBoundary` are client modules, each its own file
|
|
5
|
+
* with its own `"use client"`, so a page pays for what it renders.
|
|
6
|
+
*
|
|
7
|
+
* Styled with the kit's token roles only (`text-ink`, `border-border`,
|
|
8
|
+
* `bg-card`…); a brand restyles through `className`, a widget's named parts
|
|
9
|
+
* (`classNames={{ list: "gap-2" }}`) and its palette. Tailwind
|
|
10
|
+
* must scan the package: `@source "<path to>/node_modules/@evinvest/kitstart/dist";`,
|
|
11
|
+
* relative to the stylesheet (`../../node_modules/…` from `src/app/`).
|
|
12
|
+
*/
|
|
13
|
+
export { AnalyticsBoundary } from "./AnalyticsBoundary.js";
|
|
14
|
+
export { AreaChips } from "./AreaChips.js";
|
|
15
|
+
export { CallBar } from "./CallBar.js";
|
|
16
|
+
export { Coverage } from "./Coverage.js";
|
|
17
|
+
export { Faq } from "./Faq.js";
|
|
18
|
+
export { LangSwitch, withLang } from "./LangSwitch.js";
|
|
19
|
+
export { MapFacade } from "./MapFacade.js";
|
|
20
|
+
export { PlaceDirectory } from "./PlaceDirectory.js";
|
|
21
|
+
export { PHONE_INPUT_PROPS, QuoteFormShell } from "./QuoteFormShell.js";
|
|
22
|
+
export { StatusScreen } from "./StatusScreen.js";
|
|
23
|
+
// The kit's pieces a landing composes with, by name — the list is the surface.
|
|
24
|
+
// The kit ships one module per file with its own boundary, so a page that
|
|
25
|
+
// renders a `Button` pays for the button, and `cn` works on the server.
|
|
26
|
+
export { Badge, Button, buttonVariants, Check, cn, Display, Eyebrow, Field, FieldLabel, Input, NativeSelect, NativeSelectOption, Prose, Section, SectionHead, Separator, Textarea, } from "@evinvest/uikit";
|
|
27
|
+
export { JsonLd } from "@evinvest/marketing";
|
|
28
|
+
export { ClickToLoad } from "@evinvest/marketing/click-to-load";
|
|
29
|
+
export { ContactLinkTracker } from "@evinvest/marketing/tracker";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Class names for a widget's named parts, merged after its own through `cn`
|
|
3
|
+
* (tailwind-merge), so a brand's `gap-3` replaces the kit's `gap-6`. The
|
|
4
|
+
* geometry a brand needs, without descendant selectors that break silently
|
|
5
|
+
* when the widget's markup moves.
|
|
6
|
+
*/
|
|
7
|
+
export type PartClassNames<P extends string> = Readonly<Partial<Record<P, string>>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import type { ServerEnv } from "./env.js";
|
|
3
|
+
/**
|
|
4
|
+
* Opens the store once at boot — the brand's `instrumentation.ts` calls this —
|
|
5
|
+
* and closes it again. A missing volume, a `leads` table the migrations refuse
|
|
6
|
+
* or an adapter that does not exist then fails startup, and the pod never
|
|
7
|
+
* turns ready, instead of the first customer's submission failing. Says which
|
|
8
|
+
* store and where, because `LEADS_DB_URL` outranks `LEADS_DB_PATH` and the
|
|
9
|
+
* log is the one place that shows which won.
|
|
10
|
+
*/
|
|
11
|
+
export declare function checkLeadStore(env: Pick<ServerEnv, "leadsDb" | "leadsDbFrom">, log?: Pick<Console, "info">): Promise<void>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { describeLeadDb, openLeadStore } from "./lead-store.js";
|
|
3
|
+
/**
|
|
4
|
+
* Opens the store once at boot — the brand's `instrumentation.ts` calls this —
|
|
5
|
+
* and closes it again. A missing volume, a `leads` table the migrations refuse
|
|
6
|
+
* or an adapter that does not exist then fails startup, and the pod never
|
|
7
|
+
* turns ready, instead of the first customer's submission failing. Says which
|
|
8
|
+
* store and where, because `LEADS_DB_URL` outranks `LEADS_DB_PATH` and the
|
|
9
|
+
* log is the one place that shows which won.
|
|
10
|
+
*/
|
|
11
|
+
export async function checkLeadStore(env, log = console) {
|
|
12
|
+
const store = openLeadStore(env.leadsDb);
|
|
13
|
+
try {
|
|
14
|
+
await store.health();
|
|
15
|
+
log.info(`leads: ${describeLeadDb(env.leadsDb)}, schema v${await store.schemaVersion()} (from ${env.leadsDbFrom})`);
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
await store.close();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
/**
|
|
3
|
+
* Which proxy's word the rate limit takes for the client's address — said
|
|
4
|
+
* outright, because the wrong guess is either forgeable or useless:
|
|
5
|
+
*
|
|
6
|
+
* - `"cloudflare"`: the origin is reached only through Cloudflare (the
|
|
7
|
+
* cloudflared tunnel), so `CF-Connecting-IP` is the visitor and cannot be
|
|
8
|
+
* forged past the edge;
|
|
9
|
+
* - `{ xffHops: n }`: `n` proxies of ours each append to `X-Forwarded-For`,
|
|
10
|
+
* so the visitor is the n-th entry from the right — everything left of it
|
|
11
|
+
* is whatever the client wrote.
|
|
12
|
+
*
|
|
13
|
+
* From `TRUSTED_PROXY` (`cloudflare` | `xff:<n>`); production refuses to boot
|
|
14
|
+
* without it (`parseServerEnv`).
|
|
15
|
+
*/
|
|
16
|
+
export type ProxyTrust = "cloudflare" | {
|
|
17
|
+
xffHops: number;
|
|
18
|
+
};
|
|
19
|
+
/** One shared bucket when the trusted header is absent: coarse, but it throttles rather than admits. */
|
|
20
|
+
export declare const NO_CLIENT_ADDRESS = "no-client-address";
|
|
21
|
+
export declare function parseProxyTrust(value: string): ProxyTrust;
|
|
22
|
+
export declare function clientKey(headers: Headers, trust: ProxyTrust): string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
/** One shared bucket when the trusted header is absent: coarse, but it throttles rather than admits. */
|
|
3
|
+
export const NO_CLIENT_ADDRESS = "no-client-address";
|
|
4
|
+
export function parseProxyTrust(value) {
|
|
5
|
+
if (value === "cloudflare")
|
|
6
|
+
return "cloudflare";
|
|
7
|
+
const hops = /^xff:([1-9]\d?)$/.exec(value)?.[1];
|
|
8
|
+
if (hops)
|
|
9
|
+
return { xffHops: Number(hops) };
|
|
10
|
+
throw new Error('TRUSTED_PROXY must be "cloudflare" or "xff:<n>" (n ≥ 1)');
|
|
11
|
+
}
|
|
12
|
+
export function clientKey(headers, trust) {
|
|
13
|
+
if (trust === "cloudflare")
|
|
14
|
+
return headers.get("cf-connecting-ip")?.trim() || NO_CLIENT_ADDRESS;
|
|
15
|
+
const hops = (headers.get("x-forwarded-for") ?? "")
|
|
16
|
+
.split(",")
|
|
17
|
+
.map(h => h.trim())
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
return hops.at(-trust.xffHops) ?? NO_CLIENT_ADDRESS;
|
|
20
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { type ProxyTrust } from "./client-key.js";
|
|
3
|
+
import { type LeadDb } from "./lead-store.js";
|
|
4
|
+
/**
|
|
5
|
+
* The server's runtime configuration, parsed once. Secrets (`SMTP_URL`,
|
|
6
|
+
* `SMS_TOKEN`, `POSTHOG_KEY`) arrive from the container environment, never
|
|
7
|
+
* from the image.
|
|
8
|
+
*
|
|
9
|
+
* Production has no default for where state lives: a missing lead store
|
|
10
|
+
* (`LEADS_DB_URL` or `LEADS_DB_PATH`) throws, and a brand that parses this in
|
|
11
|
+
* `instrumentation.ts` gets a server that answers 500 everywhere — its
|
|
12
|
+
* readiness probe included — rather than one that loses the first lead.
|
|
13
|
+
*/
|
|
14
|
+
export interface ServerEnv {
|
|
15
|
+
production: boolean;
|
|
16
|
+
/** Where leads are kept; see `LeadDb`. */
|
|
17
|
+
leadsDb: LeadDb;
|
|
18
|
+
/** Which setting chose it, for the boot log. */
|
|
19
|
+
leadsDbFrom: "LEADS_DB_URL" | "LEADS_DB_PATH" | "default";
|
|
20
|
+
/** Where the live place data comes from; absent → the baked config. */
|
|
21
|
+
locationsApiUrl: string | null;
|
|
22
|
+
smtpUrl: string | null;
|
|
23
|
+
/** A bare address (`leads@brand.fr`), not `Name <…>`. */
|
|
24
|
+
notifyTo: string | null;
|
|
25
|
+
/** A bare address (`leads@brand.fr`), not `Name <…>`. */
|
|
26
|
+
notifyFrom: string | null;
|
|
27
|
+
/** Whose word the rate limit takes for the client's address; see `ProxyTrust`. */
|
|
28
|
+
trustedProxy: ProxyTrust | null;
|
|
29
|
+
smsToken: string | null;
|
|
30
|
+
posthogKey: string | null;
|
|
31
|
+
posthogHost: string;
|
|
32
|
+
}
|
|
33
|
+
export type EnvSource = Readonly<Record<string, string | undefined>>;
|
|
34
|
+
export declare function parseServerEnv(site: {
|
|
35
|
+
brand: {
|
|
36
|
+
id: string;
|
|
37
|
+
};
|
|
38
|
+
}, source: EnvSource): ServerEnv;
|
|
39
|
+
/** Lazy and cached: `next build` imports the brand's env module and must not need prod secrets. */
|
|
40
|
+
export declare function createServerEnv(site: {
|
|
41
|
+
brand: {
|
|
42
|
+
id: string;
|
|
43
|
+
};
|
|
44
|
+
}, source?: EnvSource): () => ServerEnv;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parseProxyTrust } from "./client-key.js";
|
|
5
|
+
import { parseLeadDb } from "./lead-store.js";
|
|
6
|
+
function opt(source, name) {
|
|
7
|
+
const value = source[name]?.trim();
|
|
8
|
+
return value ? value : null;
|
|
9
|
+
}
|
|
10
|
+
function url(source, name) {
|
|
11
|
+
const value = opt(source, name);
|
|
12
|
+
if (value === null)
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
return new URL(value).toString().replace(/\/$/, "");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// Never the value: a URL setting may carry credentials.
|
|
19
|
+
throw new Error(`${name} is not a URL`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* `LEADS_DB_URL` picks the adapter by scheme; `LEADS_DB_PATH`, the setting a
|
|
24
|
+
* deployed image may already carry, is the SQLite file and stays valid. Both
|
|
25
|
+
* set is the migration path (the image bakes the path, a Secret adds the URL):
|
|
26
|
+
* the URL wins, and the boot log says so.
|
|
27
|
+
*/
|
|
28
|
+
function leadsDb(source, production, brandId) {
|
|
29
|
+
const dbUrl = opt(source, "LEADS_DB_URL");
|
|
30
|
+
if (dbUrl !== null) {
|
|
31
|
+
const db = parseLeadDb(dbUrl);
|
|
32
|
+
// Refused here, at boot, until the adapter exists — not on the first lead.
|
|
33
|
+
if (db.kind === "postgres")
|
|
34
|
+
throw new Error("LEADS_DB_URL: the postgres lead store is not implemented yet; use sqlite:///<path>");
|
|
35
|
+
return { leadsDb: db, leadsDbFrom: "LEADS_DB_URL" };
|
|
36
|
+
}
|
|
37
|
+
const path = opt(source, "LEADS_DB_PATH");
|
|
38
|
+
if (path !== null)
|
|
39
|
+
return { leadsDb: { kind: "sqlite", path }, leadsDbFrom: "LEADS_DB_PATH" };
|
|
40
|
+
if (production) {
|
|
41
|
+
throw new Error("LEADS_DB_URL or LEADS_DB_PATH is required in production — the leads volume, e.g. /data/leads.db");
|
|
42
|
+
}
|
|
43
|
+
return { leadsDb: { kind: "sqlite", path: join(homedir(), ".local/share", brandId, "leads.db") }, leadsDbFrom: "default" };
|
|
44
|
+
}
|
|
45
|
+
export function parseServerEnv(site, source) {
|
|
46
|
+
const production = source["NODE_ENV"] === "production";
|
|
47
|
+
const trust = opt(source, "TRUSTED_PROXY");
|
|
48
|
+
if (trust === null && production) {
|
|
49
|
+
throw new Error("TRUSTED_PROXY is required in production: cloudflare, or xff:<n> for n proxies of ours");
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
production,
|
|
53
|
+
trustedProxy: trust === null ? null : parseProxyTrust(trust),
|
|
54
|
+
...leadsDb(source, production, site.brand.id),
|
|
55
|
+
locationsApiUrl: url(source, "LOCATIONS_API_URL"),
|
|
56
|
+
smtpUrl: opt(source, "SMTP_URL"),
|
|
57
|
+
notifyTo: opt(source, "LEAD_NOTIFY_TO"),
|
|
58
|
+
notifyFrom: opt(source, "LEAD_NOTIFY_FROM"),
|
|
59
|
+
smsToken: opt(source, "SMS_TOKEN"),
|
|
60
|
+
posthogKey: opt(source, "POSTHOG_KEY"),
|
|
61
|
+
// Explicit because PostHog rejects a project's events at the other
|
|
62
|
+
// region's host; the audience is European, so EU unless told otherwise.
|
|
63
|
+
posthogHost: url(source, "POSTHOG_HOST") ?? "https://eu.i.posthog.com",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Lazy and cached: `next build` imports the brand's env module and must not need prod secrets. */
|
|
67
|
+
export function createServerEnv(site, source = process.env) {
|
|
68
|
+
let cached;
|
|
69
|
+
return () => {
|
|
70
|
+
cached ??= parseServerEnv(site, source);
|
|
71
|
+
return cached;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@evinvest/kitstart/server` — Node-only, and marked so: every module here
|
|
3
|
+
* imports `server-only`, so reaching one from a client component fails the
|
|
4
|
+
* build instead of shipping SMTP credentials to a browser.
|
|
5
|
+
*/
|
|
6
|
+
import "server-only";
|
|
7
|
+
export { createServerEnv, parseServerEnv, type EnvSource, type ServerEnv } from "./env.js";
|
|
8
|
+
export { createPlaceSource, PLACE_REVALIDATE_SECONDS, PlaceSourceError, type PlaceSource, type PlaceSourceOptions } from "./place-source.js";
|
|
9
|
+
export { describeLeadDb, openLeadStore, parseLeadDb, type LeadDb } from "./lead-store.js";
|
|
10
|
+
export { openSqliteLeadStore, type SqliteLeadStore } from "./lead-store-sqlite.js";
|
|
11
|
+
export { LeadStoreNotImplemented, openPostgresLeadStore } from "./lead-store-postgres.js";
|
|
12
|
+
export { parseSmtpUrl, sendMail, type Mail, type SendOptions } from "./smtp.js";
|
|
13
|
+
export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE, type LeadMail, type LeadNotifier, type NotifyEnv } from "./notify.js";
|
|
14
|
+
export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust, type ProxyTrust } from "./client-key.js";
|
|
15
|
+
export { checkLeadStore } from "./boot.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@evinvest/kitstart/server` — Node-only, and marked so: every module here
|
|
3
|
+
* imports `server-only`, so reaching one from a client component fails the
|
|
4
|
+
* build instead of shipping SMTP credentials to a browser.
|
|
5
|
+
*/
|
|
6
|
+
import "server-only";
|
|
7
|
+
export { createServerEnv, parseServerEnv } from "./env.js";
|
|
8
|
+
export { createPlaceSource, PLACE_REVALIDATE_SECONDS, PlaceSourceError } from "./place-source.js";
|
|
9
|
+
export { describeLeadDb, openLeadStore, parseLeadDb } from "./lead-store.js";
|
|
10
|
+
export { openSqliteLeadStore } from "./lead-store-sqlite.js";
|
|
11
|
+
export { LeadStoreNotImplemented, openPostgresLeadStore } from "./lead-store-postgres.js";
|
|
12
|
+
export { parseSmtpUrl, sendMail } from "./smtp.js";
|
|
13
|
+
export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE } from "./notify.js";
|
|
14
|
+
export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust } from "./client-key.js";
|
|
15
|
+
export { checkLeadStore } from "./boot.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import type { LeadStore } from "../core/lead.js";
|
|
3
|
+
/**
|
|
4
|
+
* The Postgres adapter of the `LeadStore` port — not written yet. It is
|
|
5
|
+
* recognised so that `LEADS_DB_URL=postgres://…` fails at boot, loudly, instead
|
|
6
|
+
* of a deployment that asked for Postgres writing leads somewhere nobody
|
|
7
|
+
* looks. When it lands: the driver is an optional peer, the table mirrors the
|
|
8
|
+
* SQLite columns, the schema version follows `LEAD_SCHEMA_VERSION`, and it
|
|
9
|
+
* must pass `describeLeadStoreContract` before `openLeadStore` may return it.
|
|
10
|
+
*/
|
|
11
|
+
export declare class LeadStoreNotImplemented extends Error {
|
|
12
|
+
readonly name = "LeadStoreNotImplemented";
|
|
13
|
+
}
|
|
14
|
+
export declare function openPostgresLeadStore(url: string): LeadStore;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
/**
|
|
3
|
+
* The Postgres adapter of the `LeadStore` port — not written yet. It is
|
|
4
|
+
* recognised so that `LEADS_DB_URL=postgres://…` fails at boot, loudly, instead
|
|
5
|
+
* of a deployment that asked for Postgres writing leads somewhere nobody
|
|
6
|
+
* looks. When it lands: the driver is an optional peer, the table mirrors the
|
|
7
|
+
* SQLite columns, the schema version follows `LEAD_SCHEMA_VERSION`, and it
|
|
8
|
+
* must pass `describeLeadStoreContract` before `openLeadStore` may return it.
|
|
9
|
+
*/
|
|
10
|
+
export class LeadStoreNotImplemented extends Error {
|
|
11
|
+
name = "LeadStoreNotImplemented";
|
|
12
|
+
}
|
|
13
|
+
export function openPostgresLeadStore(url) {
|
|
14
|
+
const host = (() => {
|
|
15
|
+
try {
|
|
16
|
+
return new URL(url).host;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return "?";
|
|
20
|
+
}
|
|
21
|
+
})();
|
|
22
|
+
throw new LeadStoreNotImplemented(`LEADS_DB_URL names postgres (${host}), whose lead store adapter is not implemented yet; use sqlite:///<path>`);
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { type LeadStore } from "../core/lead.js";
|
|
3
|
+
/**
|
|
4
|
+
* The SQLite adapter of the `LeadStore` port: one file on the pod's volume.
|
|
5
|
+
*
|
|
6
|
+
* The table began as the Rust server's (`job`, `zip`, `mobile`, `at`); the
|
|
7
|
+
* file on a prod volume is opened as it is and brought forward in place. The
|
|
8
|
+
* columns keep their first names — renaming a column on a live volume buys
|
|
9
|
+
* nothing — and map to the lead's `subject`, `locality` and `mobile`.
|
|
10
|
+
*/
|
|
11
|
+
export interface SqliteLeadStore extends LeadStore {
|
|
12
|
+
/** The schema version the file is at after opening. */
|
|
13
|
+
version(): number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Opens (and migrates) the file synchronously, so a broken volume fails boot
|
|
17
|
+
* (see `checkLeadStore`); the port's methods are async only by contract —
|
|
18
|
+
* `node:sqlite` answers in-process, and an `async` body turns its throw into
|
|
19
|
+
* the rejection the funnel expects.
|
|
20
|
+
*/
|
|
21
|
+
export declare function openSqliteLeadStore(path: string): SqliteLeadStore;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { LEAD_SCHEMA_VERSION } from "../core/lead.js";
|
|
5
|
+
// `node:sqlite` is reached through `getBuiltinModule` rather than an import so
|
|
6
|
+
// the bundler never has to resolve a builtin it may not know yet; the type is
|
|
7
|
+
// the module's own.
|
|
8
|
+
function sqlite() {
|
|
9
|
+
const mod = process.getBuiltinModule("node:sqlite");
|
|
10
|
+
if (!mod)
|
|
11
|
+
throw new Error("node:sqlite is unavailable — Node ≥ 22.13 is required");
|
|
12
|
+
return mod;
|
|
13
|
+
}
|
|
14
|
+
/** A column of a row SQLite handed back, checked rather than cast. */
|
|
15
|
+
function column(row, key) {
|
|
16
|
+
return typeof row === "object" && row !== null ? Reflect.get(row, key) : undefined;
|
|
17
|
+
}
|
|
18
|
+
function integer(row, key) {
|
|
19
|
+
const value = column(row, key);
|
|
20
|
+
if (typeof value === "number" || typeof value === "bigint")
|
|
21
|
+
return Number(value);
|
|
22
|
+
throw new Error(`leads: expected an integer \`${key}\`, got ${typeof value}`);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Each step takes the file from version `i` to `i + 1`. Append only: a step
|
|
26
|
+
* that shipped is history, and a file anywhere may be at any of them.
|
|
27
|
+
*/
|
|
28
|
+
const STEPS = [
|
|
29
|
+
// 1 — the Rust server's table.
|
|
30
|
+
db => db.exec(`CREATE TABLE leads (
|
|
31
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
32
|
+
job TEXT NOT NULL,
|
|
33
|
+
zip TEXT NOT NULL,
|
|
34
|
+
mobile TEXT NOT NULL,
|
|
35
|
+
at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
36
|
+
)`),
|
|
37
|
+
// 2 — the point a lead came from. Nullable: every row written before points
|
|
38
|
+
// existed has none, and inventing one would be a lie in the one table that matters.
|
|
39
|
+
db => db.exec("ALTER TABLE leads ADD COLUMN location_id TEXT"),
|
|
40
|
+
// 3 — suspected spam is kept, flagged, and never notified: a false positive
|
|
41
|
+
// is a customer, and a reviewer can still find it here.
|
|
42
|
+
db => db.exec("ALTER TABLE leads ADD COLUMN spam_verdict TEXT"),
|
|
43
|
+
// 4 — the brand's extra fields, as a JSON object; `NULL` when there are none.
|
|
44
|
+
db => db.exec("ALTER TABLE leads ADD COLUMN extras TEXT"),
|
|
45
|
+
];
|
|
46
|
+
if (STEPS.length !== LEAD_SCHEMA_VERSION) {
|
|
47
|
+
// The version belongs to the port, shared by every adapter; this table of
|
|
48
|
+
// steps must end exactly there.
|
|
49
|
+
throw new Error(`leads: ${STEPS.length} sqlite steps for schema version ${LEAD_SCHEMA_VERSION}`);
|
|
50
|
+
}
|
|
51
|
+
const RUST_COLUMNS = ["id", "job", "zip", "mobile", "at"];
|
|
52
|
+
function userVersion(db) {
|
|
53
|
+
return integer(db.prepare("PRAGMA user_version").get(), "user_version");
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Step 0, for a file no versioned store has opened (`user_version` 0): which
|
|
57
|
+
* step it already stands at, read off the columns. The Rust server and the
|
|
58
|
+
* first Node port both left an unversioned table; the port added its columns
|
|
59
|
+
* one at a time, so a file may stop at any of them.
|
|
60
|
+
*/
|
|
61
|
+
function recognise(db) {
|
|
62
|
+
const columns = db.prepare("PRAGMA table_info(leads)").all().map(row => column(row, "name"));
|
|
63
|
+
if (columns.length === 0)
|
|
64
|
+
return 0;
|
|
65
|
+
if (!RUST_COLUMNS.every(c => columns.includes(c))) {
|
|
66
|
+
throw new Error(`leads: an unrecognised table (${columns.join(", ")}); refusing to migrate it`);
|
|
67
|
+
}
|
|
68
|
+
let at = 1;
|
|
69
|
+
for (const [step, added] of [[2, "location_id"], [3, "spam_verdict"], [4, "extras"]]) {
|
|
70
|
+
if (!columns.includes(added))
|
|
71
|
+
break;
|
|
72
|
+
at = step;
|
|
73
|
+
}
|
|
74
|
+
return at;
|
|
75
|
+
}
|
|
76
|
+
/** Runs `fn` under a write lock; two pods opening one file take turns. */
|
|
77
|
+
function immediate(db, fn) {
|
|
78
|
+
db.exec("BEGIN IMMEDIATE");
|
|
79
|
+
try {
|
|
80
|
+
fn();
|
|
81
|
+
db.exec("COMMIT");
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
// SQLite may already have rolled back (a failed COMMIT, an I/O error), and
|
|
85
|
+
// then ROLLBACK throws too. The first error says what went wrong; this one
|
|
86
|
+
// would only hide it. `db.isTransaction` is not in every Node ≥ 22.13.
|
|
87
|
+
try {
|
|
88
|
+
db.exec("ROLLBACK");
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Nothing left to roll back.
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function migrate(db) {
|
|
97
|
+
immediate(db, () => {
|
|
98
|
+
if (userVersion(db) === 0)
|
|
99
|
+
db.exec(`PRAGMA user_version = ${recognise(db)}`);
|
|
100
|
+
});
|
|
101
|
+
for (let target = 1; target <= STEPS.length; target++) {
|
|
102
|
+
immediate(db, () => {
|
|
103
|
+
// Read again under the lock: another process may have run this step.
|
|
104
|
+
if (userVersion(db) >= target)
|
|
105
|
+
return;
|
|
106
|
+
STEPS[target - 1]?.(db);
|
|
107
|
+
db.exec(`PRAGMA user_version = ${target}`);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Opens (and migrates) the file synchronously, so a broken volume fails boot
|
|
113
|
+
* (see `checkLeadStore`); the port's methods are async only by contract —
|
|
114
|
+
* `node:sqlite` answers in-process, and an `async` body turns its throw into
|
|
115
|
+
* the rejection the funnel expects.
|
|
116
|
+
*/
|
|
117
|
+
export function openSqliteLeadStore(path) {
|
|
118
|
+
if (path !== ":memory:")
|
|
119
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
120
|
+
const db = new (sqlite().DatabaseSync)(path);
|
|
121
|
+
try {
|
|
122
|
+
// The wait first: switching to WAL takes a lock another pod may hold.
|
|
123
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
124
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
125
|
+
migrate(db);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
// A refused file must not stay open behind the error: the handle would
|
|
129
|
+
// hold its WAL and leak on every retry.
|
|
130
|
+
db.close();
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras) VALUES (?, ?, ?, ?, ?, ?) RETURNING id");
|
|
134
|
+
const count = db.prepare("SELECT COUNT(*) AS n FROM leads");
|
|
135
|
+
return {
|
|
136
|
+
async insert(lead) {
|
|
137
|
+
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");
|
|
139
|
+
},
|
|
140
|
+
async count() {
|
|
141
|
+
return integer(count.get(), "n");
|
|
142
|
+
},
|
|
143
|
+
version() {
|
|
144
|
+
return userVersion(db);
|
|
145
|
+
},
|
|
146
|
+
async schemaVersion() {
|
|
147
|
+
return userVersion(db);
|
|
148
|
+
},
|
|
149
|
+
async health() {
|
|
150
|
+
// A read through the handle the funnel writes with: a volume that
|
|
151
|
+
// vanished or a file locked for good answers here, not on a lead.
|
|
152
|
+
integer(count.get(), "n");
|
|
153
|
+
},
|
|
154
|
+
async close() {
|
|
155
|
+
db.close();
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import type { LeadStore } from "../core/lead.js";
|
|
3
|
+
/**
|
|
4
|
+
* Where leads are kept, chosen by the scheme of `LEADS_DB_URL`:
|
|
5
|
+
*
|
|
6
|
+
* - `sqlite:///data/leads.db` — a file on the pod's volume;
|
|
7
|
+
* - `postgres://…` / `postgresql://…` — the Postgres adapter (a stub today,
|
|
8
|
+
* which refuses at boot).
|
|
9
|
+
*/
|
|
10
|
+
export type LeadDb = {
|
|
11
|
+
kind: "sqlite";
|
|
12
|
+
path: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: "postgres";
|
|
15
|
+
url: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function parseLeadDb(value: string): LeadDb;
|
|
18
|
+
/** Where a `LeadDb` points, for a log line: never a password. */
|
|
19
|
+
export declare function describeLeadDb(db: LeadDb): string;
|
|
20
|
+
export declare function openLeadStore(db: LeadDb): LeadStore;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { openPostgresLeadStore } from "./lead-store-postgres.js";
|
|
3
|
+
import { openSqliteLeadStore } from "./lead-store-sqlite.js";
|
|
4
|
+
const POSTGRES = new Set(["postgres:", "postgresql:"]);
|
|
5
|
+
export function parseLeadDb(value) {
|
|
6
|
+
let url;
|
|
7
|
+
try {
|
|
8
|
+
url = new URL(value);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
// Never the value: it may carry a password.
|
|
12
|
+
throw new Error("LEADS_DB_URL is not a URL");
|
|
13
|
+
}
|
|
14
|
+
if (url.protocol === "sqlite:") {
|
|
15
|
+
// `sqlite://data/leads.db` parses `data` as a host and would put the file
|
|
16
|
+
// at `/leads.db`; a query or fragment would be dropped just as quietly.
|
|
17
|
+
if (url.host !== "" || url.search !== "" || url.hash !== "") {
|
|
18
|
+
throw new Error("LEADS_DB_URL: a sqlite URL is sqlite:///<absolute path> with no host, query or fragment");
|
|
19
|
+
}
|
|
20
|
+
const path = decodeURIComponent(url.pathname);
|
|
21
|
+
if (!path.startsWith("/"))
|
|
22
|
+
throw new Error("LEADS_DB_URL: a sqlite path must be absolute");
|
|
23
|
+
return { kind: "sqlite", path };
|
|
24
|
+
}
|
|
25
|
+
if (POSTGRES.has(url.protocol))
|
|
26
|
+
return { kind: "postgres", url: value };
|
|
27
|
+
throw new Error(`LEADS_DB_URL: unsupported scheme ${url.protocol}`);
|
|
28
|
+
}
|
|
29
|
+
/** Where a `LeadDb` points, for a log line: never a password. */
|
|
30
|
+
export function describeLeadDb(db) {
|
|
31
|
+
if (db.kind === "sqlite")
|
|
32
|
+
return `sqlite at ${db.path}`;
|
|
33
|
+
const url = new URL(db.url);
|
|
34
|
+
return `postgres at ${url.host}${url.pathname}`;
|
|
35
|
+
}
|
|
36
|
+
export function openLeadStore(db) {
|
|
37
|
+
switch (db.kind) {
|
|
38
|
+
case "sqlite":
|
|
39
|
+
return openSqliteLeadStore(db.path);
|
|
40
|
+
case "postgres":
|
|
41
|
+
return openPostgresLeadStore(db.url);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import type { Lead } from "../core/lead.js";
|
|
3
|
+
import type { BrandFacts } from "../core/site.js";
|
|
4
|
+
import type { ServerEnv } from "./env.js";
|
|
5
|
+
/**
|
|
6
|
+
* Telling the business a lead arrived. Called only after the lead is stored,
|
|
7
|
+
* and allowed to fail: a lead on disk but un-notified is recoverable; a 500
|
|
8
|
+
* shown to a customer whose lead we already hold is not.
|
|
9
|
+
*/
|
|
10
|
+
export interface LeadNotifier {
|
|
11
|
+
notify(lead: Lead, id: number): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export interface LeadMail {
|
|
14
|
+
subject: string;
|
|
15
|
+
text: string;
|
|
16
|
+
}
|
|
17
|
+
export type NotifyEnv = Pick<ServerEnv, "smtpUrl" | "notifyTo" | "notifyFrom" | "smsToken">;
|
|
18
|
+
/** A plain default; a brand passes `format` to write it in its own language. */
|
|
19
|
+
export declare function defaultLeadMail(brand: Pick<BrandFacts, "name">, lead: Lead, id: number): LeadMail;
|
|
20
|
+
/**
|
|
21
|
+
* Checked when it is built — at boot, when a brand builds it there — not on
|
|
22
|
+
* the first lead: with SMTP configured, a sender needs a domain to be `leads@`
|
|
23
|
+
* of (or `LEAD_NOTIFY_FROM`), and a recipient needs the brand's mailbox (or
|
|
24
|
+
* `LEAD_NOTIFY_TO`). A site before launch has neither, and `leads@null` is a
|
|
25
|
+
* mail no relay delivers.
|
|
26
|
+
*/
|
|
27
|
+
/** Mails a notifier sends per minute, per process; past it the lead is only logged. */
|
|
28
|
+
export declare const MAIL_PER_MINUTE = 20;
|
|
29
|
+
export declare function leadNotifier(site: {
|
|
30
|
+
brand: BrandFacts;
|
|
31
|
+
}, env: NotifyEnv, options?: {
|
|
32
|
+
format?: (lead: Lead, id: number) => LeadMail;
|
|
33
|
+
log?: Pick<Console, "warn">;
|
|
34
|
+
/** `MAIL_PER_MINUTE` by default: a flood that passes the antispam must not become a mail flood. */
|
|
35
|
+
mailPerMinute?: number;
|
|
36
|
+
now?: () => number;
|
|
37
|
+
}): LeadNotifier;
|