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>
809 lines
28 KiB
Go
809 lines
28 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 {
|
|
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,
|
|
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
|
|
}
|