From 0326624ca3bc932abd7df01ba9009034d9760923 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Tue, 6 Oct 2026 19:11:18 +0530 Subject: [PATCH] updates on the onboardings and hubs patches as well --- controllers/adminController.go | 38 ++++- controllers/clientOnboardingController.go | 154 ++++++++++++++++++- controllers/clientOnboarding_test.go | 32 ++++ routes/routes.go | 56 +++---- routes/routes_client_fleetops_test.go | 141 +++++++++++++++++ routes/routes_client_onboarding_pg_test.go | 169 +++++++++++++++++++++ 6 files changed, 559 insertions(+), 31 deletions(-) create mode 100644 routes/routes_client_fleetops_test.go create mode 100644 routes/routes_client_onboarding_pg_test.go diff --git a/controllers/adminController.go b/controllers/adminController.go index 894d5d8..7ee319f 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -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) diff --git a/controllers/clientOnboardingController.go b/controllers/clientOnboardingController.go index f575382..c5eb184 100644 --- a/controllers/clientOnboardingController.go +++ b/controllers/clientOnboardingController.go @@ -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 diff --git a/controllers/clientOnboarding_test.go b/controllers/clientOnboarding_test.go index f4e11a0..d261957 100644 --- a/controllers/clientOnboarding_test.go +++ b/controllers/clientOnboarding_test.go @@ -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) + } + } +} diff --git a/routes/routes.go b/routes/routes.go index 651a424..34b9bd9 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -321,10 +321,10 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { adminAuth.Put("/profile/password", controllers.AdminChangePassword) // App Users Management - adminAuth.Get("/users", controllers.GetAppUsers) - adminAuth.Post("/users", controllers.CreateAppUser) - adminAuth.Put("/users/:id", controllers.UpdateAppUser) - adminAuth.Delete("/users/:id", controllers.DeleteAppUser) + adminAuth.Get("/users", middlewares.DoormileStaffOnly, controllers.GetAppUsers) + adminAuth.Post("/users", middlewares.DoormileStaffOnly, controllers.CreateAppUser) + adminAuth.Put("/users/:id", middlewares.DoormileStaffOnly, controllers.UpdateAppUser) + adminAuth.Delete("/users/:id", middlewares.DoormileStaffOnly, controllers.DeleteAppUser) // Partner management (fleet/rider suppliers — distinct from tenants, // which are the client companies Doormile delivers for) @@ -336,7 +336,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { // Tenant management adminAuth.Get("/tenants", controllers.GetTenants) - adminAuth.Post("/tenants", controllers.CreateTenant) + adminAuth.Post("/tenants", middlewares.DoormileStaffOnly, controllers.CreateTenant) adminAuth.Get("/tenants/:id", controllers.GetTenantDetails) adminAuth.Put("/tenants/:id", controllers.UpdateTenant) adminAuth.Delete("/tenants/:id", controllers.DeleteTenant) @@ -373,17 +373,17 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { // Hubs adminAuth.Get("/hubs", controllers.GetHubs) - adminAuth.Post("/hubs", controllers.CreateHub) + adminAuth.Post("/hubs", middlewares.DoormileStaffOnly, controllers.CreateHub) adminAuth.Get("/hubs/:id", controllers.GetHubDetails) - adminAuth.Put("/hubs/:id", controllers.UpdateHub) - adminAuth.Delete("/hubs/:id", controllers.DeleteHub) + adminAuth.Put("/hubs/:id", middlewares.DoormileStaffOnly, controllers.UpdateHub) + adminAuth.Delete("/hubs/:id", middlewares.DoormileStaffOnly, controllers.DeleteHub) // Vehicles adminAuth.Get("/vehicles", controllers.GetVehicles) - adminAuth.Post("/vehicles", controllers.CreateVehicle) + adminAuth.Post("/vehicles", middlewares.DoormileStaffOnly, controllers.CreateVehicle) adminAuth.Get("/vehicles/:id", controllers.GetVehicleDetails) - adminAuth.Put("/vehicles/:id", controllers.UpdateVehicle) - adminAuth.Delete("/vehicles/:id", controllers.DeleteVehicle) + adminAuth.Put("/vehicles/:id", middlewares.DoormileStaffOnly, controllers.UpdateVehicle) + adminAuth.Delete("/vehicles/:id", middlewares.DoormileStaffOnly, controllers.DeleteVehicle) // Milers adminAuth.Get("/milers", controllers.GetMilers) @@ -429,25 +429,25 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { adminAuth.Get("/returns", controllers.GetReturns) // Tripsheets - adminAuth.Get("/tripsheets", controllers.GetTripsheets) - adminAuth.Post("/tripsheets", controllers.CreateTripsheet) - adminAuth.Get("/tripsheets/:id", controllers.GetTripsheetDetails) - adminAuth.Post("/tripsheets/:id/items", controllers.AddTripsheetItem) - adminAuth.Delete("/tripsheets/:id/items/:itemid", controllers.DeleteTripsheetItem) - adminAuth.Put("/tripsheets/:id/dispatch", controllers.DispatchTripsheet) - adminAuth.Put("/tripsheets/:id/arrive", controllers.ArriveTripsheet) + adminAuth.Get("/tripsheets", middlewares.DoormileStaffOnly, controllers.GetTripsheets) + adminAuth.Post("/tripsheets", middlewares.DoormileStaffOnly, controllers.CreateTripsheet) + adminAuth.Get("/tripsheets/:id", middlewares.DoormileStaffOnly, controllers.GetTripsheetDetails) + adminAuth.Post("/tripsheets/:id/items", middlewares.DoormileStaffOnly, controllers.AddTripsheetItem) + adminAuth.Delete("/tripsheets/:id/items/:itemid", middlewares.DoormileStaffOnly, controllers.DeleteTripsheetItem) + adminAuth.Put("/tripsheets/:id/dispatch", middlewares.DoormileStaffOnly, controllers.DispatchTripsheet) + adminAuth.Put("/tripsheets/:id/arrive", middlewares.DoormileStaffOnly, controllers.ArriveTripsheet) // Competitor branch survey data (from Enquiry.xlsx Sheet 1) - adminAuth.Get("/competitor-branches", controllers.GetCompetitorBranches) - adminAuth.Post("/competitor-branches", controllers.CreateCompetitorBranch) - adminAuth.Put("/competitor-branches/:id", controllers.UpdateCompetitorBranch) - adminAuth.Delete("/competitor-branches/:id", controllers.DeleteCompetitorBranch) + adminAuth.Get("/competitor-branches", middlewares.DoormileStaffOnly, controllers.GetCompetitorBranches) + adminAuth.Post("/competitor-branches", middlewares.DoormileStaffOnly, controllers.CreateCompetitorBranch) + adminAuth.Put("/competitor-branches/:id", middlewares.DoormileStaffOnly, controllers.UpdateCompetitorBranch) + adminAuth.Delete("/competitor-branches/:id", middlewares.DoormileStaffOnly, controllers.DeleteCompetitorBranch) // Carrier pricing benchmarks (from Enquiry.xlsx Sheet 2) - adminAuth.Get("/carrier-pricing", controllers.GetCarrierPricing) - adminAuth.Post("/carrier-pricing", controllers.CreateCarrierPricing) - adminAuth.Put("/carrier-pricing/:id", controllers.UpdateCarrierPricing) - adminAuth.Delete("/carrier-pricing/:id", controllers.DeleteCarrierPricing) + adminAuth.Get("/carrier-pricing", middlewares.DoormileStaffOnly, controllers.GetCarrierPricing) + adminAuth.Post("/carrier-pricing", middlewares.DoormileStaffOnly, controllers.CreateCarrierPricing) + adminAuth.Put("/carrier-pricing/:id", middlewares.DoormileStaffOnly, controllers.UpdateCarrierPricing) + adminAuth.Delete("/carrier-pricing/:id", middlewares.DoormileStaffOnly, controllers.DeleteCarrierPricing) // Pricing adminAuth.Get("/pricing", controllers.GetPricing) @@ -465,9 +465,9 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { // Exceptions adminAuth.Get("/exceptions", controllers.GetExceptions) - adminAuth.Post("/exceptions", controllers.CreateException) + adminAuth.Post("/exceptions", middlewares.DoormileStaffOnly, controllers.CreateException) 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). // Doormile staff only — a partner-tenant login gets 403. Reads are open to diff --git a/routes/routes_client_fleetops_test.go b/routes/routes_client_fleetops_test.go new file mode 100644 index 0000000..093bf4c --- /dev/null +++ b/routes/routes_client_fleetops_test.go @@ -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) + } +} diff --git a/routes/routes_client_onboarding_pg_test.go b/routes/routes_client_onboarding_pg_test.go new file mode 100644 index 0000000..58cae6e --- /dev/null +++ b/routes/routes_client_onboarding_pg_test.go @@ -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) + } +}