updates on the ai and agent and all thse things awith onboarding

This commit is contained in:
2026-09-30 14:47:58 +05:30
parent 44ba33eda2
commit 0ac5d3d54f
37 changed files with 7755 additions and 0 deletions

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