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:
2026-06-25 12:31:53 +05:30
parent c577d47b75
commit c91c887726
21 changed files with 2399 additions and 75 deletions

271
internal/ws/chat.go Normal file
View 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
View 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
}