Files
Suriya ca846f9cd6 require a mobile number when creating a till account
A till signs in with a mobile number and a PIN, but createposuser still
accepted an account without a number. Such an account cannot reach the
sign-in screen at all, and the failure surfaces at a counter in front of
a queue rather than at the point of creation. Enforced in CreatePosUser,
so both doors are covered: POST /pos/users from the terminal and
createposuser from the console share that path.

Two checks, not one. normalisePosPhone answers ("", nil) rather than an
error for a value holding no digits, so "abc" would have passed an
emptiness check, then been written as a blank and skipped the uniqueness
check below it — which is the hole this closes.

Scope is new accounts only. The column stays nullable and UpdatePosUser
still reads an empty contactno as "leave alone", so the accounts that
predate the number keep working through the backfill and cannot have
theirs cleared. The PIN stays optional at creation.

Also in this change:

- docs: correct both phone-login handovers, which claimed creation
  already required a number. The sequencing note in the PIN handover
  said step 2 was a backfill that could never be finished; it now is
  one, and POS_LOGIN.md says which half of the pair creation enforces.

- docs: remove credentials from the examples. POS_PHONE_LOGIN_HANDOVER
  carried a real-looking back-office pair and a generated till password,
  and POS_LOGIN.md a second one.

- posController.Staff: the comment justified scoping by token because
  "the answer carries PINs". It has not since the PIN left the wire. The
  scoping is still right for a different reason, which the comment now
  gives.

- scratch/posstaffsetup: takes both mobile numbers as arguments and
  refuses to run without them. Generating stand-ins would have produced
  exactly what this change prevents. Validated before the database is
  opened, in plan mode too, so a dry run cannot print a plan that apply
  would reject halfway through and leave half a shop set up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:26:41 +05:30

234 lines
8.1 KiB
Go
Raw Permalink 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.
//
// Mobile numbers are the opposite: they must be supplied, because a till now
// signs in with one and an invented number is worse than none. It would be a
// credential nobody at the shop can type, and it could collide with a real
// person's number elsewhere on the platform.
//
// go run ./scratch/posstaffsetup plan 1087 1135 <supervisor-mobile> <cashier-mobile>
// go run ./scratch/posstaffsetup apply 1087 1135 <supervisor-mobile> <cashier-mobile>
package main
import (
"crypto/rand"
"fmt"
"log"
"math/big"
"os"
"path/filepath"
"strconv"
"strings"
"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])
}
// The two numbers these accounts will sign in with. No default, and no
// generated stand-in: CreatePosUser now refuses an account without one, and
// the right answer to "I do not know the shop's numbers" is to go and ask
// rather than to write something that will have to be found and undone.
//
// Checked in `plan` too, so a dry run fails here rather than printing a plan
// that `apply` would then reject halfway through.
if len(os.Args) < 6 {
log.Fatalf("usage: %s {plan|apply} <tenantid> <locationid> <supervisor-mobile> <cashier-mobile>",
filepath.Base(os.Args[0]))
}
supervisorPhone, err := phoneArg(os.Args[4])
if err != nil {
log.Fatalf("supervisor mobile: %v", err)
}
cashierPhone, err := phoneArg(os.Args[5])
if err != nil {
log.Fatalf("cashier mobile: %v", err)
}
// A number is unique among a tenant's till accounts, so the same one twice
// would create the supervisor and then fail on the cashier — leaving half a
// shop set up and this script's "refuses to add duplicates" guard blocking
// the retry. Caught before anything is written, as the PIN clash is below.
if supervisorPhone == cashierPhone {
log.Fatalf("both accounts were given %s; a mobile number signs in exactly one person", supervisorPhone)
}
_ = 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
}
// Both roles get a username and a password as well as a PIN, and neither is
// stated here: CreatePosUser generates them and returns them once.
//
// A PIN cannot open a *closed* terminal — the PIN route requires a session
// that already exists — so a PIN-only account works only while somebody else
// is standing there to unlock the till first. For a supervisor that was an
// outright deadlock; for a cashier it means a shop that cannot open until
// two people have arrived. Whoever gets in at seven is as often the cashier
// as the supervisor.
wanted := []models.PosUserRequest{
{Fullname: "Store Supervisor", Role: "supervisor", Pin: newPin(), Contactno: supervisorPhone},
{Fullname: "Counter Cashier", Role: "cashier", Pin: newPin(), Contactno: cashierPhone},
}
for wanted[0].Pin == wanted[1].Pin {
wanted[1].Pin = newPin()
}
fmt.Println("\nwould create:")
for _, w := range wanted {
fmt.Printf(" %-22s %-12s mobile=%s pin=%s (login generated on create)\n",
w.Fullname, w.Role, w.Contactno, 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)
fmt.Printf(" login %s / %s\n", created.Authname, created.Password)
}
// 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)
}
// phoneArg reduces a mobile number typed on the command line to the ten digits
// the row stores.
//
// A deliberate mirror of repositories.normalisePosPhone, which is unexported.
// The server stays the authority — CreatePosUser normalises again and refuses
// anything it does not like — so this exists only to fail a `plan` run early
// and to print the number in the form it will actually be stored in. If the two
// ever disagree, the server is right and this is the copy to fix.
func phoneArg(raw string) (string, error) {
digits := strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, raw)
if len(digits) == 12 && strings.HasPrefix(digits, "91") {
digits = digits[2:]
} else if len(digits) == 11 && strings.HasPrefix(digits, "0") {
digits = digits[1:]
}
if len(digits) != 10 {
return "", fmt.Errorf("must be 10 digits; got %q", raw)
}
return digits, nil
}
// newPin returns a four-digit PIN this schema can store, from crypto/rand.
//
// 1000–9999 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
}
}