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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

ledger/bazaar:ledger/34-an-admin-s-change-to-the-catalog-a-disco → main 2 commits fork 1 thread · 1 unresolved

  • Merged. ledger/34-an-admin-s-change-to-the-catalog-a-disco was merged into main as b40cb34.

Review

Sign in to review this change.
11 files changed +682 −51
README.md +14 −7
@@ -32,7 +32,10 @@ library are all of it. There is no client-side JavaScript.
3232 and the money off a card are all taken in the same transaction that writes
3333 the order's lines, so nothing is sold, spent or discounted twice; refunds
3434 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.
3639 - **Simulated unless you configure it**: the payment. With no Stripe keys the
3740 store uses a **mock gateway** — a page labelled as a mock, with Approve and
3841 Decline buttons, that moves no money.
@@ -239,9 +242,10 @@ otherwise hand over the very URL `to` was careful to clean.
239242
240243 The terminal takes the same three roles from the environment. Stock and the
241244 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,
245249 refunding, writing a code, issuing or voiding a card, setting a rate — is an
246250 admin's, and so are the two keys that only read something out: `c`, which
247251 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.
266270 | `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 |
267271 | `src/discounts.ts` `src/giftcards.ts` `src/tax.ts` | What a code is worth, what a card holds, what the rate is |
268272 | `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 |
269274 | `src/desk.ts` | The terminal's typed commands: a line of text into a code, a card or a rate |
270275 | `src/peer.ts` `src/ratelimit.ts` | Where a request came from, as far as it can be proved; the buckets keyed on it |
271276 | `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`,
289294
290295 The same shop over the same data. The exact command is in `FORGE_APP_SSH`
291296 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`
294300 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
296302 with a `·` in the tab bar and answer "That is an admin's to do." without
297303 leaving the tab they are on:
298304
@@ -303,6 +309,7 @@ leaving the tab they are on:
303309 | 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 |
304310 | 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 |
305311 | 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 |
306313
307314 `q` leaves. The first line says who the forge named and what they may read;
308315 `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 @@
88 */
99
1010 import * as catalog from "./catalog.ts";
11 import * as changes from "./changes.ts";
1112 import * as discounts from "./discounts.ts";
1213 import * as giftcards from "./giftcards.ts";
1314 import * as tax from "./tax.ts";
@@ -18,7 +19,7 @@ import * as pages from "./pages/admin.ts";
1819 import { db, type Variant } from "./db.ts";
1920 import { field, intField, redirect } from "./http.ts";
2021 import { forOrder, outbox } from "./mail.ts";
21 import { parseCents } from "./money.ts";
22 import { formatCents, parseCents } from "./money.ts";
2223 import { render, type Ctx } from "./context.ts";
2324 import { notFound } from "./shop.ts";
2425 import { identityConfigured, returnTo } from "./who.ts";
@@ -58,6 +59,24 @@ const guard = (ctx: Ctx): Response | null =>
5859 pages.forbidden(returnTo(ctx.url), identityConfigured()),
5960 );
6061
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
6180 // --- the door ---------------------------------------------------------------
6281
6382 export function dashboard(ctx: Ctx): Response {
@@ -129,14 +148,31 @@ const productInput = (ctx: Ctx) => {
129148 };
130149 };
131150
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
132164 export function productCreate(ctx: Ctx): Response {
133165 const blocked = guard(ctx);
134166 if (blocked) return blocked;
135167 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");
138170 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 })();
140176 return redirect(`/admin/products/${id}?m=created`);
141177 } catch {
142178 return redirect("/admin/products/new?m=bad-slug");
@@ -150,10 +186,20 @@ export function productUpdate(ctx: Ctx): Response {
150186 const found = Number.isInteger(id) ? catalog.productById(id) : null;
151187 if (!found) return notFound(ctx);
152188 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)
154191 return redirect(`/admin/products/${found.id}?m=bad-slug`);
155192 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 })();
157203 return redirect(`/admin/products/${found.id}?m=saved`);
158204 } catch {
159205 return redirect(`/admin/products/${found.id}?m=bad-slug`);
@@ -173,7 +219,12 @@ export function variantCreate(ctx: Ctx): Response {
173219 if (price === undefined) return redirect(`/admin/products/${found.id}?m=bad-price`);
174220 if (sku === "" || name === "") return redirect(`/admin/products/${found.id}?m=bad-sku`);
175221 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 })();
177228 return redirect(`/admin/products/${found.id}?m=variant-added`);
178229 } catch {
179230 return redirect(`/admin/products/${found.id}?m=bad-sku`);
@@ -189,7 +240,17 @@ export function variantUpdate(ctx: Ctx): Response {
189240 const price = parseCents(field(ctx.form, "price", 12));
190241 const name = field(ctx.form, "name", 80);
191242 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 })();
193254 return redirect(`/admin/products/${variant.product_id}?m=saved`);
194255 }
195256
@@ -316,6 +377,28 @@ export function discountList(ctx: Ctx): Response {
316377 return render(ctx, { title: "Discounts", admin: true }, pages.discounts(ctx.session, discounts.withUse(), messageFrom(ctx)));
317378 }
318379
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
319402 /** A date is a real date or it is nothing; a percent is basis points of a whole. */
320403 const dayOrNull = (value: string): string | null | undefined =>
321404 value === "" ? null : discounts.endOfDay(value);
@@ -342,7 +425,7 @@ export function discountCreate(ctx: Ctx): Response {
342425 if (min === undefined) return redirect("/admin/discounts?m=bad-value");
343426 if (maxRaw !== "" && !/^\d{1,6}$/.test(maxRaw)) return redirect("/admin/discounts?m=bad-value");
344427 try {
345 discounts.create({
428 const input = {
346429 code,
347430 kind: kind as "percent" | "fixed" | "free_shipping",
348431 value,
@@ -350,7 +433,11 @@ export function discountCreate(ctx: Ctx): Response {
350433 max_redemptions: maxRaw === "" ? null : Number(maxRaw),
351434 starts_at: null,
352435 expires_at: expires,
353 });
436 };
437 db.transaction(() => {
438 const id = discounts.create(input);
439 changes.changed(actor(ctx), "discount", id, {}, discountFields(input));
440 })();
354441 return redirect("/admin/discounts?m=created");
355442 } catch {
356443 return redirect("/admin/discounts?m=bad-code");
@@ -363,7 +450,11 @@ export function discountToggle(ctx: Ctx): Response {
363450 const id = Number(ctx.params.id);
364451 const found = Number.isInteger(id) ? discounts.byId(id) : null;
365452 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 })();
367458 return redirect("/admin/discounts?m=saved");
368459 }
369460
@@ -430,13 +521,28 @@ export function taxSave(ctx: Ctx): Response {
430521 const bp = basisPoints(field(ctx.form, "rate", 7));
431522 if (bp === undefined) return redirect("/admin/tax?m=bad-rate");
432523 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 })();
434529 return redirect("/admin/tax?m=saved");
435530 }
436531 const country = field(ctx.form, "country", 80);
437532 const label = field(ctx.form, "label", 60);
438533 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 })();
440546 return redirect("/admin/tax?m=saved");
441547 }
442548
@@ -444,7 +550,18 @@ export function taxRemove(ctx: Ctx): Response {
444550 const blocked = guard(ctx);
445551 if (blocked) return blocked;
446552 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 })();
449566 return redirect("/admin/tax?m=saved");
450567 }
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

