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>
511 lines
17 KiB
Go
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
|
|
}
|