Add assignment engine, FCM, WebSockets, city gate, and internal APIs
- internal/assignment: GEORADIUS miler assignment with retry/escalation, customer-side provider scoring, FCM notifications on assign - internal/notify: Firebase Admin SDK (FCM) client initialisation - internal/ws: WebSocket handlers for live parcel tracking and customer↔miler chat - middlewares: city gate (pincode prefix validation), internal API key auth, WebSocket JWT auth - controllers: InternalNotify + InternalReassign for machine-to-machine calls; pricing helpers wired into CreateCustomerBooking and CreateCRMBooking - routes: /internal/*, /ws/bookings/:id/track, /ws/bookings/:id/chat - models/users, models/doormile_pricing: new fields for device tokens, assignment state, pricing bands - seed_data.sql: initial pricing seed rows .env and Firebase service-account JSON intentionally excluded. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
269
internal/assignment/crm_assignment.go
Normal file
269
internal/assignment/crm_assignment.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package assignment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
maxRetries = 5
|
||||
retryDelay = 2 * time.Minute
|
||||
geoRadiusKm = 10.0
|
||||
geoMaxCount = 10
|
||||
maxActive = 3
|
||||
)
|
||||
|
||||
type milerCandidate struct {
|
||||
profile models.MilerProfile
|
||||
distanceKm float64
|
||||
activeBookings int64
|
||||
}
|
||||
|
||||
// AssignCRMMiler finds the best available nearby miler for a CRM booking and assigns them.
|
||||
// It retries up to maxRetries times (retryDelay apart) before logging NO_MILER_AVAILABLE.
|
||||
// Must be called as a goroutine after tx.Commit() in CreateCRMBooking.
|
||||
func AssignCRMMiler(bookingID int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
utils.Error("CRMAssignment: panic recovered", "booking_id", bookingID, "error", r)
|
||||
}
|
||||
}()
|
||||
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 1 {
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
|
||||
utils.Info("CRMAssignment: attempting assignment", "booking_id", bookingID, "attempt", attempt)
|
||||
|
||||
done, err := tryAssign(bookingID)
|
||||
if err != nil {
|
||||
utils.Error("CRMAssignment: attempt error", "booking_id", bookingID, "attempt", attempt, "error", err)
|
||||
continue
|
||||
}
|
||||
if done {
|
||||
return
|
||||
}
|
||||
|
||||
utils.Warn("CRMAssignment: no eligible miler found on attempt",
|
||||
"booking_id", bookingID,
|
||||
"attempt", attempt,
|
||||
"remaining", maxRetries-attempt,
|
||||
)
|
||||
}
|
||||
|
||||
utils.Error("CRMAssignment: NO_MILER_AVAILABLE — all retries exhausted",
|
||||
"booking_id", bookingID,
|
||||
"max_retries", maxRetries,
|
||||
)
|
||||
}
|
||||
|
||||
// tryAssign performs a single attempt: queries Redis GEO, scores candidates, commits.
|
||||
// Returns (true, nil) on success or when the booking no longer needs assignment.
|
||||
// Returns (false, nil) when no eligible miler was found (retry warranted).
|
||||
// Returns (false, err) on hard errors (booking missing, DB failure).
|
||||
func tryAssign(bookingID int) (bool, error) {
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.First(&booking, bookingID).Error; err != nil {
|
||||
return false, fmt.Errorf("load booking: %w", err)
|
||||
}
|
||||
|
||||
// If the booking was cancelled or already assigned between retries, stop.
|
||||
if booking.Status == constants.BookingCancelled || booking.Assignedmileruserid != nil {
|
||||
utils.Info("CRMAssignment: booking no longer needs assignment",
|
||||
"booking_id", bookingID,
|
||||
"status", booking.Status,
|
||||
)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if booking.Pickuplatitude == 0 || booking.Pickuplongitude == 0 {
|
||||
return false, fmt.Errorf("booking %d has no pickup coordinates", bookingID)
|
||||
}
|
||||
|
||||
nearby, err := queryNearbyMilers(booking.Pickuplatitude, booking.Pickuplongitude)
|
||||
if err != nil {
|
||||
utils.Warn("CRMAssignment: GEO query failed", "booking_id", bookingID, "error", err)
|
||||
return false, nil
|
||||
}
|
||||
if len(nearby) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
candidate, found := pickBestMiler(nearby)
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := commitAssignment(&booking, candidate); err != nil {
|
||||
return false, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// queryNearbyMilers runs GEOSEARCH on milers:locations and returns up to geoMaxCount
|
||||
// milers within geoRadiusKm km, sorted nearest-first, with distances populated.
|
||||
func queryNearbyMilers(lat, lon float64) ([]redis.GeoLocation, error) {
|
||||
if db.Rdb == nil {
|
||||
return nil, fmt.Errorf("Redis not available")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
locs, err := db.Rdb.GeoSearchLocation(ctx, "milers:locations", &redis.GeoSearchLocationQuery{
|
||||
GeoSearchQuery: redis.GeoSearchQuery{
|
||||
Longitude: lon,
|
||||
Latitude: lat,
|
||||
Radius: geoRadiusKm,
|
||||
RadiusUnit: "km",
|
||||
Sort: "ASC",
|
||||
Count: geoMaxCount,
|
||||
},
|
||||
WithDist: true,
|
||||
}).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return locs, nil
|
||||
}
|
||||
|
||||
// pickBestMiler filters the nearby list for eligibility, then returns the candidate
|
||||
// with the lowest score. score = distance_km*1.0 + active_bookings*2.0 - rating*0.5
|
||||
func pickBestMiler(nearby []redis.GeoLocation) (*milerCandidate, bool) {
|
||||
var best *milerCandidate
|
||||
bestScore := 1e18
|
||||
|
||||
for _, loc := range nearby {
|
||||
milerUserID, err := strconv.Atoi(loc.Name)
|
||||
if err != nil {
|
||||
utils.Warn("CRMAssignment: skipping non-numeric GEO member", "name", loc.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
var profile models.MilerProfile
|
||||
if err := db.DB.Where("userid = ?", milerUserID).First(&profile).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if profile.Availabilitystatus != constants.MilerAvailable {
|
||||
continue
|
||||
}
|
||||
|
||||
var activeCount int64
|
||||
db.DB.Model(&models.BookingAssignment{}).
|
||||
Where("mileruserid = ? AND assignmentstatus IN ?", milerUserID, []string{
|
||||
constants.AssignmentAssigned,
|
||||
constants.AssignmentAccepted,
|
||||
}).
|
||||
Count(&activeCount)
|
||||
|
||||
if activeCount >= maxActive {
|
||||
continue
|
||||
}
|
||||
|
||||
score := loc.Dist*1.0 + float64(activeCount)*2.0 - profile.Rating*0.5
|
||||
|
||||
if score < bestScore {
|
||||
bestScore = score
|
||||
best = &milerCandidate{
|
||||
profile: profile,
|
||||
distanceKm: loc.Dist,
|
||||
activeBookings: activeCount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best, best != nil
|
||||
}
|
||||
|
||||
// commitAssignment writes the BookingAssignment row, updates the booking and the
|
||||
// miler's availability status in a single transaction, then publishes to NATS.
|
||||
func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate) error {
|
||||
milerUserID := candidate.profile.Userid
|
||||
|
||||
tx := db.DB.Begin()
|
||||
|
||||
assignment := models.BookingAssignment{
|
||||
Bookingid: booking.Bookingid,
|
||||
Mileruserid: milerUserID,
|
||||
Assignmentstatus: constants.AssignmentAssigned,
|
||||
Assignedat: time.Now(),
|
||||
}
|
||||
if err := tx.Create(&assignment).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("create BookingAssignment: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := tx.Model(booking).Updates(map[string]interface{}{
|
||||
"assignedmileruserid": milerUserID,
|
||||
"status": constants.BookingMilerAssigned,
|
||||
"updatedat": now,
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("update PickupBooking: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Model(&models.MilerProfile{}).
|
||||
Where("userid = ?", milerUserID).
|
||||
Update("availabilitystatus", constants.MilerAssigned).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("update MilerProfile availability: %w", err)
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
utils.Info("CRMAssignment: assigned",
|
||||
"booking_id", booking.Bookingid,
|
||||
"miler_id", milerUserID,
|
||||
"distance_km", candidate.distanceKm,
|
||||
"active_bookings", candidate.activeBookings,
|
||||
"score", candidate.distanceKm*1.0+float64(candidate.activeBookings)*2.0-candidate.profile.Rating*0.5,
|
||||
)
|
||||
|
||||
publishAssignment(booking, milerUserID)
|
||||
notifyMilerNewAssignment(candidate.profile, booking.Bookingid)
|
||||
notifyCustomerMilerAssigned(booking, candidate.profile.Displayname)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishAssignment sends the booking.assigned event to NATS JetStream.
|
||||
// Non-fatal: logs a warning and returns if NATS is unavailable or publish fails.
|
||||
func publishAssignment(booking *models.PickupBooking, milerUserID int) {
|
||||
if db.Js == nil {
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"booking_id": booking.Bookingid,
|
||||
"booking_no": booking.Bookingno,
|
||||
"miler_id": milerUserID,
|
||||
"provider_company": booking.Providercompany,
|
||||
"provider_hub": booking.Providerlocation,
|
||||
"assigned_at": time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
utils.Warn("CRMAssignment: failed to marshal NATS payload", "booking_id", booking.Bookingid, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := db.Js.Publish("booking.assigned", data); err != nil {
|
||||
utils.Warn("CRMAssignment: NATS publish failed", "booking_id", booking.Bookingid, "error", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user