Files
doormile_backend/controllers/booking_assignment_service.go
Suriyakumarvijayanayagam ba2cd2299c fix: close price-tamper, premature rider-free, and IST/txn gaps in merged cx/handover work
Reviewed the 10 merged customer-app/base-handover commits and fixed the
defects found:

- HIGH (money): CreateCxBooking let the request body's `estimate` set the
  billed price with no server-side check; it flows into Estimatedprice →
  ridercharges (miler pay + tenant bill) with no weight re-price, so
  {min:1,max:1} settled a delivery at ₹1. Now the client estimate is honoured
  only when it matches the server quote within 15%, else the server quote
  stands.
- MED: base handover freed the rider and closed the booking-level assignment
  after the FIRST parcel of a multi-destination pickup, dropping the remaining
  stops and crediting one leg. Now finalized only when no consignment of the
  booking is still in the rider's hands.
- MED: inwardedat/completedat were written with time.Now() (UTC) instead of
  DBNow() (IST), skewing them ~5h30 vs createdat and the earnings/reconcile
  windows. Fixed in the handover, inbound-scan, reconcile and pickup-complete
  paths.
- MED: B2C customers got two "miler assigned" pushes on auto-assign (two token
  stores) and none on manual assign. Reconciled to one cxstage.Notify on both
  paths.
- LOW: ReconcileHubInbound now runs in a transaction and checks its audit
  inserts (was returning 200 with a silently-missing history row); CxLogout no
  longer reports signedOut when the token revoke fails; a rider-named handover
  base far from their reported position is rejected instead of silently
  rerouting the parcel to another city.

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
2026-09-16 12:16:50 +05:30

266 lines
9.6 KiB
Go

