feat: miler app contract gaps — stop type, COD, pre-pickup skip, profile

Close the gaps the miler-app dev flagged against the deployed contract.

- GET /miler/bookings: return stoptype (pickup|delivery, from status),
  step + road-optimized sequence (cumulativekms/etaminutes/cumulativeeta),
  and codamount/paymentmode. List sorted by step, unsequenced last.
  Lookups batched to avoid N+1.
- POST /miler/bookings/:bookingid/skip: pre-pickup skip that keeps the
  booking assigned and resumable — the "route back" the consignment-only
  delivery skip couldn't give a not-yet-picked-up booking.
- GET /miler/earnings: add cancelled_stops + total_stops for success rate.
- PUT /miler/profile: persist email (to appusers, 409 on unique clash) and
  a new nullable milerprofiles.address column.
- POST /miler/assignments/:id/reject: accept reason from body OR ?reason=.

Notifications read-state and bonuspoints deliberately left as-is — both
need a product/business decision, not code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Suriyakumarvijayanayagam
2026-08-21 11:22:01 +05:30
parent f09efcaf59
commit 531185cf66
4 changed files with 240 additions and 9 deletions

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"math"
"strconv"
"strings"
"time"
"doormile/config"
@@ -206,11 +207,16 @@ func UpdateMilerProfile(c *fiber.Ctx) error {
return utils.NotFound(c, "miler profile not found")
}
// Email and Address are pointers so an omitted field is left untouched rather
// than blanked. Email lives on AppUser (the login identity), Address on the
// profile — the edit screen collects both, so both are persisted here.
type ProfileUpdate struct {
Displayname string `json:"displayname"`
Profilephotourl string `json:"profilephotourl"`
Defaultvehicletype string `json:"defaultvehicletype"`
Phone string `json:"phone"`
Displayname string `json:"displayname"`
Profilephotourl string `json:"profilephotourl"`
Defaultvehicletype string `json:"defaultvehicletype"`
Phone string `json:"phone"`
Email *string `json:"email"`
Address *string `json:"address"`
}
req := new(ProfileUpdate)
@@ -224,10 +230,31 @@ func UpdateMilerProfile(c *fiber.Ctx) error {
if req.Phone != "" {
profile.Phone = req.Phone
}
if req.Address != nil {
profile.Address = *req.Address
}
profile.Profilephotourl = req.Profilephotourl
profile.Defaultvehicletype = req.Defaultvehicletype
profile.Updatedat = time.Now()
// Email is unique on appusers, so a collision must fail cleanly rather than
// 500. Only touch it when a non-empty value that actually changed is sent.
if req.Email != nil && *req.Email != "" {
var user models.AppUser
if err := db.DB.First(&user, milerUserID).Error; err == nil && !strings.EqualFold(user.Email, *req.Email) {
var clash int64
db.DB.Model(&models.AppUser{}).
Where("email = ? AND userid <> ?", *req.Email, milerUserID).Count(&clash)
if clash > 0 {
return utils.Conflict(c, "that email is already in use")
}
if err := db.DB.Model(&models.AppUser{}).
Where("userid = ?", milerUserID).Update("email", *req.Email).Error; err != nil {
return utils.Internal(c, "failed to update email")
}
}
}
if err := db.DB.Save(&profile).Error; err != nil {
return utils.Internal(c, "failed to update profile")
}
@@ -434,11 +461,15 @@ func RejectMilerAssignment(c *fiber.Ctx) error {
return utils.BadRequest(c, "invalid assignment ID")
}
// The reason may arrive in the JSON body or as a ?reason= query string — the
// deployed contract and the app disagreed on which, so accept both and prefer
// whichever is non-empty. An absent/invalid body is not an error here.
var req struct {
Reason string `json:"reason"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
_ = c.BodyParser(&req)
if req.Reason == "" {
req.Reason = c.Query("reason")
}
if req.Reason == "" {
req.Reason = "Rejected by rider"