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) // Redis key expired or not yet set — fall back to last known DB coords. if !gpsOk { var mp models.MilerProfile if db.DB.Where("userid = ?", milerUserID).First(&mp).Error == nil && (mp.Currentlatitude != 0 || mp.Currentlongitude != 0) { lat = mp.Currentlatitude lon = mp.Currentlongitude gpsOk = true } } 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 }