Files
backend_fiesta/repositories/posUserRepository.go
Suriya cd2459dbb6 Let an admin create till staff from the console, through the same code
An admin sets a shop up from a browser; a supervisor adds a cashier at the
counter. Both had to be possible, and only the second one was.

So the console gets createposuser / updateposuser / getposusers /
deleteposuser, under both /v1/web/tenants and /v1/mob/tenants — calling the
same service methods `/pos/users` calls. Not a parallel implementation: a
supervisor created from a browser is the same row, with the same PIN rules, the
same duplicate check and the same identity-column allocation, as one created at
a till. Two paths writing one table is precisely how the two stop matching, and
this codebase already had that happen once.

configid is inferred rather than asked for. It is a number nobody looks up, it
varies per tenant — 1087's accounts are spread across 1, 6 and 15 — and getting
it wrong creates somebody who cannot sign into the portal their colleagues use
and is invisible to half the platform's queries.

/posroles is served rather than left to the console to hardcode. A console that
knew supervisor was 7 would be wrong the day that changed and would have no way
to find out.

The outlet is the real difference between the two doors. A terminal proves it
with a signed token; the console asserts it, and is checked against the tenant
before anything is written. That is weaker, and it is worth being plain about:
these mint till credentials on an unauthenticated request, exactly like every
other route in the /v1/web and /v1/mob groups, because there is no auth
middleware on the web API at all. Documented as the weakest point in the design
and flagged to move behind a session guard once the console can hold one. The
terminal routes are untouched by it.

Proven in a rolled-back transaction against live data: the console creates a
supervisor at 1135, that supervisor signs in by PIN with can_manage_staff true,
the till's /pos/staff sees them alongside the two created at the counter, and
0451, 1234 and a duplicate PIN are each refused with the same message the
terminal gives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:42:27 +05:30

511 lines
17 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 {
// The advisory lock is for the PIN check below, not for the id.
//
// `userid` is an identity column — `information_schema.column_default`
// is empty for those, which is easy to misread as "no default at all"
// and was misread here once. Postgres allocates it, and this must not
// compute its own: an explicit id does not advance the sequence, so a
// hand-rolled MAX+1 leaves two allocators running in parallel that
// eventually land on the same number.
//
// The lock still earns its place. Two supervisors adding staff at the
// same instant could otherwise both find a PIN free and both take it,
// and a duplicate PIN attributes a bill to whichever row is read first.
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)
}
}
// `userid` is omitted so the identity column allocates it, and read back
// with RETURNING rather than guessed.
//
// The email columns go through NULLIF because `app_users_email_unique`
// is a real constraint: a second person created without an email would
// collide on the empty string, while NULLs do not collide in Postgres.
// A cashier who signs in by PIN alone has no email, and that is the
// common case.
var nextID int
if err := tx.Raw(`
INSERT INTO app_users
(firstname, lastname, authname, email, contactno, password,
pin, roleid, configid, tenantid, locationid, status)
VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''),
NULLIF(?, 0), ?, ?, ?, ?, 'Active')
RETURNING userid`,
first, last, authname, authname, strings.TrimSpace(req.Contactno),
password, pin, roleID, configID, tenantID, locationID,
).Scan(&nextID).Error; err != nil {
return err
}
if nextID <= 0 {
return fmt.Errorf("the account was not created")
}
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:], " ")
}
// ValidateStaffUser applies the till's rules to a staff row from anywhere.
//
// Exported because the web console writes `app_users` too, through
// `tenants/createstaff`, and that path had no validation whatsoever — no PIN
// rules, no role check, no duplicate check. A cashier created there could be
// given "0451", which a bigint column stores as 451, and would then type four
// digits at the counter and be refused for ever with nothing to explain it.
//
// Two paths writing one table drift apart. This is the shared rule set, so a
// person created from a browser and a person created from a till are subject to
// the same constraints and behave the same way at the counter.
//
// Returns the parsed PIN, or an error a caller can show to whoever typed it.
func ValidateStaffUser(user *models.User) (int64, error) {
if strings.TrimSpace(user.Firstname+user.Lastname) == "" {
return 0, fmt.Errorf("a name is required")
}
// Only the roles this platform actually defines. `roleid` 0 is the one that
// matters: it is not a role, it is what a row carries when nobody set one,
// and live data has riders and shop accounts sharing it.
if user.Roleid <= 0 {
return 0, fmt.Errorf("a role is required")
}
pin := int64(user.Pin)
if pin != 0 {
parsed, err := validatePosPin(strconv.FormatInt(pin, 10))
if err != nil {
return 0, err
}
pin = parsed
}
if pin == 0 && strings.TrimSpace(user.Password) == "" {
return 0, fmt.Errorf("set a PIN, a password, or both — otherwise this person cannot sign in")
}
return pin, nil
}
// StaffPinAvailable reports whether a PIN is free at an outlet.
//
// Exported for the same reason as [ValidateStaffUser]: the web console needs
// the check the till already makes. Two people sharing a PIN would attribute a
// bill to whichever row happened to be read first.
func (r *posRepository) StaffPinAvailable(tenantID, locationID int, pin int64, exceptUser int) (bool, error) {
if pin == 0 {
return true, nil
}
taken, err := posPinTaken(r.db, tenantID, locationID, pin, exceptUser)
return !taken, err
}
// PosConfigidFor returns the configid an outlet's people already use.
//
// The console cannot sensibly be asked for this. It is a number nobody looks
// up, it varies per tenant — live data has tenant 1087 spread across 1, 6 and
// 15 — and getting it wrong creates an account that cannot sign into the portal
// its colleagues use and is invisible to half the platform's queries.
//
// So it is inferred from whichever value that tenant's existing accounts most
// commonly carry. Returns 0 for a tenant with no accounts at all, which is
// simply what a fresh tenant looks like.
func (r *posRepository) PosConfigidFor(tenantID int) int {
var configID int
r.db.Raw(`SELECT COALESCE(configid, 0) FROM app_users
WHERE tenantid = ? AND COALESCE(configid, 0) > 0
GROUP BY configid ORDER BY COUNT(*) DESC, configid LIMIT 1`,
tenantID).Scan(&configID)
return configID
}