pos login edited with phone number

This commit is contained in:
2026-08-11 11:08:43 +05:30
parent 3531c656d4
commit d35caf34d3
12 changed files with 768 additions and 25 deletions

View File

@@ -97,6 +97,15 @@ func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req mo
return nil, err
}
// The number this person signs in with. Optional at the schema level so a
// shop can still be provisioned before it has collected them, but the
// console asks for it because the till's own sign-in is moving to it —
// an account with no number can only ever log in by username.
phone, err := normalisePosPhone(req.Contactno)
if err != nil {
return nil, err
}
password := strings.TrimSpace(req.Password)
authname := strings.ToLower(strings.TrimSpace(req.Authname))
@@ -154,6 +163,20 @@ func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req mo
}
}
// 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
@@ -208,12 +231,12 @@ func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req mo
if err := tx.Raw(`
INSERT INTO app_users
(firstname, lastname, authname, email, contactno, password,
pin, roleid, configid, tenantid, locationid, status)
pin, shiftid, roleid, configid, tenantid, locationid, status)
VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''),
NULLIF(?, 0), ?, ?, ?, ?, 'Active')
NULLIF(?, 0), NULLIF(?, 0), ?, ?, ?, ?, 'Active')
RETURNING userid`,
first, last, authname, authname, strings.TrimSpace(req.Contactno),
password, pin, roleID, configID, tenantID, locationID,
first, last, authname, authname, phone,
password, pin, req.Shiftid, roleID, configID, tenantID, locationID,
).Scan(&nextID).Error; err != nil {
return err
}
@@ -227,7 +250,8 @@ func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req mo
Firstname: first,
Lastname: last,
Authname: authname,
Contactno: strings.TrimSpace(req.Contactno),
Contactno: phone,
Shiftid: req.Shiftid,
Roleid: roleID,
Role: models.PosRoleName(roleID),
Pin: posPinString(pin),
@@ -301,9 +325,27 @@ func (r *posRepository) UpdatePosUser(tenantID, locationID int, req models.PosUs
args = append(args, authname)
}
if contactno := strings.TrimSpace(req.Contactno); contactno != "" {
// 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, 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 != "" {
@@ -335,6 +377,18 @@ func (r *posRepository) UpdatePosUser(tenantID, locationID int, req models.PosUs
}
}
// 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, ", "))
@@ -377,18 +431,38 @@ func (r *posRepository) ListPosUsers(tenantID, locationID int, includeInactive b
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 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 (?, ?)`
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 {
@@ -415,6 +489,10 @@ func (r *posRepository) ListPosUsers(tenantID, locationID int, includeInactive b
Haspassword: row.Haspassword,
Locationid: locationID,
Status: row.Status,
Shiftid: row.Shiftid,
Shiftname: row.Shiftname,
Shiftstart: row.Shiftstart,
Shiftend: row.Shiftend,
})
}
return users, nil
@@ -499,6 +577,64 @@ func posPinTaken(tx *gorm.DB, tenantID, locationID int, pin int64, exceptUser in
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
}
// validatePosPin checks a PIN is one this schema can store faithfully.
func validatePosPin(raw string) (int64, error) {
pin := strings.TrimSpace(raw)