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:
Suriya
2026-08-06 20:22:16 +05:30
parent c696ec3e79
commit 4b27b84b1f
12 changed files with 1387 additions and 14 deletions

102
scratch/posroles/main.go Normal file
View File

@@ -0,0 +1,102 @@
// Adds the two POS roles to app_roles.
//
// `app_roles` has no sequence on roleid — every id in it was assigned by hand —
// so 7 and 8 are written explicitly and must match models.PosRoleSupervisor and
// models.PosRoleCashier.
//
// configid is left NULL deliberately. Every other row is portal-specific, which
// is why Admin appears twice (3 and 5) and Manager twice (4 and 6). A till is a
// till whichever portal a tenant uses, and duplicating these per config would
// be one more thing to remember on every onboarding.
//
// go run ./scratch/posroles plan
// go run ./scratch/posroles apply
package main
import (
"fmt"
"log"
"os"
"nearle/models"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
mode := "plan"
if len(os.Args) > 1 {
mode = os.Args[1]
}
_ = godotenv.Load()
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
log.Fatal(err)
}
wanted := []struct {
id int
name string
}{
{models.PosRoleSupervisor, "Supervisor"},
{models.PosRoleCashier, "Cashier"},
}
write := mode == "apply"
changes := 0
for _, w := range wanted {
var existing string
db.Raw(`SELECT COALESCE(rolename,'') FROM app_roles WHERE roleid = ?`, w.id).Scan(&existing)
switch {
case existing == w.name:
fmt.Printf(" %-4d %-12s already present\n", w.id, w.name)
case existing != "":
// Refuses rather than overwrites. Renaming a role that something
// else already points at would silently re-permission real accounts.
fmt.Printf(" %-4d OCCUPIED by %q — refusing to overwrite\n", w.id, existing)
default:
fmt.Printf(" %-4d %-12s WOULD INSERT\n", w.id, w.name)
changes++
if write {
if err := db.Exec(
`INSERT INTO app_roles (roleid, rolename, configid) VALUES (?, ?, NULL)`,
w.id, w.name,
).Error; err != nil {
log.Fatalf("inserting role %d: %v", w.id, err)
}
}
}
}
fmt.Println()
if write {
fmt.Printf("APPLIED %d role(s).\n", changes)
} else {
fmt.Printf("%d role(s) would be added. Nothing written — run `apply`.\n", changes)
}
var rows []struct {
Roleid int
Rolename string
}
db.Raw(`SELECT roleid, COALESCE(rolename,'') AS rolename FROM app_roles ORDER BY roleid`).Scan(&rows)
fmt.Println("\napp_roles now:")
for _, r := range rows {
fmt.Printf(" %-4d %s\n", r.Roleid, r.Rolename)
}
if len(rows) > 0 {
fmt.Println("\n-- undo:")
fmt.Printf("DELETE FROM app_roles WHERE roleid IN (%d, %d);\n",
models.PosRoleSupervisor, models.PosRoleCashier)
}
}

View File

