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>
423 lines
13 KiB
Go
423 lines
13 KiB
Go
package repositories
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"nearle/models"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Till staff, managed by the shop rather than by us.
|
|
//
|
|
// A supervisor creates their own cashiers, at their own outlet, from the
|
|
// terminal. Everything here follows one rule: **the tenant and the outlet come
|
|
// from the caller's session token and never from the request body.** A
|
|
// supervisor at Selvapuram cannot create a cashier at R mart by sending a
|
|
// different number, for the same reason a till cannot bill into another shop.
|
|
|
|
// PosPinMin and PosPinMax bound an acceptable PIN.
|
|
//
|
|
// Four digits, and never starting with a zero — because `app_users.pin` is a
|
|
// `bigint`. A PIN of "0451" would be stored as 451 and read back as three
|
|
// digits, so a cashier would type four and be refused for ever. Live data
|
|
// already holds one such account.
|
|
//
|
|
// Refusing the leading zero costs a shop 1000 of 10000 combinations and buys a
|
|
// PIN that means the same thing on the way in and on the way out.
|
|
const (
|
|
PosPinMin = 1000
|
|
PosPinMax = 9999
|
|
)
|
|
|
|
// CreatePosUser adds a cashier or supervisor at the caller's outlet.
|
|
func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error) {
|
|
roleID := models.PosRoleFromName(req.Role)
|
|
if roleID == 0 {
|
|
return nil, fmt.Errorf("role must be 'supervisor' or 'cashier'")
|
|
}
|
|
|
|
name := strings.TrimSpace(req.Fullname)
|
|
if name == "" {
|
|
return nil, fmt.Errorf("a name is required")
|
|
}
|
|
first, last := splitName(name)
|
|
|
|
pin, err := validatePosPin(req.Pin)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
password := strings.TrimSpace(req.Password)
|
|
authname := strings.ToLower(strings.TrimSpace(req.Authname))
|
|
|
|
// One or the other, at least. A person with neither cannot sign in, and
|
|
// creating them would look like it worked right up until somebody tried.
|
|
if pin == 0 && password == "" {
|
|
return nil, fmt.Errorf("set a PIN, a password, or both — otherwise this person cannot sign in")
|
|
}
|
|
if password != "" && authname == "" {
|
|
return nil, fmt.Errorf("a password needs an email to go with it")
|
|
}
|
|
|
|
var created *models.PosUser
|
|
|
|
err = r.db.Transaction(func(tx *gorm.DB) error {
|
|
// `app_users` has no sequence and no identity — every id in it was
|
|
// assigned by hand. So the next one is read and written inside one
|
|
// transaction, behind an advisory lock, or two supervisors creating
|
|
// staff at the same moment would compute the same id and one insert
|
|
// would lose.
|
|
if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext('app_users'))`).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if pin > 0 {
|
|
taken, err := posPinTaken(tx, tenantID, locationID, pin, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if taken {
|
|
return fmt.Errorf("another person at this outlet already uses that PIN")
|
|
}
|
|
}
|
|
|
|
if authname != "" {
|
|
var clash int64
|
|
if err := tx.Raw(`SELECT COUNT(1) FROM app_users WHERE LOWER(TRIM(authname)) = ?`,
|
|
authname).Scan(&clash).Error; err != nil {
|
|
return err
|
|
}
|
|
if clash > 0 {
|
|
return fmt.Errorf("an account already uses %s", authname)
|
|
}
|
|
}
|
|
|
|
var nextID int
|
|
if err := tx.Raw(`SELECT COALESCE(MAX(userid), 0) + 1 FROM app_users`).Scan(&nextID).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := tx.Exec(`
|
|
INSERT INTO app_users
|
|
(userid, firstname, lastname, authname, email, contactno, password,
|
|
pin, roleid, configid, tenantid, locationid, status)
|
|
VALUES (?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''),
|
|
NULLIF(?, 0), ?, ?, ?, ?, 'Active')`,
|
|
nextID, first, last, authname, authname, strings.TrimSpace(req.Contactno),
|
|
password, pin, roleID, configID, tenantID, locationID,
|
|
).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
created = &models.PosUser{
|
|
Userid: nextID,
|
|
Fullname: name,
|
|
Firstname: first,
|
|
Lastname: last,
|
|
Authname: authname,
|
|
Contactno: strings.TrimSpace(req.Contactno),
|
|
Roleid: roleID,
|
|
Role: models.PosRoleName(roleID),
|
|
Pin: posPinString(pin),
|
|
Haspassword: password != "",
|
|
Locationid: locationID,
|
|
Status: "Active",
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return created, nil
|
|
}
|
|
|
|
// UpdatePosUser edits a till user at the caller's outlet.
|
|
//
|
|
// Scoped by tenant *and* location in the WHERE clause rather than checked
|
|
// first: a supervisor sending somebody else's user id updates no rows and is
|
|
// told so, instead of quietly editing another shop's staff.
|
|
func (r *posRepository) UpdatePosUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error) {
|
|
if req.Userid <= 0 {
|
|
return nil, fmt.Errorf("user_id is required")
|
|
}
|
|
|
|
sets := []string{}
|
|
args := []interface{}{}
|
|
|
|
if name := strings.TrimSpace(req.Fullname); name != "" {
|
|
first, last := splitName(name)
|
|
sets = append(sets, "firstname = ?", "lastname = ?")
|
|
args = append(args, first, last)
|
|
}
|
|
|
|
if role := strings.TrimSpace(req.Role); role != "" {
|
|
roleID := models.PosRoleFromName(role)
|
|
if roleID == 0 {
|
|
return nil, fmt.Errorf("role must be 'supervisor' or 'cashier'")
|
|
}
|
|
sets = append(sets, "roleid = ?")
|
|
args = append(args, roleID)
|
|
}
|
|
|
|
pin := int64(0)
|
|
if strings.TrimSpace(req.Pin) != "" {
|
|
p, err := validatePosPin(req.Pin)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pin = p
|
|
sets = append(sets, "pin = ?")
|
|
args = append(args, pin)
|
|
}
|
|
|
|
if password := strings.TrimSpace(req.Password); password != "" {
|
|
sets = append(sets, "password = ?")
|
|
args = append(args, password)
|
|
}
|
|
|
|
if status := strings.TrimSpace(req.Status); status != "" {
|
|
sets = append(sets, "status = ?")
|
|
args = append(args, status)
|
|
}
|
|
|
|
if len(sets) == 0 {
|
|
return nil, fmt.Errorf("nothing to change")
|
|
}
|
|
|
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext('app_users'))`).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if pin > 0 {
|
|
taken, err := posPinTaken(tx, tenantID, locationID, pin, req.Userid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if taken {
|
|
return fmt.Errorf("another person at this outlet already uses that PIN")
|
|
}
|
|
}
|
|
|
|
query := fmt.Sprintf(
|
|
`UPDATE app_users SET %s WHERE userid = ? AND tenantid = ? AND locationid = ?`,
|
|
strings.Join(sets, ", "))
|
|
args = append(args, req.Userid, tenantID, locationID)
|
|
|
|
result := tx.Exec(query, args...)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return fmt.Errorf("no user %d at this outlet", req.Userid)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
users, err := r.ListPosUsers(tenantID, locationID, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range users {
|
|
if users[i].Userid == req.Userid {
|
|
return &users[i], nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// ListPosUsers returns the till users at an outlet.
|
|
func (r *posRepository) ListPosUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error) {
|
|
rows := make([]struct {
|
|
Userid int
|
|
Firstname string
|
|
Lastname string
|
|
Authname string
|
|
Contactno string
|
|
Roleid int
|
|
Pin int64
|
|
Haspassword bool
|
|
Status string
|
|
}, 0)
|
|
|
|
query := `
|
|
SELECT userid,
|
|
COALESCE(firstname,'') AS firstname, COALESCE(lastname,'') AS lastname,
|
|
COALESCE(authname,'') AS authname, COALESCE(contactno,'') AS contactno,
|
|
COALESCE(roleid,0) AS roleid, COALESCE(pin,0) AS pin,
|
|
(COALESCE(password,'') <> '') AS haspassword,
|
|
COALESCE(status,'') AS status
|
|
FROM app_users
|
|
WHERE tenantid = ? AND locationid = ?
|
|
AND COALESCE(roleid,0) IN (?, ?)`
|
|
params := []interface{}{tenantID, locationID, models.PosRoleSupervisor, models.PosRoleCashier}
|
|
|
|
if !includeInactive {
|
|
query += ` AND LOWER(COALESCE(status,'active')) <> 'inactive'`
|
|
}
|
|
query += ` ORDER BY userid`
|
|
|
|
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
users := make([]models.PosUser, 0, len(rows))
|
|
for _, row := range rows {
|
|
users = append(users, models.PosUser{
|
|
Userid: row.Userid,
|
|
Fullname: strings.TrimSpace(row.Firstname + " " + row.Lastname),
|
|
Firstname: row.Firstname,
|
|
Lastname: row.Lastname,
|
|
Authname: row.Authname,
|
|
Contactno: row.Contactno,
|
|
Roleid: row.Roleid,
|
|
Role: models.PosRoleName(row.Roleid),
|
|
Pin: posPinString(row.Pin),
|
|
Haspassword: row.Haspassword,
|
|
Locationid: locationID,
|
|
Status: row.Status,
|
|
})
|
|
}
|
|
return users, nil
|
|
}
|
|
|
|
// DeactivatePosUser retires somebody without deleting them.
|
|
//
|
|
// Bills carry the cashier's name and shifts settle against it, so a hard delete
|
|
// would orphan a day's takings.
|
|
func (r *posRepository) DeactivatePosUser(tenantID, locationID, userID int) error {
|
|
result := r.db.Exec(`
|
|
UPDATE app_users SET status = 'InActive'
|
|
WHERE userid = ? AND tenantid = ? AND locationid = ?
|
|
AND COALESCE(roleid,0) IN (?, ?)`,
|
|
userID, tenantID, locationID, models.PosRoleSupervisor, models.PosRoleCashier)
|
|
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
// Either no such person, or they belong to another shop, or they are a
|
|
// back-office account rather than till staff. One message for all three
|
|
// — distinguishing them tells a caller about rows they cannot see.
|
|
return fmt.Errorf("no till user %d at this outlet", userID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PosLoginByPin signs somebody in with a PIN alone, inside an outlet.
|
|
//
|
|
// A PIN is four digits, so this must never be reachable by an anonymous caller
|
|
// — ten thousand guesses is not a barrier. It is only called with a tenant and
|
|
// location taken from an *already valid* session token, which means a
|
|
// supervisor has opened the terminal with a real password first and the guesses
|
|
// are confined to one outlet's own staff.
|
|
func (r *posRepository) PosLoginByPin(tenantID, locationID int, pin string) (*models.PosSession, error) {
|
|
value, err := validatePosPin(pin)
|
|
if err != nil {
|
|
return nil, errPosLoginRejected
|
|
}
|
|
|
|
var rows []posLoginRow
|
|
err = r.db.Raw(`
|
|
SELECT userid, COALESCE(password,'') AS password, COALESCE(status,'') AS status,
|
|
COALESCE(roleid,0) AS roleid, COALESCE(configid,0) AS configid,
|
|
COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid,
|
|
COALESCE(firstname,'') AS firstname, COALESCE(lastname,'') AS lastname,
|
|
COALESCE(email,'') AS email
|
|
FROM app_users
|
|
WHERE tenantid = ? AND locationid = ? AND pin = ?
|
|
AND LOWER(COALESCE(status,'active')) <> 'inactive'
|
|
ORDER BY userid`, tenantID, locationID, value).Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(rows) == 0 {
|
|
return nil, errPosLoginRejected
|
|
}
|
|
// Two people on one PIN would attribute a bill to whichever row was read
|
|
// first. Creation refuses a duplicate, but data predating this endpoint
|
|
// need not have, so it is refused here too rather than guessed.
|
|
if len(rows) > 1 {
|
|
return nil, fmt.Errorf("more than one person at this outlet uses that PIN; ask a supervisor to change one of them")
|
|
}
|
|
|
|
return r.sessionFor(rows[0], locationID)
|
|
}
|
|
|
|
// posPinTaken reports whether a PIN is already in use at an outlet.
|
|
//
|
|
// Scoped to the outlet rather than globally, because a PIN only ever
|
|
// distinguishes people standing at the same counter — making them unique across
|
|
// the platform would exhaust nine thousand combinations very quickly.
|
|
func posPinTaken(tx *gorm.DB, tenantID, locationID int, pin int64, exceptUser int) (bool, error) {
|
|
var count int64
|
|
err := tx.Raw(`
|
|
SELECT COUNT(1) FROM app_users
|
|
WHERE tenantid = ? AND locationid = ? AND pin = ? AND userid <> ?
|
|
AND LOWER(COALESCE(status,'active')) <> 'inactive'`,
|
|
tenantID, locationID, pin, exceptUser).Scan(&count).Error
|
|
return count > 0, err
|
|
}
|
|
|
|
// validatePosPin checks a PIN is one this schema can store faithfully.
|
|
func validatePosPin(raw string) (int64, error) {
|
|
pin := strings.TrimSpace(raw)
|
|
if pin == "" {
|
|
return 0, nil
|
|
}
|
|
|
|
if len(pin) != 4 {
|
|
return 0, fmt.Errorf("a PIN is exactly 4 digits")
|
|
}
|
|
value, err := strconv.ParseInt(pin, 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("a PIN is digits only")
|
|
}
|
|
if value < PosPinMin || value > PosPinMax {
|
|
// Which is to say: it started with a zero. Said plainly, because "a PIN
|
|
// is 4 digits" would be baffling to somebody who just typed four.
|
|
return 0, fmt.Errorf("a PIN cannot start with 0")
|
|
}
|
|
|
|
// The first thing anyone tries, and live data already has 1234 on eleven
|
|
// accounts and 1111 on nine.
|
|
switch pin {
|
|
case "1234", "1111", "0000", "2345", "3456", "4321", "9999", "2222":
|
|
return 0, fmt.Errorf("that PIN is too easy to guess; choose another")
|
|
}
|
|
|
|
return value, nil
|
|
}
|
|
|
|
// posPinString renders a stored PIN.
|
|
//
|
|
// Anything the schema cannot represent as four digits comes back empty rather
|
|
// than short: a three-digit PIN on screen is one a cashier cannot type, and
|
|
// showing it would send them to a supervisor for a fault they cannot describe.
|
|
func posPinString(pin int64) string {
|
|
if pin < PosPinMin || pin > PosPinMax {
|
|
return ""
|
|
}
|
|
return strconv.FormatInt(pin, 10)
|
|
}
|
|
|
|
// splitName turns a typed name into the two columns this schema has.
|
|
func splitName(full string) (first, last string) {
|
|
parts := strings.Fields(strings.TrimSpace(full))
|
|
if len(parts) == 0 {
|
|
return "", ""
|
|
}
|
|
if len(parts) == 1 {
|
|
return parts[0], ""
|
|
}
|
|
return parts[0], strings.Join(parts[1:], " ")
|
|
}
|