Files
backend_fiesta/repositories/userRepository.go
Suriyakumarvijayanayagam 1633617dc4 Stop reporting a failed login lookup as "Invalid Email"
GetUserByAuthname / GetUserByContactNo / GetUserLogin discarded the
Scan error, so a database that could not answer — down, pool exhausted,
or booted without its config (2026-07-20) — came back as uid 0 and every
user was told their email was wrong.

One lookup, GetUserLogin, now returns an error; sql.ErrNoRows is "not
found" and anything else reaches the service, which answers 500 "Login
is temporarily unavailable" and logs the cause. 409 "Invalid Email" is
unchanged for a genuine no-match: the console reads that exact shape as
"not registered". NULL password/role columns scan through sql.Null* so
they do not become 500s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 17:04:33 +05:30

370 lines
14 KiB
Go

package repositories
import (
"database/sql"
"errors"
"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)
UpdateStaff(user models.User) error
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, error)
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) UpdateStaff(user models.User) error {
return r.db.Table("app_users").Where("userid = ?", user.Userid).Updates(&user).Error
}
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
}
// The one tenant-user read. There used to be two.
//
// A `GetTenantUserByID` sat beside this — one capital letter apart, twenty-eight
// columns short, selecting only userid, authname, contactno, tenantid and
// tenantname. `TenantLogin` called that one, so the mobile login it served
// answered with a record whose name, branch, region and coordinates were blank,
// and the route was quietly pointed at a different handler to work around it.
// Deleted rather than documented: two functions this similar, where picking the
// wrong one fails silently, is a trap and not an API.
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,
-- The PERSON's own address, kept distinct from the shop's below.
--
-- Omitted when this query only served the web logins, and it only
-- mattered once it began serving /mob/users/tenant/login: that route
-- used to answer from UserInfo, where these five were populated, so
-- leaving them out turned five live fields into empty strings. The
-- tenant side is aliased (tenantaddress, tenantcity, ...) precisely
-- so both a person and their shop can be returned together.
a.address,a.suburb,a.city,a.state,a.postcode,
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,
a.shiftid, concat(f.starttime, ' - ', f.endtime) as shiftname, a.status
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
LEFT JOIN ridershifts f ON a.shiftid = f.shiftid
WHERE a.userid = ?
`
r.db.Raw(query, userid).Scan(&info)
// Lower-cased, as the other three reads of this table already do.
//
// `app_users.status` is stored inconsistently — "Active" here, "active"
// elsewhere — and every other path through this repository normalises it on
// the way out. This one did not, which only surfaced when it began serving
// /mob/users/tenant/login: that route previously answered "active" and now
// answered "Active", so any client comparing the string exactly would have
// read a live shop as disabled.
info.Status = strings.ToLower(info.Status)
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
}
// GetUserById reads one person back.
//
// The app_location join is LEFT, not INNER, and that is the whole fix. A user is
// not required to belong to an app location, and an INNER JOIN did not "filter"
// those users — it made them unreadable. CreateUser looks the new row up through
// here to return it, so creating a store user with no applocationid answered 201
// with userid 0 and every field blank. The user existed and every listing showed
// it; only the response meant to confirm the creation came back empty, which
// reads as a failure that silently succeeded.
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
LEFT 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
}
// GetUserLogin is the one sign-in lookup, for the app and the console alike.
//
// `field` is the column matched — "authname" or "contactno", nothing else is
// accepted — and it is interpolated, so the whitelist is what keeps this from
// being an injection point.
//
// A till account is not a Nearle Daily user. The two products share this table
// and nothing else, so the lookup itself excludes roles 7 and 8: 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
// every call site and is one edit away from being forgotten at one of them.
//
// Three outcomes, and the caller must tell them apart:
//
// - found: uid > 0, err == nil
// - not found: uid == 0, err == nil
// - failed: err != nil — the database could not answer at all
//
// The third used to be invisible. `Row().Scan`'s error was discarded, so a
// database that was down, a connection pool that was exhausted or a
// misconfigured `configid` all came back as uid 0 — which the service then
// reported as "Invalid Email". On 2026-07-20 the deployment lost its
// ConfigMaps/Secrets and every user on the platform was told their email was
// wrong, and nothing in the logs said otherwise.
func (r *userRepository) GetUserLogin(field, value string, configid int) (int, string, string, int, error) {
switch field {
case "authname", "contactno":
default:
return 0, "", "", 0, fmt.Errorf("login: %q is not a sign-in field", field)
}
var uid int
var password, status sql.NullString
var roleid sql.NullInt64
query := fmt.Sprintf(`
SELECT userid, password, status, roleid
FROM app_users
WHERE %s = ? AND configid = ?
AND COALESCE(roleid, 0) NOT IN (7, 8)`, field)
err := r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid)
if errors.Is(err, sql.ErrNoRows) {
return 0, "", "", 0, nil
}
if err != nil {
return 0, "", "", 0, err
}
// Nullable columns scanned through sql.Null* so that a NULL password or
// role — both exist on real rows — does not itself read as a failed query.
return uid, password.String, status.String, int(roleid.Int64), nil
}
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
}