A shop's back office, where what you can do depends on who you are.
Four roles — customer, picker, manager, owner — over the same six pages. The interesting part is not the menu that changes. It is that the check is on the server, so a URL typed by hand is refused exactly as firmly as a button that was never drawn.
The navigation is built from the permission table, and that is a courtesy — it keeps people from being offered work they will be refused when they get there. The table is also consulted by every operation on the server, and that is the part that holds:
export async function saveProduct(viewer, id, draft) {
insist(viewer, 'catalogue.edit');
…
}A picker who types /catalogue gets this, rather than the page:
The tests reach the operations directly — no page, no form, no button — because that is the only arrangement that proves the check is where it is claimed to be.
A permission table alone cannot express "a customer may cancel their own order", and a system that stops at the table lets any customer cancel anybody's. So authorisation asks twice:
if (!can(viewer.role, action)) return no(`a ${viewer.role} cannot …`);
if (viewer.role === 'customer' && subject.customerId !== viewer.id)
return no('that order belongs to someone else');Not listing somebody else's order is not the same as not serving it, so reading one by its id is refused too — and refused, rather than dressed up as a 404, which is a small lie that stops working the moment two customers compare notes.
The roles are written out one by one rather than layered as "manager inherits picker". Inheritance reads well until someone needs a role that does most of another's job but explicitly not one part of it, and then the hierarchy has to be unpicked in a hurry.
The order state machine and the permission table are kept apart on purpose. "Is this a legal move" and "are you allowed to make it" are different questions with different answers, and collapsing them gives you a system where an owner can refund an order that was never collected, because owners can do anything.
placed ──pack──▶ packed ──hand over──▶ collected ──refund──▶ refunded
│ │
└──────── cancel ┴──▶ cancelled
Anything that undoes a sale puts the goods back on the shelf; packing and handing over do not, because nothing has been sold or unsold. Cancelled and refunded orders are left out of the takings — counting them would overstate the till by exactly the amount that left it.
Stock is taken with a condition on the UPDATE, not a SELECT followed by an
UPDATE:
update products set stock = stock - $2
where id = $1 and stock >= $2
returning name, priceTwo customers reaching the last jar a millisecond apart both pass a SELECT —
under read committed the second still sees the row as it was before the first
transaction committed. The conditional UPDATE cannot be fooled that way: the
second statement blocks on the row lock, and when it is released it re-reads
the committed value and matches nothing.
An order that can only be part-filled takes nothing at all. Filling the first line and failing on the second would leave the shelf short with no order to show for it.
The first version fired two orders with Promise.all and asserted that one
failed. It passed — and it passed just as happily against a deliberately naive
check-then-write, because the two transactions never actually overlapped: the
second reliably started after the first had committed.
The version in the repo holds two connections open at once and waits, by
polling pg_stat_activity, until one of them is genuinely blocked on the row
lock before letting the other commit. That one fails against check-then-write,
which is the only reason to trust it when it passes.
There is a check (stock >= 0) on the column as well. It is a second line of
defence rather than the mechanism: if a future query forgets its condition, the
constraint turns an oversold shelf into a failed write.
Whole minor units, never a float, and a percentage discount rounded once on the total rather than per line. Three lines of 0.05 at half price come to 0.07 taken together and 0.06 taken line by line — and the line-by-line answer is wrong in the shop's favour every time, which is the part that gets noticed.
The price is copied onto the order at the moment of sale rather than joined from the product, so repricing a shelf never rewrites what last week's customers were charged.
There is none. Authentication is a solved problem with good implementations to
hand, and a half-built one bolted onto a demo would teach nobody anything —
what this is about is everything that happens after someone is identified.
So signing in is picking a name from a list, and /be/p-siti?next=/orders
drops you straight into a role.
The role is read from the database on every request and never carried in the cookie, because a role in a cookie is a role its holder can edit.
createdb shopfloor
npm install
npm run db
npm run dev
DATABASE_URL overrides the connection if your Postgres is elsewhere.
npm test # 38, no database needed
npm run test:db # 21, against a real Postgres
The split is deliberate. The permission table, the state machine, the money and the catalogue rules are pure functions and are tested as such — fast, and with nothing to set up. Overselling, restocking and two members of staff clicking the same button at the same moment are things only a database can be wrong about, and stubbing one would test the stub.



