auto mail generation
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user