Files
backend_fiesta/services/userService.go
2026-09-29 16:53:21 +05:30

451 lines
15 KiB
Go

package services
import (
"errors"
"fmt"
"log"
"nearle/models"
"nearle/repositories"
"strings"
"github.com/gofiber/fiber"
)
// errLoginUnavailable is returned by lookupLogin when the database could not
// answer the sign-in query. It is deliberately not "invalid user": the account
// may well exist, and the person needs to be told to try again, not to check
// their spelling.
var errLoginUnavailable = errors.New("login lookup failed")
// loginUnavailableResponse is the body for that case. 500 rather than 409,
// because the console reads `409` as "this email does not exist" (see
// daily_merchant_web/src/services/auth.ts) and would otherwise send a
// perfectly good account to the sign-up form while the database was down.
func loginUnavailableResponse() map[string]interface{} {
return map[string]interface{}{
"status": false,
"code": 500,
"message": "Login is temporarily unavailable. Please try again in a moment.",
}
}
// lookupLogin resolves who is signing in, by authname first and contact number
// second, and separates "not found" from "could not look".
//
// Both login paths used to run their own copy of this and both discarded the
// repository's error, so a database that was down came back as uid 0 and was
// reported as "Invalid Email". That message now means exactly one thing: the
// query ran and matched nobody.
func (s *userService) lookupLogin(user models.User) (uid int, password, status string, roleid int, err error) {
field, value := "authname", user.Authname
if user.Authname == "" {
field, value = "contactno", user.Contactno
}
uid, password, status, roleid, err = s.repo.GetUserLogin(field, value, user.Configid)
if err != nil {
// The value is what the caller typed — an email or a phone number —
// and is safe to log; the password never reaches this function's
// output.
log.Printf("login: lookup by %s=%q configid=%d failed: %v", field, value, user.Configid, err)
return 0, "", "", 0, errLoginUnavailable
}
return uid, password, status, roleid, nil
}
type UserService 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)
TenantLogin(user models.User) (models.TenantUserInfo, error)
UpdateStaff(user models.User) error
// SetInitialPassword is the one write reachable without a session — see
// the repository for what makes that safe.
SetInitialPassword(userid int, password string) error
AppLogin(user models.User) (models.TenantUserInfo, fiber.Map, error)
// Creates a back-office account and emails its first-password invitation.
//
// The outcome travels beside the user rather than as an error: the person is
// hired either way, and whether the mail left is something the console shows
// so somebody can resend it.
CreateUser(user models.User) (models.UserInfo, InviteOutcome, error)
TenantWebLogin(user models.User) (models.TenantUserInfo, map[string]interface{})
DeleteUser(userid int) error
}
type userService struct {
repo repositories.UserRepository
// May be nil, like the tenant service's. A deployment with no mail still
// creates accounts; the outcome names the missing variable.
invites InviteService
}
func NewUserService(repo repositories.UserRepository, invites InviteService) UserService {
return &userService{repo: repo, invites: invites}
}
func (s *userService) GetAllUsers(roleID, tenantID, pageno, pagesize int, keyword string) ([]models.UserInfo, error) {
return s.repo.GetAllUsers(roleID, tenantID, pageno, pagesize, keyword)
}
func (s *userService) GetUserByID(uid int) (models.UserInfo, error) {
return s.repo.GetUserByID(uid)
}
func (s *userService) Login(user models.User) (models.UserInfo, error) {
return s.repo.Login(user)
}
func (s *userService) TenantLogin(user models.User) (models.TenantUserInfo, error) {
uid, err := s.repo.FindUserID(user.Authname, user.Contactno, user.Configid)
if err != nil {
return models.TenantUserInfo{}, err
}
if uid == 0 {
return models.TenantUserInfo{}, errors.New("user not found")
}
// `GetTenantUserById`, NOT `GetTenantUserByID`.
//
// The two differ by one letter and by twenty-eight columns. The capital-ID
// one selects five — userid, authname, contactno, tenantid, tenantname —
// so this function used to answer with a record whose name, branch, region
// and coordinates were all blank. That is why routing
// `/mob/users/tenant/login` at it was never as simple as swapping the
// handler: the app would have received a mostly-empty object.
//
// The lowercase-d one is the query `AppLogin` and `TenantWebLogin` already
// use, and it fills the whole record. It is now the only one anything calls.
//
// The FCM token is stored here rather than left to the repository, because
// the full read does not write. Only a real token is stored: a login that
// omits it must not wipe the device already registered, which is exactly
// what "userfcmtoken": "your_fcm_token_here" did to a live account during
// this investigation.
if strings.TrimSpace(user.Userfcmtoken) != "" {
_ = s.repo.UpdateUserFcmToken(uid, user.Userfcmtoken)
}
return s.repo.GetTenantUserById(uid), nil
}
func (s *userService) UpdateStaff(user models.User) error {
return s.repo.UpdateStaff(user)
}
func (s *userService) AppLogin(user models.User) (models.TenantUserInfo, fiber.Map, error) {
if user.Authname == "" && user.Contactno == "" {
resp := fiber.Map{
"code": 400,
"status": false,
"message": "authname or contactno required",
}
return models.TenantUserInfo{}, resp, errors.New("missing authname or contactno")
}
uid, dbPassword, status, _, err := s.lookupLogin(user)
if err != nil {
return models.TenantUserInfo{}, fiber.Map(loginUnavailableResponse()), err
}
// Nobody matched. This is the only way to reach "Invalid Email" now.
if uid == 0 {
resp := fiber.Map{
"status": false,
"code": 409,
"message": "Invalid Email",
"tenantform": true,
}
return models.TenantUserInfo{}, resp, errors.New("invalid user")
}
// Inactive account
if strings.EqualFold(status, "InActive") {
resp := fiber.Map{
"status": false,
"code": 403,
"message": "Inactive Account. Contact admin.",
}
return models.TenantUserInfo{}, resp, errors.New("inactive account")
}
// No password set.
//
// ── The userid used to be in here, and that was the whole exploit ───────
//
// This branch is reached by a POST carrying an email and NO password, so
// anyone could ask it about any account. It answered with the userid, and
// `setpassword` then took a bare userid — so the recipe was: read a
// merchant's primary email off their shopfront, POST it here, receive their
// userid, set their password, own the business's admin account. No guessing
// at any step.
//
// `setpassword` now requires a signed invitation, so the userid alone is no
// longer a way in. It is still removed, because handing it out told an
// unauthenticated caller which businesses exist and which have never been
// set up — a list worth having if you are the one sending the phishing
// email that arrives before the real invitation does.
//
// The message is kept deliberately vague for the same reason. "Please set
// up a password" invited the caller to do exactly that; this says where the
// link comes from instead, which is true for the person who belongs here
// and useless to anyone else.
if strings.TrimSpace(dbPassword) == "" {
resp := fiber.Map{
"status": true,
"code": 409,
"message": "This account has not been set up yet. Use the invitation link that was emailed to you.",
"tenantform": true,
"details": fiber.Map{
"setup": true,
},
}
return models.TenantUserInfo{}, resp, nil
}
// Empty request password
if strings.TrimSpace(user.Password) == "" {
resp := fiber.Map{
"status": true,
"code": 401,
"message": "Password is required",
"tenantform": true,
}
return models.TenantUserInfo{}, resp, nil
}
// Incorrect password
if user.Password != dbPassword {
resp := fiber.Map{
"status": false,
"code": 401,
"message": "Incorrect password",
"tenantform": true,
}
return models.TenantUserInfo{}, resp, errors.New("incorrect password")
}
// Update FCM token
if user.Userfcmtoken != "" {
_ = s.repo.UpdateFCMToken(uid, user.Userfcmtoken)
}
// ✅ Fetch tenant user info
info := s.repo.GetTenantUserById(uid)
// ✅ Check if assigned store is inactive
if info.Locationid > 0 {
storeStatus := s.repo.GetLocationStatus(info.Locationid)
if strings.EqualFold(storeStatus, "InActive") {
resp := fiber.Map{
"status": false,
"code": 403,
"message": "Assigned store is inactive. Contact admin.",
}
return models.TenantUserInfo{}, resp, errors.New("inactive store")
}
}
// ✅ Return success response
resp := fiber.Map{
"status": true,
"code": 200,
"message": "Login successful",
"details": info,
}
return info, resp, nil
}
func (s *userService) CreateUser(user models.User) (models.UserInfo, InviteOutcome, error) {
// Without an authname and a console configid the account is created,
// listed, and then refused at the login screen: `weblogin` matches
// `WHERE authname = ? AND configid = ?` and never looks at the email
// column. See PrepareNewAccount.
user = PrepareNewAccount(user)
// Call repository to create user
userid, err := s.repo.CreateUser(user)
if err != nil {
return models.UserInfo{}, InviteOutcome{}, err
}
// Get user info by id
info, err := s.repo.GetUserById(userid)
if err != nil {
return models.UserInfo{}, InviteOutcome{}, err
}
// The invitation, after the write and outside it.
//
// This account is created with NO password — nothing on this path sets one —
// and since the sign-in screen stopped offering to set a first password, the
// emailed link is the only way in. Without this the person is added to the
// directory, appears in every branch picker, and cannot sign in, with nothing
// anywhere to say why.
//
// `info.Userid` rather than `userid`: identical, but this is the row that was
// actually read back, so an invitation is never addressed to an id the
// database did not confirm.
return info, s.inviteNewAccount(info, user), nil
}
// inviteNewAccount emails the person who was just hired.
//
// Never an error. A failure is the operator's task — resend, or fix the address —
// and not a reason to unwind a hire that has already happened.
func (s *userService) inviteNewAccount(info models.UserInfo, user models.User) InviteOutcome {
if s.invites == nil {
return InviteOutcome{Reason: "invitations are not configured on this server"}
}
if info.Userid <= 0 {
return InviteOutcome{Reason: "the new account could not be read back to invite it"}
}
// The authname IS the email on a back-office account — `PrepareNewAccount`
// copies one to the other — so this is a fallback for a caller that filled in
// only one of the two, never a second address.
address := strings.TrimSpace(user.Email)
if address == "" {
address = strings.TrimSpace(user.Authname)
}
// Empty business name: the invite service reads it from the tenantid. A staff
// row carries the id and nothing else about the business.
sent, reason := s.invites.Invite(info.Userid, user.Tenantid, address, "")
return InviteOutcome{Sent: sent, Reason: reason}
}
func (s *userService) TenantWebLogin(user models.User) (models.TenantUserInfo, map[string]interface{}) {
tenantFormExists := true
// Step 1: Login by authname or contactno
if user.Authname == "" && user.Contactno == "" {
return models.TenantUserInfo{}, map[string]interface{}{
"status": true,
"code": 400,
"message": "authname or contactno required",
}
}
uid, dbPassword, status, roleid, err := s.lookupLogin(user)
if err != nil {
return models.TenantUserInfo{}, loginUnavailableResponse()
}
// Step 2: Validate user. Nobody matched — the only way to reach
// "Invalid Email" now; a database that could not answer is a 500 above.
if uid == 0 {
return models.TenantUserInfo{}, map[string]interface{}{
"status": false,
"code": 409,
"message": "Invalid Email",
"tenantform": tenantFormExists,
}
}
if strings.EqualFold(status, "InActive") {
return models.TenantUserInfo{}, map[string]interface{}{
"status": false,
"code": 403,
"message": "Inactive Account. Contact admin.",
}
}
if user.Roleid != roleid {
return models.TenantUserInfo{}, map[string]interface{}{
"status": false,
"code": 403,
"message": "Unauthorized email.",
}
}
// Step 3: Password checks.
//
// The userid is withheld here for the same reason as in `AppLogin` above:
// this branch answers an unauthenticated caller asking about an email, and
// the userid was half of an account takeover. See the long note there.
if strings.TrimSpace(dbPassword) == "" {
return models.TenantUserInfo{}, map[string]interface{}{
"status": true,
"code": 409,
"message": "This account has not been set up yet. Use the invitation link that was emailed to you.",
"tenantform": tenantFormExists,
"details": map[string]interface{}{
"setup": true,
},
}
}
if strings.TrimSpace(user.Password) == "" {
return models.TenantUserInfo{}, map[string]interface{}{
"status": true,
"code": 401,
"message": "Password is required",
"tenantform": tenantFormExists,
}
}
if user.Password != dbPassword {
return models.TenantUserInfo{}, map[string]interface{}{
"status": false,
"code": 401,
"message": "Incorrect password",
"tenantform": tenantFormExists,
}
}
// Step 4: Update FCM if provided
if user.Userfcmtoken != "" {
_ = s.repo.UpdateUserFcmToken(uid, user.Userfcmtoken)
}
// Step 5: Get full tenant info
info := s.repo.GetTenantUserById(uid)
// Step 6: Check if assigned store is inactive
if info.Locationid > 0 {
storeStatus := s.repo.GetLocationStatus(info.Locationid)
if strings.EqualFold(storeStatus, "InActive") {
return models.TenantUserInfo{}, map[string]interface{}{
"status": false,
"code": 403,
"message": "Assigned store is inactive. Contact admin.",
}
}
}
return info, map[string]interface{}{
"status": true,
"code": 200,
"message": "Login successful",
}
}
func (s *userService) DeleteUser(userid int) error {
return s.repo.DeleteUser(userid)
}
// SetInitialPassword gives a never-used account its first password.
//
// The minimum length is enforced here as well as at the edge: this is the only
// write in the product reachable without a session, so the rule cannot live
// only in a handler that a second caller might not go through.
func (s *userService) SetInitialPassword(userid int, password string) error {
if userid <= 0 {
return errors.New("which account?")
}
password = strings.TrimSpace(password)
if len(password) < MinPasswordLength {
return fmt.Errorf("the password must be at least %d characters", MinPasswordLength)
}
return s.repo.SetInitialPassword(userid, password)
}
// MinPasswordLength is the floor for a console password.
//
// Six, matching the check `UpdateStaff` already applied at the controller — not
// a new rule, the same one stated where both callers can reach it.
const MinPasswordLength = 6