From aeb7bcbb76e172eaa4fcc350da6cf0f54d5d7c88 Mon Sep 17 00:00:00 2001 From: JasonYv Date: Fri, 9 Oct 2026 12:26:47 +0800 Subject: [PATCH 1/5] refactor: make an order's number and an inquiry's in one place An inquiry is quoted by a number of the same shape as an order's: a prefix, the date and eight characters of Crockford's alphabet. The generator moves out of the order code so that both use it, with a test of the shape it makes and of how one is recognised under its own prefix. --- src/plugins/shop/lib/orders.ts | 20 +++--------- src/plugins/shop/lib/reference.ts | 48 ++++++++++++++++++++++++++++ test/shop/reference.test.ts | 52 +++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 src/plugins/shop/lib/reference.ts create mode 100644 test/shop/reference.test.ts diff --git a/src/plugins/shop/lib/orders.ts b/src/plugins/shop/lib/orders.ts index ce4d15c..353451f 100644 --- a/src/plugins/shop/lib/orders.ts +++ b/src/plugins/shop/lib/orders.ts @@ -33,6 +33,7 @@ import { type OrderStatus, } from './order-state.js'; import { orderChangedOutbox, paymentRefusedOutbox } from './outbox.js'; +import { newReference } from './reference.js'; /** Thrown by anything asked to act on an order that does not exist. */ export class OrderNotFoundError extends Error { @@ -62,28 +63,15 @@ type SuccessfulCart = Extract; /** The Stripe event that confirms a payment. */ export const PAYMENT_EVENT_TYPE = 'payment_intent.succeeded'; -/** - * Crockford's base 32: no I, L, O or U, so a number read over the phone or - * copied from paper is not mistaken for another. - */ -const ORDER_NO_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; +/** What an order's number begins with. */ +const ORDER_NO_PREFIX = 'ND'; /** * The number a buyer sees: the date and a random suffix, so it says nothing * about how many orders the shop takes. - * - * Eight characters are forty bits. Six hexadecimal ones, as before, are - * twenty-four: at a thousand orders a day two of them would collide about - * once a month, and a collision is a checkout that fails. */ function newOrderNo(now: Date): string { - const date = now.toISOString().slice(2, 10).replace(/-/g, ''); - let suffix = ''; - for (const byte of crypto.getRandomValues(new Uint8Array(8))) { - // 256 is a multiple of 32, so the low five bits are uniform. - suffix += ORDER_NO_ALPHABET.charAt(byte & 31); - } - return `ND-${date}-${suffix}`; + return newReference(ORDER_NO_PREFIX, now); } /** diff --git a/src/plugins/shop/lib/reference.ts b/src/plugins/shop/lib/reference.ts new file mode 100644 index 0000000..b87fcb0 --- /dev/null +++ b/src/plugins/shop/lib/reference.ts @@ -0,0 +1,48 @@ +/** + * The numbers people quote back to a shop: an order's, an inquiry's. + * + * A prefix, the date and a random suffix — `ND-261005-7K3M9QXA` — so a + * number says nothing about how many the shop has had. + */ + +/** + * Crockford's base 32: no I, L, O or U, so a number read over the phone or + * copied from paper is not mistaken for another. + */ +const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +/** Eight characters are forty bits. */ +const SUFFIX_LENGTH = 8; + +/** + * A new number under a prefix. + * + * Eight characters, because six hexadecimal ones, as the first implementation + * had, are twenty-four bits: at a thousand a day two of them would collide + * about once a month, and a collision is a checkout that fails. + */ +export function newReference(prefix: string, now: Date): string { + const date = now.toISOString().slice(2, 10).replace(/-/g, ''); + let suffix = ''; + for (const byte of crypto.getRandomValues(new Uint8Array(SUFFIX_LENGTH))) { + // 256 is a multiple of 32, so the low five bits are uniform. + suffix += ALPHABET.charAt(byte & 31); + } + return `${prefix}-${date}-${suffix}`; +} + +/** Whether a text has the shape of a number made under this prefix. */ +export function isReference(prefix: string, value: string): boolean { + if (!value.startsWith(`${prefix}-`)) { + return false; + } + const [date = '', suffix = '', ...rest] = value + .slice(prefix.length + 1) + .split('-'); + return ( + rest.length === 0 && + /^\d{6}$/.test(date) && + suffix.length === SUFFIX_LENGTH && + [...suffix].every((character) => ALPHABET.includes(character)) + ); +} diff --git a/test/shop/reference.test.ts b/test/shop/reference.test.ts new file mode 100644 index 0000000..322189d --- /dev/null +++ b/test/shop/reference.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { + isReference, + newReference, +} from '../../src/plugins/shop/lib/reference.js'; + +describe('the numbers people quote', () => { + const now = new Date('2026-10-09T23:59:59.000Z'); + + it('are a prefix, the date and eight characters nobody mistakes for another', () => { + for (let made = 0; made < 200; made += 1) { + // No I, L, O or U: Crockford's alphabet. + expect(newReference('RFQ', now)).toMatch( + /^RFQ-261009-[0-9A-HJKMNP-TV-Z]{8}$/, + ); + } + }); + + it('do not repeat', () => { + const made = new Set( + Array.from({ length: 500 }, () => newReference('ND', now)), + ); + + expect(made.size).toBe(500); + }); + + it('are recognised by their shape, under their own prefix only', () => { + const number = newReference('RFQ', now); + + expect(isReference('RFQ', number)).toBe(true); + expect(isReference('ND', number)).toBe(false); + // A prefix of the same length, which leaves the rest in the same place. + expect(isReference('ABC', number)).toBe(false); + expect(isReference('RFQ', `ABC${number.slice(3)}`)).toBe(false); + expect(isReference('RFQ', number.toLowerCase())).toBe(false); + for (const not of [ + '', + 'RFQ', + 'RFQ-261009', + 'RFQ-261009-7K3M9QX', + 'RFQ-261009-7K3M9QXAB', + 'RFQ-26100-7K3M9QXA', + 'RFQ-261009-7K3M9QXI', + 'RFQ-261009-7K3M9QXA-1', + 'RFQ-261009-7K3M9QX%', + "RFQ-261009-' OR 1=1", + 'XRFQ-261009-7K3M9QXA', + ]) { + expect(isReference('RFQ', not), not).toBe(false); + } + }); +}); From 804b932d423ecab4cea7453da329192f829e5e48 Mon Sep 17 00:00:00 2001 From: JasonYv Date: Fri, 9 Oct 2026 12:26:47 +0800 Subject: [PATCH 2/5] feat: send a cart as one request for a quote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1's last piece, which the owner decided is the shop plugin's to build (design §11, decision 10). A form on the cart page takes who to answer and a message, and posts to cart/inquiry. The route stores the cart as an inquiry with its lines as a snapshot, queues the job that owes its emails, and empties the cart, in one batch; it answers with a redirect to the cart page, which shows the inquiry's number to the browser whose cart it was. A part with no price can be asked about, so a product page now offers a form for a size without one. The write is conditional on the cart still having lines and on the visitor being under five inquiries an hour, both asked inside the statement, so two forms at once store one inquiry and a refused one leaves the cart alone. The seller is told by email, answerable to the buyer, and reads inquiries in a panel of the admin: to mark, export as CSV, or delete. The email confirming to the buyer is built and off by default, because the form has no challenge in front of it: Mallok has nowhere to draw one on a plugin's page. --- scripts/lib/local-shop.mjs | 5 +- scripts/smoke-shop.mjs | 119 ++- src/plugins/shop/index.ts | 28 +- src/plugins/shop/lib/cart-view.ts | 18 +- src/plugins/shop/lib/inquiries.ts | 659 +++++++++++++ src/plugins/shop/lib/inquiry-admin.ts | 80 ++ src/plugins/shop/lib/inquiry-email.ts | 237 +++++ src/plugins/shop/lib/inquiry-jobs.ts | 113 +++ src/plugins/shop/lib/paths.ts | 3 + src/plugins/shop/lib/storefront.ts | 23 +- .../shop/migrations/0003_inquiries.sql | 89 ++ src/plugins/shop/plugin.json | 142 ++- src/plugins/shop/routes/cart.ts | 82 +- src/plugins/shop/routes/inquiry.ts | 193 ++++ src/theme/assets/style.css | 106 ++- src/theme/layouts/shop-cart.liquid | 63 +- src/theme/locales/de.json | 20 +- src/theme/locales/en.json | 20 +- src/theme/locales/es.json | 20 +- src/theme/locales/fr.json | 20 +- src/theme/partials/offer.liquid | 9 +- src/theme/theme.json | 2 +- test/shop/helpers.ts | 2 + test/shop/inquiries.test.ts | 868 ++++++++++++++++++ test/shop/inquiry-admin.test.ts | 254 +++++ test/shop/inquiry-emails.test.ts | 519 +++++++++++ test/shop/schema.test.ts | 3 + test/shop/storefront.test.ts | 7 +- test/theme/cart.test.ts | 425 ++++++++- test/theme/packs.test.ts | 79 ++ 30 files changed, 4150 insertions(+), 58 deletions(-) create mode 100644 src/plugins/shop/lib/inquiries.ts create mode 100644 src/plugins/shop/lib/inquiry-admin.ts create mode 100644 src/plugins/shop/lib/inquiry-email.ts create mode 100644 src/plugins/shop/lib/inquiry-jobs.ts create mode 100644 src/plugins/shop/migrations/0003_inquiries.sql create mode 100644 src/plugins/shop/routes/inquiry.ts create mode 100644 test/shop/inquiries.test.ts create mode 100644 test/shop/inquiry-admin.test.ts create mode 100644 test/shop/inquiry-emails.test.ts create mode 100644 test/theme/packs.test.ts diff --git a/scripts/lib/local-shop.mjs b/scripts/lib/local-shop.mjs index 848896e..3a2c810 100644 --- a/scripts/lib/local-shop.mjs +++ b/scripts/lib/local-shop.mjs @@ -203,8 +203,9 @@ export async function fillShop({ base, state, tokenName, step = () => {} }) { body: JSON.stringify({ name: tokenName, // `media:write` because the sample bundles carry images, and publishing - // a bundle uploads them. - scopes: ['content:write', 'settings:write', 'media:write'], + // a bundle uploads them. `export` reads the rows of a plugin's panel, + // as the admin does: the smoke run reads an inquiry back through it. + scopes: ['content:write', 'settings:write', 'media:write', 'export'], }), signal: timeout(), }); diff --git a/scripts/smoke-shop.mjs b/scripts/smoke-shop.mjs index 5b9cd31..6e18bb7 100644 --- a/scripts/smoke-shop.mjs +++ b/scripts/smoke-shop.mjs @@ -676,8 +676,125 @@ try { `the cart should hold one line of 100; it holds ${JSON.stringify(lines)}`, ); + // 7. The cart, sent as a request for a quote: through the form the cart + // page itself offers, in German, and read back the way the admin reads + // it. The cart was last shown in euros. + const quoteForm = + /
Diesen Warenkorb als Angebotsanfrage senden', + ), + 'the German cart page offers no form to send the cart as a request for a quote', + ); + const buyer = { + name: 'Smoke Buyer', + email: 'buyer@smoke.example', + company: 'Smoke GmbH', + phone: '', + message: 'Bitte mit Lieferzeit.', + website: '', + }; + const sendInquiry = () => + submit({ action: quoteForm[1], fields: buyer }, {}, cartCookie); + const sent = await sendInquiry(); + const sentTo = sent.headers.get('location') ?? ''; + const inquiries = await query( + `SELECT i.id, i.inquiry_no, i.name, i.locale, i.currency, i.subtotal, + l.sku, l.name AS product, l.quantity, l.unit_price + FROM p_shop_inquiry AS i + JOIN p_shop_inquiry_line AS l ON l.inquiry_id = i.id`, + ); + expect( + inquiries.length === 1 && + inquiries[0].name === 'Smoke Buyer' && + inquiries[0].locale === 'de' && + inquiries[0].currency === 'EUR' && + inquiries[0].sku === 'TI-SHC-M5-20' && + inquiries[0].product === + 'Titan-Zylinderschraube mit Innensechskant M5 × 0,8' && + inquiries[0].quantity === 100 && + /^2,10\s€$/.test(inquiries[0].unit_price) && + /^210,00\s€$/.test(inquiries[0].subtotal), + `the cart was stored as ${JSON.stringify(inquiries)}`, + ); + const inquiry = inquiries[0]; + expect( + sent.status === 303 && + sentTo === `/_mallok/p/shop/de/cart?sent=${inquiry.inquiry_no}`, + `sending the cart returned ${sent.status} to ${sentTo}`, + ); + const confirmation = await ( + await fetch(`${base}${sentTo}`, { + headers: { cookie: cartCookie }, + signal: timeout(), + }) + ).text(); + expect( + confirmation.includes('
') && + confirmation.includes(inquiry.inquiry_no) && + confirmation.includes('Ihre Anfrage wurde gesendet.') && + confirmation.includes('

Ihr Warenkorb ist leer.

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

${escapeHtml(title)}

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

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

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

Subtotal: ${escapeHtml(subtotal)}

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

Message

${paragraphs(inquiry.message)}

`, + ]), + '

Reply to this email to answer the buyer.

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

${escapeHtml(copy.intro)}

`, + `

${escapeHtml(copy.itemsHeading)}

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

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

`, + ]), + `

${escapeHtml(copy.signOff)}

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

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

+

{{ t.quote_sent_note }}

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

{{ t.cart_empty }}

{% if site.kinds.product %}

{{ t.back_to_catalogue }}

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

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

{% endif %}

{{ t.cart_note }}

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

{{ t.quote_form_title }}

+

{{ t.quote_form_intro }}

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

{{ t.quote_fix_first }}

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