Files
doormile_backend/controllers/booking_assignment_service.go
Suriyakumarvijayanayagam f09efcaf59 feat: express-batch dispatch — manual trigger for the AI agent
Adds the backend half of the ExpressDispatchAgent flow. Express orders can now
be created batch by batch (bulk create only accumulates them, unassigned), then
an operator hits one endpoint to hand the whole pending set to the agent for
tenant-scoped assignment + road sequencing. The normal B2C flow is untouched.

- POST /admin/expressbooking/dispatch: manual trigger. Console-auth, tenant-
  scoped; gathers the tenant's pending unassigned express orders (or a chosen
  subset) and publishes express.dispatch_requested.
- internal API for the agent: GET /internal/express/riders (tenant's available
  riders), GET /internal/express/bookings, POST /internal/express/assign (writes
  the agent's decided assignments with their sequence; re-checks the already-
  assigned guard so the agent can't double-assign).
- booking_assignment_service.go: extracted a behavior-preserving assignMilerTx
  core; AssignMilerToBooking is unchanged in behavior. assignExpressStops writes
  a batch, one FCM per rider instead of one per stop.
- EXPRESS JetStream stream / express.dispatch_requested subject.
- Gated behind EXPRESS_AGENT_ENABLED (default off): deploying this changes
  nothing until the agent is confirmed running and the flag is flipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 19:33:48 +05:30

237 lines
8.3 KiB
Go

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
}