From 2b755851ff9c44fea7248eacb651ff87a347be98 Mon Sep 17 00:00:00 2001 From: JasonYv Date: Fri, 9 Oct 2026 11:58:55 +0800 Subject: [PATCH 1/7] feat: let a cart line hold 500 at most, and judge that with the minimum order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ceiling was 10,000, a guard against scripted abuse carried over from the previous implementation. The owner set it to 500 (design §11, decision 9), which makes it a rule of the shop that a buyer will meet. So it is judged where the minimum order is, in quantityIssue: a line above it that is already in a cart cannot be ordered either, and says the figure. A quantity field carries it as max, on a product page and in the cart, from max_quantity in both views. The admin refuses a minimum order above it. --- CLAUDE.md | 4 +- SECURITY.md | 6 ++- .../2026-09-30-nundar-on-mallok-design.md | 10 ++++- scripts/smoke-shop.mjs | 4 +- src/plugins/shop/lib/cart-pricing.ts | 22 ++++++++++- src/plugins/shop/lib/cart-view.ts | 10 +++-- src/plugins/shop/lib/cart.ts | 9 ++++- src/plugins/shop/lib/storefront.ts | 3 ++ src/plugins/shop/plugin.json | 4 +- src/plugins/shop/routes/cart.ts | 12 +++--- src/theme/layouts/shop-cart.liquid | 7 ++-- src/theme/partials/offer.liquid | 11 +++--- test/shop/cart-pricing.test.ts | 37 +++++++++++++++++++ test/shop/cart-route.test.ts | 32 ++++++++++++++-- test/shop/storefront.test.ts | 15 +++++--- test/shop/variant-records.test.ts | 6 +-- test/theme/cart.test.ts | 27 +++++++++++++- 17 files changed, 177 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f93a67a..f275f57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ Mallok decides the contracts on both sides. Its documentation is the reference: - Templates are restricted Liquid. Output is escaped; only `content.html` and `page.head` are emitted verbatim. `page.head` carries hreflang and structured data from Mallok and must stay in `layouts/base.liquid`. - Interface strings are in `locales/*.json` (flat maps, the default locale is the fallback). Site-specific copy — the home page's headline and sections, the footer, the links behind the buttons — is a theme option, never a string in a template; per-language option values go under `themeOptions.$locales` in `site.json`, and `test/project.test.ts` fails when a language is left without one. The tagline is not an option: it is Mallok's own setting, a map of language to text in `site.json`. It follows the site's name in the home page's title, and describes that page unless the `home_description` option gives the language a fuller text. - **Kinds**: `product`, `collection`, `application` (the sample's industry pages, at `/industries`), `case`, `faq`, `tool`, `article`, `page`. Every kind with a `base` has a `listLayout` here, because each list is a page worth having; a kind without one has no list page — its base path answers 404 — and no entry in `site.kinds`. A template links to a kind's list only through `site.kinds.`, inside `{% if %}`: the breadcrumb (`partials/crumbs.liquid`) and the home page's link to the catalogue do, and `test/theme/unlisted.test.ts` holds both on a site that serves products without a list. -- **The cart page is the layout `layouts/shop-cart.liquid`**, declared under `pluginLayouts` in `theme.json` as `shop/cart`. It reads `plugin_page`, not `content`; `plugins` is empty on it. Every control is a plain form posting to `plugin_page.action`, a quantity field carries the minimum order in `min` and `step`, and the page has no script. The plugin has no words: a refusal or a line's problem arrives as a kind (`below_moq`, `insufficient_stock`, `unavailable`, `no_price`, `quantity_too_large`, `cart_full`), the pack has a `problem_` for each, and the number it is about is printed after the words. The layout titles itself through `{% block title %}` in `layouts/base.liquid`, since Mallok titles a plugin's page with the site's name. +- **The cart page is the layout `layouts/shop-cart.liquid`**, declared under `pluginLayouts` in `theme.json` as `shop/cart`. It reads `plugin_page`, not `content`; `plugins` is empty on it. Every control is a plain form posting to `plugin_page.action`, a quantity field carries the minimum order in `min` and `step` and the most a line may hold in `max` (`plugin_page.max_quantity`; on a product page `plugins.shop.max_quantity`), and the page has no script. The plugin has no words: a refusal or a line's problem arrives as a kind (`below_moq`, `insufficient_stock`, `unavailable`, `no_price`, `quantity_too_large`, `cart_full`), the pack has a `problem_` for each, and the number it is about is printed after the words. The layout titles itself through `{% block title %}` in `layouts/base.liquid`, since Mallok titles a plugin's page with the site's name. - **`plugins.shop` is optional everywhere.** It is absent when the plugin is off, when its read failed, in `mallok build` and in the admin's preview, and a product may have no variants. A template wraps what it prints from it in `{% if %}`, and the page is whole without it: the sizes with their SKUs, and no prices. A variant is found by walking `plugins.shop.variants`, never by `variants[sku]`: Liquid answers `size`, `first` and `last` on any collection itself. The words around a value — "Unit price", "In stock", "business days" — are the theme's, in its packs; `avail_` names the three states. - **A product is one page with its sizes on it**, not a page per size. `facets` are the attributes a buyer filters by; `sizes` maps each SKU to what distinguishes it; `specs` is the full table. `partials/spec-table.liquid` (the specification finder, the catalogue, a collection's products, a product's neighbours) takes its columns from the first product that has `facets` and fills every row by attribute name, so every product in a language must use the same names. Below 72rem the same table is laid out as cards, two to a row on a tablet: seven columns need about 1100px in German. - **References resolve by slug within the same language.** An `application` and a `case` name their `product`; the product page lists them through `content.backrefs.application` and `content.backrefs.case`. A `product` names its `collection`; the collection page lists `content.backrefs.product`. Mallok resolves `reference` only, not `reference[]`. @@ -118,7 +118,7 @@ When one is built, take it off this list and prove the behaviour with a test or - A page is in one currency. A currency is offered only when every priced variant on the page has a price in it (`currenciesFor`). Where there is none the page prints no price, and its structured data offers none. - The structured data offers what the page prints and nothing else: the same variants, the same amounts, the same states (`offersFor`; the test compares the two digit for digit). - A cart line is a variant and a quantity, never a price; `priceCart` recomputes from the database and reports every problem at once. -- MOQ and stock are enforced server-side in `quantityIssue`, shared by add-to-cart and cart pricing. The quantity field on a page starts at the minimum order and steps by it; that is a convenience, and the server is the check. +- MOQ, stock and the most a cart line may hold — 500, the owner's figure (`MAX_LINE_QUANTITY`) — are enforced server-side in `quantityIssue`, shared by add-to-cart and cart pricing. The quantity field on a page starts at the minimum order, steps by it and stops at the ceiling (`min`, `step`, `max`); that is a convenience, and the server is the check. A minimum order above the ceiling is refused in the admin, and a size that has one is offered no form. - A refused change writes nothing and creates no cart; a sum is stated only for a cart every line of which can be ordered as it stands. - A redirect is judged by the path the browser will read, not the text that was sent: `/.//host` is one slash as typed and another site once resolved. - `stock` carries `CHECK (stock >= 0)`. `test/shop/schema.test.ts` proves a failing decrement rolls back its whole D1 batch, and that `WHERE stock >= qty` does not — the payment write relies on the constraint. diff --git a/SECURITY.md b/SECURITY.md index 6b6cbab..9c621e8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -90,8 +90,10 @@ These are deliberate and should not be "simplified" away: that names no live cart: a fresh id is issued. The id is the only thing that protects a cart, so the shop never takes up one a visitor chose, and an expired cart's lines do not come back with the next thing added. -- **Cart size is bounded**: at most 100 lines and 10,000 units per line, so one - cart cannot be grown without limit. +- **Cart size is bounded**: at most 100 lines and 500 units per line, so one + cart cannot be grown without limit. The 500 is a rule of the shop as well + as a bound: it is judged with the minimum order, so a line above it cannot + be ordered either, however it came to be in a cart. - **The cart's routes are rate limited** through Mallok's rate-limit bindings, each route with its own count per visitor, at the relaxed tier: a buyer changes a cart many times in ordinary use. - **Logs never contain personal data.** The scheduled work logs counts and a reference date. diff --git a/docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md b/docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md index b084139..61f55bc 100644 --- a/docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md +++ b/docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md @@ -371,6 +371,14 @@ The phases follow Mallok's own roadmap (`mallok: docs/PRODUCT_VISION.md §9`: 0. - Decision: Mallok is published (`0.1.0-rc.7` on npm, `latest`). - The owner implements the Mallok extension points in the Mallok project from a separate task list; Nundar work starts now against that plan. +Decided later: + +9. **The most a cart line may hold (2026-10-09).** + - Decision: 500 of one variant. It was 10,000, carried over from the previous implementation as a guard against scripted abuse (§17, left open, item 7). + - It is a rule of the shop now, so it is judged where the minimum order is — `quantityIssue`, which add-to-cart and cart pricing share — and a quantity field carries it as `max`. A line above it that is already in a cart cannot be ordered and says so. + - A minimum order above 500 is refused in the admin, and a variant that has one from a seed or by hand is offered no form. + - The sample's largest minimum order is 200. + ## 12. What phase 1A changed or found Recorded here rather than edited away, so a later reader can see what the design said, what the build found, and why they differ. @@ -618,7 +626,7 @@ Along the way the review's reading of the cart turned up a column: Mallok's `con 4. *Stock set in the admin is a figure, not a difference.* When the seller types one, a payment that lands between opening the form and saving it is overwritten by it. The ledger stays true — it records the difference from the stock as it was when the write landed — but the seller is not told. A save that names no figure touches no stock. 5. *The not-found page has no cart link*: it waits for Mallok (written up for it). 6. *The cart page's forms are not rate-limited beyond Mallok's relaxed tier* (120 a minute per visitor per route), which is best-effort by Cloudflare's own description. -7. *Ten thousand to a line.* `MAX_LINE_QUANTITY` came over from the previous implementation as a guard against scripted abuse. A catalogue of small parts sells washers by the fifty thousand; whether the ceiling is right for one is the owner's to say. Until then a minimum order above it is refused in the admin. +7. *Ten thousand to a line.* Decided on 2026-10-09: five hundred (§11, decision 9). 8. *The step is the browser's.* A quantity field moves in steps of the minimum order (2026-09-03 §4.5.1); the server enforces the minimum and accepts any quantity above it. Whether multiples are a rule or a convenience is not written down. 9. *A price of nothing.* The admin refuses one; a row with one, from a seed or by hand, is shown and can go in a cart, where it totals zero — which meets §14's open item 4 when checkout is built. 10. *Deletions the plugin missed.* If the delete hook fails, or the plugin is off when a product is deleted, its variants stay: on no page and in no order, with their SKUs taken. Nothing tells a plugin afterwards. diff --git a/scripts/smoke-shop.mjs b/scripts/smoke-shop.mjs index 2b55e94..5b9cd31 100644 --- a/scripts/smoke-shop.mjs +++ b/scripts/smoke-shop.mjs @@ -490,10 +490,10 @@ try { `the product page offers no usable form for its 20 mm size: ${JSON.stringify(form)}`, ); expect( - / MAX_LINE_QUANTITY) { + return { + kind: 'quantity_too_large', + variantId: variant.id, + max: MAX_LINE_QUANTITY, + requested: quantity, + }; + } // A made-to-order variant is produced on demand, so stock does not limit it. if (variant.stock_policy === 'track' && quantity > variant.stock) { return { diff --git a/src/plugins/shop/lib/cart-view.ts b/src/plugins/shop/lib/cart-view.ts index 98583a3..05c789f 100644 --- a/src/plugins/shop/lib/cart-view.ts +++ b/src/plugins/shop/lib/cart-view.ts @@ -10,19 +10,21 @@ * empty nothing in it * orderable every line can be ordered as it stands * subtotal the sum of the lines; '' unless the cart is orderable + * max_quantity the most one line may hold * lines[] variant_id, sku, name, path, quantity, moq, unit_price, * line_total, problem, available * * A line's `problem` is '' or the reason it cannot be ordered: `unavailable`, - * `below_moq`, `insufficient_stock`, `no_price`. A theme prints its own - * words for each. `available` is how many can be had when the reason is - * stock, and null otherwise. + * `below_moq`, `quantity_too_large`, `insufficient_stock`, `no_price`. A + * theme prints its own words for each. `available` is how many can be had + * when the reason is stock, and null otherwise. * * The route adds where its forms post (`action`), the cart's own address * (`cart_path`) and, after a change that was refused, what was refused * (`problem`). */ +import { MAX_LINE_QUANTITY } from './cart.js'; import type { CartFacts, CartIssue } from './cart-pricing.js'; import type { Currency } from './currency.js'; import { formatMoney, sumMinor } from './money.js'; @@ -49,6 +51,7 @@ export interface CartView { readonly empty: boolean; readonly orderable: boolean; readonly subtotal: string; + readonly max_quantity: number; readonly lines: readonly CartLineView[]; } @@ -96,6 +99,7 @@ export function cartView(facts: CartFacts, locale: string): CartView { locale, ) : '', + max_quantity: MAX_LINE_QUANTITY, lines, }; } diff --git a/src/plugins/shop/lib/cart.ts b/src/plugins/shop/lib/cart.ts index a134a02..0f0ad58 100644 --- a/src/plugins/shop/lib/cart.ts +++ b/src/plugins/shop/lib/cart.ts @@ -17,8 +17,13 @@ export interface CartLine { /** A cart that nobody touches for this long is deleted. */ export const CART_TTL_SECONDS = 60 * 60 * 24 * 30; -/** Real orders never reach this; anything above it is scripted abuse. */ -export const MAX_LINE_QUANTITY = 10_000; +/** + * The most of one variant a cart line may hold: the owner's figure (design + * §11, 2026-10-09). It is a rule of the shop and not only a guard against a + * script, so it is judged where the minimum order is, in `quantityIssue`, + * and a page says it in the quantity field's `max`. + */ +export const MAX_LINE_QUANTITY = 500; /** A ceiling on lines, so one cart cannot be grown without bound. */ export const MAX_CART_LINES = 100; diff --git a/src/plugins/shop/lib/storefront.ts b/src/plugins/shop/lib/storefront.ts index 64cb209..7c03f6d 100644 --- a/src/plugins/shop/lib/storefront.ts +++ b/src/plugins/shop/lib/storefront.ts @@ -13,6 +13,7 @@ * currencies the currencies every priced variant has a price in * price_from the lowest unit price that can be ordered; '' when none * prices_from the same, once per currency + * max_quantity the most of one variant a cart line may hold * variants[] id, sku, label, moq, availability, lead_time, price, prices, * orderable * @@ -89,6 +90,7 @@ export interface ProductView { readonly currencies: readonly Currency[]; readonly price_from: string; readonly prices_from: readonly DisplayPrice[]; + readonly max_quantity: number; readonly variants: readonly VariantView[]; } @@ -280,6 +282,7 @@ export function productView( price_from: from.find((price) => price.currency === currency)?.display ?? '', prices_from: from, + max_quantity: MAX_LINE_QUANTITY, variants: variants.map((variant) => { const own = display(amounts.get(variant.id), currencies, locale); const price = diff --git a/src/plugins/shop/plugin.json b/src/plugins/shop/plugin.json index b13500f..0857b2f 100644 --- a/src/plugins/shop/plugin.json +++ b/src/plugins/shop/plugin.json @@ -136,8 +136,8 @@ "required": true, "default": 1, "min": 1, - "help": "The least that can be ordered. On a page the quantity starts here and goes up in steps of it. At most 10,000, which is the most one cart line may hold.", - "max": 10000 + "help": "The least that can be ordered. On a page the quantity starts here and goes up in steps of it. At most 500, which is the most one cart line may hold.", + "max": 500 }, "stock_policy": { "type": "select", diff --git a/src/plugins/shop/routes/cart.ts b/src/plugins/shop/routes/cart.ts index 4fda24b..166ef73 100644 --- a/src/plugins/shop/routes/cart.ts +++ b/src/plugins/shop/routes/cart.ts @@ -17,8 +17,9 @@ * be switched: a page rendered by the POST itself would be listed, in every * language, at an address that only takes a POST. * - * A quantity field's `min` and `step` enforce the minimum order in the - * browser. This enforces it again, because a form can be bypassed. + * A quantity field's `min`, `step` and `max` enforce the minimum order and + * the most a line may hold in the browser. This enforces both again, because + * a form can be bypassed. * * The cart page is one visitor's own: Mallok serves it uncached and * unindexed, whatever is returned here. @@ -364,13 +365,12 @@ export async function cartUpdate( if (variant === undefined || variant.product_published !== 1) { return refuse('unavailable'); } - if (target > MAX_LINE_QUANTITY) { - return refuse('quantity_too_large'); - } const issue = quantityIssue(variant, target); if (issue !== null) { return refuse( - issue.kind === 'below_moq' || issue.kind === 'insufficient_stock' + issue.kind === 'below_moq' || + issue.kind === 'quantity_too_large' || + issue.kind === 'insufficient_stock' ? issue.kind : 'unavailable', ); diff --git a/src/theme/layouts/shop-cart.liquid b/src/theme/layouts/shop-cart.liquid index 303665b..46e6ad3 100644 --- a/src/theme/layouts/shop-cart.liquid +++ b/src/theme/layouts/shop-cart.liquid @@ -7,7 +7,8 @@ The page is one visitor's own: Mallok serves it uncached and unindexed. Every control is a plain form posting to `plugin_page.action`, so the cart works without a script, and the quantity fields carry the minimum order in - `min` and `step` — which the server checks again. + `min` and `step` and the most a line may hold in `max` — which the server + checks again. {%- endcomment -%} {% block title %}{{ t.cart_title }} — {{ site.name }}{% endblock %} {% block content %} @@ -47,7 +48,7 @@ {%- endcomment -%} {% if line.problem != blank %} {% assign reason = "problem_" | append: line.problem %} -

{{ t[reason] }}{% if line.problem == "below_moq" %} {{ line.moq }}{% elsif line.problem == "insufficient_stock" %} {{ line.available }}{% endif %}

+

{{ t[reason] }}{% if line.problem == "below_moq" %} {{ line.moq }}{% elsif line.problem == "insufficient_stock" %} {{ line.available }}{% elsif line.problem == "quantity_too_large" %} {{ cart.max_quantity }}{% endif %}

{% endif %} {% if line.unit_price != blank %} @@ -61,7 +62,7 @@
- +
{% endif %} diff --git a/src/theme/partials/offer.liquid b/src/theme/partials/offer.liquid index 7598558..48ba3cb 100644 --- a/src/theme/partials/offer.liquid +++ b/src/theme/partials/offer.liquid @@ -25,16 +25,17 @@ {%- comment -%} The form that puts this size in the cart: a plain POST, no script. The - quantity starts at the minimum order and moves in steps of it. A size - without a price, or that cannot be had, is offered no form — the server - would refuse it. The currency is the one the page shows; the currency - switch changes this field with the prices. + quantity starts at the minimum order, moves in steps of it and stops at + the most a cart line may hold — all three checked again by the server. + A size without a price, or that cannot be had, is offered no form. The + currency is the one the page shows; the currency switch changes this + field with the prices. {%- endcomment -%} {% if variant.orderable and plugins.shop.cart_action != blank %}
- +
{% endif %} diff --git a/test/shop/cart-pricing.test.ts b/test/shop/cart-pricing.test.ts index 2d75081..49e047e 100644 --- a/test/shop/cart-pricing.test.ts +++ b/test/shop/cart-pricing.test.ts @@ -143,6 +143,43 @@ describe('priceCart', () => { }); }); + it('rejects more of one variant than a cart line may hold', async () => { + // Made to order, so nothing but the ceiling stands in the way. + await createVariant({ + id: 'bulk', + productGroup: valve.translationGroup, + stockPolicy: 'made_to_order', + }); + await setPrice({ + variantId: 'bulk', + currency: 'USD', + amountMinor: 100, + source: 'base', + }); + + expect(await price([{ variantId: 'bulk', quantity: 501 }])).toEqual({ + ok: false, + issues: [ + { + kind: 'quantity_too_large', + variantId: 'bulk', + max: 500, + requested: 501, + }, + ], + }); + expect((await price([{ variantId: 'bulk', quantity: 500 }])).ok).toBe(true); + }); + + it('says the ceiling rather than the stock when a quantity is above both', async () => { + // "At most 500" is still true after the next delivery. + const result = await price([{ variantId: 'dn50', quantity: 600 }]); + + expect(!result.ok && result.issues.map((issue) => issue.kind)).toEqual([ + 'quantity_too_large', + ]); + }); + it('rejects a quantity above the stock of a tracked variant', async () => { const result = await price([{ variantId: 'dn50', quantity: 101 }]); diff --git a/test/shop/cart-route.test.ts b/test/shop/cart-route.test.ts index 9b4f1c5..b99806c 100644 --- a/test/shop/cart-route.test.ts +++ b/test/shop/cart-route.test.ts @@ -278,20 +278,46 @@ describe('POST /_mallok/p/shop/cart/update', () => { expect(await cartCount()).toBe(0); }); - it('refuses an absurd quantity', async () => { + it('refuses more of one part than a cart line may hold', async () => { await createVariant({ id: 'bulk', productGroup: valve.translationGroup, stockPolicy: 'made_to_order', }); - const response = await post({ variant: 'bulk', quantity: '10001' }); + const over = await post({ variant: 'bulk', quantity: '501' }); - expect(refusal(response)).toMatchObject({ + expect(refusal(over)).toMatchObject({ status: 303, kind: 'quantity_too_large', + variant: 'bulk', }); expect(await lines()).toEqual([]); + expect(await cartCount()).toBe(0); + + // Five hundred is the most, and can be had. + const atMost = await post({ variant: 'bulk', quantity: '500' }); + expect(refusal(atMost).kind).toBeNull(); + expect(await lines()).toMatchObject([ + { variant_id: 'bulk', quantity: 500 }, + ]); + }); + + it('counts what is already in the cart towards the most a line may hold', async () => { + await createVariant({ + id: 'bulk', + productGroup: valve.translationGroup, + stockPolicy: 'made_to_order', + }); + const first = await post({ variant: 'bulk', quantity: '300' }); + const cookie = cartCookie(first); + + const more = await post({ variant: 'bulk', quantity: '201' }, cookie); + + expect(refusal(more)).toMatchObject({ kind: 'quantity_too_large' }); + expect(await lines()).toMatchObject([ + { variant_id: 'bulk', quantity: 300 }, + ]); }); it('refuses one more kind of part than a cart may hold', async () => { diff --git a/test/shop/storefront.test.ts b/test/shop/storefront.test.ts index 4744fda..06c20ab 100644 --- a/test/shop/storefront.test.ts +++ b/test/shop/storefront.test.ts @@ -191,10 +191,10 @@ describe('a product page’s view', () => { variant('unpriced'), variant('gone', { stock: 0 }), variant('custom', { stock: 0, stock_policy: 'made_to_order' }), - // A cart line holds ten thousand at most: this minimum order could + // A cart line holds five hundred at most: this minimum order could // never be met, and a form for it would be refused every time. - variant('vast', { moq: 20000, stock_policy: 'made_to_order' }), - variant('limit', { moq: 10000, stock_policy: 'made_to_order' }), + variant('vast', { moq: 501, stock_policy: 'made_to_order' }), + variant('limit', { moq: 500, stock_policy: 'made_to_order' }), ], [ price('ok', 'USD', 100), @@ -223,7 +223,7 @@ describe('a product page’s view', () => { it('says what state each variant is in, and never how many are left', () => { const view = productView( [ - variant('a', { stock: 500, moq: 100 }), + variant('a', { stock: 7321, moq: 100 }), variant('b', { stock: 50, moq: 100 }), variant('c', { stock: 0, stock_policy: 'made_to_order' }), ], @@ -237,7 +237,12 @@ describe('a product page’s view', () => { 'made_to_order', ]); expect(JSON.stringify(view)).not.toMatch(/"stock"/); - expect(JSON.stringify(view)).not.toContain('500'); + expect(JSON.stringify(view)).not.toContain('7321'); + }); + + it('tells a page the most of one variant a cart line may hold', () => { + // The owner's figure. A quantity field carries it as `max`. + expect(productView([variant('a')], [], 'en')?.max_quantity).toBe(500); }); it('states a lead time only when both ends are known and in order', () => { diff --git a/test/shop/variant-records.test.ts b/test/shop/variant-records.test.ts index 035f64b..2db0eeb 100644 --- a/test/shop/variant-records.test.ts +++ b/test/shop/variant-records.test.ts @@ -366,17 +366,17 @@ describe('variants in the admin', () => { const { ctx } = context(); const atLimit = await saveVariant( - record(screw, null, { sku: 'CS-A', moq: 10000 }), + record(screw, null, { sku: 'CS-A', moq: 500 }), ctx, ); const over = await saveVariant( - record(screw, null, { sku: 'CS-B', moq: 10001 }), + record(screw, null, { sku: 'CS-B', moq: 501 }), ctx, ); expect(atLimit).toHaveProperty('id'); expect(over).toEqual({ - errors: { moq: 'The minimum order can be at most 10000.' }, + errors: { moq: 'The minimum order can be at most 500.' }, }); }); diff --git a/test/theme/cart.test.ts b/test/theme/cart.test.ts index a1e4406..76caf94 100644 --- a/test/theme/cart.test.ts +++ b/test/theme/cart.test.ts @@ -304,6 +304,8 @@ describe('the cart', () => { name: 'quantity', min: '100', step: '100', + // The most a cart line may hold. + max: '500', value: '100', inputmode: 'numeric', }, @@ -531,6 +533,7 @@ describe('the cart', () => { name: 'quantity', min: '50', step: '50', + max: '500', value: '150', inputmode: 'numeric', }, @@ -712,6 +715,28 @@ describe('the cart', () => { expect(subtotal(html)).toBeNull(); }); + it('says the most a line may hold when the cart has more than that in it', async () => { + // No form puts this in a cart: it is a line from before the ceiling + // was what it is, or a row written by hand. + const cookie = await add(screw.path, 'CS-10', '400'); + await db() + .prepare( + "UPDATE p_shop_cart_line SET quantity = 600 WHERE variant_id = 'cs-10'", + ) + .run(); + + const { html } = await get(`${SHOP}/cart`, cookie); + const [line] = cartLines(html); + + expect(line?.problem).toBe('quantity_too_large'); + expect(line?.note).toBe('The most that can be ordered at once is 500'); + expect(line?.forms[0]?.quantity).toMatchObject({ + max: '500', + value: '600', + }); + expect(subtotal(html)).toBeNull(); + }); + it('says how many are left when the stock has fallen below what is in the cart', async () => { const cookie = await add(screw.path, 'CS-10', '400'); await db() @@ -970,7 +995,7 @@ describe('the cart', () => { [ 'quantity_too_large', 'cs-10', - 'CS-10 The most that can be ordered at once is 10000', + 'CS-10 The most that can be ordered at once is 500', ], [ 'cart_full', From aeb7bcbb76e172eaa4fcc350da6cf0f54d5d7c88 Mon Sep 17 00:00:00 2001 From: JasonYv Date: Fri, 9 Oct 2026 12:26:47 +0800 Subject: [PATCH 2/7] refactor: make an order's number and an inquiry's in one place An inquiry is quoted by a number of the same shape as an order's: a prefix, the date and eight characters of Crockford's alphabet. The generator moves out of the order code so that both use it, with a test of the shape it makes and of how one is recognised under its own prefix. --- src/plugins/shop/lib/orders.ts | 20 +++--------- src/plugins/shop/lib/reference.ts | 48 ++++++++++++++++++++++++++++ test/shop/reference.test.ts | 52 +++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 src/plugins/shop/lib/reference.ts create mode 100644 test/shop/reference.test.ts diff --git a/src/plugins/shop/lib/orders.ts b/src/plugins/shop/lib/orders.ts index ce4d15c..353451f 100644 --- a/src/plugins/shop/lib/orders.ts +++ b/src/plugins/shop/lib/orders.ts @@ -33,6 +33,7 @@ import { type OrderStatus, } from './order-state.js'; import { orderChangedOutbox, paymentRefusedOutbox } from './outbox.js'; +import { newReference } from './reference.js'; /** Thrown by anything asked to act on an order that does not exist. */ export class OrderNotFoundError extends Error { @@ -62,28 +63,15 @@ type SuccessfulCart = Extract; /** The Stripe event that confirms a payment. */ export const PAYMENT_EVENT_TYPE = 'payment_intent.succeeded'; -/** - * Crockford's base 32: no I, L, O or U, so a number read over the phone or - * copied from paper is not mistaken for another. - */ -const ORDER_NO_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; +/** What an order's number begins with. */ +const ORDER_NO_PREFIX = 'ND'; /** * The number a buyer sees: the date and a random suffix, so it says nothing * about how many orders the shop takes. - * - * Eight characters are forty bits. Six hexadecimal ones, as before, are - * twenty-four: at a thousand orders a day two of them would collide about - * once a month, and a collision is a checkout that fails. */ function newOrderNo(now: Date): string { - const date = now.toISOString().slice(2, 10).replace(/-/g, ''); - let suffix = ''; - for (const byte of crypto.getRandomValues(new Uint8Array(8))) { - // 256 is a multiple of 32, so the low five bits are uniform. - suffix += ORDER_NO_ALPHABET.charAt(byte & 31); - } - return `ND-${date}-${suffix}`; + return newReference(ORDER_NO_PREFIX, now); } /** diff --git a/src/plugins/shop/lib/reference.ts b/src/plugins/shop/lib/reference.ts new file mode 100644 index 0000000..b87fcb0 --- /dev/null +++ b/src/plugins/shop/lib/reference.ts @@ -0,0 +1,48 @@ +/** + * The numbers people quote back to a shop: an order's, an inquiry's. + * + * A prefix, the date and a random suffix — `ND-261005-7K3M9QXA` — so a + * number says nothing about how many the shop has had. + */ + +/** + * Crockford's base 32: no I, L, O or U, so a number read over the phone or + * copied from paper is not mistaken for another. + */ +const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +/** Eight characters are forty bits. */ +const SUFFIX_LENGTH = 8; + +/** + * A new number under a prefix. + * + * Eight characters, because six hexadecimal ones, as the first implementation + * had, are twenty-four bits: at a thousand a day two of them would collide + * about once a month, and a collision is a checkout that fails. + */ +export function newReference(prefix: string, now: Date): string { + const date = now.toISOString().slice(2, 10).replace(/-/g, ''); + let suffix = ''; + for (const byte of crypto.getRandomValues(new Uint8Array(SUFFIX_LENGTH))) { + // 256 is a multiple of 32, so the low five bits are uniform. + suffix += ALPHABET.charAt(byte & 31); + } + return `${prefix}-${date}-${suffix}`; +} + +/** Whether a text has the shape of a number made under this prefix. */ +export function isReference(prefix: string, value: string): boolean { + if (!value.startsWith(`${prefix}-`)) { + return false; + } + const [date = '', suffix = '', ...rest] = value + .slice(prefix.length + 1) + .split('-'); + return ( + rest.length === 0 && + /^\d{6}$/.test(date) && + suffix.length === SUFFIX_LENGTH && + [...suffix].every((character) => ALPHABET.includes(character)) + ); +} diff --git a/test/shop/reference.test.ts b/test/shop/reference.test.ts new file mode 100644 index 0000000..322189d --- /dev/null +++ b/test/shop/reference.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { + isReference, + newReference, +} from '../../src/plugins/shop/lib/reference.js'; + +describe('the numbers people quote', () => { + const now = new Date('2026-10-09T23:59:59.000Z'); + + it('are a prefix, the date and eight characters nobody mistakes for another', () => { + for (let made = 0; made < 200; made += 1) { + // No I, L, O or U: Crockford's alphabet. + expect(newReference('RFQ', now)).toMatch( + /^RFQ-261009-[0-9A-HJKMNP-TV-Z]{8}$/, + ); + } + }); + + it('do not repeat', () => { + const made = new Set( + Array.from({ length: 500 }, () => newReference('ND', now)), + ); + + expect(made.size).toBe(500); + }); + + it('are recognised by their shape, under their own prefix only', () => { + const number = newReference('RFQ', now); + + expect(isReference('RFQ', number)).toBe(true); + expect(isReference('ND', number)).toBe(false); + // A prefix of the same length, which leaves the rest in the same place. + expect(isReference('ABC', number)).toBe(false); + expect(isReference('RFQ', `ABC${number.slice(3)}`)).toBe(false); + expect(isReference('RFQ', number.toLowerCase())).toBe(false); + for (const not of [ + '', + 'RFQ', + 'RFQ-261009', + 'RFQ-261009-7K3M9QX', + 'RFQ-261009-7K3M9QXAB', + 'RFQ-26100-7K3M9QXA', + 'RFQ-261009-7K3M9QXI', + 'RFQ-261009-7K3M9QXA-1', + 'RFQ-261009-7K3M9QX%', + "RFQ-261009-' OR 1=1", + 'XRFQ-261009-7K3M9QXA', + ]) { + expect(isReference('RFQ', not), not).toBe(false); + } + }); +}); From 804b932d423ecab4cea7453da329192f829e5e48 Mon Sep 17 00:00:00 2001 From: JasonYv Date: Fri, 9 Oct 2026 12:26:47 +0800 Subject: [PATCH 3/7] feat: send a cart as one request for a quote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1's last piece, which the owner decided is the shop plugin's to build (design §11, decision 10). A form on the cart page takes who to answer and a message, and posts to cart/inquiry. The route stores the cart as an inquiry with its lines as a snapshot, queues the job that owes its emails, and empties the cart, in one batch; it answers with a redirect to the cart page, which shows the inquiry's number to the browser whose cart it was. A part with no price can be asked about, so a product page now offers a form for a size without one. The write is conditional on the cart still having lines and on the visitor being under five inquiries an hour, both asked inside the statement, so two forms at once store one inquiry and a refused one leaves the cart alone. The seller is told by email, answerable to the buyer, and reads inquiries in a panel of the admin: to mark, export as CSV, or delete. The email confirming to the buyer is built and off by default, because the form has no challenge in front of it: Mallok has nowhere to draw one on a plugin's page. --- scripts/lib/local-shop.mjs | 5 +- scripts/smoke-shop.mjs | 119 ++- src/plugins/shop/index.ts | 28 +- src/plugins/shop/lib/cart-view.ts | 18 +- src/plugins/shop/lib/inquiries.ts | 659 +++++++++++++ src/plugins/shop/lib/inquiry-admin.ts | 80 ++ src/plugins/shop/lib/inquiry-email.ts | 237 +++++ src/plugins/shop/lib/inquiry-jobs.ts | 113 +++ src/plugins/shop/lib/paths.ts | 3 + src/plugins/shop/lib/storefront.ts | 23 +- .../shop/migrations/0003_inquiries.sql | 89 ++ src/plugins/shop/plugin.json | 142 ++- src/plugins/shop/routes/cart.ts | 82 +- src/plugins/shop/routes/inquiry.ts | 193 ++++ src/theme/assets/style.css | 106 ++- src/theme/layouts/shop-cart.liquid | 63 +- src/theme/locales/de.json | 20 +- src/theme/locales/en.json | 20 +- src/theme/locales/es.json | 20 +- src/theme/locales/fr.json | 20 +- src/theme/partials/offer.liquid | 9 +- src/theme/theme.json | 2 +- test/shop/helpers.ts | 2 + test/shop/inquiries.test.ts | 868 ++++++++++++++++++ test/shop/inquiry-admin.test.ts | 254 +++++ test/shop/inquiry-emails.test.ts | 519 +++++++++++ test/shop/schema.test.ts | 3 + test/shop/storefront.test.ts | 7 +- test/theme/cart.test.ts | 425 ++++++++- test/theme/packs.test.ts | 79 ++ 30 files changed, 4150 insertions(+), 58 deletions(-) create mode 100644 src/plugins/shop/lib/inquiries.ts create mode 100644 src/plugins/shop/lib/inquiry-admin.ts create mode 100644 src/plugins/shop/lib/inquiry-email.ts create mode 100644 src/plugins/shop/lib/inquiry-jobs.ts create mode 100644 src/plugins/shop/migrations/0003_inquiries.sql create mode 100644 src/plugins/shop/routes/inquiry.ts create mode 100644 test/shop/inquiries.test.ts create mode 100644 test/shop/inquiry-admin.test.ts create mode 100644 test/shop/inquiry-emails.test.ts create mode 100644 test/theme/packs.test.ts diff --git a/scripts/lib/local-shop.mjs b/scripts/lib/local-shop.mjs index 848896e..3a2c810 100644 --- a/scripts/lib/local-shop.mjs +++ b/scripts/lib/local-shop.mjs @@ -203,8 +203,9 @@ export async function fillShop({ base, state, tokenName, step = () => {} }) { body: JSON.stringify({ name: tokenName, // `media:write` because the sample bundles carry images, and publishing - // a bundle uploads them. - scopes: ['content:write', 'settings:write', 'media:write'], + // a bundle uploads them. `export` reads the rows of a plugin's panel, + // as the admin does: the smoke run reads an inquiry back through it. + scopes: ['content:write', 'settings:write', 'media:write', 'export'], }), signal: timeout(), }); diff --git a/scripts/smoke-shop.mjs b/scripts/smoke-shop.mjs index 5b9cd31..6e18bb7 100644 --- a/scripts/smoke-shop.mjs +++ b/scripts/smoke-shop.mjs @@ -676,8 +676,125 @@ try { `the cart should hold one line of 100; it holds ${JSON.stringify(lines)}`, ); + // 7. The cart, sent as a request for a quote: through the form the cart + // page itself offers, in German, and read back the way the admin reads + // it. The cart was last shown in euros. + const quoteForm = + /
Diesen Warenkorb als Angebotsanfrage senden', + ), + 'the German cart page offers no form to send the cart as a request for a quote', + ); + const buyer = { + name: 'Smoke Buyer', + email: 'buyer@smoke.example', + company: 'Smoke GmbH', + phone: '', + message: 'Bitte mit Lieferzeit.', + website: '', + }; + const sendInquiry = () => + submit({ action: quoteForm[1], fields: buyer }, {}, cartCookie); + const sent = await sendInquiry(); + const sentTo = sent.headers.get('location') ?? ''; + const inquiries = await query( + `SELECT i.id, i.inquiry_no, i.name, i.locale, i.currency, i.subtotal, + l.sku, l.name AS product, l.quantity, l.unit_price + FROM p_shop_inquiry AS i + JOIN p_shop_inquiry_line AS l ON l.inquiry_id = i.id`, + ); + expect( + inquiries.length === 1 && + inquiries[0].name === 'Smoke Buyer' && + inquiries[0].locale === 'de' && + inquiries[0].currency === 'EUR' && + inquiries[0].sku === 'TI-SHC-M5-20' && + inquiries[0].product === + 'Titan-Zylinderschraube mit Innensechskant M5 × 0,8' && + inquiries[0].quantity === 100 && + /^2,10\s€$/.test(inquiries[0].unit_price) && + /^210,00\s€$/.test(inquiries[0].subtotal), + `the cart was stored as ${JSON.stringify(inquiries)}`, + ); + const inquiry = inquiries[0]; + expect( + sent.status === 303 && + sentTo === `/_mallok/p/shop/de/cart?sent=${inquiry.inquiry_no}`, + `sending the cart returned ${sent.status} to ${sentTo}`, + ); + const confirmation = await ( + await fetch(`${base}${sentTo}`, { + headers: { cookie: cartCookie }, + signal: timeout(), + }) + ).text(); + expect( + confirmation.includes('
') && + confirmation.includes(inquiry.inquiry_no) && + confirmation.includes('Ihre Anfrage wurde gesendet.') && + confirmation.includes('

Ihr Warenkorb ist leer.

') && + !confirmation.includes('Smoke Buyer'), + 'the cart page does not confirm the inquiry it was sent as, on an empty cart', + ); + // Somebody else, following the same link, is told nothing. + const strangers = await page(sentTo); + expect( + strangers.status === 200 && !strangers.html.includes('cart-sent'), + 'the cart page confirms an inquiry to a browser that did not send it', + ); + // The same form again — a button pressed twice — is the same inquiry. + const again = await sendInquiry(); + const stillOne = await query('SELECT COUNT(*) AS n FROM p_shop_inquiry'); + expect( + again.headers.get('location') === sentTo && stillOne[0].n === 1, + `sending the same cart again returned ${again.headers.get('location')} and left ${stillOne[0].n} inquiries`, + ); + const emptied = await query('SELECT COUNT(*) AS n FROM p_shop_cart_line'); + expect(emptied[0].n === 0, 'the cart was not emptied into its inquiry'); + + // The admin's side of it: the panel's list, and the lines of the row. + const panel = `${base}/_mallok/api/plugins/shop/panels/inquiries`; + const listed = await ( + await fetch(panel, { headers: authorised, signal: timeout() }) + ).json(); + const linesOf = await ( + await fetch(`${panel}/related/lines?parent=${inquiry.id}`, { + headers: authorised, + signal: timeout(), + }) + ).json(); + expect( + listed.rows?.length === 1 && + listed.rows[0].inquiry_no === inquiry.inquiry_no && + listed.rows[0].email === 'buyer@smoke.example' && + listed.rows[0].status === 'new' && + linesOf.rows?.length === 1 && + linesOf.rows[0].sku === 'TI-SHC-M5-20' && + linesOf.rows[0].quantity === 100, + `the admin lists the inquiry as ${JSON.stringify(listed)} with ${JSON.stringify(linesOf)}`, + ); + const exported = await fetch(`${panel}/actions/inquiry_export_csv`, { + method: 'POST', + headers: authorised, + body: JSON.stringify({ ids: [] }), + signal: timeout(), + }); + const csv = await exported.text(); + expect( + exported.ok && + (exported.headers.get('content-type') ?? '').startsWith('text/csv') && + csv.includes(`${inquiry.inquiry_no},`) && + csv.includes(',TI-SHC-M5-20,') && + csv.includes(',100,2.10,210.00,'), + `exporting the inquiries returned ${exported.status}: ${csv.slice(0, 300)}`, + ); + process.stdout.write( - `smoke:shop: ok (settings, publish, seed, every kind of page, ${followed} navigation links in ${site.locales.length} languages, ${shipped.length} theme files, cart, variants in the admin) on ${base}\n`, + `smoke:shop: ok (settings, publish, seed, every kind of page, ${followed} navigation links in ${site.locales.length} languages, ${shipped.length} theme files, cart, variants in the admin, a cart sent as an inquiry) on ${base}\n`, ); } catch (error) { failure = error; diff --git a/src/plugins/shop/index.ts b/src/plugins/shop/index.ts index b06124c..9ca99ca 100644 --- a/src/plugins/shop/index.ts +++ b/src/plugins/shop/index.ts @@ -3,7 +3,8 @@ * * Mallok provides the site: content, languages, SEO, the admin, the edge * cache. This plugin adds what makes it a shop: variants, prices in several - * currencies, stock, minimum order quantities and a cart. + * currencies, stock, minimum order quantities, a cart, and the cart sent as + * an inquiry. * * Everything here is commerce logic. Nothing about pages or rendering belongs * in it — that is the theme's side of the boundary. @@ -14,6 +15,12 @@ import { definePlugin, type PluginContext, } from 'mallok/worker'; +import { + deleteInquiries, + exportInquiries, + markInquiries, +} from './lib/inquiry-admin.js'; +import { INQUIRY_EMAILS_JOB, sendInquiryEmails } from './lib/inquiry-jobs.js'; import { renderData } from './lib/render-data.js'; import { runScheduledTick } from './lib/scheduled.js'; import { @@ -24,8 +31,10 @@ import { } from './lib/variant-records.js'; import shopSql from './migrations/0001_shop.sql'; import ordersSql from './migrations/0002_orders.sql'; +import inquiriesSql from './migrations/0003_inquiries.sql'; import manifest from './plugin.json'; import { cartPage, cartUpdate } from './routes/cart.js'; +import { cartInquiry } from './routes/inquiry.js'; async function scheduled(ctx: PluginContext): Promise { const outcome = await runScheduledTick(ctx); @@ -41,6 +50,7 @@ export const shop = definePlugin({ migrations: [ { id: 'plugin:shop:0001_shop', sql: shopSql }, { id: 'plugin:shop:0002_orders', sql: ordersSql }, + { id: 'plugin:shop:0003_inquiries', sql: inquiriesSql }, ], hooks: { scheduled, @@ -55,5 +65,19 @@ export const shop = definePlugin({ remove: (id, ctx) => removeVariant(id, ctx), }, }, - routes: { cart: cartPage, 'cart/update': cartUpdate }, + routes: { + cart: cartPage, + 'cart/update': cartUpdate, + 'cart/inquiry': cartInquiry, + }, + jobs: { + [INQUIRY_EMAILS_JOB]: (payload, ctx) => sendInquiryEmails(payload, ctx), + }, + actions: { + inquiry_mark_answered: (ids, ctx) => markInquiries(ids, ctx, 'answered'), + inquiry_mark_new: (ids, ctx) => markInquiries(ids, ctx, 'new'), + inquiry_mark_spam: (ids, ctx) => markInquiries(ids, ctx, 'spam'), + inquiry_export_csv: (ids, ctx) => exportInquiries(ids, ctx), + inquiry_delete: (ids, ctx, params) => deleteInquiries(ids, ctx, params), + }, }); diff --git a/src/plugins/shop/lib/cart-view.ts b/src/plugins/shop/lib/cart-view.ts index 05c789f..7bb6df5 100644 --- a/src/plugins/shop/lib/cart-view.ts +++ b/src/plugins/shop/lib/cart-view.ts @@ -21,7 +21,23 @@ * * The route adds where its forms post (`action`), the cart's own address * (`cart_path`) and, after a change that was refused, what was refused - * (`problem`). + * (`problem`). And for sending the cart as an inquiry: + * + * inquiry_action where the form that sends it posts + * can_inquire the cart can be sent as it stands: nothing in the way + * but, at most, a line without a price + * sent null, or `number`: the inquiry this cart has just + * become + * inquiry_problem null, or `kind` and `field`: why it was not sent — + * `invalid` (with the field: `name`, `email`, `company`, + * `phone`, `message`, or ''), `empty`, `cart_problem`, + * `too_many` + * + * The form's fields are `name`, `email`, `company`, `phone` and `message`, + * of which the first two are required, and `website`, which a person must + * not see: whatever fills it in is not one. `lib/inquiries.ts` has the + * length each may have and the pattern an address is held to; a form that + * carries the same in `maxlength` and `pattern` is never refused for them. */ import { MAX_LINE_QUANTITY } from './cart.js'; diff --git a/src/plugins/shop/lib/inquiries.ts b/src/plugins/shop/lib/inquiries.ts new file mode 100644 index 0000000..90757fd --- /dev/null +++ b/src/plugins/shop/lib/inquiries.ts @@ -0,0 +1,659 @@ +/** + * A cart sent as one inquiry: a request for a quote. + * + * The buyer fills a cart, says who they are, and sends it. Nothing is + * charged and no stock is taken: the seller answers by email. So an inquiry + * may hold what an order may not — a part with no price — and that is the + * point of it. + * + * What is sent is the cart as the database has it when the form arrives, + * judged by the same reading the cart page shows (`readCartFacts`), and + * stored as a snapshot: a later change to a product changes no inquiry. + * + * It is the one place the shop holds what a person typed about themselves + * before there is an order. + */ + +import { z } from 'zod'; +import type { CartFacts } from './cart-pricing.js'; +import { + BASE_CURRENCY, + CURRENCY_MINOR_UNITS, + type Currency, + isCurrency, +} from './currency.js'; +import { formatMoney, fromMinor, sumMinor } from './money.js'; +import { isReference, newReference } from './reference.js'; + +/** What an inquiry's number begins with: a request for a quotation. */ +export const INQUIRY_NO_PREFIX = 'RFQ'; + +/** + * How many inquiries one visitor may send in an hour. + * + * Mallok's rate limit is, in Cloudflare's own words, permissive and + * eventually consistent; this one is exact, because it is counted in the + * table inside the write. It is here for the seller's inbox: every inquiry + * is an email, and the form has no challenge in front of it. + */ +export const MAX_INQUIRIES_PER_HOUR = 5; + +const HOUR_MS = 60 * 60 * 1000; + +/** + * How long after an inquiry a second request from the same cart is taken for + * the same one: a button pressed twice, a form sent again by a reload. + */ +const SAME_SUBMISSION_MS = 2 * 60 * 1000; + +export const INQUIRY_STATUSES = ['new', 'answered', 'spam'] as const; + +export type InquiryStatus = (typeof INQUIRY_STATUSES)[number]; + +/** + * The same test Mallok's own inquiry form applies, and the one the form's + * `pattern` carries, so that a browser refuses what the server would. + */ +export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** One line of text: no line break and no other control character. */ +const ONE_LINE = /^[^\p{Cc}]*$/u; + +/** The length each field may have. The form carries the same in `maxlength`. */ +export const INQUIRY_LIMITS = { + name: 200, + email: 320, + company: 200, + phone: 60, + message: 5000, +} as const; + +/** The fields a buyer fills in, in the order a page shows them. */ +export const INQUIRY_FIELDS = [ + 'name', + 'email', + 'company', + 'phone', + 'message', +] as const; + +export type InquiryField = (typeof INQUIRY_FIELDS)[number]; + +export const inquiryFormSchema = z.object({ + name: z.string().trim().min(1).max(INQUIRY_LIMITS.name).regex(ONE_LINE), + email: z + .string() + .trim() + .min(3) + .max(INQUIRY_LIMITS.email) + .regex(EMAIL_PATTERN), + company: z + .string() + .trim() + .max(INQUIRY_LIMITS.company) + .regex(ONE_LINE) + .default(''), + phone: z + .string() + .trim() + .max(INQUIRY_LIMITS.phone) + .regex(ONE_LINE) + .default(''), + message: z.string().trim().max(INQUIRY_LIMITS.message).default(''), + /** A field no person sees. Whatever fills it in is not one. */ + website: z.string().max(500).default(''), +}); + +export type InquiryForm = z.infer; + +/** The first field a form got wrong, in the order the page shows them. */ +export function firstInvalidField(error: z.ZodError): InquiryField | null { + const named = new Set(error.issues.map((issue) => issue.path[0])); + return INQUIRY_FIELDS.find((field) => named.has(field)) ?? null; +} + +export function newInquiryNo(now: Date): string { + return newReference(INQUIRY_NO_PREFIX, now); +} + +export function isInquiryNo(value: string): boolean { + return isReference(INQUIRY_NO_PREFIX, value); +} + +/** One line of an inquiry, as it is stored. */ +export interface InquiryLine { + readonly variantId: string; + readonly sku: string; + readonly name: string; + readonly quantity: number; + /** Null when the variant has no price in the inquiry's currency. */ + readonly unitPriceMinor: number | null; +} + +/** + * The lines a cart would send, or null when it cannot be sent as it stands. + * + * A line without a price is what an inquiry is for. Any other problem — the + * part is gone, fewer than its minimum order, more than a line may hold or + * than there is — has to be put right first: the buyer is shown it on the + * line, and a seller is not sent a request the shop itself would refuse. + */ +export function inquiryLinesOf(facts: CartFacts): InquiryLine[] | null { + if (facts.lines.length === 0) { + return null; + } + const lines: InquiryLine[] = []; + for (const line of facts.lines) { + if ( + line.name === null || + (line.issue !== null && line.issue.kind !== 'no_price') + ) { + return null; + } + lines.push({ + variantId: line.variantId, + sku: line.sku, + name: line.name, + quantity: line.quantity, + unitPriceMinor: line.unitPriceMinor, + }); + } + return lines; +} + +/** What the lines come to; null when one of them has no price. */ +export function inquirySubtotalMinor( + lines: readonly InquiryLine[], +): number | null { + const totals: number[] = []; + for (const line of lines) { + if (line.unitPriceMinor === null) { + return null; + } + totals.push(line.unitPriceMinor * line.quantity); + } + return sumMinor(totals); +} + +export interface NewInquiry { + readonly id: string; + readonly inquiryNo: string; + readonly cartId: string; + readonly form: InquiryForm; + readonly locale: string; + readonly currency: Currency; + readonly country: string; + readonly ipHash: string | null; + readonly lines: readonly InquiryLine[]; + readonly now: Date; +} + +/** + * The statements that store an inquiry and empty the cart it came from, for + * one batch. The first is the inquiry itself, and the caller reads whether + * it wrote a row from that statement's result. + * + * The inquiry is written only while the cart still has something in it and + * the visitor is under their limit, and both are asked inside the write. So + * a form sent twice at the same moment stores one inquiry: the second batch + * finds the cart the first one emptied. Everything after is conditional on + * the inquiry being there — a refused inquiry must leave the cart as it was. + */ +export function inquiryStatements( + db: D1Database, + input: NewInquiry, +): D1PreparedStatement[] { + const { form, lines, currency, locale } = input; + const nowIso = input.now.toISOString(); + const subtotalMinor = inquirySubtotalMinor(lines); + const money = (minor: number | null): string => + minor === null ? '' : formatMoney(minor, currency, locale); + + const inquiry = db + .prepare( + `INSERT INTO p_shop_inquiry + (id, inquiry_no, status, name, email, company, phone, message, + locale, currency, country, line_count, subtotal_minor, subtotal, + cart_id, ip_hash, created_at, updated_at) + SELECT ?, ?, 'new', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + WHERE EXISTS (SELECT 1 FROM p_shop_cart_line WHERE cart_id = ?) + AND ( + ? IS NULL + OR (SELECT COUNT(*) FROM p_shop_inquiry + WHERE ip_hash = ? AND created_at > ?) < ? + )`, + ) + .bind( + input.id, + input.inquiryNo, + form.name, + form.email, + form.company, + form.phone, + form.message, + locale, + currency, + input.country, + lines.length, + subtotalMinor, + money(subtotalMinor), + input.cartId, + input.ipHash, + nowIso, + nowIso, + input.cartId, + input.ipHash, + input.ipHash, + new Date(input.now.getTime() - HOUR_MS).toISOString(), + MAX_INQUIRIES_PER_HOUR, + ); + + // The lines travel as one JSON parameter: one statement however many. + const stored = db + .prepare( + `INSERT INTO p_shop_inquiry_line + (id, inquiry_id, position, variant_id, sku, name, quantity, + unit_price_minor, unit_price, line_total) + SELECT ? || ':' || j.key, ?, j.key, + json_extract(j.value, '$.variantId'), + json_extract(j.value, '$.sku'), + json_extract(j.value, '$.name'), + json_extract(j.value, '$.quantity'), + json_extract(j.value, '$.unitPriceMinor'), + json_extract(j.value, '$.unitPrice'), + json_extract(j.value, '$.lineTotal') + FROM json_each(?) AS j + WHERE EXISTS (SELECT 1 FROM p_shop_inquiry WHERE id = ?)`, + ) + .bind( + input.id, + input.id, + JSON.stringify( + lines.map((line) => ({ + ...line, + unitPrice: money(line.unitPriceMinor), + lineTotal: money( + line.unitPriceMinor === null + ? null + : line.unitPriceMinor * line.quantity, + ), + })), + ), + input.id, + ); + + // The cart's row stays: its id is how the page that confirms the inquiry + // knows the browser in front of it is the one that sent it. + const emptied = db + .prepare( + `DELETE FROM p_shop_cart_line + WHERE cart_id = ? + AND EXISTS (SELECT 1 FROM p_shop_inquiry WHERE id = ?)`, + ) + .bind(input.cartId, input.id); + + return [inquiry, stored, emptied]; +} + +/** How many inquiries a visitor has sent in the last hour. */ +export function visitorCountStatement( + db: D1Database, + ipHash: string | null, + now: Date, +): D1PreparedStatement { + return db + .prepare( + `SELECT COUNT(*) AS n FROM p_shop_inquiry + WHERE ip_hash = ? AND created_at > ?`, + ) + .bind(ipHash, new Date(now.getTime() - HOUR_MS).toISOString()); +} + +/** Whether that count is the most a visitor may send. */ +export function atVisitorLimit(ipHash: string | null, sent: number): boolean { + return ipHash !== null && sent >= MAX_INQUIRIES_PER_HOUR; +} + +/** Why an inquiry was not stored, read from the data after the write. */ +export type InquiryNotStored = + /** This cart was sent a moment ago: the same request, arriving twice. */ + | { readonly kind: 'sent'; readonly inquiryNo: string } + | { readonly kind: 'too_many' } + | { readonly kind: 'empty' }; + +/** + * What stood in the way of an inquiry that wrote nothing. + * + * Read from the tables, not guessed from the write: has this cart just been + * sent, is the visitor at their limit. Neither, and the cart was emptied by + * something else between the reading and the write. + */ +export async function whyNotStored( + db: D1Database, + input: { + readonly cartId: string; + readonly ipHash: string | null; + readonly now: Date; + }, +): Promise { + const { cartId, ipHash, now } = input; + const [recentResult, countResult] = await db.batch< + { inquiry_no: string } | { n: number } + >([ + db + .prepare( + `SELECT inquiry_no FROM p_shop_inquiry + WHERE cart_id = ? AND created_at > ? + ORDER BY created_at DESC LIMIT 1`, + ) + .bind(cartId, new Date(now.getTime() - SAME_SUBMISSION_MS).toISOString()), + visitorCountStatement(db, ipHash, now), + ]); + const recent = (recentResult?.results ?? [])[0] as + | { inquiry_no: string } + | undefined; + if (recent !== undefined) { + return { kind: 'sent', inquiryNo: recent.inquiry_no }; + } + const sent = ((countResult?.results ?? [])[0] as { n: number } | undefined) + ?.n; + if (atVisitorLimit(ipHash, sent ?? 0)) { + return { kind: 'too_many' }; + } + return { kind: 'empty' }; +} + +/** + * The statement the cart page confirms an inquiry by: its number, and only + * for the cart it was sent from. A number alone, in a link somebody made + * up, finds nothing. + */ +export function sentInquiryStatement( + db: D1Database, + inquiryNo: string, + cartId: string, +): D1PreparedStatement { + return db + .prepare( + `SELECT inquiry_no FROM p_shop_inquiry + WHERE inquiry_no = ? AND cart_id = ?`, + ) + .bind(inquiryNo, cartId); +} + +/** An inquiry with its lines, as the emails and the export read it. */ +export interface InquiryDetail { + readonly id: string; + readonly inquiryNo: string; + readonly status: string; + readonly name: string; + readonly email: string; + readonly company: string; + readonly phone: string; + readonly message: string; + readonly locale: string; + readonly currency: string; + readonly country: string; + readonly subtotalMinor: number | null; + readonly notifiedAt: string | null; + readonly acknowledgedAt: string | null; + readonly createdAt: string; + readonly lines: readonly InquiryLine[]; +} + +interface InquiryRow { + id: string; + inquiry_no: string; + status: string; + name: string; + email: string; + company: string; + phone: string; + message: string; + locale: string; + currency: string; + country: string; + subtotal_minor: number | null; + notified_at: string | null; + acknowledged_at: string | null; + created_at: string; +} + +interface InquiryLineRow { + inquiry_id: string; + variant_id: string; + sku: string; + name: string; + quantity: number; + unit_price_minor: number | null; +} + +const INQUIRY_COLUMNS = `id, inquiry_no, status, name, email, company, phone, + message, locale, currency, country, subtotal_minor, notified_at, + acknowledged_at, created_at`; + +const LINE_COLUMNS = + 'inquiry_id, variant_id, sku, name, quantity, unit_price_minor'; + +function detailsOf( + inquiries: readonly InquiryRow[], + lines: readonly InquiryLineRow[], +): InquiryDetail[] { + return inquiries.map((row) => ({ + id: row.id, + inquiryNo: row.inquiry_no, + status: row.status, + name: row.name, + email: row.email, + company: row.company, + phone: row.phone, + message: row.message, + locale: row.locale, + currency: row.currency, + country: row.country, + subtotalMinor: row.subtotal_minor, + notifiedAt: row.notified_at, + acknowledgedAt: row.acknowledged_at, + createdAt: row.created_at, + lines: lines + .filter((line) => line.inquiry_id === row.id) + .map((line) => ({ + variantId: line.variant_id, + sku: line.sku, + name: line.name, + quantity: line.quantity, + unitPriceMinor: line.unit_price_minor, + })), + })); +} + +/** One inquiry and its lines, in one round trip; null when it is not there. */ +export async function readInquiry( + db: D1Database, + id: string, +): Promise { + const [inquiryResult, lineResult] = await db.batch< + InquiryRow | InquiryLineRow + >([ + db + .prepare(`SELECT ${INQUIRY_COLUMNS} FROM p_shop_inquiry WHERE id = ?`) + .bind(id), + db + .prepare( + `SELECT ${LINE_COLUMNS} FROM p_shop_inquiry_line + WHERE inquiry_id = ? ORDER BY position`, + ) + .bind(id), + ]); + return ( + detailsOf( + (inquiryResult?.results ?? []) as InquiryRow[], + (lineResult?.results ?? []) as InquiryLineRow[], + )[0] ?? null + ); +} + +/** + * The inquiries an export takes: the ones named, or the latest when none + * is, newest first, with their lines. Two statements however many. + */ +export async function readInquiries( + db: D1Database, + ids: readonly string[], + limit: number, +): Promise { + const named = JSON.stringify(ids.slice(0, limit)); + const chosen = + ids.length === 0 + ? `SELECT id FROM p_shop_inquiry ORDER BY created_at DESC, id LIMIT ?2` + : `SELECT id FROM p_shop_inquiry + WHERE id IN (SELECT value FROM json_each(?1)) LIMIT ?2`; + const [inquiryResult, lineResult] = await db.batch< + InquiryRow | InquiryLineRow + >([ + db + .prepare( + `SELECT ${INQUIRY_COLUMNS} FROM p_shop_inquiry + WHERE id IN (${chosen}) ORDER BY created_at DESC, id`, + ) + .bind(named, limit), + db + .prepare( + `SELECT ${LINE_COLUMNS} FROM p_shop_inquiry_line + WHERE inquiry_id IN (${chosen}) ORDER BY inquiry_id, position`, + ) + .bind(named, limit), + ]); + return detailsOf( + (inquiryResult?.results ?? []) as InquiryRow[], + (lineResult?.results ?? []) as InquiryLineRow[], + ); +} + +/** Sets the status of the inquiries named; one statement however many. */ +export function markInquiriesStatement( + db: D1Database, + ids: readonly string[], + status: InquiryStatus, + now: Date, +): D1PreparedStatement { + return db + .prepare( + `UPDATE p_shop_inquiry SET status = ?, updated_at = ? + WHERE id IN (SELECT value FROM json_each(?))`, + ) + .bind(status, now.toISOString(), JSON.stringify(ids)); +} + +/** + * The statements that remove the inquiries named, with their lines, for one + * batch. It is how what a person typed about themselves is erased when they + * ask, and how a flood of spam is cleared. + */ +export function deleteInquiriesStatements( + db: D1Database, + ids: readonly string[], +): D1PreparedStatement[] { + const named = JSON.stringify(ids); + return [ + db + .prepare( + `DELETE FROM p_shop_inquiry_line + WHERE inquiry_id IN (SELECT value FROM json_each(?))`, + ) + .bind(named), + db + .prepare( + `DELETE FROM p_shop_inquiry + WHERE id IN (SELECT value FROM json_each(?))`, + ) + .bind(named), + ]; +} + +/** + * A cell that a spreadsheet would run as a formula, made into text. + * + * What a buyer typed ends up in a file the seller opens, and a cell that + * begins with `=`, `+`, `-` or `@` is a formula to a spreadsheet — one that + * can fetch an address or start a program. A leading apostrophe makes it + * text again, which is what OWASP's guidance on CSV injection asks for. + */ +function inert(value: string): string { + return /^[=+\-@\t\r]/.test(value) ? `'${value}` : value; +} + +function csvCell(value: string | number | null): string { + if (value === null) { + return ''; + } + const text = typeof value === 'number' ? String(value) : inert(value); + return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +const CSV_HEADER = [ + 'inquiry_no', + 'created_at', + 'status', + 'name', + 'company', + 'email', + 'phone', + 'country', + 'locale', + 'currency', + 'sku', + 'product', + 'quantity', + 'unit_price', + 'line_total', + 'message', +] as const; + +/** + * Inquiries as CSV, one row for each line of each, so that a spreadsheet + * can total by part or by buyer. Amounts are plain decimal numbers, in the + * currency named on the row, and empty where a line had no price. + */ +export function inquiriesCsv(inquiries: readonly InquiryDetail[]): string { + // For a machine: digits and a decimal point, no symbol and no grouping. + const decimal = (minor: number | null, currency: string): string => { + if (minor === null) { + return ''; + } + const known = isCurrency(currency) ? currency : BASE_CURRENCY; + return fromMinor(minor, known).toFixed(CURRENCY_MINOR_UNITS[known]); + }; + const rows = inquiries.flatMap((inquiry) => + inquiry.lines.map((line) => + [ + inquiry.inquiryNo, + inquiry.createdAt, + inquiry.status, + inquiry.name, + inquiry.company, + inquiry.email, + inquiry.phone, + inquiry.country, + inquiry.locale, + inquiry.currency, + line.sku, + line.name, + line.quantity, + decimal(line.unitPriceMinor, inquiry.currency), + decimal( + line.unitPriceMinor === null + ? null + : line.unitPriceMinor * line.quantity, + inquiry.currency, + ), + inquiry.message, + ] + .map(csvCell) + .join(','), + ), + ); + // CRLF, as RFC 4180 has it, and a byte order mark so that a spreadsheet + // reads names outside ASCII as what they are. + return `${[CSV_HEADER.join(','), ...rows].join('\r\n')}\r\n`; +} diff --git a/src/plugins/shop/lib/inquiry-admin.ts b/src/plugins/shop/lib/inquiry-admin.ts new file mode 100644 index 0000000..cc7ff12 --- /dev/null +++ b/src/plugins/shop/lib/inquiry-admin.ts @@ -0,0 +1,80 @@ +/** + * What the admin does with inquiries: mark them, export them, delete them. + * + * Mallok lists the inquiries and their lines itself, from the panel the + * manifest declares. These are the panel's actions; each receives the ids + * of the rows that were ticked. + */ + +import type { PluginContext } from 'mallok/worker'; +import { + deleteInquiriesStatements, + type InquiryStatus, + inquiriesCsv, + markInquiriesStatement, + readInquiries, +} from './inquiries.js'; + +/** The most rows one action changes, as in Mallok's own inquiry panel. */ +const ACTION_LIMIT = 100; + +/** The most inquiries one export holds. */ +const EXPORT_LIMIT = 1000; + +export async function markInquiries( + ids: readonly string[], + ctx: PluginContext, + status: InquiryStatus, + now: Date = new Date(), +): Promise { + const bounded = ids.slice(0, ACTION_LIMIT); + if (bounded.length > 0) { + await markInquiriesStatement(ctx.db, bounded, status, now).run(); + } + return undefined; +} + +/** + * Deletes inquiries for good, with their lines. + * + * It cannot be undone, so the action asks for a box to be ticked first, and + * refuses without it: a request that names the action and no confirmation + * did not come from that form. + */ +export async function deleteInquiries( + ids: readonly string[], + ctx: PluginContext, + params: Readonly>, +): Promise { + if (params.confirm !== true) { + return Response.json( + { + error: 'Tick the box to confirm.', + errors: { confirm: 'Tick the box to delete these inquiries for good.' }, + }, + { status: 422 }, + ); + } + const bounded = ids.slice(0, ACTION_LIMIT); + if (bounded.length > 0) { + await ctx.db.batch(deleteInquiriesStatements(ctx.db, bounded)); + } + return undefined; +} + +/** The inquiries ticked, or the latest when none is, as a CSV file. */ +export async function exportInquiries( + ids: readonly string[], + ctx: PluginContext, + now: Date = new Date(), +): Promise { + const inquiries = await readInquiries(ctx.db, ids, EXPORT_LIMIT); + return new Response(inquiriesCsv(inquiries), { + headers: { + 'content-type': 'text/csv; charset=utf-8', + 'content-disposition': `attachment; filename="inquiries-${now + .toISOString() + .slice(0, 10)}.csv"`, + }, + }); +} diff --git a/src/plugins/shop/lib/inquiry-email.ts b/src/plugins/shop/lib/inquiry-email.ts new file mode 100644 index 0000000..acc01fe --- /dev/null +++ b/src/plugins/shop/lib/inquiry-email.ts @@ -0,0 +1,237 @@ +/** + * The two emails an inquiry sends: one tells the seller, one tells the buyer + * it arrived. + * + * The seller's is in English, like the admin, and is answered straight to + * the buyer: its reply address is theirs. The buyer's is in the language the + * cart was sent in. Each carries a plain-text part beside the HTML, as the + * order emails do. + * + * Everything a buyer typed is in these, and every value goes through + * `escapeHtml` on its way into markup. These build the content only; sending + * is Mallok's `ctx.sendEmail`. + */ + +import { escapeHtml } from 'mallok/worker'; +import { BASE_CURRENCY, type Currency, isCurrency } from './currency.js'; +import type { InquiryDetail, InquiryLine } from './inquiries.js'; +import { formatMoney } from './money.js'; +import type { EmailContent } from './order-email.js'; + +interface Copy { + readonly subject: (inquiryNo: string) => string; + readonly intro: string; + readonly itemsHeading: string; + readonly subtotalLabel: string; + readonly onRequest: string; + readonly signOff: string; +} + +const ENGLISH: Copy = { + subject: (inquiryNo) => `We have received your request ${inquiryNo}`, + intro: + 'Thank you for your request. We have received it and will answer by email.', + itemsHeading: 'What you asked about', + subtotalLabel: 'Subtotal', + onRequest: 'on request', + signOff: 'If you have anything to add, simply reply to this email.', +}; + +const COPY: Readonly> = { + en: ENGLISH, + de: { + subject: (inquiryNo) => `Ihre Anfrage ${inquiryNo} ist eingegangen`, + intro: + 'Vielen Dank für Ihre Anfrage. Sie ist bei uns eingegangen, und wir antworten Ihnen per E-Mail.', + itemsHeading: 'Ihre Anfrage', + subtotalLabel: 'Zwischensumme', + onRequest: 'auf Anfrage', + signOff: + 'Wenn Sie etwas ergänzen möchten, antworten Sie einfach auf diese E-Mail.', + }, + fr: { + subject: (inquiryNo) => `Nous avons bien reçu votre demande ${inquiryNo}`, + intro: + 'Merci pour votre demande. Nous l’avons bien reçue et vous répondrons par e-mail.', + itemsHeading: 'Votre demande', + subtotalLabel: 'Sous-total', + onRequest: 'sur demande', + signOff: 'Pour toute précision, répondez simplement à cet e-mail.', + }, + es: { + subject: (inquiryNo) => `Hemos recibido su solicitud ${inquiryNo}`, + intro: + 'Gracias por su solicitud. La hemos recibido y le responderemos por correo electrónico.', + itemsHeading: 'Su solicitud', + subtotalLabel: 'Subtotal', + onRequest: 'a consultar', + signOff: 'Si desea añadir algo, responda a este correo.', + }, +}; + +/** The language to write in; a language without a translation reads English. */ +function languageOf(locale: string): string { + const language = locale.toLowerCase().split('-')[0] ?? ''; + return Object.hasOwn(COPY, language) ? language : 'en'; +} + +function currencyOf(inquiry: Pick): Currency { + return isCurrency(inquiry.currency) ? inquiry.currency : BASE_CURRENCY; +} + +function layout(title: string, bodyHtml: string): string { + return ` + + +

${escapeHtml(title)}

+${bodyHtml} + +`; +} + +/** The lines as both parts of an email show them. */ +function linesOf( + lines: readonly InquiryLine[], + currency: Currency, + language: string, + onRequest: string, +): { readonly text: string; readonly html: string } { + const amount = (line: InquiryLine): string => + line.unitPriceMinor === null + ? onRequest + : formatMoney(line.unitPriceMinor * line.quantity, currency, language); + const each = (line: InquiryLine): string => + line.unitPriceMinor === null + ? onRequest + : formatMoney(line.unitPriceMinor, currency, language); + return { + text: lines + .map( + (line) => + `${line.sku} ${line.name} × ${line.quantity} ${each(line)} ${amount(line)}`, + ) + .join('\n'), + html: ` +${lines + .map( + (line) => + ``, + ) + .join('\n')} +
${escapeHtml(line.sku)}${escapeHtml(line.name)}× ${line.quantity}${escapeHtml(each(line))}${escapeHtml(amount(line))}
`, + }; +} + +/** Text a buyer typed over several lines, as HTML that keeps the lines. */ +function paragraphs(text: string): string { + return escapeHtml(text).replace(/\r?\n/g, '
'); +} + +/** + * The email that tells the seller. Its reply address is the buyer's, which + * the caller sets from `replyTo`. + */ +export function inquiryNotificationEmail( + inquiry: InquiryDetail, + siteName: string, +): EmailContent & { readonly replyTo: string } { + const currency = currencyOf(inquiry); + const lines = linesOf(inquiry.lines, currency, 'en', 'on request'); + const from = + inquiry.company === '' + ? inquiry.name + : `${inquiry.name}, ${inquiry.company}`; + const subject = `Inquiry ${inquiry.inquiryNo} from ${from}`; + const subtotal = + inquiry.subtotalMinor === null + ? 'Not stated: at least one line has no price.' + : formatMoney(inquiry.subtotalMinor, currency, 'en'); + const who: (readonly [string, string])[] = [ + ['Name', inquiry.name], + ['Company', inquiry.company], + ['Email', inquiry.email], + ['Phone', inquiry.phone], + ['Country', inquiry.country], + ['Language', inquiry.locale], + ['Currency', inquiry.currency], + ]; + const stated = who.filter(([, value]) => value !== ''); + + const text = [ + `A cart was sent as an inquiry on ${siteName}.`, + '', + ...stated.map(([label, value]) => `${label}: ${value}`), + '', + lines.text, + '', + `Subtotal: ${subtotal}`, + ...(inquiry.message === '' ? [] : ['', 'Message:', inquiry.message]), + '', + 'Reply to this email to answer the buyer.', + ].join('\n'); + + const html = layout( + subject, + [ + `

A cart was sent as an inquiry on ${escapeHtml(siteName)}.

`, + `${stated + .map( + ([label, value]) => + ``, + ) + .join('')}
${escapeHtml(label)}${escapeHtml(value)}
`, + lines.html, + `

Subtotal: ${escapeHtml(subtotal)}

`, + ...(inquiry.message === '' + ? [] + : [ + `

Message

${paragraphs(inquiry.message)}

`, + ]), + '

Reply to this email to answer the buyer.

', + ].join('\n'), + ); + + return { subject, text, html, replyTo: inquiry.email }; +} + +/** The email that tells the buyer their request arrived, in their language. */ +export function inquiryAcknowledgementEmail( + inquiry: InquiryDetail, +): EmailContent { + const language = languageOf(inquiry.locale); + const copy = COPY[language] ?? ENGLISH; + const currency = currencyOf(inquiry); + const lines = linesOf(inquiry.lines, currency, language, copy.onRequest); + const subject = copy.subject(inquiry.inquiryNo); + const subtotal = + inquiry.subtotalMinor === null + ? null + : formatMoney(inquiry.subtotalMinor, currency, language); + + const text = [ + copy.intro, + '', + `${copy.itemsHeading}:`, + lines.text, + ...(subtotal === null ? [] : ['', `${copy.subtotalLabel}: ${subtotal}`]), + '', + copy.signOff, + ].join('\n'); + + const html = layout( + subject, + [ + `

${escapeHtml(copy.intro)}

`, + `

${escapeHtml(copy.itemsHeading)}

`, + lines.html, + ...(subtotal === null + ? [] + : [ + `

${escapeHtml(copy.subtotalLabel)}: ${escapeHtml(subtotal)}

`, + ]), + `

${escapeHtml(copy.signOff)}

`, + ].join('\n'), + ); + + return { subject, text, html }; +} diff --git a/src/plugins/shop/lib/inquiry-jobs.ts b/src/plugins/shop/lib/inquiry-jobs.ts new file mode 100644 index 0000000..74f1035 --- /dev/null +++ b/src/plugins/shop/lib/inquiry-jobs.ts @@ -0,0 +1,113 @@ +/** + * What an inquiry owes after it is stored: an email to the seller, and one + * to the buyer when the shop is set to send it. + * + * It is a job, queued in the batch that stores the inquiry, so the emails + * are owed exactly when the inquiry exists. Mallok runs a job at least once + * and may run it twice; each email is marked on the inquiry once it has been + * handed to Mallok, and a second run sends only what is not marked. + */ + +import type { PluginContext } from 'mallok/worker'; +import { EMAIL_PATTERN, readInquiry } from './inquiries.js'; +import { + inquiryAcknowledgementEmail, + inquiryNotificationEmail, +} from './inquiry-email.js'; + +/** The job's name, as `definePlugin` and `ctx.enqueueStatement` know it. */ +export const INQUIRY_EMAILS_JOB = 'inquiry_emails'; + +export interface InquirySettings { + /** Where the seller is told; '' when nowhere, or not an address. */ + readonly recipient: string; + /** Whether the buyer is told their request arrived. */ + readonly acknowledge: boolean; +} + +export function inquirySettings( + settings: Readonly>, +): InquirySettings { + const recipient = + typeof settings.inquiry_recipient === 'string' + ? settings.inquiry_recipient.trim() + : ''; + return { + recipient: EMAIL_PATTERN.test(recipient) ? recipient : '', + acknowledge: settings.inquiry_acknowledge === true, + }; +} + +/** Whether an inquiry stored under these settings has any email to send. */ +export function owesEmail(settings: InquirySettings): boolean { + return settings.recipient !== '' || settings.acknowledge; +} + +function inquiryIdOf(payload: unknown): string | null { + if (typeof payload !== 'object' || payload === null) { + return null; + } + const { inquiryId } = payload as { inquiryId?: unknown }; + return typeof inquiryId === 'string' ? inquiryId : null; +} + +function markStatement( + db: D1Database, + column: 'notified_at' | 'acknowledged_at', + id: string, + now: Date, +): D1PreparedStatement { + return db + .prepare( + `UPDATE p_shop_inquiry SET ${column} = ? WHERE id = ? AND ${column} IS NULL`, + ) + .bind(now.toISOString(), id); +} + +/** + * Sends what an inquiry still owes. + * + * An inquiry that is not there owes nothing: the job is queued beside a + * conditional write, and is there even when the write stored no inquiry — + * one over the visitor's limit, or the second of two sent at once. So does + * one that was deleted before its job ran. + * + * A failure to hand an email to Mallok throws, and Mallok runs the job + * again later; what was already handed over is marked and is not repeated. + */ +export async function sendInquiryEmails( + payload: unknown, + ctx: PluginContext, + now: Date = new Date(), +): Promise { + const id = inquiryIdOf(payload); + if (id === null) { + return; + } + const inquiry = await readInquiry(ctx.db, id); + if (inquiry === null) { + return; + } + const settings = inquirySettings(ctx.settings); + + if (settings.recipient !== '' && inquiry.notifiedAt === null) { + await ctx.sendEmail({ + to: settings.recipient, + ...inquiryNotificationEmail(inquiry, ctx.site.name), + }); + await markStatement(ctx.db, 'notified_at', id, now).run(); + } + + // Nobody is written to about what the seller has called spam. + if ( + settings.acknowledge && + inquiry.acknowledgedAt === null && + inquiry.status !== 'spam' + ) { + await ctx.sendEmail({ + to: inquiry.email, + ...inquiryAcknowledgementEmail(inquiry), + }); + await markStatement(ctx.db, 'acknowledged_at', id, now).run(); + } +} diff --git a/src/plugins/shop/lib/paths.ts b/src/plugins/shop/lib/paths.ts index cf06fa2..5dd7ee6 100644 --- a/src/plugins/shop/lib/paths.ts +++ b/src/plugins/shop/lib/paths.ts @@ -15,6 +15,9 @@ export const CART_ROUTE = 'cart'; /** Where every form that changes the cart posts. */ export const CART_UPDATE_ROUTE = 'cart/update'; +/** Where the cart page's form posts to send the cart as an inquiry. */ +export const CART_INQUIRY_ROUTE = 'cart/inquiry'; + export function shopPath( route: string, locale: string, diff --git a/src/plugins/shop/lib/storefront.ts b/src/plugins/shop/lib/storefront.ts index 7c03f6d..b1ae122 100644 --- a/src/plugins/shop/lib/storefront.ts +++ b/src/plugins/shop/lib/storefront.ts @@ -15,7 +15,7 @@ * prices_from the same, once per currency * max_quantity the most of one variant a cart line may hold * variants[] id, sku, label, moq, availability, lead_time, price, prices, - * orderable + * addable * * On every page, priced or not: * @@ -81,8 +81,11 @@ export interface VariantView { readonly price: string; /** The unit price in every currency the page offers. */ readonly prices: readonly DisplayPrice[]; - /** Can go in the cart: it has a price and can be had. */ - readonly orderable: boolean; + /** + * A form can put it in the cart. Not the same as "can be ordered": a size + * without a price goes in a cart to be asked about. + */ + readonly addable: boolean; } export interface ProductView { @@ -297,13 +300,13 @@ export function productView( lead_time: leadTime(variant), price, prices: own, - // A cart line without a price cannot be priced, one that is out of - // stock would be refused, and so would a minimum order above what a - // cart line may hold: none of them is offered a form. - orderable: - price !== '' && - availability !== 'out_of_stock' && - variant.moq <= MAX_LINE_QUANTITY, + // A size that is out of stock would be refused by the cart, and so + // would a minimum order above what a cart line may hold: neither is + // offered a form. A size without a price is. It cannot be ordered, + // and it can be asked about — which is what a cart sent as an + // inquiry is for. + addable: + availability !== 'out_of_stock' && variant.moq <= MAX_LINE_QUANTITY, }; }), }; diff --git a/src/plugins/shop/migrations/0003_inquiries.sql b/src/plugins/shop/migrations/0003_inquiries.sql new file mode 100644 index 0000000..c4ca987 --- /dev/null +++ b/src/plugins/shop/migrations/0003_inquiries.sql @@ -0,0 +1,89 @@ +-- Shop plugin: a cart sent as one inquiry, and its lines. +-- +-- Mallok's migrator drops whole-line comments and then splits on semicolons, +-- so a comment must have a line to itself, and none here contains a +-- semicolon. Every statement is idempotent. +-- +-- An inquiry is a request for a quote: nothing is charged and no stock is +-- taken. It holds what a person typed about themselves, which is personal +-- data, and nothing else in the shop's tables does before an order exists. + +CREATE TABLE IF NOT EXISTS p_shop_inquiry ( + id TEXT PRIMARY KEY, + -- The number the buyer and the seller quote to each other: a date and a + -- random suffix, so it says nothing about how many inquiries a shop gets + inquiry_no TEXT NOT NULL UNIQUE, + -- new | answered | spam. Whoever reads the inquiries sets it, and nothing + -- in the shop acts on it + status TEXT NOT NULL DEFAULT 'new' + CHECK (status IN ('new', 'answered', 'spam')), + name TEXT NOT NULL, + email TEXT NOT NULL, + company TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + message TEXT NOT NULL DEFAULT '', + -- The language the cart was sent in, so the buyer is answered in it + locale TEXT NOT NULL, + -- The one currency every amount of this inquiry is in + currency TEXT NOT NULL, + -- The country the request came from, as the edge reports it. Empty when it + -- does not + country TEXT NOT NULL DEFAULT '', + line_count INTEGER NOT NULL + CHECK (typeof(line_count) = 'integer' AND line_count > 0), + -- What the lines came to when the cart was sent. Null when a line had no + -- price, which is what an inquiry is for + subtotal_minor INTEGER + CHECK (subtotal_minor IS NULL + OR (typeof(subtotal_minor) = 'integer' AND subtotal_minor >= 0)), + -- The same, as the buyer saw it. The admin lists a column as it is stored + -- and has no way to format an amount, so the text is stored beside it + subtotal TEXT NOT NULL DEFAULT '', + -- The cart it was sent from. The page that confirms an inquiry shows it + -- only to the browser holding that cart + cart_id TEXT NOT NULL, + -- One-way, from Mallok. It is what an exact limit per visitor counts by. + -- Null when the request had no address to hash + ip_hash TEXT, + -- When the seller was told by email, and when the buyer was. Null until + -- then, which is how a job that runs twice sends each once + notified_at TEXT, + acknowledged_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS p_shop_inquiry_created + ON p_shop_inquiry (created_at); + +CREATE INDEX IF NOT EXISTS p_shop_inquiry_visitor + ON p_shop_inquiry (ip_hash, created_at); + +CREATE INDEX IF NOT EXISTS p_shop_inquiry_cart + ON p_shop_inquiry (cart_id, created_at); + +-- A line is a snapshot, as an order's is. Renaming, repricing or delisting +-- the product later must not change what the buyer asked about +CREATE TABLE IF NOT EXISTS p_shop_inquiry_line ( + -- ':' + id TEXT PRIMARY KEY, + inquiry_id TEXT NOT NULL, + position INTEGER NOT NULL + CHECK (typeof(position) = 'integer' AND position >= 0), + variant_id TEXT NOT NULL, + sku TEXT NOT NULL, + name TEXT NOT NULL, + quantity INTEGER NOT NULL + CHECK (typeof(quantity) = 'integer' AND quantity > 0), + -- Null when the variant had no price in the inquiry's currency + unit_price_minor INTEGER + CHECK (unit_price_minor IS NULL + OR (typeof(unit_price_minor) = 'integer' AND unit_price_minor >= 0)), + -- As the buyer saw them, for the admin's list. Empty without a price + unit_price TEXT NOT NULL DEFAULT '', + line_total TEXT NOT NULL DEFAULT '', + UNIQUE (inquiry_id, variant_id) +); + +CREATE INDEX IF NOT EXISTS p_shop_inquiry_line_inquiry + ON p_shop_inquiry_line (inquiry_id, position); diff --git a/src/plugins/shop/plugin.json b/src/plugins/shop/plugin.json index 0857b2f..4ade2a2 100644 --- a/src/plugins/shop/plugin.json +++ b/src/plugins/shop/plugin.json @@ -2,7 +2,7 @@ "id": "shop", "name": "Shop", "version": "0.1.0", - "description": "Variants, prices in several currencies, stock, minimum order quantities and a cart for a Mallok site.", + "description": "Variants, prices in several currencies, stock, minimum order quantities, a cart and the cart sent as an inquiry, for a Mallok site.", "pluginApi": 2, "hooks": ["scheduled", "renderData", "onContentDelete"], "routes": [ @@ -17,6 +17,11 @@ "path": "cart/update", "method": "POST", "rateLimit": "relaxed" + }, + { + "path": "cart/inquiry", + "method": "POST", + "rateLimit": "strict" } ], "settings": { @@ -45,6 +50,19 @@ "choices": ["ending99", "integer"], "default": "ending99", "group": "Pricing" + }, + "inquiry_recipient": { + "type": "string", + "label": "Inquiry recipient", + "help": "The address that is told by email when a cart is sent as an inquiry. The email is sent from the site's own address (Settings → Email) and is answered straight to the buyer. Left empty, inquiries are only listed here in the admin.", + "group": "Inquiries" + }, + "inquiry_acknowledge": { + "type": "boolean", + "label": "Confirm to the buyer by email", + "help": "Sends the buyer an email saying their request arrived, in their language. Off by default: the form has no challenge in front of it, so anyone can have that email sent to an address that is not theirs.", + "default": false, + "group": "Inquiries" } }, "panels": [ @@ -174,6 +192,128 @@ "help": "Lower comes first among the variants a product page does not name." } } + }, + { + "id": "inquiries", + "label": "Inquiries", + "type": "table", + "table": "p_shop_inquiry", + "columns": [ + { + "field": "inquiry_no", + "label": "Number", + "sortable": true + }, + { + "field": "name", + "label": "Name", + "sortable": true + }, + { + "field": "company", + "label": "Company" + }, + { + "field": "email", + "label": "Email", + "type": "email" + }, + { + "field": "country", + "label": "Country" + }, + { + "field": "line_count", + "label": "Lines" + }, + { + "field": "subtotal", + "label": "Subtotal" + }, + { + "field": "status", + "label": "Status", + "type": "badge" + }, + { + "field": "created_at", + "label": "Received", + "type": "datetime", + "sortable": true + } + ], + "filters": ["status"], + "search": ["inquiry_no", "name", "company", "email"], + "detail": [ + "message", + "phone", + "locale", + "currency", + "notified_at", + "acknowledged_at" + ], + "orderBy": "created_at", + "related": [ + { + "id": "lines", + "label": "Lines", + "table": "p_shop_inquiry_line", + "foreignKey": "inquiry_id", + "orderBy": "position", + "columns": [ + { + "field": "sku", + "label": "SKU" + }, + { + "field": "name", + "label": "Product" + }, + { + "field": "quantity", + "label": "Quantity" + }, + { + "field": "unit_price", + "label": "Unit price" + }, + { + "field": "line_total", + "label": "Total" + } + ] + } + ], + "actions": [ + { + "id": "inquiry_mark_answered", + "label": "Mark answered" + }, + { + "id": "inquiry_mark_new", + "label": "Mark new" + }, + { + "id": "inquiry_mark_spam", + "label": "Mark spam" + }, + { + "id": "inquiry_export_csv", + "label": "Export CSV", + "type": "download" + }, + { + "id": "inquiry_delete", + "label": "Delete for good", + "params": { + "confirm": { + "type": "boolean", + "label": "Delete these inquiries and their lines. This cannot be undone.", + "required": true + } + } + } + ] } ] } diff --git a/src/plugins/shop/routes/cart.ts b/src/plugins/shop/routes/cart.ts index 166ef73..9394a2e 100644 --- a/src/plugins/shop/routes/cart.ts +++ b/src/plugins/shop/routes/cart.ts @@ -53,7 +53,24 @@ import { defaultCurrencyForLocale, isCurrency, } from '../lib/currency.js'; -import { CART_ROUTE, CART_UPDATE_ROUTE, shopPath } from '../lib/paths.js'; +import { + INQUIRY_FIELDS, + inquiryLinesOf, + isInquiryNo, + sentInquiryStatement, +} from '../lib/inquiries.js'; +import { + CART_INQUIRY_ROUTE, + CART_ROUTE, + CART_UPDATE_ROUTE, + shopPath, +} from '../lib/paths.js'; +import { + INQUIRY_FIELD_PARAM, + INQUIRY_PARAM, + INQUIRY_REFUSALS, + SENT_PARAM, +} from './inquiry.js'; const updateSchema = z.object({ action: z.enum(['add', 'set', 'remove', 'currency']).default('add'), @@ -127,11 +144,16 @@ function safeReturnPath( } } -function paths(ctx: PluginRequestContext): { cart: string; update: string } { +function paths(ctx: PluginRequestContext): { + cart: string; + update: string; + inquiry: string; +} { const { defaultLocale } = ctx.site; return { cart: shopPath(CART_ROUTE, ctx.locale, defaultLocale), update: shopPath(CART_UPDATE_ROUTE, ctx.locale, defaultLocale), + inquiry: shopPath(CART_INQUIRY_ROUTE, ctx.locale, defaultLocale), }; } @@ -191,7 +213,8 @@ function refusalOf( * `GET cart`: the cart page. It reads and never writes. * * Two round trips: the cart's lines and its currency — and, after a refused - * change, the variant it was about — then everything those lines depend on. + * change, the variant it was about, or after a cart was sent, the inquiry it + * became — then everything those lines depend on. * An unknown, expired or absent cart is an empty one. */ export async function cartPage( @@ -209,17 +232,37 @@ export async function cartPage( ? '' : (ctx.url.searchParams.get(VARIANT_PARAM) ?? '').slice(0, 100); - const [linesResult, currencyResult, variantResult] = await ctx.db.batch< - | { variant_id: string; quantity: number } - | { currency: string | null } - | VariantLookup - >([ - readCartStatement(ctx.db, known, now), - readCartCurrencyStatement(ctx.db, known, now), - // The variant a refusal was about. With no refusal to explain it finds - // nothing; either way it rides in the same round trip. - variantStatement(ctx.db, refusedVariant, now), - ]); + // What became of a cart sent as an inquiry: the number of the one that + // was made, or the kind of reason none was. Only a number of the right + // shape is looked up, and only for the cart this browser holds. + const sentAsked = ctx.url.searchParams.get(SENT_PARAM) ?? ''; + const sentNo = known !== '' && isInquiryNo(sentAsked) ? sentAsked : ''; + const inquiryAsked = ctx.url.searchParams.get(INQUIRY_PARAM); + const inquiryKind = INQUIRY_REFUSALS.find((kind) => kind === inquiryAsked); + const fieldAsked = ctx.url.searchParams.get(INQUIRY_FIELD_PARAM); + const inquiryField = + inquiryKind === 'invalid' + ? (INQUIRY_FIELDS.find((field) => field === fieldAsked) ?? '') + : ''; + + const [linesResult, currencyResult, variantResult, sentResult] = + await ctx.db.batch< + | { variant_id: string; quantity: number } + | { currency: string | null } + | VariantLookup + | { inquiry_no: string } + >([ + readCartStatement(ctx.db, known, now), + readCartCurrencyStatement(ctx.db, known, now), + // The variant a refusal was about. With no refusal to explain it finds + // nothing; either way it rides in the same round trip. + variantStatement(ctx.db, refusedVariant, now), + // And so does the inquiry a confirmation is about. + sentInquiryStatement(ctx.db, sentNo, known), + ]); + const sent = (sentResult?.results ?? [])[0] as + | { inquiry_no: string } + | undefined; const lines = ( (linesResult?.results ?? []) as { variant_id: string; quantity: number }[] ).map((row) => ({ variantId: row.variant_id, quantity: row.quantity })); @@ -238,12 +281,21 @@ export async function cartPage( currency, now, }); - const { cart, update } = paths(ctx); + const { cart, update, inquiry } = paths(ctx); return { view: { ...cartView(facts, ctx.locale), action: update, cart_path: cart, + inquiry_action: inquiry, + // A cart with a part that has no price can be asked about though it + // cannot be ordered; one with any other problem has to be put right. + can_inquire: inquiryLinesOf(facts) !== null, + sent: sent === undefined ? null : { number: sent.inquiry_no }, + inquiry_problem: + inquiryKind === undefined + ? null + : { kind: inquiryKind, field: inquiryField }, problem: refusedKind === undefined ? null diff --git a/src/plugins/shop/routes/inquiry.ts b/src/plugins/shop/routes/inquiry.ts new file mode 100644 index 0000000..d6d16f7 --- /dev/null +++ b/src/plugins/shop/routes/inquiry.ts @@ -0,0 +1,193 @@ +/** + * `POST /_mallok/p/shop/cart/inquiry`: the cart, sent as a request for a + * quote. + * + * It is the cart page's own form, and like the cart's other form it never + * answers with a page: every answer is a 303 to the cart page, which says + * what happened — the number of the inquiry, or why none was made. What the + * address carries is a kind and, for a field that was wrong, its name, and + * nothing a person typed: an address ends up in histories and logs. + * + * So a form the server refuses comes back empty. The fields carry the + * server's own rules as `required`, `maxlength` and `pattern`, which is what + * keeps a person using a browser from ever meeting that. + * + * The form has no challenge in front of it. A theme may load no script from + * another host and Mallok runs no hook on a plugin's page, so Turnstile has + * nowhere to be drawn. What stands in its place: Mallok's cross-site check + * and its strict rate limit, a field no person sees, an exact limit per + * visitor counted in the table, and that a cart must exist — an inquiry is a + * second request, with the cookie from the first. + */ + +import type { PluginRequestContext, RouteInput } from 'mallok/worker'; +import { readCartCurrencyStatement, readCartStatement } from '../lib/cart.js'; +import { readCartFacts } from '../lib/cart-pricing.js'; +import { readCartCookie } from '../lib/cookie.js'; +import { + type Currency, + defaultCurrencyForLocale, + isCurrency, +} from '../lib/currency.js'; +import { + atVisitorLimit, + firstInvalidField, + inquiryFormSchema, + inquiryLinesOf, + inquiryStatements, + newInquiryNo, + visitorCountStatement, + whyNotStored, +} from '../lib/inquiries.js'; +import { + INQUIRY_EMAILS_JOB, + inquirySettings, + owesEmail, +} from '../lib/inquiry-jobs.js'; +import { CART_ROUTE, shopPath } from '../lib/paths.js'; + +/** Why a cart was not sent. A theme has words for each. */ +export const INQUIRY_REFUSALS = [ + 'invalid', + 'empty', + 'cart_problem', + 'too_many', +] as const; + +export type InquiryRefusalKind = (typeof INQUIRY_REFUSALS)[number]; + +/** The cart page's address carries the outcome as these parameters. */ +export const INQUIRY_PARAM = 'inquiry'; +export const INQUIRY_FIELD_PARAM = 'field'; +export const SENT_PARAM = 'sent'; + +export async function cartInquiry( + input: RouteInput, + ctx: PluginRequestContext, +): Promise { + const now = new Date(); + const cart = shopPath(CART_ROUTE, ctx.locale, ctx.site.defaultLocale); + const to = (query: Record): Response => { + const search = new URLSearchParams(query).toString(); + return new Response(null, { + status: 303, + headers: { location: search === '' ? cart : `${cart}?${search}` }, + }); + }; + const refuse = (kind: InquiryRefusalKind, field?: string): Response => + to({ + [INQUIRY_PARAM]: kind, + ...(field === undefined ? {} : { [INQUIRY_FIELD_PARAM]: field }), + }); + + const parsed = inquiryFormSchema.safeParse(input.fields); + if (!parsed.success) { + return refuse('invalid', firstInvalidField(parsed.error) ?? undefined); + } + const form = parsed.data; + + // Whatever filled in the field no person sees is answered as if all were + // well, and nothing is stored: it learns nothing to try differently. + if (form.website !== '') { + return to({}); + } + + const cartId = readCartCookie(ctx.request); + if (cartId === null) { + return refuse('empty'); + } + + const [linesResult, currencyResult, countResult] = await ctx.db.batch< + | { variant_id: string; quantity: number } + | { currency: string | null } + | { n: number } + >([ + readCartStatement(ctx.db, cartId, now), + readCartCurrencyStatement(ctx.db, cartId, now), + visitorCountStatement(ctx.db, ctx.ipHash, now), + ]); + const lines = ( + (linesResult?.results ?? []) as { variant_id: string; quantity: number }[] + ).map((row) => ({ variantId: row.variant_id, quantity: row.quantity })); + const chosen = ( + (currencyResult?.results ?? []) as { currency: string | null }[] + )[0]?.currency; + const currency: Currency = + typeof chosen === 'string' && isCurrency(chosen) + ? chosen + : defaultCurrencyForLocale(ctx.locale); + + /** An inquiry that was not stored: say which it was, from the data. */ + const notStored = async (): Promise => { + const why = await whyNotStored(ctx.db, { + cartId, + ipHash: ctx.ipHash, + now, + }); + return why.kind === 'sent' + ? to({ [SENT_PARAM]: why.inquiryNo }) + : refuse(why.kind); + }; + + // Nothing in the cart: an empty cart, or this same form arriving a second + // time after the first emptied it. + if (lines.length === 0) { + return notStored(); + } + + const facts = await readCartFacts(ctx.db, { + lines, + locale: ctx.locale, + defaultLocale: ctx.site.defaultLocale, + currency, + now, + }); + const sendable = inquiryLinesOf(facts); + if (sendable === null) { + return refuse('cart_problem'); + } + + // The limit is asked again inside the write, which is what makes it exact. + // It is asked here first so that a visitor over it queues no job: Mallok + // runs five jobs a minute for the whole site, and a job that finds nothing + // to send still takes a turn. + const alreadySent = + ((countResult?.results ?? [])[0] as { n: number } | undefined)?.n ?? 0; + if (atVisitorLimit(ctx.ipHash, alreadySent)) { + return refuse('too_many'); + } + + const id = crypto.randomUUID(); + const inquiryNo = newInquiryNo(now); + const statements = inquiryStatements(ctx.db, { + id, + inquiryNo, + cartId, + form, + locale: ctx.locale, + currency: facts.currency, + country: ctx.country ?? '', + ipHash: ctx.ipHash, + lines: sendable, + now, + }); + // The emails are owed in the same batch that stores the inquiry. The job + // is queued whether or not the conditional write stores one — Mallok + // builds its statement and it takes no condition — and finds nothing to + // send when it did not. + if (owesEmail(inquirySettings(ctx.settings))) { + statements.push( + ctx.enqueueStatement(INQUIRY_EMAILS_JOB, { inquiryId: id }), + ); + } + const [stored] = await ctx.db.batch(statements); + if ((stored?.meta.changes ?? 0) === 0) { + return notStored(); + } + + // The address is all that is logged of it: a kind and a count, no person. + console.log( + JSON.stringify({ event: 'shop_inquiry', lines: sendable.length }), + ); + return to({ [SENT_PARAM]: inquiryNo }); +} diff --git a/src/theme/assets/style.css b/src/theme/assets/style.css index a8aafdc..8e96a74 100644 --- a/src/theme/assets/style.css +++ b/src/theme/assets/style.css @@ -2126,6 +2126,33 @@ p { font-size: 0.8125rem; } +/* A cart that was sent as a request for a quote: its number, to quote. */ +.cart-sent { + margin-bottom: 1.5rem; + padding: 0.875rem 1rem; + border: 1px solid #bbf7d0; + border-left: 0.25rem solid #15803d; + border-radius: var(--radius-lg); + background: #f0fdf4; + color: #14532d; + font-size: 0.875rem; +} + +.cart-sent p { + margin: 0; +} + +.cart-sent p + p { + margin-top: 0.25rem; +} + +.cart-sent .offer-sku { + color: inherit; + font-family: var(--font-mono); + font-size: 0.8125rem; + font-weight: 600; +} + .cart-back { margin-top: 1.5rem; text-align: center; @@ -2259,6 +2286,71 @@ p { margin-top: 0.5rem; } +/* The form that sends the cart as a request for a quote. Its fields are + those of Mallok's inquiry form, further down, and are styled with them. */ +.quote-form { + position: relative; + display: grid; + gap: 1rem; + margin-top: 2.5rem; + padding: clamp(1.25rem, 3vw, 2rem); + border: 1px solid var(--slate-200); + border-radius: var(--radius-2xl); + background: var(--white); + box-shadow: var(--shadow-sm); +} + +.quote-form-title { + margin: 0; + color: var(--slate-900); + font-size: 1.125rem; + font-weight: 700; + letter-spacing: -0.01em; +} + +.quote-form-intro { + margin: -0.5rem 0 0; + color: var(--slate-600); + font-size: 0.875rem; +} + +.quote-form-pair { + display: grid; + gap: 1rem; +} + +@media (min-width: 40rem) { + .quote-form-pair { + grid-template-columns: 1fr 1fr; + } +} + +.quote-form-optional { + color: var(--slate-500); + font-weight: 400; + letter-spacing: 0; + text-transform: none; +} + +.quote-form .btn { + justify-self: start; +} + +/* Out of sight and out of reach: a person never meets this field. */ +.quote-form-trap { + position: absolute; + left: -624.9375rem; + width: 1px; + height: 1px; + margin: 0; + overflow: hidden; +} + +.quote-form-blocked { + margin-top: 1.5rem; + text-align: right; +} + .spec-card { padding: 1.25rem; border: 1px solid var(--slate-200); @@ -2809,7 +2901,8 @@ p { margin: 0; } -.mallok-inquiry label { +.mallok-inquiry label, +.quote-form label { display: block; color: var(--slate-700); font-size: 0.6875rem; @@ -2824,7 +2917,9 @@ p { } .mallok-inquiry input:not([type="hidden"]), -.mallok-inquiry textarea { +.mallok-inquiry textarea, +.quote-form input:not([type="hidden"]), +.quote-form textarea { display: block; width: 100%; margin-top: 0.375rem; @@ -2841,13 +2936,16 @@ p { } .mallok-inquiry input:focus, -.mallok-inquiry textarea:focus { +.mallok-inquiry textarea:focus, +.quote-form input:focus, +.quote-form textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } -.mallok-inquiry textarea { +.mallok-inquiry textarea, +.quote-form textarea { resize: vertical; } diff --git a/src/theme/layouts/shop-cart.liquid b/src/theme/layouts/shop-cart.liquid index 46e6ad3..4c3c763 100644 --- a/src/theme/layouts/shop-cart.liquid +++ b/src/theme/layouts/shop-cart.liquid @@ -8,7 +8,7 @@ Every control is a plain form posting to `plugin_page.action`, so the cart works without a script, and the quantity fields carry the minimum order in `min` and `step` and the most a line may hold in `max` — which the server - checks again. + checks again. The last of them sends the cart as a request for a quote. {%- endcomment -%} {% block title %}{{ t.cart_title }} — {{ site.name }}{% endblock %} {% block content %} @@ -32,6 +32,26 @@
{% endif %} + {%- comment -%} + What became of the cart when it was sent as a request for a quote. The + plugin names the inquiry it became, which it looked up for this visitor's + own cart, or the kind of reason none was made; nothing a person typed + comes back through the address, so the form below is empty again. + {%- endcomment -%} + {% if cart.sent %} +
+

{{ t.quote_sent }} {{ cart.sent.number }}

+

{{ t.quote_sent_note }}

+
+ {% endif %} + {% if cart.inquiry_problem %} + {% assign reason = "quote_problem_" | append: cart.inquiry_problem.kind %} + + {% endif %} + {% if cart.empty %}

{{ t.cart_empty }}

{% if site.kinds.product %}

{{ t.back_to_catalogue }}

{% endif %} @@ -89,13 +109,48 @@ {% endif %} {% if cart.subtotal != blank %}

{{ t.subtotal }} {{ cart.subtotal }}

{% endif %}

{{ t.cart_note }}

- {% if theme.options.quote_href != blank or site.kinds.product %} + {% if site.kinds.product %}
- {% if theme.options.quote_href != blank %}{{ t.request_quote }}{% endif %} - {% if site.kinds.product %}{{ t.back_to_catalogue }}{% endif %} + {{ t.back_to_catalogue }}
{% endif %} + + {%- comment -%} + The cart, sent as one request for a quote: a plain form, like every + other control here. A line without a price does not stand in its way — + that is what a quote is for — and any other problem does, so the form is + offered only when the plugin says the cart can go as it stands. + + Each field carries the server's own rule: `required`, the length the + plugin allows, and for the address the pattern it is held to. A browser + then refuses what the server would, and the server's refusal — which + comes back with the fields empty — is not something a person meets. + + The last field is one no person sees or reaches: whatever fills it in is + not one, and the plugin stores nothing for it. + {%- endcomment -%} + {% if cart.inquiry_action != blank %} + {% if cart.can_inquire %} + +

{{ t.quote_form_title }}

+

{{ t.quote_form_intro }}

+
+ + +
+
+ + +
+ + + +
+ {% else %} +

{{ t.quote_fix_first }}

+ {% endif %} + {% endif %} {% endif %} {% endblock %} diff --git a/src/theme/locales/de.json b/src/theme/locales/de.json index e399be2..f846f7a 100644 --- a/src/theme/locales/de.json +++ b/src/theme/locales/de.json @@ -74,9 +74,27 @@ "problem_below_moq": "Die Mindestmenge beträgt", "problem_insufficient_stock": "Ab Lager verfügbar:", "problem_unavailable": "Dieses Teil kann nicht mehr bestellt werden.", - "problem_no_price": "Für dieses Teil ist hier kein Preis hinterlegt. Bitte fragen Sie ein Angebot an.", + "problem_no_price": "Für dieses Teil ist kein Preis hinterlegt. Es wird angeboten, wenn Sie diesen Warenkorb als Anfrage senden.", "problem_quantity_too_large": "Die größte bestellbare Menge beträgt", "problem_cart_full": "Der Warenkorb kann keine weiteren Positionen aufnehmen.", + "quote_form_title": "Diesen Warenkorb als Angebotsanfrage senden", + "quote_form_intro": "Sagen Sie uns, wem wir antworten sollen. Wir melden uns per E-Mail mit Preisen, Lieferzeiten und Versand für die oben genannten Teile.", + "quote_name": "Ihr Name", + "quote_email": "E-Mail", + "quote_company": "Unternehmen", + "quote_phone": "Telefon", + "quote_message": "Nachricht", + "optional": "(optional)", + "quote_leave_empty": "Dieses Feld bitte leer lassen", + "quote_send": "Anfrage senden", + "quote_sent": "Ihre Anfrage wurde gesendet.", + "quote_sent_note": "Wir antworten Ihnen per E-Mail. Bitte geben Sie diese Nummer an, wenn Sie uns schreiben.", + "quote_refused": "Ihre Anfrage wurde nicht gesendet.", + "quote_problem_invalid": "Bitte prüfen Sie dieses Feld:", + "quote_problem_empty": "Der Warenkorb enthält nichts, was gesendet werden könnte.", + "quote_problem_cart_problem": "Bitte korrigieren Sie zuerst die unten markierten Positionen.", + "quote_problem_too_many": "Von hier wurden in der letzten Stunde zu viele Anfragen gesendet. Bitte versuchen Sie es später erneut.", + "quote_fix_first": "Sobald die oben markierten Positionen korrigiert sind, können Sie den Warenkorb als Angebotsanfrage senden.", "spec_highlights": "Worauf es hier ankommt", "used_in": "Branchen, die es spezifizieren", "cases_with": "Fallstudien mit diesem Teil", diff --git a/src/theme/locales/en.json b/src/theme/locales/en.json index d4fccd6..e16d616 100644 --- a/src/theme/locales/en.json +++ b/src/theme/locales/en.json @@ -74,9 +74,27 @@ "problem_below_moq": "The minimum order is", "problem_insufficient_stock": "Available from stock:", "problem_unavailable": "This part can no longer be ordered.", - "problem_no_price": "This part has no price here. Please ask for a quote.", + "problem_no_price": "No price is listed for this part. It will be quoted when you send this cart as a request.", "problem_quantity_too_large": "The most that can be ordered at once is", "problem_cart_full": "The cart cannot hold more different parts.", + "quote_form_title": "Send this cart as a request for a quote", + "quote_form_intro": "Tell us who to answer. We reply by email with prices, lead times and shipping for the parts above.", + "quote_name": "Your name", + "quote_email": "Email", + "quote_company": "Company", + "quote_phone": "Phone", + "quote_message": "Message", + "optional": "(optional)", + "quote_leave_empty": "Leave this field empty", + "quote_send": "Send request", + "quote_sent": "Your request has been sent.", + "quote_sent_note": "We will answer by email. Please quote this number if you write to us.", + "quote_refused": "Your request was not sent.", + "quote_problem_invalid": "Please check this field:", + "quote_problem_empty": "There is nothing in the cart to send.", + "quote_problem_cart_problem": "Please put right the lines marked below first.", + "quote_problem_too_many": "Too many requests have been sent from here in the last hour. Please try again later.", + "quote_fix_first": "Once the lines marked above are put right, the cart can be sent as a request for a quote.", "spec_highlights": "What matters here", "used_in": "Industries that specify it", "cases_with": "Case studies with this part", diff --git a/src/theme/locales/es.json b/src/theme/locales/es.json index 36b7107..74da01e 100644 --- a/src/theme/locales/es.json +++ b/src/theme/locales/es.json @@ -74,9 +74,27 @@ "problem_below_moq": "El pedido mínimo es de", "problem_insufficient_stock": "Disponible en existencias:", "problem_unavailable": "Esta pieza ya no se puede pedir.", - "problem_no_price": "Esta pieza no tiene precio aquí. Solicite un presupuesto.", + "problem_no_price": "Esta pieza no tiene precio indicado. Se presupuestará cuando envíe este carrito como solicitud.", "problem_quantity_too_large": "La cantidad máxima por pedido es de", "problem_cart_full": "El carrito no admite más referencias distintas.", + "quote_form_title": "Enviar este carrito como solicitud de presupuesto", + "quote_form_intro": "Díganos a quién responder. Le contestamos por correo electrónico con precios, plazos y envío para las piezas indicadas arriba.", + "quote_name": "Su nombre", + "quote_email": "Correo electrónico", + "quote_company": "Empresa", + "quote_phone": "Teléfono", + "quote_message": "Mensaje", + "optional": "(opcional)", + "quote_leave_empty": "Deje este campo vacío", + "quote_send": "Enviar solicitud", + "quote_sent": "Su solicitud ha sido enviada.", + "quote_sent_note": "Le responderemos por correo electrónico. Indique este número si nos escribe.", + "quote_refused": "Su solicitud no se ha enviado.", + "quote_problem_invalid": "Revise este campo:", + "quote_problem_empty": "No hay nada en el carrito que enviar.", + "quote_problem_cart_problem": "Corrija primero las líneas señaladas abajo.", + "quote_problem_too_many": "Se han enviado demasiadas solicitudes desde aquí en la última hora. Inténtelo de nuevo más tarde.", + "quote_fix_first": "Cuando corrija las líneas señaladas arriba, podrá enviar el carrito como solicitud de presupuesto.", "spec_highlights": "Lo que importa aquí", "used_in": "Sectores que la especifican", "cases_with": "Casos de éxito con esta pieza", diff --git a/src/theme/locales/fr.json b/src/theme/locales/fr.json index 9703021..c8a52c1 100644 --- a/src/theme/locales/fr.json +++ b/src/theme/locales/fr.json @@ -74,9 +74,27 @@ "problem_below_moq": "La quantité minimale est de", "problem_insufficient_stock": "Disponible en stock :", "problem_unavailable": "Cette pièce ne peut plus être commandée.", - "problem_no_price": "Cette pièce n’a pas de prix ici. Merci de demander un devis.", + "problem_no_price": "Aucun prix n’est indiqué pour cette pièce. Elle sera chiffrée lorsque vous enverrez ce panier comme demande.", "problem_quantity_too_large": "La quantité maximale par commande est de", "problem_cart_full": "Le panier ne peut pas contenir davantage de références.", + "quote_form_title": "Envoyer ce panier comme demande de devis", + "quote_form_intro": "Indiquez-nous à qui répondre. Nous vous répondons par e-mail avec les prix, les délais et la livraison pour les pièces ci-dessus.", + "quote_name": "Votre nom", + "quote_email": "E-mail", + "quote_company": "Société", + "quote_phone": "Téléphone", + "quote_message": "Message", + "optional": "(facultatif)", + "quote_leave_empty": "Laissez ce champ vide", + "quote_send": "Envoyer la demande", + "quote_sent": "Votre demande a été envoyée.", + "quote_sent_note": "Nous vous répondrons par e-mail. Merci de rappeler ce numéro si vous nous écrivez.", + "quote_refused": "Votre demande n’a pas été envoyée.", + "quote_problem_invalid": "Merci de vérifier ce champ :", + "quote_problem_empty": "Le panier ne contient rien à envoyer.", + "quote_problem_cart_problem": "Merci de corriger d’abord les lignes signalées ci-dessous.", + "quote_problem_too_many": "Trop de demandes ont été envoyées d’ici au cours de la dernière heure. Merci de réessayer plus tard.", + "quote_fix_first": "Une fois les lignes signalées ci-dessus corrigées, le panier pourra être envoyé comme demande de devis.", "spec_highlights": "Ce qui compte ici", "used_in": "Secteurs qui la spécifient", "cases_with": "Études de cas avec cette pièce", diff --git a/src/theme/partials/offer.liquid b/src/theme/partials/offer.liquid index 48ba3cb..6f5be09 100644 --- a/src/theme/partials/offer.liquid +++ b/src/theme/partials/offer.liquid @@ -27,11 +27,12 @@ The form that puts this size in the cart: a plain POST, no script. The quantity starts at the minimum order, moves in steps of it and stops at the most a cart line may hold — all three checked again by the server. - A size without a price, or that cannot be had, is offered no form. The - currency is the one the page shows; the currency switch changes this - field with the prices. + A size that cannot be had is offered no form. One without a price is: + it goes in the cart to be asked about, and the cart is sent as a request + for a quote. The currency is the one the page shows; the currency switch + changes this field with the prices. {%- endcomment -%} - {% if variant.orderable and plugins.shop.cart_action != blank %} + {% if variant.addable and plugins.shop.cart_action != blank %}
diff --git a/src/theme/theme.json b/src/theme/theme.json index c53a7f1..96ac802 100644 --- a/src/theme/theme.json +++ b/src/theme/theme.json @@ -2,7 +2,7 @@ "id": "nundar", "name": "Nundar", "description": "A commerce theme for a technical catalogue: a specification finder, product pages built around a size table, landing pages for the applications a part is used in, collections by type, case studies and reference pages. With the shop plugin, each size carries its price, minimum order and availability, and the cart is a page of the site. HTML first: every page is complete without a script, and the three it declares only add to what is already there — filters for a table, a switch between the currencies a page has prices in, calculators beside the tables of an engineering reference.", - "version": "0.10.0", + "version": "0.11.0", "themeApi": 1, "home": "layouts/home.liquid", "kinds": { diff --git a/test/shop/helpers.ts b/test/shop/helpers.ts index 0fd2cae..80ab71f 100644 --- a/test/shop/helpers.ts +++ b/test/shop/helpers.ts @@ -328,6 +328,8 @@ export async function setRate( export async function clearShopTables(): Promise { await db().batch( [ + 'p_shop_inquiry_line', + 'p_shop_inquiry', 'p_shop_outbox', 'p_shop_stock_adjustment', 'p_shop_stripe_event', diff --git a/test/shop/inquiries.test.ts b/test/shop/inquiries.test.ts new file mode 100644 index 0000000..6900fd3 --- /dev/null +++ b/test/shop/inquiries.test.ts @@ -0,0 +1,868 @@ +import { SELF } from 'cloudflare:test'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + INQUIRY_LIMITS, + inquiriesCsv, + inquiryFormSchema, + inquiryStatements, + isInquiryNo, + MAX_INQUIRIES_PER_HOUR, + newInquiryNo, + readInquiries, + readInquiry, +} from '../../src/plugins/shop/lib/inquiries.js'; +import { + api, + clearShopTables, + countD1Calls, + createProduct, + createVariant, + db, + ensureSite, + ORIGIN, + setPrice, + type TestProduct, +} from './helpers.js'; + +const SHOP = '/_mallok/p/shop'; + +let screw: TestProduct; +let washer: TestProduct; + +/** A visitor, as Mallok tells them apart: by the address they connect from. */ +const VISITOR = '203.0.113.7'; +const OTHER_VISITOR = '203.0.113.8'; + +function post( + path: string, + fields: Record, + options: { cookie?: string; visitor?: string | null } = {}, +): Promise { + const visitor = options.visitor === undefined ? VISITOR : options.visitor; + return SELF.fetch(`${ORIGIN}${SHOP}${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + ...(options.cookie === undefined ? {} : { cookie: options.cookie }), + ...(visitor === null ? {} : { 'cf-connecting-ip': visitor }), + }, + body: new URLSearchParams(fields).toString(), + redirect: 'manual', + }); +} + +/** Puts something in a cart and answers with the cart's cookie. */ +async function add( + variant: string, + quantity: string, + cookie?: string, + locale?: string, +): Promise { + const response = await post( + `${locale === undefined ? '' : `/${locale}`}/cart/update`, + { variant, quantity }, + cookie === undefined ? {} : { cookie }, + ); + return ( + cookie ?? response.headers.get('set-cookie')?.split(';')[0] ?? 'no cookie' + ); +} + +const BUYER = { + name: 'Ada Lovelace', + email: 'ada@buyer.example', + company: 'Analytical Engines Ltd', + phone: '+44 20 7946 0000', + message: 'Please quote delivered prices.\nWe need them by March.', +}; + +function send( + cookie: string | undefined, + fields: Record = BUYER, + options: { visitor?: string | null; locale?: string } = {}, +): Promise { + return post( + `${options.locale === undefined ? '' : `/${options.locale}`}/cart/inquiry`, + fields, + { + ...(cookie === undefined ? {} : { cookie }), + ...(options.visitor === undefined ? {} : { visitor: options.visitor }), + }, + ); +} + +/** Where a response sends the buyer, taken apart. */ +function outcome(response: Response): { + status: number; + path: string; + sent: string | null; + inquiry: string | null; + field: string | null; + search: string; +} { + const location = new URL(response.headers.get('location') ?? '/', ORIGIN); + return { + status: response.status, + path: location.pathname, + sent: location.searchParams.get('sent'), + inquiry: location.searchParams.get('inquiry'), + field: location.searchParams.get('field'), + search: location.search, + }; +} + +interface StoredInquiry { + id: string; + inquiry_no: string; + status: string; + name: string; + email: string; + company: string; + phone: string; + message: string; + locale: string; + currency: string; + line_count: number; + subtotal_minor: number | null; + subtotal: string; + cart_id: string; + ip_hash: string | null; +} + +async function inquiries(): Promise { + const { results } = await db() + .prepare('SELECT * FROM p_shop_inquiry ORDER BY created_at, id') + .all(); + return results; +} + +async function inquiryLines(): Promise[]> { + const { results } = await db() + .prepare( + `SELECT position, variant_id, sku, name, quantity, unit_price_minor, + unit_price, line_total + FROM p_shop_inquiry_line ORDER BY inquiry_id, position`, + ) + .all>(); + return results; +} + +async function cartLines(): Promise< + { variant_id: string; quantity: number }[] +> { + const { results } = await db() + .prepare( + 'SELECT variant_id, quantity FROM p_shop_cart_line ORDER BY variant_id', + ) + .all<{ variant_id: string; quantity: number }>(); + return results; +} + +/** The jobs Mallok has been asked to run for this plugin's emails. */ +async function queuedJobs(): Promise<{ type: string; payload: string }[]> { + const { results } = await db() + .prepare( + "SELECT type, payload FROM job WHERE type LIKE '%inquiry_emails%' ORDER BY created_at", + ) + .all<{ type: string; payload: string }>(); + return results; +} + +async function setShopSettings( + settings: Record, +): Promise { + const response = await api('PATCH', '/_mallok/api/plugins/shop/settings', { + buffer_rate: 0.03, + recalc_threshold: 0.02, + rounding: 'ending99', + inquiry_recipient: '', + inquiry_acknowledge: false, + ...settings, + }); + if (!response.ok) { + throw new Error(`Settings were refused: ${await response.text()}`); + } +} + +describe('a cart sent as an inquiry', () => { + beforeAll(async () => { + await ensureSite(); + screw = await createProduct({ + title: 'Cap screw M5', + slug: 'inquiry-cap-screw', + }); + await createProduct({ + title: 'Zylinderschraube M5', + slug: 'anfrage-zylinderschraube', + locale: 'de', + translationGroup: screw.translationGroup, + }); + washer = await createProduct({ + title: 'Washer M5', + slug: 'inquiry-washer', + }); + }); + + beforeEach(async () => { + await clearShopTables(); + await db() + .prepare("DELETE FROM job WHERE type LIKE '%inquiry_emails%'") + .run(); + await setShopSettings({}); + await createVariant({ + id: 'cs-10', + productGroup: screw.translationGroup, + sku: 'CS-10', + moq: 100, + stock: 5000, + }); + await setPrice({ + variantId: 'cs-10', + currency: 'USD', + amountMinor: 185, + source: 'base', + }); + await setPrice({ + variantId: 'cs-10', + currency: 'EUR', + amountMinor: 172, + source: 'manual', + }); + // A part with no price: what an inquiry is for. + await createVariant({ + id: 'wa-5', + productGroup: washer.translationGroup, + sku: 'WA-5', + moq: 50, + stockPolicy: 'made_to_order', + }); + }); + + describe('sending', () => { + it('stores the cart as one inquiry, empties it, and sends the buyer to its number', async () => { + const cookie = await add('cs-10', '300'); + + const response = await send(cookie); + + const [stored] = await inquiries(); + expect(outcome(response)).toMatchObject({ + status: 303, + path: `${SHOP}/cart`, + sent: stored?.inquiry_no, + inquiry: null, + }); + expect(isInquiryNo(stored?.inquiry_no ?? '')).toBe(true); + expect(stored).toMatchObject({ + status: 'new', + name: 'Ada Lovelace', + email: 'ada@buyer.example', + company: 'Analytical Engines Ltd', + phone: '+44 20 7946 0000', + message: 'Please quote delivered prices.\nWe need them by March.', + locale: 'en', + currency: 'USD', + line_count: 1, + subtotal_minor: 55500, + subtotal: '$555.00', + cart_id: cookie.split('=')[1], + }); + expect(await inquiryLines()).toEqual([ + { + position: 0, + variant_id: 'cs-10', + sku: 'CS-10', + name: 'Cap screw M5', + quantity: 300, + unit_price_minor: 185, + unit_price: '$1.85', + line_total: '$555.00', + }, + ]); + // The cart is empty, and is still this browser's cart. + expect(await cartLines()).toEqual([]); + const cart = await db() + .prepare('SELECT COUNT(*) AS n FROM p_shop_cart') + .first<{ n: number }>(); + expect(cart?.n).toBe(1); + }); + + it('puts nothing a person typed in the address it answers with', async () => { + const cookie = await add('cs-10', '100'); + + const { search } = outcome(await send(cookie)); + + for (const typed of ['Ada', 'buyer.example', 'Analytical', '7946']) { + expect(search).not.toContain(typed); + } + }); + + it('takes a part with no price, which is what an inquiry is for', async () => { + const cookie = await add('cs-10', '100'); + await add('wa-5', '50', cookie); + + const response = await send(cookie); + + expect(outcome(response).sent).not.toBeNull(); + const [stored] = await inquiries(); + // A sum over lines that are not all priced would be no sum. + expect(stored).toMatchObject({ + line_count: 2, + subtotal_minor: null, + subtotal: '', + }); + expect(await inquiryLines()).toMatchObject([ + { sku: 'CS-10', unit_price_minor: 185, line_total: '$185.00' }, + { + sku: 'WA-5', + name: 'Washer M5', + quantity: 50, + unit_price_minor: null, + unit_price: '', + line_total: '', + }, + ]); + }); + + it('is sent in the language and the currency of the cart', async () => { + const cookie = await add('cs-10', '200', undefined, 'de'); + + const response = await send(cookie, BUYER, { locale: 'de' }); + + expect(outcome(response).path).toBe(`${SHOP}/de/cart`); + expect((await inquiries())[0]).toMatchObject({ + locale: 'de', + currency: 'EUR', + subtotal_minor: 34400, + }); + expect(await inquiryLines()).toMatchObject([ + { name: 'Zylinderschraube M5', unit_price_minor: 172 }, + ]); + }); + + it('is in the one currency the whole cart has prices in, as the cart page is', async () => { + // A German cart, with a part that is priced in dollars alone. + await createVariant({ + id: 'usd-only', + productGroup: washer.translationGroup, + sku: 'WA-USD', + stockPolicy: 'made_to_order', + }); + await setPrice({ + variantId: 'usd-only', + currency: 'USD', + amountMinor: 40, + source: 'base', + }); + const cookie = await add('cs-10', '100', undefined, 'de'); + await add('usd-only', '10', cookie, 'de'); + + await send(cookie, BUYER, { locale: 'de' }); + + expect((await inquiries())[0]).toMatchObject({ + locale: 'de', + currency: 'USD', + subtotal_minor: 18900, + }); + expect(await inquiryLines()).toMatchObject([ + { sku: 'CS-10', unit_price_minor: 185 }, + { sku: 'WA-USD', unit_price_minor: 40 }, + ]); + }); + + it('needs only a name and an address', async () => { + const cookie = await add('cs-10', '100'); + + const response = await send(cookie, { + name: 'Ada', + email: 'ada@buyer.example', + }); + + expect(outcome(response).sent).not.toBeNull(); + expect((await inquiries())[0]).toMatchObject({ + company: '', + phone: '', + message: '', + }); + }); + + it('takes three round trips of its own', async () => { + const cookie = await add('cs-10', '100'); + + const calls = await countD1Calls(async () => { + await send(cookie); + }); + + // One of Mallok's own, then the cart, what its lines depend on, and + // the write. + expect(calls).toBe(4); + }); + + it('snapshots its lines: a later change to the product changes no inquiry', async () => { + const cookie = await add('cs-10', '100'); + await send(cookie); + + await db().batch([ + db().prepare( + "UPDATE p_shop_variant SET sku = 'RENAMED' WHERE id = 'cs-10'", + ), + db().prepare( + "UPDATE p_shop_price SET amount_minor = 999 WHERE variant_id = 'cs-10'", + ), + ]); + + expect(await inquiryLines()).toMatchObject([ + { sku: 'CS-10', unit_price_minor: 185 }, + ]); + }); + }); + + describe('a form the shop refuses', () => { + it.each([ + ['name', { ...BUYER, name: ' ' }], + ['name', { ...BUYER, name: 'x'.repeat(INQUIRY_LIMITS.name + 1) }], + ['name', { ...BUYER, name: 'Ada\nBcc: someone@else.example' }], + ['email', { ...BUYER, email: 'ada at buyer' }], + ['email', { ...BUYER, email: 'ada@buyer' }], + ['email', { ...BUYER, email: '' }], + [ + 'company', + { ...BUYER, company: 'x'.repeat(INQUIRY_LIMITS.company + 1) }, + ], + ['phone', { ...BUYER, phone: 'x'.repeat(INQUIRY_LIMITS.phone + 1) }], + [ + 'message', + { ...BUYER, message: 'x'.repeat(INQUIRY_LIMITS.message + 1) }, + ], + ])('names the field %s and stores nothing', async (field, fields) => { + const cookie = await add('cs-10', '100'); + + const response = await send(cookie, fields); + + expect(outcome(response)).toMatchObject({ + status: 303, + path: `${SHOP}/cart`, + sent: null, + inquiry: 'invalid', + field, + }); + expect(await inquiries()).toEqual([]); + expect(await cartLines()).toEqual([ + { variant_id: 'cs-10', quantity: 100 }, + ]); + }); + + it('names the first field of several, in the order the page shows them', async () => { + const cookie = await add('cs-10', '100'); + + const response = await send(cookie, { ...BUYER, email: 'no', name: '' }); + + expect(outcome(response).field).toBe('name'); + }); + + it('carries back nothing that was typed', async () => { + const cookie = await add('cs-10', '100'); + + const { search } = outcome( + await send(cookie, { ...BUYER, email: 'ada at buyer' }), + ); + + expect(search).toBe('?inquiry=invalid&field=email'); + }); + + it('refuses an empty cart, and a visitor with none', async () => { + const cookie = await add('cs-10', '100'); + await post( + '/cart/update', + { action: 'remove', variant: 'cs-10' }, + { cookie }, + ); + + expect(outcome(await send(cookie))).toMatchObject({ inquiry: 'empty' }); + expect(outcome(await send(undefined))).toMatchObject({ + inquiry: 'empty', + }); + expect(outcome(await send('nundar_cart=not-a-cart-id'))).toMatchObject({ + inquiry: 'empty', + }); + expect(await inquiries()).toEqual([]); + }); + + it.each([ + [ + 'fewer than its minimum order', + "UPDATE p_shop_variant SET moq = 500 WHERE id = 'cs-10'", + ], + [ + 'more than there is', + "UPDATE p_shop_variant SET stock = 50 WHERE id = 'cs-10'", + ], + [ + 'more than a cart line may hold', + "UPDATE p_shop_cart_line SET quantity = 600 WHERE variant_id = 'cs-10'", + ], + [ + 'a part that is no longer sold', + "UPDATE p_shop_variant SET status = 'archived' WHERE id = 'cs-10'", + ], + ])( + 'refuses a cart with a line of %s, and leaves it as it was', + async (_what, change) => { + const cookie = await add('cs-10', '100'); + await add('wa-5', '50', cookie); + await db().prepare(change).run(); + const before = await cartLines(); + + const response = await send(cookie); + + expect(outcome(response)).toMatchObject({ + status: 303, + sent: null, + inquiry: 'cart_problem', + }); + expect(await inquiries()).toEqual([]); + expect(await cartLines()).toEqual(before); + }, + ); + + it('answers what fills in the field no person sees as if all were well, and stores nothing', async () => { + const cookie = await add('cs-10', '100'); + + const response = await send(cookie, { + ...BUYER, + website: 'https://spam.example', + }); + + expect(outcome(response)).toMatchObject({ + status: 303, + path: `${SHOP}/cart`, + search: '', + }); + expect(await inquiries()).toEqual([]); + expect(await cartLines()).toEqual([ + { variant_id: 'cs-10', quantity: 100 }, + ]); + }); + + it('is refused by Mallok when another site caused it', async () => { + const cookie = await add('cs-10', '100'); + + const response = await SELF.fetch(`${ORIGIN}${SHOP}/cart/inquiry`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + 'sec-fetch-site': 'cross-site', + cookie, + }, + body: new URLSearchParams(BUYER).toString(), + redirect: 'manual', + }); + + expect(response.status).toBe(403); + expect(await inquiries()).toEqual([]); + }); + + it('does not answer a link', async () => { + const response = await SELF.fetch(`${ORIGIN}${SHOP}/cart/inquiry`, { + redirect: 'manual', + }); + + expect([404, 405]).toContain(response.status); + }); + }); + + describe('sent twice', () => { + it('stores one inquiry when the same cart is sent twice at the same moment', async () => { + const cookie = await add('cs-10', '100'); + + const [first, second] = await Promise.all([send(cookie), send(cookie)]); + + const stored = await inquiries(); + expect(stored).toHaveLength(1); + expect(await inquiryLines()).toHaveLength(1); + // Both are told the number of the one inquiry there is. + expect(outcome(first).sent).toBe(stored[0]?.inquiry_no); + expect(outcome(second).sent).toBe(stored[0]?.inquiry_no); + }); + + it('answers a form sent again a moment later with the inquiry it already became', async () => { + const cookie = await add('cs-10', '100'); + const first = outcome(await send(cookie)); + + const again = outcome(await send(cookie)); + + expect(again).toMatchObject({ sent: first.sent, inquiry: null }); + expect(await inquiries()).toHaveLength(1); + }); + + it('is a new inquiry once the cart has been filled again', async () => { + const cookie = await add('cs-10', '100'); + const first = outcome(await send(cookie)); + await add('cs-10', '200', cookie); + + const second = outcome(await send(cookie)); + + expect(second.sent).not.toBeNull(); + expect(second.sent).not.toBe(first.sent); + expect(await inquiries()).toHaveLength(2); + }); + }); + + describe('the limit for one visitor', () => { + async function sendFromNewCart(visitor: string): Promise { + const cookie = await add('cs-10', '100'); + return send(cookie, BUYER, { visitor }); + } + + it(`lets one visitor send ${MAX_INQUIRIES_PER_HOUR} in an hour and refuses the next, leaving its cart`, async () => { + for (let sent = 0; sent < MAX_INQUIRIES_PER_HOUR; sent += 1) { + expect(outcome(await sendFromNewCart(VISITOR)).sent).not.toBeNull(); + } + + const cookie = await add('cs-10', '100'); + const over = await send(cookie, BUYER, { visitor: VISITOR }); + + expect(outcome(over)).toMatchObject({ + status: 303, + sent: null, + inquiry: 'too_many', + }); + expect(await inquiries()).toHaveLength(MAX_INQUIRIES_PER_HOUR); + expect(await cartLines()).toEqual([ + { variant_id: 'cs-10', quantity: 100 }, + ]); + + // Another visitor is not held to it. + expect(outcome(await sendFromNewCart(OTHER_VISITOR)).sent).not.toBeNull(); + }); + + it('counts the last hour only', async () => { + for (let sent = 0; sent < MAX_INQUIRIES_PER_HOUR; sent += 1) { + await sendFromNewCart(VISITOR); + } + await db() + .prepare('UPDATE p_shop_inquiry SET created_at = ?') + .bind(new Date(Date.now() - 61 * 60 * 1000).toISOString()) + .run(); + + expect(outcome(await sendFromNewCart(VISITOR)).sent).not.toBeNull(); + }); + + it('holds when two carts of one visitor are sent at the same moment', async () => { + for (let sent = 0; sent < MAX_INQUIRIES_PER_HOUR - 1; sent += 1) { + await sendFromNewCart(VISITOR); + } + const one = await add('cs-10', '100'); + const other = await add('cs-10', '200'); + + const answers = await Promise.all([ + send(one, BUYER, { visitor: VISITOR }), + send(other, BUYER, { visitor: VISITOR }), + ]); + + // Both read "one short of the limit". The write asks again. + expect(await inquiries()).toHaveLength(MAX_INQUIRIES_PER_HOUR); + expect( + answers.map((answer) => outcome(answer).inquiry ?? 'sent').sort(), + ).toEqual(['sent', 'too_many']); + // The one that was refused still has its cart. + expect(await cartLines()).toHaveLength(1); + }); + + it('stores a one-way mark of the visitor, never the address', async () => { + await sendFromNewCart(VISITOR); + + const [stored] = await inquiries(); + expect(stored?.ip_hash).toMatch(/^[0-9a-f]{64}$/); + expect(JSON.stringify(stored)).not.toContain(VISITOR); + }); + }); + + describe('the emails it owes', () => { + it('queues no job when nobody is to be told', async () => { + const cookie = await add('cs-10', '100'); + + await send(cookie); + + expect(await queuedJobs()).toEqual([]); + }); + + it('queues one job, naming the inquiry, when the seller is to be told', async () => { + await setShopSettings({ inquiry_recipient: 'sales@seller.example' }); + const cookie = await add('cs-10', '100'); + + await send(cookie); + + const [stored] = await inquiries(); + const jobs = await queuedJobs(); + expect(jobs).toHaveLength(1); + expect(JSON.parse(jobs[0]?.payload ?? '{}')).toMatchObject({ + inquiryId: stored?.id, + }); + // Its payload is an id: nothing a person typed is copied into a queue. + expect(jobs[0]?.payload).not.toContain('Ada'); + }); + + it('queues one when only the buyer is to be told', async () => { + await setShopSettings({ inquiry_acknowledge: true }); + const cookie = await add('cs-10', '100'); + + await send(cookie); + + expect(await queuedJobs()).toHaveLength(1); + }); + + it('queues none for a visitor over the limit', async () => { + await setShopSettings({ inquiry_recipient: 'sales@seller.example' }); + for (let sent = 0; sent < MAX_INQUIRIES_PER_HOUR; sent += 1) { + await send(await add('cs-10', '100')); + } + const before = (await queuedJobs()).length; + + for (let attempt = 0; attempt < 3; attempt += 1) { + await send(await add('cs-10', '100')); + } + + // A job that finds nothing to send still takes one of the five turns + // Mallok gives the whole site each minute. + expect((await queuedJobs()).length).toBe(before); + }); + }); + + describe('reading and exporting', () => { + const now = new Date('2026-10-09T08:00:00.000Z'); + + async function store(over: { + id: string; + name?: string; + message?: string; + at?: Date; + priced?: boolean; + }): Promise { + const cartId = crypto.randomUUID().replace(/-/g, ''); + await db() + .prepare( + `INSERT INTO p_shop_cart (id, created_at, updated_at, expires_at) + VALUES (?, ?, ?, ?)`, + ) + .bind(cartId, now.toISOString(), now.toISOString(), '2999-01-01') + .run(); + await db() + .prepare( + 'INSERT INTO p_shop_cart_line (cart_id, variant_id, quantity) VALUES (?, ?, 1)', + ) + .bind(cartId, 'cs-10') + .run(); + await db().batch( + inquiryStatements(db(), { + id: over.id, + inquiryNo: newInquiryNo(over.at ?? now), + cartId, + form: inquiryFormSchema.parse({ + ...BUYER, + ...(over.name === undefined ? {} : { name: over.name }), + ...(over.message === undefined ? {} : { message: over.message }), + }), + locale: 'en', + currency: 'USD', + country: 'GB', + ipHash: null, + lines: [ + { + variantId: 'cs-10', + sku: 'CS-10', + name: 'Cap screw, "M5"', + quantity: 300, + unitPriceMinor: over.priced === false ? null : 185, + }, + { + variantId: 'wa-5', + sku: 'WA-5', + name: 'Washer M5', + quantity: 50, + unitPriceMinor: null, + }, + ], + now: over.at ?? now, + }), + ); + } + + it('reads an inquiry with its lines in one round trip', async () => { + await store({ id: 'one' }); + + let detail: Awaited> = null; + const calls = await countD1Calls(async () => { + detail = await readInquiry(db(), 'one'); + }); + + expect(calls).toBe(1); + expect(detail).toMatchObject({ + id: 'one', + name: 'Ada Lovelace', + country: 'GB', + subtotalMinor: null, + notifiedAt: null, + lines: [ + { sku: 'CS-10', quantity: 300, unitPriceMinor: 185 }, + { sku: 'WA-5', quantity: 50, unitPriceMinor: null }, + ], + }); + expect(await readInquiry(db(), 'nobody')).toBeNull(); + }); + + it('exports the inquiries named, or the latest when none is, newest first', async () => { + await store({ id: 'old', at: new Date('2026-10-01T08:00:00.000Z') }); + await store({ id: 'new', at: new Date('2026-10-08T08:00:00.000Z') }); + + expect((await readInquiries(db(), [], 10)).map((row) => row.id)).toEqual([ + 'new', + 'old', + ]); + expect((await readInquiries(db(), [], 1)).map((row) => row.id)).toEqual([ + 'new', + ]); + expect( + (await readInquiries(db(), ['old', 'nobody'], 10)).map((row) => row.id), + ).toEqual(['old']); + }); + + it('writes one CSV row for each line, with amounts as plain numbers', async () => { + await store({ id: 'one', message: 'Two lines,\nand a "quote".' }); + + const csv = inquiriesCsv(await readInquiries(db(), [], 10)); + const [header, first, ...rest] = csv.replace(/^/, '').split('\r\n'); + + expect(csv.startsWith('')).toBe(true); + expect(header).toBe( + 'inquiry_no,created_at,status,name,company,email,phone,country,locale,currency,sku,product,quantity,unit_price,line_total,message', + ); + // The message keeps its own line break, inside its quotes: a row ends + // at a carriage return and a line feed, and this is a line feed alone. + expect(first).toMatch( + /^RFQ-261009-[0-9A-Z]{8},2026-10-09T08:00:00\.000Z,new,Ada Lovelace,Analytical Engines Ltd,ada@buyer\.example,'\+44 20 7946 0000,GB,en,USD,CS-10,"Cap screw, ""M5""",300,1\.85,555\.00,"Two lines,\nand a ""quote""\."$/, + ); + // A line with no price has no amounts. + expect(rest[0]).toMatch( + /,WA-5,Washer M5,50,,,"Two lines,\nand a ""quote""\."$/, + ); + expect(rest).toHaveLength(2); + expect(rest[1]).toBe(''); + }); + + it('makes text of a cell a spreadsheet would run as a formula', async () => { + await store({ + id: 'one', + name: '=HYPERLINK("https://evil.example","x")', + message: '+1 for speed\n@home', + }); + + const csv = inquiriesCsv(await readInquiries(db(), [], 10)); + + expect(csv).toContain(`"'=HYPERLINK(""https://evil.example"",""x"")"`); + expect(csv).toContain(`"'+1 for speed\n@home"`); + // So is a phone number that begins with a plus sign, the ordinary + // case: left alone, a spreadsheet reads `+1-555-0100` as a sum. + expect(csv).toContain(",'+44 20 7946 0000,"); + }); + }); +}); diff --git a/test/shop/inquiry-admin.test.ts b/test/shop/inquiry-admin.test.ts new file mode 100644 index 0000000..79f0219 --- /dev/null +++ b/test/shop/inquiry-admin.test.ts @@ -0,0 +1,254 @@ +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + inquiryFormSchema, + inquiryStatements, + newInquiryNo, +} from '../../src/plugins/shop/lib/inquiries.js'; +import { api, clearShopTables, db, ensureSite } from './helpers.js'; + +const PANEL = '/_mallok/api/plugins/shop/panels/inquiries'; + +async function store(input: { + readonly id: string; + readonly name: string; + readonly at: string; + readonly email?: string; +}): Promise { + const cartId = crypto.randomUUID().replace(/-/g, ''); + await db().batch([ + db() + .prepare( + `INSERT INTO p_shop_cart (id, created_at, updated_at, expires_at) + VALUES (?, ?, ?, '2999-01-01')`, + ) + .bind(cartId, input.at, input.at), + db() + .prepare( + "INSERT INTO p_shop_cart_line (cart_id, variant_id, quantity) VALUES (?, 'cs-10', 1)", + ) + .bind(cartId), + ]); + await db().batch( + inquiryStatements(db(), { + id: input.id, + inquiryNo: newInquiryNo(new Date(input.at)), + cartId, + form: inquiryFormSchema.parse({ + name: input.name, + email: input.email ?? 'buyer@buyer.example', + company: 'Engines Ltd', + message: 'Delivered prices, please.', + }), + locale: 'en', + currency: 'USD', + country: 'GB', + ipHash: 'f'.repeat(64), + lines: [ + { + variantId: 'cs-10', + sku: 'CS-10', + name: 'Cap screw M5', + quantity: 300, + unitPriceMinor: 185, + }, + { + variantId: 'wa-5', + sku: 'WA-5', + name: 'Washer M5', + quantity: 50, + unitPriceMinor: null, + }, + ], + now: new Date(input.at), + }), + ); +} + +function act(action: string, body: Record): Promise { + return api('POST', `${PANEL}/actions/${action}`, body); +} + +async function statuses(): Promise> { + const { results } = await db() + .prepare('SELECT id, status FROM p_shop_inquiry ORDER BY id') + .all<{ id: string; status: string }>(); + return Object.fromEntries(results.map((row) => [row.id, row.status])); +} + +describe('inquiries in the admin', () => { + beforeAll(ensureSite); + beforeEach(async () => { + await clearShopTables(); + await store({ id: 'a', name: 'Ada', at: '2026-10-07T08:00:00.000Z' }); + await store({ id: 'b', name: 'Grace', at: '2026-10-08T08:00:00.000Z' }); + await store({ id: 'c', name: 'Edsger', at: '2026-10-09T08:00:00.000Z' }); + }); + + it('lists them newest first, with what the panel declares and nothing else', async () => { + const response = await api('GET', PANEL); + const { rows } = (await response.json()) as { + rows: Record[]; + }; + + expect(response.status).toBe(200); + expect(rows.map((row) => row.name)).toEqual(['Edsger', 'Grace', 'Ada']); + expect(rows[0]).toMatchObject({ + id: 'c', + name: 'Edsger', + company: 'Engines Ltd', + email: 'buyer@buyer.example', + country: 'GB', + line_count: 2, + // A sum is stated only when every line has a price. + subtotal: '', + status: 'new', + created_at: '2026-10-09T08:00:00.000Z', + message: 'Delivered prices, please.', + locale: 'en', + currency: 'USD', + }); + // The cart it came from, and the mark of who sent it, stay in the table. + for (const row of rows) { + expect(row).not.toHaveProperty('cart_id'); + expect(row).not.toHaveProperty('ip_hash'); + } + }); + + it('finds one by its number, a name, a company or an address', async () => { + const find = async (q: string): Promise => { + const response = await api('GET', `${PANEL}?q=${encodeURIComponent(q)}`); + return ((await response.json()) as { rows: { id: string }[] }).rows.map( + (row) => row.id, + ); + }; + const number = await db() + .prepare("SELECT inquiry_no FROM p_shop_inquiry WHERE id = 'b'") + .first<{ inquiry_no: string }>(); + + expect(await find(number?.inquiry_no ?? 'none')).toEqual(['b']); + expect(await find('grace')).toEqual(['b']); + expect(await find('Engines')).toHaveLength(3); + expect(await find('buyer.example')).toHaveLength(3); + expect(await find('nobody')).toEqual([]); + }); + + it('shows the lines of one, in the order they were in the cart', async () => { + const response = await api('GET', `${PANEL}/related/lines?parent=b`); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + rows: [ + { + sku: 'CS-10', + name: 'Cap screw M5', + quantity: 300, + unit_price: '$1.85', + line_total: '$555.00', + }, + { + sku: 'WA-5', + name: 'Washer M5', + quantity: 50, + unit_price: '', + line_total: '', + }, + ], + hasMore: false, + }); + }); + + it('marks the ones ticked as answered, as spam, and as new again', async () => { + expect( + (await act('inquiry_mark_answered', { ids: ['a', 'b'] })).status, + ).toBe(200); + expect(await statuses()).toEqual({ + a: 'answered', + b: 'answered', + c: 'new', + }); + + await act('inquiry_mark_spam', { ids: ['c'] }); + await act('inquiry_mark_new', { ids: ['a'] }); + + expect(await statuses()).toEqual({ a: 'new', b: 'answered', c: 'spam' }); + }); + + it('can be filtered by status', async () => { + await act('inquiry_mark_spam', { ids: ['c'] }); + + const response = await api('GET', `${PANEL}?status=spam`); + const { rows } = (await response.json()) as { rows: { id: string }[] }; + + expect(rows.map((row) => row.id)).toEqual(['c']); + }); + + it('exports the ones ticked as a CSV file, and the latest when none is', async () => { + const ticked = await act('inquiry_export_csv', { ids: ['a'] }); + + expect(ticked.status).toBe(200); + expect(ticked.headers.get('content-type')).toBe('text/csv; charset=utf-8'); + expect(ticked.headers.get('content-disposition')).toMatch( + /^attachment; filename="inquiries-\d{4}-\d\d-\d\d\.csv"$/, + ); + const rows = (await ticked.text()).trim().split('\r\n'); + // The heading, and a row for each of its two lines. + expect(rows).toHaveLength(3); + expect(rows[1]).toContain(',Ada,Engines Ltd,'); + + const all = await act('inquiry_export_csv', { ids: [] }); + const names = (await all.text()) + .trim() + .split('\r\n') + .slice(1) + .map((row) => row.split(',')[3]); + expect(names).toEqual(['Edsger', 'Edsger', 'Grace', 'Grace', 'Ada', 'Ada']); + }); + + it('deletes the ones ticked for good, with their lines, once the box is ticked', async () => { + const response = await act('inquiry_delete', { + ids: ['a', 'b'], + params: { confirm: true }, + }); + + expect(response.status).toBe(200); + expect(await statuses()).toEqual({ c: 'new' }); + const lines = await db() + .prepare('SELECT DISTINCT inquiry_id FROM p_shop_inquiry_line') + .all<{ inquiry_id: string }>(); + expect(lines.results).toEqual([{ inquiry_id: 'c' }]); + }); + + it.each([ + ['the box is not ticked', { confirm: false }], + ['nothing is said of it', {}], + ])('deletes nothing when %s', async (_what, params) => { + const response = await act('inquiry_delete', { ids: ['a'], params }); + + expect(response.status).toBe(422); + expect(Object.keys(await statuses())).toEqual(['a', 'b', 'c']); + }); + + it('needs a row to act on, except to export', async () => { + for (const action of [ + 'inquiry_mark_answered', + 'inquiry_mark_new', + 'inquiry_mark_spam', + 'inquiry_delete', + ]) { + const response = await act(action, { + ids: [], + params: { confirm: true }, + }); + expect(response.status, action).toBe(400); + } + expect(await statuses()).toEqual({ a: 'new', b: 'new', c: 'new' }); + }); + + it('refuses a status the table does not know', async () => { + await expect( + db() + .prepare("UPDATE p_shop_inquiry SET status = 'paid' WHERE id = 'a'") + .run(), + ).rejects.toThrow(); + }); +}); diff --git a/test/shop/inquiry-emails.test.ts b/test/shop/inquiry-emails.test.ts new file mode 100644 index 0000000..8dd4857 --- /dev/null +++ b/test/shop/inquiry-emails.test.ts @@ -0,0 +1,519 @@ +import { + createExecutionContext, + createScheduledController, + env, + SELF, + waitOnExecutionContext, +} from 'cloudflare:test'; +import type { EmailMessage, PluginContext } from 'mallok/worker'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + type InquiryDetail, + inquiryFormSchema, + inquiryStatements, +} from '../../src/plugins/shop/lib/inquiries.js'; +import { + inquiryAcknowledgementEmail, + inquiryNotificationEmail, +} from '../../src/plugins/shop/lib/inquiry-email.js'; +import { + inquirySettings, + owesEmail, + sendInquiryEmails, +} from '../../src/plugins/shop/lib/inquiry-jobs.js'; +import worker from '../../src/worker/index.js'; +import { + api, + clearShopTables, + createProduct, + createVariant, + db, + ensureSite, + ORIGIN, + setPrice, +} from './helpers.js'; + +const INQUIRY: InquiryDetail = { + id: 'inq-1', + inquiryNo: 'RFQ-261009-7K3M9QXA', + status: 'new', + name: 'Ada Lovelace', + email: 'ada@buyer.example', + company: 'Engines & Sons', + phone: '+44 20 7946 0000', + message: 'Delivered prices, please.\n', + locale: 'de', + currency: 'EUR', + country: 'GB', + subtotalMinor: null, + notifiedAt: null, + acknowledgedAt: null, + createdAt: '2026-10-09T08:00:00.000Z', + lines: [ + { + variantId: 'cs-10', + sku: 'CS-10', + name: 'Zylinderschraube ', + quantity: 300, + unitPriceMinor: 172, + }, + { + variantId: 'wa-5', + sku: 'WA-5', + name: 'Scheibe M5', + quantity: 50, + unitPriceMinor: null, + }, + ], +}; + +describe('the email that tells the seller', () => { + const mail = inquiryNotificationEmail(INQUIRY, 'Titan & Co'); + + it('names the inquiry and who sent it, and is answered to the buyer', () => { + expect(mail.subject).toBe( + 'Inquiry RFQ-261009-7K3M9QXA from Ada Lovelace, Engines & Sons', + ); + expect(mail.replyTo).toBe('ada@buyer.example'); + }); + + it('says in text everything the buyer sent', () => { + expect(mail.text).toBe( + [ + 'A cart was sent as an inquiry on Titan & Co.', + '', + 'Name: Ada Lovelace', + 'Company: Engines & Sons', + 'Email: ada@buyer.example', + 'Phone: +44 20 7946 0000', + 'Country: GB', + 'Language: de', + 'Currency: EUR', + '', + 'CS-10 Zylinderschraube × 300 €1.72 €516.00', + 'WA-5 Scheibe M5 × 50 on request on request', + '', + 'Subtotal: Not stated: at least one line has no price.', + '', + 'Message:', + 'Delivered prices, please.', + '', + '', + 'Reply to this email to answer the buyer.', + ].join('\n'), + ); + }); + + it('lets nothing a buyer typed become markup', () => { + expect(mail.html).not.toContain('Lovelace'); + expect(mail.html).not.toContain('