A PIN cannot open a closed terminal. The PIN route needs a session that already exists, so a PIN-only account works only while somebody else is standing there to unlock the till first. For a supervisor that was an outright deadlock and was fixed last commit. For a cashier it is subtler and just as wrong: the shop cannot open until two people have arrived, and whoever gets in at seven is as often the cashier as the supervisor. So every till account now gets a username and a password, and the role decides the shell rather than the credential deciding it. A cashier signs in exactly the way a supervisor does and is still held to billing only, because that comes from roleid 8 and not from how they got in. The earlier reasoning — that a second password is one more credential to leak for no capability gained — was measuring the wrong thing. It counted the cost of the credential and not the cost of the shop that cannot open without one. CreatePosUser generates both when the request omits them, so provisioning is one call per person and nobody has to invent a naming scheme. An explicit value always wins. A generated name that collides walks to the next free one, because a second cashier at one counter is ordinary rather than an error; a name the caller supplied is refused instead, because silently signing somebody in as another person's address is worse than a message. Uniqueness is checked against authname and email together, since the insert writes the same value to both and app_users_email_unique would otherwise fail the transaction rather than return something anyone can act on. The password comes back exactly once, in the creation response. Listing till users still reports only has_password, so an admin who loses it reissues rather than looks it up — the right shape even while the column behind it is plaintext. The domain is deliberately unroutable. These are till credentials, never a mailbox, and an address that looks deliverable invites somebody to try sending a reset to it. Verified against live rows by scratch/posseparation, which now checks the cashier path too: cashier.1185@pos.nearle.in opens a closed terminal alone and comes back can_manage_staff=false. All five outlets that stock products have both accounts, each proved by an actual sign-in. Also drops a stray `print(queryBuilder.String())` from GetAllUsers, which was writing the whole SQL statement to stderr on every call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
337 lines
12 KiB
Go
337 lines
12 KiB
Go
package repositories
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"nearle/models"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type UserRepository interface {
|
|
GetAllUsers(roleID, tenantID, pageno, pagesize int, keyword string) ([]models.UserInfo, error)
|
|
GetUserByID(uid int) (models.UserInfo, error)
|
|
Login(user models.User) (models.UserInfo, error)
|
|
FindUserID(authname, contactno string, configid int) (int, error)
|
|
GetTenantUserByID(userid int) (models.TenantUserInfo, error)
|
|
UpdateStaff(user models.User) error
|
|
GetUserByAuthname(authname string, configid int) (int, string, string)
|
|
GetUserByContactNo(contactno string, configid int) (int, string, string)
|
|
UpdateFCMToken(userid int, token string) error
|
|
GetTenantUserById(userid int) models.TenantUserInfo
|
|
CreateUser(user models.User) (int, error)
|
|
GetUserById(uid int) (models.UserInfo, error)
|
|
GetUserLogin(field, value string, configid int) (int, string, string, int)
|
|
UpdateUserFcmToken(uid int, token string) error
|
|
GetLocationStatus(locationid int) string
|
|
DeleteUser(userid int) error
|
|
}
|
|
|
|
type userRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewUserRepository(db *gorm.DB) UserRepository {
|
|
return &userRepository{db: db}
|
|
}
|
|
|
|
func (r *userRepository) GetAllUsers(roleID, tenantID, pageno, pagesize int, keyword string) ([]models.UserInfo, error) {
|
|
var users []models.UserInfo
|
|
var params []interface{}
|
|
var queryBuilder strings.Builder
|
|
|
|
offset := (pageno - 1) * pagesize
|
|
|
|
queryBuilder.WriteString(`SELECT
|
|
a.userid, a.authname, a.email, a.configid, a.roleid, a.authmode, a.contactno,
|
|
a.firstname, a.lastname, CONCAT(a.firstname, ' ', a.lastname) AS fullname,
|
|
a.address, a.suburb, a.city, a.state, a.postcode,
|
|
a.userfcmtoken, a.pin, a.deviceid, a.devicetype, a.tenantid, a.status, a.shiftid,
|
|
a.applocationid, b.locationname AS applocation, b.latitude AS applatitude, concat(c.starttime, ' - ', c.endtime) as shiftname,
|
|
b.longitude AS applongitude, b.radius AS appradius
|
|
FROM app_users a
|
|
LEFT JOIN app_location b ON a.applocationid = b.applocationid
|
|
LEFT JOIN ridershifts c ON a.shiftid = c.shiftid
|
|
WHERE 1=1`)
|
|
|
|
// Till accounts are not Nearle Daily users and must not be listed as though
|
|
// they were. The two products share this table and nothing else: a cashier
|
|
// has no app login, no rider shift and no back-office screen, so a row
|
|
// returned here is one every action on the page would fail against.
|
|
//
|
|
// Asking for 7 or 8 by name still works, so the POS console can read its own
|
|
// people through the same endpoint — this hides them from the general list,
|
|
// it does not make them unreachable.
|
|
if roleID != models.PosRoleSupervisor && roleID != models.PosRoleCashier {
|
|
queryBuilder.WriteString(" AND COALESCE(a.roleid, 0) NOT IN (7, 8)")
|
|
}
|
|
|
|
if roleID != 0 {
|
|
queryBuilder.WriteString(" AND a.roleid = ?")
|
|
params = append(params, roleID)
|
|
}
|
|
|
|
if tenantID != 0 {
|
|
queryBuilder.WriteString(" AND a.tenantid = ?")
|
|
params = append(params, tenantID)
|
|
}
|
|
|
|
if keyword != "" {
|
|
queryBuilder.WriteString(` AND (
|
|
LOWER(a.firstname) LIKE ? OR
|
|
LOWER(a.contactno) LIKE ? OR
|
|
LOWER(a.suburb) LIKE ?
|
|
)`)
|
|
search := "%" + strings.ToLower(keyword) + "%"
|
|
params = append(params, search, search, search)
|
|
}
|
|
|
|
queryBuilder.WriteString(" ORDER BY a.userid DESC LIMIT ? OFFSET ?")
|
|
params = append(params, pagesize, offset)
|
|
|
|
if err := r.db.Raw(queryBuilder.String(), params...).Scan(&users).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for i := range users {
|
|
users[i].Status = strings.ToLower(users[i].Status)
|
|
}
|
|
|
|
return users, nil
|
|
}
|
|
|
|
func (r *userRepository) GetUserByID(uid int) (models.UserInfo, error) {
|
|
var user models.UserInfo
|
|
|
|
q := `SELECT a.userid,a.authname,a.email,a.configid,a.roleid,a.authmode,a.contactno,
|
|
a.firstname,a.lastname,concat(a.firstname,' ',a.lastname) as fullname,a.password,a.address,a.suburb,a.city,a.state,a.postcode,
|
|
a.userfcmtoken,a.pin,a.deviceid,a.devicetype,a.tenantid,a.shiftid,a.locationid,
|
|
a.applocationid,b.locationname as applocation,b.latitude as applatitude,b.longitude as applongitude, b.radius as appradius,
|
|
concat(c.starttime, ' - ', c.endtime) as shiftname, a.status
|
|
FROM app_users a
|
|
INNER JOIN app_location b ON a.applocationid = b.applocationid
|
|
LEFT JOIN ridershifts c ON a.shiftid = c.shiftid
|
|
WHERE a.userid = ?`
|
|
|
|
if err := r.db.Raw(q, uid).Scan(&user).Error; err != nil {
|
|
return models.UserInfo{}, err
|
|
}
|
|
|
|
user.Status = strings.ToLower(user.Status)
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func (r *userRepository) Login(user models.User) (models.UserInfo, error) {
|
|
var uid int
|
|
var userInfo models.UserInfo
|
|
|
|
var q string
|
|
if user.Authname != "" {
|
|
q = `SELECT a.userid FROM app_users a
|
|
WHERE a.authname = ? AND a.configid = ?
|
|
AND COALESCE(a.roleid, 0) NOT IN (7, 8)`
|
|
if err := r.db.Raw(q, user.Authname, user.Configid).Scan(&uid).Error; err != nil {
|
|
return models.UserInfo{}, err
|
|
}
|
|
} else {
|
|
q = `SELECT a.userid FROM app_users a
|
|
WHERE a.contactno = ? AND a.configid = ?
|
|
AND COALESCE(a.roleid, 0) NOT IN (7, 8)`
|
|
if err := r.db.Raw(q, user.Contactno, user.Configid).Scan(&uid).Error; err != nil {
|
|
return models.UserInfo{}, err
|
|
}
|
|
}
|
|
|
|
if uid == 0 {
|
|
return models.UserInfo{}, gorm.ErrRecordNotFound
|
|
}
|
|
|
|
// ✅ Update FCM token in app_users table if provided
|
|
if user.Userfcmtoken != "" {
|
|
if err := r.db.Table("app_users").
|
|
Where("userid = ?", uid).
|
|
Update("userfcmtoken", user.Userfcmtoken).Error; err != nil {
|
|
return models.UserInfo{}, err
|
|
}
|
|
}
|
|
|
|
userInfo, err := r.GetUserByID(uid)
|
|
if err != nil {
|
|
return models.UserInfo{}, err
|
|
}
|
|
|
|
return userInfo, nil
|
|
}
|
|
|
|
|
|
func (r *userRepository) FindUserID(authname, contactno string, configid int) (int, error) {
|
|
var uid int
|
|
var query string
|
|
|
|
if authname != "" {
|
|
query = `SELECT a.userid FROM app_users a
|
|
WHERE a.authname = ? AND a.configid = ?
|
|
AND COALESCE(a.roleid, 0) NOT IN (7, 8)`
|
|
if err := r.db.Raw(query, authname, configid).Scan(&uid).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
} else {
|
|
query = `SELECT a.userid FROM app_users a
|
|
WHERE a.contactno = ? AND a.configid = ?
|
|
AND COALESCE(a.roleid, 0) NOT IN (7, 8)`
|
|
if err := r.db.Raw(query, contactno, configid).Scan(&uid).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
return uid, nil
|
|
}
|
|
|
|
func (r *userRepository) GetTenantUserByID(userid int) (models.TenantUserInfo, error) {
|
|
var info models.TenantUserInfo
|
|
query := `SELECT a.userid, a.authname, a.contactno, a.tenantid, t.tenantname
|
|
FROM app_users a
|
|
LEFT JOIN tenants t ON a.tenantid = t.tenantid
|
|
WHERE a.userid = ?`
|
|
|
|
if err := r.db.Raw(query, userid).Scan(&info).Error; err != nil {
|
|
return info, err
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
func (r *userRepository) UpdateStaff(user models.User) error {
|
|
return r.db.Table("app_users").Where("userid = ?", user.Userid).Updates(&user).Error
|
|
}
|
|
|
|
// A till account is not a Nearle Daily user. The two products share this table
|
|
// and nothing else, so every way into the application excludes roles 7 and 8 in
|
|
// the lookup itself: a cashier is not "refused", they are simply not found.
|
|
//
|
|
// Doing it in the query rather than after it is deliberate. A check bolted on
|
|
// afterwards has to be repeated at each of these call sites and is one edit away
|
|
// from being forgotten at one of them, and that one would be the hole.
|
|
func (r *userRepository) GetUserByAuthname(authname string, configid int) (int, string, string) {
|
|
var uid int
|
|
var password, status string
|
|
query := `SELECT userid, password, status FROM app_users
|
|
WHERE authname = ? AND configid = ?
|
|
AND COALESCE(roleid, 0) NOT IN (7, 8)`
|
|
r.db.Raw(query, authname, configid).Row().Scan(&uid, &password, &status)
|
|
return uid, password, status
|
|
}
|
|
|
|
func (r *userRepository) GetUserByContactNo(contactno string, configid int) (int, string, string) {
|
|
var uid int
|
|
var password, status string
|
|
query := `SELECT userid, password, status FROM app_users
|
|
WHERE contactno = ? AND configid = ?
|
|
AND COALESCE(roleid, 0) NOT IN (7, 8)`
|
|
r.db.Raw(query, contactno, configid).Row().Scan(&uid, &password, &status)
|
|
return uid, password, status
|
|
}
|
|
|
|
func (r *userRepository) UpdateFCMToken(userid int, token string) error {
|
|
query := `UPDATE app_users SET userfcmtoken = ? WHERE userid = ?`
|
|
return r.db.Exec(query, token, userid).Error
|
|
}
|
|
|
|
func (r *userRepository) GetTenantUserById(userid int) models.TenantUserInfo {
|
|
var info models.TenantUserInfo
|
|
|
|
// app_location is LEFT JOINed (not INNER) so a user with no matching
|
|
// tenant/applocation row — e.g. a super admin, tenantid=0 — still comes
|
|
// back with their own fields (issuperadmin included) instead of the
|
|
// whole query silently returning zero rows.
|
|
query := `
|
|
SELECT a.userid,a.authname,a.email,a.configid,a.roleid,a.authmode,a.contactno,
|
|
a.firstname,a.lastname,concat(a.firstname,' ',a.lastname) as fullname,
|
|
a.userfcmtoken,a.pin,a.deviceid,a.devicetype,a.tenantid,a.locationid,a.applocationid,
|
|
a.issuperadmin,
|
|
b.partnerid,b.moduleid,b.categoryid as categoryid,b.subcategoryid as subcategoryid,
|
|
b.applocationid,b.tenantname,b.address as tenantaddress,b.state as tenantstate,b.city as tenantcity,
|
|
b.postcode as tenantpostcode,b.latitude as tenantlat,b.longitude as tenantlong,c.locationname AS applocation,
|
|
c.latitude as applatitude,c.longitude as applongitude,c.radius as appradius, d.categoryname, e.locationname
|
|
from app_users a
|
|
LEFT JOIN tenants b ON a.tenantid=b.tenantid
|
|
LEFT JOIN app_location c on c.applocationid=b.applocationid
|
|
LEFT JOIN app_category d ON b.categoryid=d.categoryid
|
|
LEFT JOIN tenantlocations e ON a.locationid=e.locationid
|
|
WHERE a.userid = ?
|
|
`
|
|
|
|
r.db.Raw(query, userid).Scan(&info)
|
|
print(query)
|
|
return info
|
|
}
|
|
|
|
func (r *userRepository) CreateUser(user models.User) (int, error) {
|
|
tx := r.db.Begin()
|
|
|
|
if err := tx.Table("app_users").Create(&user).Error; err != nil {
|
|
tx.Rollback()
|
|
return 0, err
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return user.Userid, nil
|
|
}
|
|
|
|
func (r *userRepository) GetUserById(uid int) (models.UserInfo, error) {
|
|
var user models.UserInfo
|
|
|
|
q1 := `SELECT a.userid,a.authname,a.email,a.configid,a.roleid,a.authmode,a.contactno,
|
|
a.firstname,a.lastname,concat(a.firstname,' ',a.lastname) as fullname,a.password,a.address,a.suburb,a.city,a.state,a.postcode,
|
|
a.userfcmtoken,a.pin,a.deviceid,a.devicetype,a.tenantid,a.shiftid,
|
|
a.applocationid,b.locationname as applocation,b.latitude as applatitude,b.longitude as applongitude, b.radius as appradius , concat(c.starttime, ' - ', c.endtime) as shiftname, a.status
|
|
FROM app_users a
|
|
INNER JOIN app_location b on a.applocationid=b.applocationid
|
|
LEFT JOIN ridershifts c ON a.shiftid = c.shiftid
|
|
WHERE a.userid= ?`
|
|
|
|
if err := r.db.Raw(q1, uid).Scan(&user).Error; err != nil {
|
|
return models.UserInfo{}, err
|
|
}
|
|
|
|
user.Status = strings.ToLower(user.Status)
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func (r *userRepository) GetUserLogin(field, value string, configid int) (int, string, string, int) {
|
|
var uid, roleid int
|
|
var password, status string
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT userid, password, status, roleid
|
|
FROM app_users
|
|
WHERE %s = ? AND configid = ?
|
|
AND COALESCE(roleid, 0) NOT IN (7, 8)`, field)
|
|
|
|
r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid)
|
|
|
|
return uid, password, status, roleid
|
|
}
|
|
|
|
func (r *userRepository) UpdateUserFcmToken(userid int, fcmToken string) error {
|
|
query := `UPDATE app_users SET userfcmtoken = ? WHERE userid = ?`
|
|
return r.db.Exec(query, fcmToken, userid).Error
|
|
}
|
|
|
|
func (r *userRepository) GetLocationStatus(locationid int) string {
|
|
var status string
|
|
query := `SELECT status FROM tenantlocations WHERE locationid = ?`
|
|
r.db.Raw(query, locationid).Row().Scan(&status)
|
|
return status
|
|
}
|
|
|
|
func (r *userRepository) DeleteUser(userid int) error {
|
|
return r.db.Table("app_users").Where("userid = ?", userid).Delete(&models.User{}).Error
|
|
}
|
|
|
|
|