@@ -0,0 +1,160 @@
// Creates a supervisor and a cashier at an outlet, then proves both can sign in.
//
// Exists because outlet 1135 — the one the terminal ships pointed at — had no
// staff at all, so the till fell back to the three PINs compiled into the app.
// Real staff here are what retire those.
//
// PINs are generated rather than chosen, from crypto/rand, and printed once so
// they can be handed to the shop. They are deliberately not derived from
// anything guessable.
//
// go run ./scratch/posstaffsetup plan 1087 1135
// go run ./scratch/posstaffsetup apply 1087 1135
package main
import (
"crypto/rand"
"fmt"
"log"
"math/big"
"os"
"strconv"
"nearle/models"
"nearle/repositories"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
mode, tenantID, locationID := "plan", 1087, 1135
if len(os.Args) > 1 {
mode = os.Args[1]
}
if len(os.Args) > 3 {
tenantID, _ = strconv.Atoi(os.Args[2])
locationID, _ = strconv.Atoi(os.Args[3])
}
_ = godotenv.Load()
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
log.Fatal(err)
}
repo := repositories.NewPosRepository(db)
var locName string
db.Raw(`SELECT COALESCE(locationname,'') FROM tenantlocations WHERE locationid=? AND tenantid=?`,
locationID, tenantID).Scan(&locName)
if locName == "" {
log.Fatalf("tenant %d has no outlet %d", tenantID, locationID)
}
// The configid the shop's other accounts use, so a new cashier is visible
// to the same portal as everybody else at that outlet.
var configID int
db.Raw(`SELECT COALESCE(configid,0) FROM app_users
WHERE tenantid=? AND COALESCE(configid,0) > 0
GROUP BY configid ORDER BY COUNT(*) DESC LIMIT 1`, tenantID).Scan(&configID)
fmt.Printf("tenant %d, outlet %d (%s), configid %d\n\n", tenantID, locationID, locName, configID)
existing, err := repo.ListPosUsers(tenantID, locationID, true)
if err != nil {
log.Fatal(err)
}
fmt.Printf("till users already at this outlet: %d\n", len(existing))
for _, u := range existing {
fmt.Printf(" %-6d %-22s %-12s pin=%s %s\n", u.Userid, u.Fullname, u.Role, u.Pin, u.Status)
}
if len(existing) > 0 {
fmt.Println("\nAlready set up. Nothing to do — this refuses to add duplicates.")
return
}
wanted := []models.PosUserRequest{
{Fullname: "Store Supervisor", Role: "supervisor", Pin: newPin()},
{Fullname: "Counter Cashier", Role: "cashier", Pin: newPin()},
}
for wanted[0].Pin == wanted[1].Pin {
wanted[1].Pin = newPin()
}
fmt.Println("\nwould create:")
for _, w := range wanted {
fmt.Printf(" %-22s %-12s pin=%s\n", w.Fullname, w.Role, w.Pin)
}
if mode != "apply" {
fmt.Println("\nNothing written — run `apply` to commit.")
return
}
fmt.Println()
for _, w := range wanted {
created, err := repo.CreatePosUser(tenantID, locationID, configID, w)
if err != nil {
log.Fatalf("creating %s: %v", w.Fullname, err)
}
fmt.Printf(" created userid %-6d %-22s %-12s PIN %s\n",
created.Userid, created.Fullname, created.Role, created.Pin)
}
// The point of the exercise: does the till now see real staff?
staff, err := repo.PosStaff(tenantID, locationID)
if err != nil {
log.Fatal(err)
}
fmt.Printf("\n/pos/staff now returns %d person(s):\n", len(staff))
for _, s := range staff {
fmt.Printf(" %-22s %-12s\n", s.Fullname, s.Role)
}
// And can they actually sign in?
fmt.Println("\nPIN sign-in:")
for _, w := range wanted {
session, err := repo.PosLoginByPin(tenantID, locationID, w.Pin)
if err != nil {
fmt.Printf(" %-22s REFUSED: %v\n", w.Fullname, err)
continue
}
fmt.Printf(" %-22s -> %s at %s, can_manage_staff=%v\n",
w.Fullname, session.Role, session.Locationname, session.Canmanagestaff)
}
if _, err := repo.PosLoginByPin(tenantID, locationID, "5555"); err != nil {
fmt.Printf("\n an unknown PIN is refused: %v\n", err)
} else {
fmt.Println("\n !! an unknown PIN was ACCEPTED")
}
fmt.Println("\n-- undo:")
fmt.Printf("UPDATE app_users SET status='InActive' WHERE tenantid=%d AND locationid=%d AND roleid IN (%d,%d);\n",
tenantID, locationID, models.PosRoleSupervisor, models.PosRoleCashier)
}
// newPin returns a four-digit PIN this schema can store, from crypto/rand.
//
// 10009999 because a leading zero cannot survive a bigint column, and the
// obvious ones are rejected by validatePosPin anyway — retried here rather than
// filtered, so the distribution stays even.
func newPin() string {
for {
n, err := rand.Int(rand.Reader, big.NewInt(9000))
if err != nil {
log.Fatal(err)
}
pin := strconv.FormatInt(n.Int64()+1000, 10)
switch pin {
case "1234", "1111", "2345", "3456", "4321", "9999", "2222":
continue
}
return pin
}
}