Two paths write `app_users`: the console's `tenants/createstaff`, and the
terminal's `/pos/users`. Only one of them checked anything.
`createstaff` wrote whatever it was handed. A cashier could be created there
with PIN "0451" — which a bigint column stores as 451 — and would then type four
digits at the counter and be refused for ever, with nothing on either screen to
explain it. Or with 1234, which live data already has on eleven accounts. Or
with a PIN somebody at the same outlet already had, which attributes a bill to
whichever row is read first. Or with no way to sign in at all.
None of that surfaced where it was caused. It surfaced at a counter, days later,
as "the new person cannot log in".
So the rules move into `ValidateStaffUser`, and both paths use it: a name, a
role that is actually a role, a PIN the schema can hold and nobody guesses
first, and at least one way to sign in. The duplicate-PIN check runs too, when
the row names an outlet.
The handler also stops answering 500 with a body claiming 409. Every one of
these is something the person filling in the form can fix, so it is a 400
carrying the reason.
`GetStaffs` now returns `rolename` alongside `roleid`, so a console can show
"Supervisor" without mapping ids itself — `app_roles` has six rows for four
back-office roles and most accounts carry an id absent from it, so any mapping
written client-side would be wrong.
This is what makes the two role systems one. A supervisor or cashier created
from the web behaves at the till exactly like one created at the till, because
there is now a single definition of what those are.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`app_users.userid` is a `GENERATED BY DEFAULT AS IDENTITY` column. It did not
look like one: `information_schema.columns.column_default` is empty for identity
columns, and reading that as "no default at all" is how this came to compute its
own id with MAX+1.
That worked, and quietly did the wrong thing. An explicit id does not advance
the sequence, so two allocators ended up running in parallel — the sequence sat
at 1447 while MAX(userid) had reached 9189. They cannot collide today, because
almost nothing occupies the range between, but they converge on every insert and
the first collision would be a primary key violation on a live sign-up.
The insert now omits userid and reads it back with RETURNING. The advisory lock
stays, because it was never about the id: two supervisors adding staff at the
same instant could both find a PIN free and both take it, and a duplicate PIN
attributes a bill to whichever row is read first.
Also documents why the email columns go through NULLIF. `app_users_email_unique`
is real, and a second cashier created without an email would otherwise collide
on the empty string — while NULLs do not collide in Postgres. A cashier who
signs in by PIN alone has no email, which is the common case rather than the
edge one.
Verified in a rolled-back transaction against live data: creation allocates
1448, the sequence advances 1447 -> 1448, a second emailless user is accepted,
and a duplicate PIN is still refused.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A shop had no way to add the people who work in it. The terminal fell back to
three names and three PINs compiled into the app — the same three on every
install — because there was nothing for it to fall back *from*.
Two roles now exist in `app_roles`: Supervisor (7) runs the terminal and creates
staff, Cashier (8) bills. Fixed ids, written by hand, because that table has no
sequence and every id in it was assigned the same way. configid is left NULL
rather than duplicated per portal: a till is a till whichever portal a tenant
uses, and Admin already appears twice in that table for exactly that reason.
`/pos/users` is CRUD over them, and `/pos/login/pin` signs a cashier on at a
terminal a supervisor has already opened.
The rule every one of these follows: **tenant and outlet come from the caller's
token, never from the request.** There is no location field on the create body
to get wrong. A supervisor at Selvapuram cannot create staff at R mart, for the
same reason a till cannot bill into another shop's books — it is the same
inversion applied to people instead of sales.
PIN sign-in is deliberately behind the guard. Four digits is ten thousand
guesses, which is no barrier to an anonymous caller; requiring a session means a
real password opened the terminal first and the guesses are confined to one
outlet's own staff. The session it mints is fresh rather than derived, so a
cashier taking over from a supervisor drops their permissions instead of
inheriting them.
Three things the schema forced:
- A PIN cannot start with zero. `app_users.pin` is a bigint, so "0451" stores as
451 and reads back as three digits — a cashier would type four and be refused
for ever. Live data already holds one such account. Rendering refuses to show
a PIN it cannot represent, rather than showing a short one nobody can type.
- `app_users` has no sequence either, so the next id is read and written inside
one transaction behind an advisory lock. Two supervisors creating staff at the
same moment would otherwise compute the same id and one insert would lose.
- 1234, 1111 and friends are refused outright. Live data has 1234 on eleven
accounts and 1111 on nine.
Proven against outlet 1135, which had zero staff and was the reason the built-in
PINs were still load-bearing:
created 9188 Store Supervisor Supervisor can_manage_staff=true
created 9189 Counter Cashier Cashier can_manage_staff=false
/pos/staff now returns 2 an unknown PIN is refused
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>