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

View File

@@ -2,6 +2,7 @@
APP_PORT=8081 APP_PORT=8081
ENV=development ENV=development
JWT_SECRET_KEY=DoormileSuperSecretJWTKey2026! JWT_SECRET_KEY=DoormileSuperSecretJWTKey2026!
INTERNAL_API_KEY=doormile-internal-2024
# PostgreSQL Database Configuration # PostgreSQL Database Configuration
DB_HOST=31.97.228.132 DB_HOST=31.97.228.132

View File

@@ -13,6 +13,8 @@ import (
"doormile/constants" "doormile/constants"
"doormile/db" "doormile/db"
"doormile/dto" "doormile/dto"
"doormile/internal/assignment"
"doormile/internal/notify"
"doormile/models" "doormile/models"
"doormile/utils" "doormile/utils"
@@ -1096,6 +1098,8 @@ func CreateCRMBooking(c *fiber.Ctx) error {
tx.Commit() tx.Commit()
go assignment.AssignCRMMiler(booking.Bookingid)
if db.Js != nil { if db.Js != nil {
payload := map[string]interface{}{ payload := map[string]interface{}{
"booking_id": booking.Bookingid, "booking_id": booking.Bookingid,
@@ -1946,3 +1950,143 @@ func GetAdminProfile(c *fiber.Ctx) error {
return utils.OK(c, user) return utils.OK(c, user)
} }
// InternalNotify sends FCM push notifications on behalf of the Python agent system.
// The caller specifies target = "customer", "miler", or "both".
// Auth: X-Internal-Key header (see InternalKeyAuth middleware).
//
// POST /api/v1/internal/notify
func InternalNotify(c *fiber.Ctx) error {
type req struct {
BookingID int `json:"booking_id"`
Target string `json:"target"`
Title string `json:"title"`
Message string `json:"message"`
Data map[string]string `json:"data"`
}
body := new(req)
if err := c.BodyParser(body); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if body.BookingID == 0 {
return utils.BadRequest(c, "booking_id is required")
}
if body.Target != "customer" && body.Target != "miler" && body.Target != "both" {
return utils.BadRequest(c, "target must be customer, miler, or both")
}
if body.Title == "" || body.Message == "" {
return utils.BadRequest(c, "title and message are required")
}
var booking models.PickupBooking
if err := db.DB.First(&booking, body.BookingID).Error; err != nil {
return utils.NotFound(c, "booking not found")
}
data := body.Data
if data == nil {
data = map[string]string{}
}
data["booking_id"] = strconv.Itoa(booking.Bookingid)
sent := make([]string, 0, 2)
if body.Target == "customer" || body.Target == "both" {
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
if err := notify.SendToDevice(customer.Devicetoken, body.Title, body.Message, data); err != nil {
utils.Warn("InternalNotify: failed to notify customer", "booking_id", booking.Bookingid, "error", err)
} else {
sent = append(sent, "customer")
}
}
}
if body.Target == "miler" || body.Target == "both" {
if booking.Assignedmileruserid != nil {
var profile models.MilerProfile
if err := db.DB.Where("userid = ?", *booking.Assignedmileruserid).First(&profile).Error; err == nil && profile.Devicetoken != "" {
if err := notify.SendToDevice(profile.Devicetoken, body.Title, body.Message, data); err != nil {
utils.Warn("InternalNotify: failed to notify miler", "booking_id", booking.Bookingid, "error", err)
} else {
sent = append(sent, "miler")
}
}
}
}
return utils.OK(c, fiber.Map{
"sent": true,
"targets": sent,
})
}
// InternalReassign releases the current miler assignment and re-triggers the
// auto-assignment engine for a stalled booking. Only valid when the booking is
// in Miler_Assigned or Pickup_Scheduled state.
// Auth: X-Internal-Key header (see InternalKeyAuth middleware).
//
// POST /api/v1/internal/bookings/:id/reassign
func InternalReassign(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.BadRequest(c, "invalid booking ID")
}
tx := db.DB.Begin()
var booking models.PickupBooking
if err := tx.First(&booking, id).Error; err != nil {
tx.Rollback()
return utils.NotFound(c, "booking not found")
}
if booking.Status != constants.BookingMilerAssigned && booking.Status != constants.BookingPickupScheduled {
tx.Rollback()
return utils.BadRequest(c, "booking must be in Miler_Assigned or Pickup_Scheduled state to reassign")
}
now := time.Now()
if booking.Assignedmileruserid != nil {
tx.Model(&models.MilerProfile{}).
Where("userid = ?", *booking.Assignedmileruserid).
Updates(map[string]interface{}{
"availabilitystatus": constants.MilerAvailable,
"updatedat": now,
})
tx.Delete(&models.BookingAssignment{},
"bookingid = ? AND assignmentstatus IN ?",
booking.Bookingid,
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted},
)
}
booking.Status = constants.BookingCreated
booking.Assignedmileruserid = nil
booking.Updatedat = now
if err := tx.Save(&booking).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to reset booking")
}
tx.Commit()
if booking.Bookingsource == "CRM_Console" {
go assignment.AssignCRMMiler(booking.Bookingid)
} else {
go assignment.AssignCustomerMiler(booking.Bookingid)
}
utils.Info("InternalReassign: reassignment triggered",
"booking_id", booking.Bookingid,
"booking_source", booking.Bookingsource,
)
return utils.OK(c, fiber.Map{
"reassignment": "triggered",
"booking_id": booking.Bookingid,
})
}

View File

@@ -12,6 +12,7 @@ import (
"doormile/constants" "doormile/constants"
"doormile/db" "doormile/db"
"doormile/dto" "doormile/dto"
"doormile/internal/assignment"
"doormile/models" "doormile/models"
"doormile/utils" "doormile/utils"
@@ -454,34 +455,23 @@ func CreateCustomerBooking(c *fiber.Ctx) error {
} }
} }
var distance float64
if booking.Deliverylatitude != 0 && booking.Deliverylongitude != 0 {
distance = calculateDistance(booking.Pickuplatitude, booking.Pickuplongitude, booking.Deliverylatitude, booking.Deliverylongitude)
}
var pricing models.Pricing
pricingErr := tx.Where("status = ? AND ? BETWEEN effectivefrom AND effectiveto", "Active", time.Now()).Order("priority DESC").First(&pricing).Error
var estimatedPrice float64
var pricingID *int
if pricingErr == nil {
pricingID = &pricing.Pricingid
kmExtra := math.Max(0, distance-pricing.Basedistance)
kgExtra := math.Max(0, totalWeight-pricing.Baseweight)
estimatedPrice = pricing.Baseprice + (kmExtra * pricing.Priceperkm) + (kgExtra * pricing.Priceperkg) + pricing.Handlingcharges
} else {
estimatedPrice = 50.0 + (distance * 5.0) + (totalWeight * 10.0)
}
serviceType := req.ServiceOption serviceType := req.ServiceOption
if serviceType == "" { if serviceType == "" {
serviceType = "Normal" serviceType = "Normal"
} }
if serviceType == "Fast" { zone := resolveZone(req.Pickuppincode, req.Deliverypincode)
estimatedPrice *= 1.25 itemCategory := normalizePricingCategory(req.Parcels[0].Itemcategory)
} else if serviceType == "Superfast" {
estimatedPrice *= 1.5 var estimatedPrice float64
if price, found := lookupDoormilePrice(zone, mapServiceTypeToPricing(serviceType), totalWeight, itemCategory); found {
estimatedPrice = price
} else {
var distance float64
if booking.Deliverylatitude != 0 && booking.Deliverylongitude != 0 {
distance = calculateDistance(booking.Pickuplatitude, booking.Pickuplongitude, booking.Deliverylatitude, booking.Deliverylongitude)
}
estimatedPrice = 50.0 + (distance * 5.0) + (totalWeight * 10.0)
} }
now := time.Now() now := time.Now()
@@ -501,7 +491,6 @@ func CreateCustomerBooking(c *fiber.Ctx) error {
Estimatedprice: estimatedPrice, Estimatedprice: estimatedPrice,
Estimateddeliveryat: &estDelivery, Estimateddeliveryat: &estDelivery,
Sladueat: &slaDue, Sladueat: &slaDue,
Pricingid: pricingID,
} }
if err := tx.Create(&srvOption).Error; err != nil { if err := tx.Create(&srvOption).Error; err != nil {
@@ -521,6 +510,8 @@ func CreateCustomerBooking(c *fiber.Ctx) error {
tx.Commit() tx.Commit()
go assignment.AssignCustomerMiler(booking.Bookingid)
if db.Js != nil { if db.Js != nil {
payload := map[string]interface{}{ payload := map[string]interface{}{
"booking_id": booking.Bookingid, "booking_id": booking.Bookingid,
@@ -642,3 +633,25 @@ func TrackConsignment(c *fiber.Ctx) error {
"history": history, "history": history,
}) })
} }
func SaveCustomerDeviceToken(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var req struct {
DeviceToken string `json:"device_token"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.DeviceToken == "" {
return utils.BadRequest(c, "device_token is required")
}
if err := db.DB.Model(&models.AppCustomer{}).
Where("appcustomerid = ?", customerID).
Update("device_token", req.DeviceToken).Error; err != nil {
return utils.Internal(c, "failed to save device token")
}
return utils.Message(c, "device token saved")
}

View File

@@ -13,6 +13,7 @@ import (
"doormile/constants" "doormile/constants"
"doormile/db" "doormile/db"
"doormile/dto" "doormile/dto"
"doormile/internal/notify"
"doormile/models" "doormile/models"
"doormile/utils" "doormile/utils"
@@ -336,6 +337,21 @@ func AcceptMilerAssignment(c *fiber.Ctx) error {
tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Update("availabilitystatus", constants.MilerAssigned) tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Update("availabilitystatus", constants.MilerAssigned)
tx.Commit() tx.Commit()
if booking.Bookingid != 0 {
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
if notifyErr := notify.SendToDevice(
customer.Devicetoken,
"Miler Accepted",
"Your miler has accepted and is coming",
map[string]string{"booking_id": strconv.Itoa(booking.Bookingid)},
); notifyErr != nil {
utils.Warn("FCM: failed to notify customer on accept", "booking_id", booking.Bookingid, "error", notifyErr)
}
}
}
return utils.Message(c, "assignment accepted successfully") return utils.Message(c, "assignment accepted successfully")
} }
@@ -406,30 +422,56 @@ func BookingParcelConfirm(c *fiber.Ctx) error {
} }
type ParcelUpdate struct { type ParcelUpdate struct {
ParcelID int `json:"parcel_id"`
Weight float64 `json:"weight"` Weight float64 `json:"weight"`
Length float64 `json:"length"` Length float64 `json:"length"`
Width float64 `json:"width"` Width float64 `json:"width"`
Height float64 `json:"height"` Height float64 `json:"height"`
} }
var req struct {
req := new(ParcelUpdate) Parcels []ParcelUpdate `json:"parcels"`
if err := c.BodyParser(req); err != nil { }
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body") return utils.BadRequest(c, "invalid request body")
} }
if len(req.Parcels) == 0 {
return utils.BadRequest(c, "parcels array is required")
}
var parcel models.BookingParcel var parcels []models.BookingParcel
if err := db.DB.Where("bookingid = ?", bookingID).First(&parcel).Error; err != nil { if err := db.DB.Where("bookingid = ?", bookingID).Find(&parcels).Error; err != nil {
return utils.NotFound(c, "parcel details not found") return utils.NotFound(c, "parcel details not found")
} }
parcel.Weight = req.Weight // Index loaded parcels by ID for O(1) lookup.
parcel.Length = req.Length parcelMap := make(map[int]*models.BookingParcel, len(parcels))
parcel.Width = req.Width for i := range parcels {
parcel.Height = req.Height parcelMap[parcels[i].Bookingparcelid] = &parcels[i]
parcel.Updatedat = time.Now() }
db.DB.Save(&parcel)
return utils.OK(c, parcel) now := time.Now()
var totalChargeable float64
for _, upd := range req.Parcels {
p, ok := parcelMap[upd.ParcelID]
if !ok {
continue
}
p.Weight = upd.Weight
p.Length = upd.Length
p.Width = upd.Width
p.Height = upd.Height
p.Updatedat = now
db.DB.Save(p)
volumetric := calculateVolumetricWeight(upd.Length, upd.Width, upd.Height)
totalChargeable += math.Max(upd.Weight, volumetric)
}
return utils.OK(c, fiber.Map{
"parcels": parcels,
"total_chargeable_weight": totalChargeable,
})
} }
func BookingPaymentCollect(c *fiber.Ctx) error { func BookingPaymentCollect(c *fiber.Ctx) error {
@@ -499,11 +541,28 @@ func BookingPickupComplete(c *fiber.Ctx) error {
tx.Save(&profile) tx.Save(&profile)
} }
var parcel models.BookingParcel var parcels []models.BookingParcel
tx.Where("bookingid = ?", bookingID).First(&parcel) tx.Where("bookingid = ?", bookingID).Find(&parcels)
volumetric := calculateVolumetricWeight(parcel.Length, parcel.Width, parcel.Height) var totalDead, totalChargeable, maxL, maxW, maxH float64
chargeable := math.Max(parcel.Weight, volumetric) for _, p := range parcels {
vol := calculateVolumetricWeight(p.Length, p.Width, p.Height)
totalDead += p.Weight
totalChargeable += math.Max(p.Weight, vol)
if p.Length > maxL {
maxL = p.Length
}
if p.Width > maxW {
maxW = p.Width
}
if p.Height > maxH {
maxH = p.Height
}
}
if len(parcels) == 0 {
totalDead = 0.5
totalChargeable = 0.5
}
trackingNo := generateTrackingNo() trackingNo := generateTrackingNo()
@@ -522,12 +581,12 @@ func BookingPickupComplete(c *fiber.Ctx) error {
Deliverylongitude: booking.Deliverylongitude, Deliverylongitude: booking.Deliverylongitude,
Pickuppincode: booking.Pickuppincode, Pickuppincode: booking.Pickuppincode,
Deliverypincode: booking.Deliverypincode, Deliverypincode: booking.Deliverypincode,
Length: parcel.Length, Length: maxL,
Width: parcel.Width, Width: maxW,
Height: parcel.Height, Height: maxH,
Deadweight: parcel.Weight, Deadweight: totalDead,
Volumetricweight: volumetric, Volumetricweight: totalChargeable - totalDead,
Chargeableweight: chargeable, Chargeableweight: totalChargeable,
Paymentmode: "Prepaid", Paymentmode: "Prepaid",
Status: constants.ConsignmentInwardedAtHub, Status: constants.ConsignmentInwardedAtHub,
Estimateddeliveryat: nil, Estimateddeliveryat: nil,
@@ -568,6 +627,21 @@ func BookingPickupComplete(c *fiber.Ctx) error {
tx.Commit() tx.Commit()
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
if notifyErr := notify.SendToDevice(
customer.Devicetoken,
"Parcel Picked Up",
fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo),
map[string]string{
"booking_id": strconv.Itoa(bookingID),
"tracking_no": trackingNo,
},
); notifyErr != nil {
utils.Warn("FCM: failed to notify customer on pickup", "booking_id", bookingID, "error", notifyErr)
}
}
return utils.OK(c, fiber.Map{ return utils.OK(c, fiber.Map{
"tracking_no": trackingNo, "tracking_no": trackingNo,
"consignment_id": consignment.Consignmentid, "consignment_id": consignment.Consignmentid,
@@ -881,3 +955,25 @@ func GetUserConsignmentLogs(c *fiber.Ctx) error {
return utils.List(c, logs, int64(len(logs))) return utils.List(c, logs, int64(len(logs)))
} }
func SaveMilerDeviceToken(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
var req struct {
DeviceToken string `json:"device_token"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.DeviceToken == "" {
return utils.BadRequest(c, "device_token is required")
}
if err := db.DB.Model(&models.MilerProfile{}).
Where("userid = ?", milerUserID).
Update("device_token", req.DeviceToken).Error; err != nil {
return utils.Internal(c, "failed to save device token")
}
return utils.Message(c, "device token saved")
}

View File

@@ -0,0 +1,119 @@
package controllers
import (
"context"
"encoding/json"
"strconv"
"time"
"doormile/db"
"doormile/models"
"doormile/utils"
)
// pincodeToState maps an Indian pincode to a state key using its first 3 digits.
// Covers the states Doormile operates in; other pincodes fall back to their 2-digit prefix.
func pincodeToState(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" // Andhra Pradesh / Telangana
case p >= 560 && p <= 591:
return "KA" // Karnataka
case p >= 600 && p <= 643:
return "TN" // Tamil Nadu
case p >= 670 && p <= 695:
return "KL" // Kerala
case p >= 380 && p <= 396:
return "GJ" // Gujarat
case p >= 400 && p <= 444:
return "MH" // Maharashtra
default:
if len(pincode) >= 2 {
return pincode[:2]
}
return pincode
}
}
// resolveZone determines the DoormilePricing zone from pickup and delivery pincodes.
// - Local — same 3-digit prefix (same city/sorting district)
// - Interstate — different city, same state
// - OtherState — different state
func resolveZone(pickupPincode, deliveryPincode string) string {
if len(pickupPincode) >= 3 && len(deliveryPincode) >= 3 && pickupPincode[:3] == deliveryPincode[:3] {
return "Local"
}
if pincodeToState(pickupPincode) == pincodeToState(deliveryPincode) {
return "Interstate"
}
return "OtherState"
}
// normalizePricingCategory returns a DoormilePricing-compatible category string.
// Falls back to "General" for unknown or empty values.
func normalizePricingCategory(category string) string {
switch category {
case "General", "Documents", "Electronics", "Clothing", "Fragile", "Medical", "Automotive", "Food":
return category
default:
return "General"
}
}
// mapServiceTypeToPricing maps the booking service type to a DoormilePricing service type.
// DoormilePricing has only Normal and Express.
func mapServiceTypeToPricing(serviceType string) string {
if serviceType == "Fast" || serviceType == "Superfast" {
return "Express"
}
return "Normal"
}
// lookupDoormilePrice fetches pricing for the given zone/serviceType/weight/category
// from Redis first, falling back to Postgres on a cache miss.
// Returns the midpoint of the matched price band and true, or 0 and false if no rule matches.
func lookupDoormilePrice(zone, pricingServiceType string, weight float64, category string) (float64, bool) {
var allRules []models.DoormilePricing
if db.Rdb != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
cached, err := db.Rdb.Get(ctx, pricingCacheKey(zone, pricingServiceType)).Result()
cancel()
if err == nil {
if jerr := json.Unmarshal([]byte(cached), &allRules); jerr != nil {
utils.Warn("Corrupt pricing cache in booking, evicting", "zone", zone, "servicetype", pricingServiceType)
invalidatePricingCache(zone, pricingServiceType)
allRules = nil
}
}
}
if allRules == nil {
var err error
allRules, err = loadFromPostgres(zone, pricingServiceType)
if err != nil || len(allRules) == 0 {
return 0, false
}
}
matched := applyFilters(allRules, weight, category)
if len(matched) == 0 && category != "General" {
matched = applyFilters(allRules, weight, "General")
}
if len(matched) == 0 {
return 0, false
}
rule := matched[0]
return (rule.Minprice + rule.Maxprice) / 2, true
}

61
go.mod
View File

@@ -1,6 +1,6 @@
module doormile module doormile
go 1.24.0 go 1.25.0
require ( require (
github.com/gofiber/fiber/v2 v2.52.10 github.com/gofiber/fiber/v2 v2.52.10
@@ -9,16 +9,45 @@ require (
github.com/lib/pq v1.12.3 github.com/lib/pq v1.12.3
github.com/redis/go-redis/v9 v9.16.0 github.com/redis/go-redis/v9 v9.16.0
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
golang.org/x/crypto v0.31.0 golang.org/x/crypto v0.51.0
gorm.io/driver/postgres v1.5.11 gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.12 gorm.io/gorm v1.25.12
) )
require ( require (
cel.dev/expr v0.25.2 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/firestore v1.22.0 // indirect
cloud.google.com/go/iam v1.11.0 // indirect
cloud.google.com/go/longrunning v1.0.0 // indirect
cloud.google.com/go/monitoring v1.29.0 // indirect
cloud.google.com/go/storage v1.62.1 // indirect
firebase.google.com/go/v4 v4.20.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/fasthttp/websocket v1.5.3 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gofiber/websocket/v2 v2.2.1 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect github.com/jackc/pgx/v5 v5.5.5 // indirect
@@ -32,12 +61,34 @@ require (
github.com/nats-io/nats.go v1.31.0 // indirect github.com/nats-io/nats.go v1.31.0 // indirect
github.com/nats-io/nkeys v0.4.5 // indirect github.com/nats-io/nkeys v0.4.5 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/multierr v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect
golang.org/x/sync v0.10.0 // indirect golang.org/x/net v0.54.0 // indirect
golang.org/x/sys v0.28.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.279.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
) )

141
go.sum
View File

@@ -1,3 +1,33 @@
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E=
cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU=
cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM=
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY=
cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM=
cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY=
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
cloud.google.com/go/storage v1.62.1 h1:Os0G3XbUbjZumkpDUf2Y0rLoXJTCF1kU2kWUujKYXD8=
cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
firebase.google.com/go/v4 v4.20.0 h1:ighpjeAC45rY/95cUQ+ojIKlKcTnz2YC0ldam56z2YU=
firebase.google.com/go/v4 v4.20.0/go.mod h1:hqhkQtZkThGH42TnaYi7A8EFR1E0FEuB5oHvJ1Q57t8=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 h1:O2sXMyJh8b7devAGdE+163xtRurt0RVpB6DIzX5vGfg=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 h1:0YP0+/ixwu+Uqeu/FGiBZNQ19huiUxxiPXIc9WsLKuQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@@ -6,17 +36,49 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek=
github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY= github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY=
github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas=
github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
@@ -48,6 +110,8 @@ github.com/nats-io/nkeys v0.4.5 h1:Zdz2BUlFm4fJlierwvGK+yl20IAKUm7eV6AAZXEhkPk=
github.com/nats-io/nkeys v0.4.5/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64= github.com/nats-io/nkeys v0.4.5/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4= github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
@@ -55,6 +119,10 @@ github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -66,22 +134,95 @@ github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1S
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU=
go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74=
google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 h1:rhBdfmsOlOZIvz3Y5/BdUzPg2CkO8L7QQPKj96B8554=
google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60/go.mod h1:8xo2Pj1b20ZOCpzlU3B9qieMwVIAXx1QVZWLMlPL6sM=
google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc=
google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View 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)
}
}

