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)
|
||||
}
|
||||
}
|
||||
391
internal/assignment/customer_assignment.go
Normal file
391
internal/assignment/customer_assignment.go
Normal file
@@ -0,0 +1,391 @@
|
||||
package assignment
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
)
|
||||
|
||||
// providerResult holds the chosen logistics provider and their pricing details.
|
||||
type providerResult struct {
|
||||
company string
|
||||
estimatedPrice float64
|
||||
reliability float64
|
||||
}
|
||||
|
||||
// AssignCustomerMiler is the B2C goroutine entry point.
|
||||
// It selects both a miler (first-mile pickup) and a provider (delivery routing),
|
||||
// then commits the assignment. Retries up to maxRetries times with retryDelay in between.
|
||||
// Must be called as a goroutine after tx.Commit() in CreateCustomerBooking.
|
||||
func AssignCustomerMiler(bookingID int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
utils.Error("B2CAssignment: panic recovered", "booking_id", bookingID, "error", r)
|
||||
}
|
||||
}()
|
||||
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 1 {
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
|
||||
utils.Info("B2CAssignment: attempting assignment", "booking_id", bookingID, "attempt", attempt)
|
||||
|
||||
done, err := tryCustomerAssign(bookingID)
|
||||
if err != nil {
|
||||
utils.Error("B2CAssignment: attempt error", "booking_id", bookingID, "attempt", attempt, "error", err)
|
||||
continue
|
||||
}
|
||||
if done {
|
||||
return
|
||||
}
|
||||
|
||||
utils.Warn("B2CAssignment: no eligible miler on attempt",
|
||||
"booking_id", bookingID,
|
||||
"attempt", attempt,
|
||||
"remaining", maxRetries-attempt,
|
||||
)
|
||||
}
|
||||
|
||||
utils.Error("B2CAssignment: NO_MILER_AVAILABLE — all retries exhausted",
|
||||
"booking_id", bookingID,
|
||||
"max_retries", maxRetries,
|
||||
)
|
||||
}
|
||||
|
||||
// tryCustomerAssign performs one full attempt: GEO miler search → provider selection → commit.
|
||||
// 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.
|
||||
func tryCustomerAssign(bookingID int) (bool, error) {
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, bookingID).Error; err != nil {
|
||||
return false, fmt.Errorf("load booking: %w", err)
|
||||
}
|
||||
|
||||
if booking.Status == constants.BookingCancelled || booking.Assignedmileruserid != nil {
|
||||
utils.Info("B2CAssignment: 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)
|
||||
}
|
||||
|
||||
// Step 1 — Find the best nearby miler (shared GEO logic from crm_assignment.go).
|
||||
nearby, err := queryNearbyMilers(booking.Pickuplatitude, booking.Pickuplongitude)
|
||||
if err != nil {
|
||||
utils.Warn("B2CAssignment: GEO query failed", "booking_id", bookingID, "error", err)
|
||||
return false, nil
|
||||
}
|
||||
if len(nearby) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
miler, found := pickBestMiler(nearby)
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Step 2 — Select the best logistics provider from DoormilePricing.
|
||||
zone := b2cResolveZone(booking.Pickuppincode, booking.Deliverypincode)
|
||||
category := b2cNormalizePricingCategory(b2cFirstParcelCategory(&booking))
|
||||
serviceType := b2cFirstServiceType(&booking)
|
||||
weight := b2cChargeableWeight(&booking)
|
||||
|
||||
provider, providerFound := selectBestProvider(zone, category, serviceType, weight)
|
||||
if !providerFound {
|
||||
utils.Warn("B2CAssignment: no provider pricing match, proceeding without one",
|
||||
"booking_id", bookingID,
|
||||
"zone", zone,
|
||||
"category", category,
|
||||
)
|
||||
provider = providerResult{}
|
||||
}
|
||||
|
||||
// Step 6 — ETA based on miler-to-pickup distance.
|
||||
etaMinutes := calculateETA(miler.distanceKm)
|
||||
|
||||
// Steps 3–5 — Commit to DB and publish to NATS.
|
||||
if err := commitCustomerAssignment(&booking, miler, provider, etaMinutes); err != nil {
|
||||
return false, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ─── Provider selection ───────────────────────────────────────────────────────
|
||||
|
||||
// selectBestProvider queries all active DoormilePricing rows that cover the given
|
||||
// zone / serviceType / weight and picks the provider with the lowest composite score.
|
||||
//
|
||||
// Scoring (lower = better):
|
||||
//
|
||||
// score = avg_price - reliability_score * 2.0
|
||||
//
|
||||
// A reliability point is worth 2 rupees — price dominates for large spreads, but
|
||||
// a highly reliable provider can edge out a marginally cheaper one.
|
||||
func selectBestProvider(zone, category, serviceType string, weight float64) (providerResult, bool) {
|
||||
pricingServiceType := b2cMapServiceTypeToPricing(serviceType)
|
||||
|
||||
var rules []models.DoormilePricing
|
||||
db.DB.Where(
|
||||
"zone = ? AND servicetype = ? AND status = ? AND deletedat IS NULL"+
|
||||
" AND min_weight <= ? AND max_weight >= ?",
|
||||
zone, pricingServiceType, "Active", weight, weight,
|
||||
).Find(&rules)
|
||||
|
||||
if len(rules) == 0 {
|
||||
return providerResult{}, false
|
||||
}
|
||||
|
||||
// Prefer exact category match; fall back to General; use all rows as last resort.
|
||||
matched := b2cFilterByCategory(rules, category)
|
||||
if len(matched) == 0 && category != "General" {
|
||||
matched = b2cFilterByCategory(rules, "General")
|
||||
}
|
||||
if len(matched) == 0 {
|
||||
matched = rules
|
||||
}
|
||||
|
||||
var best *providerResult
|
||||
bestScore := math.MaxFloat64
|
||||
|
||||
for _, rule := range matched {
|
||||
avgPrice := (rule.Minprice + rule.Maxprice) / 2.0
|
||||
score := avgPrice - rule.Reliabilityscore*2.0
|
||||
|
||||
if score < bestScore {
|
||||
bestScore = score
|
||||
r := providerResult{
|
||||
company: rule.Providercompany,
|
||||
estimatedPrice: avgPrice,
|
||||
reliability: rule.Reliabilityscore,
|
||||
}
|
||||
best = &r
|
||||
}
|
||||
}
|
||||
|
||||
if best == nil {
|
||||
return providerResult{}, false
|
||||
}
|
||||
return *best, true
|
||||
}
|
||||
|
||||
// ─── ETA ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// calculateETA returns the estimated arrival time in minutes.
|
||||
// Formula: (miler_to_pickup_distance_km / 20.0) * 60 + 10-minute pickup buffer.
|
||||
func calculateETA(distanceKm float64) float64 {
|
||||
return math.Round((distanceKm/20.0)*60.0 + 10.0)
|
||||
}
|
||||
|
||||
// ─── Commit ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// commitCustomerAssignment writes the full assignment in a single DB transaction:
|
||||
// - BookingAssignment row (status: Assigned)
|
||||
// - PickupBooking: assignedmileruserid, status → Miler_Assigned, providercompany (if found)
|
||||
// - MilerProfile: availabilitystatus → Assigned
|
||||
//
|
||||
// Publishes to NATS after a successful commit.
|
||||
func commitCustomerAssignment(
|
||||
booking *models.PickupBooking,
|
||||
miler *milerCandidate,
|
||||
provider providerResult,
|
||||
etaMinutes float64,
|
||||
) error {
|
||||
milerUserID := miler.profile.Userid
|
||||
|
||||
tx := db.DB.Begin()
|
||||
|
||||
ba := models.BookingAssignment{
|
||||
Bookingid: booking.Bookingid,
|
||||
Mileruserid: milerUserID,
|
||||
Assignmentstatus: constants.AssignmentAssigned,
|
||||
Assignedat: time.Now(),
|
||||
}
|
||||
if err := tx.Create(&ba).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("create BookingAssignment: %w", err)
|
||||
}
|
||||
|
||||
bookingUpdates := map[string]interface{}{
|
||||
"assignedmileruserid": milerUserID,
|
||||
"status": constants.BookingMilerAssigned,
|
||||
"updatedat": time.Now(),
|
||||
}
|
||||
if provider.company != "" {
|
||||
bookingUpdates["providercompany"] = provider.company
|
||||
}
|
||||
|
||||
if err := tx.Model(booking).Updates(bookingUpdates).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("B2CAssignment: assigned",
|
||||
"booking_id", booking.Bookingid,
|
||||
"miler_id", milerUserID,
|
||||
"provider", provider.company,
|
||||
"estimated_price", provider.estimatedPrice,
|
||||
"eta_minutes", etaMinutes,
|
||||
"miler_distance_km", miler.distanceKm,
|
||||
)
|
||||
|
||||
publishCustomerAssignment(booking, milerUserID, provider, etaMinutes)
|
||||
notifyMilerNewAssignment(miler.profile, booking.Bookingid)
|
||||
notifyCustomerMilerAssigned(booking, miler.profile.Displayname)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishCustomerAssignment sends the booking.assigned event to NATS JetStream.
|
||||
// Non-fatal: logs a warning and returns if NATS is unavailable or publish fails.
|
||||
func publishCustomerAssignment(
|
||||
booking *models.PickupBooking,
|
||||
milerUserID int,
|
||||
provider providerResult,
|
||||
etaMinutes float64,
|
||||
) {
|
||||
if db.Js == nil {
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"booking_id": booking.Bookingid,
|
||||
"booking_no": booking.Bookingno,
|
||||
"miler_id": milerUserID,
|
||||
"selected_provider": provider.company,
|
||||
"estimated_price": provider.estimatedPrice,
|
||||
"eta_minutes": etaMinutes,
|
||||
"assigned_at": time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
utils.Warn("B2CAssignment: failed to marshal NATS payload", "booking_id", booking.Bookingid, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := db.Js.Publish("booking.assigned", data); err != nil {
|
||||
utils.Warn("B2CAssignment: NATS publish failed", "booking_id", booking.Bookingid, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Booking field extractors ─────────────────────────────────────────────────
|
||||
|
||||
func b2cFirstParcelCategory(booking *models.PickupBooking) string {
|
||||
if len(booking.Parcels) > 0 {
|
||||
return booking.Parcels[0].Itemcategory
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func b2cFirstServiceType(booking *models.PickupBooking) string {
|
||||
if len(booking.ServiceOptions) > 0 {
|
||||
return booking.ServiceOptions[0].Servicetype
|
||||
}
|
||||
return "Normal"
|
||||
}
|
||||
|
||||
// b2cChargeableWeight sums max(actual, volumetric) per parcel across the booking.
|
||||
func b2cChargeableWeight(booking *models.PickupBooking) float64 {
|
||||
var total float64
|
||||
for _, p := range booking.Parcels {
|
||||
volumetric := (p.Length * p.Width * p.Height) / 5000.0
|
||||
total += math.Max(p.Weight, volumetric)
|
||||
}
|
||||
if total == 0 {
|
||||
return 0.5 // minimum chargeable to avoid zero-weight lookup
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// ─── Zone / category helpers (self-contained; no import of controllers pkg) ──
|
||||
|
||||
func b2cPincodeToState(pincode string) string {
|
||||
if len(pincode) < 3 {
|
||||
return pincode
|
||||
}
|
||||
p, err := strconv.Atoi(pincode[:3])
|
||||
if err != nil {
|
||||
if len(pincode) >= 2 {
|
||||
return pincode[:2]
|
||||
}
|
||||
return pincode
|
||||
}
|
||||
switch {
|
||||
case p >= 500 && p <= 535:
|
||||
return "AP_TS"
|
||||
case p >= 560 && p <= 591:
|
||||
return "KA"
|
||||
case p >= 600 && p <= 643:
|
||||
return "TN"
|
||||
case p >= 670 && p <= 695:
|
||||
return "KL"
|
||||
case p >= 380 && p <= 396:
|
||||
return "GJ"
|
||||
case p >= 400 && p <= 444:
|
||||
return "MH"
|
||||
default:
|
||||
if len(pincode) >= 2 {
|
||||
return pincode[:2]
|
||||
}
|
||||
return pincode
|
||||
}
|
||||
}
|
||||
|
||||
func b2cResolveZone(pickupPincode, deliveryPincode string) string {
|
||||
if len(pickupPincode) >= 3 && len(deliveryPincode) >= 3 && pickupPincode[:3] == deliveryPincode[:3] {
|
||||
return "Local"
|
||||
}
|
||||
if b2cPincodeToState(pickupPincode) == b2cPincodeToState(deliveryPincode) {
|
||||
return "Interstate"
|
||||
}
|
||||
return "OtherState"
|
||||
}
|
||||
|
||||
func b2cNormalizePricingCategory(category string) string {
|
||||
switch category {
|
||||
case "General", "Documents", "Electronics", "Clothing", "Fragile", "Medical", "Automotive", "Food":
|
||||
return category
|
||||
default:
|
||||
return "General"
|
||||
}
|
||||
}
|
||||
|
||||
func b2cMapServiceTypeToPricing(serviceType string) string {
|
||||
if serviceType == "Fast" || serviceType == "Superfast" || serviceType == "Express" {
|
||||
return "Express"
|
||||
}
|
||||
return "Normal"
|
||||
}
|
||||
|
||||
func b2cFilterByCategory(rules []models.DoormilePricing, category string) []models.DoormilePricing {
|
||||
out := make([]models.DoormilePricing, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
if r.Category == category {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
55
internal/assignment/notify.go
Normal file
55
internal/assignment/notify.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package assignment
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"doormile/db"
|
||||
"doormile/internal/notify"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
)
|
||||
|
||||
// notifyMilerNewAssignment sends an FCM push to the miler when a booking is assigned.
|
||||
// Non-fatal: failure is logged but never blocks the assignment flow.
|
||||
func notifyMilerNewAssignment(profile models.MilerProfile, bookingID int) {
|
||||
if profile.Devicetoken == "" {
|
||||
return
|
||||
}
|
||||
if err := notify.SendToDevice(
|
||||
profile.Devicetoken,
|
||||
"New Pickup Assigned",
|
||||
"New booking assigned — tap to view details",
|
||||
map[string]string{"booking_id": strconv.Itoa(bookingID)},
|
||||
); err != nil {
|
||||
utils.Warn("FCM: failed to notify miler on assignment",
|
||||
"miler_id", profile.Userid,
|
||||
"booking_id", bookingID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// notifyCustomerMilerAssigned sends an FCM push to the customer when their miler is assigned.
|
||||
// Loads the customer's device token from DB. Non-fatal on any failure.
|
||||
func notifyCustomerMilerAssigned(booking *models.PickupBooking, milerName string) {
|
||||
var customer models.AppCustomer
|
||||
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err != nil {
|
||||
return
|
||||
}
|
||||
if customer.Devicetoken == "" {
|
||||
return
|
||||
}
|
||||
if err := notify.SendToDevice(
|
||||
customer.Devicetoken,
|
||||
"Miler Assigned",
|
||||
fmt.Sprintf("Miler assigned — %s is on the way", milerName),
|
||||
map[string]string{"booking_id": strconv.Itoa(booking.Bookingid)},
|
||||
); err != nil {
|
||||
utils.Warn("FCM: failed to notify customer on assignment",
|
||||
"customer_id", booking.Appcustomerid,
|
||||
"booking_id", booking.Bookingid,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
74
internal/notify/fcm.go
Normal file
74
internal/notify/fcm.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
firebase "firebase.google.com/go/v4"
|
||||
"firebase.google.com/go/v4/messaging"
|
||||
"doormile/utils"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
var (
|
||||
fcmClient *messaging.Client
|
||||
fcmOnce sync.Once
|
||||
)
|
||||
|
||||
// InitFCM initializes the Firebase Admin SDK using the service account JSON file
|
||||
// at FIREBASE_SERVICE_ACCOUNT_PATH. Safe to call from main() at startup.
|
||||
// If the env var is unset or the file is invalid, FCM is disabled for the process
|
||||
// lifetime — all SendToDevice calls become no-ops.
|
||||
func InitFCM() {
|
||||
fcmOnce.Do(func() {
|
||||
path := os.Getenv("FIREBASE_SERVICE_ACCOUNT_PATH")
|
||||
if path == "" {
|
||||
utils.Warn("FCM: FIREBASE_SERVICE_ACCOUNT_PATH not set — push notifications disabled")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
app, err := firebase.NewApp(ctx, nil, option.WithCredentialsFile(path))
|
||||
if err != nil {
|
||||
utils.Error("FCM: failed to initialize Firebase app", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := app.Messaging(ctx)
|
||||
if err != nil {
|
||||
utils.Error("FCM: failed to get Messaging client", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
fcmClient = client
|
||||
utils.Info("FCM: initialized successfully")
|
||||
})
|
||||
}
|
||||
|
||||
// SendToDevice sends an FCM notification to a single device token.
|
||||
// Returns nil (no-op) if FCM was not initialized or the token is empty.
|
||||
// The caller should log errors but must not block the booking flow on failure.
|
||||
func SendToDevice(token, title, body string, data map[string]string) error {
|
||||
if fcmClient == nil || token == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
msg := &messaging.Message{
|
||||
Notification: &messaging.Notification{
|
||||
Title: title,
|
||||
Body: body,
|
||||
},
|
||||
Data: data,
|
||||
Token: token,
|
||||
}
|
||||
|
||||
_, err := fcmClient.Send(ctx, msg)
|
||||
return err
|
||||
}
|
||||
271
internal/ws/chat.go
Normal file
271
internal/ws/chat.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/websocket/v2"
|
||||
)
|
||||
|
||||
// chatMessage is the JSON frame broadcast to the receiving participant.
|
||||
type chatMessage struct {
|
||||
Sender string `json:"sender"`
|
||||
Message string `json:"message"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// chatConn wraps a single WebSocket connection with a write mutex so concurrent
|
||||
// senders (broadcast from the other peer, poller close) never interleave frames.
|
||||
type chatConn struct {
|
||||
conn *websocket.Conn
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (cc *chatConn) send(data []byte) {
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
cc.conn.WriteMessage(websocket.TextMessage, data) //nolint:errcheck — best-effort delivery
|
||||
}
|
||||
|
||||
func (cc *chatConn) close() {
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
cc.conn.Close() //nolint:errcheck
|
||||
}
|
||||
|
||||
// chatRoom holds at most two named slots: "customer" and "miler".
|
||||
// All slot mutations are protected by mu.
|
||||
type chatRoom struct {
|
||||
bookingID int
|
||||
mu sync.Mutex
|
||||
slots map[string]*chatConn
|
||||
closeOnce sync.Once // ensures shutdown() body runs exactly once
|
||||
closeCh chan struct{} // closed by shutdown()
|
||||
pollerOnce sync.Once // ensures exactly one status-poller goroutine per room
|
||||
}
|
||||
|
||||
// rooms is the process-wide registry of active chat rooms, keyed by bookingID.
|
||||
var rooms sync.Map // map[int]*chatRoom
|
||||
|
||||
// ChatHandler is the WebSocket handler for the ephemeral per-booking chat room.
|
||||
//
|
||||
// Route: GET /ws/bookings/:bookingid/chat
|
||||
// Params: role=customer|miler (query)
|
||||
// token=<JWT> (query, validated by WsChatAuth middleware)
|
||||
//
|
||||
// At most two participants (one customer, one miler) may occupy a room.
|
||||
// Messages received from one participant are forwarded to the other.
|
||||
// The room closes automatically when the booking reaches a terminal status.
|
||||
func ChatHandler(c *websocket.Conn) {
|
||||
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
||||
if err != nil {
|
||||
sendError(c, "invalid booking ID")
|
||||
return
|
||||
}
|
||||
|
||||
role := c.Query("role")
|
||||
if role != "customer" && role != "miler" {
|
||||
sendError(c, "role must be 'customer' or 'miler'")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the booking exists before letting anyone into the room.
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.First(&booking, bookingID).Error; err != nil {
|
||||
sendError(c, "booking not found")
|
||||
return
|
||||
}
|
||||
if isTerminalStatus(booking.Status) {
|
||||
sendError(c, "chat is closed — booking is already completed")
|
||||
return
|
||||
}
|
||||
|
||||
room, err := joinRoom(bookingID, role, c)
|
||||
if err != nil {
|
||||
sendError(c, err.Error())
|
||||
return
|
||||
}
|
||||
defer room.leave(role)
|
||||
|
||||
utils.Info("WS/Chat: participant joined", "booking_id", bookingID, "role", role)
|
||||
|
||||
// Block here reading client messages. Exits on client disconnect or room close.
|
||||
for {
|
||||
_, raw, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
room.broadcast(role, string(raw))
|
||||
}
|
||||
|
||||
utils.Info("WS/Chat: participant left", "booking_id", bookingID, "role", role)
|
||||
}
|
||||
|
||||
// ─── Room lifecycle ───────────────────────────────────────────────────────────
|
||||
|
||||
// joinRoom finds or creates the room for bookingID, registers the connection in
|
||||
// the given role slot, and starts the booking-status poller on first join.
|
||||
// Returns an error if the slot is already occupied or the room is shutting down.
|
||||
func joinRoom(bookingID int, role string, c *websocket.Conn) (*chatRoom, error) {
|
||||
val, _ := rooms.LoadOrStore(bookingID, &chatRoom{
|
||||
bookingID: bookingID,
|
||||
slots: make(map[string]*chatConn),
|
||||
closeCh: make(chan struct{}),
|
||||
})
|
||||
room := val.(*chatRoom)
|
||||
|
||||
room.mu.Lock()
|
||||
defer room.mu.Unlock()
|
||||
|
||||
// If shutdown already started (e.g., race with a closing poller), reject.
|
||||
select {
|
||||
case <-room.closeCh:
|
||||
return nil, fmt.Errorf("chat room is closed")
|
||||
default:
|
||||
}
|
||||
|
||||
if _, exists := room.slots[role]; exists {
|
||||
return nil, fmt.Errorf("%s is already connected to this room", role)
|
||||
}
|
||||
|
||||
room.slots[role] = &chatConn{conn: c}
|
||||
|
||||
// Start the status poller the first time any participant joins.
|
||||
room.pollerOnce.Do(func() { go room.pollBookingStatus() })
|
||||
|
||||
return room, nil
|
||||
}
|
||||
|
||||
// leave removes the participant from the room and shuts the room down if empty.
|
||||
// Called via defer in ChatHandler — safe even after a force-close.
|
||||
func (r *chatRoom) leave(role string) {
|
||||
r.mu.Lock()
|
||||
delete(r.slots, role)
|
||||
isEmpty := len(r.slots) == 0
|
||||
r.mu.Unlock()
|
||||
|
||||
if isEmpty {
|
||||
r.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
// broadcast wraps the raw text in a chatMessage frame and sends it to every
|
||||
// participant other than the sender.
|
||||
func (r *chatRoom) broadcast(senderRole, text string) {
|
||||
frame := chatMessage{
|
||||
Sender: senderRole,
|
||||
Message: text,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
data, err := json.Marshal(frame)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
targets := make([]*chatConn, 0, 1)
|
||||
for role, cc := range r.slots {
|
||||
if role != senderRole {
|
||||
targets = append(targets, cc)
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
for _, cc := range targets {
|
||||
cc.send(data)
|
||||
}
|
||||
}
|
||||
|
||||
// shutdown closes the done channel and removes the room from the global registry.
|
||||
// Protected by closeOnce — safe to call from both the poller and connection handlers.
|
||||
func (r *chatRoom) shutdown() {
|
||||
r.closeOnce.Do(func() {
|
||||
close(r.closeCh)
|
||||
rooms.Delete(r.bookingID)
|
||||
utils.Info("WS/Chat: room removed from registry", "booking_id", r.bookingID)
|
||||
})
|
||||
}
|
||||
|
||||
// forceClose closes every open WebSocket connection then shuts the room down.
|
||||
// Called by the status poller when the booking reaches a terminal status.
|
||||
// Each handler's c.ReadMessage() will return an error, causing it to break
|
||||
// its read loop and call room.leave() — which hits shutdown() again (no-op).
|
||||
func (r *chatRoom) forceClose() {
|
||||
r.mu.Lock()
|
||||
conns := make([]*chatConn, 0, len(r.slots))
|
||||
for _, cc := range r.slots {
|
||||
conns = append(conns, cc)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
for _, cc := range conns {
|
||||
cc.close()
|
||||
}
|
||||
|
||||
r.shutdown()
|
||||
}
|
||||
|
||||
// ─── Status poller ────────────────────────────────────────────────────────────
|
||||
|
||||
// pollBookingStatus runs in a goroutine for the room's lifetime.
|
||||
// Every 5 s it reloads the booking; when it hits a terminal status it publishes
|
||||
// the NATS event, force-closes all connections, and exits.
|
||||
func (r *chatRoom) pollBookingStatus() {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.closeCh:
|
||||
return
|
||||
|
||||
case <-t.C:
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.First(&booking, r.bookingID).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if isTerminalStatus(booking.Status) {
|
||||
utils.Info("WS/Chat: closing room — booking reached terminal status",
|
||||
"booking_id", r.bookingID,
|
||||
"status", booking.Status,
|
||||
)
|
||||
publishChatRoomClosed(r.bookingID)
|
||||
r.forceClose()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// publishChatRoomClosed publishes a NATS event so downstream services know the
|
||||
// chat session has ended. Non-fatal — logs a warning on failure.
|
||||
func publishChatRoomClosed(bookingID int) {
|
||||
if db.Js == nil {
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"booking_id": bookingID,
|
||||
"closed_at": time.Now().UnixMilli(),
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("chat.room.closed.%d", bookingID)
|
||||
if _, err := db.Js.Publish(subject, data); err != nil {
|
||||
utils.Warn("WS/Chat: failed to publish room-closed event",
|
||||
"booking_id", bookingID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
204
internal/ws/tracking.go
Normal file
204
internal/ws/tracking.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/websocket/v2"
|
||||
)
|
||||
|
||||
// trackingFrame is the JSON payload pushed to the client every 2 seconds.
|
||||
type trackingFrame struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
EtaMinutes float64 `json:"eta_minutes"`
|
||||
Status string `json:"status"`
|
||||
MilerName string `json:"miler_name"`
|
||||
}
|
||||
|
||||
// TrackingHandler streams live miler location for a booking over WebSocket.
|
||||
//
|
||||
// Route: GET /ws/bookings/:bookingid/track (no auth — public tracking link)
|
||||
//
|
||||
// The handler:
|
||||
// 1. Validates the booking ID and loads the booking.
|
||||
// 2. Spawns a reader goroutine that closes `done` on client disconnect.
|
||||
// 3. Every 2 s: re-fetches booking status, reads miler GPS from Redis,
|
||||
// computes ETA (haversine miler→pickup, 20 km/h urban average), and
|
||||
// sends a JSON frame.
|
||||
// 4. Exits (closing the WS) when status is Picked_Up, Converted_To_Consignment,
|
||||
// or Cancelled, or when the client disconnects.
|
||||
func TrackingHandler(c *websocket.Conn) {
|
||||
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
||||
if err != nil {
|
||||
sendError(c, "invalid booking ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Initial booking load — fail fast if it doesn't exist.
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.First(&booking, bookingID).Error; err != nil {
|
||||
sendError(c, "booking not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Reader goroutine: detect client disconnect via any read error.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
if _, _, err := c.ReadMessage(); err != nil {
|
||||
close(done)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Cache miler display names to avoid redundant DB hits each tick.
|
||||
nameCache := make(map[int]string)
|
||||
milerName := func(userID int) string {
|
||||
if n, ok := nameCache[userID]; ok {
|
||||
return n
|
||||
}
|
||||
var p models.MilerProfile
|
||||
if err := db.DB.Where("userid = ?", userID).First(&p).Error; err == nil {
|
||||
nameCache[userID] = p.Displayname
|
||||
return p.Displayname
|
||||
}
|
||||
return "Miler"
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
|
||||
case <-ticker.C:
|
||||
// Re-fetch booking on every tick so status changes are reflected.
|
||||
if err := db.DB.First(&booking, bookingID).Error; err != nil {
|
||||
utils.Warn("WS/Tracking: failed to reload booking", "booking_id", bookingID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Terminal state — send one final frame then close.
|
||||
if isTerminalStatus(booking.Status) {
|
||||
frame := trackingFrame{Status: booking.Status}
|
||||
if booking.Assignedmileruserid != nil {
|
||||
frame.MilerName = milerName(*booking.Assignedmileruserid)
|
||||
}
|
||||
sendJSON(c, frame)
|
||||
return
|
||||
}
|
||||
|
||||
// No miler assigned yet — send status-only frame and keep waiting.
|
||||
if booking.Assignedmileruserid == nil {
|
||||
if err := sendJSON(c, trackingFrame{Status: booking.Status}); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
milerUserID := *booking.Assignedmileruserid
|
||||
lat, lon, gpsOk := readMilerGPS(milerUserID)
|
||||
|
||||
frame := trackingFrame{
|
||||
Status: booking.Status,
|
||||
MilerName: milerName(milerUserID),
|
||||
}
|
||||
if gpsOk {
|
||||
frame.Lat = lat
|
||||
frame.Lon = lon
|
||||
frame.EtaMinutes = haversineETA(
|
||||
lat, lon,
|
||||
booking.Pickuplatitude, booking.Pickuplongitude,
|
||||
)
|
||||
}
|
||||
|
||||
if err := sendJSON(c, frame); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func isTerminalStatus(status string) bool {
|
||||
return status == constants.BookingPickedUp ||
|
||||
status == constants.BookingConvertedConsignment ||
|
||||
status == constants.BookingCancelled
|
||||
}
|
||||
|
||||
// readMilerGPS fetches the miler's last-known position from Redis.
|
||||
// The key "miler:gps:{id}" is written by UpdateMilerLocation as "{lat},{lon}".
|
||||
func readMilerGPS(milerUserID int) (lat, lon float64, ok bool) {
|
||||
if db.Rdb == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
val, err := db.Rdb.Get(ctx, fmt.Sprintf("miler:gps:%d", milerUserID)).Result()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(strings.TrimSpace(val), ",", 2)
|
||||
if len(parts) != 2 {
|
||||
return
|
||||
}
|
||||
|
||||
lat, err = strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
lon, err = strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return lat, lon, true
|
||||
}
|
||||
|
||||
// haversineETA returns the estimated arrival time in minutes from (milerLat, milerLon)
|
||||
// to (destLat, destLon), assuming 20 km/h average urban speed plus a 2-minute buffer.
|
||||
func haversineETA(milerLat, milerLon, destLat, destLon float64) float64 {
|
||||
const earthRadiusKm = 6371.0
|
||||
dLat := (destLat - milerLat) * math.Pi / 180.0
|
||||
dLon := (destLon - milerLon) * math.Pi / 180.0
|
||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||
math.Cos(milerLat*math.Pi/180.0)*math.Cos(destLat*math.Pi/180.0)*
|
||||
math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||
distKm := earthRadiusKm * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
return math.Round((distKm/20.0)*60.0 + 2.0)
|
||||
}
|
||||
|
||||
// sendJSON marshals v and writes it as a text WebSocket frame.
|
||||
// Returns a non-nil error only when the write fails (client gone).
|
||||
func sendJSON(c *websocket.Conn, v any) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil // marshal failure is a code bug, not a client issue
|
||||
}
|
||||
return c.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
|
||||
// sendError sends a single error frame and ignores the write result
|
||||
// (the handler is about to return regardless).
|
||||
func sendError(c *websocket.Conn, msg string) {
|
||||
data, _ := json.Marshal(map[string]string{"error": msg})
|
||||
c.WriteMessage(websocket.TextMessage, data) //nolint:errcheck
|
||||
}
|
||||
Reference in New Issue
Block a user