From 29690c56f22bd176b4bedad0d9ed1db5b4599cd5 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Wed, 7 Oct 2026 17:19:18 +0530 Subject: [PATCH] updates on the admincontroller and the milercontroller and changes in the creaet single order calculations as well --- controllers/adminController.go | 105 ++++++++++-- controllers/milerAccount.go | 135 +++++++++++++++ controllers/milerAppController.go | 10 +- controllers/milerController.go | 28 ++-- routes/routes.go | 1 + routes/routes_miler_account_pg_test.go | 223 +++++++++++++++++++++++++ 6 files changed, 481 insertions(+), 21 deletions(-) create mode 100644 controllers/milerAccount.go create mode 100644 routes/routes_miler_account_pg_test.go diff --git a/controllers/adminController.go b/controllers/adminController.go index 7ee319f..bb4c5ff 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -1945,17 +1945,51 @@ func CreateMiler(c *fiber.Ctx) error { return utils.BadRequest(c, "invalid request body") } - tx := db.DB.Begin() + // The console form checks these too; the server is what every caller + // (imports, scripts, other tools) actually goes through. + req.Authname = strings.TrimSpace(req.Authname) + req.Displayname = strings.TrimSpace(req.Displayname) + req.Email = strings.ToLower(strings.TrimSpace(req.Email)) + req.Contactno = normalisePhone(req.Contactno) + if req.Authname == "" { + return utils.BadRequest(c, "enter the rider's login name") + } + if req.Displayname == "" { + req.Displayname = req.Authname + } + if !indianMobile.MatchString(req.Contactno) { + return utils.BadRequest(c, "enter a valid 10-digit Indian mobile number") + } + if req.Email == "" || !strings.Contains(req.Email, "@") { + return utils.BadRequest(c, "enter a valid email address") + } + vehicle, ok := canonicalVehicleType(req.Defaultvehicletype) + if !ok { + return utils.BadRequest(c, "vehicle type must be one of "+strings.Join(milerVehicleTypes, ", ")) + } appLocID := req.Applocationid if appLocID == 0 { appLocID = 1 } + var city models.AppLocation + if err := db.DB.Where("applocationid = ?", appLocID).First(&city).Error; err != nil { + return utils.BadRequest(c, "that city does not exist") + } + if msg := checkMilerHub(req.Hubid, appLocID); msg != "" { + return utils.BadRequest(c, msg) + } // A client login may only create riders under its own tenant. tenantID := req.Tenantid if own := consoleTenantID(c); own != 0 { tenantID = own + } else if tenantID != 0 { + var n int64 + db.DB.Model(&models.Tenant{}).Where("tenantid = ?", tenantID).Count(&n) + if n == 0 { + return utils.BadRequest(c, "that client does not exist") + } } // Configid must match what LoginMiler looks up by — it queries @@ -1967,6 +2001,18 @@ func CreateMiler(c *fiber.Ctx) error { configID = 1001 } + // One rider per phone number: login finds the rider by it. + if milerPhoneTaken(req.Contactno, configID, 0) { + return utils.Conflict(c, "a rider with this phone number already exists") + } + var emailUsers int64 + db.DB.Model(&models.AppUser{}).Where("LOWER(email) = ?", req.Email).Count(&emailUsers) + if emailUsers > 0 { + return utils.Conflict(c, "this email is already used by another login") + } + + tx := db.DB.Begin() + user := models.AppUser{ Authname: req.Authname, Email: req.Email, @@ -1984,6 +2030,9 @@ func CreateMiler(c *fiber.Ctx) error { if err := tx.Create(&user).Error; err != nil { tx.Rollback() + if isUniqueViolation(err) { + return utils.Conflict(c, "this email is already used by another login") + } return utils.Internal(c, "failed to create miler account") } @@ -1991,7 +2040,7 @@ func CreateMiler(c *fiber.Ctx) error { Userid: user.Userid, Displayname: req.Displayname, Phone: req.Contactno, - Defaultvehicletype: req.Defaultvehicletype, + Defaultvehicletype: vehicle, Availabilitystatus: constants.MilerOffline, Rating: 5.00, Applocationid: appLocID, @@ -2067,9 +2116,10 @@ func UpdateMiler(c *fiber.Ctx) error { } type MilerUpdate struct { - Displayname string `json:"displayname"` - Defaultvehicletype string `json:"defaultvehicletype"` - Hubid *int `json:"hubid"` + Displayname string `json:"displayname"` + Defaultvehicletype string `json:"defaultvehicletype"` + Hubid *int `json:"hubid"` + Contactno *string `json:"contactno"` } req := new(MilerUpdate) @@ -2077,18 +2127,53 @@ func UpdateMiler(c *fiber.Ctx) error { return utils.BadRequest(c, "invalid request body") } - if req.Displayname != "" { - profile.Displayname = req.Displayname + var user models.AppUser + if err := db.DB.Where("userid = ?", profile.Userid).First(&user).Error; err != nil { + return utils.NotFound(c, "miler not found") + } + + if name := strings.TrimSpace(req.Displayname); name != "" { + profile.Displayname = name } if req.Defaultvehicletype != "" { - profile.Defaultvehicletype = req.Defaultvehicletype + vehicle, ok := canonicalVehicleType(req.Defaultvehicletype) + if !ok { + return utils.BadRequest(c, "vehicle type must be one of "+strings.Join(milerVehicleTypes, ", ")) + } + profile.Defaultvehicletype = vehicle } - if req.Hubid != nil { + if req.Hubid != nil && (profile.Hubid == nil || *profile.Hubid != *req.Hubid) { + // Only a CHANGED hub is checked: an older rider whose current hub is in + // another city (or since deleted) must still be editable. + if msg := checkMilerHub(req.Hubid, profile.Applocationid); msg != "" { + return utils.BadRequest(c, msg) + } profile.Hubid = req.Hubid } + // The phone is the rider's login: a wrong or duplicate number could not be + // corrected at all before, so the rider could never sign in. + phone := user.Contactno + if req.Contactno != nil { + phone = normalisePhone(*req.Contactno) + if !indianMobile.MatchString(phone) { + return utils.BadRequest(c, "enter a valid 10-digit Indian mobile number") + } + if phone != user.Contactno && milerPhoneTaken(phone, user.Configid, user.Userid) { + return utils.Conflict(c, "a rider with this phone number already exists") + } + profile.Phone = phone + } profile.Updatedat = time.Now() - if err := db.DB.Save(profile).Error; err != nil { + err := db.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Save(profile).Error; err != nil { + return err + } + // appusers carries the login phone and the hub the hub console reads. + return tx.Model(&models.AppUser{}).Where("userid = ?", user.Userid). + Updates(map[string]interface{}{"contactno": phone, "hubid": profile.Hubid}).Error + }) + if err != nil { return utils.Internal(c, "failed to update miler") } return utils.OK(c, profile) diff --git a/controllers/milerAccount.go b/controllers/milerAccount.go new file mode 100644 index 0000000..0094698 --- /dev/null +++ b/controllers/milerAccount.go @@ -0,0 +1,135 @@ +package controllers + +import ( + "strconv" + "strings" + "time" + + "doormile/constants" + "doormile/db" + "doormile/models" + "doormile/utils" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +// Rider (miler) accounts: the rules for creating, editing, blocking and +// signing in that the console and the rider app both depend on. +// +// Before this file, CreateMiler checked nothing: two riders could share a phone +// number (login then picked the oldest row, often a blocked or old account, and +// answered "miler account is not active"), a duplicate email surfaced as a +// bare 500, a Coimbatore rider could be attached to a Hyderabad hub, and a +// blocked rider who was still signed in could start duty and put themselves +// back to Available. + +// milerVehicleTypes are the vehicle types the console offers; matched without +// regard to case and stored in this spelling. +var milerVehicleTypes = []string{"Bike", "Scooter", "Bicycle", "Car", "Van"} + +func canonicalVehicleType(v string) (string, bool) { + v = strings.TrimSpace(v) + if v == "" { + return "Bike", true + } + for _, t := range milerVehicleTypes { + if strings.EqualFold(t, v) { + return t, true + } + } + return "", false +} + +// milerLoginLookup finds the rider account for a phone number. Several rows +// can share a number (riders created before duplicates were refused). The +// order is: an Active miler that already has a PIN (the account the rider +// actually uses), then an Active miler without one, then any miler row, then +// anything else; oldest first within each, which is what the plain lookup +// used to return. Ranking a PIN-less duplicate first would answer "incorrect +// PIN" to a rider who signed in yesterday, and let set-pin claim the duplicate. +func milerLoginLookup(phone string, configID int) *gorm.DB { + return db.DB.Where("contactno = ? AND configid = ?", normalisePhone(phone), configID). + Order(`CASE WHEN roleid = 5 AND status = 'Active' AND COALESCE(password, '') <> '' THEN 0 + WHEN roleid = 5 AND status = 'Active' THEN 1 + WHEN roleid = 5 THEN 2 ELSE 3 END, userid ASC`) +} + +// milerNotActiveMessage is what a rider is told when their account cannot sign +// in; a blocked rider is told so, rather than a generic "not active". +func milerNotActiveMessage(status string) string { + if strings.EqualFold(status, constants.MilerBlocked) { + return "your account is blocked — contact your manager" + } + return "miler account is not active" +} + +// milerIsBlocked reports whether ops have blocked this rider. Checked on the +// rider-app actions that would otherwise undo a block for a rider who was +// already signed in when it happened (starting duty, setting availability). +func milerIsBlocked(milerUserID int) bool { + var p models.MilerProfile + if db.DB.Select("availabilitystatus").Where("userid = ?", milerUserID).First(&p).Error == nil && + strings.EqualFold(p.Availabilitystatus, constants.MilerBlocked) { + return true + } + var u models.AppUser + return db.DB.Select("status").Where("userid = ?", milerUserID).First(&u).Error == nil && + strings.EqualFold(u.Status, constants.MilerBlocked) +} + +// milerPhoneTaken reports whether another rider already signs in with this +// number (exceptUserID is the rider being edited, 0 when creating). +func milerPhoneTaken(phone string, configID, exceptUserID int) bool { + var n int64 + db.DB.Model(&models.AppUser{}). + Where("contactno = ? AND configid = ? AND roleid = 5 AND userid <> ?", phone, configID, exceptUserID). + Count(&n) + return n > 0 +} + +// checkMilerHub makes sure a hub exists and sits in the rider's city. A nil hub +// (no base) is always fine. +func checkMilerHub(hubID *int, cityID int) string { + if hubID == nil { + return "" + } + var hub models.Hub + if err := db.DB.Select("hubid", "applocationid").Where("hubid = ? AND deletedat IS NULL", *hubID).First(&hub).Error; err != nil { + return "that hub does not exist" + } + if hub.Applocationid != cityID { + return "that hub is in a different city from the rider — choose a hub in the rider's city" + } + return "" +} + +// UnblockMiler — PUT /admin/milers/:id/unblock +// The counterpart of BlockMiler: the rider can sign in again and is Offline +// until they start duty. Same scoping as block (a client login, its own riders). +func UnblockMiler(c *fiber.Ctx) error { + id, _ := strconv.Atoi(c.Params("id")) + profile, ok := findMilerForConsole(c, id) + if !ok { + return utils.NotFound(c, "miler not found") + } + if !milerIsBlocked(profile.Userid) { + return utils.BadRequest(c, "this miler is not blocked") + } + + now := time.Now() + err := db.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&models.MilerProfile{}).Where("milerprofileid = ?", profile.Milerprofileid). + Updates(map[string]interface{}{"availabilitystatus": constants.MilerOffline, "updatedat": now}).Error; err != nil { + return err + } + return tx.Model(&models.AppUser{}).Where("userid = ? AND status = ?", profile.Userid, constants.MilerBlocked). + Update("status", "Active").Error + }) + if err != nil { + return utils.Internal(c, "failed to unblock miler") + } + profile.Availabilitystatus = constants.MilerOffline + profile.Updatedat = now + return utils.OK(c, profile) +} diff --git a/controllers/milerAppController.go b/controllers/milerAppController.go index 7a49cbd..7366a01 100644 --- a/controllers/milerAppController.go +++ b/controllers/milerAppController.go @@ -33,6 +33,12 @@ func MilerStartDuty(c *fiber.Ctx) error { return utils.BadRequest(c, "invalid request body") } + // A rider still signed in when ops blocked them used to come straight back + // to Available here. + if milerIsBlocked(milerUserID) { + return utils.Forbidden(c, milerNotActiveMessage(constants.MilerBlocked)) + } + midnight := todayMidnight() var existing models.MilerDutyLog if err := db.DB.Where("userid = ? AND onduty = ? AND logoutat IS NULL AND loginat >= ?", milerUserID, true, midnight). @@ -237,7 +243,9 @@ func MilerEndBreak(c *fiber.Ctx) error { return utils.Internal(c, "failed to end break") } - db.DB.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Update("availabilitystatus", constants.MilerAvailable) + // Ending a break never lifts a block ops applied meanwhile. + db.DB.Model(&models.MilerProfile{}).Where("userid = ? AND availabilitystatus <> ?", milerUserID, constants.MilerBlocked). + Update("availabilitystatus", constants.MilerAvailable) return utils.OK(c, fiber.Map{ "breaklogid": breakLog.Breaklogid, diff --git a/controllers/milerController.go b/controllers/milerController.go index 3a4ae9c..c50e41c 100644 --- a/controllers/milerController.go +++ b/controllers/milerController.go @@ -44,7 +44,7 @@ func LoginMiler(cfg *config.Config) fiber.Handler { } var user models.AppUser - if err := db.DB.Where("contactno = ? AND configid = ?", req.Phone, configID).First(&user).Error; err != nil { + if err := milerLoginLookup(req.Phone, configID).First(&user).Error; err != nil { return utils.NotFound(c, "no miler account found for this phone number") } @@ -53,7 +53,7 @@ func LoginMiler(cfg *config.Config) fiber.Handler { } if user.Status != "Active" { - return utils.Forbidden(c, "miler account is not active") + return utils.Forbidden(c, milerNotActiveMessage(user.Status)) } // pin_set tells the app which screen to show next: true → enter-PIN @@ -102,7 +102,7 @@ func VerifyMilerPin(cfg *config.Config) fiber.Handler { } var user models.AppUser - if err := db.DB.Where("contactno = ? AND configid = ?", req.Phone, configID).First(&user).Error; err != nil { + if err := milerLoginLookup(req.Phone, configID).First(&user).Error; err != nil { return utils.NotFound(c, "no miler account found for this phone number") } @@ -111,7 +111,7 @@ func VerifyMilerPin(cfg *config.Config) fiber.Handler { } if user.Status != "Active" { - return utils.Forbidden(c, "miler account is not active") + return utils.Forbidden(c, milerNotActiveMessage(user.Status)) } if !utils.CheckPasswordHash(req.Pin, user.Password) { @@ -192,7 +192,7 @@ func ResetMilerPin(c *fiber.Ctx) error { } var user models.AppUser - if err := db.DB.Where("contactno = ? AND configid = ?", req.Phone, configID).First(&user).Error; err != nil { + if err := milerLoginLookup(req.Phone, configID).First(&user).Error; err != nil { return utils.NotFound(c, "no miler account found for this phone number") } @@ -238,14 +238,14 @@ func SetMilerPin(cfg *config.Config) fiber.Handler { } var user models.AppUser - if err := db.DB.Where("contactno = ? AND configid = ?", req.Phone, configID).First(&user).Error; err != nil { + if err := milerLoginLookup(req.Phone, configID).First(&user).Error; err != nil { return utils.NotFound(c, "no miler account found for this phone number") } if user.Roleid != 5 { return utils.Forbidden(c, "this endpoint is restricted to miler accounts") } if user.Status != "Active" { - return utils.Forbidden(c, "miler account is not active") + return utils.Forbidden(c, milerNotActiveMessage(user.Status)) } if user.Password != "" { return utils.Conflict(c, "a PIN is already set for this account; use verify-pin to log in") @@ -428,6 +428,14 @@ func UpdateMilerAvailability(c *fiber.Ctx) error { if status == "" { return utils.BadRequest(c, "status is required") } + // Blocking is an ops decision: a rider can neither lift it (Available + // from a session that was open when the block happened) nor set it. + if strings.EqualFold(status, constants.MilerBlocked) { + return utils.BadRequest(c, "riders cannot set this status") + } + if milerIsBlocked(milerUserID) { + return utils.Forbidden(c, milerNotActiveMessage(constants.MilerBlocked)) + } var profile models.MilerProfile if err := db.DB.Where("userid = ?", milerUserID).First(&profile).Error; err != nil { @@ -702,7 +710,7 @@ func RejectMilerAssignment(c *fiber.Ctx) error { } } - if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID). + if err := tx.Model(&models.MilerProfile{}).Where("userid = ? AND availabilitystatus <> ?", milerUserID, constants.MilerBlocked). Update("availabilitystatus", constants.MilerAvailable).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to update miler availability") @@ -782,7 +790,7 @@ func MilerCancelAssignment(c *fiber.Ctx) error { return utils.Internal(c, "failed to release booking") } - if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID). + if err := tx.Model(&models.MilerProfile{}).Where("userid = ? AND availabilitystatus <> ?", milerUserID, constants.MilerBlocked). Update("availabilitystatus", constants.MilerAvailable).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to update miler availability") @@ -1526,7 +1534,7 @@ func BookingPickupComplete(c *fiber.Ctx) error { return utils.Internal(c, "failed to close assignment") } } - if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID). + if err := tx.Model(&models.MilerProfile{}).Where("userid = ? AND availabilitystatus <> ?", milerUserID, constants.MilerBlocked). Update("availabilitystatus", postPickupAvailability).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to update miler availability") diff --git a/routes/routes.go b/routes/routes.go index 14f328c..07e9252 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -396,6 +396,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { adminAuth.Get("/milers/:id/activity", controllers.GetMilerActivity) adminAuth.Put("/milers/:id", controllers.UpdateMiler) adminAuth.Put("/milers/:id/block", controllers.BlockMiler) + adminAuth.Put("/milers/:id/unblock", controllers.UnblockMiler) adminAuth.Put("/milers/:id/assign-vehicle", controllers.AssignMilerVehicle) adminAuth.Post("/milers/:id/notify", controllers.AdminNotifyMiler) diff --git a/routes/routes_miler_account_pg_test.go b/routes/routes_miler_account_pg_test.go new file mode 100644 index 0000000..8d12af7 --- /dev/null +++ b/routes/routes_miler_account_pg_test.go @@ -0,0 +1,223 @@ +package routes_test + +import ( + "fmt" + "net/http" + "os" + "strings" + "testing" + + "doormile/constants" + "doormile/db" + "doormile/internal/testpg" + "doormile/models" + "doormile/utils" + + "gorm.io/gorm" +) + +// Rider accounts end to end against a real Postgres: create-time checks, +// duplicate phones, editing the phone, and block / unblock actually holding. +// Postgres-gated; the DSN must be a THROWAWAY database. + +func milerAccountDB(t *testing.T) *gorm.DB { + t.Helper() + dsn := os.Getenv("REGISTRY_TEST_DSN") + if dsn == "" { + t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres miler account test") + } + gdb := testpg.Open(t, dsn, "miler_account_routes_test") + all := []any{&models.AppUser{}, &models.MilerProfile{}, &models.MilerDutyLog{}, &models.Hub{}, &models.AppLocation{}, &models.Tenant{}} + 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: "Hyderabad", Status: "Active"}) + gdb.Create(&models.Hub{Hubid: 11, Hubname: "Coimbatore Neptune Hub", Hubtype: "delivery_hub", Applocationid: 1, Status: "Active"}) + gdb.Create(&models.Hub{Hubid: 21, Hubname: "Hyderabad Mars Hub", Hubtype: "delivery_hub", Applocationid: 2, Status: "Active"}) + gdb.Create(&models.Tenant{Tenantid: 5, Tenantname: "Sai's Kitchen", Primaryemail: "sai@k.test", Primarycontact: "9000000005", Status: "Active"}) + // A client login whose email a rider must not reuse. + gdb.Create(&models.AppUser{Authname: "Sai", Email: "sai@k.test", Contactno: "9000000005", Password: "x", Roleid: 3, Status: "Active", Configid: 1}) + return gdb +} + +func riderBody(name, phone, email, extra string) string { + return fmt.Sprintf(`{"authname":%q,"displayname":%q,"contactno":%q,"email":%q,"tenantid":5,"applocationid":1%s}`, + name, name, phone, email, extra) +} + +func TestCreateMilerChecksEverythingAndRefusesDuplicates(t *testing.T) { + gdb := milerAccountDB(t) + app := onboardingApp() + staff := consoleToken(t, "ops@doormile.com", 1, 0) + + refused := []struct{ name, body, want string }{ + {"bad phone", riderBody("Ravi", "12345", "ravi@r.test", ""), "10-digit"}, + {"no email", riderBody("Ravi", "9876500001", "", ""), "valid email"}, + {"no name", riderBody("", "9876500001", "ravi@r.test", ""), "login name"}, + {"unknown vehicle", riderBody("Ravi", "9876500001", "ravi@r.test", `,"defaultvehicletype":"Rocket"`), "vehicle type"}, + {"hub in another city", riderBody("Ravi", "9876500001", "ravi@r.test", `,"hubid":21`), "different city"}, + {"unknown client", strings.Replace(riderBody("Ravi", "9876500001", "ravi@r.test", ""), `"tenantid":5`, `"tenantid":99`, 1), "client does not exist"}, + {"email used by a client login", riderBody("Ravi", "9876500001", "SAI@k.test", ""), "already used by another login"}, + } + for _, r := range refused { + code, body := do(t, app, http.MethodPost, "/api/v1/admin/milers", staff, r.body) + if code < 400 || code >= 500 || !strings.Contains(body, r.want) { + t.Errorf("%s: %d %s, want a 4xx containing %q", r.name, code, body, r.want) + } + } + + // A good rider, typed the way people type numbers, with a lower-case vehicle. + code, body := do(t, app, http.MethodPost, "/api/v1/admin/milers", staff, + riderBody("Ravi", "+91 98765 00001", "Ravi@R.test", `,"defaultvehicletype":"bike","hubid":11`)) + if code != 201 { + t.Fatalf("create = %d %s", code, body) + } + var u models.AppUser + gdb.Where("roleid = 5").First(&u) + var p models.MilerProfile + gdb.Where("userid = ?", u.Userid).First(&p) + if u.Contactno != "9876500001" || u.Email != "ravi@r.test" || u.Configid != 1001 || p.Defaultvehicletype != "Bike" || p.Phone != "9876500001" { + t.Fatalf("stored rider = %+v / %+v", u, p) + } + + // The same number again, however it is written, is refused. + code, body = do(t, app, http.MethodPost, "/api/v1/admin/milers", staff, riderBody("Ravi Two", "098765 00001", "ravi2@r.test", "")) + if code != 409 || !strings.Contains(body, "phone number already exists") { + t.Fatalf("duplicate phone = %d %s, want 409", code, body) + } +} + +func TestMilerLoginPrefersTheActiveRiderWhenAPhoneIsShared(t *testing.T) { + gdb := milerAccountDB(t) + app := onboardingApp() + // Rows from before duplicates were refused: an old blocked rider first. + gdb.Create(&models.AppUser{Authname: "Old", Email: "old@r.test", Contactno: "9876500002", Password: "", Roleid: 5, Status: "Blocked", Configid: 1001}) + gdb.Create(&models.AppUser{Authname: "New", Email: "new@r.test", Contactno: "9876500002", Password: "", Roleid: 5, Status: "Active", Configid: 1001}) + + code, body := do(t, app, http.MethodPost, "/api/v1/miler/login", "", `{"phone":"9876500002"}`) + if code != 200 || !strings.Contains(body, `"pin_set":false`) { + t.Fatalf("login = %d %s, want the active rider found", code, body) + } + + // Two Active rows: the rider's real account (has a PIN) and a later + // PIN-less duplicate. The real one must win, or verify-pin says + // "incorrect PIN" and set-pin could claim the duplicate. + hash, _ := utils.HashPassword("1234") + gdb.Create(&models.AppUser{Authname: "Real", Email: "real@r.test", Contactno: "9876500009", Password: hash, Roleid: 5, Status: "Active", Configid: 1001}) + gdb.Create(&models.AppUser{Authname: "Dup", Email: "dup@r.test", Contactno: "9876500009", Password: "", Roleid: 5, Status: "Active", Configid: 1001}) + code, body = do(t, app, http.MethodPost, "/api/v1/miler/login", "", `{"phone":"9876500009"}`) + if code != 200 || !strings.Contains(body, `"pin_set":true`) { + t.Fatalf("login with a PIN-less duplicate = %d %s, want the real account (pin_set true)", code, body) + } + if code, body := do(t, app, http.MethodPost, "/api/v1/miler/set-pin", "", `{"phone":"9876500009","new_pin":"9999"}`); code != 409 { + t.Fatalf("set-pin on a phone whose real account has a PIN = %d %s, want 409", code, body) + } + + // A phone that belongs only to a blocked rider says so. + gdb.Create(&models.AppUser{Authname: "Gone", Email: "gone@r.test", Contactno: "9876500003", Password: "", Roleid: 5, Status: "Blocked", Configid: 1001}) + code, body = do(t, app, http.MethodPost, "/api/v1/miler/login", "", `{"phone":"9876500003"}`) + if code != 403 || !strings.Contains(body, "blocked") { + t.Fatalf("blocked login = %d %s, want 403 naming the block", code, body) + } +} + +func TestBlockHoldsForASignedInRiderAndUnblockLiftsIt(t *testing.T) { + gdb := milerAccountDB(t) + app := onboardingApp() + staff := consoleToken(t, "ops@doormile.com", 1, 0) + + if code, body := do(t, app, http.MethodPost, "/api/v1/admin/milers", staff, riderBody("Nagalakshmi", "9876500004", "naga@r.test", "")); code != 201 { + t.Fatalf("create = %d %s", code, body) + } + var u models.AppUser + gdb.Where("contactno = ?", "9876500004").First(&u) + var p models.MilerProfile + gdb.Where("userid = ?", u.Userid).First(&p) + // The token she already holds from before the block. + rider, err := utils.GenerateToken(u.Userid, u.Email, 5, 0, 1001, jwtSecret) + if err != nil { + t.Fatal(err) + } + + if code, body := do(t, app, http.MethodPut, fmt.Sprintf("/api/v1/admin/milers/%d/block", p.Milerprofileid), staff, `{}`); code != 200 { + t.Fatalf("block = %d %s", code, body) + } + if code, body := do(t, app, http.MethodPost, "/api/v1/miler/duty/start", rider, `{"lat":0,"lon":0}`); code != 403 || !strings.Contains(body, "blocked") { + t.Fatalf("blocked rider start duty = %d %s, want 403", code, body) + } + if code, _ := do(t, app, http.MethodPut, "/api/v1/miler/availability", rider, `{"status":"Available"}`); code != 403 { + t.Fatalf("blocked rider set Available = %d, want 403", code) + } + gdb.Where("userid = ?", u.Userid).First(&p) + if p.Availabilitystatus != constants.MilerBlocked { + t.Fatalf("after the rider's attempts status = %q, want still Blocked", p.Availabilitystatus) + } + + // Unblock: Offline, can sign in and start duty again. + if code, body := do(t, app, http.MethodPut, fmt.Sprintf("/api/v1/admin/milers/%d/unblock", p.Milerprofileid), staff, `{}`); code != 200 { + t.Fatalf("unblock = %d %s", code, body) + } + gdb.Where("userid = ?", u.Userid).First(&p) + gdb.Where("userid = ?", u.Userid).First(&u) + if p.Availabilitystatus != constants.MilerOffline || u.Status != "Active" { + t.Fatalf("after unblock: profile %q, account %q", p.Availabilitystatus, u.Status) + } + if code, body := do(t, app, http.MethodPost, "/api/v1/miler/duty/start", rider, `{"lat":0,"lon":0}`); code != 200 { + t.Fatalf("start duty after unblock = %d %s", code, body) + } + if code, _ := do(t, app, http.MethodPut, fmt.Sprintf("/api/v1/admin/milers/%d/unblock", p.Milerprofileid), staff, `{}`); code != 400 { + t.Fatalf("unblocking a rider who is not blocked = %d, want 400", code) + } + // A rider cannot block themselves either. + if code, _ := do(t, app, http.MethodPut, "/api/v1/miler/availability", rider, `{"status":"Blocked"}`); code != 400 { + t.Fatalf("rider setting Blocked = %d, want 400", code) + } +} + +func TestEditingAMilerCanFixThePhoneAndKeepsTheHubInTheirCity(t *testing.T) { + gdb := milerAccountDB(t) + app := onboardingApp() + staff := consoleToken(t, "ops@doormile.com", 1, 0) + for i, phone := range []string{"9876500005", "9876500006"} { + if code, body := do(t, app, http.MethodPost, "/api/v1/admin/milers", staff, riderBody(fmt.Sprintf("R%d", i), phone, fmt.Sprintf("r%d@r.test", i), "")); code != 201 { + t.Fatalf("create = %d %s", code, body) + } + } + var p models.MilerProfile + gdb.Where("phone = ?", "9876500005").First(&p) + url := fmt.Sprintf("/api/v1/admin/milers/%d", p.Milerprofileid) + + if code, _ := do(t, app, http.MethodPut, url, staff, `{"contactno":"9876500006"}`); code != 409 { + t.Fatalf("editing to another rider's phone = %d, want 409", code) + } + if code, _ := do(t, app, http.MethodPut, url, staff, `{"hubid":21}`); code != 400 { + t.Fatalf("editing to a hub in another city = %d, want 400", code) + } + // An older rider whose CURRENT hub is in another city can still be edited + // (the form sends the unchanged hub back on every save). + gdb.Model(&models.MilerProfile{}).Where("milerprofileid = ?", p.Milerprofileid).Update("hubid", 21) + if code, body := do(t, app, http.MethodPut, url, staff, `{"displayname":"Renamed","hubid":21}`); code != 200 { + t.Fatalf("editing a legacy rider with an unchanged out-of-city hub = %d %s, want 200", code, body) + } + if code, body := do(t, app, http.MethodPut, url, staff, `{"contactno":"+91 98765 00007","hubid":11}`); code != 200 { + t.Fatalf("edit = %d %s", code, body) + } + var u models.AppUser + gdb.Where("userid = ?", p.Userid).First(&u) + gdb.Where("userid = ?", p.Userid).First(&p) + if u.Contactno != "9876500007" || p.Phone != "9876500007" || u.Hubid == nil || *u.Hubid != 11 { + t.Fatalf("after edit: account %q hub %v, profile %q", u.Contactno, u.Hubid, p.Phone) + } + // The rider signs in with the corrected number. + if code, body := do(t, app, http.MethodPost, "/api/v1/miler/login", "", `{"phone":"9876500007"}`); code != 200 { + t.Fatalf("login with the new phone = %d %s", code, body) + } +}