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

@@ -47,7 +47,7 @@ func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession
return nil, fmt.Errorf("an email or mobile number is required")
}
rows, err := r.posLoginCandidates(field, value, req.Configid)
rows, err := r.posLoginCandidates(field, value, req.Configid, true)
if err != nil {
return nil, err
}
@@ -55,6 +55,21 @@ func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession
// One message for "no such account" and for "wrong password", on purpose.
// Distinguishing them turns the login into a directory of who banks here.
if len(rows) == 0 {
// Nothing eligible under that credential. Before answering with the
// deliberately vague rejection, look again without the role filter — a
// back-office account typing its own password at a till deserves to be
// told that is the problem, rather than sent hunting for a password
// that was never wrong.
//
// Only ever reached after that account's own password verifies, so it
// discloses nothing the caller has not just proved. An ambiguous match
// falls through to the vague answer rather than naming anything.
if others, oerr := r.posLoginCandidates(field, value, req.Configid, false); oerr == nil &&
len(others) == 1 &&
strings.TrimSpace(others[0].Password) != "" &&
constantTimeEqual(others[0].Password, req.Password) {
return nil, errPosRoleIneligible
}
return nil, errPosLoginRejected
}
@@ -205,7 +220,7 @@ func posRoleLabel(roleID int) string {
// demanding a number they have never seen would make the login unusable. So it
// is honoured when sent and inferred when not — and inference that finds more
// than one candidate is reported, never guessed.
func (r *posRepository) posLoginCandidates(field, value string, configID int) ([]posLoginRow, error) {
func (r *posRepository) posLoginCandidates(field, value string, configID int, tillOnly bool) ([]posLoginRow, error) {
rows := make([]posLoginRow, 0, 2)
query := fmt.Sprintf(`
@@ -223,6 +238,25 @@ func (r *posRepository) posLoginCandidates(field, value string, configID int) ([
params = append(params, configID)
}
// Only till accounts are candidates.
//
// This matters most for signing in by phone. `contactno` is not unique in
// this schema — 34 numbers are shared by 104 active accounts, one of them
// by eleven — and the caller refuses any lookup returning more than one
// row, because choosing between them could bill into the wrong tenant's
// books. Without this clause a cashier whose number also sits on a tenant
// admin's record simply cannot log in.
//
// Narrowing here means a till phone number only has to be unique among
// till accounts, not across all 608 users. An eligible-but-wrong-role
// account still gets the specific errPosRoleIneligible answer, because the
// caller checks the role again after the password verifies — this clause
// removes ambiguity, it does not replace that check.
if tillOnly {
query += fmt.Sprintf(` AND COALESCE(roleid, 0) IN (%d, %d)`,
models.PosRoleSupervisor, models.PosRoleCashier)
}
// Inactive accounts are excluded from the match rather than matched and
// then refused. A deactivated duplicate would otherwise make a working
// login ambiguous, which turns "this person left" into "nobody can open

View File

@@ -50,6 +50,11 @@ type PosRepository interface {
CreatePosUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error)
UpdatePosUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error)
ListPosUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error)
// Shift windows for till staff.
ListStaffShifts(tenantID, locationID int, includeInactive bool) ([]models.StaffShifts, error)
CreateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error)
UpdateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error)
DeactivatePosUser(tenantID, locationID, userID int) error
PosLoginByPin(tenantID, locationID int, pin string) (*models.PosSession, error)
PosConfigidFor(tenantID int) int

View File

@@ -0,0 +1,170 @@
package repositories
import (
"fmt"
"regexp"
"strings"
"nearle/models"
)
// Shift windows for till staff.
//
// Scoped by tenant *and* outlet in every statement rather than checked first,
// the same shape the till-user queries use: a console naming somebody else's
// shift id updates no rows and is told so, instead of quietly editing another
// shop's hours.
var posTimeOfDay = regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`)
// normaliseShiftTime accepts what a time input actually sends.
//
// `<input type="time">` gives "07:00", some browsers and most hand-typed values
// give "07:00:00", and `ridershifts` already stores the seconds form. Both are
// reduced to `HH:MM` so a shift written by one client reads the same to another.
func normaliseShiftTime(raw string) (string, error) {
t := strings.TrimSpace(raw)
if t == "" {
return "", fmt.Errorf("a start and end time are required")
}
if len(t) == 8 && strings.Count(t, ":") == 2 {
t = t[:5]
}
if !posTimeOfDay.MatchString(t) {
return "", fmt.Errorf("time must be 24-hour HH:MM; got %q", raw)
}
return t, nil
}
// ListStaffShifts returns an outlet's shifts, newest last so a picker reads in
// the order they were created rather than alphabetically by name.
func (r *posRepository) ListStaffShifts(tenantID, locationID int, includeInactive bool) ([]models.StaffShifts, error) {
if tenantID <= 0 || locationID <= 0 {
return nil, fmt.Errorf("tenantid and locationid are required")
}
shifts := make([]models.StaffShifts, 0)
query := `SELECT * FROM staffshifts WHERE tenantid = ? AND locationid = ?`
if !includeInactive {
query += ` AND LOWER(COALESCE(status,'active')) <> 'inactive'`
}
query += ` ORDER BY staffshiftid`
if err := r.db.Raw(query, tenantID, locationID).Scan(&shifts).Error; err != nil {
return nil, err
}
return shifts, nil
}
// CreateStaffShift adds a window at one outlet.
func (r *posRepository) CreateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error) {
if tenantID <= 0 || locationID <= 0 {
return nil, fmt.Errorf("tenantid and locationid are required")
}
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, fmt.Errorf("a shift name is required")
}
start, err := normaliseShiftTime(req.Starttime)
if err != nil {
return nil, err
}
end, err := normaliseShiftTime(req.Endtime)
if err != nil {
return nil, err
}
// An end before a start is allowed on purpose — a night shift runs 22:00 to
// 06:00 and wrapping midnight is ordinary in retail. Only the equal case is
// refused, because a zero-length window cannot be what anyone meant.
if start == end {
return nil, fmt.Errorf("a shift cannot start and end at the same time")
}
weekdays := strings.TrimSpace(req.Weekdays)
if weekdays != "" && !regexp.MustCompile(`^[01]{7}$`).MatchString(weekdays) {
return nil, fmt.Errorf("weekdays must be seven 0/1 characters starting Monday, e.g. 1111100")
}
shift := models.StaffShifts{
Tenantid: tenantID,
Locationid: locationID,
Name: name,
Starttime: start,
Endtime: end,
Weekdays: weekdays,
Status: "Active",
}
if err := r.db.Table("staffshifts").Create(&shift).Error; err != nil {
return nil, err
}
return &shift, nil
}
// UpdateStaffShift edits a window. Every field is optional; only the ones sent
// are written, matching how the till-user update behaves.
func (r *posRepository) UpdateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error) {
if req.Staffshiftid <= 0 {
return nil, fmt.Errorf("staff_shift_id is required")
}
sets := []string{}
args := []interface{}{}
if name := strings.TrimSpace(req.Name); name != "" {
sets = append(sets, "name = ?")
args = append(args, name)
}
if strings.TrimSpace(req.Starttime) != "" {
t, err := normaliseShiftTime(req.Starttime)
if err != nil {
return nil, err
}
sets = append(sets, "starttime = ?")
args = append(args, t)
}
if strings.TrimSpace(req.Endtime) != "" {
t, err := normaliseShiftTime(req.Endtime)
if err != nil {
return nil, err
}
sets = append(sets, "endtime = ?")
args = append(args, t)
}
if w := strings.TrimSpace(req.Weekdays); w != "" {
if !regexp.MustCompile(`^[01]{7}$`).MatchString(w) {
return nil, fmt.Errorf("weekdays must be seven 0/1 characters starting Monday")
}
sets = append(sets, "weekdays = ?")
args = append(args, w)
}
if s := strings.TrimSpace(req.Status); s != "" {
sets = append(sets, "status = ?")
args = append(args, s)
}
if len(sets) == 0 {
return nil, fmt.Errorf("nothing to change")
}
sets = append(sets, "updated = NOW()")
args = append(args, req.Staffshiftid, tenantID, locationID)
res := r.db.Exec(
fmt.Sprintf(`UPDATE staffshifts SET %s WHERE staffshiftid = ? AND tenantid = ? AND locationid = ?`,
strings.Join(sets, ", ")), args...)
if res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return nil, fmt.Errorf("no shift %d at this outlet", req.Staffshiftid)
}
var out models.StaffShifts
if err := r.db.Raw(`SELECT * FROM staffshifts WHERE staffshiftid = ?`, req.Staffshiftid).Scan(&out).Error; err != nil {
return nil, err
}
return &out, nil
}

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)