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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
This commit is contained in:
2026-09-16 12:17:00 +05:30
parent ba2cd2299c
commit dd0fa75e7b
5 changed files with 133 additions and 49 deletions

View File

@@ -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,

View File

@@ -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 {