auto mail generation

This commit is contained in:
2026-09-29 16:53:21 +05:30
parent b18080d429
commit b46902f51b
27 changed files with 2765 additions and 80 deletions

View File

@@ -25,14 +25,20 @@ type TenantRepository interface {
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
// 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
CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, 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)
@@ -357,7 +363,20 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
-- 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
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
@@ -383,27 +402,32 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
// `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.
func (r *tenantRepository) CreateStaff(user models.User) error {
// 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 err
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 err
return 0, err
}
if taken {
return fmt.Errorf("another person at this outlet already uses that PIN")
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 err
return 0, err
}
return nil
return user.Userid, nil
}
func (r *tenantRepository) UpdateStaff(user models.User) error {
@@ -413,7 +437,7 @@ func (r *tenantRepository) UpdateStaff(user models.User) error {
return nil
}
func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, error) {
func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, int, error) {
var user models.Tenantuser
tx := r.db.Begin()
@@ -438,7 +462,7 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
// authenticate.
if data.Operatorid <= 0 && strings.TrimSpace(data.Email) == "" {
tx.Rollback()
return models.Tenantlocations{}, errors.New(
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")
}
@@ -447,7 +471,7 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
// QR code (payload is just {tenantid, locationid}) right after onboarding.
if err := tx.Create(&data).Error; err != nil {
tx.Rollback()
return models.Tenantlocations{}, err
return models.Tenantlocations{}, 0, err
}
// Step 2a: bind an existing person, when one was named.
@@ -464,21 +488,25 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
Updates(map[string]any{"locationid": data.Locationid})
if res.Error != nil {
tx.Rollback()
return models.Tenantlocations{}, res.Error
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{}, fmt.Errorf(
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{}, err
return models.Tenantlocations{}, 0, err
}
return data, nil
// 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.
@@ -508,15 +536,19 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
if err := tx.Table("app_users").Create(&user).Error; err != nil {
tx.Rollback()
return models.Tenantlocations{}, err
return models.Tenantlocations{}, 0, err
}
// Commit
if err := tx.Commit().Error; err != nil {
return models.Tenantlocations{}, err
return models.Tenantlocations{}, 0, err
}
return data, nil
// 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 {
@@ -1062,3 +1094,119 @@ func (r *tenantRepository) AssignPartner(tenantID, partnerID int) error {
}
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
}