updates on the onboardings and hubs patches as well

This commit is contained in:
2026-10-06 19:11:18 +05:30
parent 7ff9dad288
commit 0326624ca3
6 changed files with 559 additions and 31 deletions

View File

@@ -37,6 +37,22 @@ func consoleTenantID(c *fiber.Ctx) int {
return tenantID
}
// clientCityID is a client login's operating city: the applocationid of the
// token's appusers row. isClient is false for Doormile staff. A client whose
// user row has no city gets city 0, and callers show them nothing rather than
// everything.
func clientCityID(c *fiber.Ctx) (city int, isClient bool) {
if isDoormileConsoleStaff(c) {
return 0, false
}
uid, _ := c.Locals("userid").(int)
var u models.AppUser
if uid == 0 || db.DB.Select("applocationid").Where("userid = ?", uid).First(&u).Error != nil {
return 0, true
}
return u.Applocationid, true
}
// isDoormileConsoleStaff reports whether the caller sees every tenant's data.
func isDoormileConsoleStaff(c *fiber.Ctx) bool {
return consoleTenantID(c) == 0
@@ -263,6 +279,9 @@ func LoginAdmin(cfg *config.Config) fiber.Handler {
"email": auth.Email,
"role": auth.Role,
"tenantid": auth.Tenantid,
// The login's operating city, so the console can offer a client
// the Doormile hubs of their own city as zones.
"applocationid": appUser.Applocationid,
},
})
}
@@ -1610,6 +1629,16 @@ func GetHubs(c *fiber.Ctx) error {
var hubs []models.Hub
query := db.DB.Where("deletedat IS NULL")
// A client login sees only the hubs of its own city. Every page that
// lists hubs (zones, order form, Fleet Ops) reads this endpoint, and it
// used to hand clients every hub in every city.
if city, isClient := clientCityID(c); isClient {
if city == 0 {
return utils.List(c, []models.Hub{}, 0)
}
query = query.Where("applocationid = ?", city)
}
if appLocationID := c.Query("applocationid"); appLocationID != "" {
query = query.Where("applocationid = ?", appLocationID)
}
@@ -1707,6 +1736,10 @@ func GetHubDetails(c *fiber.Ctx) error {
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", id).First(&hub).Error; err != nil {
return utils.NotFound(c, "hub not found")
}
// Same city rule as GetHubs; another city's hub reads as not found.
if city, isClient := clientCityID(c); isClient && hub.Applocationid != city {
return utils.NotFound(c, "hub not found")
}
// A one-element slice, because attachHubCities writes THROUGH the slice —
// handing it `[]models.Hub{hub}` would fill a copy and return the original
// with City still empty.
@@ -3678,7 +3711,10 @@ func CreateException(c *fiber.Ctx) error {
func GetExceptionDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var exception models.ConsignmentException
if err := db.DB.Where("exceptionid = ? AND deletedat IS NULL", id).First(&exception).Error; err != nil {
// Scoped like GetExceptions: a client could otherwise read any other
// client's exception by guessing its id.
if err := scopeViaConsignments(c, db.DB, "consignmentid").
Where("exceptionid = ? AND deletedat IS NULL", id).First(&exception).Error; err != nil {
return utils.NotFound(c, "exception not found")
}
return utils.OK(c, exception)

View File

@@ -48,6 +48,55 @@ type onboardClientRequest struct {
Password string `json:"password"`
Applocationid int `json:"applocationid"`
Requiredeliveryotp bool `json:"requiredeliveryotp"`
// The client's main address (flat in the JSON). Saved as their primary
// tenantlocations row, which is what a client login's zone list and the
// order form's pickup "Business Hub" read. A client onboarded without one
// had an empty zone list and no pickup point to start from.
clientAddress
}
// clientAddress is one client location as the onboarding form sends it, after
// the operator picked it from the address search (which supplies the map
// coordinates).
type clientAddress struct {
Address string `json:"address"`
City string `json:"city"`
State string `json:"state"`
Pincode string `json:"pincode"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
var indianPincode = regexp.MustCompile(`^[1-9]\d{5}$`)
// validate normalises the address in place and returns an operator-readable
// message for the first problem, or "". Kept apart from the request's own
// validate because an edit may leave the address alone.
func (a *clientAddress) validate() string {
a.Address = strings.Join(strings.Fields(a.Address), " ")
a.City = strings.Join(strings.Fields(a.City), " ")
a.State = strings.Join(strings.Fields(a.State), " ")
a.Pincode = strings.ReplaceAll(strings.TrimSpace(a.Pincode), " ", "")
switch n := utf8.RuneCountInString(a.Address); {
case n < 5:
return "enter the client's address"
case n > 300:
return "address is too long (at most 300 characters)"
}
if !indianPincode.MatchString(a.Pincode) {
return "enter a valid 6-digit pincode"
}
if utf8.RuneCountInString(a.City) > 80 || utf8.RuneCountInString(a.State) > 80 {
return "city or state is too long (at most 80 characters)"
}
// Roughly India's bounding box. Zero (no pick) and swapped lat/lon both
// land outside it, and a location without real coordinates would match no
// zone and give the rider nowhere to go.
if a.Latitude < 6 || a.Latitude > 37.5 || a.Longitude < 68 || a.Longitude > 97.5 {
return "pick the address from the suggestions so it has a map location"
}
return ""
}
// normalisePhone strips spaces, dashes and a +91/91/0 prefix.
@@ -136,6 +185,9 @@ func OnboardClient(c *fiber.Ctx) error {
if msg := req.validate(); msg != "" {
return utils.BadRequest(c, msg)
}
if msg := req.clientAddress.validate(); msg != "" {
return utils.BadRequest(c, msg)
}
hash, err := utils.HashPassword(req.Password)
if err != nil {
@@ -145,6 +197,7 @@ func OnboardClient(c *fiber.Ctx) error {
var tenant models.Tenant
var user models.AppUser
var auth models.DoormileAuth
var location models.TenantLocation
err = db.DB.Transaction(func(tx *gorm.DB) error {
var city models.AppLocation
@@ -178,6 +231,29 @@ func OnboardClient(c *fiber.Ctx) error {
}
tenantID := tenant.Tenantid
// The client's main address, as their primary location, in the same
// transaction: a client is never created without it.
cityName := req.City
if cityName == "" {
cityName = city.Applocationname
}
location = models.TenantLocation{
Tenantid: tenantID,
Locationname: req.Companyname,
Address: req.Address,
City: cityName,
State: req.State,
Pincode: req.Pincode,
Latitude: req.Latitude,
Longitude: req.Longitude,
Isprimary: true,
Status: "Active",
}
if err := tx.Create(&location).Error; err != nil {
return err
}
auth = models.DoormileAuth{Email: req.Email, PasswordHash: hash, Role: clientLoginRole, Tenantid: &tenantID}
if err := tx.Create(&auth).Error; err != nil {
return err
@@ -222,6 +298,13 @@ func OnboardClient(c *fiber.Ctx) error {
"status": tenant.Status,
"requiredeliveryotp": tenant.Requiredeliveryotp,
},
"location": fiber.Map{
"tenantlocationid": location.Tenantlocationid,
"address": location.Address,
"city": location.City,
"state": location.State,
"pincode": location.Pincode,
},
"login": fiber.Map{
"email": auth.Email,
"role": auth.Role,
@@ -244,6 +327,14 @@ type onboardedClient struct {
Loginemail string `json:"loginemail"`
Loginrole string `json:"loginrole"`
Logincreatedat *time.Time `json:"logincreatedat"`
// The client's main address (primary location); empty for a client
// onboarded before addresses were collected.
Address string `json:"address"`
City string `json:"city"`
State string `json:"state"`
Pincode string `json:"pincode"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
// realTime drops the zero/placeholder timestamps some older logins carry (they
@@ -266,9 +357,15 @@ func GetOnboardedClients(c *fiber.Ctx) error {
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`).
a.created_at AS authcreatedat, t.createdat AS tenantcreatedat,
COALESCE(l.address, '') AS address, COALESCE(l.city, '') AS city, COALESCE(l.state, '') AS state,
COALESCE(l.pincode, '') AS pincode, COALESCE(l.latitude, 0) AS latitude, COALESCE(l.longitude, 0) AS longitude`).
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").
Joins(`LEFT JOIN LATERAL (
SELECT address, city, state, pincode, latitude, longitude FROM tenantlocations
WHERE tenantid = t.tenantid AND (status IS NULL OR status = '' OR LOWER(status) = 'active')
ORDER BY isprimary DESC, tenantlocationid LIMIT 1) l ON TRUE`).
Where("a.tenantid IS NOT NULL").
Order("a.id DESC").
Limit(200).
@@ -301,6 +398,12 @@ type onboardedClientRow struct {
Loginrole string `gorm:"column:loginrole"`
Authcreatedat *time.Time `gorm:"column:authcreatedat"`
Tenantcreatedat *time.Time `gorm:"column:tenantcreatedat"`
Address string `gorm:"column:address"`
City string `gorm:"column:city"`
State string `gorm:"column:state"`
Pincode string `gorm:"column:pincode"`
Latitude float64 `gorm:"column:latitude"`
Longitude float64 `gorm:"column:longitude"`
}
func (r onboardedClientRow) toClient() onboardedClient {
@@ -318,6 +421,8 @@ func (r onboardedClientRow) toClient() onboardedClient {
Primaryemail: r.Primaryemail, Primarycontact: r.Primarycontact, Status: r.Status,
Requiredeliveryotp: r.Requiredeliveryotp, Contactname: r.Contactname,
Loginemail: r.Loginemail, Loginrole: r.Loginrole, Logincreatedat: created,
Address: r.Address, City: r.City, State: r.State, Pincode: r.Pincode,
Latitude: r.Latitude, Longitude: r.Longitude,
}
}
@@ -339,6 +444,9 @@ type updateClientRequest struct {
Status *string `json:"status"`
Requiredeliveryotp *bool `json:"requiredeliveryotp"`
Password *string `json:"password"` // optional reset; empty = unchanged
// Location replaces the client's main address (primary location), or
// creates it for a client onboarded before addresses were collected.
Location *clientAddress `json:"location"`
}
var clientStatuses = map[string]string{"active": "Active", "pending": "Pending", "inactive": "Inactive"}
@@ -395,6 +503,11 @@ func UpdateOnboardedClient(c *fiber.Ctx) error {
if msg := check.validate(); msg != "" {
return utils.BadRequest(c, msg)
}
if req.Location != nil {
if msg := req.Location.validate(); msg != "" {
return utils.BadRequest(c, msg)
}
}
status := tenant.Status
if req.Status != nil {
s, ok := clientStatuses[strings.ToLower(strings.TrimSpace(*req.Status))]
@@ -481,6 +594,13 @@ func UpdateOnboardedClient(c *fiber.Ctx) error {
return err
}
}
if req.Location != nil {
name := tenant.Tenantname
if req.Companyname != nil {
name = check.Companyname
}
return saveMainAddress(tx, tenant.Tenantid, name, *req.Location)
}
return nil
})
@@ -496,10 +616,40 @@ func UpdateOnboardedClient(c *fiber.Ctx) error {
}
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))
"password_reset", hash != "", "email_changed", req.Email != nil && check.Email != strings.ToLower(oldEmail),
"address_changed", req.Location != nil)
return utils.OK(c, fiber.Map{"authid": auth.ID, "tenantid": tenant.Tenantid, "status": status, "password_reset": hash != ""})
}
// saveMainAddress updates the client's main address (the primary location,
// else its first active one) or, when it has none, creates it as primary.
// City falls back to the existing one when the form sent none.
func saveMainAddress(tx *gorm.DB, tenantID int, name string, a clientAddress) error {
var loc models.TenantLocation
err := tx.Where("tenantid = ? AND (status IS NULL OR status = '' OR LOWER(status) = 'active')", tenantID).
Order("isprimary DESC, tenantlocationid").First(&loc).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return tx.Create(&models.TenantLocation{
Tenantid: tenantID, Locationname: name, Address: a.Address, City: a.City, State: a.State,
Pincode: a.Pincode, Latitude: a.Latitude, Longitude: a.Longitude, Isprimary: true, Status: "Active",
}).Error
}
if err != nil {
return err
}
updates := map[string]any{
"address": a.Address, "pincode": a.Pincode, "latitude": a.Latitude, "longitude": a.Longitude,
"isprimary": true, "updatedat": gorm.Expr("CURRENT_TIMESTAMP"),
}
if a.City != "" {
updates["city"] = a.City
}
if a.State != "" {
updates["state"] = a.State
}
return tx.Model(&models.TenantLocation{}).Where("tenantlocationid = ?", loc.Tenantlocationid).Updates(updates).Error
}
// DeleteOnboardedClient — DELETE /admin/clients/:id (id = the login's authid).
//
// Removes the CONSOLE LOGIN, not the company's history: the doormile_auth row

View File

@@ -81,6 +81,7 @@ func TestOnboardedClientRowMapsEveryColumn(t *testing.T) {
for _, col := range []string{
"authid", "tenantid", "tenantname", "primaryemail", "primarycontact", "status",
"requiredeliveryotp", "contactname", "loginemail", "loginrole", "authcreatedat", "tenantcreatedat",
"address", "city", "state", "pincode", "latitude", "longitude",
} {
if s.LookUpField(col) == nil {
t.Errorf("column %q selected by the list query maps to no field", col)
@@ -104,3 +105,34 @@ func TestOnboardedClientRowToClient(t *testing.T) {
t.Fatal("no real date must give null, not year 1")
}
}
func TestClientAddressValidate(t *testing.T) {
ok := clientAddress{Address: " 14 DB Road, RS Puram ", City: " Coimbatore ", State: "Tamil Nadu",
Pincode: " 641 002", Latitude: 11.009, Longitude: 76.95}
if msg := ok.validate(); msg != "" {
t.Fatalf("valid address refused: %s", msg)
}
if ok.Address != "14 DB Road, RS Puram" || ok.City != "Coimbatore" || ok.State != "Tamil Nadu" || ok.Pincode != "641002" {
t.Fatalf("not normalised: %+v", ok)
}
cases := []struct {
name string
a clientAddress
want string
}{
{"empty", clientAddress{}, "enter the client's address"},
{"too short", clientAddress{Address: "abc", Pincode: "641002", Latitude: 11, Longitude: 77}, "enter the client's address"},
{"too long", clientAddress{Address: strings.Repeat("a", 301), Pincode: "641002", Latitude: 11, Longitude: 77}, "too long"},
{"pincode 5 digits", clientAddress{Address: "14 DB Road", Pincode: "64100", Latitude: 11, Longitude: 77}, "6-digit pincode"},
{"pincode starts 0", clientAddress{Address: "14 DB Road", Pincode: "041002", Latitude: 11, Longitude: 77}, "6-digit pincode"},
{"no map location", clientAddress{Address: "14 DB Road", Pincode: "641002"}, "pick the address"},
{"swapped lat/lon", clientAddress{Address: "14 DB Road", Pincode: "641002", Latitude: 76.95, Longitude: 11.0}, "pick the address"},
{"outside India", clientAddress{Address: "14 DB Road", Pincode: "641002", Latitude: 51.5, Longitude: -0.1}, "pick the address"},
}
for _, c := range cases {
a := c.a
if msg := a.validate(); !strings.Contains(msg, c.want) {
t.Errorf("%s: %q, want %q", c.name, msg, c.want)
}
}
}