package controllers
import (
"encoding/json"
"fmt"
"time"
"doormile/constants"
"doormile/db"
"doormile/internal/cxstage"
"doormile/internal/notify"
"doormile/internal/routing"
"doormile/models"
"doormile/utils"
"gorm.io/gorm"
)
// milerStopSequence carries the road-optimized ordering the Route Optimization
// API produced for one stop. Passed to assignMilerTx when the caller already
// knows the sequence (the express-batch path), nil when it does not (a plain
// manual assignment, which leaves the stop unsequenced at step 0).
type milerStopSequence struct {
Step int
Previouskms float64
Cumulativekms float64
Etaminutes int
Cumulativeeta int
}
// assignMilerTx performs the DB half of a miler assignment inside the given
// transaction: flip the booking to Miler_Assigned, create the BookingAssignment
// (optionally already sequenced), and mark the miler Assigned. It does not
// commit, publish, or notify — those are the caller's job, so the same writes
// can be reused by the single-booking path (AssignMilerToBooking) and the
// batch express path (assignExpressStops) without duplicating the SQL or firing
// one notification per stop.
func assignMilerTx(tx *gorm.DB, bookingID, milerUserID int, assignedByUserID *int, seq *milerStopSequence) (*models.PickupBooking, error) {
var booking models.PickupBooking
if err := tx.First(&booking, bookingID).Error; err != nil {
return nil, fmt.Errorf("booking not found")
}
booking.Status = constants.BookingMilerAssigned
booking.Assignedmileruserid = &milerUserID
booking.Updatedat = time.Now()
if err := tx.Save(&booking).Error; err != nil {
return nil, fmt.Errorf("failed to update booking: %w", err)
}
assignment := models.BookingAssignment{
Bookingid: booking.Bookingid,
Mileruserid: milerUserID,
Assignedbyuserid: assignedByUserID,
Assignmentstatus: constants.AssignmentAssigned,
}
if seq != nil {
now := time.Now()
assignment.Step = seq.Step
assignment.Previouskms = seq.Previouskms
assignment.Cumulativekms = seq.Cumulativekms
assignment.Etaminutes = seq.Etaminutes
assignment.Cumulativeeta = seq.Cumulativeeta
assignment.Sequencedat = &now
}
if err := tx.Create(&assignment).Error; err != nil {
return nil, fmt.Errorf("failed to create assignment: %w", err)
}
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
Update("availabilitystatus", constants.MilerAssigned).Error; err != nil {
return nil, fmt.Errorf("failed to update miler availability: %w", err)
}
// The customer's "Miler assigned" milestone, recorded where the assignment
// is actually created rather than where a rider taps Accept. A rider who
// never opens the app would otherwise leave the customer watching "finding
// a Miler" while ops has the booking down as assigned — two surfaces
// disagreeing about the same fact.
if err := cxstage.Record(tx, cxstage.Event{
BookingID: booking.Bookingid,
Stage: constants.CxStageAssigned,
ActorType: constants.CxActorOps,
ActorID: assignedByUserID,
Source: "assignMilerTx",
}); err != nil {
return nil, fmt.Errorf("failed to record the assigned stage: %w", err)
}
return &booking, nil
}
// publishAssignmentUpdate emits the best-effort booking.update NATS event and
// pushes the FCM notification for one assignment. Shared by both assignment
// paths so the outward side effects stay identical.
func publishAssignmentUpdate(booking *models.PickupBooking, milerUserID int) {
if db.Js != nil {
payload := map[string]interface{}{
"booking_id": booking.Bookingid,
"booking_no": booking.Bookingno,
"status": booking.Status,
"miler_id": milerUserID,
"updated_at": time.Now().UnixMilli(),
}
if data, err := json.Marshal(payload); err == nil {
if _, err := db.Js.Publish("api.v1.bookings.update", data); err != nil {
utils.Warn("Failed to publish booking.update to NATS", "booking_id", booking.Bookingid, "error", err)
}
}
}
var miler models.MilerProfile
if db.DB.Where("userid = ?", milerUserID).First(&miler).Error == nil && miler.Devicetoken != "" {
if err := notify.SendToDevice(
miler.Devicetoken,
"New Pickup Assigned",
"New booking assigned — tap to view details",
map[string]string{"booking_id": fmt.Sprintf("%d", booking.Bookingid)},
); err != nil {
utils.Warn("FCM: failed to notify miler on assignment",
"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
// miler to a pickup booking — shared by the admin console (AdminAssignMiler)
// and the hub console (HubAssignMiler) so both go through identical DB
// updates, NATS publish, and FCM notify instead of duplicating the logic.
func AssignMilerToBooking(bookingID, milerUserID int, assignedByUserID *int) (*models.PickupBooking, error) {
tx := db.DB.Begin()
booking, err := assignMilerTx(tx, bookingID, milerUserID, assignedByUserID, nil)
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit().Error; err != nil {
return nil, fmt.Errorf("failed to commit miler assignment: %w", err)
}
publishAssignmentUpdate(booking, milerUserID)
// Re-sequence the rider's stops now they hold one more. No-op below two active
// stops; runs off the request path so the optimizer's network call never
// blocks or fails a manual assignment.
routing.SequenceMilerStopsAsync(milerUserID)
return booking, nil
}
// ExpressStop is one already-decided assignment from the ExpressDispatchAgent:
// which miler carries which booking, in what road-optimized order. The agent
// chose the miler and called the Route Optimization API for the sequence; this
// struct is the writeback contract.
type ExpressStop struct {
BookingID int `json:"booking_id"`
MilerUserID int `json:"miler_user_id"`
Step int `json:"step"`
Previouskms float64 `json:"previouskms"`
Cumulativekms float64 `json:"cumulativekms"`
Etaminutes int `json:"etaminutes"`
Cumulativeeta int `json:"cumulativeeta"`
}
// ExpressAssignResult is the per-booking outcome of a batch writeback.
type ExpressAssignResult struct {
BookingID int `json:"booking_id"`
MilerUserID int `json:"miler_user_id"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// assignExpressStops writes a batch of agent-decided assignments. Each stop is
// its own transaction so one bad booking id cannot roll back the whole batch —
// the same per-row-independence the bulk-create endpoint gives. Each carries its
// sequence, so the assignment lands already ordered rather than needing a second
// sequencing pass. A miler is notified once for the whole batch, not once per
// stop, so a rider handed five stops gets one push, not five.
func assignExpressStops(stops []ExpressStop) []ExpressAssignResult {
results := make([]ExpressAssignResult, 0, len(stops))
// Preserve first-seen miler order so the summary notification is deterministic.
notifyBooking := map[int]*models.PickupBooking{}
notifyOrder := []int{}
for _, s := range stops {
tx := db.DB.Begin()
booking, err := assignMilerTx(tx, s.BookingID, s.MilerUserID, nil, &milerStopSequence{
Step: s.Step,
Previouskms: s.Previouskms,
Cumulativekms: s.Cumulativekms,
Etaminutes: s.Etaminutes,
Cumulativeeta: s.Cumulativeeta,
})
if err != nil {
tx.Rollback()
results = append(results, ExpressAssignResult{
BookingID: s.BookingID, MilerUserID: s.MilerUserID, Success: false, Error: err.Error()})
continue
}
if err := tx.Commit().Error; err != nil {
results = append(results, ExpressAssignResult{
BookingID: s.BookingID, MilerUserID: s.MilerUserID, Success: false, Error: "commit failed"})
continue
}
// booking.update per stop keeps live trackers accurate; the FCM push is
// deferred and coalesced per miler below.
if db.Js != nil {
payload := map[string]interface{}{
"booking_id": booking.Bookingid,
"booking_no": booking.Bookingno,
"status": booking.Status,
"miler_id": s.MilerUserID,
"updated_at": time.Now().UnixMilli(),
}
if data, err := json.Marshal(payload); err == nil {
if _, err := db.Js.Publish("api.v1.bookings.update", data); err != nil {
utils.Warn("Failed to publish booking.update to NATS", "booking_id", booking.Bookingid, "error", err)
}
}
}
if _, seen := notifyBooking[s.MilerUserID]; !seen {
notifyOrder = append(notifyOrder, s.MilerUserID)
}
notifyBooking[s.MilerUserID] = booking
results = append(results, ExpressAssignResult{
BookingID: s.BookingID, MilerUserID: s.MilerUserID, Success: true})
}
for _, milerUserID := range notifyOrder {
count := 0
for _, r := range results {
if r.MilerUserID == milerUserID && r.Success {
count++
}
}
var miler models.MilerProfile
if db.DB.Where("userid = ?", milerUserID).First(&miler).Error == nil && miler.Devicetoken != "" {
msg := "New pickups assigned — tap to view your route"
if count == 1 {
msg = "New booking assigned — tap to view details"
}
if err := notify.SendToDevice(
miler.Devicetoken,
"New Pickups Assigned",
msg,
map[string]string{"count": fmt.Sprintf("%d", count)},
); err != nil {
utils.Warn("FCM: failed to notify miler on express batch assignment",
"miler_id", milerUserID, "error", err)
}
}
}
return results
}