View 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 35 — 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
}

View 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
View 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
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
}

View File

@@ -9,6 +9,7 @@ import (
"doormile/config" "doormile/config"
"doormile/controllers" "doormile/controllers"
"doormile/db" "doormile/db"
"doormile/internal/notify"
"doormile/internal/worker" "doormile/internal/worker"
"doormile/middlewares" "doormile/middlewares"
"doormile/migrations" "doormile/migrations"
@@ -31,6 +32,7 @@ func main() {
db.Connect(cfg) db.Connect(cfg)
db.InitRedis(cfg) db.InitRedis(cfg)
db.InitNATS(cfg) db.InitNATS(cfg)
notify.InitFCM()
// 3. Run GORM migrations for logistics tables // 3. Run GORM migrations for logistics tables
if db.DB != nil { if db.DB != nil {

40
middlewares/city_gate.go Normal file
View File

@@ -0,0 +1,40 @@
package middlewares
import (
"encoding/json"
"strings"
"github.com/gofiber/fiber/v2"
)
// operatingCityPrefixes maps supported 3-digit pincode prefixes to city names.
var operatingCityPrefixes = map[string]string{
"641": "Coimbatore",
"600": "Chennai",
"560": "Bengaluru",
"500": "Hyderabad",
}
// CityGateMiddleware rejects bookings from pincodes outside Doormile's operating cities.
// It reads pickuppincode from the JSON body without consuming it, so the downstream
// controller can still call c.BodyParser() as usual.
func CityGateMiddleware(c *fiber.Ctx) error {
var body struct {
Pickuppincode string `json:"pickuppincode"`
}
if err := json.Unmarshal(c.Body(), &body); err != nil || body.Pickuppincode == "" {
return c.Next()
}
pincode := strings.TrimSpace(body.Pickuppincode)
if len(pincode) >= 3 {
if _, ok := operatingCityPrefixes[pincode[:3]]; ok {
return c.Next()
}
}
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
"error": "We are not yet operating in your city. Stay tuned!",
"code": "CITY_NOT_SUPPORTED",
})
}

View File

@@ -0,0 +1,21 @@
package middlewares
import (
"os"
"github.com/gofiber/fiber/v2"
)
// InternalKeyAuth guards machine-to-machine endpoints with a static API key.
// The key is read from INTERNAL_API_KEY env var at request time so it can be
// rotated without redeployment. Returns 401 if the header is missing or wrong.
func InternalKeyAuth(c *fiber.Ctx) error {
expected := os.Getenv("INTERNAL_API_KEY")
if expected == "" || c.Get("X-Internal-Key") != expected {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"success": false,
"message": "unauthorized",
})
}
return c.Next()
}

