813 lines
29 KiB
Go
813 lines
29 KiB
Go
package repositories
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"fmt"
|
|
"math/big"
|
|
"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
|
|
)
|
|
|
|
// posDefaultAuthname is the username a till account gets when nobody names one.
|
|
//
|
|
// Keyed on the outlet and the role rather than on the person, so it survives
|
|
// staff turnover: a shop replacing its cashier reissues one password instead of
|
|
// re-teaching a new address. `nth` disambiguates a second account of the same
|
|
// role at the same counter and is omitted for the first, so the common case
|
|
// stays the readable one.
|
|
//
|
|
// The domain is deliberately not a real one. These are till credentials, never
|
|
// a mailbox, and an address that looks deliverable invites somebody to try
|
|
// sending a reset to it.
|
|
func posDefaultAuthname(roleID, locationID, nth int) string {
|
|
role := strings.ToLower(models.PosRoleName(roleID))
|
|
if role == "" {
|
|
role = "staff"
|
|
}
|
|
if nth > 1 {
|
|
return fmt.Sprintf("%s%d.%d@pos.nearle.in", role, nth, locationID)
|
|
}
|
|
return fmt.Sprintf("%s.%d@pos.nearle.in", role, locationID)
|
|
}
|
|
|
|
// newPosPassword generates a till password.
|
|
//
|
|
// From crypto/rand, and returned to the caller exactly once — at creation —
|
|
// because the column it lands in is plaintext and reading it back later should
|
|
// take a deliberate query rather than an ordinary list call.
|
|
//
|
|
// The alphabet drops l, I, O, 0 and 1. These get read off one screen and typed
|
|
// on another by somebody with a queue in front of them.
|
|
func newPosPassword() 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 {
|
|
// crypto/rand failing is not a condition to paper over with a
|
|
// weaker source; a guessable till password is worse than no till.
|
|
panic(fmt.Sprintf("generating a till password: %v", err))
|
|
}
|
|
out[i] = alphabet[n.Int64()]
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// The number this person signs in with, and required.
|
|
//
|
|
// Optional once, on the reasoning that a shop could be provisioned before it
|
|
// had collected everybody's number. That stopped being defensible when the
|
|
// number became half of the sign-in: an account created without one cannot
|
|
// reach the new login screen at all, so "optional" meant the console could
|
|
// quietly keep manufacturing accounts nobody can sign into — and the failure
|
|
// surfaces at a counter, in front of a queue, rather than here.
|
|
//
|
|
// The column stays nullable and [UpdatePosUser] still treats an empty value
|
|
// as "leave alone", so the accounts that predate this keep working through
|
|
// the backfill and cannot have their number cleared. This closes the door on
|
|
// new ones only.
|
|
if strings.TrimSpace(req.Contactno) == "" {
|
|
return nil, fmt.Errorf("a mobile number is required; it is what this person signs in with at the till")
|
|
}
|
|
phone, err := normalisePosPhone(req.Contactno)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if phone == "" {
|
|
// A different case from the check above, not a repeat of it.
|
|
// [normalisePosPhone] answers
|
|
// ("", nil) rather than an error when a value holds no digits at all, so
|
|
// "not a number" arrives here looking exactly like "no number" — and
|
|
// without this the insert would write a blank and skip the uniqueness
|
|
// check below, which is the hole this whole change is closing.
|
|
return nil, fmt.Errorf("mobile number must be 10 digits; got %q", req.Contactno)
|
|
}
|
|
|
|
password := strings.TrimSpace(req.Password)
|
|
authname := strings.ToLower(strings.TrimSpace(req.Authname))
|
|
|
|
// Every till account gets a username and a password, cashiers included.
|
|
//
|
|
// A PIN cannot open a *closed* terminal — the PIN route needs a session that
|
|
// already exists — so a PIN-only cashier can work only while a supervisor is
|
|
// standing there to unlock the till first. That is not how a shop opens: the
|
|
// person who arrives at seven is as often the cashier as the supervisor.
|
|
//
|
|
// Generated when the console does not supply them, so provisioning is one
|
|
// call and nobody has to invent a scheme. An explicit value always wins: a
|
|
// shop that wants its people signing in as themselves just sends one.
|
|
//
|
|
// Whether the name was generated is remembered, because the two cases want
|
|
// opposite handling on a collision — see the uniqueness check below.
|
|
nameWasGenerated := authname == ""
|
|
if nameWasGenerated {
|
|
authname = posDefaultAuthname(roleID, locationID, 0)
|
|
}
|
|
if password == "" {
|
|
password = newPosPassword()
|
|
}
|
|
|
|
// A PIN stays optional. It switches operator at an open counter, which not
|
|
// every shop does, and it is the one credential the till keeps in plaintext
|
|
// to hand around — so it is set deliberately, never by default.
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
// Checked under the same advisory lock as the PIN, and for the same
|
|
// reason: two supervisors provisioning at once would otherwise both see
|
|
// the number free and both write it, leaving a login that resolves to
|
|
// two people and therefore to nobody.
|
|
if phone != "" {
|
|
taken, err := posPhoneTaken(tx, tenantID, phone, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if taken {
|
|
return fmt.Errorf("another till account in this business already signs in with %s", phone)
|
|
}
|
|
}
|
|
|
|
// Uniqueness is checked against `authname` and `email` together because
|
|
// the insert below writes the same value to both, and
|
|
// `app_users_email_unique` is a real constraint — a clash there fails the
|
|
// transaction rather than returning a message anyone can act on.
|
|
taken := func(candidate string) (bool, error) {
|
|
var n int64
|
|
err := tx.Raw(`SELECT COUNT(1) FROM app_users
|
|
WHERE LOWER(TRIM(authname)) = ? OR LOWER(TRIM(email)) = ?`,
|
|
candidate, candidate).Scan(&n).Error
|
|
return n > 0, err
|
|
}
|
|
|
|
if nameWasGenerated {
|
|
// Walk to the first free one. Bounded so a bug here cannot spin:
|
|
// twenty till accounts of one role at a single outlet is already far
|
|
// past what a counter has, and the error names the fix.
|
|
found := false
|
|
for i := 0; i < 20; i++ {
|
|
clash, err := taken(authname)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !clash {
|
|
found = true
|
|
break
|
|
}
|
|
authname = posDefaultAuthname(roleID, locationID, i+2)
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("this outlet already has too many %s accounts; supply an email explicitly",
|
|
strings.ToLower(models.PosRoleName(roleID)))
|
|
}
|
|
} else {
|
|
clash, err := taken(authname)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if clash {
|
|
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, shiftid, roleid, configid, tenantid, locationid, status)
|
|
VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''),
|
|
NULLIF(?, 0), NULLIF(?, 0), ?, ?, ?, ?, 'Active')
|
|
RETURNING userid`,
|
|
first, last, authname, authname, phone,
|
|
password, pin, req.Shiftid, 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: phone,
|
|
Shiftid: req.Shiftid,
|
|
Roleid: roleID,
|
|
Role: models.PosRoleName(roleID),
|
|
Pin: posPinString(pin),
|
|
Haspassword: password != "",
|
|
Locationid: locationID,
|
|
Status: "Active",
|
|
|
|
// The one moment this is ever returned. Listing a till user reports
|
|
// only whether a password exists, so an admin who loses this has to
|
|
// reissue rather than look it up — which is the right shape even
|
|
// while the column itself is plaintext.
|
|
Password: password,
|
|
}
|
|
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)
|
|
}
|
|
|
|
// The username a supervisor opens a closed terminal with.
|
|
//
|
|
// Editable because a password on its own is unusable: sign-in matches on
|
|
// `authname` or `contactno`, so an account given a password and no username
|
|
// cannot be reached by either. This was missing, and the failure was silent
|
|
// — the update reported success, wrote the password, dropped the username,
|
|
// and the supervisor was refused at the counter with "not recognised".
|
|
if authname := strings.TrimSpace(req.Authname); authname != "" {
|
|
sets = append(sets, "authname = ?")
|
|
args = append(args, authname)
|
|
}
|
|
|
|
// Normalised on the way in, exactly as on create — a number edited to
|
|
// "+91 98765 43210" would otherwise stop matching the login that reduces
|
|
// what is typed to ten digits.
|
|
phone := ""
|
|
if strings.TrimSpace(req.Contactno) != "" {
|
|
p, err := normalisePosPhone(req.Contactno)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
phone = p
|
|
sets = append(sets, "contactno = ?")
|
|
args = append(args, phone)
|
|
}
|
|
|
|
// Zero means "not specified" and leaves the shift alone. Clearing one is
|
|
// therefore not expressible here, which is deliberate: every other field on
|
|
// this endpoint behaves the same way, and a sentinel that only one field
|
|
// honours is the kind of asymmetry that gets forgotten.
|
|
if req.Shiftid > 0 {
|
|
sets = append(sets, "shiftid = ?")
|
|
args = append(args, req.Shiftid)
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
// The person being edited is excluded, so re-saving an unchanged number
|
|
// is not reported as a clash with themselves.
|
|
if phone != "" {
|
|
taken, err := posPhoneTaken(tx, tenantID, phone, req.Userid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if taken {
|
|
return fmt.Errorf("another till account in this business already signs in with %s", phone)
|
|
}
|
|
}
|
|
|
|
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
|
|
Shiftid int
|
|
Shiftname string
|
|
Shiftstart string
|
|
Shiftend string
|
|
}, 0)
|
|
|
|
// The shift is LEFT JOINed and matched on the outlet as well as the id.
|
|
//
|
|
// `app_users.shiftid` predates this table and points at `ridershifts` for
|
|
// riders, so the same number means different things depending on the row's
|
|
// role. Joining on tenant and location too means a rider shift id can never
|
|
// resolve to a staff shift that happens to share it — an unmatched id just
|
|
// comes back blank, which is the honest answer for an account created
|
|
// before shifts existed.
|
|
query := `
|
|
SELECT a.userid,
|
|
COALESCE(a.firstname,'') AS firstname, COALESCE(a.lastname,'') AS lastname,
|
|
COALESCE(a.authname,'') AS authname, COALESCE(a.contactno,'') AS contactno,
|
|
COALESCE(a.roleid,0) AS roleid, COALESCE(a.pin,0) AS pin,
|
|
(COALESCE(a.password,'') <> '') AS haspassword,
|
|
COALESCE(a.status,'') AS status,
|
|
COALESCE(s.staffshiftid,0) AS shiftid,
|
|
COALESCE(s.name,'') AS shiftname,
|
|
COALESCE(s.starttime,'') AS shiftstart,
|
|
COALESCE(s.endtime,'') AS shiftend
|
|
FROM app_users a
|
|
LEFT JOIN staffshifts s
|
|
ON s.staffshiftid = a.shiftid
|
|
AND s.tenantid = a.tenantid
|
|
AND s.locationid = a.locationid
|
|
WHERE a.tenantid = ? AND a.locationid = ?
|
|
AND COALESCE(a.roleid,0) IN (?, ?)`
|
|
params := []interface{}{tenantID, locationID, models.PosRoleSupervisor, models.PosRoleCashier}
|
|
|
|
if !includeInactive {
|
|
// `a.status`, qualified. `staffshifts` carries a `status` column too, so
|
|
// the bare name made Postgres refuse the whole statement with
|
|
// `column reference "status" is ambiguous` (42702) — a 500 on every
|
|
// console call, since the console never asks for inactive rows.
|
|
query += ` AND LOWER(COALESCE(a.status,'active')) <> 'inactive'`
|
|
}
|
|
query += ` ORDER BY a.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,
|
|
Shiftid: row.Shiftid,
|
|
Shiftname: row.Shiftname,
|
|
Shiftstart: row.Shiftstart,
|
|
Shiftend: row.Shiftend,
|
|
})
|
|
}
|
|
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) {
|
|
// posLoginPin, not validatePosPin: the latter also refuses the PINs nobody
|
|
// should be *given*, and applying a creation rule on the way in would lock
|
|
// out every account issued before it existed. Live data has 1234 on eleven
|
|
// accounts and 1111 on nine.
|
|
value, err := posLoginPin(pin)
|
|
if err != nil {
|
|
return nil, errPosLoginRejected
|
|
}
|
|
|
|
var rows []posLoginRow
|
|
err = r.db.Raw(`
|
|
SELECT userid, COALESCE(password,'') AS password, COALESCE(pin,0) AS pin,
|
|
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
|
|
}
|
|
|
|
// normalisePosPhone reduces a mobile number to the ten digits stored on the row.
|
|
//
|
|
// The till signs in with this, so what is stored and what is typed have to
|
|
// agree exactly. A number arrives as "+91 98765 43210", "098765-43210" or
|
|
// "9876543210" depending on who typed it, and matching those as free text means
|
|
// a cashier who is certain of their own number cannot get in.
|
|
//
|
|
// Reduced to digits, then a leading 91 or 0 is dropped once. Anything that is
|
|
// not ten digits afterwards is refused rather than stored — a number that
|
|
// cannot be typed back identically is not a credential.
|
|
func normalisePosPhone(raw string) (string, error) {
|
|
digits := strings.Map(func(r rune) rune {
|
|
if r >= '0' && r <= '9' {
|
|
return r
|
|
}
|
|
return -1
|
|
}, raw)
|
|
|
|
if digits == "" {
|
|
return "", nil
|
|
}
|
|
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("mobile number must be 10 digits; got %q", raw)
|
|
}
|
|
return digits, nil
|
|
}
|
|
|
|
// posPhoneTaken reports whether another till account already signs in with this
|
|
// number.
|
|
//
|
|
// Scoped to the tenant rather than the outlet, unlike the PIN check: a PIN is
|
|
// typed at one counter and only has to be unique there, but a phone number is a
|
|
// login and must resolve to exactly one person across the whole chain. A person
|
|
// working two shops of the same tenant is one account, not two.
|
|
//
|
|
// Only till roles are counted, matching what the login itself looks at — 34
|
|
// numbers are already shared among 104 back-office accounts, and a cashier must
|
|
// not be blocked by a tenant admin who happens to share their number.
|
|
func posPhoneTaken(tx *gorm.DB, tenantID int, phone string, exceptUser int) (bool, error) {
|
|
if phone == "" {
|
|
return false, nil
|
|
}
|
|
var count int64
|
|
err := tx.Raw(`
|
|
SELECT COUNT(1) FROM app_users
|
|
WHERE tenantid = ? AND TRIM(COALESCE(contactno,'')) = ? AND userid <> ?
|
|
AND COALESCE(roleid,0) IN (?, ?)
|
|
AND LOWER(COALESCE(status,'active')) <> 'inactive'`,
|
|
tenantID, phone, exceptUser, models.PosRoleSupervisor, models.PosRoleCashier,
|
|
).Scan(&count).Error
|
|
return count > 0, err
|
|
}
|
|
|
|
// posLoginPin reads a PIN somebody has just typed at a terminal.
|
|
//
|
|
// Format only — four digits the column can hold, and nothing about whether the
|
|
// PIN was a wise one to issue. That distinction is the whole reason this is
|
|
// separate from [validatePosPin]: a rule about what may be *created* must never
|
|
// run on the way *in*. Applied at sign-in, the guessable-PIN list below would
|
|
// permanently lock out the eleven live accounts holding 1234 and the nine
|
|
// holding 1111 — accounts this system itself issued before the rule existed.
|
|
func posLoginPin(raw string) (int64, error) {
|
|
pin := strings.TrimSpace(raw)
|
|
|
|
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")
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// validatePosPin checks a PIN is one this schema can store faithfully, and one
|
|
// worth issuing.
|
|
func validatePosPin(raw string) (int64, error) {
|
|
pin := strings.TrimSpace(raw)
|
|
if pin == "" {
|
|
return 0, nil
|
|
}
|
|
|
|
value, err := posLoginPin(pin)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// The first thing anyone tries, and live data already has 1234 on eleven
|
|
// accounts and 1111 on nine. Refused at creation only — see posLoginPin.
|
|
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
|
|
}
|