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>
77 lines
3.3 KiB
Go
77 lines
3.3 KiB
Go
package routes
|
|
|
|
import (
|
|
"nearle/facade"
|
|
"nearle/middleware"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
// Routes for the Nearle POS terminal.
|
|
//
|
|
// The paths are fixed by the till, which appends `/orders`, `/customers` and
|
|
// `/catalogue` to whatever base URL a shop enters in Settings. Set that base to
|
|
// this group — `https://your-host/live/api/v1/pos` — and the three line up.
|
|
//
|
|
// Kept in their own group rather than folded into the order routes because a
|
|
// terminal authenticates as a device, not as a signed-in user, and because
|
|
// these answer with a bare ack rather than the web app's response envelope.
|
|
func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
|
|
|
|
pos := api.Group("/v1/pos")
|
|
|
|
// Sign-in, and the only route on this group that runs before the guard —
|
|
// it is where a session comes from. A till posts the same `app_users`
|
|
// credentials the web console takes, and gets back a token plus the outlet
|
|
// that account is entitled to. The store id it will bill under is decided
|
|
// here, from the user's record, instead of being typed into Settings and
|
|
// taken on trust.
|
|
pos.Post("/login", f.PosController.Login)
|
|
|
|
// Everything past this point carries the session.
|
|
//
|
|
// The guard verifies the token and refuses a request naming an outlet the
|
|
// token's tenant does not own. Until `POS_AUTH_REQUIRED=true` is set it
|
|
// lets an unauthenticated request through, so the terminals already
|
|
// trading do not stop the day this deploys — see middleware.PosAuth.
|
|
pos.Use(middleware.PosAuth(f.PosService()))
|
|
|
|
pos.Get("/session", f.PosController.Session)
|
|
|
|
// Who may ring a bill here. Deliberately takes no location parameter — the
|
|
// answer carries PINs, so the outlet comes from the caller's own token.
|
|
pos.Get("/staff", f.PosController.Staff)
|
|
|
|
// Signing on by PIN, once a supervisor has opened the terminal with a real
|
|
// password. Sits behind the guard on purpose — see PinLogin.
|
|
pos.Post("/login/pin", f.PosController.PinLogin)
|
|
|
|
// The shop's own counter staff. A supervisor creates their cashiers; the
|
|
// outlet is always the caller's own, read from their token.
|
|
pos.Get("/users", f.PosController.ListPosUsers)
|
|
pos.Post("/users", f.PosController.CreatePosUser)
|
|
pos.Put("/users", f.PosController.UpdatePosUser)
|
|
pos.Delete("/users", f.PosController.DeletePosUser)
|
|
|
|
pos.Post("/orders", f.PosController.IngestOrders)
|
|
pos.Post("/customers", f.PosController.IngestCustomers)
|
|
pos.Get("/catalogue", f.PosController.Catalogue)
|
|
|
|
// The 30-second heartbeat, for tills on the HTTP route. The broker carries
|
|
// the same payload for tills on MQTT; both land in the same Redis record,
|
|
// so the fleet board cannot tell them apart and does not need to.
|
|
pos.Post("/health", f.PosController.IngestHealth)
|
|
|
|
// Counter sales, read back out. The ingest above only ever writes; without
|
|
// these a committed bill is unreachable from every screen in the product.
|
|
pos.Get("/sales", f.PosController.GetSales)
|
|
pos.Get("/sales/detail", f.PosController.GetSaleDetail)
|
|
pos.Get("/sales/summary", f.PosController.GetSalesSummary)
|
|
|
|
// Terminal presence, read from Redis. What the rider app's POS board and a
|
|
// support call both hit — the tills themselves publish health over the
|
|
// broker rather than posting it here.
|
|
pos.Get("/health/terminal", f.PosController.TerminalHealth)
|
|
pos.Get("/health/location", f.PosController.LocationHealth)
|
|
}
|