pos login edited with phone number
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
170
repositories/posShiftRepository.go
Normal file
170
repositories/posShiftRepository.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user