- 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>
205 lines
5.7 KiB
Go
205 lines
5.7 KiB
Go
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
|
|
}
|