Changes requested: a change that lands can be missing its row.

diff compares values that say has 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.

ROWS: ["title:  -> <script>…", "slug:  -> probe-thing", "category:  -> Gifts", "blurb:  -> b",
       "description:  -> aaaaaaaaaaaaaaaaaaaa", "active:  -> yes", "category: Gifts -> Coffee"]

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.ts says 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 jobs say is doing —

const flat = (value: Value): string =>
  value === null || value === undefined ? "" :
  typeof value === "boolean" ? (value ? "yes" : "no") :
  String(value).replace(/\s+/g, " ").trim();
export const say = (value: Value): string => clip(flat(value));

— and have diff decide on flat(before[name]) !== flat(value) while still storing say(...). record clips 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 than CAP and expect exactly one description row.

Two smaller things, neither blocking:

  1. tax_default has no arm in READING's CASE and a subject_id of 0, so the default rate reads as tax_default #0 on the page and in the terminal. nameOf could name it — "anywhere else" is what /admin/tax and the tty tab already call it.
  2. In src/desk.ts the paragraph about a change at the desk leaving the same row is attached to percent, a formatter that has nothing to do with it. Move it onto TABS or onto the first recording call, or it reads as documenting the wrong thing.

The rest holds up, and I checked rather than assumed:

  • Identity. The actor is ctx.who on the web and atKeyboard() at the desk, never a form field, never a client-written header; actorOf refuses to name an anonymous. Every new call site is behind the existing guard, every insert is parameterised, every value on the page goes through escape — 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.
  • Atomicity. Each write and its rows share one 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.
  • Both doors. changes is read: "admin" in the one DESK table both gates read, so the existing visible(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 documents 7 and /admin/changes.
  • Preview. /admin/changes is 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 test at this head: 92 pass, 0 fail.
  • Scope. One new module named for one concept, no dependency added; tax.rateAt and upsertRate'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.)

outdated

probe

outdated

Changes requested — the verdict for my full review on src/changes.ts:71 above, which two stray API probes of mine displaced. Nothing new here; read that one.

In short: diff compares values say has already clipped to 120 characters, so an admin's edit to a description, blurb or label past the cap writes no row at all — the ledger silently misses a change that landed. Fix it by comparing the whole normalized value and clipping only on the way into record, with a test that edits the tail of a description longer than CAP and expects one description row. Everything else — the actor from ctx.who and atKeyboard(), the transactions, the admin gate on both doors, the escaping, 92 green tests, the 403 in the preview — holds up.

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,
202202 CREATE INDEX IF NOT EXISTS gift_card_entry_order ON gift_card_entry(order_id);
203203 CREATE UNIQUE INDEX IF NOT EXISTS gift_card_entry_key ON gift_card_entry(idempotency_key);
204204
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
205224 -- Tax by where the parcel goes. Country and region are stored upper-cased;
206225 -- an empty region is the whole country's rate.
207226 CREATE TABLE IF NOT EXISTS tax_rate (
@@ -368,6 +387,17 @@ export type GiftCard = {
368387 active: number;
369388 created_at: string;
370389 };
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 };
371401 export type TaxRate = {
372402 id: number;
373403 country: string;
src/desk.ts +71 −14
@@ -16,15 +16,25 @@
1616 * and a tab cannot be opened by someone who may not read what is on it.
1717 */
1818
19 import * as changes from "./changes.ts";
1920 import * as discounts from "./discounts.ts";
2021 import * as giftcards from "./giftcards.ts";
2122 import * as tax from "./tax.ts";
23 import { db } from "./db.ts";
2224 import { formatCents, parseCents } from "./money.ts";
2325 import type { Who } from "./who.ts";
2426
2527 export type Done = { ok: boolean; message: string };
2628
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;
2838 export type Tab = (typeof TABS)[number];
2939
3040 /**
@@ -75,6 +85,9 @@ const DESK: Record<Tab, { read: "anyone" | "admin"; keys: { key: string; label:
7585 { key: "x", label: "x remove" },
7686 ],
7787 },
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: [] },
7891 };
7992
8093 /** 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 {
141154
142155 if (discounts.byCode(code)) return no(`${code} already exists.`);
143156 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 })();
153184 const made = discounts.byId(id)!;
154185 return yes(
155186 `${made.code} — ${discounts.describe(made)}${min > 0 ? `, over ${formatCents(min)}` : ""}${
@@ -164,7 +195,10 @@ export function newDiscount(line: string): Done {
164195 export function toggleDiscount(id: number): Done {
165196 const found = discounts.byId(id);
166197 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 })();
168202 return yes(`${found.code} is now ${found.active === 1 ? "off" : "on"}.`);
169203 }
170204
@@ -225,14 +259,28 @@ export function setRate(line: string): Done {
225259 if (country === "") return no("A rate needs a country.");
226260 if (points === undefined || points > 10000) return no("A rate is a percentage between 0 and 100, like 8.75.");
227261 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 })();
229273 return yes(`${country}${region ? ` ${region}` : ""} — ${(points / 100).toFixed(2)}% (${label}).`);
230274 }
231275
232276 export function setDefaultRate(text: string): Done {
233277 const points = parseCents(text.trim());
234278 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 })();
236284 return yes(`Anywhere the table does not name is taxed at ${(points / 100).toFixed(2)}%.`);
237285 }
238286
@@ -240,6 +288,15 @@ export function removeRate(id: number, confirmation: string): Done {
240288 const rate = tax.rateById(id);
241289 if (!rate) return no("No rate selected.");
242290 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 })();
244301 return yes(`${rate.country}${rate.region ? ` ${rate.region}` : ""} removed; it now falls to the default.`);
245302 }
src/pages/admin.ts +36
@@ -9,6 +9,7 @@ import { FORGE_LINK_ATTRS, SIGN_OUT_HREF, signInHref } from "../who.ts";
99 import { formatCents } from "../money.ts";
1010 import { itemsTable, orderTotals, statusPill } from "./checkout.ts";
1111 import { describe } from "../discounts.ts";
12 import { href, nameOf, type Reading } from "../changes.ts";
1213 import type { DiscountStats } from "../discounts.ts";
1314 import type { ProductWithPrice } from "../catalog.ts";
1415 import type { Category, GiftCard, Order, OrderItem, Payment, TaxRate, Variant } from "../db.ts";
@@ -26,6 +27,7 @@ export const nav = (current: string): string =>
2627 ["/admin/giftcards", "Gift cards", "giftcards"],
2728 ["/admin/tax", "Tax", "tax"],
2829 ["/admin/mail", "Outbox", "mail"],
30 ["/admin/changes", "Changes", "changes"],
2931 ]
3032 .map(([href, label, key]) =>
3133 key === current ? `<strong>${escape(label)}</strong>` : `<a href="${escape(href)}">${escape(label)}</a>`,
@@ -341,6 +343,40 @@ ${
341343 }
342344 `;
343345
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
344380 // --- codes, cards and tax ----------------------------------------------------
345381
346382 const dateCell = (value: string | null): string => escape(value ?? "—");
src/routes.ts +1
@@ -72,6 +72,7 @@ export const routes: Route[] = [
7272 { method: "POST", pattern: "/admin/orders/:number/refund", handler: admin.orderRefund },
7373 { method: "POST", pattern: "/admin/orders/:number/cancel", handler: admin.orderCancel },
7474 { method: "GET", pattern: "/admin/mail", handler: admin.mailbox },
75 { method: "GET", pattern: "/admin/changes", handler: admin.changeList },
7576 ];
7677
7778 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"
11581158 }
11591159 });
11601160
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
11611307 test("this file's rows are this file's own", async () => {
11621308 // The isolation every test file depends on: the database the app modules
11631309 // 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[] =>
4646 export const rateById = (id: number): TaxRate | null =>
4747 db.query<TaxRate, [number]>("SELECT * FROM tax_rate WHERE id = ?1").get(id);
4848
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
4955 /**
5056 * The rate for an address, or the default when the table does not know it.
5157 * 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 {
6874 export const taxOn = (base_cents: number, rate: Rate): number =>
6975 base_cents <= 0 ? 0 : Math.round((base_cents * rate.rate_bp) / 10000);
7076
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;
8298 }
8399
84100 export const removeRate = (id: number): void => {
src/tty.ts +18 −4
@@ -12,8 +12,9 @@
1212 * a receipt carries a gift card's code in full, and that code is money to
1313 * whoever reads it. `src/desk.ts` holds the one table both gates read.
1414 *
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
1718 * at its foot what it can do: s moves stock with a reason, / finds an order by
1819 * number, name or email, r refunds what is left of an order and puts it back
1920 * 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";
3132 import { itemsOf, paymentsOf, recent, refund, refundableCents, refundedCents } from "./orders.ts";
3233 import { outbox } from "./mail.ts";
3334 import { atKeyboard } from "./who.ts";
35 import { nameOf, recent as recentChanges, type Reading } from "./changes.ts";
3436 import type { GiftCard, Order, TaxRate } from "./db.ts";
3537
3638 const person = atKeyboard();
@@ -89,7 +91,9 @@ const rowsFor = (): unknown[] =>
8991 ? discounts.withUse()
9092 : tab === "cards"
9193 ? giftcards.all()
92 : tax.rates();
94 : tab === "tax"
95 ? tax.rates()
96 : recentChanges(100);
9397
9498 /** A message from the desk, with a refusal marked so it cannot be missed. */
9599 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." });
103107 */
104108 const hints = (): string =>
105109 [
106 "1-6 tabs",
110 "1-7 tabs",
107111 "j/k move",
108112 ...desk.offered(tab, person),
109113 ...(tab === "orders" && filter !== "" ? [`showing “${filter}”`] : []),
@@ -201,6 +205,16 @@ function draw(): void {
201205 lines.push(`${mark} ${i === cursor ? bold(text) : text}`);
202206 });
203207 }
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 });
204218 lines.push("");
205219 if (message) lines.push(message, "");
206220 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");
1717 const peer = await import("./peer.ts");
1818 const { escape } = await import("./html.ts");
1919 const catalog = await import("./catalog.ts");
20 const changes = await import("./changes.ts");
2021 const desk = await import("./desk.ts");
2122 const discounts = await import("./discounts.ts");
2223 const giftcards = await import("./giftcards.ts");
@@ -299,6 +300,65 @@ test("the orders a browser remembers are signed, deduplicated and capped", () =>
299300 expect(mine.decode(undefined, secret)).toEqual([]);
300301 });
301302
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
302362 test("the settings the admin password left behind are deleted, and nothing else is", () => {
303363 setSetting("admin_demo_password", "whatever-it-was");
304364 setSetting("admin_salt", "and-its-salt");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs