guide changes

This commit is contained in:
2026-09-01 12:01:43 +05:30
parent ae6162a632
commit 485f31239d
11 changed files with 746 additions and 5 deletions

View File

@@ -22,8 +22,11 @@ type TenantRepository interface {
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)
CreateStaff(user models.User) error
AssignStaffToBranch(tenantID, userID, locationID int) error
UpdateStaff(user models.User) error
CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, error)
UpdateTenantLocation(data models.Tenantlocations) error
@@ -312,6 +315,18 @@ func (r *tenantRepository) CreateLocation(data models.Tenantlocations) error {
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
@@ -323,10 +338,11 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
b.locationname,
COALESCE(c.rolename,'') AS rolename
FROM app_users a
INNER JOIN tenantlocations b ON a.locationid = b.locationid
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)`
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
@@ -390,6 +406,21 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
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{}, 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.
@@ -398,7 +429,42 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
return models.Tenantlocations{}, err
}
// Step 2: Insert into app_users
// 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{}, 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{}, 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{}, err
}
return data, 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
@@ -754,3 +820,104 @@ func (r *tenantRepository) GetTenantByKeyword(keyword string) ([]models.TenantSe
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
}