Public: anyone can read this; the crew can change it.
An admin's change to the catalog, a discount or a tax rate is a row that names who made it #53
Check · Review · Land · Live done — Landed and live in production.
ledger/bazaar:ledger/34-an-admin-s-change-to-the-catalog-a-disco → main 2 commits fork 1 thread · 1 unresolved
README.md +14 −7
| @@ -32,7 +32,10 @@ library are all of it. There is no client-side JavaScript. | ||
| 32 | 32 | and the money off a card are all taken in the same transaction that writes |
| 33 | 33 | the order's lines, so nothing is sold, spent or discounted twice; refunds |
| 34 | 34 | are checked against what is left refundable inside a transaction, and go |
| 35 | back the way they came — the gateway first, then the card. | |
| 35 | back the way they came — the gateway first, then the card. An admin's | |
| 36 | changes to what the shop sells and what it charges are a ledger too: a | |
| 37 | price, a slug, a discount code or a tax rate that moves writes a row at | |
| 38 | `/admin/changes` naming who moved it and what it was before. | |
| 36 | 39 | - **Simulated unless you configure it**: the payment. With no Stripe keys the |
| 37 | 40 | store uses a **mock gateway** — a page labelled as a mock, with Approve and |
| 38 | 41 | Decline buttons, that moves no money. |
| @@ -239,9 +242,10 @@ otherwise hand over the very URL `to` was careful to clean. | ||
| 239 | 242 | |
| 240 | 243 | The terminal takes the same three roles from the environment. Stock and the |
| 241 | 244 | tax table read for anybody the forge names; orders, |
| 242 | the outbox, the codes and the cards do not, because a receipt carries a gift | |
| 243 | card's code in full, an order carries a name and an address, and the card list | |
| 244 | is every balance in the shop. Every key that does anything — moving stock, | |
| 245 | the outbox, the codes, the cards and the changes do not, because a receipt | |
| 246 | carries a gift card's code in full, an order carries a name and an address, | |
| 247 | the card list is every balance in the shop, and the changes name the people | |
| 248 | who run it. Every key that does anything — moving stock, | |
| 245 | 249 | refunding, writing a code, issuing or voiding a card, setting a rate — is an |
| 246 | 250 | admin's, and so are the two keys that only read something out: `c`, which |
| 247 | 251 | prints a balance, and `h`, which prints the stock ledger. `src/desk.ts` holds |
| @@ -266,6 +270,7 @@ till: someone reading their card over and over must still be able to spend it. | ||
| 266 | 270 | | `src/cart.ts` `src/codes.ts` `src/mine.ts` `src/money.ts` `src/sign.ts` | The signed cart, the codes cookie, the orders this browser placed, integer cents, HMACs | |
| 267 | 271 | | `src/discounts.ts` `src/giftcards.ts` `src/tax.ts` | What a code is worth, what a card holds, what the rate is | |
| 268 | 272 | | `src/who.ts` `src/session.ts` | Who the forge says is asking; the session that carries the CSRF token | |
| 273 | | `src/changes.ts` | What an admin changed to the catalog, a code or a rate, and who they were | | |
| 269 | 274 | | `src/desk.ts` | The terminal's typed commands: a line of text into a code, a card or a rate | |
| 270 | 275 | | `src/peer.ts` `src/ratelimit.ts` | Where a request came from, as far as it can be proved; the buckets keyed on it | |
| 271 | 276 | | `src/gateway.ts` `src/mail.ts` | Stripe or the mock, the outbox | |
| @@ -289,10 +294,11 @@ the suite passes in any order — `bun test`, `bun test --isolate`, | ||
| 289 | 294 | |
| 290 | 295 | The same shop over the same data. The exact command is in `FORGE_APP_SSH` |
| 291 | 296 | when the app runs; the footer of every page prints it verbatim, and says the |
| 292 | terminal is off when it is unset. `1`–`6` switch between stock, orders, the | |
| 293 | outbox, discount codes, gift cards and the tax table — everything `/admin` | |
| 297 | terminal is off when it is unset. `1`–`7` switch between stock, orders, the | |
| 298 | outbox, discount codes, gift cards, the tax table and the changes an admin | |
| 299 | made to any of it — everything `/admin` | |
| 294 | 300 | can see — and `j`/`k` move. A tab whose rows are the shop's own business is an |
| 295 | admin's to open: a non-admin gets stock and tax, the other four are marked | |
| 301 | admin's to open: a non-admin gets stock and tax, the other five are marked | |
| 296 | 302 | with a `·` in the tab bar and answer "That is an admin's to do." without |
| 297 | 303 | leaving the tab they are on: |
| 298 | 304 | |
| @@ -303,6 +309,7 @@ leaving the tab they are on: | ||
| 303 | 309 | | codes | `n` writes a code (`CODE 20% [min 25] [uses 100] [until 2026-12-31]`), `t` turns one on or off, `c` looks a code up | |
| 304 | 310 | | cards | `i` issues a card (`25 a sorry-for-the-wait card`) and prints its code once, `v` voids or revives one, `c` looks a code up | |
| 305 | 311 | | tax | `e` sets a rate (`COUNTRY / REGION / 8.75 / what to call it`), `d` sets the rate for anywhere the table does not name, `x` removes one | |
| 312 | | changes | reads only: the last hundred changes an admin made to the catalog, the codes or the tax table, newest first — the terminal's half of `/admin/changes`. A change made here writes one of these rows too, naming the account the forge put at the keyboard | | |
| 306 | 313 | |
| 307 | 314 | `q` leaves. The first line says who the forge named and what they may read; |
| 308 | 315 | `FORGE_APP_USER_ROLE` has to be `admin` for any of the keys above, `c` and `h` |
src/admin.ts +132 −15
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import * as catalog from "./catalog.ts"; |
| 11 | import * as changes from "./changes.ts"; | |
| 11 | 12 | import * as discounts from "./discounts.ts"; |
| 12 | 13 | import * as giftcards from "./giftcards.ts"; |
| 13 | 14 | import * as tax from "./tax.ts"; |
| @@ -18,7 +19,7 @@ import * as pages from "./pages/admin.ts"; | ||
| 18 | 19 | import { db, type Variant } from "./db.ts"; |
| 19 | 20 | import { field, intField, redirect } from "./http.ts"; |
| 20 | 21 | import { forOrder, outbox } from "./mail.ts"; |
| 21 | import { parseCents } from "./money.ts"; | |
| 22 | import { formatCents, parseCents } from "./money.ts"; | |
| 22 | 23 | import { render, type Ctx } from "./context.ts"; |
| 23 | 24 | import { notFound } from "./shop.ts"; |
| 24 | 25 | import { identityConfigured, returnTo } from "./who.ts"; |
| @@ -58,6 +59,24 @@ const guard = (ctx: Ctx): Response | null => | ||
| 58 | 59 | pages.forbidden(returnTo(ctx.url), identityConfigured()), |
| 59 | 60 | ); |
| 60 | 61 | |
| 62 | // --- the trail -------------------------------------------------------------- | |
| 63 | ||
| 64 | /** | |
| 65 | * Who is making this change. `ctx.who` is the forge's word, verified in | |
| 66 | * src/who.ts, and it is the only source there is: a form field or a header | |
| 67 | * that named someone else would be the caller signing their own trail. | |
| 68 | */ | |
| 69 | const actor = (ctx: Ctx): changes.Actor => changes.actorOf(ctx.who); | |
| 70 | ||
| 71 | /** A rate as the tax table says it. */ | |
| 72 | const percent = (bp: number): string => `${(bp / 100).toFixed(2)}%`; | |
| 73 | ||
| 74 | export function changeList(ctx: Ctx): Response { | |
| 75 | const blocked = guard(ctx); | |
| 76 | if (blocked) return blocked; | |
| 77 | return render(ctx, { title: "Changes", admin: true }, pages.changes(changes.recent(100))); | |
| 78 | } | |
| 79 | ||
| 61 | 80 | // --- the door --------------------------------------------------------------- |
| 62 | 81 | |
| 63 | 82 | export function dashboard(ctx: Ctx): Response { |
| @@ -129,14 +148,31 @@ const productInput = (ctx: Ctx) => { | ||
| 129 | 148 | }; |
| 130 | 149 | }; |
| 131 | 150 | |
| 151 | /** What the trail says about a product: the fields this form can move. */ | |
| 152 | const productFields = ( | |
| 153 | input: { title: string; slug: string; blurb: string; description: string; active: boolean }, | |
| 154 | category: string, | |
| 155 | ): changes.Snapshot => ({ | |
| 156 | title: input.title, | |
| 157 | slug: input.slug, | |
| 158 | category, | |
| 159 | blurb: input.blurb, | |
| 160 | description: input.description, | |
| 161 | active: input.active, | |
| 162 | }); | |
| 163 | ||
| 132 | 164 | export function productCreate(ctx: Ctx): Response { |
| 133 | 165 | const blocked = guard(ctx); |
| 134 | 166 | if (blocked) return blocked; |
| 135 | 167 | const input = productInput(ctx); |
| 136 | if (input.title === "" || input.slug === "" || !catalog.categories().some((c) => c.id === input.category_id)) | |
| 137 | return redirect("/admin/products/new?m=bad-slug"); | |
| 168 | const category = catalog.categories().find((c) => c.id === input.category_id); | |
| 169 | if (input.title === "" || input.slug === "" || !category) return redirect("/admin/products/new?m=bad-slug"); | |
| 138 | 170 | try { |
| 139 | const id = catalog.createProduct(input); | |
| 171 | const id = db.transaction(() => { | |
| 172 | const made = catalog.createProduct(input); | |
| 173 | changes.changed(actor(ctx), "product", made, {}, productFields(input, category.name)); | |
| 174 | return made; | |
| 175 | })(); | |
| 140 | 176 | return redirect(`/admin/products/${id}?m=created`); |
| 141 | 177 | } catch { |
| 142 | 178 | return redirect("/admin/products/new?m=bad-slug"); |
| @@ -150,10 +186,20 @@ export function productUpdate(ctx: Ctx): Response { | ||
| 150 | 186 | const found = Number.isInteger(id) ? catalog.productById(id) : null; |
| 151 | 187 | if (!found) return notFound(ctx); |
| 152 | 188 | const input = productInput(ctx); |
| 153 | if (input.title === "" || input.slug === "" || !catalog.categories().some((c) => c.id === input.category_id)) | |
| 189 | const category = catalog.categories().find((c) => c.id === input.category_id); | |
| 190 | if (input.title === "" || input.slug === "" || !category) | |
| 154 | 191 | return redirect(`/admin/products/${found.id}?m=bad-slug`); |
| 155 | 192 | try { |
| 156 | catalog.updateProduct(found.id, input); | |
| 193 | db.transaction(() => { | |
| 194 | catalog.updateProduct(found.id, input); | |
| 195 | changes.changed( | |
| 196 | actor(ctx), | |
| 197 | "product", | |
| 198 | found.id, | |
| 199 | productFields({ ...found, active: found.active === 1 }, found.category_name), | |
| 200 | productFields(input, category.name), | |
| 201 | ); | |
| 202 | })(); | |
| 157 | 203 | return redirect(`/admin/products/${found.id}?m=saved`); |
| 158 | 204 | } catch { |
| 159 | 205 | return redirect(`/admin/products/${found.id}?m=bad-slug`); |
| @@ -173,7 +219,12 @@ export function variantCreate(ctx: Ctx): Response { | ||
| 173 | 219 | if (price === undefined) return redirect(`/admin/products/${found.id}?m=bad-price`); |
| 174 | 220 | if (sku === "" || name === "") return redirect(`/admin/products/${found.id}?m=bad-sku`); |
| 175 | 221 | try { |
| 176 | catalog.createVariant({ product_id: found.id, sku, name, price_cents: price, stock }); | |
| 222 | // Opening stock is not a field of the trail: it moves through the stock | |
| 223 | // ledger, which already says who and why. | |
| 224 | db.transaction(() => { | |
| 225 | const id = catalog.createVariant({ product_id: found.id, sku, name, price_cents: price, stock }); | |
| 226 | changes.changed(actor(ctx), "variant", id, {}, { sku, name, price: formatCents(price) }); | |
| 227 | })(); | |
| 177 | 228 | return redirect(`/admin/products/${found.id}?m=variant-added`); |
| 178 | 229 | } catch { |
| 179 | 230 | return redirect(`/admin/products/${found.id}?m=bad-sku`); |
| @@ -189,7 +240,17 @@ export function variantUpdate(ctx: Ctx): Response { | ||
| 189 | 240 | const price = parseCents(field(ctx.form, "price", 12)); |
| 190 | 241 | const name = field(ctx.form, "name", 80); |
| 191 | 242 | if (price === undefined || name === "") return redirect(`/admin/products/${variant.product_id}?m=bad-price`); |
| 192 | catalog.updateVariant(variant.id, { name, price_cents: price, active: ctx.form?.get("active") === "1" }); | |
| 243 | const input = { name, price_cents: price, active: ctx.form?.get("active") === "1" }; | |
| 244 | db.transaction(() => { | |
| 245 | catalog.updateVariant(variant.id, input); | |
| 246 | changes.changed( | |
| 247 | actor(ctx), | |
| 248 | "variant", | |
| 249 | variant.id, | |
| 250 | { name: variant.name, price: formatCents(variant.price_cents), active: variant.active === 1 }, | |
| 251 | { name: input.name, price: formatCents(input.price_cents), active: input.active }, | |
| 252 | ); | |
| 253 | })(); | |
| 193 | 254 | return redirect(`/admin/products/${variant.product_id}?m=saved`); |
| 194 | 255 | } |
| 195 | 256 | |
| @@ -316,6 +377,28 @@ export function discountList(ctx: Ctx): Response { | ||
| 316 | 377 | return render(ctx, { title: "Discounts", admin: true }, pages.discounts(ctx.session, discounts.withUse(), messageFrom(ctx))); |
| 317 | 378 | } |
| 318 | 379 | |
| 380 | /** | |
| 381 | * What the trail says about a discount. A discount code is not a secret — it | |
| 382 | * is printed on a poster — so it is recorded. A gift card's code is money to | |
| 383 | * whoever reads it, which is why no gift card is a subject here. | |
| 384 | */ | |
| 385 | const discountFields = (input: { | |
| 386 | code: string; | |
| 387 | kind: "percent" | "fixed" | "free_shipping"; | |
| 388 | value: number; | |
| 389 | min_subtotal_cents: number; | |
| 390 | max_redemptions: number | null; | |
| 391 | expires_at: string | null; | |
| 392 | }): changes.Snapshot => ({ | |
| 393 | code: input.code, | |
| 394 | kind: input.kind, | |
| 395 | value: input.kind === "percent" ? percent(input.value) : input.kind === "fixed" ? formatCents(input.value) : "", | |
| 396 | minimum: input.min_subtotal_cents === 0 ? "" : formatCents(input.min_subtotal_cents), | |
| 397 | uses: input.max_redemptions, | |
| 398 | expires: input.expires_at?.slice(0, 10) ?? "", | |
| 399 | active: true, | |
| 400 | }); | |
| 401 | ||
| 319 | 402 | /** A date is a real date or it is nothing; a percent is basis points of a whole. */ |
| 320 | 403 | const dayOrNull = (value: string): string | null | undefined => |
| 321 | 404 | value === "" ? null : discounts.endOfDay(value); |
| @@ -342,7 +425,7 @@ export function discountCreate(ctx: Ctx): Response { | ||
| 342 | 425 | if (min === undefined) return redirect("/admin/discounts?m=bad-value"); |
| 343 | 426 | if (maxRaw !== "" && !/^\d{1,6}$/.test(maxRaw)) return redirect("/admin/discounts?m=bad-value"); |
| 344 | 427 | try { |
| 345 | discounts.create({ | |
| 428 | const input = { | |
| 346 | 429 | code, |
| 347 | 430 | kind: kind as "percent" | "fixed" | "free_shipping", |
| 348 | 431 | value, |
| @@ -350,7 +433,11 @@ export function discountCreate(ctx: Ctx): Response { | ||
| 350 | 433 | max_redemptions: maxRaw === "" ? null : Number(maxRaw), |
| 351 | 434 | starts_at: null, |
| 352 | 435 | expires_at: expires, |
| 353 | }); | |
| 436 | }; | |
| 437 | db.transaction(() => { | |
| 438 | const id = discounts.create(input); | |
| 439 | changes.changed(actor(ctx), "discount", id, {}, discountFields(input)); | |
| 440 | })(); | |
| 354 | 441 | return redirect("/admin/discounts?m=created"); |
| 355 | 442 | } catch { |
| 356 | 443 | return redirect("/admin/discounts?m=bad-code"); |
| @@ -363,7 +450,11 @@ export function discountToggle(ctx: Ctx): Response { | ||
| 363 | 450 | const id = Number(ctx.params.id); |
| 364 | 451 | const found = Number.isInteger(id) ? discounts.byId(id) : null; |
| 365 | 452 | if (!found) return notFound(ctx); |
| 366 | discounts.setActive(found.id, ctx.form?.get("active") === "1"); | |
| 453 | const active = ctx.form?.get("active") === "1"; | |
| 454 | db.transaction(() => { | |
| 455 | discounts.setActive(found.id, active); | |
| 456 | changes.changed(actor(ctx), "discount", found.id, { active: found.active === 1 }, { active }); | |
| 457 | })(); | |
| 367 | 458 | return redirect("/admin/discounts?m=saved"); |
| 368 | 459 | } |
| 369 | 460 | |
| @@ -430,13 +521,28 @@ export function taxSave(ctx: Ctx): Response { | ||
| 430 | 521 | const bp = basisPoints(field(ctx.form, "rate", 7)); |
| 431 | 522 | if (bp === undefined) return redirect("/admin/tax?m=bad-rate"); |
| 432 | 523 | if (field(ctx.form, "what", 10) === "default") { |
| 433 | tax.setDefaultRateBp(bp); | |
| 524 | const was = tax.defaultRateBp(); | |
| 525 | db.transaction(() => { | |
| 526 | tax.setDefaultRateBp(bp); | |
| 527 | changes.changed(actor(ctx), "tax_default", 0, { rate: percent(was) }, { rate: percent(bp) }); | |
| 528 | })(); | |
| 434 | 529 | return redirect("/admin/tax?m=saved"); |
| 435 | 530 | } |
| 436 | 531 | const country = field(ctx.form, "country", 80); |
| 437 | 532 | const label = field(ctx.form, "label", 60); |
| 438 | 533 | if (country === "" || label === "") return redirect("/admin/tax?m=bad-rate"); |
| 439 | tax.upsertRate({ country, region: field(ctx.form, "region", 80), rate_bp: bp, label, active: true }); | |
| 534 | const region = field(ctx.form, "region", 80); | |
| 535 | const existing = tax.rateAt(country, region); | |
| 536 | db.transaction(() => { | |
| 537 | const id = tax.upsertRate({ country, region, rate_bp: bp, label, active: true }); | |
| 538 | changes.changed( | |
| 539 | actor(ctx), | |
| 540 | "tax_rate", | |
| 541 | id, | |
| 542 | existing ? { rate: percent(existing.rate_bp), label: existing.label, active: existing.active === 1 } : {}, | |
| 543 | { rate: percent(bp), label, active: true }, | |
| 544 | ); | |
| 545 | })(); | |
| 440 | 546 | return redirect("/admin/tax?m=saved"); |
| 441 | 547 | } |
| 442 | 548 | |
| @@ -444,7 +550,18 @@ export function taxRemove(ctx: Ctx): Response { | ||
| 444 | 550 | const blocked = guard(ctx); |
| 445 | 551 | if (blocked) return blocked; |
| 446 | 552 | const id = Number(ctx.params.id); |
| 447 | if (!Number.isInteger(id) || !tax.rateById(id)) return notFound(ctx); | |
| 448 | tax.removeRate(id); | |
| 553 | const rate = Number.isInteger(id) ? tax.rateById(id) : null; | |
| 554 | if (!rate) return notFound(ctx); | |
| 555 | // The row goes, so the trail carries what it said: nothing is left to join to. | |
| 556 | db.transaction(() => { | |
| 557 | tax.removeRate(rate.id); | |
| 558 | changes.record(actor(ctx), "tax_rate", rate.id, [ | |
| 559 | { | |
| 560 | field: "removed", | |
| 561 | before: `${rate.country}${rate.region ? ` ${rate.region}` : ""} ${percent(rate.rate_bp)} ${rate.label}`, | |
| 562 | after: "", | |
| 563 | }, | |
| 564 | ]); | |
| 565 | })(); | |
| 449 | 566 | return redirect("/admin/tax?m=saved"); |
| 450 | 567 | } |
src/changes.ts added +147
| @@ -0,0 +1,147 @@ | ||
| 1 | /** | |
| 2 | * What an admin changed, and who they were. | |
| 3 | * | |
| 4 | * Stock, payments, order events and gift-card balances already leave a row | |
| 5 | * behind. The catalog, the discount codes and the tax table did not: a price | |
| 6 | * could move between a shopper seeing it and paying it and nothing said who | |
| 7 | * moved it. This is that row — one per field that actually changed, written | |
| 8 | * inside the same transaction as the change, so a change that is rolled back | |
| 9 | * takes its audit row with it and a change that lands cannot be missing one. | |
| 10 | * | |
| 11 | * The actor is whoever the forge named: `ctx.who` on the web, the account the | |
| 12 | * forge put in the environment at the terminal. It is never a form field and | |
| 13 | * never a header the caller wrote — the caller does not get to sign the trail | |
| 14 | * that is about them. | |
| 15 | * | |
| 16 | * Nothing spendable is recorded. A discount code is printed on a poster; a | |
| 17 | * gift card's code is bearer money and its balance has a ledger of its own, | |
| 18 | * so gift cards are not a subject here. | |
| 19 | */ | |
| 20 | ||
| 21 | import { db, type AdminChange } from "./db.ts"; | |
| 22 | import { atKeyboard, type Who } from "./who.ts"; | |
| 23 | ||
| 24 | export type Change = AdminChange; | |
| 25 | export type Actor = { id: string; name: string }; | |
| 26 | export type Field = { field: string; before: string; after: string }; | |
| 27 | ||
| 28 | /** A value as an audit row says it: plain data in, one short string out. */ | |
| 29 | export type Value = string | number | boolean | null | undefined; | |
| 30 | export type Snapshot = Record<string, Value>; | |
| 31 | ||
| 32 | /** | |
| 33 | * The longest a recorded value gets. A 4,000-character description is a | |
| 34 | * product page, not an audit row: the row says that the description changed | |
| 35 | * and how it starts, and the page itself says the rest. | |
| 36 | */ | |
| 37 | export const CAP = 120; | |
| 38 | ||
| 39 | const clip = (value: string, cap = CAP): string => (value.length <= cap ? value : `${value.slice(0, cap - 1)}…`); | |
| 40 | ||
| 41 | /** | |
| 42 | * One line, no runs of whitespace, whole. Booleans read as words. | |
| 43 | * | |
| 44 | * This is the value a comparison may trust: clipping happens on the way into a | |
| 45 | * row, never before two values are held up against each other. An admin who | |
| 46 | * edits the tail of a 2,000-character description has changed it, and the | |
| 47 | * first 120 characters are no evidence either way. | |
| 48 | */ | |
| 49 | export const flat = (value: Value): string => | |
| 50 | value === null || value === undefined | |
| 51 | ? "" | |
| 52 | : typeof value === "boolean" | |
| 53 | ? value | |
| 54 | ? "yes" | |
| 55 | : "no" | |
| 56 | : String(value).replace(/\s+/g, " ").trim(); | |
| 57 | ||
| 58 | /** A value as a row says it: flat, and short enough to be a row. */ | |
| 59 | export const say = (value: Value): string => clip(flat(value)); | |
| 60 | ||
| 61 | /** Who the forge says is asking, as an actor. Anonymous cannot be an admin. */ | |
| 62 | export const actorOf = (who: Who): Actor => | |
| 63 | who.role === "anon" ? { id: "", name: "unknown" } : { id: who.id, name: who.name }; | |
| 64 | ||
| 65 | /** | |
| 66 | * Who is at the keyboard of the app's terminal. There are no headers on an | |
| 67 | * ssh session; the forge names the person in the environment before it starts | |
| 68 | * us, which is the same trust as the socket itself. | |
| 69 | */ | |
| 70 | export const atDesk = (): Actor => actorOf(atKeyboard()); | |
| 71 | ||
outdated
probe
outdated
Changes requested — the verdict for my full review on In short: |
||
| 72 | /** | |
| 73 | * The fields of two snapshots that differ. A form submitted with nothing | |
| 74 | * changed produces an empty list, and an empty list writes nothing. | |
| 75 | * | |
| 76 | * Whole values decide whether something moved; what the row keeps of them is | |
| 77 | * clipped. Comparing the clipped forms would lose every edit past `CAP`. | |
| 78 | */ | |
| 79 | export function diff(before: Snapshot, after: Snapshot): Field[] { | |
| 80 | const fields: Field[] = []; | |
| 81 | for (const [name, value] of Object.entries(after)) { | |
| 82 | if (flat(before[name]) !== flat(value)) | |
| 83 | fields.push({ field: name, before: say(before[name]), after: say(value) }); | |
| 84 | } | |
| 85 | return fields; | |
| 86 | } | |
| 87 | ||
| 88 | /** Write the rows. Returns how many there were, which is zero for a no-op. */ | |
| 89 | export function record(actor: Actor, subject: string, subjectId: number, fields: Field[]): number { | |
| 90 | for (const one of fields) | |
| 91 | db.query( | |
| 92 | `INSERT INTO admin_change (actor_id, actor_name, subject, subject_id, field, before, after) | |
| 93 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`, | |
| 94 | ).run( | |
| 95 | clip(actor.id, 64), | |
| 96 | clip(actor.name, 64), | |
| 97 | clip(subject, 32), | |
| 98 | subjectId, | |
| 99 | clip(one.field, 40), | |
| 100 | clip(one.before), | |
| 101 | clip(one.after), | |
| 102 | ); | |
| 103 | return fields.length; | |
| 104 | } | |
| 105 | ||
| 106 | /** The common case: two snapshots of one thing, and only what moved is kept. */ | |
| 107 | export const changed = (actor: Actor, subject: string, subjectId: number, before: Snapshot, after: Snapshot): number => | |
| 108 | record(actor, subject, subjectId, diff(before, after)); | |
| 109 | ||
| 110 | /** | |
| 111 | * A row as a page reads it: the subject named, and the product a variant | |
| 112 | * belongs to so the row can link somewhere. Every lookup is by primary key. | |
| 113 | */ | |
| 114 | export type Reading = Change & { subject_name: string | null; product_id: number | null }; | |
| 115 | ||
| 116 | const READING = `SELECT c.*, | |
| 117 | CASE c.subject | |
| 118 | WHEN 'product' THEN (SELECT p.title FROM product p WHERE p.id = c.subject_id) | |
| 119 | WHEN 'variant' THEN (SELECT v.sku FROM variant v WHERE v.id = c.subject_id) | |
| 120 | WHEN 'discount' THEN (SELECT d.code FROM discount d WHERE d.id = c.subject_id) | |
| 121 | WHEN 'tax_rate' THEN (SELECT t.country || CASE WHEN t.region = '' THEN '' ELSE ' ' || t.region END | |
| 122 | FROM tax_rate t WHERE t.id = c.subject_id) | |
| 123 | END AS subject_name, | |
| 124 | CASE WHEN c.subject = 'variant' THEN (SELECT v.product_id FROM variant v WHERE v.id = c.subject_id) END AS product_id | |
| 125 | FROM admin_change c ORDER BY c.at DESC, c.id DESC LIMIT ?1`; | |
| 126 | ||
| 127 | /** The most recent changes, newest first. The index is the whole ordering. */ | |
| 128 | export const recent = (limit = 100): Reading[] => | |
| 129 | db.query<Reading, [number]>(READING).all(Math.max(1, Math.min(500, Math.trunc(limit) || 100))); | |
| 130 | ||
| 131 | /** | |
| 132 | * What the subject is called on the page: its name now, or failing that its | |
| 133 | * id. The default tax rate is not a row in any table — it is the one setting | |
| 134 | * the tax table falls back to — so it is named the way the tax page and the | |
| 135 | * terminal's tax tab already name it. | |
| 136 | */ | |
| 137 | export const nameOf = (row: Reading): string => | |
| 138 | row.subject === "tax_default" ? "anywhere else" : (row.subject_name ?? `#${row.subject_id}`); | |
| 139 | ||
| 140 | /** The page this subject has, where there is one. */ | |
| 141 | export function href(row: Reading): string | null { | |
| 142 | if (row.subject === "product") return `/admin/products/${row.subject_id}`; | |
| 143 | if (row.subject === "variant") return row.product_id === null ? null : `/admin/products/${row.product_id}`; | |
| 144 | if (row.subject === "discount") return "/admin/discounts"; | |
| 145 | if (row.subject === "tax_rate" || row.subject === "tax_default") return "/admin/tax"; | |
| 146 | return null; | |
| 147 | } |
src/db.ts +30
| @@ -202,6 +202,25 @@ CREATE INDEX IF NOT EXISTS gift_card_entry_card ON gift_card_entry(gift_card_id, | ||
| 202 | 202 | CREATE INDEX IF NOT EXISTS gift_card_entry_order ON gift_card_entry(order_id); |
| 203 | 203 | CREATE UNIQUE INDEX IF NOT EXISTS gift_card_entry_key ON gift_card_entry(idempotency_key); |
| 204 | 204 | |
| 205 | -- What an admin changed about what the shop sells and what it charges: one | |
| 206 | -- row per field that actually moved, written in the same transaction as the | |
| 207 | -- change itself. The actor is whoever the forge named, never a form field. | |
| 208 | -- Nothing a bearer could spend is recorded here: a gift card's code is money. | |
| 209 | CREATE TABLE IF NOT EXISTS admin_change ( | |
| 210 | id INTEGER PRIMARY KEY, | |
| 211 | at TEXT NOT NULL DEFAULT (datetime('now')), | |
| 212 | actor_id TEXT NOT NULL DEFAULT '', | |
| 213 | actor_name TEXT NOT NULL DEFAULT '', | |
| 214 | subject TEXT NOT NULL, | |
| 215 | subject_id INTEGER NOT NULL DEFAULT 0, | |
| 216 | field TEXT NOT NULL, | |
| 217 | before TEXT NOT NULL DEFAULT '', | |
| 218 | after TEXT NOT NULL DEFAULT '' | |
| 219 | ); | |
| 220 | -- Newest first is the only way this table is ever read, and the index is the | |
| 221 | -- whole order: no scan, however many rows there are. | |
| 222 | CREATE INDEX IF NOT EXISTS admin_change_at ON admin_change(at DESC, id DESC); | |
| 223 | ||
| 205 | 224 | -- Tax by where the parcel goes. Country and region are stored upper-cased; |
| 206 | 225 | -- an empty region is the whole country's rate. |
| 207 | 226 | CREATE TABLE IF NOT EXISTS tax_rate ( |
| @@ -368,6 +387,17 @@ export type GiftCard = { | ||
| 368 | 387 | active: number; |
| 369 | 388 | created_at: string; |
| 370 | 389 | }; |
| 390 | export type AdminChange = { | |
| 391 | id: number; | |
| 392 | at: string; | |
| 393 | actor_id: string; | |
| 394 | actor_name: string; | |
| 395 | subject: string; | |
| 396 | subject_id: number; | |
| 397 | field: string; | |
| 398 | before: string; | |
| 399 | after: string; | |
| 400 | }; | |
| 371 | 401 | export type TaxRate = { |
| 372 | 402 | id: number; |
| 373 | 403 | country: string; |
src/desk.ts +71 −14
| @@ -16,15 +16,25 @@ | ||
| 16 | 16 | * and a tab cannot be opened by someone who may not read what is on it. |
| 17 | 17 | */ |
| 18 | 18 | |
| 19 | import * as changes from "./changes.ts"; | |
| 19 | 20 | import * as discounts from "./discounts.ts"; |
| 20 | 21 | import * as giftcards from "./giftcards.ts"; |
| 21 | 22 | import * as tax from "./tax.ts"; |
| 23 | import { db } from "./db.ts"; | |
| 22 | 24 | import { formatCents, parseCents } from "./money.ts"; |
| 23 | 25 | import type { Who } from "./who.ts"; |
| 24 | 26 | |
| 25 | 27 | export type Done = { ok: boolean; message: string }; |
| 26 | 28 | |
| 27 | export const TABS = ["stock", "orders", "outbox", "codes", "cards", "tax"] as const; | |
| 29 | const percent = (bp: number): string => `${(bp / 100).toFixed(2)}%`; | |
| 30 | ||
| 31 | /** | |
| 32 | * The tabs the terminal has. `changes` is the trail the admin pages write: | |
| 33 | * a change made here is the same change the admin pages make, so it leaves | |
| 34 | * the same row. The actor is the account the forge named at the keyboard — | |
| 35 | * the environment it started us with, which is the same trust as the socket. | |
| 36 | */ | |
| 37 | export const TABS = ["stock", "orders", "outbox", "codes", "cards", "tax", "changes"] as const; | |
| 28 | 38 | export type Tab = (typeof TABS)[number]; |
| 29 | 39 | |
| 30 | 40 | /** |
| @@ -75,6 +85,9 @@ const DESK: Record<Tab, { read: "anyone" | "admin"; keys: { key: string; label: | ||
| 75 | 85 | { key: "x", label: "x remove" }, |
| 76 | 86 | ], |
| 77 | 87 | }, |
| 88 | // Who changed a price, a code or a rate is the shop's own business, and it | |
| 89 | // names people: the same gate the orders and the cards are behind. | |
| 90 | changes: { read: "admin", keys: [] }, | |
| 78 | 91 | }; |
| 79 | 92 | |
| 80 | 93 | /** True when this key on this tab is the desk's work, and so an admin's. */ |
| @@ -141,15 +154,33 @@ export function newDiscount(line: string): Done { | ||
| 141 | 154 | |
| 142 | 155 | if (discounts.byCode(code)) return no(`${code} already exists.`); |
| 143 | 156 | try { |
| 144 | const id = discounts.create({ | |
| 145 | code, | |
| 146 | kind: worth.kind, | |
| 147 | value: worth.value, | |
| 148 | min_subtotal_cents: min, | |
| 149 | max_redemptions: uses, | |
| 150 | starts_at: null, | |
| 151 | expires_at: expires, | |
| 152 | }); | |
| 157 | const id = db.transaction(() => { | |
| 158 | const made = discounts.create({ | |
| 159 | code, | |
| 160 | kind: worth.kind, | |
| 161 | value: worth.value, | |
| 162 | min_subtotal_cents: min, | |
| 163 | max_redemptions: uses, | |
| 164 | starts_at: null, | |
| 165 | expires_at: expires, | |
| 166 | }); | |
| 167 | changes.changed( | |
| 168 | changes.atDesk(), | |
| 169 | "discount", | |
| 170 | made, | |
| 171 | {}, | |
| 172 | { | |
| 173 | code, | |
| 174 | kind: worth.kind, | |
| 175 | value: worth.kind === "percent" ? percent(worth.value) : worth.kind === "fixed" ? formatCents(worth.value) : "", | |
| 176 | minimum: min === 0 ? "" : formatCents(min), | |
| 177 | uses, | |
| 178 | expires: expires?.slice(0, 10) ?? "", | |
| 179 | active: true, | |
| 180 | }, | |
| 181 | ); | |
| 182 | return made; | |
| 183 | })(); | |
| 153 | 184 | const made = discounts.byId(id)!; |
| 154 | 185 | return yes( |
| 155 | 186 | `${made.code} — ${discounts.describe(made)}${min > 0 ? `, over ${formatCents(min)}` : ""}${ |
| @@ -164,7 +195,10 @@ export function newDiscount(line: string): Done { | ||
| 164 | 195 | export function toggleDiscount(id: number): Done { |
| 165 | 196 | const found = discounts.byId(id); |
| 166 | 197 | if (!found) return no("No code selected."); |
| 167 | discounts.setActive(found.id, found.active !== 1); | |
| 198 | db.transaction(() => { | |
| 199 | discounts.setActive(found.id, found.active !== 1); | |
| 200 | changes.changed(changes.atDesk(), "discount", found.id, { active: found.active === 1 }, { active: found.active !== 1 }); | |
| 201 | })(); | |
| 168 | 202 | return yes(`${found.code} is now ${found.active === 1 ? "off" : "on"}.`); |
| 169 | 203 | } |
| 170 | 204 | |
| @@ -225,14 +259,28 @@ export function setRate(line: string): Done { | ||
| 225 | 259 | if (country === "") return no("A rate needs a country."); |
| 226 | 260 | if (points === undefined || points > 10000) return no("A rate is a percentage between 0 and 100, like 8.75."); |
| 227 | 261 | const label = (parts[3] ?? "").slice(0, 60).trim() || `${country}${region ? ` ${region}` : ""} tax`; |
| 228 | tax.upsertRate({ country, region, rate_bp: points, label, active: true }); | |
| 262 | const existing = tax.rateAt(country, region); | |
| 263 | db.transaction(() => { | |
| 264 | const id = tax.upsertRate({ country, region, rate_bp: points, label, active: true }); | |
| 265 | changes.changed( | |
| 266 | changes.atDesk(), | |
| 267 | "tax_rate", | |
| 268 | id, | |
| 269 | existing ? { rate: percent(existing.rate_bp), label: existing.label, active: existing.active === 1 } : {}, | |
| 270 | { rate: percent(points), label, active: true }, | |
| 271 | ); | |
| 272 | })(); | |
| 229 | 273 | return yes(`${country}${region ? ` ${region}` : ""} — ${(points / 100).toFixed(2)}% (${label}).`); |
| 230 | 274 | } |
| 231 | 275 | |
| 232 | 276 | export function setDefaultRate(text: string): Done { |
| 233 | 277 | const points = parseCents(text.trim()); |
| 234 | 278 | if (points === undefined || points > 10000) return no("A rate is a percentage between 0 and 100, like 8.5."); |
| 235 | tax.setDefaultRateBp(points); | |
| 279 | const was = tax.defaultRateBp(); | |
| 280 | db.transaction(() => { | |
| 281 | tax.setDefaultRateBp(points); | |
| 282 | changes.changed(changes.atDesk(), "tax_default", 0, { rate: percent(was) }, { rate: percent(points) }); | |
| 283 | })(); | |
| 236 | 284 | return yes(`Anywhere the table does not name is taxed at ${(points / 100).toFixed(2)}%.`); |
| 237 | 285 | } |
| 238 | 286 | |
| @@ -240,6 +288,15 @@ export function removeRate(id: number, confirmation: string): Done { | ||
| 240 | 288 | const rate = tax.rateById(id); |
| 241 | 289 | if (!rate) return no("No rate selected."); |
| 242 | 290 | if (!/^y(es)?$/i.test(confirmation.trim())) return no(`${rate.country} ${rate.region} kept.`); |
| 243 | tax.removeRate(rate.id); | |
| 291 | db.transaction(() => { | |
| 292 | tax.removeRate(rate.id); | |
| 293 | changes.record(changes.atDesk(), "tax_rate", rate.id, [ | |
| 294 | { | |
| 295 | field: "removed", | |
| 296 | before: `${rate.country}${rate.region ? ` ${rate.region}` : ""} ${percent(rate.rate_bp)} ${rate.label}`, | |
| 297 | after: "", | |
| 298 | }, | |
| 299 | ]); | |
| 300 | })(); | |
| 244 | 301 | return yes(`${rate.country}${rate.region ? ` ${rate.region}` : ""} removed; it now falls to the default.`); |
| 245 | 302 | } |
src/pages/admin.ts +36
| @@ -9,6 +9,7 @@ import { FORGE_LINK_ATTRS, SIGN_OUT_HREF, signInHref } from "../who.ts"; | ||
| 9 | 9 | import { formatCents } from "../money.ts"; |
| 10 | 10 | import { itemsTable, orderTotals, statusPill } from "./checkout.ts"; |
| 11 | 11 | import { describe } from "../discounts.ts"; |
| 12 | import { href, nameOf, type Reading } from "../changes.ts"; | |
| 12 | 13 | import type { DiscountStats } from "../discounts.ts"; |
| 13 | 14 | import type { ProductWithPrice } from "../catalog.ts"; |
| 14 | 15 | import type { Category, GiftCard, Order, OrderItem, Payment, TaxRate, Variant } from "../db.ts"; |
| @@ -26,6 +27,7 @@ export const nav = (current: string): string => | ||
| 26 | 27 | ["/admin/giftcards", "Gift cards", "giftcards"], |
| 27 | 28 | ["/admin/tax", "Tax", "tax"], |
| 28 | 29 | ["/admin/mail", "Outbox", "mail"], |
| 30 | ["/admin/changes", "Changes", "changes"], | |
| 29 | 31 | ] |
| 30 | 32 | .map(([href, label, key]) => |
| 31 | 33 | key === current ? `<strong>${escape(label)}</strong>` : `<a href="${escape(href)}">${escape(label)}</a>`, |
| @@ -341,6 +343,40 @@ ${ | ||
| 341 | 343 | } |
| 342 | 344 | `; |
| 343 | 345 | |
| 346 | /** | |
| 347 | * The changes an admin made to what the shop sells and what it charges: one | |
| 348 | * line per field that moved, newest first. Every value here was typed by | |
| 349 | * somebody, so every value goes through `escape` on its way out. | |
| 350 | */ | |
| 351 | export const changes = (list: Reading[]): string => ` | |
| 352 | ${nav("changes")} | |
| 353 | <h1>Changes</h1> | |
| 354 | <p class="lede">What the shop sells and what it charges, and who changed it. Stock, payments, refunds and | |
| 355 | gift-card balances have ledgers of their own — this is everything else.</p> | |
| 356 | ${ | |
| 357 | list.length === 0 | |
| 358 | ? '<p class="muted">Nothing has been changed yet.</p>' | |
| 359 | : `<table> | |
| 360 | <thead><tr><th>When</th><th>Who</th><th>What</th><th>Field</th><th>From</th><th>To</th></tr></thead> | |
| 361 | <tbody>${list | |
| 362 | .map((row) => { | |
| 363 | const where = href(row); | |
| 364 | const named = escape(nameOf(row)); | |
| 365 | return `<tr> | |
| 366 | <td class="muted" style="white-space:nowrap">${escape(row.at)}</td> | |
| 367 | <td>${escape(row.actor_name)}<div class="muted" style="font-size:.8rem">${escape(row.actor_id || "—")}</div></td> | |
| 368 | <td>${where ? `<a href="${escape(where)}">${named}</a>` : named} | |
| 369 | <div class="muted" style="font-size:.8rem">${escape(row.subject)}</div></td> | |
| 370 | <td>${escape(row.field)}</td> | |
| 371 | <td class="muted">${escape(row.before || "—")}</td> | |
| 372 | <td>${escape(row.after || "—")}</td> | |
| 373 | </tr>`; | |
| 374 | }) | |
| 375 | .join("\n")}</tbody> | |
| 376 | </table>` | |
| 377 | } | |
| 378 | `; | |
| 379 | ||
| 344 | 380 | // --- codes, cards and tax ---------------------------------------------------- |
| 345 | 381 | |
| 346 | 382 | const dateCell = (value: string | null): string => escape(value ?? "—"); |
src/routes.ts +1
| @@ -72,6 +72,7 @@ export const routes: Route[] = [ | ||
| 72 | 72 | { method: "POST", pattern: "/admin/orders/:number/refund", handler: admin.orderRefund }, |
| 73 | 73 | { method: "POST", pattern: "/admin/orders/:number/cancel", handler: admin.orderCancel }, |
| 74 | 74 | { method: "GET", pattern: "/admin/mail", handler: admin.mailbox }, |
| 75 | { method: "GET", pattern: "/admin/changes", handler: admin.changeList }, | |
| 75 | 76 | ]; |
| 76 | 77 | |
| 77 | 78 | export type Match = { route: Route; params: Record<string, string> }; |
src/server.test.ts +146
| @@ -1158,6 +1158,152 @@ test("the rate the cart estimates is the rate the order charges, from one table" | ||
| 1158 | 1158 | } |
| 1159 | 1159 | }); |
| 1160 | 1160 | |
| 1161 | test("a price change is a row that names the admin, and a no-op save writes nothing", async () => { | |
| 1162 | const admin = asAdmin(); | |
| 1163 | const form = await admin.page("/admin/products/new"); | |
| 1164 | const categoryId = db.query<{ id: number }, []>("SELECT id FROM category ORDER BY id LIMIT 1").get()!.id; | |
| 1165 | const created = await admin.post( | |
| 1166 | "/admin/products", | |
| 1167 | { title: "Audit Kettle", slug: "", category: String(categoryId), blurb: "Watched.", description: "", active: "1" }, | |
| 1168 | Shopper.tokenIn(form), | |
| 1169 | ); | |
| 1170 | const editPath = created.headers.get("location")!.split("?")[0]!; | |
| 1171 | const edit = await admin.page(editPath); | |
| 1172 | await admin.post(`${editPath}/variants`, { sku: "aud-kettle-1", name: "Steel", price: "20.00", stock: "0" }, Shopper.tokenIn(edit)); | |
| 1173 | ||
| 1174 | const id = variantId("AUD-KETTLE-1"); | |
| 1175 | const rows = (): { field: string; before: string; after: string; actor_id: string; actor_name: string }[] => | |
| 1176 | db | |
| 1177 | .query<{ field: string; before: string; after: string; actor_id: string; actor_name: string }, [number]>( | |
| 1178 | "SELECT field, before, after, actor_id, actor_name FROM admin_change WHERE subject = 'variant' AND subject_id = ?1 ORDER BY id", | |
| 1179 | ) | |
| 1180 | .all(id); | |
| 1181 | expect(rows().map((row) => row.field)).toEqual(["sku", "name", "price"]); | |
| 1182 | ||
| 1183 | const priced = await admin.post( | |
| 1184 | `/admin/variants/${id}`, | |
| 1185 | { name: "Steel", price: "21.50", active: "1" }, | |
| 1186 | Shopper.tokenIn(await admin.page(editPath)), | |
| 1187 | ); | |
| 1188 | expect(priced.status).toBe(303); | |
| 1189 | const change = rows().at(-1)!; | |
| 1190 | expect(change).toMatchObject({ field: "price", before: "$20.00", after: "$21.50", actor_id: "u_1", actor_name: "ada" }); | |
| 1191 | ||
| 1192 | // The same form again moves nothing, so it says nothing. | |
| 1193 | const written = rows().length; | |
| 1194 | const again = await admin.post( | |
| 1195 | `/admin/variants/${id}`, | |
| 1196 | { name: "Steel", price: "21.50", active: "1" }, | |
| 1197 | Shopper.tokenIn(await admin.page(editPath)), | |
| 1198 | ); | |
| 1199 | expect(again.status).toBe(303); | |
| 1200 | expect(rows().length).toBe(written); | |
| 1201 | ||
| 1202 | // And the page reads it back, with the variant linked to the product's page. | |
| 1203 | const page = await admin.page("/admin/changes"); | |
| 1204 | expect(page).toContain("AUD-KETTLE-1"); | |
| 1205 | expect(page).toContain("$21.50"); | |
| 1206 | expect(page).toContain("ada"); | |
| 1207 | expect(page).toContain(`href="${editPath}"`); | |
| 1208 | }); | |
| 1209 | ||
| 1210 | test("editing the tail of a long description leaves a row, like any other edit", async () => { | |
| 1211 | const admin = asAdmin(); | |
| 1212 | const form = await admin.page("/admin/products/new"); | |
| 1213 | const categories = db.query<{ id: number }, []>("SELECT id FROM category ORDER BY id LIMIT 2").all(); | |
| 1214 | const [first, second] = [categories[0]!.id, categories[1]!.id]; | |
| 1215 | const long = "a".repeat(300); | |
| 1216 | const created = await admin.post( | |
| 1217 | "/admin/products", | |
| 1218 | { title: "Long Thing", slug: "", category: String(first), blurb: "b", description: long, active: "1" }, | |
| 1219 | Shopper.tokenIn(form), | |
| 1220 | ); | |
| 1221 | const editPath = created.headers.get("location")!.split("?")[0]!; | |
| 1222 | const productId = Number(editPath.split("/").at(-1)); | |
| 1223 | ||
| 1224 | const fields = (): string[] => | |
| 1225 | db | |
| 1226 | .query<{ field: string }, [number]>( | |
| 1227 | "SELECT field FROM admin_change WHERE subject = 'product' AND subject_id = ?1 ORDER BY id", | |
| 1228 | ) | |
| 1229 | .all(productId) | |
| 1230 | .map((row) => row.field); | |
| 1231 | const written = fields().length; | |
| 1232 | ||
| 1233 | // Only the tail of the description moves, past the 120 characters a row keeps. | |
| 1234 | const saved = await admin.post( | |
| 1235 | editPath, | |
| 1236 | { | |
| 1237 | title: "Long Thing", | |
| 1238 | slug: "long-thing", | |
| 1239 | category: String(second), | |
| 1240 | blurb: "b", | |
| 1241 | description: `${long} and a closing word`, | |
| 1242 | active: "1", | |
| 1243 | }, | |
| 1244 | Shopper.tokenIn(await admin.page(editPath)), | |
| 1245 | ); | |
| 1246 | expect(saved.status).toBe(303); | |
| 1247 | expect(fields().slice(written)).toEqual(["category", "description"]); | |
| 1248 | }); | |
| 1249 | ||
| 1250 | test("a tax rate and a discount code each leave a row, and nothing secret is in one", async () => { | |
| 1251 | const admin = asAdmin(); | |
| 1252 | const taxPage = await admin.page("/admin/tax"); | |
| 1253 | const saved = await admin.post( | |
| 1254 | "/admin/tax", | |
| 1255 | { country: "Narnia", region: "", rate: "12.50", label: "Narnian VAT" }, | |
| 1256 | Shopper.tokenIn(taxPage), | |
| 1257 | ); | |
| 1258 | expect(saved.status).toBe(303); | |
| 1259 | const rate = db | |
| 1260 | .query<{ field: string; after: string; actor_name: string }, []>( | |
| 1261 | "SELECT field, after, actor_name FROM admin_change WHERE subject = 'tax_rate' AND field = 'rate' ORDER BY id DESC LIMIT 1", | |
| 1262 | ) | |
| 1263 | .get()!; | |
| 1264 | expect(rate.after).toBe("12.50%"); | |
| 1265 | expect(rate.actor_name).toBe("ada"); | |
| 1266 | ||
| 1267 | // The default rate is a setting, not a row in the table, so it is named for | |
| 1268 | // what it is rather than read back as an id nothing has. | |
| 1269 | const was = tax.defaultRateBp(); | |
| 1270 | try { | |
| 1271 | const defaulted = await admin.post( | |
| 1272 | "/admin/tax", | |
| 1273 | { what: "default", rate: "9.25" }, | |
| 1274 | Shopper.tokenIn(await admin.page("/admin/tax")), | |
| 1275 | ); | |
| 1276 | expect(defaulted.status).toBe(303); | |
| 1277 | } finally { | |
| 1278 | tax.setDefaultRateBp(was); | |
| 1279 | } | |
| 1280 | ||
| 1281 | const codes = await admin.page("/admin/discounts"); | |
| 1282 | await admin.post( | |
| 1283 | "/admin/discounts", | |
| 1284 | { code: "AUDIT15", kind: "percent", value: "15", min: "", max: "", expires: "" }, | |
| 1285 | Shopper.tokenIn(codes), | |
| 1286 | ); | |
| 1287 | const page = await admin.page("/admin/changes"); | |
| 1288 | expect(page).toContain("AUDIT15"); | |
| 1289 | expect(page).toContain("Narnia"); | |
| 1290 | expect(page).toContain("anywhere else"); | |
| 1291 | expect(page).not.toContain("#0"); | |
| 1292 | // A gift card's code is money to whoever reads it, so no card is a subject. | |
| 1293 | expect( | |
| 1294 | db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM admin_change WHERE subject LIKE 'gift%'").get()!.n, | |
| 1295 | ).toBe(0); | |
| 1296 | }); | |
| 1297 | ||
| 1298 | test("/admin/changes is an admin's page and nobody else's", async () => { | |
| 1299 | const anon = new Shopper(); | |
| 1300 | expect((await anon.get("/admin/changes")).status).toBe(403); | |
| 1301 | const user = new Shopper({ id: "u_2", name: "bo", role: "user" }); | |
| 1302 | const refused = await user.get("/admin/changes"); | |
| 1303 | expect(refused.status).toBe(403); | |
| 1304 | expect(await refused.text()).not.toContain("AUD-KETTLE-1"); | |
| 1305 | }); | |
| 1306 | ||
| 1161 | 1307 | test("this file's rows are this file's own", async () => { |
| 1162 | 1308 | // The isolation every test file depends on: the database the app modules |
| 1163 | 1309 | // are querying is the one this file opened, not one another file chose. |
src/tax.ts +27 −11
| @@ -46,6 +46,12 @@ export const rates = (): TaxRate[] => | ||
| 46 | 46 | export const rateById = (id: number): TaxRate | null => |
| 47 | 47 | db.query<TaxRate, [number]>("SELECT * FROM tax_rate WHERE id = ?1").get(id); |
| 48 | 48 | |
| 49 | /** The exact row a country and region name, if the table has one. */ | |
| 50 | export const rateAt = (country: string, region: string): TaxRate | null => | |
| 51 | db | |
| 52 | .query<TaxRate, [string, string]>("SELECT * FROM tax_rate WHERE country = ?1 AND region = ?2") | |
| 53 | .get(normalize(country), normalize(region)); | |
| 54 | ||
| 49 | 55 | /** |
| 50 | 56 | * The rate for an address, or the default when the table does not know it. |
| 51 | 57 | * With no address it is the home country's rate — the one the checkout form |
| @@ -68,17 +74,27 @@ export function rateFor(destination: Destination = home()): Rate { | ||
| 68 | 74 | export const taxOn = (base_cents: number, rate: Rate): number => |
| 69 | 75 | base_cents <= 0 ? 0 : Math.round((base_cents * rate.rate_bp) / 10000); |
| 70 | 76 | |
| 71 | export function upsertRate(input: { country: string; region: string; rate_bp: number; label: string; active: boolean }): void { | |
| 72 | db.query( | |
| 73 | `INSERT INTO tax_rate (country, region, rate_bp, label, active) VALUES ($country, $region, $rate_bp, $label, $active) | |
| 74 | ON CONFLICT(country, region) DO UPDATE SET rate_bp = excluded.rate_bp, label = excluded.label, active = excluded.active`, | |
| 75 | ).run({ | |
| 76 | $country: normalize(input.country), | |
| 77 | $region: normalize(input.region), | |
| 78 | $rate_bp: input.rate_bp, | |
| 79 | $label: input.label, | |
| 80 | $active: input.active ? 1 : 0, | |
| 81 | }); | |
| 77 | /** Writes the rate and answers with the row's id, new or found. */ | |
| 78 | export function upsertRate(input: { | |
| 79 | country: string; | |
| 80 | region: string; | |
| 81 | rate_bp: number; | |
| 82 | label: string; | |
| 83 | active: boolean; | |
| 84 | }): number { | |
| 85 | return db | |
| 86 | .query<{ id: number }, any>( | |
| 87 | `INSERT INTO tax_rate (country, region, rate_bp, label, active) VALUES ($country, $region, $rate_bp, $label, $active) | |
| 88 | ON CONFLICT(country, region) DO UPDATE SET rate_bp = excluded.rate_bp, label = excluded.label, active = excluded.active | |
| 89 | RETURNING id`, | |
| 90 | ) | |
| 91 | .get({ | |
| 92 | $country: normalize(input.country), | |
| 93 | $region: normalize(input.region), | |
| 94 | $rate_bp: input.rate_bp, | |
| 95 | $label: input.label, | |
| 96 | $active: input.active ? 1 : 0, | |
| 97 | })!.id; | |
| 82 | 98 | } |
| 83 | 99 | |
| 84 | 100 | export const removeRate = (id: number): void => { |
src/tty.ts +18 −4
| @@ -12,8 +12,9 @@ | ||
| 12 | 12 | * a receipt carries a gift card's code in full, and that code is money to |
| 13 | 13 | * whoever reads it. `src/desk.ts` holds the one table both gates read. |
| 14 | 14 | * |
| 15 | * 1..6 switch between stock, orders, the outbox, discount codes, gift cards | |
| 16 | * and the tax table — everything /admin can see. j/k move, and each tab says | |
| 15 | * 1..7 switch between stock, orders, the outbox, discount codes, gift cards, | |
| 16 | * the tax table and the changes an admin made to any of it — everything | |
| 17 | * /admin can see. j/k move, and each tab says | |
| 17 | 18 | * at its foot what it can do: s moves stock with a reason, / finds an order by |
| 18 | 19 | * number, name or email, r refunds what is left of an order and puts it back |
| 19 | 20 | * on the shelf, n writes a code, i issues a card, e sets a tax rate, c looks a |
| @@ -31,6 +32,7 @@ import { history, addStock, setStock } from "./inventory.ts"; | ||
| 31 | 32 | import { itemsOf, paymentsOf, recent, refund, refundableCents, refundedCents } from "./orders.ts"; |
| 32 | 33 | import { outbox } from "./mail.ts"; |
| 33 | 34 | import { atKeyboard } from "./who.ts"; |
| 35 | import { nameOf, recent as recentChanges, type Reading } from "./changes.ts"; | |
| 34 | 36 | import type { GiftCard, Order, TaxRate } from "./db.ts"; |
| 35 | 37 | |
| 36 | 38 | const person = atKeyboard(); |
| @@ -89,7 +91,9 @@ const rowsFor = (): unknown[] => | ||
| 89 | 91 | ? discounts.withUse() |
| 90 | 92 | : tab === "cards" |
| 91 | 93 | ? giftcards.all() |
| 92 | : tax.rates(); | |
| 94 | : tab === "tax" | |
| 95 | ? tax.rates() | |
| 96 | : recentChanges(100); | |
| 93 | 97 | |
| 94 | 98 | /** A message from the desk, with a refusal marked so it cannot be missed. */ |
| 95 | 99 | const say = (done: desk.Done): string => (done.ok ? done.message : `${esc}33m${done.message}${esc}0m`); |
| @@ -103,7 +107,7 @@ const notYours = say({ ok: false, message: "That is an admin's to do." }); | ||
| 103 | 107 | */ |
| 104 | 108 | const hints = (): string => |
| 105 | 109 | [ |
| 106 | "1-6 tabs", | |
| 110 | "1-7 tabs", | |
| 107 | 111 | "j/k move", |
| 108 | 112 | ...desk.offered(tab, person), |
| 109 | 113 | ...(tab === "orders" && filter !== "" ? [`showing “${filter}”`] : []), |
| @@ -201,6 +205,16 @@ function draw(): void { | ||
| 201 | 205 | lines.push(`${mark} ${i === cursor ? bold(text) : text}`); |
| 202 | 206 | }); |
| 203 | 207 | } |
| 208 | if (tab === "changes") | |
| 209 | (rows as Reading[]).forEach((row, i) => { | |
| 210 | const mark = i === cursor ? "▸" : " "; | |
| 211 | const text = `${row.at.padEnd(20)} ${row.actor_name.padEnd(14).slice(0, 14)} ${`${row.subject} ${nameOf(row)}` | |
| 212 | .padEnd(26) | |
| 213 | .slice(0, 26)} ${row.field.padEnd(12).slice(0, 12)} ${(row.before || "—").slice(0, 20).padEnd(20)} → ${( | |
| 214 | row.after || "—" | |
| 215 | ).slice(0, 20)}`; | |
| 216 | lines.push(`${mark} ${i === cursor ? bold(text) : text}`); | |
| 217 | }); | |
| 204 | 218 | lines.push(""); |
| 205 | 219 | if (message) lines.push(message, ""); |
| 206 | 220 | lines.push(prompt === undefined ? dim(hints()) : `${prompt.label} ${prompt.text}${esc}7m ${esc}0m`); |
src/units.test.ts +60
| @@ -17,6 +17,7 @@ const mine = await import("./mine.ts"); | ||
| 17 | 17 | const peer = await import("./peer.ts"); |
| 18 | 18 | const { escape } = await import("./html.ts"); |
| 19 | 19 | const catalog = await import("./catalog.ts"); |
| 20 | const changes = await import("./changes.ts"); | |
| 20 | 21 | const desk = await import("./desk.ts"); |
| 21 | 22 | const discounts = await import("./discounts.ts"); |
| 22 | 23 | const giftcards = await import("./giftcards.ts"); |
| @@ -299,6 +300,65 @@ test("the orders a browser remembers are signed, deduplicated and capped", () => | ||
| 299 | 300 | expect(mine.decode(undefined, secret)).toEqual([]); |
| 300 | 301 | }); |
| 301 | 302 | |
| 303 | test("an audit row is one field that moved, capped, and a no-op is no row at all", () => { | |
| 304 | const ada = { id: "u_9", name: "ada" }; | |
| 305 | expect(changes.diff({ price: "$1.00", name: "Steel" }, { price: "$1.00", name: "Steel" })).toEqual([]); | |
| 306 | expect(changes.changed(ada, "product", 1, { title: "Kettle" }, { title: "Kettle" })).toBe(0); | |
| 307 | ||
| 308 | const blurb = "x".repeat(4000); | |
| 309 | expect(changes.changed(ada, "product", 42, { blurb: "" }, { blurb })).toBe(1); | |
| 310 | const row = changes.recent(1)[0]!; | |
| 311 | expect(row.field).toBe("blurb"); | |
| 312 | expect(row.before).toBe(""); | |
| 313 | expect(row.after.length).toBe(changes.CAP); | |
| 314 | expect(row.after.endsWith("…")).toBe(true); | |
| 315 | expect(row.actor_id).toBe("u_9"); | |
| 316 | expect(row.actor_name).toBe("ada"); | |
| 317 | ||
| 318 | // Whoever the forge did not name is nobody, and a boolean reads as a word. | |
| 319 | expect(changes.actorOf({ role: "anon" })).toEqual({ id: "", name: "unknown" }); | |
| 320 | expect(changes.say(true)).toBe("yes"); | |
| 321 | expect(changes.say(undefined)).toBe(""); | |
| 322 | }); | |
| 323 | ||
| 324 | test("an edit past the cap is still an edit: the whole value decides, the row is what is short", () => { | |
| 325 | const ada = { id: "u_9", name: "ada" }; | |
| 326 | const long = "a".repeat(changes.CAP + 200); | |
| 327 | // The two differ only in their tail, which is past what a row can hold. | |
| 328 | expect(changes.diff({ description: long }, { description: `${long} and one more thing` })).toEqual([ | |
| 329 | { field: "description", before: changes.say(long), after: changes.say(`${long} and one more thing`) }, | |
| 330 | ]); | |
| 331 | expect(changes.changed(ada, "product", 77, { description: long }, { description: `${long}!` })).toBe(1); | |
| 332 | const row = changes.recent(1)[0]!; | |
| 333 | expect(row.field).toBe("description"); | |
| 334 | expect(row.before.length).toBe(changes.CAP); | |
| 335 | expect(row.after.length).toBe(changes.CAP); | |
| 336 | ||
| 337 | // Whole values still read as one line, and the same long value is a no-op. | |
| 338 | expect(changes.flat(` two words\n`)).toBe("two words"); | |
| 339 | expect(changes.changed(ada, "product", 77, { description: long }, { description: long })).toBe(0); | |
| 340 | }); | |
| 341 | ||
| 342 | test("a change made at the desk names the account the forge put at the keyboard", () => { | |
| 343 | process.env.FORGE_APP_USER = "bo"; | |
| 344 | process.env.FORGE_APP_USER_ID = "u_7"; | |
| 345 | process.env.FORGE_APP_USER_ROLE = "admin"; | |
| 346 | try { | |
| 347 | expect(desk.setRate("atlantis / / 3.5 / Atlantean levy").ok).toBe(true); | |
| 348 | const row = changes.recent(1)[0]!; | |
| 349 | expect(row.subject).toBe("tax_rate"); | |
| 350 | expect(row.subject_name).toBe("ATLANTIS"); | |
| 351 | expect(row.actor_id).toBe("u_7"); | |
| 352 | expect(row.actor_name).toBe("bo"); | |
| 353 | } finally { | |
| 354 | delete process.env.FORGE_APP_USER; | |
| 355 | delete process.env.FORGE_APP_USER_ID; | |
| 356 | delete process.env.FORGE_APP_USER_ROLE; | |
| 357 | const atlantis = tax.rates().find((rate) => rate.country === "ATLANTIS"); | |
| 358 | if (atlantis) tax.removeRate(atlantis.id); | |
| 359 | } | |
| 360 | }); | |
| 361 | ||
| 302 | 362 | test("the settings the admin password left behind are deleted, and nothing else is", () => { |
| 303 | 363 | setSetting("admin_demo_password", "whatever-it-was"); |
| 304 | 364 | setSetting("admin_salt", "and-its-salt"); |
Changes requested: a change that lands can be missing its row.
diffcompares values thatsayhas already clipped to 120 characters, so an edit past the cap is invisible to the trail. Running it against this head: create a product with a 300-character description, then edit only its tail and move its category — the category row is written, the description row is not.The form takes 2,000 characters of description and 200 of blurb, and a tax label is 60, so this is the normal case for the longest fields on the page, not a corner.
src/changes.tssays in its own header that "a change that lands cannot be missing one", and here it can — silently, no message and no row.Fix this first, in
src/changes.ts: compare whole, clip only on the way in. Split the two jobssayis doing —— and have
diffdecide onflat(before[name]) !== flat(value)while still storingsay(...).recordclips again on write, so the row stays short and the comparison stops lying. The test that would have caught it: edit the tail of a description longer thanCAPand expect exactly onedescriptionrow.Two smaller things, neither blocking:
tax_defaulthas no arm inREADING's CASE and asubject_idof 0, so the default rate reads astax_default #0on the page and in the terminal.nameOfcould name it — "anywhere else" is what/admin/taxand the tty tab already call it.src/desk.tsthe paragraph about a change at the desk leaving the same row is attached topercent, a formatter that has nothing to do with it. Move it ontoTABSor onto the first recording call, or it reads as documenting the wrong thing.The rest holds up, and I checked rather than assumed:
ctx.whoon the web andatKeyboard()at the desk, never a form field, never a client-written header;actorOfrefuses to name an anonymous. Every new call site is behind the existingguard, every insert is parameterised, every value on the page goes throughescape— a product titled<script>alert(1)</script>comes back as entities. Nothing spendable is a subject, and the gift-card exclusion is argued in the code rather than left implicit.db.transaction, including the create paths whose unique-slug and unique-sku catches used to swallow a half-write; a rollback takes the audit row with it, and a no-op form writes nothing.changesisread: "admin"in the oneDESKtable both gates read, so the existingvisible(shopper)assertion still pins the tab list to["stock", "tax"]and the tab is marked closed rather than drawn. The five desk writes record the same rows the pages do, and the README documents7and/admin/changes./admin/changesis 403 for anyone the forge did not sign as an admin, with the security floor intact on the refusal; the page renders in 1.7 ms locally behind the new(at DESC, id DESC)index.bun testat this head: 92 pass, 0 fail.tax.rateAtandupsertRate's returned id are the minimum needed to say what a rate was before.(Anchored on the comparison itself: line 71 is where the clipped values are compared.)