updates on the ai and agent and all thse things awith onboarding
This commit is contained in:
566
controllers/clientOnboardingController.go
Normal file
566
controllers/clientOnboardingController.go
Normal file
@@ -0,0 +1,566 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Client onboarding: one call creates everything a new client needs to sign in
|
||||
// to the console, in one transaction —
|
||||
//
|
||||
// tenants the client company (the tenant every booking is scoped to)
|
||||
// doormile_auth the console login LoginAdmin checks: email, bcrypt hash,
|
||||
// role "manager", tenantid = the new tenant
|
||||
// appusers the user row LoginAdmin reads the userid and name from,
|
||||
// roleid 3, tenantid = the new tenant
|
||||
//
|
||||
// A client needs all three: an appusers row alone cannot log in (LoginAdmin
|
||||
// authenticates against doormile_auth), and a doormile_auth row without a
|
||||
// tenantid would be Doormile STAFF — unscoped, seeing every client's data.
|
||||
//
|
||||
// The client login gets role "manager" (roleid 3), not "admin": nothing a
|
||||
// client does needs roleid 1, and roleid 1 is what gates the agent-registry
|
||||
// writes. Their data scope comes from the tenantid in the token.
|
||||
//
|
||||
// Routes sit behind ClientOnboardingOwnerOnly (see routes.go).
|
||||
|
||||
const clientLoginRole = "manager"
|
||||
const clientLoginRoleID = 3
|
||||
|
||||
var indianMobile = regexp.MustCompile(`^[6-9]\d{9}$`)
|
||||
|
||||
type onboardClientRequest struct {
|
||||
Companyname string `json:"companyname"`
|
||||
Contactname string `json:"contactname"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Password string `json:"password"`
|
||||
Applocationid int `json:"applocationid"`
|
||||
Requiredeliveryotp bool `json:"requiredeliveryotp"`
|
||||
}
|
||||
|
||||
// normalisePhone strips spaces, dashes and a +91/91/0 prefix.
|
||||
func normalisePhone(p string) string {
|
||||
p = strings.NewReplacer(" ", "", "-", "", "(", "", ")", "").Replace(strings.TrimSpace(p))
|
||||
p = strings.TrimPrefix(p, "+91")
|
||||
if len(p) == 12 && strings.HasPrefix(p, "91") {
|
||||
p = p[2:]
|
||||
}
|
||||
if len(p) == 11 && strings.HasPrefix(p, "0") {
|
||||
p = p[1:]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// validate normalises the request in place and returns an operator-readable
|
||||
// message for the first problem, or "".
|
||||
func (r *onboardClientRequest) validate() string {
|
||||
r.Companyname = strings.Join(strings.Fields(r.Companyname), " ")
|
||||
r.Contactname = strings.Join(strings.Fields(r.Contactname), " ")
|
||||
r.Email = strings.ToLower(strings.TrimSpace(r.Email))
|
||||
r.Phone = normalisePhone(r.Phone)
|
||||
|
||||
switch n := utf8.RuneCountInString(r.Companyname); {
|
||||
case n < 2:
|
||||
return "company name is required"
|
||||
case n > 120:
|
||||
return "company name is too long (at most 120 characters)"
|
||||
}
|
||||
if utf8.RuneCountInString(r.Contactname) < 2 || utf8.RuneCountInString(r.Contactname) > 80 {
|
||||
return "contact person's name is required (at most 80 characters)"
|
||||
}
|
||||
if addr, err := mail.ParseAddress(r.Email); err != nil || addr.Address != r.Email || !strings.Contains(r.Email[strings.LastIndex(r.Email, "@"):], ".") {
|
||||
return "enter a valid email address"
|
||||
}
|
||||
if !indianMobile.MatchString(r.Phone) {
|
||||
return "enter a valid 10-digit mobile number"
|
||||
}
|
||||
switch n := utf8.RuneCountInString(r.Password); {
|
||||
case n < 8:
|
||||
return "password must be at least 8 characters"
|
||||
case n > 72: // bcrypt ignores everything past 72 bytes
|
||||
return "password is too long (at most 72 characters)"
|
||||
}
|
||||
if strings.EqualFold(r.Password, r.Email) || strings.EqualFold(r.Password, r.Phone) {
|
||||
return "password must not be the email or the phone number"
|
||||
}
|
||||
if r.Applocationid <= 0 {
|
||||
return "choose the client's operating city"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// errOnboardingConflict carries a 409 message out of the transaction.
|
||||
type errOnboardingConflict struct{ msg string }
|
||||
|
||||
func (e errOnboardingConflict) Error() string { return e.msg }
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "23505") || strings.Contains(strings.ToLower(s), "duplicate key")
|
||||
}
|
||||
|
||||
// onboardingOwnerStillValid re-reads the caller's doormile_auth row: still an
|
||||
// admin, still Doormile staff. The middleware checked the token; this checks
|
||||
// the account behind it has not been removed or demoted since it was issued.
|
||||
func onboardingOwnerStillValid(email string) bool {
|
||||
var n int64
|
||||
db.DB.Model(&models.DoormileAuth{}).
|
||||
Where("LOWER(email) = ? AND role = ? AND tenantid IS NULL", strings.ToLower(email), "admin").
|
||||
Count(&n)
|
||||
return n == 1
|
||||
}
|
||||
|
||||
// OnboardClient — POST /admin/clients/onboard
|
||||
func OnboardClient(c *fiber.Ctx) error {
|
||||
actor := actorOf(c)
|
||||
if !onboardingOwnerStillValid(actor.Email) {
|
||||
return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account")
|
||||
}
|
||||
|
||||
req := new(onboardClientRequest)
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
if msg := req.validate(); msg != "" {
|
||||
return utils.BadRequest(c, msg)
|
||||
}
|
||||
|
||||
hash, err := utils.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
return utils.Internal(c, "failed to process the password")
|
||||
}
|
||||
|
||||
var tenant models.Tenant
|
||||
var user models.AppUser
|
||||
var auth models.DoormileAuth
|
||||
|
||||
err = db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var city models.AppLocation
|
||||
if err := tx.Where("applocationid = ?", req.Applocationid).First(&city).Error; err != nil {
|
||||
return errOnboardingConflict{"that operating city does not exist"}
|
||||
}
|
||||
|
||||
var n int64
|
||||
tx.Model(&models.Tenant{}).Where("LOWER(tenantname) = LOWER(?)", req.Companyname).Count(&n)
|
||||
if n > 0 {
|
||||
return errOnboardingConflict{"a client with this company name already exists"}
|
||||
}
|
||||
tx.Model(&models.DoormileAuth{}).Where("LOWER(email) = ?", req.Email).Count(&n)
|
||||
if n > 0 {
|
||||
return errOnboardingConflict{"this email already has a console login"}
|
||||
}
|
||||
tx.Model(&models.AppUser{}).Where("LOWER(email) = ?", req.Email).Count(&n)
|
||||
if n > 0 {
|
||||
return errOnboardingConflict{"this email is already used by another user"}
|
||||
}
|
||||
|
||||
tenant = models.Tenant{
|
||||
Tenantname: req.Companyname,
|
||||
Primaryemail: req.Email,
|
||||
Primarycontact: req.Phone,
|
||||
Status: "Active",
|
||||
Requiredeliveryotp: req.Requiredeliveryotp,
|
||||
}
|
||||
if err := tx.Create(&tenant).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tenantID := tenant.Tenantid
|
||||
auth = models.DoormileAuth{Email: req.Email, PasswordHash: hash, Role: clientLoginRole, Tenantid: &tenantID}
|
||||
if err := tx.Create(&auth).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user = models.AppUser{
|
||||
Authname: req.Contactname,
|
||||
Email: req.Email,
|
||||
Contactno: req.Phone,
|
||||
Password: hash,
|
||||
Roleid: clientLoginRoleID,
|
||||
Tenantid: tenantID,
|
||||
Applocationid: req.Applocationid,
|
||||
Status: "Active",
|
||||
}
|
||||
return tx.Create(&user).Error
|
||||
})
|
||||
|
||||
var conflict errOnboardingConflict
|
||||
switch {
|
||||
case errors.As(err, &conflict):
|
||||
if conflict.msg == "that operating city does not exist" {
|
||||
return utils.BadRequest(c, conflict.msg)
|
||||
}
|
||||
return utils.Conflict(c, conflict.msg)
|
||||
case err != nil && isUniqueViolation(err):
|
||||
// Lost a race with a concurrent onboarding of the same email.
|
||||
return utils.Conflict(c, "this email already has a console login")
|
||||
case err != nil:
|
||||
utils.Error("client onboarding failed", "error", err.Error(), "by", actor.Email)
|
||||
return utils.Internal(c, "failed to onboard the client; nothing was created")
|
||||
}
|
||||
|
||||
utils.Info("client onboarded", "by", actor.Email, "tenantid", tenant.Tenantid, "login", auth.Email, "userid", user.Userid)
|
||||
|
||||
return utils.Created(c, fiber.Map{
|
||||
"tenant": fiber.Map{
|
||||
"tenantid": tenant.Tenantid,
|
||||
"tenantname": tenant.Tenantname,
|
||||
"primaryemail": tenant.Primaryemail,
|
||||
"primarycontact": tenant.Primarycontact,
|
||||
"status": tenant.Status,
|
||||
"requiredeliveryotp": tenant.Requiredeliveryotp,
|
||||
},
|
||||
"login": fiber.Map{
|
||||
"email": auth.Email,
|
||||
"role": auth.Role,
|
||||
"userid": user.Userid,
|
||||
"name": user.Authname,
|
||||
"tenantid": tenant.Tenantid,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type onboardedClient struct {
|
||||
Authid uint64 `json:"authid"`
|
||||
Tenantid int `json:"tenantid"`
|
||||
Tenantname string `json:"tenantname"`
|
||||
Primaryemail string `json:"primaryemail"`
|
||||
Primarycontact string `json:"primarycontact"`
|
||||
Status string `json:"status"`
|
||||
Requiredeliveryotp bool `json:"requiredeliveryotp"`
|
||||
Contactname string `json:"contactname"`
|
||||
Loginemail string `json:"loginemail"`
|
||||
Loginrole string `json:"loginrole"`
|
||||
Logincreatedat *time.Time `json:"logincreatedat"`
|
||||
}
|
||||
|
||||
// realTime drops the zero/placeholder timestamps some older logins carry (they
|
||||
// render as "1 Jan 0001"), so the console shows "—" instead of a fake date.
|
||||
func realTime(t *time.Time) *time.Time {
|
||||
if t == nil || t.Year() < 2000 {
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// GetOnboardedClients — GET /admin/clients/onboarded: the clients that have a
|
||||
// console login, newest first. One row per login. Never returns a password hash.
|
||||
func GetOnboardedClients(c *fiber.Ctx) error {
|
||||
if !onboardingOwnerStillValid(actorOf(c).Email) {
|
||||
return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account")
|
||||
}
|
||||
var rows []onboardedClientRow
|
||||
err := db.DB.Table("doormile_auth AS a").
|
||||
Select(`a.id AS authid, t.tenantid, t.tenantname, t.primaryemail, t.primarycontact, t.status,
|
||||
t.requiredeliveryotp, COALESCE(u.authname, '') AS contactname,
|
||||
a.email AS loginemail, a.role AS loginrole,
|
||||
a.created_at AS authcreatedat, t.createdat AS tenantcreatedat`).
|
||||
Joins("JOIN tenants t ON t.tenantid = a.tenantid").
|
||||
Joins("LEFT JOIN appusers u ON LOWER(u.email) = LOWER(a.email) AND u.tenantid = a.tenantid").
|
||||
Where("a.tenantid IS NOT NULL").
|
||||
Order("a.id DESC").
|
||||
Limit(200).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
utils.Error("list onboarded clients", "error", err.Error())
|
||||
return utils.Internal(c, "failed to list clients")
|
||||
}
|
||||
out := make([]onboardedClient, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.toClient())
|
||||
}
|
||||
return utils.List(c, out, int64(len(out)))
|
||||
}
|
||||
|
||||
// onboardedClientRow is what the list query scans into. It is deliberately
|
||||
// FLAT with every column named: GORM silently skips an embedded struct of an
|
||||
// unexported type, which once left every field but the dates empty (and every
|
||||
// authid 0). TestOnboardedClientRowMapsEveryColumn guards this.
|
||||
type onboardedClientRow struct {
|
||||
Authid uint64 `gorm:"column:authid"`
|
||||
Tenantid int `gorm:"column:tenantid"`
|
||||
Tenantname string `gorm:"column:tenantname"`
|
||||
Primaryemail string `gorm:"column:primaryemail"`
|
||||
Primarycontact string `gorm:"column:primarycontact"`
|
||||
Status string `gorm:"column:status"`
|
||||
Requiredeliveryotp bool `gorm:"column:requiredeliveryotp"`
|
||||
Contactname string `gorm:"column:contactname"`
|
||||
Loginemail string `gorm:"column:loginemail"`
|
||||
Loginrole string `gorm:"column:loginrole"`
|
||||
Authcreatedat *time.Time `gorm:"column:authcreatedat"`
|
||||
Tenantcreatedat *time.Time `gorm:"column:tenantcreatedat"`
|
||||
}
|
||||
|
||||
func (r onboardedClientRow) toClient() onboardedClient {
|
||||
created := realTime(r.Authcreatedat) // timestamptz: already the right instant
|
||||
if created == nil {
|
||||
// tenants.createdat is a legacy timestamp WITHOUT zone holding IST
|
||||
// digits; read as UTC it shows 5h30m late. utils.IST puts it right.
|
||||
if t := realTime(r.Tenantcreatedat); t != nil {
|
||||
ist := utils.IST(*t)
|
||||
created = &ist
|
||||
}
|
||||
}
|
||||
return onboardedClient{
|
||||
Authid: r.Authid, Tenantid: r.Tenantid, Tenantname: r.Tenantname,
|
||||
Primaryemail: r.Primaryemail, Primarycontact: r.Primarycontact, Status: r.Status,
|
||||
Requiredeliveryotp: r.Requiredeliveryotp, Contactname: r.Contactname,
|
||||
Loginemail: r.Loginemail, Loginrole: r.Loginrole, Logincreatedat: created,
|
||||
}
|
||||
}
|
||||
|
||||
// loadClientLogin finds a CLIENT login by doormile_auth id. A Doormile staff
|
||||
// login (tenantid NULL) is reported as not found: these routes never touch one.
|
||||
func loadClientLogin(authID string) (*models.DoormileAuth, error) {
|
||||
var auth models.DoormileAuth
|
||||
if err := db.DB.Where("id = ? AND tenantid IS NOT NULL", authID).First(&auth).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &auth, nil
|
||||
}
|
||||
|
||||
type updateClientRequest struct {
|
||||
Companyname *string `json:"companyname"`
|
||||
Contactname *string `json:"contactname"`
|
||||
Email *string `json:"email"`
|
||||
Phone *string `json:"phone"`
|
||||
Status *string `json:"status"`
|
||||
Requiredeliveryotp *bool `json:"requiredeliveryotp"`
|
||||
Password *string `json:"password"` // optional reset; empty = unchanged
|
||||
}
|
||||
|
||||
var clientStatuses = map[string]string{"active": "Active", "pending": "Pending", "inactive": "Inactive"}
|
||||
|
||||
// UpdateOnboardedClient — PUT /admin/clients/:id (id = the login's authid).
|
||||
// Edits the client company (tenants) and that login (doormile_auth + appusers)
|
||||
// in one transaction. Only fields sent are changed.
|
||||
func UpdateOnboardedClient(c *fiber.Ctx) error {
|
||||
actor := actorOf(c)
|
||||
if !onboardingOwnerStillValid(actor.Email) {
|
||||
return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account")
|
||||
}
|
||||
auth, err := loadClientLogin(c.Params("id"))
|
||||
if err != nil {
|
||||
return utils.NotFound(c, "client login not found")
|
||||
}
|
||||
req := new(updateClientRequest)
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
|
||||
// Validate by reusing the onboarding rules on a filled-in copy.
|
||||
var tenant models.Tenant
|
||||
if err := db.DB.First(&tenant, *auth.Tenantid).Error; err != nil {
|
||||
return utils.NotFound(c, "client not found")
|
||||
}
|
||||
check := onboardClientRequest{
|
||||
Companyname: tenant.Tenantname, Contactname: "xx", Email: auth.Email,
|
||||
Phone: tenant.Primarycontact, Password: "unchanged-ok", Applocationid: 1,
|
||||
}
|
||||
if req.Companyname != nil {
|
||||
check.Companyname = *req.Companyname
|
||||
}
|
||||
if req.Contactname != nil {
|
||||
check.Contactname = *req.Contactname
|
||||
}
|
||||
if req.Email != nil {
|
||||
check.Email = *req.Email
|
||||
} else {
|
||||
check.Email = "unchanged@doormile.example" // as with the phone: only a changed email is validated
|
||||
}
|
||||
if req.Phone != nil {
|
||||
check.Phone = *req.Phone
|
||||
} else {
|
||||
// An older client may carry a phone that fails today's rule; only a
|
||||
// phone the caller is actually changing is validated.
|
||||
check.Phone = "9000000000"
|
||||
}
|
||||
newPassword := ""
|
||||
if req.Password != nil && *req.Password != "" {
|
||||
newPassword = *req.Password
|
||||
check.Password = newPassword
|
||||
}
|
||||
if msg := check.validate(); msg != "" {
|
||||
return utils.BadRequest(c, msg)
|
||||
}
|
||||
status := tenant.Status
|
||||
if req.Status != nil {
|
||||
s, ok := clientStatuses[strings.ToLower(strings.TrimSpace(*req.Status))]
|
||||
if !ok {
|
||||
return utils.BadRequest(c, "status must be Active, Pending or Inactive")
|
||||
}
|
||||
status = s
|
||||
}
|
||||
|
||||
var hash string
|
||||
if newPassword != "" {
|
||||
if hash, err = utils.HashPassword(newPassword); err != nil {
|
||||
return utils.Internal(c, "failed to process the password")
|
||||
}
|
||||
}
|
||||
|
||||
oldEmail := auth.Email
|
||||
err = db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var n int64
|
||||
if req.Companyname != nil && !strings.EqualFold(check.Companyname, tenant.Tenantname) {
|
||||
tx.Model(&models.Tenant{}).Where("LOWER(tenantname) = LOWER(?) AND tenantid <> ?", check.Companyname, tenant.Tenantid).Count(&n)
|
||||
if n > 0 {
|
||||
return errOnboardingConflict{"a client with this company name already exists"}
|
||||
}
|
||||
}
|
||||
emailChanged := req.Email != nil && check.Email != strings.ToLower(oldEmail)
|
||||
if emailChanged {
|
||||
tx.Model(&models.DoormileAuth{}).Where("LOWER(email) = ? AND id <> ?", check.Email, auth.ID).Count(&n)
|
||||
if n > 0 {
|
||||
return errOnboardingConflict{"this email already has a console login"}
|
||||
}
|
||||
tx.Model(&models.AppUser{}).Where("LOWER(email) = ? AND LOWER(email) <> LOWER(?)", check.Email, oldEmail).Count(&n)
|
||||
if n > 0 {
|
||||
return errOnboardingConflict{"this email is already used by another user"}
|
||||
}
|
||||
}
|
||||
|
||||
tenantUpdates := map[string]any{"status": status, "updatedat": gorm.Expr("CURRENT_TIMESTAMP")}
|
||||
if req.Companyname != nil {
|
||||
tenantUpdates["tenantname"] = check.Companyname
|
||||
}
|
||||
if req.Phone != nil {
|
||||
tenantUpdates["primarycontact"] = check.Phone
|
||||
}
|
||||
if emailChanged && strings.EqualFold(tenant.Primaryemail, oldEmail) {
|
||||
tenantUpdates["primaryemail"] = check.Email
|
||||
}
|
||||
if req.Requiredeliveryotp != nil {
|
||||
tenantUpdates["requiredeliveryotp"] = *req.Requiredeliveryotp
|
||||
}
|
||||
if err := tx.Model(&models.Tenant{}).Where("tenantid = ?", tenant.Tenantid).Updates(tenantUpdates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authUpdates := map[string]any{"updated_at": time.Now()}
|
||||
if emailChanged {
|
||||
authUpdates["email"] = check.Email
|
||||
}
|
||||
if hash != "" {
|
||||
authUpdates["password_hash"] = hash
|
||||
}
|
||||
if err := tx.Model(&models.DoormileAuth{}).Where("id = ?", auth.ID).Updates(authUpdates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userUpdates := map[string]any{}
|
||||
if emailChanged {
|
||||
userUpdates["email"] = check.Email
|
||||
}
|
||||
if req.Contactname != nil {
|
||||
userUpdates["authname"] = check.Contactname
|
||||
}
|
||||
if req.Phone != nil {
|
||||
userUpdates["contactno"] = check.Phone
|
||||
}
|
||||
if hash != "" {
|
||||
userUpdates["password"] = hash
|
||||
}
|
||||
if len(userUpdates) > 0 {
|
||||
userUpdates["updatedat"] = gorm.Expr("CURRENT_TIMESTAMP")
|
||||
if err := tx.Model(&models.AppUser{}).
|
||||
Where("LOWER(email) = LOWER(?) AND tenantid = ?", oldEmail, tenant.Tenantid).
|
||||
Updates(userUpdates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
var conflict errOnboardingConflict
|
||||
switch {
|
||||
case errors.As(err, &conflict):
|
||||
return utils.Conflict(c, conflict.msg)
|
||||
case err != nil && isUniqueViolation(err):
|
||||
return utils.Conflict(c, "this email already has a console login")
|
||||
case err != nil:
|
||||
utils.Error("client update failed", "error", err.Error(), "by", actor.Email)
|
||||
return utils.Internal(c, "failed to update the client; nothing was changed")
|
||||
}
|
||||
|
||||
utils.Info("client updated", "by", actor.Email, "tenantid", tenant.Tenantid, "authid", auth.ID,
|
||||
"password_reset", hash != "", "email_changed", req.Email != nil && check.Email != strings.ToLower(oldEmail))
|
||||
return utils.OK(c, fiber.Map{"authid": auth.ID, "tenantid": tenant.Tenantid, "status": status, "password_reset": hash != ""})
|
||||
}
|
||||
|
||||
// DeleteOnboardedClient — DELETE /admin/clients/:id (id = the login's authid).
|
||||
//
|
||||
// Removes the CONSOLE LOGIN, not the company's history: the doormile_auth row
|
||||
// and the matching appusers row are deleted, so the client can no longer sign
|
||||
// in, and the client is marked Inactive when this was its last login. The
|
||||
// tenants row and every booking, consignment and price attached to it stay —
|
||||
// deleting them would break past orders and reports.
|
||||
//
|
||||
// A token already issued keeps working until it expires (JWTs are stateless);
|
||||
// the login cannot be used to sign in again.
|
||||
func DeleteOnboardedClient(c *fiber.Ctx) error {
|
||||
actor := actorOf(c)
|
||||
if !onboardingOwnerStillValid(actor.Email) {
|
||||
return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account")
|
||||
}
|
||||
auth, err := loadClientLogin(c.Params("id"))
|
||||
if err != nil {
|
||||
return utils.NotFound(c, "client login not found")
|
||||
}
|
||||
tenantID := *auth.Tenantid
|
||||
deactivated := false
|
||||
|
||||
err = db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("id = ?", auth.ID).Delete(&models.DoormileAuth{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("LOWER(email) = LOWER(?) AND tenantid = ?", auth.Email, tenantID).
|
||||
Delete(&models.AppUser{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var remaining int64
|
||||
tx.Model(&models.DoormileAuth{}).Where("tenantid = ?", tenantID).Count(&remaining)
|
||||
if remaining == 0 {
|
||||
deactivated = true
|
||||
return tx.Model(&models.Tenant{}).Where("tenantid = ?", tenantID).
|
||||
Updates(map[string]any{"status": "Inactive", "updatedat": gorm.Expr("CURRENT_TIMESTAMP")}).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
utils.Error("client login delete failed", "error", err.Error(), "by", actor.Email)
|
||||
return utils.Internal(c, "failed to remove the client login; nothing was changed")
|
||||
}
|
||||
|
||||
utils.Info("client login removed", "by", actor.Email, "tenantid", tenantID, "login", auth.Email, "client_deactivated", deactivated)
|
||||
return utils.OK(c, fiber.Map{"authid": auth.ID, "tenantid": tenantID, "login_removed": true, "client_deactivated": deactivated})
|
||||
}
|
||||
|
||||
// GetOnboardingCities — GET /admin/clients/cities: the operating cities a new
|
||||
// client can be placed in, straight from applocations (the table OnboardClient
|
||||
// validates against). The console's usual city picker derives cities from
|
||||
// hubs, which would hide a city that has no hub yet.
|
||||
func GetOnboardingCities(c *fiber.Ctx) error {
|
||||
var cities []models.AppLocation
|
||||
if err := db.DB.Where("status IS NULL OR status = '' OR LOWER(status) = 'active'").
|
||||
Order("applocationid").Find(&cities).Error; err != nil {
|
||||
utils.Error("list onboarding cities", "error", err.Error())
|
||||
return utils.Internal(c, "failed to list cities")
|
||||
}
|
||||
if cities == nil {
|
||||
cities = []models.AppLocation{}
|
||||
}
|
||||
return utils.List(c, cities, int64(len(cities)))
|
||||
}
|
||||
Reference in New Issue
Block a user