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))) }