Let a shop run its own counter: supervisor and cashier, created from the till
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>
This commit is contained in:
118
models/pos.go
118
models/pos.go
@@ -1,5 +1,7 @@
|
||||
package models
|
||||
|
||||
import "strings"
|
||||
|
||||
// Wire format for the Nearle POS terminal.
|
||||
//
|
||||
// These types mirror what the till actually publishes, field for field. The
|
||||
@@ -237,6 +239,11 @@ type PosLoginRequest struct {
|
||||
Configid int `json:"configid"`
|
||||
Locationid int `json:"location_id"`
|
||||
|
||||
// A PIN, for signing on at a terminal a supervisor has already opened. Only
|
||||
// honoured by the PIN route, which requires an existing session — four
|
||||
// digits is no barrier to an anonymous caller.
|
||||
Pin string `json:"pin"`
|
||||
|
||||
// Which physical till is asking. Recorded on the session so a stolen token
|
||||
// can be told apart from the terminal it was issued to.
|
||||
Terminalid string `json:"terminal_id"`
|
||||
@@ -267,6 +274,20 @@ type PosSession struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
Roleid int `json:"role_id"`
|
||||
|
||||
// What the role is called, and the one thing the terminal actually branches
|
||||
// on. Sent as a flag rather than leaving the till to map role ids itself:
|
||||
// `app_roles` has six rows for four roles and most accounts carry an id
|
||||
// absent from it, so any mapping written on the terminal would be wrong.
|
||||
Role string `json:"role"`
|
||||
Canmanagestaff bool `json:"can_manage_staff"`
|
||||
|
||||
// Which portal this account belongs to. Carried so a supervisor creating a
|
||||
// cashier gives them the same configid — an account created under the wrong
|
||||
// one cannot sign into the web console and is invisible to half the
|
||||
// platform's queries. Not sent to the terminal: it has no use for it and it
|
||||
// is one more number to get wrong.
|
||||
Configid int `json:"-"`
|
||||
|
||||
Tenantid int `json:"tenant_id"`
|
||||
Tenantname string `json:"tenant_name"`
|
||||
|
||||
@@ -324,3 +345,100 @@ type PosStaffResponse struct {
|
||||
Locationid int `json:"location_id"`
|
||||
Staff []PosStaffMember `json:"staff"`
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ POS staff roles
|
||||
//
|
||||
// `app_roles` is keyed by roleid and carries a configid, so the same name
|
||||
// appears more than once — Admin is both 3 and 5, Manager both 4 and 6, one per
|
||||
// portal. These two are deliberately not per-portal: a till is a till whichever
|
||||
// tenant owns it, and a role that had to be duplicated per config would be one
|
||||
// more thing to remember when a tenant is onboarded.
|
||||
//
|
||||
// The ids are fixed rather than allocated, because they are referenced from the
|
||||
// terminal and from this source. `app_roles.roleid` has no sequence and no
|
||||
// default — every id in that table was assigned by hand — so nothing is being
|
||||
// worked around here.
|
||||
const (
|
||||
// PosRoleSupervisor runs the terminal: settings, imports, price overrides,
|
||||
// voids, and creating the people below.
|
||||
PosRoleSupervisor = 7
|
||||
|
||||
// PosRoleCashier bills, and nothing else.
|
||||
PosRoleCashier = 8
|
||||
)
|
||||
|
||||
// PosRoleName maps a role id to what a person calls it.
|
||||
func PosRoleName(roleID int) string {
|
||||
switch roleID {
|
||||
case PosRoleSupervisor:
|
||||
return "Supervisor"
|
||||
case PosRoleCashier:
|
||||
return "Cashier"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PosRoleFromName reads the role off a request.
|
||||
//
|
||||
// Accepts the name rather than the number, so a caller never has to hardcode 7
|
||||
// or 8 — and returns 0 for anything unrecognised, which every caller treats as
|
||||
// a refusal rather than as a default.
|
||||
func PosRoleFromName(name string) int {
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "supervisor":
|
||||
return PosRoleSupervisor
|
||||
case "cashier":
|
||||
return PosRoleCashier
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// PosRoleCanManageStaff reports whether a role may create and edit till users.
|
||||
//
|
||||
// Supervisors, plus the back office's own admin and manager roles — somebody
|
||||
// who can already administer the shop from a browser is not made less
|
||||
// privileged by standing at the counter.
|
||||
//
|
||||
// A cashier is never included, and neither is roleid 0. Zero is not a role: it
|
||||
// is what an account carries when nobody set one, and live data has riders and
|
||||
// shop accounts sharing it.
|
||||
func PosRoleCanManageStaff(roleID int) bool {
|
||||
switch roleID {
|
||||
case PosRoleSupervisor, 1, 2, 3, 4, 5, 6:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PosUser is a person who signs in at a till.
|
||||
type PosUser struct {
|
||||
Userid int `json:"user_id"`
|
||||
Fullname string `json:"full_name"`
|
||||
Firstname string `json:"first_name,omitempty"`
|
||||
Lastname string `json:"last_name,omitempty"`
|
||||
Authname string `json:"authname,omitempty"`
|
||||
Contactno string `json:"contactno,omitempty"`
|
||||
Roleid int `json:"role_id"`
|
||||
Role string `json:"role"`
|
||||
Pin string `json:"pin,omitempty"`
|
||||
Haspassword bool `json:"has_password"`
|
||||
Locationid int `json:"location_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// PosUserRequest creates or edits a till user.
|
||||
//
|
||||
// Note what is absent: tenant and location. Both come from the caller's own
|
||||
// session token. A supervisor creating staff can only ever create them at their
|
||||
// own outlet, and no field in this struct can say otherwise — which is the same
|
||||
// inversion that stopped a till naming its own shop.
|
||||
type PosUserRequest struct {
|
||||
Userid int `json:"user_id"`
|
||||
Fullname string `json:"full_name"`
|
||||
Role string `json:"role"`
|
||||
Pin string `json:"pin"`
|
||||
Password string `json:"password"`
|
||||
Authname string `json:"authname"`
|
||||
Contactno string `json:"contactno"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user