diff --git a/CLAUDE.md b/CLAUDE.md index f275f57..4c3bb79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,17 +60,22 @@ Mallok decides the contracts on both sides. Its documentation is the reference: - **One rule says which currency a page or a cart is in** (`settleCurrency` in `lib/currency.ts`): the one asked for when everything has a price in it, else the base currency, else whatever is shared. A page and the cart it leads to must not disagree, so neither has a rule of its own. - **`readCartFacts` (`lib/cart-pricing.ts`) is the one reading of a cart**: every line judged against the database, in one round trip. `priceCart` turns it into an order's lines or its list of problems; the cart page shows it line by line. Do not grow a second calculation beside it. - **A public page is the same for every visitor.** What is in a cart is never in one: the header's link to the cart, and the form a product page offers for a size, are rendered into the cached page and identical for all. The hook tells every page where the cart is (`cart_path`, `cart_action`) and costs no query for it. +- **A cart is sent as one inquiry** — a request for a quote — by `POST /_mallok/p/shop/cart/inquiry` (`routes/inquiry.ts`, `lib/inquiries.ts`; design §18). Like the cart's other form it never renders: it answers 303 to the cart page with the inquiry's number (`?sent=RFQ-…`) or the kind of reason none was made (`?inquiry=invalid&field=email`), and **nothing a person typed travels in an address**. What can be sent is decided by `readCartFacts`, the cart page's own reading: every line in order, or without a price — a part with no price can be asked about though it cannot be ordered, which is why a product page offers a form for one (`addable` in the page's view). +- **The inquiry's write is one batch**: the inquiry, its lines, the job that owes its emails, the cart emptied. The inquiry is written only while the cart is still exactly what is being sent — the same lines and quantities, and no other — and the visitor is under their limit (`MAX_INQUIRIES_PER_HOUR`, exact because it is counted inside the statement; requests with no `ipHash` are counted together), and everything after it is conditional on the inquiry being there — so two forms at once store one inquiry, a cart that changed meanwhile is not sent as it no longer is (`cart_changed`), and a refused one leaves the cart as it was. A request that wrote nothing reads why from the tables (`whyNotStored`), in an order that matters: the limit when the cart is unchanged, then a changed cart, then the same request arriving twice. The limit is also asked before the write, only so that a visitor over it queues no job: Mallok runs five plugin jobs a minute for the whole site. +- **An inquiry's emails are a job** (`lib/inquiry-jobs.ts`), queued with `ctx.enqueueStatement` in that batch and given an id and nothing else. It may run twice: each email is marked on the inquiry once it has been handed to Mallok (`notified_at`, `acknowledged_at`), and only what is not marked is sent. The seller's is in English and answered to the buyer; the buyer's, in their language, is sent only when `inquiry_acknowledge` is on — it is off by default because the form has no challenge in front of it. This is the one place the plugin uses Mallok's jobs; the order code still writes its own outbox, which nothing drains. +- **Inquiries are read in the admin** in a `table` panel with their lines as a related table, and marked, exported and deleted by the actions in `lib/inquiry-admin.ts`. An amount is stored twice, as minor units and as the text the buyer saw, because a table panel lists a column as it is stored. `cart_id` and `ip_hash` are in the table and not in the panel. +- **An inquiry is the first personal data the shop holds.** It goes in no log and no address, and the plugin's own job carries an id only. Every value goes through `escapeHtml` on its way into an email; a name, a company and a phone number are made one line each (white space collapses; it is not refused, because a refused form comes back empty); an address is held to an allow-list (`EMAIL_PATTERN_SOURCE`), because the admin makes a `mailto:` link of it; a cell of the export that a spreadsheet would run as a formula is made text, and so is every piece a semicolon would make a cell of (`inert`). **What the plugin cannot keep to itself is the email**: `ctx.sendEmail` stores the whole message in Mallok's `job` table, where deleting the inquiry does not reach it. `SECURITY.md` says so; do not claim otherwise. - **Orders and payment are built but not reachable** (design §14). `lib/orders.ts` places an order and confirms its payment, `lib/order-fulfilment.ts` ships, cancels and refunds, `lib/outbox.ts` records what each change still owes, `lib/stripe-webhook.ts` decides what a Stripe delivery means and which status to answer, `lib/stripe-signature.ts` and `lib/stripe-client.ts` talk to Stripe over `fetch`, `lib/order-email.ts` builds the buyer's emails. No route, page or admin screen calls them, and nothing drains the outbox: those wait for Mallok. Do not add a route that works around that. - **A change to an order is conditional on the status it was read in** (`ORDER_STILL_IN_STATUS` in `lib/order-guard.ts`), and the statement that changes the status comes last in its batch. That is what makes a racing second call write nothing. - **A change to an order writes its outbox row in the same batch** (`orderChangedOutbox`). What follows the change — an email, a purge — is owed from that row. Never send from a function's return value: it is lost whenever the Worker stops after the batch. Rows are read back by `seq`, the order they were committed in, never by `created_at`, which is whatever time the caller passed. -- Pure logic (`money`, `pricing`, `ecb`, `order-state`, `currency`, `availability`, `storefront`, `stripe-signature`) takes no database; DB functions take a `D1Database` and, where time matters, a `now`. +- Pure logic (`money`, `pricing`, `ecb`, `order-state`, `currency`, `availability`, `storefront`, `reference`, `inquiry-email`, `stripe-signature`) takes no database; DB functions take a `D1Database` and, where time matters, a `now`. ### The theme and content - 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 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. +- **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. Below the cart it draws the form that sends it as a request for a quote, when `plugin_page.can_inquire` says the cart can go as it stands, and above it what became of one that was sent (`plugin_page.sent`, `plugin_page.inquiry_problem`). The form's words are the pack's `quote_*` keys — not `inquiry_*`, which are Mallok's inquiry form's and must stay out of the default pack. Each field carries the plugin's own rule as `required`, `maxlength` and `pattern` — the address's and the name's are the very strings the server compiles (`EMAIL_PATTERN_SOURCE`, `NAME_PATTERN_SOURCE`) — and `test/theme/cart.test.ts` holds those attributes to the constants in `lib/inquiries.ts`: a refused form comes back empty, so a browser has to refuse what the server would, and the server has to take what a browser lets through (a tab in a name, a line break counted once). The field named `website` is one no person may see or reach. - **`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[]`. @@ -86,7 +91,7 @@ Mallok decides the contracts on both sides. Its documentation is the reference: `mallok@0.1.0-rc.11` allows each of these; the code here does not use it yet. -- A cart leads nowhere yet: it cannot be sent as one inquiry, and there is no checkout. The cart page ends on a link to the quote page. +- There is no checkout: a cart can be sent as an inquiry and cannot be paid for. - No raw-body route, so no Stripe webhook, and so no checkout. - The sample's products name one collection each, though `reference[]` is resolved now. @@ -97,6 +102,9 @@ When one is built, take it off this list and prove the behaviour with a test or - `renderData` is told the items a list or the home page shows, and nothing of what a content page lists through a reference. So the products on a collection's page, and a product's neighbours, have no prices; `test/theme/prices.test.ts` holds that, and fails the day Mallok passes them. - A `remove` handler of a records panel can only throw, which the admin shows as a failure with no reason; so deleting a variant that is on an order archives it instead of refusing. A panel's own order is always descending, so the variants list opens on the last one changed, not in the order a page shows them. - `renderData` does not run for a not-found page, so the header there has no link to the cart. +- A challenge cannot be drawn on a plugin's page: a theme may load no script from another host and `afterRender` does not run there, so the inquiry form has no Turnstile in front of it though Mallok could verify a token. What stands in its place is in `SECURITY.md`; do not add a script to get round it. +- `ctx.enqueueStatement` takes no condition, so a job is queued beside a conditional write even when the write stored nothing. A job has to find nothing to do in that case. +- A page cannot be rendered from a `POST` without being listed under the `POST`'s address, so a form the server refuses comes back with its fields empty. - Nothing tells a plugin of a deletion it missed, and nothing here sweeps up afterwards: a variant with no product may as well be one that was loaded before its product was published, and a sweep cannot tell the two apart. - Switching a plugin on or off, or changing a setting, can leave cached pages as they were — and the admin's "Clear cached pages" can report success without clearing anything. So the plugins are switched on before the first publish (`scripts/lib/local-shop.mjs`, the README), and `test/shop/helpers.ts` deletes the cached home page by hand after changing settings. Keep both until a Mallok release says the purge can be trusted. @@ -105,10 +113,11 @@ When one is built, take it off this list and prove the behaviour with a test or - Tests in `test/shop` and `test/theme` run inside workerd against the site's own Worker. `test/shop/helpers.ts` brings a site up through Mallok's HTTP API (first request → admin → token → settings → plugin enabled); tables are created by Mallok's migrator, never by hand. Create content with `createContent`, not by inserting rows. - Mallok caches pages in `caches.default`. In a test, create all content before requesting any page. - A test for a fix must be seen failing without the fix. For new guards, break the guard and confirm the test goes red. -- A race is tested by running the calls with `Promise.all`, and such a test only counts once breaking the guard turns it red: that is the proof the two calls really interleave. +- A race is tested by running the calls with `Promise.all`, and such a test only counts once breaking the guard turns it red **every time**: that is the proof the two calls really interleave. Through `SELF.fetch` they do so about half the time, and where a request reads before it writes, the other half never reaches the guard. `holdWrites` in `test/shop/helpers.ts` holds each request at its write until the test has seen them all arrive; the inquiry's races use it. It waits by polling a flag: a promise settled by one request for another stops the Worker. - `countD1Calls` in `test/shop/helpers.ts` counts round trips; use it wherever the number is a design constraint. `interceptBatches` runs a hook around each batch: it is how a test changes the data between a function's reading and its writing, or loses the answer to a write that committed. - `test/theme/pages.test.ts` renders every layout from content it creates itself, with everything set; `test/theme/bare.test.ts` does the same for a site that has filled in almost nothing, and fails on any empty element or `href=""`. Neither reads `content/`. A template that prints a wrapper has to check that there is something to put in it — and `content.html` is not a string, so capture it before comparing it with `blank`. The sample is checked by `test/content.test.ts` and `test/project.test.ts` (files only, no Worker) and by the smoke run. - `npm run smoke:shop` is the only place theme, plugin, content and the Mallok CLI run together; run it when touching any of them. It publishes the real sample, requests every kind of page, and follows every header and footer link in all four languages. +- Mallok's scheduled handler can be run in a test (`worker.scheduled` with `createScheduledController`, as `test/shop/inquiry-emails.test.ts` does): it is how a job is seen to run from Mallok's own queue. It logs one `uncaught exception … internal error` line a tick in workerd, which is Mallok's and has not been diagnosed; the tick does what it should. - `scripts/lib/local-shop.mjs` is the one place that brings a local shop up — administrator, plugins, settings, content, variants, in that order and before any page is requested. The smoke run and `npm run preview` both use it; do not grow a second copy. Locally nothing purges the page cache (`s-maxage=3600`, persisted under the state directory), so a page requested before the set-up finished stays as it was. ## Commerce invariants (do not "simplify" them away) @@ -132,6 +141,8 @@ When one is built, take it off this list and prove the behaviour with a test or - An order line is a snapshot of SKU, name and unit price. Nothing that later happens to the product may change a past order. - A `manual` price is never overwritten; the base price is never rewritten; an `auto` price moves only past the drift threshold — or when the base price it is derived from is changed in the admin. Saving a variant for any other reason leaves its derived prices as they are. - Stock set in the admin is a figure, and the ledger records the difference from the stock as it is when the write lands, in the same batch — not from what the form was opened with. A save that names no figure writes no stock at all. +- An inquiry charges nothing and takes no stock. Its lines are a snapshot, as an order's are, and it is stored once for a cart however often the form arrives: the parallel tests in `test/shop/inquiries.test.ts` hold that, and the limit per visitor, and are not to be made sequential. +- What an inquiry owes — its emails — is queued in the batch that stores it. Never send from the route. - A variant that has ever been ordered is never deleted, by the admin or with its product: an order's lines and the ledger name it. It is archived. - Order status changes only through `lib/order-state.ts`. - Every external input passes Zod; every SQL value is bound; redirects go only to same-site paths; logs carry no personal data. diff --git a/README.md b/README.md index 3e078a1..1fb3399 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![CI](https://github.com/fobstack/Nundar/actions/workflows/ci.yml/badge.svg)](https://github.com/fobstack/Nundar/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)](LICENSE) -> **Status: in development.** Nundar is being rebuilt on Mallok. The catalogue side works today. Prices and a cart are on the pages; checkout is not there yet — see [What works today](#what-works-today). It is not ready to run a real shop. +> **Status: in development.** Nundar is being rebuilt on Mallok. The catalogue side works today. Prices and a cart are on the pages, and a cart can be sent as a request for a quote; checkout is not there yet — see [What works today](#what-works-today). It is not ready to run a real shop. --- @@ -42,7 +42,7 @@ Nundar is not a second application beside Mallok. A shop is a Mallok site with t | Part | Where | What it owns | |---|---|---| -| **Shop plugin** | `src/plugins/shop/` | Variants, prices per currency, stock, minimum order quantities, the cart, exchange-rate repricing | +| **Shop plugin** | `src/plugins/shop/` | Variants, prices per currency, stock, minimum order quantities, the cart, the cart sent as an inquiry, exchange-rate repricing | | **Commerce theme** | `src/theme/` | How the home page, the specification finder and the product, application, collection, case study, question and reference pages look, in four languages | | **Sample content** | `content/`, `seed/`, `site.json` | A titanium fastener catalogue in four languages: six products in five collections, five industry pages, four case studies, questions and answers, reference pages, and the site's own copy | @@ -59,7 +59,8 @@ Nundar builds on `mallok@0.1.0-rc.11`, the first release with the plugin API the | **Pages** | A home page with a specification finder; product pages with their sizes and SKUs, and for each size its price, minimum order, availability and lead time, read from the shop while the page is rendered and cached with it; the same offers in the page's `Product` structured data; a starting price under each product in the finder and the catalogue; collection, industry, case study, question, engineering reference and contact pages — all in English, German, French and Spanish, with `hreflang`, canonicals, sitemap and FAQ structured data; self-hosted fonts; prices in US dollars on English pages and in euros on the others, with a switch to any currency the shop prices in; no client JavaScript except three small scripts, each added to a page that is complete without it: filters for the finder, the currency switch, and calculators on the engineering reference page | Prices beside the products a collection page lists (Mallok does not yet tell a plugin which products a content page shows) | | **Catalogue data** | Variants, prices as integer minor units, stock, MOQ, lead time, a made-to-order policy — edited in the admin, in a form under each product's editor; every change of stock goes into a ledger; a deleted product takes its variants with it | | | **Pricing** | USD base price; EUR and GBP derived from ECB rates with a buffer, rounding to a price point and a drift threshold; manual prices never overwritten; the pages of a product are purged from the cache when one of its prices moves | | -| **Cart** | A form beside each size on a product page, and a cart page in the site's own design and language: set a quantity, remove a line, choose a currency. No script anywhere in it. MOQ and stock are enforced by the server, which says what it refused on the cart page | Submitting a cart as one inquiry; checkout | +| **Cart** | A form beside each size on a product page, and a cart page in the site's own design and language: set a quantity, remove a line, choose a currency. No script anywhere in it. MOQ, stock and the most a line may hold (500) are enforced by the server, which says what it refused on the cart page | Checkout | +| **Inquiries** | The cart sent as one request for a quote, from a form on the cart page: who to answer, and a message. A size with no price can go in the cart to be asked about. The buyer lands on the inquiry's number; the seller is told by email, answerable straight to the buyer, and finds every inquiry in the admin with its lines — to mark, export as CSV, or delete | A challenge in front of the form (it waits for Mallok), which is why the email confirming to the buyer is off unless switched on | | **Orders and payment** | The logic, tested and not yet reachable: orders with line snapshots, a payment that takes stock exactly once however often Stripe reports it, oversold orders, refunds that return stock, Stripe signature checks, order emails in four languages | The checkout and order pages, the webhook route, and order handling in the admin | The reasoning and the plan are in [`docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md`](docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md). @@ -70,7 +71,9 @@ The reasoning and the plan are in [`docs/superpowers/specs/2026-09-30-nundar-on- - A page says whether a size can be had, never how many are left: a count would be wrong after the next sale, a state rarely is. - The structured data offers exactly the prices the page prints. A test compares the two digit for digit. - The cart stores variants and quantities only — never a price. -- MOQ is enforced by the form *and* by the server, because a form can be bypassed. +- MOQ, and the most a cart line may hold, are enforced by the form *and* by the server, because a form can be bypassed. +- An inquiry is a snapshot of the cart it was sent from, stored once however often the form arrives: tests send the same cart twice at the same moment. +- Nothing a buyer typed comes back through an address, becomes markup in an email, or runs as a formula in an exported spreadsheet. - A public page is the same for every visitor and stays in the cache: nothing about a cart is in it, and the cart's cookie is sent only to the shop's own routes. - Stock carries a database constraint, so a payment's decrement cannot go negative: a test proves the whole D1 batch rolls back. - Stock comes off when a payment is confirmed, never before, and once: tests deliver the same payment twice at the same moment. @@ -145,7 +148,7 @@ The sample is a supplier of titanium fasteners that does not exist. Its products ## Deploying -Not yet. A deployed shop today would show a catalogue with prices and a cart that leads nowhere: there is no checkout, and a cart cannot be sent as an inquiry. Nothing here has been measured on a real Cloudflare account either. When Nundar is ready, deployment is Mallok's own: `npx mallok create . --slug `, which creates the Worker, the D1 database and the R2 bucket on your own Cloudflare account. That path has not been run for this repository. +Not yet. A deployed shop today would show a catalogue with prices and a cart that can be sent as a request for a quote and cannot be paid for: there is no checkout. Nothing here has been measured on a real Cloudflare account either. When Nundar is ready, deployment is Mallok's own: `npx mallok create . --slug `, which creates the Worker, the D1 database and the R2 bucket on your own Cloudflare account. That path has not been run for this repository. ## Languages and currencies diff --git a/README.zh-CN.md b/README.zh-CN.md index f55bf09..9a32b69 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,7 +4,7 @@ **[Mallok](https://github.com/fobstack/mallok) 的商城插件和商城主题。** 为想靠买家真实搜索词获得流量的跨境卖家而做,不去和所有人争抢同一个大词。 -> **状态:开发中。** Nundar 正在 Mallok 上重建。页面上已有价格和购物车;结账还没有,见[现在能用什么](#现在能用什么)。目前还不能用来经营真实店铺。 +> **状态:开发中。** Nundar 正在 Mallok 上重建。页面上已有价格和购物车,购物车可以作为询价单发出;结账还没有,见[现在能用什么](#现在能用什么)。目前还不能用来经营真实店铺。 --- @@ -41,7 +41,7 @@ Nundar 不是 Mallok 旁边的第二个应用。一个商城就是一个加了 | 部分 | 位置 | 负责什么 | |---|---|---| -| **商城插件** | `src/plugins/shop/` | 规格、各币种价格、库存、起订量、购物车、按汇率重新定价 | +| **商城插件** | `src/plugins/shop/` | 规格、各币种价格、库存、起订量、购物车、把购物车作为询盘发出、按汇率重新定价 | | **商城主题** | `src/theme/` | 首页、规格查找表,以及商品页、应用场景页、聚合页、案例页、问答页和资料页的外观,四种语言 | | **示例内容** | `content/`、`seed/`、`site.json` | 一套四种语言的钛紧固件目录:五个聚合页下的六个商品、五个行业页、四个案例、问答、资料页,以及站点自己的文案 | @@ -58,7 +58,8 @@ Nundar 基于 `mallok@0.1.0-rc.11`,这是第一个带有商城其余部分所 | **页面** | 带规格查找表的首页;列出尺寸和 SKU 的商品页,每个尺寸带价格、起订量、库存状态和交期(渲染页面时从商城读出,随页面一起缓存);同样的报价写进页面的 `Product` 结构化数据;规格查找表和目录里每个商品名下显示起价;聚合页、行业页、案例页、问答页、工程资料页和联系页——全部有英、德、法、西四种语言,带 `hreflang`、canonical、sitemap 和 FAQ 结构化数据;字体由站点自己提供;英文页面以美元标价,其余语言以欧元标价,并可切换到商城支持的任一币种;除了三个小脚本之外没有客户端 JavaScript,而且页面没有它们也是完整的:给规格查找表加筛选、币种切换、工程资料页上的计算器 | 聚合页所列商品旁的价格(Mallok 目前不会告诉插件一个内容页列出了哪些商品) | | **目录数据** | 规格、以整数最小单位存储的价格、库存、起订量、交期、按单生产策略——在后台每个商品编辑器下方的表单里编辑;库存的每次变动都记入流水;删除商品时一并清理它的规格 | | | **定价** | 美元基准价;欧元和英镑按 ECB 汇率换算,带缓冲、价位取整和漂移阈值;手动价格永不被覆盖;某个商品的价格变动时,清除该商品页面的缓存 | | -| **购物车** | 商品页每个尺寸旁有一个表单;购物车页沿用站点自己的设计和语言,可改数量、移除、选币种,全程不需要脚本。起订量和库存由服务端校验,被拒绝的原因直接显示在购物车页上 | 把购物车作为一次询盘提交;结账 | +| **购物车** | 商品页每个尺寸旁有一个表单;购物车页沿用站点自己的设计和语言,可改数量、移除、选币种,全程不需要脚本。起订量、库存和单行数量上限(500)由服务端校验,被拒绝的原因直接显示在购物车页上 | 结账 | +| **询盘** | 在购物车页填一张表单(回复给谁、留言),把整个购物车作为一张询价单发出。没有价格的尺寸也可以加入购物车来询价。买家提交后看到询盘编号;卖家收到一封邮件,直接回复即可回到买家,并在后台看到每张询盘及其明细,可以标记、导出 CSV 或删除 | 表单前面的人机验证(等 Mallok 提供),因此给买家的确认邮件默认关闭,需要手动开启 | | **订单与付款** | 逻辑已写好并有测试,但还没有入口可以用到:带行快照的订单、无论 Stripe 通知多少次都只扣一次库存的付款、超卖订单、退还库存的退款、Stripe 签名校验、四种语言的订单邮件 | 结账页和订单页、webhook 路由、后台的订单处理 | 原因和计划见 [`docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md`](docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md)。 @@ -69,7 +70,9 @@ Nundar 基于 `mallok@0.1.0-rc.11`,这是第一个带有商城其余部分所 - 页面只说一个尺寸能不能买到,不说还剩多少:数量在下一笔成交后就错了,状态很少变。 - 结构化数据里的报价与页面上印出的价格完全一致,有测试逐位比对。 - 购物车只存规格和数量,绝不存价格。 -- 起订量由表单校验,**并且**由服务端再校验一次,因为表单可以被绕过。 +- 起订量和单行数量上限由表单校验,**并且**由服务端再校验一次,因为表单可以被绕过。 +- 询盘是发出时购物车的快照,无论表单到达多少次都只存一张:测试会让同一个购物车在同一时刻提交两次。 +- 买家填写的内容不会经由网址带回页面,不会在邮件里变成标记,也不会在导出的表格里被当作公式执行。 - 公开页面对每个访客都一样,可以一直留在缓存里:页面上没有任何与购物车有关的内容,购物车的 cookie 只发给商城自己的路由。 - 库存带数据库约束,付款时的扣减不可能变成负数:有测试证明整个 D1 batch 会回滚。 - 库存只在付款确认后扣减,绝不提前,而且只扣一次:有测试让同一笔付款在同一时刻到达两次。 @@ -144,7 +147,7 @@ npm run seed:local ## 部署 -暂时不要部署。现在部署出来的商城有带价格的目录和购物车,但购物车没有下一步:还不能结账,也不能把购物车作为询盘提交;并且这里的一切都还没有在真实的 Cloudflare 账号上实测过。等 Nundar 就绪后,部署走 Mallok 自己的流程:`npx mallok create . --slug `,它会在你自己的 Cloudflare 账号上创建 Worker、D1 数据库和 R2 存储桶。这条路径还没有在本仓库上跑过。 +暂时不要部署。现在部署出来的商城有带价格的目录和购物车,购物车可以作为询价单发出,但还不能付款:没有结账;并且这里的一切都还没有在真实的 Cloudflare 账号上实测过。等 Nundar 就绪后,部署走 Mallok 自己的流程:`npx mallok create . --slug `,它会在你自己的 Cloudflare 账号上创建 Worker、D1 数据库和 R2 存储桶。这条路径还没有在本仓库上跑过。 ## 语言和币种 diff --git a/SECURITY.md b/SECURITY.md index 9c621e8..e3638a9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -44,6 +44,8 @@ security document describes each. Nundar implements none of them again. | Card numbers, CVV | **Nowhere in Nundar** | Payment goes through Stripe's hosted checkout, so card data never reaches this code. A test lists the columns an order may have. No checkout route exists yet. | | Buyer email and shipping address | D1, on the order | Written when an order is placed, which nothing can do yet: the order logic is built and has no route. Never written to a log. | | Stripe keys and the webhook signing secret | Not stored yet | They will be plugin secrets, which Mallok encrypts. No code path reads them today. | +| What a buyer says of themselves in an inquiry: name, email address, company, phone, message | D1, on the inquiry | Written when a cart is sent as a request for a quote. It is the first personal data the shop holds. Read in the admin by a signed-in administrator, or a token with `export`; deleted there. Never written to a log or put in an address, and the job the plugin queues carries an id only. **The email is another matter**: once handed to Mallok it sits, whole, in Mallok's own queue — see the residual risks. | +| A mark of the visitor who sent an inquiry | D1, on the inquiry | Mallok's one-way hash of the connecting address, which is what the limit per visitor counts by. The address itself is never stored, and the admin's list does not show the mark. | | Cart contents | D1, keyed by an unguessable 128-bit id | Variant ids and quantities only — **never prices**. A test asserts the table has no price column. | | The cart cookie | `HttpOnly`, `SameSite=Lax`, `Secure` on HTTPS, scoped to `/_mallok/p/shop` | It identifies a cart and nothing else. It is not a session and grants nothing in the admin. | | Prices, stock, variants | D1 | Changed only through the admin's variant form — which needs a signed-in administrator, or a token with `content:write` — or the plugin's own scheduled repricing. Every change of stock is a row in a ledger. | @@ -120,6 +122,70 @@ These are deliberate and should not be "simplified" away: at write time, so a price set by hand between the read and the write still stands. +### Inquiries + +A cart can be sent as a request for a quote, from a form on the cart page. +That form is public, stores a row and sends an email, and — see the residual +risks — has no challenge in front of it. What is in its place: + +- **Mallok refuses the form when another site caused it**, and limits it at + its strict tier, with a count of its own per visitor. +- **A visitor may send five inquiries in an hour, exactly.** The count is + taken in the plugin's own table inside the write, so two sent at the same + moment cannot both slip under it. It is asked once before the write too, + so that a visitor over it queues no work. Requests that reach the Worker + with no connecting address, which Mallok can give no mark, are counted + together as one visitor, as Mallok's own rate limit counts them: tighter + than meant, never unlimited. +- **An inquiry needs a cart.** The form is a second request, carrying the + cookie of a first that put a real part, in a quantity the shop accepts, + into a cart. A request that has neither stores nothing. +- **A field no person sees.** Whatever fills it in is answered as if all + were well, and nothing is stored. +- **A cart is stored as one inquiry however often the form arrives, and as + exactly what is in it.** The write happens only while the cart is still + what was read — the same lines, the same quantities, no other — and + empties it in the same batch. A second request finds the first one's + inquiry and is told its number; a cart that changed meanwhile is not sent, + and nothing put in it is lost. +- **Nothing a person typed travels in an address.** The answer to the form + is a redirect that carries the inquiry's number, or the kind of reason + none was made and the name of a field — so a refused form comes back + empty. The fields carry the server's own rules as `required`, `maxlength` + and `pattern`, so that a browser refuses what the server would. +- **The page that confirms an inquiry shows it only to the browser holding + the cart it was sent from.** A number alone, in a link somebody passed on + or made up, shows nothing. +- **A name, a company and a phone number are one line each.** White space of + any kind in one, a line break included, becomes a single space before it + is stored: such a value ends up in the subject of an email. Any other + control character is refused. +- **An address is made only of what addresses are made of**: letters and + digits of any script, and `.`, `_`, `+`, `-` and `'`. The admin makes a + `mailto:` link of an address, and the seller's email is answered to it; a + `?`, a `%` or a `,` in one would add a recipient or a subject of the + buyer's choosing. The rare real address with another sign in it is refused + with them. +- **Emails escape everything a person typed**, and the one to the seller is + answered to the buyer's address, not sent from it. +- **The email that confirms to the buyer is off unless switched on**, and + repeats nothing the buyer typed about themselves: it goes to an address + nobody has shown to be theirs. +- **An exported cell cannot be a formula.** A value beginning with `=`, `+`, + `-` or `@` is a formula to a spreadsheet; the export makes it text. So + is every piece of a value that a spreadsheet would make a cell of: where + the list separator is a semicolon, a row is split on `;` whatever the + quotes say. +- **Deleting inquiries asks for a box to be ticked**, and is refused without + it. It removes the inquiry and its lines from the shop's tables for good. + It does not reach an email already handed to Mallok — see the residual + risks. +- **The job that sends the emails is given an id and nothing else.** It reads + the inquiry when it runs, and marks each email on the inquiry once Mallok + has taken it, so that a second run sends only what is not marked. That is + at least once, and nearly always once: a Worker stopped between the two + sends that email again. The mark means handed to Mallok, not delivered. + ### Payment, built and not yet reachable The order and payment logic is in the repository with its tests, and no route @@ -193,6 +259,38 @@ Stated plainly rather than left for an auditor to find: the figure, though not from the ledger, which records the difference from the stock as it was when the write landed. A save that names no figure writes no stock: the field comes to the form empty for that reason. +- **The inquiry form has no challenge in front of it.** Mallok can verify a + Turnstile token for a plugin's route, and has nowhere to draw the widget on + a plugin's page: a theme may load no script from another host, and the + hook that injects one does not run there. The controls listed under + Inquiries stop one visitor and a simple script. They do not stop many + addresses working together, which can fill the inquiry table and the + seller's inbox, and use up what the site's email provider allows. It is + written up for Mallok; until it is there, watch the inquiries, mark spam + and delete it. +- **With the buyer's confirmation switched on, the form sends an email to + whatever address is typed into it.** That is the reason it is off by + default. Switched on without a challenge, the shop can be used to send its + confirmation to people who asked for nothing. +- **An inquiry keeps what a buyer typed until someone deletes it.** There is + no retention period. An administrator deletes inquiries in the admin; a + CSV that was exported, and an email already sent to the seller, are copies + the shop no longer controls. +- **Deleting an inquiry does not delete its email from Mallok's queue.** To + send an email a plugin hands the whole message to Mallok, which keeps it — + the buyer's name, address, phone number and message — as a row of its own + `job` table: for seven days after it is delivered, and for good if it + never is, which is what happens on a site with no email key. A plugin may + not write Mallok's tables, so nothing here can remove it. **To erase what + a person typed, delete the inquiry and then the rows of `job` whose + payload names them, in the database.** It is written up for Mallok. +- **A form the server refuses comes back empty.** The fields carry the + server's rules, and the server takes what a browser lets through, so a + person should not meet that for what they typed; the cart changing, or + the visitor's limit being reached, while the form is being filled in, is + how they can. Whether a browser's autofill ever fills the field no person + sees — which would drop a real request without a word — has not been + tried. - **Sample content and sample variants are public test data.** Do not load `seed/shop-sample.sql` into a production database. The sample catalogue describes a supplier that does not exist: its certifications, test figures 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 61f55bc..b90c30b 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 @@ -378,6 +378,9 @@ Decided later: - 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. +10. **The inquiry cart (2026-10-09).** + - Decision: the shop plugin builds it — a table of its own, a form on the cart page, an email to the seller (§17, left open, item 1; built in §18). + - Mallok's inquiry plugin stays what it is: the form on a content page. The two are separate stores, read in separate panels of the admin. ## 12. What phase 1A changed or found @@ -594,7 +597,7 @@ The quantity field starts at the minimum order and steps by it, as 2026-09-03 § | `Offer` JSON-LD equals the rendered price | Met: compared digit for digit, in both currencies a page can be in | | A price change purges exactly the affected pages | Met as far as a test without a zone can see: the tags asked for are exactly the products whose prices moved, and the pages carry those tags. **Whether Cloudflare then evicts them is not verified** — it needs a deployed site, and Mallok's own check on one found purges failing for a reason not yet known (§16) | | Cold product render p50/p95 CPU and D1 round trips on a real Free account | **Not met.** Round trips are counted locally: three. CPU has not been measured by anyone | -| The cart submitted as one inquiry | **Not built.** See below | +| The cart submitted as one inquiry | Met since 2026-10-09 (§18): the smoke run fills a cart, sends it through the page's own form, and finds it in the admin with its lines | **How it was verified.** 52 file tests and 633 tests inside workerd, 184 of the latter new. 209 deliberate breakages of the new code — the hook, the views, the routes, the handlers, every template and all three scripts — of which 202 turned a test red, five change nothing a caller can observe, and two showed checks that could never fire, which were removed. The smoke run walks the whole path on a real local Worker: the sample published, a product page's form posted as it stands, a refused quantity followed to the page that explains it, the cart page read in English and in German, a variant added and removed the way the admin's form does. In a browser: a buyer's path clicked through by hand, the admin's form opened and saved, and every page of the sample measured at thirteen widths from 320 to 1920 px, the cart pages with three lines in them. @@ -620,7 +623,7 @@ Along the way the review's reading of the cart turned up a column: Mallok's `con **Left open.** -1. *The inquiry cart.* Phase 1 ends with "submitting the cart as one inquiry, which is Mallok 0.2's inquiry cart" (§9). Whether that is the shop plugin's to build — a table of its own, a form on the cart page, an email to the seller — or Mallok's inquiry plugin's, which the shop would hand a cart to, is the owner's decision; building it here would be a second inquiry store beside Mallok's. Until then the cart page ends on the link to the quote page. +1. *The inquiry cart.* Decided on 2026-10-09: the shop plugin's (§11, decision 10), and built (§18). 2. *Measurements on a real account*, and whether purges evict (the table above). 3. *The rounding rule and unit prices.* A derived price is rounded up to the next `.99` or whole unit (2026-09-03 §4.4). For a part priced at two dollars that turns $1.60, $1.85 and $2.05 alike into €1.99. The rule is the owner's and is unchanged; a catalogue of small parts wants a third strategy that rounds to the cent, or hand-entered prices, which is what the sample uses. 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. @@ -630,3 +633,66 @@ Along the way the review's reading of the cart turned up a column: Mallok's `con 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. + +## 18. What the inquiry cart changed or found (2026-10-09) + +Phase 1's last piece (§9): the cart, submitted as one inquiry. The owner decided it is the shop plugin's to build (§11, decision 10). It is built on `mallok@0.1.0-rc.11` as it shipped, with nothing worked around; what Mallok lacks for it is written up and listed at the end. + +**What a buyer does.** Fills a cart, and on the cart page says who to answer — a name and an email address, and if they like a company, a phone number and a message — and sends it. They land on the cart page again, now empty, with the inquiry's number (`RFQ-261009-7K3M9QXA`) to quote. Nothing is charged and no stock is taken. + +**What a seller gets.** An email naming the inquiry and who sent it, with every line, answerable straight to the buyer; and a panel in the admin that lists every inquiry with its lines, to mark as answered or as spam, export as CSV, or delete for good. + +**How it is built.** + +- *Tables* `p_shop_inquiry` and `p_shop_inquiry_line` (`migrations/0003_inquiries.sql`). A line is a snapshot, as an order's is: SKU, name, quantity and the unit price as they were, so that nothing later done to a product changes what was asked. An amount is stored twice — as minor units, and as the text the buyer saw — because the admin lists a column as it is stored and has no way to format one. +- *What can be sent* is decided by the reading the cart page shows (`readCartFacts`): a cart that is not empty and whose every line is in order, or has no price. **A part with no price can be asked about though it cannot be ordered** — that is what an inquiry is for — so a product page now offers a form for a size without a price (`addable` in the page's view, which was `orderable` and required one). Any other problem on a line has to be put right first: the page says which, and the form is not offered until then. +- *The route* `POST cart/inquiry`, at Mallok's strict rate-limit tier. Like the cart's other form it never renders: it answers 303 to the cart page with the inquiry's number, or the kind of reason none was made. Three round trips of its own: the cart, what its lines depend on, the write. +- *The write is one batch*: the inquiry, its lines, the job that owes the emails, the cart emptied. The inquiry is written only while the cart is still exactly what is being sent — the same lines and quantities, and no other — and the visitor is under their limit, both asked inside the statement; everything after is conditional on the inquiry being there. So the same form arriving twice at the same moment stores one inquiry, a cart that changed between being read and being written is not sent as it no longer is, and a refused one leaves the cart as it was. A request that wrote nothing reads why from the tables — the visitor is at their limit, the cart changed, or this cart was sent a moment ago — and a form sent twice is answered both times with the one number. +- *The emails are a job* (`ctx.enqueueStatement`, queued in that batch), not a call made on the strength of the write's answer: a Worker stopped after the batch still owes them. The job marks each email on the inquiry once it has handed it to Mallok, and a second run sends only what is not marked. A test runs Mallok's own scheduled handler and finds the plugin's job done and the seller's email in Mallok's queue. +- *The admin* is a `table` panel with a related table for the lines and five actions. Nothing here draws it. +- *The theme* draws the form, the confirmation and the refusal in `layouts/shop-cart.liquid`, in the four languages, with no script. Each field carries the server's own rule — `required`, the length the plugin allows, the pattern an address is held to — and a test holds the page's attributes to the plugin's constants. + +**Choices made here, which the owner may reverse.** + +1. *A size without a price can go in the cart.* It was offered no form before. Without this an inquiry could only ask about what already has a price. +2. *A line with any other problem stops the cart being sent*, stock included: a buyer who wants more than there is writes that in the message. The cart refuses that quantity already, so a cart that holds one got it by the stock falling afterwards. +3. *The email confirming to the buyer is built and off by default* (`inquiry_acknowledge`). The form has no challenge in front of it (below), and an email sent to whatever address is typed into a public form is how a shop comes to send mail to people who asked for nothing. It repeats nothing the buyer typed about themselves. +4. *Five inquiries an hour from one visitor*, counted exactly in the plugin's own table. Mallok's rate limit is, in Cloudflare's words, permissive and eventually consistent; this one is for the seller's inbox. +5. *An inquiry's number begins `RFQ-`* and is otherwise an order's: the date and eight characters. `lib/reference.ts` now makes both. +6. *Deleting is offered, behind a box to tick.* It is how what a person typed about themselves is erased when they ask, and how spam is cleared. Mallok's own inquiry panel has no delete. +7. *A refused form comes back empty.* Nothing a person typed may travel in an address, and a page cannot be rendered from the `POST` (§17). So the fields carry the server's rules, and the server takes what a browser lets through: white space in a name becomes one space instead of being refused, and a line break in the message is counted once, as the field counted it. +9. *An address is made only of letters and digits of any script and `. _ + - '`.* The admin makes a `mailto:` link of an address as it is stored, where `?`, `%` and `,` would add a recipient or a subject of the buyer's choosing. A real address with `&` or `!` in it is refused with them. +10. *Requests with no mark of their visitor are counted together*, as one visitor, the way Mallok's own rate limit counts them. The other choice was no limit for them. +8. *The cart page's link to the quote page is gone*: the form is on the page. + +**How it was verified.** 53 file tests and 775 tests inside workerd, 137 of the latter new since the ceiling of §11's decision 9: the route and its races, the emails and the job, the panel through Mallok's own API, and the page — the form's attributes, the confirmation shown only to the browser whose cart it was, nothing from an address reaching the page. 84 deliberate breakages of the new code in two rounds, before and after the review below; three showed a test that was not sharp enough, which was made so, and the rest turned a test red at once. The four guards inside the write were each broken three times over and went red every time. The smoke run sends a cart through the page's own form on a real local Worker, in German, reads the inquiry back the way the admin does, and was seen to fail with the confirmation's check on the cart taken out. In a browser: the form at ten widths from 320 to 1920 px in the four languages, with no sideways scroll and nothing outside its box; the keyboard's order, which passes over the field no person fills in; the address and name fields refusing and accepting exactly what the server does; one cart sent by hand; and in the admin, the panel's list, an inquiry opened with its lines, and the delete action refusing an unticked box. + +**What an independent review then found.** As with the payment core (§14) and phase 1B (§17), the finished work was given to a second reviewer with the properties it must hold and no account of how it holds them. It found the code's guards sound and eleven things wrong around them. Each was checked here before it was acted on. + +1. *The tests of the two races could not be relied on.* `Promise.all` over two requests interleaves them about half the time in this harness; the other half, the second request's reading already sees what the first did and the guard inside the write is never reached. Breaking each of the four guards left the tests green six times in ten. That the first round of breakages caught them was luck. The tests now hold both requests at the write until both have arrived (`holdWrites` in the test helpers), and each guard, broken, is red every time. +2. *What a buyer typed outlives the inquiry.* To send an email a plugin hands Mallok the whole message, and Mallok keeps it in its `job` table: seven days after delivery, and for good when it is never delivered. Deleting an inquiry does not reach it, and this document, the security notes and the guidance file all said that it did. They say what is true now, and it is written up for Mallok. +3. *A semicolon let a formula through the export.* Where the list separator is a semicolon — the default for German, French and Spanish, this shop's other three languages — a spreadsheet splits a row on `;` whatever the quotes say. Every piece that could begin a cell is made text now. +4. *An address could carry a `mailto:` link's own syntax.* `x?bcc=…%40…&subject=…@buyer.example` has one `@` and no white space; the admin links an address as it is stored. An address is held to a list of what it may be made of. +5. *A browser let through what the server refused*: a name of spaces, a tab pasted into a name, a long message whose line breaks the field counted once and the server twice. The form came back empty for each. The name has a pattern, white space in a one-line field becomes one space, and a line break is counted once. +6. *A cart that changed between the reading and the write lost the change*: a part added in those few milliseconds was in neither the inquiry nor the cart. The write is conditional on the cart being exactly what is sent. +7. *The export grouped lines by scanning them all for every inquiry.* One pass now. +8. *A cart refused for the limit inside the write could be told "sent"* when it had been sent once a moment before. The reading afterwards looks at the cart first. +9. *A request with no mark of its visitor was not limited.* Such requests are counted together. +10. *"Each email once" was "at least once"*, and the mark on the inquiry means handed to Mallok, not delivered. The documents say so. +11. *Tests that could not fail*: no address with a line break in it was tried, no company or phone number with a control character, only two of the four signs in the export, and nothing held the stylesheet rule that hides the trap field. All added. + +Not changed: the export still takes up to a thousand inquiries, as Mallok's own does. What that costs in CPU on a real account has not been measured. + +**Not verified.** Nothing was deployed, so no email was ever delivered: the tests end where Mallok's queue takes the message, and whether the email provider accepts every reply address the form lets through is not known. The form was not tried with a screen reader, and whether a browser's autofill ever fills the field no person sees was not tried. The German, French and Spanish strings, and the three translated emails, were not read by a native speaker. The export's behaviour was checked in its text, not in a spreadsheet. + +**Left open.** + +1. *No challenge in front of the form.* Mallok verifies a Turnstile token for a route and has nowhere to draw the widget on a plugin's page. Written up for Mallok. Until then the limits above are what there is, and `SECURITY.md` says what they do not stop. +2. *No retention period, and a copy the shop cannot delete.* An inquiry stays until an administrator deletes it, and its emails stay in Mallok's queue after that (the review's second finding). Written up. +3. *The shop's data is not in Mallok's export.* The plugin implements no `exportFiles`, for inquiries or for anything else it holds — variants, prices, the stock ledger. That is a gap of its own, older than this work, and is not closed here. +4. *A job is queued beside a conditional write that stored nothing* in a true race, and finds nothing to send. Mallok's statement takes no condition. Written up. +5. *An amount is stored twice* because a table panel has no money column. Written up. +6. *Whether a quantity must be a multiple of the minimum order* (§17, left open, item 8) and *the rounding of a derived price* (item 3) were put to the owner with this work and are not settled in it. +7. *The older tests of a race* — a payment reported twice at once, two saves of one variant — are written the way the inquiry's first were, with `Promise.all` alone. They were seen red with their guards broken, once each. Whether they are red every time has not been looked at since the review showed that this harness interleaves only sometimes. +8. *A request with no connecting address.* Whether a zone that uses Cloudflare's "Remove visitor IP headers" transform still hands a Worker that header is not stated in the documentation that was read. If it does not, every visitor of such a site shares one limit of five an hour. + 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..8c02b91 100644 --- a/scripts/smoke-shop.mjs +++ b/scripts/smoke-shop.mjs @@ -676,8 +676,142 @@ 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: neither a + // browser with no cart, nor one with a cart of its own. + const strangers = await page(sentTo); + const othersCart = ( + (await submit(form)).headers.get('set-cookie') ?? '' + ).split(';')[0]; + const others = await ( + await fetch(`${base}${sentTo}`, { + headers: { cookie: othersCart }, + signal: timeout(), + }) + ).text(); + expect( + strangers.status === 200 && + !strangers.html.includes('cart-sent') && + othersCart !== '' && + othersCart !== cartCookie && + !others.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 + WHERE cart_id = '${cartCookie.split('=')[1]}'`, + ); + 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..9901dc1 --- /dev/null +++ b/src/plugins/shop/lib/inquiries.ts @@ -0,0 +1,759 @@ +/** + * 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]; + +/** + * What an address may be, as the source of a pattern. The form carries + * exactly this in its `pattern`, and a browser compiles that the way the + * server does below, so the two agree on every address. + * + * It is a list of what is allowed, not of what is not: letters and digits of + * any script, and the few signs addresses really use. An address ends up in + * places where other characters mean something — `?`, `&` and `%` in the + * `mailto:` link an admin makes of it, where they would add a recipient or a + * subject of the buyer's choosing; `,` and `<` in the reply address of an + * email. The rare real address with `&` or `!` in it is refused with them. + */ +export const EMAIL_PATTERN_SOURCE = + "[\\p{L}\\p{N}._+'\\-]+@[\\p{L}\\p{N}\\-]+(\\.[\\p{L}\\p{N}\\-]+)+"; + +/** The `v` flag is the one a browser gives a `pattern`. */ +export const EMAIL_PATTERN = new RegExp(`^(?:${EMAIL_PATTERN_SOURCE})$`, 'v'); + +/** + * What a name must have: something that is not a space. The form carries it + * as the name's `pattern`, since `required` alone is satisfied by spaces. + */ +export const NAME_PATTERN_SOURCE = '.*\\S.*'; + +/** No control character: what is left to refuse once white space is one space. */ +const NO_CONTROL = /^[^\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]; + +/** + * One line of text: a name, a company, a phone number. + * + * White space of any kind — a tab pasted from a spreadsheet, a line break — + * becomes one space, rather than being refused: a browser lets a person type + * or paste it, and a form the server refuses comes back empty. What is + * stored is one line either way, which is what an email's subject needs. + */ +function oneLine(max: number, min = 0) { + return z + .string() + .default('') + .transform((value) => value.replace(/\s+/gu, ' ').trim()) + .pipe(z.string().min(min).max(max).regex(NO_CONTROL)); +} + +export const inquiryFormSchema = z.object({ + name: oneLine(INQUIRY_LIMITS.name, 1), + email: z + .string() + .default('') + .transform((value) => value.trim()) + .pipe(z.string().min(3).max(INQUIRY_LIMITS.email).regex(EMAIL_PATTERN)), + company: oneLine(INQUIRY_LIMITS.company), + phone: oneLine(INQUIRY_LIMITS.phone), + // A browser counts a line break as one character and sends it as two. It + // is made one again before it is counted, so that a message the field let + // through is not one the server finds too long. + message: z + .string() + .default('') + .transform((value) => value.replace(/\r\n?/g, '\n').trim()) + .pipe(z.string().max(INQUIRY_LIMITS.message)), + /** 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 is still exactly what is being + * sent — the same lines, in the same quantities, and no other — 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. And a cart that changed between + * being read and being written is not sent as it no longer is, nor emptied + * of what was put in it meanwhile. 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 NOT EXISTS ( + SELECT 1 FROM json_each(?) AS sent + WHERE NOT EXISTS ( + SELECT 1 FROM p_shop_cart_line + WHERE cart_id = ? + AND variant_id = json_extract(sent.value, '$.variantId') + AND quantity = json_extract(sent.value, '$.quantity') + ) + ) + AND (SELECT COUNT(*) FROM p_shop_cart_line WHERE cart_id = ?) = ? + AND (SELECT COUNT(*) FROM p_shop_inquiry + WHERE ip_hash IS ? 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, + JSON.stringify( + lines.map((line) => ({ + variantId: line.variantId, + quantity: line.quantity, + })), + ), + input.cartId, + input.cartId, + lines.length, + 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. + * + * `IS`, not `=`: a request Mallok could give no mark — one that reached the + * Worker without a connecting address — is counted with every other such + * request, as one visitor. Mallok's own rate limit does the same. The other + * choice, no limit at all for them, would switch the limit off for a whole + * site whose requests arrive that way. + */ +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 IS ? 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(sent: number): boolean { + return 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' } + /** The cart is not what was being sent any more. */ + | { readonly kind: 'cart_changed' } + | { readonly kind: 'empty' }; + +/** + * What stood in the way of an inquiry that wrote nothing. + * + * Read from the tables, not guessed from the write, and in this order. The + * cart is still exactly what was being sent, and the visitor is at their + * limit: it was the limit. The cart has something else in it now: it + * changed, and the buyer should look at it before sending. The cart is + * empty and was sent a moment ago: this is that same request, arriving a + * second time, and is told the number of the first. Otherwise there was + * nothing to send. + * + * `lines` is what was being sent; none, when the cart was already empty as + * it was read. + */ +export async function whyNotStored( + db: D1Database, + input: { + readonly cartId: string; + readonly ipHash: string | null; + readonly now: Date; + readonly lines: readonly Pick[]; + }, +): Promise { + const { cartId, ipHash, now, lines } = input; + const [recentResult, countResult, cartResult] = await db.batch< + | { inquiry_no: string } + | { n: number } + | { variant_id: string; quantity: 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), + db + .prepare( + 'SELECT variant_id, quantity FROM p_shop_cart_line WHERE cart_id = ?', + ) + .bind(cartId), + ]); + const inCart = (cartResult?.results ?? []) as { + variant_id: string; + quantity: number; + }[]; + const unchanged = + lines.length > 0 && + inCart.length === lines.length && + lines.every((line) => + inCart.some( + (row) => + row.variant_id === line.variantId && row.quantity === line.quantity, + ), + ); + const sent = + ((countResult?.results ?? [])[0] as { n: number } | undefined)?.n ?? 0; + if (unchanged && atVisitorLimit(sent)) { + return { kind: 'too_many' }; + } + if (inCart.length > 0) { + return { kind: 'cart_changed' }; + } + const recent = (recentResult?.results ?? [])[0] as + | { inquiry_no: string } + | undefined; + return recent === undefined + ? { kind: 'empty' } + : { kind: 'sent', inquiryNo: recent.inquiry_no }; +} + +/** + * 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[] { + // One pass over the lines, not one for each inquiry: an export holds a + // thousand inquiries, and the Worker's CPU is counted in milliseconds. + const byInquiry = new Map(); + for (const line of lines) { + const own = byInquiry.get(line.inquiry_id) ?? []; + own.push({ + variantId: line.variant_id, + sku: line.sku, + name: line.name, + quantity: line.quantity, + unitPriceMinor: line.unit_price_minor, + }); + byInquiry.set(line.inquiry_id, own); + } + 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: byInquiry.get(row.id) ?? [], + })); +} + +/** 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. + * + * Not only at the start of the value. Where the list separator is a + * semicolon — German, French and Spanish settings among them — a + * spreadsheet opening this file splits a row on `;`, whatever the quotes + * say, and each piece is a cell of its own: `x;=1+1` is two. A line break + * inside a value starts a row the same way. So the apostrophe goes in front + * of every one of those signs that such a spreadsheet could find at the + * start of a cell. + */ +function inert(value: string): string { + return value.replace(/(^|[;\t\r\n])([=+\-@])/g, "$1'$2"); +} + +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/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/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/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/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..d6a2d6e --- /dev/null +++ b/src/plugins/shop/routes/inquiry.ts @@ -0,0 +1,204 @@ +/** + * `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`, and the + * server takes what a browser lets through — a tab in a name, a line break + * counted once — so that a person using a browser does not meet that for + * what they typed. They can for what happened meanwhile: the cart changed, + * or the limit was reached. + * + * 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. + * + * What is sent is the cart exactly as it is at the write: a cart that + * changed between being read here and being written is not sent, and the + * buyer is asked to look at it again. + */ + +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', + 'cart_changed', + '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 ( + sending: readonly { variantId: string; quantity: number }[], + ): Promise => { + const why = await whyNotStored(ctx.db, { + cartId, + ipHash: ctx.ipHash, + now, + lines: sending, + }); + 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(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(sendable); + } + + // 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..268e620 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,49 @@ {% 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, for the address the pattern it is held to, and for the + name that it is not only spaces. 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 for what they typed. + + 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..e150f44 100644 --- a/src/theme/locales/de.json +++ b/src/theme/locales/de.json @@ -74,9 +74,28 @@ "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_cart_changed": "Der Warenkorb hat sich während des Sendens geändert. Bitte prüfen Sie ihn und senden Sie ihn erneut.", + "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..2172d9c 100644 --- a/src/theme/locales/en.json +++ b/src/theme/locales/en.json @@ -74,9 +74,28 @@ "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_cart_changed": "The cart changed while it was being sent. Please check it and send it again.", + "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..82e1871 100644 --- a/src/theme/locales/es.json +++ b/src/theme/locales/es.json @@ -74,9 +74,28 @@ "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_cart_changed": "El carrito ha cambiado durante el envío. Revíselo y envíelo de nuevo.", + "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..b2496b0 100644 --- a/src/theme/locales/fr.json +++ b/src/theme/locales/fr.json @@ -74,9 +74,28 @@ "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_cart_changed": "Le panier a changé pendant l’envoi. Merci de le vérifier et de l’envoyer à nouveau.", + "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/project.test.ts b/test/project.test.ts index 2d55ddc..6a4e81d 100644 --- a/test/project.test.ts +++ b/test/project.test.ts @@ -345,6 +345,34 @@ describe('the theme’s client scripts', () => { }); }); +describe('the form that sends a cart as a request for a quote', () => { + it('keeps the field no person fills in where nobody sees it', async () => { + // The field is in the page for whatever fills in every field it finds. + // It is the stylesheet, and nothing in the markup, that keeps it from a + // person's eyes: without this rule every buyer is shown a field that + // throws their request away when they fill it in. + const theme = join('src', 'theme'); + const layout = await readFile( + join(theme, 'layouts', 'shop-cart.liquid'), + 'utf8', + ); + const styles = await readFile(join(theme, 'assets', 'style.css'), 'utf8'); + + assert.match( + layout, + /