From dd0fa75e7bb3b3ea61bcc5797df43454308191c6 Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Wed, 16 Sep 2026 12:17:00 +0530 Subject: [PATCH] feat: miler self-set PIN on first login; no console-set default PIN Milers now choose their own PIN the first time they log in, instead of the console assigning a shared default: - CreateMiler always creates a rider with an empty Password (PIN field removed from MilerCreateRequest); any client-supplied PIN is ignored, making "riders set their own PIN" a backend invariant, not a console convention. - LoginMiler returns `pin_set` so the app routes to enter-PIN vs set-PIN. - New POST /miler/set-pin (SetMilerPin): self-service first PIN, allowed ONLY when the account has none yet (409 otherwise, so it can't overwrite/take over an active account), then logs the rider in. Self-service and throttle-only is safe because of that guard; OTP-gate it once the SMS gateway is live. - verify-pin and set-pin share issueMilerSession so the two success responses can't drift. Also switches BookingPickupComplete's timestamp to DBNow() (IST) so the compatibility-flow inwardedat matches the reconciliation windows. Existing riders keep their PIN and are unaffected; blanking their password to move them onto self-set is a separate, deliberate DB step. go build, go vet and go test ./... all pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD --- controllers/adminController.go | 12 +-- controllers/milerController.go | 143 ++++++++++++++++++++++++--------- dto/admin.go | 12 ++- dto/auth.go | 10 +++ routes/routes.go | 5 ++ 5 files changed, 133 insertions(+), 49 deletions(-) diff --git a/controllers/adminController.go b/controllers/adminController.go index 8b7f115..4988454 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -1859,8 +1859,6 @@ func CreateMiler(c *fiber.Ctx) error { return utils.BadRequest(c, "invalid request body") } - passHash, _ := utils.HashPassword(req.Password) - tx := db.DB.Begin() appLocID := req.Applocationid @@ -1884,10 +1882,12 @@ func CreateMiler(c *fiber.Ctx) error { } user := models.AppUser{ - Authname: req.Authname, - Email: req.Email, - Contactno: req.Contactno, - Password: passHash, + Authname: req.Authname, + Email: req.Email, + Contactno: req.Contactno, + // Empty PIN by design: the rider self-sets it on first login via + // /miler/set-pin. See MilerCreateRequest — no console-set PIN. + Password: "", Roleid: 5, // Miler Status: "Active", Applocationid: appLocID, diff --git a/controllers/milerController.go b/controllers/milerController.go index 476d004..d01c829 100644 --- a/controllers/milerController.go +++ b/controllers/milerController.go @@ -55,10 +55,14 @@ func LoginMiler(cfg *config.Config) fiber.Handler { return utils.Forbidden(c, "miler account is not active") } + // pin_set tells the app which screen to show next: true → enter-PIN + // (verify-pin login), false → set-PIN (first-time). A rider created without + // a PIN has an empty Password; every real PIN is a non-empty bcrypt hash. return c.JSON(fiber.Map{ "success": true, "message": "PIN verification required", "phone": req.Phone, + "pin_set": user.Password != "", }) } } @@ -113,49 +117,57 @@ func VerifyMilerPin(cfg *config.Config) fiber.Handler { return utils.Unauthorized(c, "incorrect PIN") } - token, err := utils.GenerateToken(user.Userid, user.Email, user.Roleid, user.Tenantid, user.Configid, cfg.JWTSecret) - if err != nil { - return utils.Internal(c, "failed to generate token") - } + return issueMilerSession(c, cfg, user, req.DeviceToken) + } +} - var profile models.MilerProfile - if err := db.DB.Where("userid = ?", user.Userid).First(&profile).Error; err != nil { - profile = models.MilerProfile{ - Userid: user.Userid, - Displayname: user.Authname, - Phone: user.Contactno, - Availabilitystatus: constants.MilerOffline, - Rating: 5.0, - Applocationid: user.Applocationid, - } - db.DB.Create(&profile) - } - if req.DeviceToken != "" && profile.Devicetoken != req.DeviceToken { - profile.Devicetoken = req.DeviceToken - db.DB.Model(&profile).Update("device_token", req.DeviceToken) - } +// issueMilerSession builds the authenticated-session response shared by +// verify-pin and set-pin: it mints the JWT, ensures a MilerProfile exists, +// refreshes the device token, and returns the single {token, user, profile} +// shape both entry points must agree on. tenantname drives the app's +// service-profile resolution (hyperlocal vs logistics), checked ahead of the +// raw tenantid and persisted client-side so a rider picks up a changed tenant +// name on next login. +func issueMilerSession(c *fiber.Ctx, cfg *config.Config, user models.AppUser, deviceToken string) error { + token, err := utils.GenerateToken(user.Userid, user.Email, user.Roleid, user.Tenantid, user.Configid, cfg.JWTSecret) + if err != nil { + return utils.Internal(c, "failed to generate token") + } - // tenantname drives the app's service-profile resolution (hyperlocal vs - // logistics), checked ahead of the raw tenantid. Persisted client-side at - // verify-pin, so a rider picks up a changed tenant name on next login. - tenantName := resolveTenantName(user.Tenantid) + var profile models.MilerProfile + if err := db.DB.Where("userid = ?", user.Userid).First(&profile).Error; err != nil { + profile = models.MilerProfile{ + Userid: user.Userid, + Displayname: user.Authname, + Phone: user.Contactno, + Availabilitystatus: constants.MilerOffline, + Rating: 5.0, + Applocationid: user.Applocationid, + } + db.DB.Create(&profile) + } + if deviceToken != "" && profile.Devicetoken != deviceToken { + profile.Devicetoken = deviceToken + db.DB.Model(&profile).Update("device_token", deviceToken) + } - return c.JSON(fiber.Map{ - "success": true, - "token": token, + tenantName := resolveTenantName(user.Tenantid) + + return c.JSON(fiber.Map{ + "success": true, + "token": token, + "tenantid": user.Tenantid, + "tenantname": tenantName, + "user": fiber.Map{ + "userid": user.Userid, + "authname": user.Authname, + "email": user.Email, + "contactno": user.Contactno, "tenantid": user.Tenantid, "tenantname": tenantName, - "user": fiber.Map{ - "userid": user.Userid, - "authname": user.Authname, - "email": user.Email, - "contactno": user.Contactno, - "tenantid": user.Tenantid, - "tenantname": tenantName, - "profile": profile, - }, - }) - } + "profile": profile, + }, + }) } // ResetMilerPin lets a miler who forgot their PIN set a new one from just @@ -200,6 +212,57 @@ func ResetMilerPin(c *fiber.Ctx) error { return utils.Message(c, "PIN reset successfully") } +// SetMilerPin is the self-service first-login PIN creation, the counterpart to +// the ops-only ResetMilerPin. It is allowed ONLY when the account has no PIN yet +// (empty Password): because it can never overwrite an existing PIN, the +// phone-only unauthenticated path here cannot take over an already-active +// account the way an unguarded reset could — the worst case is a not-yet-used +// account being claimed by someone who knows the phone number, which OTP should +// close once the SMS gateway is live. A rider who already has a PIN is sent to +// verify-pin (or an ops reset) via 409. On success the rider is logged in +// immediately, so the app never has to make a second verify-pin call. +func SetMilerPin(cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + req := new(dto.MilerSetPinRequest) + if err := c.BodyParser(req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + if req.Phone == "" || req.NewPin == "" { + return utils.BadRequest(c, "phone and new_pin are required") + } + + configID := req.Configid + if configID == 0 { + configID = 1001 + } + + var user models.AppUser + if err := db.DB.Where("contactno = ? AND configid = ?", 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") + } + if user.Password != "" { + return utils.Conflict(c, "a PIN is already set for this account; use verify-pin to log in") + } + + pinHash, err := utils.HashPassword(req.NewPin) + if err != nil { + return utils.Internal(c, "failed to set PIN") + } + user.Password = pinHash + if err := db.DB.Save(&user).Error; err != nil { + return utils.Internal(c, "failed to set PIN") + } + + return issueMilerSession(c, cfg, user, req.DeviceToken) + } +} + func GetMilerProfile(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) @@ -1119,7 +1182,9 @@ func BookingPickupComplete(c *fiber.Ctx) error { return utils.NotFound(c, "assigned booking not found") } - now := time.Now() + // IST wall-clock (DBNow), so the compatibility-flow inwardedat stamped below + // (and booking.updatedat) matches createdat and the reconciliation windows. + now := utils.DBNow() booking.Status = constants.BookingPickedUp booking.Updatedat = now if err := tx.Save(&booking).Error; err != nil { diff --git a/dto/admin.go b/dto/admin.go index 75d7aa1..3f1974c 100644 --- a/dto/admin.go +++ b/dto/admin.go @@ -63,10 +63,14 @@ type VehicleCreateRequest struct { } type MilerCreateRequest struct { - Authname string `json:"authname"` - Email string `json:"email"` - Contactno string `json:"contactno"` - Password string `json:"password"` + Authname string `json:"authname"` + Email string `json:"email"` + Contactno string `json:"contactno"` + // No PIN field: riders never receive a console-set PIN. A rider is created + // with an empty Password and sets their own PIN on first login via + // /miler/set-pin (LoginMiler reports pin_set:false). Any "password" the + // console sends is ignored. This makes "riders choose their own PIN" a + // backend invariant instead of trusting the console not to send a default. Displayname string `json:"displayname"` // Tenantid attaches a rider to the client they deliver for — riders migrated // from jupiter belong to a specific client (DailyGrubs, Bawa Medicals) diff --git a/dto/auth.go b/dto/auth.go index 411b04f..a32bcfb 100644 --- a/dto/auth.go +++ b/dto/auth.go @@ -24,6 +24,16 @@ type MilerResetPinRequest struct { Configid int `json:"configid"` } +// MilerSetPinRequest is the self-service first-login PIN creation payload +// (SetMilerPin). DeviceToken is accepted so the rider is fully logged in the +// moment they set their PIN, without a second verify-pin round trip. +type MilerSetPinRequest struct { + Phone string `json:"phone" xml:"phone" form:"phone"` + NewPin string `json:"new_pin" xml:"new_pin" form:"new_pin"` + Configid int `json:"configid"` + DeviceToken string `json:"device_token"` +} + type AdminLoginRequest struct { Email string `json:"email" xml:"email" form:"email"` Password string `json:"password" xml:"password" form:"password"` diff --git a/routes/routes.go b/routes/routes.go index f3eed09..b9dae9e 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -175,6 +175,11 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { miler := api.Group("/miler") miler.Post("/login", authThrottle, controllers.LoginMiler(cfg)) miler.Post("/verify-pin", authThrottle, controllers.VerifyMilerPin(cfg)) + // First-login PIN creation is self-service (unlike reset-pin below), and safe + // to leave unauthenticated because SetMilerPin refuses to overwrite an + // existing PIN — it only works on an account that has none yet. authThrottle + // still caps abuse of the phone-number probe. + miler.Post("/set-pin", authThrottle, controllers.SetMilerPin(cfg)) // PIN reset is console-operated, NOT self-service: ResetMilerPin overwrites // the PIN given only a phone number, and phone numbers are the miler login // identifier rather than a secret. Left unauthenticated, two calls