Skip to content
Mustafa0u0Public

About

A shop back office with four roles — customer, picker, manager, owner — where the permission check is on the server, not on the button, and the shop cannot sell the same jar twice

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Shopfloor

CI

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 order queue as a picker sees it The catalogue, editable by a manager

Hiding a button is not authorisation

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:

Refused. A picker cannot edit the catalogue

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.

Two questions, not one

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.

Legal is not the same as permitted

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.

The shop cannot sell the same jar twice

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, price

Two 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 race test very nearly did not test anything

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.

Money

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.

Signing in

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.

The team page, which only the owner can reach

Running it

createdb shopfloor
npm install
npm run db
npm run dev

DATABASE_URL overrides the connection if your Postgres is elsewhere.

Tests

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.

About

A shop back office with four roles — customer, picker, manager, owner — where the permission check is on the server, not on the button, and the shop cannot sell the same jar twice

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages