updates on the onboardings and hubs patches as well
This commit is contained in:
@@ -37,6 +37,22 @@ func consoleTenantID(c *fiber.Ctx) int {
|
|||||||
return tenantID
|
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.
|
// isDoormileConsoleStaff reports whether the caller sees every tenant's data.
|
||||||
func isDoormileConsoleStaff(c *fiber.Ctx) bool {
|
func isDoormileConsoleStaff(c *fiber.Ctx) bool {
|
||||||
return consoleTenantID(c) == 0
|
return consoleTenantID(c) == 0
|
||||||
@@ -263,6 +279,9 @@ func LoginAdmin(cfg *config.Config) fiber.Handler {
|
|||||||
"email": auth.Email,
|
"email": auth.Email,
|
||||||
"role": auth.Role,
|
"role": auth.Role,
|
||||||
"tenantid": auth.Tenantid,
|
"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
|
var hubs []models.Hub
|
||||||
query := db.DB.Where("deletedat IS NULL")
|
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 != "" {
|
if appLocationID := c.Query("applocationid"); appLocationID != "" {
|
||||||
query = query.Where("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 {
|
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", id).First(&hub).Error; err != nil {
|
||||||
return utils.NotFound(c, "hub not found")
|
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 —
|
// A one-element slice, because attachHubCities writes THROUGH the slice —
|
||||||
// handing it `[]models.Hub{hub}` would fill a copy and return the original
|
// handing it `[]models.Hub{hub}` would fill a copy and return the original
|
||||||
// with City still empty.
|
// with City still empty.
|
||||||
@@ -3678,7 +3711,10 @@ func CreateException(c *fiber.Ctx) error {
|
|||||||
func GetExceptionDetails(c *fiber.Ctx) error {
|
func GetExceptionDetails(c *fiber.Ctx) error {
|
||||||
id, _ := strconv.Atoi(c.Params("id"))
|
id, _ := strconv.Atoi(c.Params("id"))
|
||||||
var exception models.ConsignmentException
|
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.NotFound(c, "exception not found")
|
||||||
}
|
}
|
||||||
return utils.OK(c, exception)
|
return utils.OK(c, exception)
|
||||||
|
|||||||
@@ -48,6 +48,55 @@ type onboardClientRequest struct {
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Applocationid int `json:"applocationid"`
|
Applocationid int `json:"applocationid"`
|
||||||
Requiredeliveryotp bool `json:"requiredeliveryotp"`
|
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.
|
// 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 != "" {
|
if msg := req.validate(); msg != "" {
|
||||||
return utils.BadRequest(c, msg)
|
return utils.BadRequest(c, msg)
|
||||||
}
|
}
|
||||||
|
if msg := req.clientAddress.validate(); msg != "" {
|
||||||
|
return utils.BadRequest(c, msg)
|
||||||
|
}
|
||||||
|
|
||||||
hash, err := utils.HashPassword(req.Password)
|
hash, err := utils.HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -145,6 +197,7 @@ func OnboardClient(c *fiber.Ctx) error {
|
|||||||
var tenant models.Tenant
|
var tenant models.Tenant
|
||||||
var user models.AppUser
|
var user models.AppUser
|
||||||
var auth models.DoormileAuth
|
var auth models.DoormileAuth
|
||||||
|
var location models.TenantLocation
|
||||||
|
|
||||||
err = db.DB.Transaction(func(tx *gorm.DB) error {
|
err = db.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
var city models.AppLocation
|
var city models.AppLocation
|
||||||
@@ -178,6 +231,29 @@ func OnboardClient(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tenantID := tenant.Tenantid
|
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}
|
auth = models.DoormileAuth{Email: req.Email, PasswordHash: hash, Role: clientLoginRole, Tenantid: &tenantID}
|
||||||
if err := tx.Create(&auth).Error; err != nil {
|
if err := tx.Create(&auth).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -222,6 +298,13 @@ func OnboardClient(c *fiber.Ctx) error {
|
|||||||
"status": tenant.Status,
|
"status": tenant.Status,
|
||||||
"requiredeliveryotp": tenant.Requiredeliveryotp,
|
"requiredeliveryotp": tenant.Requiredeliveryotp,
|
||||||
},
|
},
|
||||||
|
"location": fiber.Map{
|
||||||
|
"tenantlocationid": location.Tenantlocationid,
|
||||||
|
"address": location.Address,
|
||||||
|
"city": location.City,
|
||||||
|
"state": location.State,
|
||||||
|
"pincode": location.Pincode,
|
||||||
|
},
|
||||||
"login": fiber.Map{
|
"login": fiber.Map{
|
||||||
"email": auth.Email,
|
"email": auth.Email,
|
||||||
"role": auth.Role,
|
"role": auth.Role,
|
||||||
@@ -244,6 +327,14 @@ type onboardedClient struct {
|
|||||||
Loginemail string `json:"loginemail"`
|
Loginemail string `json:"loginemail"`
|
||||||
Loginrole string `json:"loginrole"`
|
Loginrole string `json:"loginrole"`
|
||||||
Logincreatedat *time.Time `json:"logincreatedat"`
|
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
|
// 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,
|
Select(`a.id AS authid, t.tenantid, t.tenantname, t.primaryemail, t.primarycontact, t.status,
|
||||||
t.requiredeliveryotp, COALESCE(u.authname, '') AS contactname,
|
t.requiredeliveryotp, COALESCE(u.authname, '') AS contactname,
|
||||||
a.email AS loginemail, a.role AS loginrole,
|
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("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 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").
|
Where("a.tenantid IS NOT NULL").
|
||||||
Order("a.id DESC").
|
Order("a.id DESC").
|
||||||
Limit(200).
|
Limit(200).
|
||||||
@@ -301,6 +398,12 @@ type onboardedClientRow struct {
|
|||||||
Loginrole string `gorm:"column:loginrole"`
|
Loginrole string `gorm:"column:loginrole"`
|
||||||
Authcreatedat *time.Time `gorm:"column:authcreatedat"`
|
Authcreatedat *time.Time `gorm:"column:authcreatedat"`
|
||||||
Tenantcreatedat *time.Time `gorm:"column:tenantcreatedat"`
|
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 {
|
func (r onboardedClientRow) toClient() onboardedClient {
|
||||||
@@ -318,6 +421,8 @@ func (r onboardedClientRow) toClient() onboardedClient {
|
|||||||
Primaryemail: r.Primaryemail, Primarycontact: r.Primarycontact, Status: r.Status,
|
Primaryemail: r.Primaryemail, Primarycontact: r.Primarycontact, Status: r.Status,
|
||||||
Requiredeliveryotp: r.Requiredeliveryotp, Contactname: r.Contactname,
|
Requiredeliveryotp: r.Requiredeliveryotp, Contactname: r.Contactname,
|
||||||
Loginemail: r.Loginemail, Loginrole: r.Loginrole, Logincreatedat: created,
|
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"`
|
Status *string `json:"status"`
|
||||||
Requiredeliveryotp *bool `json:"requiredeliveryotp"`
|
Requiredeliveryotp *bool `json:"requiredeliveryotp"`
|
||||||
Password *string `json:"password"` // optional reset; empty = unchanged
|
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"}
|
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 != "" {
|
if msg := check.validate(); msg != "" {
|
||||||
return utils.BadRequest(c, msg)
|
return utils.BadRequest(c, msg)
|
||||||
}
|
}
|
||||||
|
if req.Location != nil {
|
||||||
|
if msg := req.Location.validate(); msg != "" {
|
||||||
|
return utils.BadRequest(c, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
status := tenant.Status
|
status := tenant.Status
|
||||||
if req.Status != nil {
|
if req.Status != nil {
|
||||||
s, ok := clientStatuses[strings.ToLower(strings.TrimSpace(*req.Status))]
|
s, ok := clientStatuses[strings.ToLower(strings.TrimSpace(*req.Status))]
|
||||||
@@ -481,6 +594,13 @@ func UpdateOnboardedClient(c *fiber.Ctx) error {
|
|||||||
return err
|
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
|
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,
|
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 != ""})
|
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).
|
// DeleteOnboardedClient — DELETE /admin/clients/:id (id = the login's authid).
|
||||||
//
|
//
|
||||||
// Removes the CONSOLE LOGIN, not the company's history: the doormile_auth row
|
// Removes the CONSOLE LOGIN, not the company's history: the doormile_auth row
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ func TestOnboardedClientRowMapsEveryColumn(t *testing.T) {
|
|||||||
for _, col := range []string{
|
for _, col := range []string{
|
||||||
"authid", "tenantid", "tenantname", "primaryemail", "primarycontact", "status",
|
"authid", "tenantid", "tenantname", "primaryemail", "primarycontact", "status",
|
||||||
"requiredeliveryotp", "contactname", "loginemail", "loginrole", "authcreatedat", "tenantcreatedat",
|
"requiredeliveryotp", "contactname", "loginemail", "loginrole", "authcreatedat", "tenantcreatedat",
|
||||||
|
"address", "city", "state", "pincode", "latitude", "longitude",
|
||||||
} {
|
} {
|
||||||
if s.LookUpField(col) == nil {
|
if s.LookUpField(col) == nil {
|
||||||
t.Errorf("column %q selected by the list query maps to no field", col)
|
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")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -321,10 +321,10 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
adminAuth.Put("/profile/password", controllers.AdminChangePassword)
|
adminAuth.Put("/profile/password", controllers.AdminChangePassword)
|
||||||
|
|
||||||
// App Users Management
|
// App Users Management
|
||||||
adminAuth.Get("/users", controllers.GetAppUsers)
|
adminAuth.Get("/users", middlewares.DoormileStaffOnly, controllers.GetAppUsers)
|
||||||
adminAuth.Post("/users", controllers.CreateAppUser)
|
adminAuth.Post("/users", middlewares.DoormileStaffOnly, controllers.CreateAppUser)
|
||||||
adminAuth.Put("/users/:id", controllers.UpdateAppUser)
|
adminAuth.Put("/users/:id", middlewares.DoormileStaffOnly, controllers.UpdateAppUser)
|
||||||
adminAuth.Delete("/users/:id", controllers.DeleteAppUser)
|
adminAuth.Delete("/users/:id", middlewares.DoormileStaffOnly, controllers.DeleteAppUser)
|
||||||
|
|
||||||
// Partner management (fleet/rider suppliers — distinct from tenants,
|
// Partner management (fleet/rider suppliers — distinct from tenants,
|
||||||
// which are the client companies Doormile delivers for)
|
// which are the client companies Doormile delivers for)
|
||||||
@@ -336,7 +336,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
|
|
||||||
// Tenant management
|
// Tenant management
|
||||||
adminAuth.Get("/tenants", controllers.GetTenants)
|
adminAuth.Get("/tenants", controllers.GetTenants)
|
||||||
adminAuth.Post("/tenants", controllers.CreateTenant)
|
adminAuth.Post("/tenants", middlewares.DoormileStaffOnly, controllers.CreateTenant)
|
||||||
adminAuth.Get("/tenants/:id", controllers.GetTenantDetails)
|
adminAuth.Get("/tenants/:id", controllers.GetTenantDetails)
|
||||||
adminAuth.Put("/tenants/:id", controllers.UpdateTenant)
|
adminAuth.Put("/tenants/:id", controllers.UpdateTenant)
|
||||||
adminAuth.Delete("/tenants/:id", controllers.DeleteTenant)
|
adminAuth.Delete("/tenants/:id", controllers.DeleteTenant)
|
||||||
@@ -373,17 +373,17 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
|
|
||||||
// Hubs
|
// Hubs
|
||||||
adminAuth.Get("/hubs", controllers.GetHubs)
|
adminAuth.Get("/hubs", controllers.GetHubs)
|
||||||
adminAuth.Post("/hubs", controllers.CreateHub)
|
adminAuth.Post("/hubs", middlewares.DoormileStaffOnly, controllers.CreateHub)
|
||||||
adminAuth.Get("/hubs/:id", controllers.GetHubDetails)
|
adminAuth.Get("/hubs/:id", controllers.GetHubDetails)
|
||||||
adminAuth.Put("/hubs/:id", controllers.UpdateHub)
|
adminAuth.Put("/hubs/:id", middlewares.DoormileStaffOnly, controllers.UpdateHub)
|
||||||
adminAuth.Delete("/hubs/:id", controllers.DeleteHub)
|
adminAuth.Delete("/hubs/:id", middlewares.DoormileStaffOnly, controllers.DeleteHub)
|
||||||
|
|
||||||
// Vehicles
|
// Vehicles
|
||||||
adminAuth.Get("/vehicles", controllers.GetVehicles)
|
adminAuth.Get("/vehicles", controllers.GetVehicles)
|
||||||
adminAuth.Post("/vehicles", controllers.CreateVehicle)
|
adminAuth.Post("/vehicles", middlewares.DoormileStaffOnly, controllers.CreateVehicle)
|
||||||
adminAuth.Get("/vehicles/:id", controllers.GetVehicleDetails)
|
adminAuth.Get("/vehicles/:id", controllers.GetVehicleDetails)
|
||||||
adminAuth.Put("/vehicles/:id", controllers.UpdateVehicle)
|
adminAuth.Put("/vehicles/:id", middlewares.DoormileStaffOnly, controllers.UpdateVehicle)
|
||||||
adminAuth.Delete("/vehicles/:id", controllers.DeleteVehicle)
|
adminAuth.Delete("/vehicles/:id", middlewares.DoormileStaffOnly, controllers.DeleteVehicle)
|
||||||
|
|
||||||
// Milers
|
// Milers
|
||||||
adminAuth.Get("/milers", controllers.GetMilers)
|
adminAuth.Get("/milers", controllers.GetMilers)
|
||||||
@@ -429,25 +429,25 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
adminAuth.Get("/returns", controllers.GetReturns)
|
adminAuth.Get("/returns", controllers.GetReturns)
|
||||||
|
|
||||||
// Tripsheets
|
// Tripsheets
|
||||||
adminAuth.Get("/tripsheets", controllers.GetTripsheets)
|
adminAuth.Get("/tripsheets", middlewares.DoormileStaffOnly, controllers.GetTripsheets)
|
||||||
adminAuth.Post("/tripsheets", controllers.CreateTripsheet)
|
adminAuth.Post("/tripsheets", middlewares.DoormileStaffOnly, controllers.CreateTripsheet)
|
||||||
adminAuth.Get("/tripsheets/:id", controllers.GetTripsheetDetails)
|
adminAuth.Get("/tripsheets/:id", middlewares.DoormileStaffOnly, controllers.GetTripsheetDetails)
|
||||||
adminAuth.Post("/tripsheets/:id/items", controllers.AddTripsheetItem)
|
adminAuth.Post("/tripsheets/:id/items", middlewares.DoormileStaffOnly, controllers.AddTripsheetItem)
|
||||||
adminAuth.Delete("/tripsheets/:id/items/:itemid", controllers.DeleteTripsheetItem)
|
adminAuth.Delete("/tripsheets/:id/items/:itemid", middlewares.DoormileStaffOnly, controllers.DeleteTripsheetItem)
|
||||||
adminAuth.Put("/tripsheets/:id/dispatch", controllers.DispatchTripsheet)
|
adminAuth.Put("/tripsheets/:id/dispatch", middlewares.DoormileStaffOnly, controllers.DispatchTripsheet)
|
||||||
adminAuth.Put("/tripsheets/:id/arrive", controllers.ArriveTripsheet)
|
adminAuth.Put("/tripsheets/:id/arrive", middlewares.DoormileStaffOnly, controllers.ArriveTripsheet)
|
||||||
|
|
||||||
// Competitor branch survey data (from Enquiry.xlsx Sheet 1)
|
// Competitor branch survey data (from Enquiry.xlsx Sheet 1)
|
||||||
adminAuth.Get("/competitor-branches", controllers.GetCompetitorBranches)
|
adminAuth.Get("/competitor-branches", middlewares.DoormileStaffOnly, controllers.GetCompetitorBranches)
|
||||||
adminAuth.Post("/competitor-branches", controllers.CreateCompetitorBranch)
|
adminAuth.Post("/competitor-branches", middlewares.DoormileStaffOnly, controllers.CreateCompetitorBranch)
|
||||||
adminAuth.Put("/competitor-branches/:id", controllers.UpdateCompetitorBranch)
|
adminAuth.Put("/competitor-branches/:id", middlewares.DoormileStaffOnly, controllers.UpdateCompetitorBranch)
|
||||||
adminAuth.Delete("/competitor-branches/:id", controllers.DeleteCompetitorBranch)
|
adminAuth.Delete("/competitor-branches/:id", middlewares.DoormileStaffOnly, controllers.DeleteCompetitorBranch)
|
||||||
|
|
||||||
// Carrier pricing benchmarks (from Enquiry.xlsx Sheet 2)
|
// Carrier pricing benchmarks (from Enquiry.xlsx Sheet 2)
|
||||||
adminAuth.Get("/carrier-pricing", controllers.GetCarrierPricing)
|
adminAuth.Get("/carrier-pricing", middlewares.DoormileStaffOnly, controllers.GetCarrierPricing)
|
||||||
adminAuth.Post("/carrier-pricing", controllers.CreateCarrierPricing)
|
adminAuth.Post("/carrier-pricing", middlewares.DoormileStaffOnly, controllers.CreateCarrierPricing)
|
||||||
adminAuth.Put("/carrier-pricing/:id", controllers.UpdateCarrierPricing)
|
adminAuth.Put("/carrier-pricing/:id", middlewares.DoormileStaffOnly, controllers.UpdateCarrierPricing)
|
||||||
adminAuth.Delete("/carrier-pricing/:id", controllers.DeleteCarrierPricing)
|
adminAuth.Delete("/carrier-pricing/:id", middlewares.DoormileStaffOnly, controllers.DeleteCarrierPricing)
|
||||||
|
|
||||||
// Pricing
|
// Pricing
|
||||||
adminAuth.Get("/pricing", controllers.GetPricing)
|
adminAuth.Get("/pricing", controllers.GetPricing)
|
||||||
@@ -465,9 +465,9 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
|
|
||||||
// Exceptions
|
// Exceptions
|
||||||
adminAuth.Get("/exceptions", controllers.GetExceptions)
|
adminAuth.Get("/exceptions", controllers.GetExceptions)
|
||||||
adminAuth.Post("/exceptions", controllers.CreateException)
|
adminAuth.Post("/exceptions", middlewares.DoormileStaffOnly, controllers.CreateException)
|
||||||
adminAuth.Get("/exceptions/:id", controllers.GetExceptionDetails)
|
adminAuth.Get("/exceptions/:id", controllers.GetExceptionDetails)
|
||||||
adminAuth.Put("/exceptions/:id/status", controllers.ResolveException)
|
adminAuth.Put("/exceptions/:id/status", middlewares.DoormileStaffOnly, controllers.ResolveException)
|
||||||
|
|
||||||
// AI agent registry (krow_talent_app/docs/agent-platform-plan.md, Phase 1).
|
// AI agent registry (krow_talent_app/docs/agent-platform-plan.md, Phase 1).
|
||||||
// Doormile staff only — a partner-tenant login gets 403. Reads are open to
|
// Doormile staff only — a partner-tenant login gets 403. Reads are open to
|
||||||
|
|||||||
141
routes/routes_client_fleetops_test.go
Normal file
141
routes/routes_client_fleetops_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package routes_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"doormile/db"
|
||||||
|
"doormile/internal/testpg"
|
||||||
|
"doormile/models"
|
||||||
|
"doormile/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A client (tenant) login can open the console's Fleet Ops menu, so every
|
||||||
|
// create/edit/delete behind it, and every internal list, must refuse a client
|
||||||
|
// token on the server, not just be hidden in the UI.
|
||||||
|
var staffOnlyFleetRoutes = []struct{ method, path string }{
|
||||||
|
{http.MethodPost, "/api/v1/admin/tenants"},
|
||||||
|
{http.MethodGet, "/api/v1/admin/users"},
|
||||||
|
{http.MethodPost, "/api/v1/admin/users"},
|
||||||
|
{http.MethodPut, "/api/v1/admin/users/1"},
|
||||||
|
{http.MethodDelete, "/api/v1/admin/users/1"},
|
||||||
|
{http.MethodPost, "/api/v1/admin/hubs"},
|
||||||
|
{http.MethodPut, "/api/v1/admin/hubs/1"},
|
||||||
|
{http.MethodDelete, "/api/v1/admin/hubs/1"},
|
||||||
|
{http.MethodPost, "/api/v1/admin/vehicles"},
|
||||||
|
{http.MethodPut, "/api/v1/admin/vehicles/1"},
|
||||||
|
{http.MethodDelete, "/api/v1/admin/vehicles/1"},
|
||||||
|
{http.MethodGet, "/api/v1/admin/tripsheets"},
|
||||||
|
{http.MethodPost, "/api/v1/admin/tripsheets"},
|
||||||
|
{http.MethodGet, "/api/v1/admin/tripsheets/1"},
|
||||||
|
{http.MethodPost, "/api/v1/admin/tripsheets/1/items"},
|
||||||
|
{http.MethodDelete, "/api/v1/admin/tripsheets/1/items/1"},
|
||||||
|
{http.MethodPut, "/api/v1/admin/tripsheets/1/dispatch"},
|
||||||
|
{http.MethodPut, "/api/v1/admin/tripsheets/1/arrive"},
|
||||||
|
{http.MethodGet, "/api/v1/admin/competitor-branches"},
|
||||||
|
{http.MethodPost, "/api/v1/admin/competitor-branches"},
|
||||||
|
{http.MethodPut, "/api/v1/admin/competitor-branches/1"},
|
||||||
|
{http.MethodDelete, "/api/v1/admin/competitor-branches/1"},
|
||||||
|
{http.MethodGet, "/api/v1/admin/carrier-pricing"},
|
||||||
|
{http.MethodPost, "/api/v1/admin/carrier-pricing"},
|
||||||
|
{http.MethodPut, "/api/v1/admin/carrier-pricing/1"},
|
||||||
|
{http.MethodDelete, "/api/v1/admin/carrier-pricing/1"},
|
||||||
|
{http.MethodPost, "/api/v1/admin/exceptions"},
|
||||||
|
{http.MethodPut, "/api/v1/admin/exceptions/1/status"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFleetOpsWritesRefuseAClientLogin(t *testing.T) {
|
||||||
|
app := onboardingApp()
|
||||||
|
client := consoleToken(t, "ops@client.test", 3, 42)
|
||||||
|
for _, r := range staffOnlyFleetRoutes {
|
||||||
|
code, body := do(t, app, r.method, r.path, client, `{}`)
|
||||||
|
if code != http.StatusForbidden || !strings.Contains(body, "Doormile staff only") {
|
||||||
|
t.Errorf("%s %s as a client = %d %s, want 403", r.method, r.path, code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFleetOpsGuardsLetStaffThrough(t *testing.T) {
|
||||||
|
app := onboardingApp()
|
||||||
|
staff := consoleToken(t, "ops@doormile.com", 1, 0)
|
||||||
|
for _, r := range staffOnlyFleetRoutes {
|
||||||
|
// Past the guard the handler runs (and, with no database, may fail);
|
||||||
|
// all that matters here is that the guard did not refuse.
|
||||||
|
func() {
|
||||||
|
defer func() { _ = recover() }()
|
||||||
|
if code, body := do(t, app, r.method, r.path, staff, `{}`); strings.Contains(body, "Doormile staff only") {
|
||||||
|
t.Errorf("%s %s as staff = %d %s, the guard refused", r.method, r.path, code, body)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hubs are scoped to the client's own city, from the server. Postgres-gated
|
||||||
|
// like the other route tests; the DSN must be a THROWAWAY database.
|
||||||
|
func TestClientSeesOnlyItsOwnCityHubs(t *testing.T) {
|
||||||
|
dsn := os.Getenv("REGISTRY_TEST_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres hub scoping test")
|
||||||
|
}
|
||||||
|
gdb := testpg.Open(t, dsn, "client_fleetops_routes_test")
|
||||||
|
all := []any{&models.Hub{}, &models.AppUser{}, &models.AppLocation{}}
|
||||||
|
if err := gdb.Migrator().DropTable(all...); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := gdb.AutoMigrate(all...); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prev := db.DB
|
||||||
|
db.DB = gdb
|
||||||
|
t.Cleanup(func() { db.DB = prev })
|
||||||
|
|
||||||
|
gdb.Create(&models.AppLocation{Applocationid: 1, Applocationname: "Coimbatore", Status: "Active"})
|
||||||
|
gdb.Create(&models.AppLocation{Applocationid: 2, Applocationname: "Chennai", Status: "Active"})
|
||||||
|
cbe := models.Hub{Hubname: "Coimbatore Neptune Hub", Hubtype: "delivery_hub", Applocationid: 1, Status: "Active"}
|
||||||
|
chn := models.Hub{Hubname: "Chennai Guindy Hub", Hubtype: "delivery_hub", Applocationid: 2, Status: "Active"}
|
||||||
|
gdb.Create(&cbe)
|
||||||
|
gdb.Create(&chn)
|
||||||
|
withCity := models.AppUser{Authname: "Priya", Email: "ops@peelamedu.test", Contactno: "9876543210", Password: "x", Roleid: 3, Applocationid: 1}
|
||||||
|
noCity := models.AppUser{Authname: "Nocity", Email: "ops@nocity.test", Contactno: "9876543211", Password: "x", Roleid: 3}
|
||||||
|
gdb.Create(&withCity)
|
||||||
|
gdb.Create(&noCity)
|
||||||
|
|
||||||
|
app := onboardingApp()
|
||||||
|
mint := func(uid int, email string, tenant int) string {
|
||||||
|
tok, err := utils.GenerateToken(uid, email, 3, tenant, 1, jwtSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tok
|
||||||
|
}
|
||||||
|
client := mint(withCity.Userid, withCity.Email, 42)
|
||||||
|
|
||||||
|
code, body := do(t, app, http.MethodGet, "/api/v1/admin/hubs", client, "")
|
||||||
|
if code != 200 || !strings.Contains(body, "Coimbatore Neptune Hub") || strings.Contains(body, "Chennai Guindy Hub") {
|
||||||
|
t.Fatalf("client hub list = %d %s, want Coimbatore only", code, body)
|
||||||
|
}
|
||||||
|
// Asking for another city by query string changes nothing.
|
||||||
|
if _, body := do(t, app, http.MethodGet, "/api/v1/admin/hubs?applocationid=2", client, ""); strings.Contains(body, "Chennai Guindy Hub") {
|
||||||
|
t.Fatalf("?applocationid=2 leaked another city: %s", body)
|
||||||
|
}
|
||||||
|
if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/hubs/"+strconv.Itoa(chn.Hubid), client, ""); code != 404 {
|
||||||
|
t.Fatalf("another city's hub detail = %d, want 404", code)
|
||||||
|
}
|
||||||
|
if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/hubs/"+strconv.Itoa(cbe.Hubid), client, ""); code != 200 {
|
||||||
|
t.Fatalf("own city's hub detail = %d, want 200", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client with no city on file sees no hubs, never every hub.
|
||||||
|
if _, body := do(t, app, http.MethodGet, "/api/v1/admin/hubs", mint(noCity.Userid, noCity.Email, 43), ""); strings.Contains(body, "Hub\"") {
|
||||||
|
t.Fatalf("client without a city = %s, want an empty list", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staff still see every hub.
|
||||||
|
_, body = do(t, app, http.MethodGet, "/api/v1/admin/hubs", consoleToken(t, "ops@doormile.com", 1, 0), "")
|
||||||
|
if !strings.Contains(body, "Coimbatore Neptune Hub") || !strings.Contains(body, "Chennai Guindy Hub") {
|
||||||
|
t.Fatalf("staff hub list = %s, want both cities", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
169
routes/routes_client_onboarding_pg_test.go
Normal file
169
routes/routes_client_onboarding_pg_test.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package routes_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"doormile/db"
|
||||||
|
"doormile/internal/testpg"
|
||||||
|
"doormile/models"
|
||||||
|
"doormile/utils"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client onboarding with an address, end to end through the real router and
|
||||||
|
// handlers against a real Postgres. Skipped unless REGISTRY_TEST_DSN is set;
|
||||||
|
// the DSN must be a THROWAWAY database — these tables are dropped and
|
||||||
|
// recreated in their own schema.
|
||||||
|
|
||||||
|
func onboardingDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("REGISTRY_TEST_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres onboarding test")
|
||||||
|
}
|
||||||
|
gdb := testpg.Open(t, dsn, "client_onboarding_routes_test")
|
||||||
|
all := []any{&models.Tenant{}, &models.DoormileAuth{}, &models.AppUser{}, &models.TenantLocation{}, &models.AppLocation{}}
|
||||||
|
if err := gdb.Migrator().DropTable(all...); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := gdb.AutoMigrate(all...); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Put the previous handle back, nil included: other tests in this package
|
||||||
|
// rely on there being no database (the gate tests expect the handler to
|
||||||
|
// fail without one, not to query a closed test schema).
|
||||||
|
prev := db.DB
|
||||||
|
db.DB = gdb
|
||||||
|
t.Cleanup(func() { db.DB = prev })
|
||||||
|
// The owner must exist as Doormile staff (onboardingOwnerStillValid).
|
||||||
|
hash, _ := utils.HashPassword("owner-pass-123")
|
||||||
|
if err := gdb.Create(&models.DoormileAuth{Email: onboardingOwner, PasswordHash: hash, Role: "admin"}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := gdb.Create(&models.AppLocation{Applocationid: 1, Applocationname: "Coimbatore", Status: "Active"}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return gdb
|
||||||
|
}
|
||||||
|
|
||||||
|
func onboardBody(extra string) string {
|
||||||
|
return `{"companyname":"Peelamedu Provisions","contactname":"Priya Raman","email":"ops@peelamedu.test",
|
||||||
|
"phone":"9876543210","password":"Strong-pass-1","applocationid":1` + extra + `}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const goodAddress = `,"address":"14 DB Road, RS Puram, Coimbatore","city":"Coimbatore","state":"Tamil Nadu",
|
||||||
|
"pincode":"641002","latitude":11.0090,"longitude":76.9500`
|
||||||
|
|
||||||
|
func TestOnboardingRequiresAnAddressWithAMapLocation(t *testing.T) {
|
||||||
|
gdb := onboardingDB(t)
|
||||||
|
app := onboardingApp()
|
||||||
|
tok := consoleToken(t, onboardingOwner, 1, 0)
|
||||||
|
|
||||||
|
cases := []struct{ name, extra, want string }{
|
||||||
|
{"no address", "", "enter the client's address"},
|
||||||
|
{"bad pincode", `,"address":"14 DB Road, RS Puram","pincode":"64100","latitude":11.0,"longitude":76.9`, "6-digit pincode"},
|
||||||
|
{"typed, not picked", `,"address":"14 DB Road, RS Puram","pincode":"641002"`, "pick the address from the suggestions"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
code, body := do(t, app, http.MethodPost, "/api/v1/admin/clients/onboard", tok, onboardBody(c.extra))
|
||||||
|
if code != 400 || !strings.Contains(body, c.want) {
|
||||||
|
t.Errorf("%s: %d %s, want 400 containing %q", c.name, code, body, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
gdb.Model(&models.Tenant{}).Count(&n)
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatal("a refused onboarding must create nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnboardingSavesTheAddressAsThePrimaryLocation(t *testing.T) {
|
||||||
|
gdb := onboardingDB(t)
|
||||||
|
app := onboardingApp()
|
||||||
|
tok := consoleToken(t, onboardingOwner, 1, 0)
|
||||||
|
|
||||||
|
code, body := do(t, app, http.MethodPost, "/api/v1/admin/clients/onboard", tok, onboardBody(goodAddress))
|
||||||
|
if code != 201 {
|
||||||
|
t.Fatalf("onboard = %d %s", code, body)
|
||||||
|
}
|
||||||
|
var loc models.TenantLocation
|
||||||
|
if err := gdb.First(&loc).Error; err != nil {
|
||||||
|
t.Fatalf("no location created: %v", err)
|
||||||
|
}
|
||||||
|
if !loc.Isprimary || loc.Address != "14 DB Road, RS Puram, Coimbatore" || loc.Pincode != "641002" ||
|
||||||
|
loc.City != "Coimbatore" || loc.Latitude != 11.009 || loc.Locationname != "Peelamedu Provisions" {
|
||||||
|
t.Fatalf("location saved as %+v", loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list shows it.
|
||||||
|
code, body = do(t, app, http.MethodGet, "/api/v1/admin/clients/onboarded", tok, "")
|
||||||
|
if code != 200 || !strings.Contains(body, `"pincode":"641002"`) || !strings.Contains(body, `"address":"14 DB Road, RS Puram, Coimbatore"`) {
|
||||||
|
t.Fatalf("list = %d %s", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The client signs in; the login now carries their city for the zone list.
|
||||||
|
code, body = do(t, app, http.MethodPost, "/api/v1/admin/login", "", `{"email":"ops@peelamedu.test","password":"Strong-pass-1"}`)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("client login = %d %s", code, body)
|
||||||
|
}
|
||||||
|
var login struct {
|
||||||
|
User struct {
|
||||||
|
Applocationid int `json:"applocationid"`
|
||||||
|
Tenantid *int `json:"tenantid"`
|
||||||
|
} `json:"user"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(body), &login); err != nil || login.User.Applocationid != 1 || login.User.Tenantid == nil {
|
||||||
|
t.Fatalf("login user = %+v (%v)", login.User, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditingTheAddressUpdatesOrCreatesTheMainLocation(t *testing.T) {
|
||||||
|
gdb := onboardingDB(t)
|
||||||
|
app := onboardingApp()
|
||||||
|
tok := consoleToken(t, onboardingOwner, 1, 0)
|
||||||
|
|
||||||
|
if code, body := do(t, app, http.MethodPost, "/api/v1/admin/clients/onboard", tok, onboardBody(goodAddress)); code != 201 {
|
||||||
|
t.Fatalf("onboard = %d %s", code, body)
|
||||||
|
}
|
||||||
|
var auth models.DoormileAuth
|
||||||
|
if err := gdb.Where("email = ?", "ops@peelamedu.test").First(&auth).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
move := `{"location":{"address":"5 Avinashi Road, Peelamedu","city":"Coimbatore","state":"Tamil Nadu",
|
||||||
|
"pincode":"641004","latitude":11.0300,"longitude":76.9900}}`
|
||||||
|
if code, body := do(t, app, http.MethodPut, fmt.Sprintf("/api/v1/admin/clients/%d", auth.ID), tok, move); code != 200 {
|
||||||
|
t.Fatalf("edit = %d %s", code, body)
|
||||||
|
}
|
||||||
|
var locs []models.TenantLocation
|
||||||
|
gdb.Find(&locs)
|
||||||
|
if len(locs) != 1 || locs[0].Address != "5 Avinashi Road, Peelamedu" || locs[0].Pincode != "641004" || !locs[0].Isprimary {
|
||||||
|
t.Fatalf("after edit: %+v", locs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An edit with a bad address is refused and changes nothing.
|
||||||
|
bad := `{"location":{"address":"x","pincode":"641004","latitude":11.03,"longitude":76.99}}`
|
||||||
|
if code, _ := do(t, app, http.MethodPut, fmt.Sprintf("/api/v1/admin/clients/%d", auth.ID), tok, bad); code != 400 {
|
||||||
|
t.Fatalf("bad address edit = %d, want 400", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client onboarded before addresses existed: the edit creates the location.
|
||||||
|
hash, _ := utils.HashPassword("Old-pass-123")
|
||||||
|
old := models.Tenant{Tenantname: "Old Client", Primaryemail: "old@client.test", Primarycontact: "9876500000", Status: "Active"}
|
||||||
|
gdb.Create(&old)
|
||||||
|
tid := old.Tenantid
|
||||||
|
oldAuth := models.DoormileAuth{Email: "old@client.test", PasswordHash: hash, Role: "manager", Tenantid: &tid}
|
||||||
|
gdb.Create(&oldAuth)
|
||||||
|
if code, body := do(t, app, http.MethodPut, fmt.Sprintf("/api/v1/admin/clients/%d", oldAuth.ID), tok, move); code != 200 {
|
||||||
|
t.Fatalf("edit old client = %d %s", code, body)
|
||||||
|
}
|
||||||
|
var created models.TenantLocation
|
||||||
|
if err := gdb.Where("tenantid = ?", tid).First(&created).Error; err != nil || !created.Isprimary || created.Locationname != "Old Client" {
|
||||||
|
t.Fatalf("old client location = %+v (%v)", created, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user