36
middlewares/ws_auth.go Normal file
View File

@@ -0,0 +1,36 @@
package middlewares
import (
"doormile/config"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// WsChatAuth validates a JWT passed as a ?token= query parameter.
//
// WebSocket clients (both browsers and Flutter) cannot set the Authorization
// header during the initial HTTP upgrade handshake, so the token is sent as a
// query parameter instead. Fiber locals set here are forwarded into the
// WebSocket handler via the gofiber/websocket package.
func WsChatAuth(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
token := c.Query("token")
if token == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "token query parameter is required",
})
}
claims, err := utils.ParseToken(token, cfg.JWTSecret)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "invalid or expired token",
})
}
c.Locals("userid", claims.UserID)
c.Locals("roleid", claims.RoleID)
return c.Next()
}
}

View File

@@ -16,6 +16,8 @@ type DoormilePricing struct {
Minprice float64 `json:"min_price" gorm:"column:min_price;not null"` Minprice float64 `json:"min_price" gorm:"column:min_price;not null"`
Maxprice float64 `json:"max_price" gorm:"column:max_price;not null"` Maxprice float64 `json:"max_price" gorm:"column:max_price;not null"`
Currency string `json:"currency" gorm:"column:currency;default:INR"` Currency string `json:"currency" gorm:"column:currency;default:INR"`
Providercompany string `json:"provider_company" gorm:"column:provider_company;default:Doormile"`
Reliabilityscore float64 `json:"reliability_score" gorm:"column:reliability_score;default:5.0"`
Status string `json:"status" gorm:"column:status;default:Active"` Status string `json:"status" gorm:"column:status;default:Active"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`

View File

@@ -98,6 +98,7 @@ type MilerProfile struct {
Rating float64 `json:"rating" gorm:"column:rating;default:5.00"` Rating float64 `json:"rating" gorm:"column:rating;default:5.00"`
Totalcompletedpickups int `json:"totalcompletedpickups" gorm:"column:totalcompletedpickups;default:0"` Totalcompletedpickups int `json:"totalcompletedpickups" gorm:"column:totalcompletedpickups;default:0"`
Totalcancelledpickups int `json:"totalcancelledpickups" gorm:"column:totalcancelledpickups;default:0"` Totalcancelledpickups int `json:"totalcancelledpickups" gorm:"column:totalcancelledpickups;default:0"`
Devicetoken string `json:"device_token,omitempty" gorm:"column:device_token"`
Lastlocationupdatedat *time.Time `json:"lastlocationupdatedat" gorm:"column:lastlocationupdatedat"` Lastlocationupdatedat *time.Time `json:"lastlocationupdatedat" gorm:"column:lastlocationupdatedat"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
@@ -117,6 +118,7 @@ type AppCustomer struct {
Defaultlatitude float64 `json:"defaultlatitude" gorm:"column:defaultlatitude"` Defaultlatitude float64 `json:"defaultlatitude" gorm:"column:defaultlatitude"`
Defaultlongitude float64 `json:"defaultlongitude" gorm:"column:defaultlongitude"` Defaultlongitude float64 `json:"defaultlongitude" gorm:"column:defaultlongitude"`
Defaultpincode string `json:"defaultpincode" gorm:"column:defaultpincode"` Defaultpincode string `json:"defaultpincode" gorm:"column:defaultpincode"`
Devicetoken string `json:"device_token,omitempty" gorm:"column:device_token"`
Status string `json:"status" gorm:"column:status;default:Active"` // Active, Blocked, Deleted Status string `json:"status" gorm:"column:status;default:Active"` // Active, Blocked, Deleted
Configid int `json:"configid" gorm:"column:configid;default:1"` Configid int `json:"configid" gorm:"column:configid;default:1"`
Lastloginat *time.Time `json:"lastloginat" gorm:"column:lastloginat"` Lastloginat *time.Time `json:"lastloginat" gorm:"column:lastloginat"`

View File

@@ -7,9 +7,11 @@ import (
"doormile/config" "doormile/config"
"doormile/controllers" "doormile/controllers"
"doormile/db" "doormile/db"
"doormile/internal/ws"
"doormile/middlewares" "doormile/middlewares"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/gofiber/websocket/v2"
) )
func RegisterRoutes(app *fiber.App, cfg *config.Config) { func RegisterRoutes(app *fiber.App, cfg *config.Config) {
@@ -73,7 +75,9 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
customerAuth.Put("/locations/:id", controllers.UpdateCustomerLocation) customerAuth.Put("/locations/:id", controllers.UpdateCustomerLocation)
customerAuth.Delete("/locations/:id", controllers.DeleteCustomerLocation) customerAuth.Delete("/locations/:id", controllers.DeleteCustomerLocation)
customerAuth.Post("/bookings", controllers.CreateCustomerBooking) customerAuth.Put("/device-token", controllers.SaveCustomerDeviceToken)
customerAuth.Post("/bookings", middlewares.CityGateMiddleware, controllers.CreateCustomerBooking)
customerAuth.Get("/bookings", controllers.GetCustomerBookings) customerAuth.Get("/bookings", controllers.GetCustomerBookings)
customerAuth.Get("/bookings/:bookingid", controllers.GetCustomerBookingDetails) customerAuth.Get("/bookings/:bookingid", controllers.GetCustomerBookingDetails)
customerAuth.Post("/bookings/:bookingid/cancel", controllers.CancelCustomerBooking) customerAuth.Post("/bookings/:bookingid/cancel", controllers.CancelCustomerBooking)
@@ -92,6 +96,8 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
milerAuth.Get("/profile", controllers.GetMilerProfile) milerAuth.Get("/profile", controllers.GetMilerProfile)
milerAuth.Put("/profile", controllers.UpdateMilerProfile) milerAuth.Put("/profile", controllers.UpdateMilerProfile)
milerAuth.Put("/device-token", controllers.SaveMilerDeviceToken)
milerAuth.Put("/location", controllers.UpdateMilerLocation) milerAuth.Put("/location", controllers.UpdateMilerLocation)
milerAuth.Put("/availability", controllers.UpdateMilerAvailability) milerAuth.Put("/availability", controllers.UpdateMilerAvailability)
@@ -178,7 +184,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
// Bookings // Bookings
adminAuth.Get("/bookings", controllers.GetAdminBookings) adminAuth.Get("/bookings", controllers.GetAdminBookings)
adminAuth.Post("/crmbooking", controllers.CreateCRMBooking) adminAuth.Post("/crmbooking", middlewares.CityGateMiddleware, controllers.CreateCRMBooking)
adminAuth.Get("/bookings/:id", controllers.GetAdminBookingDetails) adminAuth.Get("/bookings/:id", controllers.GetAdminBookingDetails)
adminAuth.Post("/bookings/:id/assign-miler", controllers.AdminAssignMiler) adminAuth.Post("/bookings/:id/assign-miler", controllers.AdminAssignMiler)
adminAuth.Post("/bookings/:id/assign-vehicle", controllers.AdminAssignVehicle) adminAuth.Post("/bookings/:id/assign-vehicle", controllers.AdminAssignVehicle)
@@ -261,4 +267,23 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
crm.Get("/clients/:id", controllers.GetClientDetails) crm.Get("/clients/:id", controllers.GetClientDetails)
crm.Put("/clients/:id", controllers.UpdateClient) crm.Put("/clients/:id", controllers.UpdateClient)
crm.Delete("/clients/:id", controllers.DeleteClient) crm.Delete("/clients/:id", controllers.DeleteClient)
// --------------------
// INTERNAL — machine-to-machine endpoints (API key auth, no JWT)
// --------------------
internal := api.Group("/internal", middlewares.InternalKeyAuth)
internal.Post("/notify", controllers.InternalNotify)
internal.Post("/bookings/:id/reassign", controllers.InternalReassign)
// --------------------
// WEBSOCKET — live miler tracking (no auth, public tracking link)
// --------------------
app.Use("/ws", func(c *fiber.Ctx) error {
if websocket.IsWebSocketUpgrade(c) {
return c.Next()
}
return fiber.ErrUpgradeRequired
})
app.Get("/ws/bookings/:bookingid/track", websocket.New(ws.TrackingHandler))
app.Get("/ws/bookings/:bookingid/chat", middlewares.WsChatAuth(cfg), websocket.New(ws.ChatHandler))
} }

367
seed_data.sql Normal file
View File

@@ -0,0 +1,367 @@
-- ============================================================
-- Doormile Seed Data — 4 Operating Zones
-- Idempotent: safe to re-run (uses WHERE NOT EXISTS guards)
-- ============================================================
-- ── STEP 0A: Fix sequences that drifted from explicit-ID inserts ──────────
SELECT setval('applocations_applocationid_seq',
GREATEST((SELECT COALESCE(MAX(applocationid), 0) FROM applocations), 1), true);
SELECT setval('tenants_tenantid_seq',
GREATEST((SELECT COALESCE(MAX(tenantid), 0) FROM tenants), 1), true);
SELECT setval('hubs_hubid_seq',
GREATEST((SELECT COALESCE(MAX(hubid), 0) FROM hubs), 1), true);
SELECT setval('appusers_userid_seq',
GREATEST((SELECT COALESCE(MAX(userid), 0) FROM appusers), 1), true);
SELECT setval('milerprofiles_milerprofileid_seq',
GREATEST((SELECT COALESCE(MAX(milerprofileid), 0) FROM milerprofiles), 1), true);
SELECT setval('tenantlocations_tenantlocationid_seq',
GREATEST((SELECT COALESCE(MAX(tenantlocationid), 0) FROM tenantlocations), 1), true);
-- ── STEP 0B: Add columns the Go backend expects but DB is missing ─────────
ALTER TABLE doormile_pricing
ADD COLUMN IF NOT EXISTS provider_company text DEFAULT 'Doormile';
ALTER TABLE doormile_pricing
ADD COLUMN IF NOT EXISTS reliability_score numeric DEFAULT 5.0;
ALTER TABLE milerprofiles
ADD COLUMN IF NOT EXISTS device_token text;
ALTER TABLE appcustomers
ADD COLUMN IF NOT EXISTS device_token text;
-- ══════════════════════════════════════════════════════════════
-- DATA INSERTS — all guarded with WHERE NOT EXISTS
-- ══════════════════════════════════════════════════════════════
BEGIN;
-- ── SECTION 1: APPLOCATION — Chennai ─────────────────────────────────────
INSERT INTO applocations (applocationname, status)
SELECT 'Chennai', 'Active'
WHERE NOT EXISTS (SELECT 1 FROM applocations WHERE applocationname = 'Chennai');
-- ── SECTION 2: TENANTS ───────────────────────────────────────────────────
-- Chennai operational tenant
INSERT INTO tenants (tenantname, primaryemail, primarycontact, status)
SELECT 'Doormile Chennai Logistics', 'chn-ops@doormile.com', '9876543215', 'Active'
WHERE NOT EXISTS (SELECT 1 FROM tenants WHERE tenantname = 'Doormile Chennai Logistics');
-- Provider/partner tenants (2 per zone, 8 total)
INSERT INTO tenants (tenantname, primaryemail, primarycontact, status)
SELECT 'KPM Travels', 'ops@kpmtravels.in', '9876500100', 'Active'
WHERE NOT EXISTS (SELECT 1 FROM tenants WHERE tenantname = 'KPM Travels');
INSERT INTO tenants (tenantname, primaryemail, primarycontact, status)
SELECT 'Sri Murugan Logistics', 'ops@srimuruganlogistics.in', '9876500101', 'Active'
WHERE NOT EXISTS (SELECT 1 FROM tenants WHERE tenantname = 'Sri Murugan Logistics');
INSERT INTO tenants (tenantname, primaryemail, primarycontact, status)
SELECT 'Namma Logistics', 'ops@nammalogistics.in', '9876500102', 'Active'
WHERE NOT EXISTS (SELECT 1 FROM tenants WHERE tenantname = 'Namma Logistics');
INSERT INTO tenants (tenantname, primaryemail, primarycontact, status)
SELECT 'Silicon Valley Couriers', 'ops@svccouriers.in', '9876500103', 'Active'
WHERE NOT EXISTS (SELECT 1 FROM tenants WHERE tenantname = 'Silicon Valley Couriers');
INSERT INTO tenants (tenantname, primaryemail, primarycontact, status)
SELECT 'Deccan Express', 'ops@deccanexpress.in', '9876500104', 'Active'
WHERE NOT EXISTS (SELECT 1 FROM tenants WHERE tenantname = 'Deccan Express');
INSERT INTO tenants (tenantname, primaryemail, primarycontact, status)
SELECT 'Charminar Logistics', 'ops@charminarlogs.in', '9876500105', 'Active'
WHERE NOT EXISTS (SELECT 1 FROM tenants WHERE tenantname = 'Charminar Logistics');
INSERT INTO tenants (tenantname, primaryemail, primarycontact, status)
SELECT 'Marina Logistics', 'ops@marinalogistics.in', '9876500106', 'Active'
WHERE NOT EXISTS (SELECT 1 FROM tenants WHERE tenantname = 'Marina Logistics');
INSERT INTO tenants (tenantname, primaryemail, primarycontact, status)
SELECT 'Ponni Couriers', 'ops@ponnicouriers.in', '9876500107', 'Active'
WHERE NOT EXISTS (SELECT 1 FROM tenants WHERE tenantname = 'Ponni Couriers');
-- ── SECTION 3: TENANT LOCATIONS ──────────────────────────────────────────
INSERT INTO tenantlocations (tenantid, address, city, state, pincode, latitude, longitude, isprimary, status)
SELECT t.tenantid, v.address, v.city, v.state, v.pincode, v.lat, v.lon, true, 'Active'
FROM tenants t
JOIN (VALUES
('KPM Travels', 'Gandhipuram, Coimbatore', 'Coimbatore', 'Tamil Nadu', '641012', 11.0168, 76.9558),
('Sri Murugan Logistics', 'Peelamedu, Coimbatore', 'Coimbatore', 'Tamil Nadu', '641004', 11.0301, 77.0034),
('Namma Logistics', 'Koramangala, Bengaluru', 'Bengaluru', 'Karnataka', '560034', 12.9352, 77.6244),
('Silicon Valley Couriers', 'Whitefield, Bengaluru', 'Bengaluru', 'Karnataka', '560066', 12.9698, 77.7499),
('Deccan Express', 'Gachibowli, Hyderabad', 'Hyderabad', 'Telangana', '500032', 17.4483, 78.3741),
('Charminar Logistics', 'Banjara Hills, Hyderabad', 'Hyderabad', 'Telangana', '500034', 17.4153, 78.4384),
('Marina Logistics', 'T Nagar, Chennai', 'Chennai', 'Tamil Nadu', '600017', 13.0418, 80.2341),
('Ponni Couriers', 'Velachery, Chennai', 'Chennai', 'Tamil Nadu', '600042', 12.9815, 80.2180)
) AS v(name, address, city, state, pincode, lat, lon)
ON t.tenantname = v.name
WHERE NOT EXISTS (
SELECT 1 FROM tenantlocations tl WHERE tl.tenantid = t.tenantid
);
-- ── SECTION 4: HUBS ──────────────────────────────────────────────────────
-- Coimbatore: RS Puram + Saravanampatti (existing: Gandhipuram sorting + Peelamedu delivery)
INSERT INTO hubs (hubname, hubtype, applocationid, address, latitude, longitude, pincode, status, contactno)
SELECT 'Coimbatore RS Puram Hub', 'delivery_hub', 1, 'RS Puram, Coimbatore', 11.0051, 76.9602, '641002', 'Active', '9876500200'
WHERE NOT EXISTS (SELECT 1 FROM hubs WHERE hubname = 'Coimbatore RS Puram Hub');
INSERT INTO hubs (hubname, hubtype, applocationid, address, latitude, longitude, pincode, status, contactno)
SELECT 'Coimbatore Saravanampatti Hub', 'delivery_hub', 1, 'Saravanampatti, Coimbatore', 11.0639, 77.0109, '641035', 'Active', '9876500201'
WHERE NOT EXISTS (SELECT 1 FROM hubs WHERE hubname = 'Coimbatore Saravanampatti Hub');
-- Hyderabad: Banjara Hills + Secunderabad (existing: Gachibowli sorting + Madhapur delivery)
INSERT INTO hubs (hubname, hubtype, applocationid, address, latitude, longitude, pincode, status, contactno)
SELECT 'Hyderabad Banjara Hills Hub', 'delivery_hub', 2, 'Banjara Hills, Hyderabad', 17.4153, 78.4384, '500034', 'Active', '9876500202'
WHERE NOT EXISTS (SELECT 1 FROM hubs WHERE hubname = 'Hyderabad Banjara Hills Hub');
INSERT INTO hubs (hubname, hubtype, applocationid, address, latitude, longitude, pincode, status, contactno)
SELECT 'Hyderabad Secunderabad Hub', 'delivery_hub', 2, 'Secunderabad, Hyderabad', 17.4399, 78.4983, '500003', 'Active', '9876500203'
WHERE NOT EXISTS (SELECT 1 FROM hubs WHERE hubname = 'Hyderabad Secunderabad Hub');
-- Bengaluru: Whitefield + Electronic City (existing: Koramangala sorting + Indiranagar delivery)
INSERT INTO hubs (hubname, hubtype, applocationid, address, latitude, longitude, pincode, status, contactno)
SELECT 'Bangalore Whitefield Hub', 'delivery_hub', 3, 'Whitefield, Bengaluru', 12.9698, 77.7499, '560066', 'Active', '9876500204'
WHERE NOT EXISTS (SELECT 1 FROM hubs WHERE hubname = 'Bangalore Whitefield Hub');
INSERT INTO hubs (hubname, hubtype, applocationid, address, latitude, longitude, pincode, status, contactno)
SELECT 'Bangalore Electronic City Hub', 'delivery_hub', 3, 'Electronic City, Bengaluru', 12.8399, 77.6770, '560100', 'Active', '9876500205'
WHERE NOT EXISTS (SELECT 1 FROM hubs WHERE hubname = 'Bangalore Electronic City Hub');
-- Chennai: 1 sorting_center + 3 delivery_hubs (all new)
INSERT INTO hubs (hubname, hubtype, applocationid, address, latitude, longitude, pincode, status, contactno)
SELECT 'Chennai T Nagar Hub', 'sorting_center',
(SELECT applocationid FROM applocations WHERE applocationname = 'Chennai'),
'T Nagar, Chennai', 13.0418, 80.2341, '600017', 'Active', '9876500206'
WHERE NOT EXISTS (SELECT 1 FROM hubs WHERE hubname = 'Chennai T Nagar Hub');
INSERT INTO hubs (hubname, hubtype, applocationid, address, latitude, longitude, pincode, status, contactno)
SELECT 'Chennai Anna Nagar Hub', 'delivery_hub',
(SELECT applocationid FROM applocations WHERE applocationname = 'Chennai'),
'Anna Nagar, Chennai', 13.0891, 80.2094, '600040', 'Active', '9876500207'
WHERE NOT EXISTS (SELECT 1 FROM hubs WHERE hubname = 'Chennai Anna Nagar Hub');
INSERT INTO hubs (hubname, hubtype, applocationid, address, latitude, longitude, pincode, status, contactno)
SELECT 'Chennai Velachery Hub', 'delivery_hub',
(SELECT applocationid FROM applocations WHERE applocationname = 'Chennai'),
'Velachery, Chennai', 12.9815, 80.2180, '600042', 'Active', '9876500208'
WHERE NOT EXISTS (SELECT 1 FROM hubs WHERE hubname = 'Chennai Velachery Hub');
INSERT INTO hubs (hubname, hubtype, applocationid, address, latitude, longitude, pincode, status, contactno)
SELECT 'Chennai Guindy Hub', 'delivery_hub',
(SELECT applocationid FROM applocations WHERE applocationname = 'Chennai'),
'Guindy, Chennai', 13.0067, 80.2206, '600032', 'Active', '9876500209'
WHERE NOT EXISTS (SELECT 1 FROM hubs WHERE hubname = 'Chennai Guindy Hub');
-- ── SECTION 5: MILERS ────────────────────────────────────────────────────
-- Password = Test@1234 (bcrypt hash matches existing milers in the DB)
-- Pattern: INSERT appusers WHERE phone not taken → RETURNING userid → INSERT milerprofiles
-- ── COIMBATORE — 3 new (existing: Ramesh 9876543255, Rajesh 9876543256, Karthi 9876543257) ──
WITH u AS (
INSERT INTO appusers (authname, email, contactno, password, roleid, applocationid, tenantid, configid, status)
SELECT 'Murugan Palani', 'murugan@doormile.com', '9876500001',
'$2a$10$UUfjthIGXgA3y.1tFwpB..qx40ZmmUTHTQup5NQuJxHopVdNSgV4u', 5, 1, 1, 1001, 'Active'
WHERE NOT EXISTS (SELECT 1 FROM appusers WHERE contactno = '9876500001')
RETURNING userid
)
INSERT INTO milerprofiles (userid, displayname, phone, defaultvehicletype, currentlatitude, currentlongitude, currentpincode, availabilitystatus, rating, applocationid)
SELECT u.userid, 'Murugan CBE Rider', '9876500001', 'Bike', 11.0055, 76.9608, '641002', 'Available', 5.00, 1 FROM u
WHERE NOT EXISTS (SELECT 1 FROM milerprofiles WHERE phone = '9876500001');
WITH u AS (
INSERT INTO appusers (authname, email, contactno, password, roleid, applocationid, tenantid, configid, status)
SELECT 'Selvam Raja', 'selvam@doormile.com', '9876500002',
'$2a$10$UUfjthIGXgA3y.1tFwpB..qx40ZmmUTHTQup5NQuJxHopVdNSgV4u', 5, 1, 1, 1001, 'Active'
WHERE NOT EXISTS (SELECT 1 FROM appusers WHERE contactno = '9876500002')
RETURNING userid
)
INSERT INTO milerprofiles (userid, displayname, phone, defaultvehicletype, currentlatitude, currentlongitude, currentpincode, availabilitystatus, rating, applocationid)
SELECT u.userid, 'Selvam CBE Rider', '9876500002', 'Bike', 11.0645, 77.0115, '641035', 'Available', 5.00, 1 FROM u
WHERE NOT EXISTS (SELECT 1 FROM milerprofiles WHERE phone = '9876500002');
WITH u AS (
INSERT INTO appusers (authname, email, contactno, password, roleid, applocationid, tenantid, configid, status)
SELECT 'Karthikeyan Vel', 'karthikvel@doormile.com', '9876500003',
'$2a$10$UUfjthIGXgA3y.1tFwpB..qx40ZmmUTHTQup5NQuJxHopVdNSgV4u', 5, 1, 1, 1001, 'Active'
WHERE NOT EXISTS (SELECT 1 FROM appusers WHERE contactno = '9876500003')
RETURNING userid
)
INSERT INTO milerprofiles (userid, displayname, phone, defaultvehicletype, currentlatitude, currentlongitude, currentpincode, availabilitystatus, rating, applocationid)
SELECT u.userid, 'Karthikeyan CBE Rider', '9876500003', 'Bike', 11.0042, 76.9595, '641002', 'Available', 5.00, 1 FROM u
WHERE NOT EXISTS (SELECT 1 FROM milerprofiles WHERE phone = '9876500003');
-- ── HYDERABAD — 3 new (existing: Suresh 9876543261, Ali 9876543262, Venkatesh 9876543263) ──
WITH u AS (
INSERT INTO appusers (authname, email, contactno, password, roleid, applocationid, tenantid, configid, status)
SELECT 'Prasad Reddy', 'prasad@doormile.com', '9876500004',
'$2a$10$UUfjthIGXgA3y.1tFwpB..qx40ZmmUTHTQup5NQuJxHopVdNSgV4u', 5, 2, 2, 1001, 'Active'
WHERE NOT EXISTS (SELECT 1 FROM appusers WHERE contactno = '9876500004')
RETURNING userid
)
INSERT INTO milerprofiles (userid, displayname, phone, defaultvehicletype, currentlatitude, currentlongitude, currentpincode, availabilitystatus, rating, applocationid)
SELECT u.userid, 'Prasad HYD Rider', '9876500004', 'Bike', 17.4158, 78.4390, '500034', 'Available', 5.00, 2 FROM u
WHERE NOT EXISTS (SELECT 1 FROM milerprofiles WHERE phone = '9876500004');
WITH u AS (
INSERT INTO appusers (authname, email, contactno, password, roleid, applocationid, tenantid, configid, status)
SELECT 'Naveen Kumar', 'naveen@doormile.com', '9876500005',
'$2a$10$UUfjthIGXgA3y.1tFwpB..qx40ZmmUTHTQup5NQuJxHopVdNSgV4u', 5, 2, 2, 1001, 'Active'
WHERE NOT EXISTS (SELECT 1 FROM appusers WHERE contactno = '9876500005')
RETURNING userid
)
INSERT INTO milerprofiles (userid, displayname, phone, defaultvehicletype, currentlatitude, currentlongitude, currentpincode, availabilitystatus, rating, applocationid)
SELECT u.userid, 'Naveen HYD Rider', '9876500005', 'Bike', 17.4405, 78.4990, '500003', 'Available', 5.00, 2 FROM u
WHERE NOT EXISTS (SELECT 1 FROM milerprofiles WHERE phone = '9876500005');
WITH u AS (
INSERT INTO appusers (authname, email, contactno, password, roleid, applocationid, tenantid, configid, status)
SELECT 'Ravi Shankar', 'ravishankar@doormile.com', '9876500006',
'$2a$10$UUfjthIGXgA3y.1tFwpB..qx40ZmmUTHTQup5NQuJxHopVdNSgV4u', 5, 2, 2, 1001, 'Active'
WHERE NOT EXISTS (SELECT 1 FROM appusers WHERE contactno = '9876500006')
RETURNING userid
)
INSERT INTO milerprofiles (userid, displayname, phone, defaultvehicletype, currentlatitude, currentlongitude, currentpincode, availabilitystatus, rating, applocationid)
SELECT u.userid, 'Ravi HYD Rider', '9876500006', 'Auto', 17.4162, 78.4378, '500034', 'Available', 5.00, 2 FROM u
WHERE NOT EXISTS (SELECT 1 FROM milerprofiles WHERE phone = '9876500006');
-- ── BENGALURU — 3 new (existing: Sanjay 9876543266, Anil 9876543271, Vijay 9876543272) ──
WITH u AS (
INSERT INTO appusers (authname, email, contactno, password, roleid, applocationid, tenantid, configid, status)
SELECT 'Ravi Gowda', 'ravigowda@doormile.com', '9876500007',
'$2a$10$UUfjthIGXgA3y.1tFwpB..qx40ZmmUTHTQup5NQuJxHopVdNSgV4u', 5, 3, 3, 1001, 'Active'
WHERE NOT EXISTS (SELECT 1 FROM appusers WHERE contactno = '9876500007')
RETURNING userid
)
INSERT INTO milerprofiles (userid, displayname, phone, defaultvehicletype, currentlatitude, currentlongitude, currentpincode, availabilitystatus, rating, applocationid)
SELECT u.userid, 'Ravi BLR Rider', '9876500007', 'Bike', 12.9705, 77.7508, '560066', 'Available', 5.00, 3 FROM u
WHERE NOT EXISTS (SELECT 1 FROM milerprofiles WHERE phone = '9876500007');
WITH u AS (
INSERT INTO appusers (authname, email, contactno, password, roleid, applocationid, tenantid, configid, status)
SELECT 'Suresh Naik', 'sureshnaik@doormile.com', '9876500008',
'$2a$10$UUfjthIGXgA3y.1tFwpB..qx40ZmmUTHTQup5NQuJxHopVdNSgV4u', 5, 3, 3, 1001, 'Active'
WHERE NOT EXISTS (SELECT 1 FROM appusers WHERE contactno = '9876500008')
RETURNING userid
)
INSERT INTO milerprofiles (userid, displayname, phone, defaultvehicletype, currentlatitude, currentlongitude, currentpincode, availabilitystatus, rating, applocationid)
SELECT u.userid, 'Suresh BLR Rider', '9876500008', 'Bike', 12.8405, 77.6778, '560100', 'Available', 5.00, 3 FROM u
WHERE NOT EXISTS (SELECT 1 FROM milerprofiles WHERE phone = '9876500008');
WITH u AS (
INSERT INTO appusers (authname, email, contactno, password, roleid, applocationid, tenantid, configid, status)
SELECT 'Mahesh Nair', 'maheshnair@doormile.com', '9876500009',
'$2a$10$UUfjthIGXgA3y.1tFwpB..qx40ZmmUTHTQup5NQuJxHopVdNSgV4u', 5, 3, 3, 1001, 'Active'
WHERE NOT EXISTS (SELECT 1 FROM appusers WHERE contactno = '9876500009')
RETURNING userid
)
INSERT INTO milerprofiles (userid, displayname, phone, defaultvehicletype, currentlatitude, currentlongitude, currentpincode, availabilitystatus, rating, applocationid)
SELECT u.userid, 'Mahesh BLR Rider', '9876500009', 'Bike', 12.9712, 77.7495, '560066', 'Available', 5.00, 3 FROM u
WHERE NOT EXISTS (SELECT 1 FROM milerprofiles WHERE phone = '9876500009');
-- ── CHENNAI — 6 new milers (all new city) ────────────────────────────────
WITH u AS (
INSERT INTO appusers (authname, email, contactno, password, roleid, applocationid, tenantid, configid, status)
SELECT 'Prakash Kumar', 'prakash@doormile.com', '9876500010',
'$2a$10$UUfjthIGXgA3y.1tFwpB..qx40ZmmUTHTQup5NQuJxHopVdNSgV4u', 5,
(SELECT applocationid FROM applocations WHERE applocationname='Chennai'),
(SELECT tenantid FROM tenants WHERE tenantname='Doormile Chennai Logistics'),
1001, 'Active'
WHERE NOT EXISTS (SELECT 1 FROM appusers WHERE contactno = '9876500010')
RETURNING userid
)
INSERT INTO milerprofiles (userid, displayname, phone, defaultvehicletype, currentlatitude, currentlongitude, currentpincode, availabilitystatus, rating, applocationid)
SELECT u.userid, 'Prakash CHN Rider', '9876500010', 'Bike', 13.0422, 80.2348, '600017', 'Available', 5.00,
(SELECT applocationid FROM applocations WHERE applocationname='Chennai')
FROM u
WHERE NOT EXISTS (SELECT 1 FROM milerprofiles WHERE phone = '9876500010');
WITH u AS (
INSERT INTO appusers (authname, email, contactno, password, roleid, applocationid, tenantid, configid, status)
SELECT 'Senthil Nathan', 'senthil@doormile.com', '9876500011',
'$2a$10$UUfjthIGXgA3y.1tFwpB..qx40ZmmUTHTQup5NQuJxHopVdNSgV4u', 5,
(SELECT applocationid FROM applocations WHERE applocationname='Chennai'),
(SELECT tenantid FROM tenants WHERE tenantname='Doormile Chennai Logistics'),
1001, 'Active'
WHERE NOT EXISTS (SELECT 1 FROM appusers WHERE contactno = '9876500011')
RETURNING userid
)
INSERT INTO milerprofiles (userid, displayname, phone, defaultvehicletype, currentlatitude, currentlongitude, currentpincode, availabilitystatus, rating, applocationid)
SELECT u.userid, 'Senthil CHN Rider', '9876500011', 'Bike', 13.0898, 80.2101, '600040', 'Available', 5.00,
(SELECT applocationid FROM applocations WHERE applocationname='Chennai')
FROM u
WHERE NOT EXISTS (SELECT 1 FROM milerprofiles WHERE phone = '9876500011');
WITH u AS (
INSERT INTO appusers (authname, email, contactno, password, roleid, applocationid, tenantid, configid, status)
SELECT 'Mani Raja', 'maniraja@doormile.com', '9876500012',
'$2a$10$UUfjthIGXgA3y.1tFwpB..qx40ZmmUTHTQup5NQuJxHopVdNSgV4u', 5,
(SELECT applocationid FROM applocations WHERE applocationname='Chennai'),
(SELECT tenantid FROM tenants WHERE tenantname='Doormile Chennai Logistics'),
1001, 'Active'
WHERE NOT EXISTS (SELECT 1 FROM appusers WHERE contactno = '9876500012')
RETURNING userid
)
INSERT INTO milerprofiles (userid, displayname, phone, defaultvehicletype, currentlatitude, currentlongitude, currentpincode, availabilitystatus, rating, applocationid)
SELECT u.userid, 'Mani CHN Rider', '9876500012', 'Bike', 12.9820, 80.2185, '600042', 'Available', 5.00,
(SELECT applocationid FROM applocations WHERE applocationname='Chennai')
FROM u
WHERE NOT EXISTS (SELECT 1 FROM milerprofiles WHERE phone = '9876500012');
WITH u AS (
INSERT INTO appusers (authname, email, contactno, password, roleid, applocationid, tenantid, configid, status)
SELECT 'Deepak Shankar', 'deepak@doormile.com', '9876500013',
'$2a$10$UUfjthIGXgA3y.1tFwpB..qx40ZmmUTHTQup5NQuJxHopVdNSgV4u', 5,
(SELECT applocationid FROM applocations WHERE applocationname='Chennai'),
(SELECT tenantid FROM tenants WHERE tenantname='Doormile Chennai Logistics'),
1001, 'Active'
WHERE NOT EXISTS (SELECT 1 FROM appusers WHERE contactno = '9876500013')
RETURNING userid
)
INSERT INTO milerprofiles (userid, displayname, phone, defaultvehicletype, currentlatitude, currentlongitude, currentpincode, availabilitystatus, rating, applocationid)
SELECT u.userid, 'Deepak CHN Rider', '9876500013', 'Auto', 13.0072, 80.2210, '600032', 'Available', 5.00,
(SELECT applocationid FROM applocations WHERE applocationname='Chennai')
FROM u
WHERE NOT EXISTS (SELECT 1 FROM milerprofiles WHERE phone = '9876500013');
WITH u AS (
INSERT INTO appusers (authname, email, contactno, password, roleid, applocationid, tenantid, configid, status)
SELECT 'Kiran Vel', 'kiranvel@doormile.com', '9876500014',
'$2a$10$UUfjthIGXgA3y.1tFwpB..qx40ZmmUTHTQup5NQuJxHopVdNSgV4u', 5,
(SELECT applocationid FROM applocations WHERE applocationname='Chennai'),
(SELECT tenantid FROM tenants WHERE tenantname='Doormile Chennai Logistics'),
1001, 'Active'
WHERE NOT EXISTS (SELECT 1 FROM appusers WHERE contactno = '9876500014')
RETURNING userid
)
INSERT INTO milerprofiles (userid, displayname, phone, defaultvehicletype, currentlatitude, currentlongitude, currentpincode, availabilitystatus, rating, applocationid)
SELECT u.userid, 'Kiran CHN Rider', '9876500014', 'Bike', 13.0430, 80.2352, '600017', 'Available', 5.00,
(SELECT applocationid FROM applocations WHERE applocationname='Chennai')
FROM u
WHERE NOT EXISTS (SELECT 1 FROM milerprofiles WHERE phone = '9876500014');
WITH u AS (
INSERT INTO appusers (authname, email, contactno, password, roleid, applocationid, tenantid, configid, status)
SELECT 'Arjun Raj', 'arjunraj@doormile.com', '9876500015',
'$2a$10$UUfjthIGXgA3y.1tFwpB..qx40ZmmUTHTQup5NQuJxHopVdNSgV4u', 5,
(SELECT applocationid FROM applocations WHERE applocationname='Chennai'),
(SELECT tenantid FROM tenants WHERE tenantname='Doormile Chennai Logistics'),
1001, 'Active'
WHERE NOT EXISTS (SELECT 1 FROM appusers WHERE contactno = '9876500015')
RETURNING userid
)
INSERT INTO milerprofiles (userid, displayname, phone, defaultvehicletype, currentlatitude, currentlongitude, currentpincode, availabilitystatus, rating, applocationid)
SELECT u.userid, 'Arjun CHN Rider', '9876500015', 'Bike', 13.0902, 80.2105, '600040', 'Available', 5.00,
(SELECT applocationid FROM applocations WHERE applocationname='Chennai')
FROM u
WHERE NOT EXISTS (SELECT 1 FROM milerprofiles WHERE phone = '9876500015');
COMMIT;