package repositories import ( "errors" "fmt" "nearle/models" "strconv" "strings" "github.com/jinzhu/copier" "gorm.io/gorm" ) type TenantRepository interface { SearchTenant(status, searchstr string) ([]models.Tenantinfo, error) GetAllTenants(pageno, pagesize, aid int, status, tenanttype, keyword string) ([]models.Tenantinfo, error) GetTenantLocations(tid int) ([]models.Tenantlocations, error) GetTenantSlot() (models.Tenantslot, error) CreateTenantCustomer(customer models.Tenantcustomer) (*models.Tenantcustomer, error) GetCustomerTenants(customerID int, categoryID int, tenantFlag int) ([]models.TenantInfo, error) GetTenantPricing(tid, aid int) (*models.Tenantpricing, error) UpdateLocation(input models.Tenantlocations) error CreateLocation(data models.Tenantlocations) error DeleteLocation(locationid int, tenantid int) error UpdateTenantProfile(tenantID int, fields map[string]any) error UpdateOwnProfile(userID, tenantID int, fields map[string]any) error GetStaffs(tid int) ([]models.StaffInfo, error) // Returns the new userid: the account has no password and has to be invited. CreateStaff(user models.User) (int, error) AssignStaffToBranch(tenantID, userID, locationID int) error UpdateStaff(user models.User) error // Second return is the userid of the login this spawned for the branch, or 0 // when an existing person was named and no account was created. CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, int, error) UpdateTenantLocation(data models.Tenantlocations) error CheckTenantByNo(cno string) int CreateTenantUser(data models.Tenants) (bool, error) GetUserByNo(cno string) models.UserInfo PrimaryAdminForTenant(tenantID int) (InviteTarget, error) InviteTargetForUser(userID int) (InviteTarget, error) TenantNameByID(tenantID int) (string, error) GetTenantByID(tid int, locationid int, userid int) (models.Tenantinfo, error) AssignPartner(tenantID, partnerID int) error GetTenantByKeyword(keyword string) ([]models.TenantSearch, error) } type tenantRepository struct { db *gorm.DB } func NewTenantRepository(db *gorm.DB) TenantRepository { return &tenantRepository{db: db} } func (r *tenantRepository) SearchTenant(status, keyword string) ([]models.Tenantinfo, error) { var data []models.Tenantinfo var query string searchStr := strings.ToLower(keyword) if strings.ToLower(status) != "pending" { query = ` SELECT a.*, b.subcategoryname, c.firstname, c.lastname, CONCAT(c.firstname, ' ', c.lastname) AS accountname FROM tenants a INNER JOIN app_subcategory b ON a.subcategoryid = b.subcategoryid LEFT JOIN app_users c ON c.userid = a.partneruserid WHERE a.approved = 1 AND LOWER(a.status) = ? AND LOWER(a.tenantname) LIKE ? ` r.db.Raw(query, strings.ToLower(status), searchStr+"%").Scan(&data) } else { query = ` SELECT a.*, b.subcategoryname, c.firstname, c.lastname, CONCAT(c.firstname, ' ', c.lastname) AS accountname FROM tenants a INNER JOIN app_subcategory b ON a.subcategoryid = b.subcategoryid LEFT JOIN app_users c ON c.userid = a.partneruserid WHERE a.approved = 0 AND LOWER(a.tenantname) LIKE ? ` r.db.Raw(query, searchStr+"%").Scan(&data) } return data, nil } func (r *tenantRepository) GetAllTenants(pageno, pagesize, aid int, status, tenanttype, keyword string) ([]models.Tenantinfo, error) { offset := (pageno - 1) * pagesize var data []models.Tenantinfo // `branchcount` is selected here because there is nowhere else to get it. // // This returns one row per TENANT — there is no join to tenantlocations at // all — but the console's store list read it as one row per // tenant-location pair and counted the duplicates, so every merchant on the // platform showed exactly one branch, and the "Branches" and "Avg branches" // tiles above the list were the tenant count wearing another name. The // tenant's own detail page, which reads gettenantlocations, disagreed with // the list it was opened from. // // A correlated subquery rather than a LEFT JOIN + GROUP BY: the row shape // stays exactly as it was, so nothing else that reads this endpoint has to // change, and every filter below still applies to `a` alone. base := `SELECT a.*, (SELECT COUNT(*) FROM tenantlocations tl WHERE tl.tenantid = a.tenantid) AS branchcount FROM tenants a WHERE 1 = 1` var ( conds []string params []interface{} ) switch strings.ToLower(status) { case "active": conds = append(conds, "a.approved = 1 AND a.status = 'Active'") case "inactive": conds = append(conds, "a.approved = 1 AND a.status = 'InActive'") case "pending": conds = append(conds, "a.approved = 0") } if aid != 0 { conds = append(conds, "a.applocationid = ?") params = append(params, aid) } if tenanttype != "" { conds = append(conds, "a.tenanttype = ?") params = append(params, tenanttype) } if keyword != "" { kw := "%" + strings.ToLower(keyword) + "%" conds = append(conds, "(LOWER(a.tenantname) LIKE ? OR LOWER(a.primarycontact) LIKE ?)") params = append(params, kw, kw) } if len(conds) > 0 { base += " AND " + strings.Join(conds, " AND ") } base += " ORDER BY a.tenantid DESC LIMIT ? OFFSET ?" params = append(params, pagesize, offset) err := r.db.Raw(base, params...).Scan(&data).Error if err != nil { return nil, err } return data, nil } func (r *tenantRepository) GetTenantLocations(tid int) ([]models.Tenantlocations, error) { var data []models.Tenantlocations q1 := `SELECT * FROM tenantlocations WHERE tenantid = ?` if err := r.db.Raw(q1, tid).Find(&data).Error; err != nil { return nil, err } for i := range data { data[i].Status = strings.ToLower(data[i].Status) } return data, nil } func (r *tenantRepository) GetTenantSlot() (models.Tenantslot, error) { var data models.Tenantslot err := r.db.Raw(`SELECT * FROM tenantslot`).Find(&data).Error if err != nil { return models.Tenantslot{}, err } return data, nil } func (r *tenantRepository) CreateTenantCustomer(customer models.Tenantcustomer) (*models.Tenantcustomer, error) { var existing models.Tenantcustomer // 🔍 Step 1: Check if a record already exists with same customerid and locationid err := r.db. Where("customerid = ? AND locationid = ?", customer.CustomerID, customer.LocationID). First(&existing).Error // If record found, prevent insertion if err == nil { return nil, fmt.Errorf("customer already exists for this location") } // If error other than record not found, return it if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } // ✅ Step 2: Insert new record if no duplicate found if err := r.db.Create(&customer).Error; err != nil { return nil, err } return &customer, nil } func (r *tenantRepository) GetCustomerTenants(customerID int, categoryID int, tenantFlag int) ([]models.TenantInfo, error) { var tenants []models.TenantInfo query := ` SELECT a.customerid, a.locationid, b.tenantid,b.tenantname,b.address,b.licenseno, b.primaryemail,b.primarycontact,b.applocationid,b.suburb,b.city, b.latitude,b.longitude,b.postcode,b.tenantimage,b.subcategoryid, b.categoryid,b.registrationno,d.userfcmtoken,c.locationname, COALESCE(o.orderscount, 0) AS orderscount FROM tenantcustomers a LEFT JOIN tenants b ON a.tenantid = b.tenantid INNER JOIN tenantlocations c ON a.locationid = c.locationid LEFT JOIN ( SELECT tenantid, customerid, COUNT(*) AS orderscount FROM orders GROUP BY tenantid, customerid ) o ON b.tenantid = o.tenantid AND o.customerid = a.customerid LEFT JOIN ( SELECT locationid, MAX(userfcmtoken) AS userfcmtoken FROM app_users GROUP BY locationid ) d ON d.locationid = a.locationid WHERE a.customerid = ? AND b.tenantid IS NOT NULL ` args := []interface{}{customerID} if categoryID != 0 { query += " AND b.categoryid = ?" args = append(args, categoryID) } if tenantFlag == 1 { query += " AND COALESCE(o.orderscount,0) > 0" } if err := r.db.Raw(query, args...).Scan(&tenants).Error; err != nil { return nil, err } if tenants == nil { return []models.TenantInfo{}, nil } // Attach top 5 subcategories if len(tenants) > 0 { var subcategories []models.ProductSubcategory if err := r.db.Table("productsubcategories"). Order("subcatid ASC"). Limit(5). Find(&subcategories).Error; err != nil { return nil, err } for i := range tenants { tenants[i].ProductSubcategory = subcategories } } print(tenants) return tenants, nil } func (r *tenantRepository) GetTenantPricing(tid, aid int) (*models.Tenantpricing, error) { var data models.Tenantpricing var q1 string if tid != 0 { q1 = `SELECT * FROM tenantpricing WHERE pricingdate = ( SELECT MAX(pricingdate) FROM tenantpricing WHERE tenantid=` + strconv.Itoa(tid) + ` ) AND tenantid=? ORDER BY tenantpricingid DESC` } if err := r.db.Raw(q1, tid).Find(&data).Error; err != nil { return nil, err } return &data, nil } func (r *tenantRepository) UpdateLocation(input models.Tenantlocations) error { tx := r.db.Begin() if err := tx.Where("locationid=?", input.Locationid).Updates(&input).Error; err != nil { tx.Rollback() return err } if err := tx.Commit().Error; err != nil { return err } return nil } func (r *tenantRepository) DeleteLocation(locationid int, tenantid int) error { tx := r.db.Begin() if err := tx.Where("locationid=? AND tenantid=?", locationid, tenantid).Delete(&models.Tenantlocations{}).Error; err != nil { tx.Rollback() return err } if err := tx.Commit().Error; err != nil { return err } return nil } func (r *tenantRepository) CreateLocation(data models.Tenantlocations) error { if err := r.db.Create(&data).Error; err != nil { return err } return nil } // GetStaffs lists a merchant's people, INCLUDING the ones not yet given a // branch. // // The join was INNER, which excluded exactly the state this list exists to // show. A person hired before their outlet opens — or moved off a branch, or // created and not yet placed — has `locationid` 0, matches no `tenantlocations` // row, and vanished from the only screen that could assign them one. They could // sign in (and were met with "No store assigned"), they simply could not be // seen by the person able to fix it. // // LEFT, and unassigned first: they are the ones needing an action, and a list // sorted by branch buries them under everybody already settled. func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) { var data []models.StaffInfo q1 := `SELECT a.userid,a.firstname,a.lastname, CONCAT(a.firstname,' ',a.lastname) AS fullname, a.email,a.contactno,a.address,a.suburb,a.city, a.state,a.postcode,a.userfcmtoken,a.pin,a.applocationid, a.roleid,a.partnerid,a.tenantid,a.locationid, b.locationname, COALESCE(c.rolename,'') AS rolename, -- Whether the account still works. Absent from this SELECT -- until now, so Users & access had nothing to read and showed -- every person on the platform as "Unknown" — an admin could not -- tell a working login from one that had been switched off. COALESCE(a.status,'') AS status, -- Whether they have ever signed in — or can. -- -- Every back-office account is created with an empty password and -- is emailed a link to choose one. Until they use it they are in -- this list, in every branch picker, and cannot sign in at all. -- Without this column the directory cannot tell that person from -- anybody else, so a lost invitation is invisible until they say -- so — and the screen has no way to offer them a new one. -- -- Computed here rather than by returning the password: there is no -- reason for a cleartext password to travel up through a service -- and a controller to answer a yes/no question. (COALESCE(TRIM(a.password), '') <> '') AS issetup FROM app_users a LEFT JOIN tenantlocations b ON a.locationid = b.locationid LEFT JOIN app_roles c ON c.roleid = a.roleid WHERE a.tenantid = ? AND COALESCE(a.roleid, 0) NOT IN (7, 8) ORDER BY a.locationid NULLS FIRST, a.userid DESC` if err := r.db.Raw(q1, tid).Scan(&data).Error; err != nil { return nil, err } return data, nil } // CreateStaff adds a person to a shop from the web console. // // Now subject to the same rules the till applies — see ValidateStaffUser. This // wrote whatever it was handed, so a cashier could be created with a PIN the // schema cannot store, a PIN somebody else already has, or no way to sign in at // all. The failure surfaced at the counter rather than on the screen that // caused it. // // `userid` is deliberately not set: it is a `GENERATED BY DEFAULT AS IDENTITY` // column and Postgres allocates it. Computing one here would leave the sequence // unadvanced and two allocators racing each other. // The userid is returned because the account is created with NO password and the // caller has to invite it. Postgres allocates the id and GORM writes it back // onto `user`, so this costs nothing — and without it the service would have to // look the row up again by authname, which is the one field a concurrent create // could collide on. func (r *tenantRepository) CreateStaff(user models.User) (int, error) { pin, err := ValidateStaffUser(&user) if err != nil { return 0, err } user.Pin = int(pin) if pin > 0 && user.Tenantid > 0 && user.Locationid > 0 { taken, err := posPinTaken(r.db, user.Tenantid, user.Locationid, pin, user.Userid) if err != nil { return 0, err } if taken { return 0, fmt.Errorf("another person at this outlet already uses that PIN") } } if err := r.db.Table("app_users").Create(&user).Error; err != nil { return 0, err } return user.Userid, nil } func (r *tenantRepository) UpdateStaff(user models.User) error { if err := r.db.Table("app_users").Where("userid = ?", user.Userid).Updates(&user).Error; err != nil { return err } return nil } func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, int, error) { var user models.Tenantuser tx := r.db.Begin() // Default to Active if the caller didn't specify — matches // tenantlocations' own gorm default and the primary-location behavior // from tenant onboarding. Forcing InActive here used to also block the // spawned manager login (AppLogin checks account status before it ever // gets to the "no password set" branch), so a new store's login could // never reach the password-setup screen. if data.Status == "" { data.Status = "Active" } // An outlet nobody can sign in to is a dead end, and a silent one — it // appears in every list and every branch picker, and the first person to // notice is whoever is standing in the shop. // // So a branch must arrive with an operator, one way or the other: an // existing person named in Operatorid, or an email to spawn a login from. // Neither used to be checked, and a create with a blank email produced an // account whose authname was the empty string — a row that can never // authenticate. if data.Operatorid <= 0 && strings.TrimSpace(data.Email) == "" { tx.Rollback() return models.Tenantlocations{}, 0, errors.New( "a branch needs somebody to run it: name an existing user in operatorid, or give an email to create a login from") } // Step 1: Insert into tenantlocations. GORM writes the DB-assigned // locationid back onto data, which callers need to build the store's // QR code (payload is just {tenantid, locationid}) right after onboarding. if err := tx.Create(&data).Error; err != nil { tx.Rollback() return models.Tenantlocations{}, 0, err } // Step 2a: bind an existing person, when one was named. // // Scoped to this tenant in the WHERE clause rather than checked first: a // userid belonging to another merchant then matches no row, and the branch // is refused rather than handed to a stranger. Doing it as one guarded // UPDATE also means the check and the write cannot drift apart under a // concurrent reassignment. if data.Operatorid > 0 { res := tx.Table("app_users"). Where("userid = ? AND tenantid = ? AND COALESCE(roleid, 0) NOT IN (7, 8)", data.Operatorid, data.Tenantid). Updates(map[string]any{"locationid": data.Locationid}) if res.Error != nil { tx.Rollback() return models.Tenantlocations{}, 0, res.Error } if res.RowsAffected == 0 { // Either the person does not exist, belongs to another merchant, or // is a till account. All three are the same answer to the caller, // and none of them should leave a branch standing. tx.Rollback() return models.Tenantlocations{}, 0, fmt.Errorf( "user %d cannot run this branch — they belong to another business, do not exist, or are a till account", data.Operatorid) } if err := tx.Commit().Error; err != nil { return models.Tenantlocations{}, 0, err } // No userid: nothing was created. The named person already had an account // before this branch existed, so there is nothing here to invite — if // THEY have never set a password, it is their own creation that owes them // an invitation, not this one. return data, 0, nil } // Step 2b: no person named — spawn a login, as before. // // Kept so every existing caller behaves exactly as it did. The account it // makes is named after the shop and sits on the shop's email, which is why // naming a real person above is the better path where the caller has one. user.Authname = data.Email user.Firstname = data.Locationname user.Email = data.Email user.Contactno = data.Contactno user.Address = data.Address user.Suburb = data.Suburb user.City = data.City user.State = data.State user.Postcode = data.Postcode user.Partnerid = data.Partnerid user.Tenantid = data.Tenantid user.Locationid = data.Locationid user.Applocationid = data.Applocationid user.Configid = 1 user.Status = data.Status user.Roleid = 0 user.Authmode = 0 user.Password = "" user.Dialcode = "+91" if err := tx.Table("app_users").Create(&user).Error; err != nil { tx.Rollback() return models.Tenantlocations{}, 0, err } // Commit if err := tx.Commit().Error; err != nil { return models.Tenantlocations{}, 0, err } // The spawned login's userid, so the service can invite it. This account is // created with `Password = ""` a few lines above, and the invitation is now // the only way to fill that in — the sign-in screen no longer offers a form. // Without this the branch would be commissioned with a login nobody can use. return data, user.Userid, nil } func (r *tenantRepository) UpdateTenantLocation(input models.Tenantlocations) error { tx := r.db.Begin() // ✅ Step 1: Prepare map for tenantlocations update locationUpdate := make(map[string]interface{}) if input.Locationname != "" { locationUpdate["locationname"] = input.Locationname } if input.Email != "" { locationUpdate["email"] = input.Email } if input.Contactno != "" { locationUpdate["contactno"] = input.Contactno } if input.Address != "" { locationUpdate["address"] = input.Address } if input.Suburb != "" { locationUpdate["suburb"] = input.Suburb } if input.City != "" { locationUpdate["city"] = input.City } if input.State != "" { locationUpdate["state"] = input.State } if input.Postcode != "" { locationUpdate["postcode"] = input.Postcode } if input.Partnerid != 0 { locationUpdate["partnerid"] = input.Partnerid } if input.Tenantid != 0 { locationUpdate["tenantid"] = input.Tenantid } if input.Applocationid != 0 { locationUpdate["applocationid"] = input.Applocationid } if input.Status != "" { locationUpdate["status"] = input.Status } // ✅ Step 2: Update tenantlocations (only provided fields) if len(locationUpdate) > 0 { if err := tx.Table("tenantlocations"). Where("locationid = ?", input.Locationid). Updates(locationUpdate).Error; err != nil { tx.Rollback() return err } } // ✅ Step 3: Prepare map for app_users update (only matching fields) userUpdate := make(map[string]interface{}) if input.Locationname != "" { userUpdate["firstname"] = input.Locationname } if input.Email != "" { userUpdate["email"] = input.Email } if input.Contactno != "" { userUpdate["contactno"] = input.Contactno } if input.Address != "" { userUpdate["address"] = input.Address } if input.Suburb != "" { userUpdate["suburb"] = input.Suburb } if input.City != "" { userUpdate["city"] = input.City } if input.State != "" { userUpdate["state"] = input.State } if input.Postcode != "" { userUpdate["postcode"] = input.Postcode } if input.Tenantid != 0 { userUpdate["tenantid"] = input.Tenantid } if input.Partnerid != 0 { userUpdate["partnerid"] = input.Partnerid } if input.Applocationid != 0 { userUpdate["applocationid"] = input.Applocationid } if input.Status != "" { userUpdate["status"] = input.Status } // ✅ Step 4: Update app_users (only provided fields) if len(userUpdate) > 0 { if err := tx.Table("app_users"). Where("locationid = ?", input.Locationid). Updates(userUpdate).Error; err != nil { tx.Rollback() return err } } // ✅ Commit transaction if err := tx.Commit().Error; err != nil { return err } return nil } // ✅ Check if tenant exists func (r *tenantRepository) CheckTenantByNo(cno string) int { var id int q1 := "SELECT tenantid FROM tenants WHERE primarycontact = '" + cno + `'` r.db.Raw(q1).Find(&id) return id } // ✅ Create tenant + user + customer records func (r *tenantRepository) CreateTenantUser(data models.Tenants) (bool, error) { var seq models.Ordersequences var user models.User var cust models.Customers var custloc models.Customerlocations var tcust models.Tenantcustomers // A tenant with configid 0 is unreachable, and it takes its customer row // with it. // // Step 3 below already forces `user.Configid = 1`, with a comment // explaining that AppLogin only ever queries configid 1 and a zero makes // the account permanently unfindable. The same zero was left to flow into // `tenants` itself and into the `customers` row copied from it at step 4, // where nothing corrected it — so a caller that omits configid (the console // sends it; the mobile route and anything else need not) created a business // and a customer that no scoped read can see. // // Defaulted rather than rejected: 1 is the only value any caller has ever // meant here, and refusing the create would break callers that work today. if data.Configid == 0 { data.Configid = 1 } // Give the primary outlet the scaffolding the tenant already has. // // The outlet itself is created by GORM, as the `Tenantlocations` // association on the struct below — the console nests a full object in the // request and step 1 saves it with the tenant. What it does NOT do is fill // anything the caller left out, and two of those columns matter: // // applocationid — `orderRepository.go` calls it "authoritative" and has // no fallback anywhere for a 0. // moduleid — same file: "tenantlocations carries 0 for // moduleid/partnerid at outlets whose live orders // nonetheless use non-zero values", worked around there // by copying scaffolding off the most recent real order. // A shop commissioned a minute ago has no such order. // // Neither column has a database default, and no onboarding form asks for // them — they describe the platform, not the shop. The tenant's own values // are the right answer and are already right here. // // Filled before the insert rather than corrected after it, so there is one // write and no window where the row exists with a zero in it. if data.Tenantlocations.Applocationid == 0 { data.Tenantlocations.Applocationid = data.Applocationid } if data.Tenantlocations.Moduleid == 0 { data.Tenantlocations.Moduleid = data.Moduleid } tx := r.db.Begin() // Step 1: Insert into tenants if err := tx.Create(&data).Error; err != nil { tx.Rollback() return false, errors.New("error in tenant creation") } // Step 2: Create order sequence seq.Tenantid = data.Tenantid if err := tx.Table("ordersequences").Create(&seq).Error; err != nil { tx.Rollback() return false, errors.New("error in sequence") } // Step 3: Create app_user if err := copier.Copy(&user, &data); err != nil { tx.Rollback() return false, err } user.Userfcmtoken = data.Tenanttoken user.Contactno = data.Primarycontact user.Email = data.Primaryemail user.Authname = data.Primaryemail user.Deviceid = data.Deviceid user.Tenantid = data.Tenantid user.Locationid = data.Tenantlocations.Locationid // A merchant's own administrator is an ADMIN (3), not a Super admin (1). // // This wrote 1, and `app_roles` calls 1 "Super admin" — so every shop on the // platform was provisioned with an account that reads as a platform // operator on its own Users & access screen. It never had platform access: // that is `app_users.issuperadmin`, a separate column the console checks // first, and no store account has it set. But a label saying "Super admin" // on a merchant's staff list is a thing somebody will eventually act on. // // 3 also matches the old console's ladder, which this one is meant to // follow: Super Admin = platform, Admin = the merchant group, Manager (4) = // a single store. `rmartuser` and `Kmartuser` — the store users there — are // both roleid 4. // // Existing tenants keep roleid 1 and keep working: `resolveRole` still // treats 1 and 3 alike, deliberately, because changing that would lock out // every merchant provisioned before today. user.Roleid = 3 // The onboarding form never sends a tenant configid, so copier.Copy left // this at zero — AppLogin's GetUserByAuthname always queries configid=1 // for the web login, so a zero here makes the account permanently // unfindable by email no matter what's typed. user.Configid = 1 if err := tx.Table("app_users").Create(&user).Error; err != nil { tx.Rollback() return false, errors.New("error in user creation") } // Step 4: Create / Update customers cust.Configid = data.Configid cust.Firstname = data.Tenantname cust.Email = data.Primaryemail cust.Contactno = data.Primarycontact cust.Deviceid = data.Deviceid cust.Devicetype = data.Devicetype cust.Customertoken = data.Tenanttoken cust.Profileimage = data.Tenantimage cust.Address = data.Address cust.Suburb = data.Suburb cust.City = data.City cust.State = data.State cust.Postcode = data.Postcode cust.Applocationid = data.Applocationid cust.Latitude = models.FlexibleString(data.Latitude) cust.Longitude = models.FlexibleString(data.Longitude) cust.Primaryaddress = 1 cid := r.CheckCustomer(data.Primarycontact) if cid == 0 { if err := tx.Table("customers").Create(&cust).Error; err != nil { tx.Rollback() return false, errors.New("error in customer creation") } if err := copier.Copy(&custloc, &cust); err != nil { tx.Rollback() return false, err } if err := tx.Table("customerlocations").Create(&custloc).Error; err != nil { tx.Rollback() return false, errors.New("error in customer location") } } else { if err := tx.Table("customers").Where("customerid=?", cid).Updates(&cust).Error; err != nil { tx.Rollback() return false, errors.New("error updating customer") } if err := copier.Copy(&custloc, &cust); err != nil { tx.Rollback() return false, err } if err := tx.Table("customerlocations").Where("customerid=?", cid).Updates(&custloc).Error; err != nil { tx.Rollback() return false, errors.New("error updating customer location") } } // Step 5: Create tenant-customer link tcust.Customerid = cust.Customerid tcust.Tenantid = data.Tenantid tcust.Locationid = data.Tenantlocations.Locationid if err := tx.Table("tenantcustomers").Create(&tcust).Error; err != nil { tx.Rollback() return false, errors.New("error in tenant customer") } // ✅ Commit transaction if err := tx.Commit().Error; err != nil { return false, errors.New("error in tenant creation") } return true, nil } // ✅ Check if customer exists func (r *tenantRepository) CheckCustomer(cno string) int { var id int q := "SELECT customerid FROM customers WHERE contactno = '" + cno + `'` r.db.Raw(q).Find(&id) return id } // ✅ Get user by contact number func (r *tenantRepository) GetUserByNo(cno string) models.UserInfo { 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.userfcmtoken,a.pin,a.deviceid,a.devicetype,a.tenantid,a.locationid, b.partnerid,b.moduleid,b.categoryid,b.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 FROM app_users a LEFT JOIN tenants b ON a.tenantid = b.tenantid WHERE a.contactno = '` + cno + `'` r.db.Raw(q1).Find(&user) return user } // GetTenantByID reads one business. // // Both master joins are LEFT, and that is the whole fix. They were INNER — // `app_category` on `a.categoryid` and `app_location` on `a.applocationid` — so // a tenant whose category or city master row is missing did not come back // "without a category name", it did not come back AT ALL. The endpoint answered // 200 with an all-zero record: tenantid 0, every string empty. // // Four of two hundred tenants have `categoryid = 0`, which matches no // `app_category` row, and a newly onboarded shop is the likeliest to be one of // them. The damage was silent and total: the shop-profile screen seeded an // empty form over a business that had an address, and the setup walkthrough // read every field as blank forever — so its first step could never complete // however many times somebody saved it. // // The same INNER-JOIN-as-a-filter mistake has been fixed twice before in this // file's neighbours, in GetStaffs and GetUserById. A master row is context; its // absence must never delete the record it decorates. func (r *tenantRepository) GetTenantByID(tid int, locationid int, userid int) (models.Tenantinfo, error) { var data models.Tenantinfo if locationid == 0 && userid > 0 { var userLocationID int err := r.db.Table("app_users").Select("locationid").Where("userid = ?", userid).Row().Scan(&userLocationID) if err == nil && userLocationID > 0 { locationid = userLocationID } } q1 := ` SELECT a.*,b.categoryname,c.locationname AS applocation, d.allocationid AS allocationmode,e.typename AS allocationtype,e.mapid AS allocationid,f.locationid, f.locationname, f.contactno as locationcontact FROM tenants a LEFT JOIN app_category b ON a.categoryid = b.categoryid LEFT JOIN app_location c ON a.applocationid = c.applocationid LEFT JOIN partnerinfo d ON a.partnerid = d.partnerid LEFT JOIN app_types e ON d.allocationid = e.apptypeid LEFT JOIN tenantlocations f ON a.tenantid = f.tenantid WHERE a.tenantid = ? ` var args []interface{} args = append(args, tid) if locationid != 0 { q1 += " AND f.locationid = ?" args = append(args, locationid) } if err := r.db.Raw(q1, args...).Find(&data).Error; err != nil { return data, err } data.Status = strings.ToLower(data.Status) return data, nil } func (r *tenantRepository) GetTenantByKeyword(keyword string) ([]models.TenantSearch, error) { var data []models.TenantSearch kw := "%" + strings.ToLower(keyword) + "%" query := ` SELECT a.tenantname, b.productname, c.subcatname FROM tenants a LEFT JOIN products b ON a.tenantid = b.tenantid LEFT JOIN productsubcategories c ON b.subcategoryid = c.subcatid WHERE c.categoryid = 2 AND (LOWER(a.tenantname) LIKE ? OR LOWER(b.productname) LIKE ? OR LOWER(c.subcatname) LIKE ?) ` if err := r.db.Raw(query, kw, kw, kw).Scan(&data).Error; err != nil { return nil, err } return data, nil } // AssignStaffToBranch moves one of a merchant's people to a branch, or takes // them off one. // // `locationid` of 0 unassigns — a real state, not a missing value. Somebody // leaves a shop before the next one opens, and the alternative to holding them // unassigned is deleting the account and losing who did what. // // Both the person and the branch are checked against the tenant IN THE QUERY // rather than beforehand. A userid from another merchant then matches no row // and the call fails, instead of one business quietly moving another's staff — // and the check cannot drift from the write under a concurrent edit. // // Till accounts (roleids 7 and 8) are excluded for the same reason GetStaffs // hides them: they are POS people with no back-office screen, and their branch // is managed by the till console, not here. func (r *tenantRepository) AssignStaffToBranch(tenantID, userID, locationID int) error { if locationID > 0 { var owned int64 if err := r.db.Raw( `SELECT COUNT(1) FROM tenantlocations WHERE locationid = ? AND tenantid = ?`, locationID, tenantID).Scan(&owned).Error; err != nil { return err } if owned == 0 { return fmt.Errorf("branch %d does not belong to this business", locationID) } } res := r.db.Table("app_users"). Where("userid = ? AND tenantid = ? AND COALESCE(roleid, 0) NOT IN (7, 8)", userID, tenantID). Updates(map[string]any{"locationid": locationID}) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return fmt.Errorf( "user %d is not one of this business's people", userID) } return nil } // UpdateTenantProfile writes a merchant's own business record. // // The FIRST write path this table has ever had. Every field on `tenants` was // set once at onboarding by a Nearle Admin and could never be changed again, by // anybody — which is why, across 200 merchants, 18 had a shop photograph and // none had a licence number. // // `fields` has already been reduced to the merchant-editable columns by // services.TenantProfileUpdate. This deliberately does not take a struct: GORM // would then decide what to write from which values happen to be non-zero, and // the set of columns a merchant may touch would be implied by a form rather // than stated anywhere. func (r *tenantRepository) UpdateTenantProfile(tenantID int, fields map[string]any) error { if tenantID <= 0 { return errors.New("tenantid is required") } if len(fields) == 0 { return errors.New("nothing to update") } res := r.db.Table("tenants").Where("tenantid = ?", tenantID).Updates(fields) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return fmt.Errorf("no business with tenantid %d", tenantID) } return nil } // UpdateOwnProfile writes the fields a person owns about themselves. // // Scoped by userid AND tenantid together, in the WHERE clause. `UpdateStaff` // checks only the userid, so a request naming somebody else's account is // carried out — which is survivable while the only caller is an admin screen, // and is not once a person can edit their own profile. // // `fields` has already been reduced by services.OwnProfileUpdate to identity // columns. Role, branch, tenant, status, password and PIN are not in it: this // table keeps who-you-are next to what-you-may-do, and only the first half // belongs to the person. func (r *tenantRepository) UpdateOwnProfile(userID, tenantID int, fields map[string]any) error { if userID <= 0 || tenantID <= 0 { return errors.New("userid and tenantid are both required") } if len(fields) == 0 { return errors.New("nothing to update") } res := r.db.Table("app_users"). Where("userid = ? AND tenantid = ?", userID, tenantID). Updates(fields) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return fmt.Errorf("no account %d in this business", userID) } return nil } // AssignPartner sets which delivery partner supplies a merchant's riders. // // A route of its own rather than a field on `updatetenant`, and that is the // whole point. `partnerid` is deliberately absent from `editableTenantFields`: // a merchant who could set it would move themselves under another partner's // billing and pick up their riders. This is the platform's decision, so it gets // the platform's endpoint. // // `partnerid` 0 is meaningful and allowed — it takes the partner away, which a // merchant switching to their own riders needs. So the value is written rather // than skipped when zero, unlike everywhere else in this file. func (r *tenantRepository) AssignPartner(tenantID, partnerID int) error { if tenantID <= 0 { return errors.New("tenantid is required") } // A partner that does not exist would silently orphan every rider lookup // the merchant makes afterwards — `getriders?partnerid=` would answer 200 // with nothing, which reads as "no riders on duty". if partnerID > 0 { var found int64 if err := r.db.Table("partnerinfo").Where("partnerid = ?", partnerID).Count(&found).Error; err != nil { return err } if found == 0 { return fmt.Errorf("no delivery partner with partnerid %d", partnerID) } } res := r.db.Table("tenants").Where("tenantid = ?", tenantID). Updates(map[string]any{"partnerid": partnerID, "updated": gorm.Expr("NOW()")}) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return fmt.Errorf("no business with tenantid %d", tenantID) } return nil } // InviteTarget is the account a tenant's invitation is addressed to. type InviteTarget struct { Userid int Email string Tenantname string // True when the account already has a password, which means the merchant is // set up and there is nothing to invite them to. IsSetUp bool } // PrimaryAdminForTenant finds the account an invitation should go to. // // ── Which of a tenant's users is "the" admin ──────────────────────────────── // // A business can have several roleid-3 accounts — staff added later are the // same role. The one onboarding created is identified by its authname matching // the tenant's own `primaryemail`, which is how `CreateTenantUser` writes it, // and that is the account the invitation belongs to. Picking any roleid-3 row // would email whichever staff member happened to sort first. // // `IsSetUp` is computed in the query rather than by returning the password. // There is no reason for a hash — or on this backend, a cleartext password — to // travel up through a service and a controller to answer a yes/no question. func (r *tenantRepository) PrimaryAdminForTenant(tenantID int) (InviteTarget, error) { if tenantID <= 0 { return InviteTarget{}, errors.New("tenantid is required") } var row InviteTarget query := ` SELECT u.userid AS userid, COALESCE(NULLIF(TRIM(u.email), ''), t.primaryemail) AS email, t.tenantname AS tenantname, (COALESCE(TRIM(u.password), '') <> '') AS issetup FROM tenants t JOIN app_users u ON u.tenantid = t.tenantid AND LOWER(TRIM(u.authname)) = LOWER(TRIM(t.primaryemail)) WHERE t.tenantid = ? LIMIT 1` if err := r.db.Raw(query, tenantID).Scan(&row).Error; err != nil { return InviteTarget{}, err } if row.Userid == 0 { // Either no such tenant, or one whose primary email matches no account. // The second happens when the address was changed on the tenant after // onboarding without the login being changed with it — worth saying, // because the fix is to correct one of the two rather than to resend. return InviteTarget{}, fmt.Errorf( "tenant %d has no account matching its primary email address", tenantID) } return row, nil } // InviteTargetForUser finds one account by its userid. // // The other half of resend. `PrimaryAdminForTenant` answers "the owner of this // business", which is the only account a tenant HAS at onboarding — but staff // added later and the login every branch spawns are created with no password // too, and there is exactly one of the owner, so they cannot be reached that // way. An operator chasing a branch manager who never got their mail needs to // name the person. // // The tenant is joined for its name only, and joined LEFT: a back-office account // with no tenant is a Nearle staff row, and one exists — the platform agent's. // Failing the lookup on that would be refusing to answer a question that has a // perfectly good answer. func (r *tenantRepository) InviteTargetForUser(userID int) (InviteTarget, error) { if userID <= 0 { return InviteTarget{}, errors.New("userid is required") } var row InviteTarget query := ` SELECT u.userid AS userid, COALESCE(NULLIF(TRIM(u.email), ''), TRIM(u.authname)) AS email, COALESCE(t.tenantname, '') AS tenantname, (COALESCE(TRIM(u.password), '') <> '') AS issetup FROM app_users u LEFT JOIN tenants t ON t.tenantid = u.tenantid WHERE u.userid = ? AND COALESCE(u.roleid, 0) NOT IN (7, 8) LIMIT 1` if err := r.db.Raw(query, userID).Scan(&row).Error; err != nil { return InviteTarget{}, err } if row.Userid == 0 { // No such account, or a till one. Roles 7 and 8 are excluded because a // cashier does not sign in to the console at all — they authenticate at // the terminal with a PIN, and an invitation would send them to a screen // that cannot help them. return InviteTarget{}, fmt.Errorf( "user %d is not a back-office account on this platform", userID) } return row, nil } // TenantNameByID is the business's name, for an invitation's first line. // // Its own tiny read rather than a field threaded through the create paths: a // staff account arrives carrying a tenantid and nothing else about the business, // and the alternative was every caller passing a name it would have had to look // up anyway. An empty name is not an error — `inviteMessage` says "your // business" instead, which is worse copy and a working email. func (r *tenantRepository) TenantNameByID(tenantID int) (string, error) { if tenantID <= 0 { return "", nil } var name string err := r.db.Raw(`SELECT COALESCE(tenantname, '') FROM tenants WHERE tenantid = ? LIMIT 1`, tenantID).Scan(&name).Error return name, err }