package controllers import ( "encoding/json" "fmt" "time" "doormile/constants" "doormile/db" "doormile/internal/notify" "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) } 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) } } } // 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) 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 }