Files
backend_fiesta/scratch/posstaffsetup/main.go
Suriya c0a7fbc1b1 Stop the till and Nearle Daily from sharing accounts
app_users is the only thing the two products have in common, and the code was
treating it as though it were the whole relationship. Both directions leaked.

Back-office roles were leaking into the till. PosRoleCanManageStaff returned
true for roleid 1 to 6, on the reasoning that somebody who already administers a
shop from a browser is not made less privileged by standing at the counter. That
sounds fine and is wrong: measured against live data it handed till-supervisor
powers to 68 accounts, 59 of them Nearle Daily Super admins, not one of whom is
the administrator of anybody's POS. Meanwhile the actual shop accounts carry
roleid 0 and were refused, so the mapping was backwards from intent in both
halves at once.

Till accounts were leaking into the application. GetStaffs is WHERE tenantid
with no role filter, so a Counter Cashier appeared in the tenant staff list
beside the delivery riders — a row every action on that page would fail against,
since a cashier has no app login, no rider shift and no back-office screen.

So: eligibility for a till is now granted explicitly by provisioning a
Supervisor or a Cashier, never inherited from a back-office role, and roles 7
and 8 are excluded from every Nearle Daily lookup. The exclusion lives in the
queries rather than in a check after them, because a check bolted on afterwards
has to be repeated at six call sites and is one edit away from being forgotten
at one of them — and that one would be the hole. A till account is not rejected
by the app login; it is not found.

Two things this surfaced that were not visible before.

A Supervisor could not open a till. PIN sign-in needs a session that already
exists, so once back-office roles were refused, an outlet whose only POS
accounts were PIN-only had no way in at all. Supervisors are now provisioned
with a username and password as well as a PIN; cashiers deliberately get neither,
because they sign on at a counter somebody has already opened and a second
password would be one more credential to leak for no capability gained.

UpdatePosUser silently dropped authname. It wrote the password, reported
success, and left the account unreachable by either lookup — the failure
surfaced at a counter as "not recognised" rather than on the screen that caused
it. Contactno had the same gap.

Verified against live rows rather than asserted, by scratch/posseparation: a
provisioned supervisor signs in and gets the supervisor shell; five real
back-office accounts including Super admins are refused; the supervisor is
invisible to applogin, tenant weblogin and the password-setup lookup; and no
till account appears in getallusers, while asking for role 7 by name still
returns them so the console can read its own people.

All five outlets that stock products now have a Supervisor and a Cashier.

Also moves the loose markdown into docs/, which was already staged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:52:23 +05:30

208 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
)
// newPassword generates a password for a supervisor's till login.
//
// From crypto/rand and printed once, like the PINs. Deliberately not derived
// from the shop's name or id: a credential anybody could guess from the sign
// above the door is not a credential.
func newPassword() string {
const alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
out := make([]byte, 14)
for i := range out {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
if err != nil {
log.Fatalf("generating a password: %v", err)
}
out[i] = alphabet[n.Int64()]
}
return string(out)
}
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
}
// The supervisor gets a username and password as well as a PIN, because a
// PIN cannot open a *closed* terminal — the PIN route requires a session
// that already exists. Without these, an outlet whose only accounts are POS
// accounts has no way in at all: the back-office logins are refused by role
// and the till logins have no password. That deadlock is not hypothetical;
// it is what the first cut of strict mode actually produced.
//
// The cashier deliberately gets neither. They sign on at a terminal a
// supervisor has already opened, so a second password would be one more
// credential to leak for no capability gained.
//
// The username is derived from the outlet rather than from a person, so it
// survives staff turnover. `authname` is not unique in this schema, but
// scoping it to the outlet keeps it unambiguous in practice, and `email` is
// left null on purpose — that column *is* unique, and blank strings collide.
wanted := []models.PosUserRequest{
{
Fullname: "Store Supervisor",
Role: "supervisor",
Pin: newPin(),
Authname: fmt.Sprintf("supervisor.%d@pos.nearle.in", locationID),
Password: newPassword(),
},
{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", w.Fullname, w.Role, w.Pin)
if w.Authname != "" {
fmt.Printf(" login=%s / %s", w.Authname, w.Password)
}
fmt.Println()
}
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",
created.Userid, created.Fullname, created.Role, created.Pin)
if w.Authname != "" {
fmt.Printf(" login=%s / %s", w.Authname, w.Password)
}
fmt.Println()
}
// 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
}
}