Compare commits
5 Commits
25bc33975c
...
dd0fa75e7b
| Author | SHA1 | Date | |
|---|---|---|---|
| dd0fa75e7b | |||
| ba2cd2299c | |||
| 049bb85359 | |||
| e2e8b537f8 | |||
|
|
6e5da09329 |
@@ -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,
|
||||
|
||||
@@ -294,8 +294,13 @@ func GetMilerLogs(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// The zset is scored by the log's own unix timestamp, so the range query is
|
||||
// the date filter — no scanning every key for the rider.
|
||||
keys, err := db.Rdb.ZRangeByScore(db.Ctx, fmt.Sprintf("miler_periodic_logs:%d", profile.Userid), &redis.ZRangeBy{
|
||||
// the date filter — no scanning every key for the rider. Fetch newest-first
|
||||
// (ZRevRangeByScore) so that when the window is capped by limit we keep the
|
||||
// most recent rows, not the oldest: a rider-detail card asking ?limit=1 wants
|
||||
// the latest fix (full battery/speed/connection telemetry), and the first
|
||||
// ping of the day is an early-boot row whose fields are still blank. The
|
||||
// slice is flipped back to chronological order below for the trail/distance.
|
||||
keys, err := db.Rdb.ZRevRangeByScore(db.Ctx, fmt.Sprintf("miler_periodic_logs:%d", profile.Userid), &redis.ZRangeBy{
|
||||
Min: strconv.FormatInt(from.Unix(), 10),
|
||||
Max: strconv.FormatInt(to.Unix(), 10),
|
||||
Count: int64(limit),
|
||||
@@ -327,6 +332,13 @@ func GetMilerLogs(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Flip the newest-first fetch back to chronological (oldest→newest) so the
|
||||
// returned trail reads in ride order and the distance sum below walks
|
||||
// consecutive fixes. The latest fix is still included — it's just last now.
|
||||
for i, j := 0, len(logs)-1; i < j; i, j = i+1, j-1 {
|
||||
logs[i], logs[j] = logs[j], logs[i]
|
||||
}
|
||||
|
||||
// Trail distance, so the console can show kms actually ridden over the
|
||||
// window rather than only the per-booking figure.
|
||||
var distance float64
|
||||
|
||||
@@ -121,6 +121,12 @@ func publishAssignmentUpdate(booking *models.PickupBooking, milerUserID int) {
|
||||
"miler_id", milerUserID, "booking_id", booking.Bookingid, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Customer push, the same path auto-assign uses (cxstage → doormile_cx device
|
||||
// tokens). Manual assignment recorded the CxStageAssigned stage but sent the
|
||||
// customer nothing, so a console/hub assignment left the customer with no
|
||||
// "miler assigned" notification while auto-assign sent one.
|
||||
cxstage.Notify(booking.Bookingid, nil, constants.CxStageAssigned)
|
||||
}
|
||||
|
||||
// AssignMilerToBooking is the single source of truth for manually assigning a
|
||||
|
||||
@@ -504,9 +504,14 @@ func CxLogout(c *fiber.Ctx) error {
|
||||
|
||||
now := time.Now()
|
||||
if strings.TrimSpace(req.RefreshToken) != "" {
|
||||
db.DB.Model(&models.CustomerRefreshToken{}).
|
||||
// A failed revoke must not answer signedOut — the 60-day refresh token
|
||||
// would stay valid while the customer believes they logged out.
|
||||
if err := db.DB.Model(&models.CustomerRefreshToken{}).
|
||||
Where("tokenhash = ? AND appcustomerid = ?", hashToken(req.RefreshToken), customerID).
|
||||
Update("revokedat", now)
|
||||
Update("revokedat", now).Error; err != nil {
|
||||
utils.Error("CxLogout: could not revoke the presented token", "customer_id", customerID, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
} else {
|
||||
// No token supplied — sign out everywhere rather than leave a session
|
||||
// the customer believes they ended.
|
||||
@@ -514,8 +519,12 @@ func CxLogout(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.DeviceToken) != "" {
|
||||
db.DB.Where("appcustomerid = ? AND token = ?", customerID, req.DeviceToken).
|
||||
Delete(&models.CustomerDevice{})
|
||||
if err := db.DB.Where("appcustomerid = ? AND token = ?", customerID, req.DeviceToken).
|
||||
Delete(&models.CustomerDevice{}).Error; err != nil {
|
||||
// The session is already revoked above; a stuck device row only means a
|
||||
// stray push, so log and still report signed out rather than fail.
|
||||
utils.Warn("CxLogout: could not remove device token", "customer_id", customerID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return utils.CxOK(c, fiber.Map{"signedOut": true})
|
||||
|
||||
@@ -91,6 +91,32 @@ type cxCreateBookingRequest struct {
|
||||
Remarks string `json:"remarks"`
|
||||
}
|
||||
|
||||
// cxEstimateMatchesQuote reports whether a customer-supplied price band is close
|
||||
// enough to the server's own quote to be trusted as the agreed price. It guards
|
||||
// the stored Estimatedprice — which becomes ridercharges at completion — against
|
||||
// a tampered request body while still honouring an honest estimate that came
|
||||
// from our estimate endpoint. Rejects negatives, inverted bands, and — when the
|
||||
// server could not price the pickup (zero quote) — any client number at all,
|
||||
// since with no server figure to check against the client's would be unbounded.
|
||||
func cxEstimateMatchesQuote(clientMin, clientMax, quoteMin, quoteMax int) bool {
|
||||
if clientMin < 0 || clientMax < clientMin {
|
||||
return false
|
||||
}
|
||||
serverMid := float64(quoteMin+quoteMax) / 2
|
||||
if serverMid <= 0 {
|
||||
return false
|
||||
}
|
||||
clientMid := float64(clientMin+clientMax) / 2
|
||||
diff := clientMid - serverMid
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
// 15% of the server midpoint absorbs rounding and minor pricing drift between
|
||||
// the estimate call and confirm, without letting a materially different
|
||||
// number through.
|
||||
return diff <= 0.15*serverMid
|
||||
}
|
||||
|
||||
// CreateCxBooking creates the pickup.
|
||||
func CreateCxBooking(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
@@ -228,9 +254,22 @@ func CreateCxBooking(c *fiber.Ctx) error {
|
||||
quote := quoteCxPickup(req.Pickup.Lat, req.Pickup.Lng, estimateDestinations)
|
||||
estimateMin, estimateMax := quote.Min, quote.Max
|
||||
if req.Estimate != nil && req.Estimate.Max > 0 {
|
||||
// The customer's number wins. They agreed to what was on their screen,
|
||||
// and re-pricing at confirm time would quietly change the deal.
|
||||
estimateMin, estimateMax = req.Estimate.Min, req.Estimate.Max
|
||||
if cxEstimateMatchesQuote(req.Estimate.Min, req.Estimate.Max, quote.Min, quote.Max) {
|
||||
// The customer's number wins — but only when it agrees with what the
|
||||
// server independently prices for this pickup. An honest app took its
|
||||
// estimate from our own estimate endpoint, so it matches; re-pricing at
|
||||
// confirm time would quietly change the deal for that customer. A
|
||||
// tampered body (e.g. {min:1,max:1}) does NOT match and must never
|
||||
// stand, because this midpoint becomes Estimatedprice, which the pickup
|
||||
// and every handover leg copy verbatim into bookingassignments.ridercharges
|
||||
// — the miler's pay and the tenant's bill — with no weight re-price.
|
||||
estimateMin, estimateMax = req.Estimate.Min, req.Estimate.Max
|
||||
} else {
|
||||
utils.Warn("CreateCxBooking: client estimate rejected, pricing from server quote",
|
||||
"customer_id", customerID,
|
||||
"client_min", req.Estimate.Min, "client_max", req.Estimate.Max,
|
||||
"quote_min", quote.Min, "quote_max", quote.Max)
|
||||
}
|
||||
}
|
||||
|
||||
now := utils.DBNow()
|
||||
|
||||
@@ -561,7 +561,9 @@ func CreateInboundScan(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
// IST wall-clock (DBNow), so inwardedat matches createdat/updatedat and the
|
||||
// earnings/reconciliation windows that compare against it.
|
||||
now := utils.DBNow()
|
||||
consignment.Status = constants.ConsignmentInwardedAtHub
|
||||
consignment.Currenthubid = &hubID
|
||||
consignment.Condition = req.Condition
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
@@ -204,7 +203,11 @@ func ReconcileHubInbound(c *fiber.Ctx) error {
|
||||
return utils.NotFound(c, "consignment not found")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
// IST wall-clock (DBNow) to match the other consignment timestamps. A single
|
||||
// transaction so a failed audit insert can't leave a state change with no
|
||||
// history row and still answer 200.
|
||||
now := utils.DBNow()
|
||||
tx := db.DB.Begin()
|
||||
|
||||
if !*req.Received {
|
||||
description := strings.TrimSpace(req.Remarks)
|
||||
@@ -224,16 +227,23 @@ func ReconcileHubInbound(c *fiber.Ctx) error {
|
||||
Status: constants.ExceptionOpen,
|
||||
Createdby: staffAccountID,
|
||||
}
|
||||
if err := db.DB.Create(&exception).Error; err != nil {
|
||||
if err := tx.Create(&exception).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to raise handover exception")
|
||||
}
|
||||
db.DB.Create(&models.ConsignmentHistory{
|
||||
if err := tx.Create(&models.ConsignmentHistory{
|
||||
Consignmentid: consignment.Consignmentid,
|
||||
Hubid: &hubID,
|
||||
Eventstatus: consignment.Status,
|
||||
Remarks: fmt.Sprintf("Handover disputed at base by hub staff account %d: %s",
|
||||
staffAccountID, description),
|
||||
})
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to record dispute history")
|
||||
}
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return utils.Internal(c, "failed to record the dispute")
|
||||
}
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
"consignmentid": consignment.Consignmentid,
|
||||
@@ -258,7 +268,8 @@ func ReconcileHubInbound(c *fiber.Ctx) error {
|
||||
if consignment.Inwardedat == nil {
|
||||
consignment.Inwardedat = &now
|
||||
}
|
||||
if err := db.DB.Save(&consignment).Error; err != nil {
|
||||
if err := tx.Save(&consignment).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to record receipt")
|
||||
}
|
||||
|
||||
@@ -267,12 +278,19 @@ func ReconcileHubInbound(c *fiber.Ctx) error {
|
||||
if remarks == "" {
|
||||
remarks = "Physical receipt confirmed at base"
|
||||
}
|
||||
db.DB.Create(&models.ConsignmentHistory{
|
||||
if err := tx.Create(&models.ConsignmentHistory{
|
||||
Consignmentid: consignment.Consignmentid,
|
||||
Hubid: &hubID,
|
||||
Eventstatus: constants.ConsignmentInwardedAtHub,
|
||||
Remarks: fmt.Sprintf("%s (hub staff account %d)", remarks, staffAccountID),
|
||||
})
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to record receipt history")
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return utils.Internal(c, "failed to record the receipt")
|
||||
}
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
@@ -26,6 +25,12 @@ import (
|
||||
// the app's wording, and nothing in the app's wording should leak back in here.
|
||||
// --------------------
|
||||
|
||||
// maxHandoverBaseKM bounds how far a rider may be from a base they EXPLICITLY
|
||||
// name at handover. A rider stands at the base they hand into, so a named base
|
||||
// this far from their reported position is a wrong id (a different city), not a
|
||||
// real handover. Generous enough to never reject two bases in one metro.
|
||||
const maxHandoverBaseKM = 50.0
|
||||
|
||||
// hubHandoverEnabled gates the two-step hub flow: a hub-routed parcel stops at
|
||||
// Created — collected, in the rider's hands, on its way to a base — and only
|
||||
// reaches Inwarded_at_Hub when the handover is actually recorded, by the rider
|
||||
@@ -372,6 +377,32 @@ func MilerInwardConsignmentAtHub(c *fiber.Ctx) error {
|
||||
return utils.Fail(c, fiber.StatusNotFound, constants.ErrHubNotFound, "hub_id does not match a known base")
|
||||
}
|
||||
|
||||
lat, lon := 0.0, 0.0
|
||||
if req.Latitude != nil {
|
||||
lat = *req.Latitude
|
||||
} else if req.Lat != nil {
|
||||
lat = *req.Lat
|
||||
}
|
||||
if req.Longitude != nil {
|
||||
lon = *req.Longitude
|
||||
} else if req.Lon != nil {
|
||||
lon = *req.Lon
|
||||
}
|
||||
|
||||
// Guard a fat-fingered base id from silently rerouting the parcel to a base in
|
||||
// the wrong city. Only a base the rider EXPLICITLY names (not the routed
|
||||
// default) is checked, and only when they report their position and the base
|
||||
// has real coordinates: a rider is physically at the base they hand into, so a
|
||||
// named base far from where they stand is a wrong id, not a real handover.
|
||||
riderNamedHub := req.HubID != nil || req.HubIDAlt != nil
|
||||
routedHub := consignment.Currenthubid != nil && hub.Hubid == *consignment.Currenthubid
|
||||
if riderNamedHub && !routedHub && (lat != 0 || lon != 0) && hub.Latitude != 0 && hub.Longitude != 0 {
|
||||
if km := haversineKM(lat, lon, hub.Latitude, hub.Longitude); km > maxHandoverBaseKM {
|
||||
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState,
|
||||
fmt.Sprintf("selected base %s is %.0f km from your location — check the base before handing over", hub.Hubname, km))
|
||||
}
|
||||
}
|
||||
|
||||
// Already inwarded: answer with the state that stands rather than failing, so
|
||||
// a retry after a dropped response confirms rather than errors. This is also
|
||||
// what a rider on the compatibility flow hits every time — there,
|
||||
@@ -397,19 +428,10 @@ func MilerInwardConsignmentAtHub(c *fiber.Ctx) error {
|
||||
fmt.Sprintf("consignment is %s — it cannot be handed over at a base from this state", consignment.Status))
|
||||
}
|
||||
|
||||
lat, lon := 0.0, 0.0
|
||||
if req.Latitude != nil {
|
||||
lat = *req.Latitude
|
||||
} else if req.Lat != nil {
|
||||
lat = *req.Lat
|
||||
}
|
||||
if req.Longitude != nil {
|
||||
lon = *req.Longitude
|
||||
} else if req.Lon != nil {
|
||||
lon = *req.Lon
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
// IST wall-clock, matching createdat/updatedat and the DBNow() convention, so
|
||||
// inwardedat lines up with the other timestamps base reconciliation and the
|
||||
// earnings "today" window compare it against.
|
||||
now := utils.DBNow()
|
||||
tx := db.DB.Begin()
|
||||
|
||||
consignment.Status = constants.ConsignmentInwardedAtHub
|
||||
@@ -448,40 +470,69 @@ func MilerInwardConsignmentAtHub(c *fiber.Ctx) error {
|
||||
// multi-destination pickup, so joining on it found nothing for orders 2..N
|
||||
// — and an intercity rider handing in the second parcel of a three-stop
|
||||
// pickup had their assignment left open and their distance recorded as zero.
|
||||
// Close the rider's booking-level assignment and free them ONLY once every
|
||||
// parcel from this pickup has left their hands. A customer-app booking is one
|
||||
// booking → N destinations → N consignments but a single BookingAssignment;
|
||||
// closing on the FIRST handover freed the rider and dropped the remaining
|
||||
// stops from the sequencer while parcels 2..N were still on them, crediting
|
||||
// only the first leg. So finalize only when no consignment of this booking is
|
||||
// still in a rider-carrying state (this one is already Inwarded_at_Hub above).
|
||||
finalizeRiderLeg := true
|
||||
if _, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid); ok && bookingPtr != nil {
|
||||
booking := *bookingPtr
|
||||
dropLat, dropLon := lat, lon
|
||||
if dropLat == 0 && dropLon == 0 {
|
||||
dropLat, dropLon = hub.Latitude, hub.Longitude
|
||||
}
|
||||
riderKms := haversineKM(consignment.Pickuplatitude, consignment.Pickuplongitude, dropLat, dropLon)
|
||||
|
||||
orderAmount := 0.0
|
||||
var serviceOpt models.BookingServiceOption
|
||||
if tx.Where("bookingid = ?", booking.Bookingid).Order("createdat DESC").
|
||||
First(&serviceOpt).Error == nil {
|
||||
orderAmount = serviceOpt.Estimatedprice
|
||||
}
|
||||
|
||||
if err := tx.Model(&models.BookingAssignment{}).
|
||||
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?",
|
||||
booking.Bookingid, milerUserID,
|
||||
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
||||
Updates(map[string]interface{}{
|
||||
"assignmentstatus": constants.AssignmentCompleted,
|
||||
"completedat": now,
|
||||
"riderkms": riderKms,
|
||||
"ridercharges": orderAmount,
|
||||
}).Error; err != nil {
|
||||
var carrying int64
|
||||
if err := tx.Model(&models.Consignment{}).
|
||||
Joins("JOIN bookingdestinations bd ON bd.consignmentid = consignments.consignmentid").
|
||||
Where("bd.bookingid = ? AND consignments.status IN ?",
|
||||
booking.Bookingid,
|
||||
[]string{constants.ConsignmentCreated, constants.ConsignmentCollectedByMiler, constants.ConsignmentOutForDelivery}).
|
||||
Count(&carrying).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to close assignment")
|
||||
return utils.Internal(c, "failed to check the booking's remaining parcels")
|
||||
}
|
||||
|
||||
if carrying > 0 {
|
||||
// Rider still carries other parcels from this pickup: leave the
|
||||
// assignment open and the rider on the job. The leg is credited and the
|
||||
// rider freed at the final handover.
|
||||
finalizeRiderLeg = false
|
||||
} else {
|
||||
dropLat, dropLon := lat, lon
|
||||
if dropLat == 0 && dropLon == 0 {
|
||||
dropLat, dropLon = hub.Latitude, hub.Longitude
|
||||
}
|
||||
riderKms := haversineKM(consignment.Pickuplatitude, consignment.Pickuplongitude, dropLat, dropLon)
|
||||
|
||||
orderAmount := 0.0
|
||||
var serviceOpt models.BookingServiceOption
|
||||
if tx.Where("bookingid = ?", booking.Bookingid).Order("createdat DESC").
|
||||
First(&serviceOpt).Error == nil {
|
||||
orderAmount = serviceOpt.Estimatedprice
|
||||
}
|
||||
|
||||
if err := tx.Model(&models.BookingAssignment{}).
|
||||
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?",
|
||||
booking.Bookingid, milerUserID,
|
||||
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
||||
Updates(map[string]interface{}{
|
||||
"assignmentstatus": constants.AssignmentCompleted,
|
||||
"completedat": now,
|
||||
"riderkms": riderKms,
|
||||
"ridercharges": orderAmount,
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to close assignment")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
||||
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to update miler availability")
|
||||
if finalizeRiderLeg {
|
||||
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
||||
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to update miler availability")
|
||||
}
|
||||
}
|
||||
|
||||
// The customer's "In transit" milestone. Recorded against THIS order, not
|
||||
|
||||
@@ -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 {
|
||||
|
||||
12
dto/admin.go
12
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)
|
||||
|
||||
10
dto/auth.go
10
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"`
|
||||
|
||||
@@ -256,7 +256,9 @@ func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate,
|
||||
|
||||
publishAssignment(booking, milerUserID)
|
||||
notifyMilerNewAssignment(candidate.profile, booking.Bookingid)
|
||||
notifyCustomerMilerAssigned(booking, candidate.profile.Displayname)
|
||||
// Customer push goes through cxstage.Notify above (the doormile_cx device
|
||||
// tokens). notifyCustomerMilerAssigned targeted the older AppCustomer.Devicetoken
|
||||
// and firing both sent the customer two near-identical "miler assigned" pushes.
|
||||
|
||||
// Order the rider's stops now this one is added. No-op below two active stops;
|
||||
// best-effort and off this goroutine's critical path.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user