From c91c887726f3816000bef8721731e452aa5a60eb Mon Sep 17 00:00:00 2001 From: Suriya Date: Thu, 25 Jun 2026 12:31:53 +0530 Subject: [PATCH] Add assignment engine, FCM, WebSockets, city gate, and internal APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 1 + controllers/adminController.go | 144 ++++++++ controllers/customerController.go | 61 ++-- controllers/milerController.go | 148 ++++++-- controllers/pricing_helpers.go | 119 +++++++ go.mod | 61 +++- go.sum | 141 ++++++++ internal/assignment/crm_assignment.go | 269 ++++++++++++++ internal/assignment/customer_assignment.go | 391 +++++++++++++++++++++ internal/assignment/notify.go | 55 +++ internal/notify/fcm.go | 74 ++++ internal/ws/chat.go | 271 ++++++++++++++ internal/ws/tracking.go | 204 +++++++++++ main.go | 2 + middlewares/city_gate.go | 40 +++ middlewares/internal_auth.go | 21 ++ middlewares/ws_auth.go | 36 ++ models/doormile_pricing.go | 2 + models/users.go | 38 +- routes/routes.go | 29 +- seed_data.sql | 367 +++++++++++++++++++ 21 files changed, 2399 insertions(+), 75 deletions(-) create mode 100644 controllers/pricing_helpers.go create mode 100644 internal/assignment/crm_assignment.go create mode 100644 internal/assignment/customer_assignment.go create mode 100644 internal/assignment/notify.go create mode 100644 internal/notify/fcm.go create mode 100644 internal/ws/chat.go create mode 100644 internal/ws/tracking.go create mode 100644 middlewares/city_gate.go create mode 100644 middlewares/internal_auth.go create mode 100644 middlewares/ws_auth.go create mode 100644 seed_data.sql diff --git a/.env.example b/.env.example index 2169959..aac0e16 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ APP_PORT=8081 ENV=development JWT_SECRET_KEY=DoormileSuperSecretJWTKey2026! +INTERNAL_API_KEY=doormile-internal-2024 # PostgreSQL Database Configuration DB_HOST=31.97.228.132 diff --git a/controllers/adminController.go b/controllers/adminController.go index 47e8915..d8bc58b 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -13,6 +13,8 @@ import ( "doormile/constants" "doormile/db" "doormile/dto" + "doormile/internal/assignment" + "doormile/internal/notify" "doormile/models" "doormile/utils" @@ -1096,6 +1098,8 @@ func CreateCRMBooking(c *fiber.Ctx) error { tx.Commit() + go assignment.AssignCRMMiler(booking.Bookingid) + if db.Js != nil { payload := map[string]interface{}{ "booking_id": booking.Bookingid, @@ -1946,3 +1950,143 @@ func GetAdminProfile(c *fiber.Ctx) error { 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, + }) +} diff --git a/controllers/customerController.go b/controllers/customerController.go index 0a2e34a..2292970 100644 --- a/controllers/customerController.go +++ b/controllers/customerController.go @@ -12,6 +12,7 @@ import ( "doormile/constants" "doormile/db" "doormile/dto" + "doormile/internal/assignment" "doormile/models" "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 if serviceType == "" { serviceType = "Normal" } - if serviceType == "Fast" { - estimatedPrice *= 1.25 - } else if serviceType == "Superfast" { - estimatedPrice *= 1.5 + zone := resolveZone(req.Pickuppincode, req.Deliverypincode) + itemCategory := normalizePricingCategory(req.Parcels[0].Itemcategory) + + 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() @@ -501,7 +491,6 @@ func CreateCustomerBooking(c *fiber.Ctx) error { Estimatedprice: estimatedPrice, Estimateddeliveryat: &estDelivery, Sladueat: &slaDue, - Pricingid: pricingID, } if err := tx.Create(&srvOption).Error; err != nil { @@ -521,6 +510,8 @@ func CreateCustomerBooking(c *fiber.Ctx) error { tx.Commit() + go assignment.AssignCustomerMiler(booking.Bookingid) + if db.Js != nil { payload := map[string]interface{}{ "booking_id": booking.Bookingid, @@ -642,3 +633,25 @@ func TrackConsignment(c *fiber.Ctx) error { "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") +} diff --git a/controllers/milerController.go b/controllers/milerController.go index a51e9d6..c1a2fc8 100644 --- a/controllers/milerController.go +++ b/controllers/milerController.go @@ -13,6 +13,7 @@ import ( "doormile/constants" "doormile/db" "doormile/dto" + "doormile/internal/notify" "doormile/models" "doormile/utils" @@ -336,6 +337,21 @@ func AcceptMilerAssignment(c *fiber.Ctx) error { tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Update("availabilitystatus", constants.MilerAssigned) 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") } @@ -406,30 +422,56 @@ func BookingParcelConfirm(c *fiber.Ctx) error { } type ParcelUpdate struct { - Weight float64 `json:"weight"` - Length float64 `json:"length"` - Width float64 `json:"width"` - Height float64 `json:"height"` + ParcelID int `json:"parcel_id"` + Weight float64 `json:"weight"` + Length float64 `json:"length"` + Width float64 `json:"width"` + Height float64 `json:"height"` } - - req := new(ParcelUpdate) - if err := c.BodyParser(req); err != nil { + var req struct { + Parcels []ParcelUpdate `json:"parcels"` + } + if err := c.BodyParser(&req); err != nil { return utils.BadRequest(c, "invalid request body") } + if len(req.Parcels) == 0 { + return utils.BadRequest(c, "parcels array is required") + } - var parcel models.BookingParcel - if err := db.DB.Where("bookingid = ?", bookingID).First(&parcel).Error; err != nil { + var parcels []models.BookingParcel + if err := db.DB.Where("bookingid = ?", bookingID).Find(&parcels).Error; err != nil { return utils.NotFound(c, "parcel details not found") } - parcel.Weight = req.Weight - parcel.Length = req.Length - parcel.Width = req.Width - parcel.Height = req.Height - parcel.Updatedat = time.Now() - db.DB.Save(&parcel) + // Index loaded parcels by ID for O(1) lookup. + parcelMap := make(map[int]*models.BookingParcel, len(parcels)) + for i := range parcels { + parcelMap[parcels[i].Bookingparcelid] = &parcels[i] + } - 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 { @@ -499,11 +541,28 @@ func BookingPickupComplete(c *fiber.Ctx) error { tx.Save(&profile) } - var parcel models.BookingParcel - tx.Where("bookingid = ?", bookingID).First(&parcel) + var parcels []models.BookingParcel + tx.Where("bookingid = ?", bookingID).Find(&parcels) - volumetric := calculateVolumetricWeight(parcel.Length, parcel.Width, parcel.Height) - chargeable := math.Max(parcel.Weight, volumetric) + var totalDead, totalChargeable, maxL, maxW, maxH float64 + 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() @@ -522,12 +581,12 @@ func BookingPickupComplete(c *fiber.Ctx) error { Deliverylongitude: booking.Deliverylongitude, Pickuppincode: booking.Pickuppincode, Deliverypincode: booking.Deliverypincode, - Length: parcel.Length, - Width: parcel.Width, - Height: parcel.Height, - Deadweight: parcel.Weight, - Volumetricweight: volumetric, - Chargeableweight: chargeable, + Length: maxL, + Width: maxW, + Height: maxH, + Deadweight: totalDead, + Volumetricweight: totalChargeable - totalDead, + Chargeableweight: totalChargeable, Paymentmode: "Prepaid", Status: constants.ConsignmentInwardedAtHub, Estimateddeliveryat: nil, @@ -568,6 +627,21 @@ func BookingPickupComplete(c *fiber.Ctx) error { 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{ "tracking_no": trackingNo, "consignment_id": consignment.Consignmentid, @@ -881,3 +955,25 @@ func GetUserConsignmentLogs(c *fiber.Ctx) error { 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") +} diff --git a/controllers/pricing_helpers.go b/controllers/pricing_helpers.go new file mode 100644 index 0000000..6dd0293 --- /dev/null +++ b/controllers/pricing_helpers.go @@ -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 +} diff --git a/go.mod b/go.mod index 3fdfb15..0e41637 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module doormile -go 1.24.0 +go 1.25.0 require ( github.com/gofiber/fiber/v2 v2.52.10 @@ -9,16 +9,45 @@ require ( github.com/lib/pq v1.12.3 github.com/redis/go-redis/v9 v9.16.0 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/gorm v1.25.12 ) 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/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/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/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/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // 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/nkeys v0.4.5 // 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/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/fasthttp v1.51.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 - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/oauth2 v0.36.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 ) diff --git a/go.sum b/go.sum index a1a6ad9..1075cd7 100644 --- a/go.sum +++ b/go.sum @@ -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/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= 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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/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/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/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/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/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 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/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= 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/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 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/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= 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/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/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.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/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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/assignment/crm_assignment.go b/internal/assignment/crm_assignment.go new file mode 100644 index 0000000..2941149 --- /dev/null +++ b/internal/assignment/crm_assignment.go @@ -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) + } +} diff --git a/internal/assignment/customer_assignment.go b/internal/assignment/customer_assignment.go new file mode 100644 index 0000000..b1e2713 --- /dev/null +++ b/internal/assignment/customer_assignment.go @@ -0,0 +1,391 @@ +package assignment + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "time" + + "doormile/constants" + "doormile/db" + "doormile/models" + "doormile/utils" +) + +// providerResult holds the chosen logistics provider and their pricing details. +type providerResult struct { + company string + estimatedPrice float64 + reliability float64 +} + +// AssignCustomerMiler is the B2C goroutine entry point. +// It selects both a miler (first-mile pickup) and a provider (delivery routing), +// then commits the assignment. Retries up to maxRetries times with retryDelay in between. +// Must be called as a goroutine after tx.Commit() in CreateCustomerBooking. +func AssignCustomerMiler(bookingID int) { + defer func() { + if r := recover(); r != nil { + utils.Error("B2CAssignment: panic recovered", "booking_id", bookingID, "error", r) + } + }() + + for attempt := 1; attempt <= maxRetries; attempt++ { + if attempt > 1 { + time.Sleep(retryDelay) + } + + utils.Info("B2CAssignment: attempting assignment", "booking_id", bookingID, "attempt", attempt) + + done, err := tryCustomerAssign(bookingID) + if err != nil { + utils.Error("B2CAssignment: attempt error", "booking_id", bookingID, "attempt", attempt, "error", err) + continue + } + if done { + return + } + + utils.Warn("B2CAssignment: no eligible miler on attempt", + "booking_id", bookingID, + "attempt", attempt, + "remaining", maxRetries-attempt, + ) + } + + utils.Error("B2CAssignment: NO_MILER_AVAILABLE — all retries exhausted", + "booking_id", bookingID, + "max_retries", maxRetries, + ) +} + +// tryCustomerAssign performs one full attempt: GEO miler search → provider selection → commit. +// Returns (true, nil) on success or when the booking no longer needs assignment. +// Returns (false, nil) when no eligible miler was found (retry warranted). +// Returns (false, err) on hard errors. +func tryCustomerAssign(bookingID int) (bool, error) { + var booking models.PickupBooking + if err := db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, bookingID).Error; err != nil { + return false, fmt.Errorf("load booking: %w", err) + } + + if booking.Status == constants.BookingCancelled || booking.Assignedmileruserid != nil { + utils.Info("B2CAssignment: booking no longer needs assignment", + "booking_id", bookingID, + "status", booking.Status, + ) + return true, nil + } + + if booking.Pickuplatitude == 0 || booking.Pickuplongitude == 0 { + return false, fmt.Errorf("booking %d has no pickup coordinates", bookingID) + } + + // Step 1 — Find the best nearby miler (shared GEO logic from crm_assignment.go). + nearby, err := queryNearbyMilers(booking.Pickuplatitude, booking.Pickuplongitude) + if err != nil { + utils.Warn("B2CAssignment: GEO query failed", "booking_id", bookingID, "error", err) + return false, nil + } + if len(nearby) == 0 { + return false, nil + } + + miler, found := pickBestMiler(nearby) + if !found { + return false, nil + } + + // Step 2 — Select the best logistics provider from DoormilePricing. + zone := b2cResolveZone(booking.Pickuppincode, booking.Deliverypincode) + category := b2cNormalizePricingCategory(b2cFirstParcelCategory(&booking)) + serviceType := b2cFirstServiceType(&booking) + weight := b2cChargeableWeight(&booking) + + provider, providerFound := selectBestProvider(zone, category, serviceType, weight) + if !providerFound { + utils.Warn("B2CAssignment: no provider pricing match, proceeding without one", + "booking_id", bookingID, + "zone", zone, + "category", category, + ) + provider = providerResult{} + } + + // Step 6 — ETA based on miler-to-pickup distance. + etaMinutes := calculateETA(miler.distanceKm) + + // Steps 3–5 — Commit to DB and publish to NATS. + if err := commitCustomerAssignment(&booking, miler, provider, etaMinutes); err != nil { + return false, fmt.Errorf("commit: %w", err) + } + + return true, nil +} + +// ─── Provider selection ─────────────────────────────────────────────────────── + +// selectBestProvider queries all active DoormilePricing rows that cover the given +// zone / serviceType / weight and picks the provider with the lowest composite score. +// +// Scoring (lower = better): +// +// score = avg_price - reliability_score * 2.0 +// +// A reliability point is worth 2 rupees — price dominates for large spreads, but +// a highly reliable provider can edge out a marginally cheaper one. +func selectBestProvider(zone, category, serviceType string, weight float64) (providerResult, bool) { + pricingServiceType := b2cMapServiceTypeToPricing(serviceType) + + var rules []models.DoormilePricing + db.DB.Where( + "zone = ? AND servicetype = ? AND status = ? AND deletedat IS NULL"+ + " AND min_weight <= ? AND max_weight >= ?", + zone, pricingServiceType, "Active", weight, weight, + ).Find(&rules) + + if len(rules) == 0 { + return providerResult{}, false + } + + // Prefer exact category match; fall back to General; use all rows as last resort. + matched := b2cFilterByCategory(rules, category) + if len(matched) == 0 && category != "General" { + matched = b2cFilterByCategory(rules, "General") + } + if len(matched) == 0 { + matched = rules + } + + var best *providerResult + bestScore := math.MaxFloat64 + + for _, rule := range matched { + avgPrice := (rule.Minprice + rule.Maxprice) / 2.0 + score := avgPrice - rule.Reliabilityscore*2.0 + + if score < bestScore { + bestScore = score + r := providerResult{ + company: rule.Providercompany, + estimatedPrice: avgPrice, + reliability: rule.Reliabilityscore, + } + best = &r + } + } + + if best == nil { + return providerResult{}, false + } + return *best, true +} + +// ─── ETA ───────────────────────────────────────────────────────────────────── + +// calculateETA returns the estimated arrival time in minutes. +// Formula: (miler_to_pickup_distance_km / 20.0) * 60 + 10-minute pickup buffer. +func calculateETA(distanceKm float64) float64 { + return math.Round((distanceKm/20.0)*60.0 + 10.0) +} + +// ─── Commit ────────────────────────────────────────────────────────────────── + +// commitCustomerAssignment writes the full assignment in a single DB transaction: +// - BookingAssignment row (status: Assigned) +// - PickupBooking: assignedmileruserid, status → Miler_Assigned, providercompany (if found) +// - MilerProfile: availabilitystatus → Assigned +// +// Publishes to NATS after a successful commit. +func commitCustomerAssignment( + booking *models.PickupBooking, + miler *milerCandidate, + provider providerResult, + etaMinutes float64, +) error { + milerUserID := miler.profile.Userid + + tx := db.DB.Begin() + + ba := models.BookingAssignment{ + Bookingid: booking.Bookingid, + Mileruserid: milerUserID, + Assignmentstatus: constants.AssignmentAssigned, + Assignedat: time.Now(), + } + if err := tx.Create(&ba).Error; err != nil { + tx.Rollback() + return fmt.Errorf("create BookingAssignment: %w", err) + } + + bookingUpdates := map[string]interface{}{ + "assignedmileruserid": milerUserID, + "status": constants.BookingMilerAssigned, + "updatedat": time.Now(), + } + if provider.company != "" { + bookingUpdates["providercompany"] = provider.company + } + + if err := tx.Model(booking).Updates(bookingUpdates).Error; err != nil { + tx.Rollback() + return fmt.Errorf("update PickupBooking: %w", err) + } + + if err := tx.Model(&models.MilerProfile{}). + Where("userid = ?", milerUserID). + Update("availabilitystatus", constants.MilerAssigned).Error; err != nil { + tx.Rollback() + return fmt.Errorf("update MilerProfile availability: %w", err) + } + + tx.Commit() + + utils.Info("B2CAssignment: assigned", + "booking_id", booking.Bookingid, + "miler_id", milerUserID, + "provider", provider.company, + "estimated_price", provider.estimatedPrice, + "eta_minutes", etaMinutes, + "miler_distance_km", miler.distanceKm, + ) + + publishCustomerAssignment(booking, milerUserID, provider, etaMinutes) + notifyMilerNewAssignment(miler.profile, booking.Bookingid) + notifyCustomerMilerAssigned(booking, miler.profile.Displayname) + + return nil +} + +// publishCustomerAssignment sends the booking.assigned event to NATS JetStream. +// Non-fatal: logs a warning and returns if NATS is unavailable or publish fails. +func publishCustomerAssignment( + booking *models.PickupBooking, + milerUserID int, + provider providerResult, + etaMinutes float64, +) { + if db.Js == nil { + return + } + + payload := map[string]interface{}{ + "booking_id": booking.Bookingid, + "booking_no": booking.Bookingno, + "miler_id": milerUserID, + "selected_provider": provider.company, + "estimated_price": provider.estimatedPrice, + "eta_minutes": etaMinutes, + "assigned_at": time.Now().UnixMilli(), + } + + data, err := json.Marshal(payload) + if err != nil { + utils.Warn("B2CAssignment: failed to marshal NATS payload", "booking_id", booking.Bookingid, "error", err) + return + } + + if _, err := db.Js.Publish("booking.assigned", data); err != nil { + utils.Warn("B2CAssignment: NATS publish failed", "booking_id", booking.Bookingid, "error", err) + } +} + +// ─── Booking field extractors ───────────────────────────────────────────────── + +func b2cFirstParcelCategory(booking *models.PickupBooking) string { + if len(booking.Parcels) > 0 { + return booking.Parcels[0].Itemcategory + } + return "" +} + +func b2cFirstServiceType(booking *models.PickupBooking) string { + if len(booking.ServiceOptions) > 0 { + return booking.ServiceOptions[0].Servicetype + } + return "Normal" +} + +// b2cChargeableWeight sums max(actual, volumetric) per parcel across the booking. +func b2cChargeableWeight(booking *models.PickupBooking) float64 { + var total float64 + for _, p := range booking.Parcels { + volumetric := (p.Length * p.Width * p.Height) / 5000.0 + total += math.Max(p.Weight, volumetric) + } + if total == 0 { + return 0.5 // minimum chargeable to avoid zero-weight lookup + } + return total +} + +// ─── Zone / category helpers (self-contained; no import of controllers pkg) ── + +func b2cPincodeToState(pincode string) string { + if len(pincode) < 3 { + return pincode + } + p, err := strconv.Atoi(pincode[:3]) + if err != nil { + if len(pincode) >= 2 { + return pincode[:2] + } + return pincode + } + switch { + case p >= 500 && p <= 535: + return "AP_TS" + case p >= 560 && p <= 591: + return "KA" + case p >= 600 && p <= 643: + return "TN" + case p >= 670 && p <= 695: + return "KL" + case p >= 380 && p <= 396: + return "GJ" + case p >= 400 && p <= 444: + return "MH" + default: + if len(pincode) >= 2 { + return pincode[:2] + } + return pincode + } +} + +func b2cResolveZone(pickupPincode, deliveryPincode string) string { + if len(pickupPincode) >= 3 && len(deliveryPincode) >= 3 && pickupPincode[:3] == deliveryPincode[:3] { + return "Local" + } + if b2cPincodeToState(pickupPincode) == b2cPincodeToState(deliveryPincode) { + return "Interstate" + } + return "OtherState" +} + +func b2cNormalizePricingCategory(category string) string { + switch category { + case "General", "Documents", "Electronics", "Clothing", "Fragile", "Medical", "Automotive", "Food": + return category + default: + return "General" + } +} + +func b2cMapServiceTypeToPricing(serviceType string) string { + if serviceType == "Fast" || serviceType == "Superfast" || serviceType == "Express" { + return "Express" + } + return "Normal" +} + +func b2cFilterByCategory(rules []models.DoormilePricing, category string) []models.DoormilePricing { + out := make([]models.DoormilePricing, 0, len(rules)) + for _, r := range rules { + if r.Category == category { + out = append(out, r) + } + } + return out +} diff --git a/internal/assignment/notify.go b/internal/assignment/notify.go new file mode 100644 index 0000000..b673ff8 --- /dev/null +++ b/internal/assignment/notify.go @@ -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, + ) + } +} diff --git a/internal/notify/fcm.go b/internal/notify/fcm.go new file mode 100644 index 0000000..697618e --- /dev/null +++ b/internal/notify/fcm.go @@ -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 +} diff --git a/internal/ws/chat.go b/internal/ws/chat.go new file mode 100644 index 0000000..af02cef --- /dev/null +++ b/internal/ws/chat.go @@ -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= (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, + ) + } +} diff --git a/internal/ws/tracking.go b/internal/ws/tracking.go new file mode 100644 index 0000000..a6f7764 --- /dev/null +++ b/internal/ws/tracking.go @@ -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 +} diff --git a/main.go b/main.go index 780104f..252c9b5 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "doormile/config" "doormile/controllers" "doormile/db" + "doormile/internal/notify" "doormile/internal/worker" "doormile/middlewares" "doormile/migrations" @@ -31,6 +32,7 @@ func main() { db.Connect(cfg) db.InitRedis(cfg) db.InitNATS(cfg) + notify.InitFCM() // 3. Run GORM migrations for logistics tables if db.DB != nil { diff --git a/middlewares/city_gate.go b/middlewares/city_gate.go new file mode 100644 index 0000000..ce52b5f --- /dev/null +++ b/middlewares/city_gate.go @@ -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", + }) +} diff --git a/middlewares/internal_auth.go b/middlewares/internal_auth.go new file mode 100644 index 0000000..a6686f6 --- /dev/null +++ b/middlewares/internal_auth.go @@ -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() +} diff --git a/middlewares/ws_auth.go b/middlewares/ws_auth.go new file mode 100644 index 0000000..ae25dd4 --- /dev/null +++ b/middlewares/ws_auth.go @@ -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() + } +} diff --git a/models/doormile_pricing.go b/models/doormile_pricing.go index eaa22cf..23e7530 100644 --- a/models/doormile_pricing.go +++ b/models/doormile_pricing.go @@ -16,6 +16,8 @@ type DoormilePricing struct { Minprice float64 `json:"min_price" gorm:"column:min_price;not null"` Maxprice float64 `json:"max_price" gorm:"column:max_price;not null"` 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"` Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` diff --git a/models/users.go b/models/users.go index bd69d9b..d449064 100644 --- a/models/users.go +++ b/models/users.go @@ -83,24 +83,25 @@ func (AppUser) TableName() string { } type MilerProfile struct { - Milerprofileid int `json:"milerprofileid" gorm:"primaryKey;column:milerprofileid"` - Userid int `json:"userid" gorm:"column:userid;unique;not null"` - Applocationid int `json:"applocationid" gorm:"column:applocationid;default:1"` - Displayname string `json:"displayname" gorm:"column:displayname;not null"` - Phone string `json:"phone" gorm:"column:phone;not null"` - Profilephotourl string `json:"profilephotourl" gorm:"column:profilephotourl"` - Vehicleid *int `json:"vehicleid" gorm:"column:vehicleid"` - Defaultvehicletype string `json:"defaultvehicletype" gorm:"column:defaultvehicletype"` - Currentlatitude float64 `json:"currentlatitude" gorm:"column:currentlatitude"` - Currentlongitude float64 `json:"currentlongitude" gorm:"column:currentlongitude"` - Currentpincode string `json:"currentpincode" gorm:"column:currentpincode"` - Availabilitystatus string `json:"availabilitystatus" gorm:"column:availabilitystatus;default:Offline"` // Offline, Available, Assigned, On_Pickup, At_Customer, Picked_Up, On_Delivery, Break, Blocked - Rating float64 `json:"rating" gorm:"column:rating;default:5.00"` - Totalcompletedpickups int `json:"totalcompletedpickups" gorm:"column:totalcompletedpickups;default:0"` - Totalcancelledpickups int `json:"totalcancelledpickups" gorm:"column:totalcancelledpickups;default:0"` - Lastlocationupdatedat *time.Time `json:"lastlocationupdatedat" gorm:"column:lastlocationupdatedat"` - Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` - Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` + Milerprofileid int `json:"milerprofileid" gorm:"primaryKey;column:milerprofileid"` + Userid int `json:"userid" gorm:"column:userid;unique;not null"` + Applocationid int `json:"applocationid" gorm:"column:applocationid;default:1"` + Displayname string `json:"displayname" gorm:"column:displayname;not null"` + Phone string `json:"phone" gorm:"column:phone;not null"` + Profilephotourl string `json:"profilephotourl" gorm:"column:profilephotourl"` + Vehicleid *int `json:"vehicleid" gorm:"column:vehicleid"` + Defaultvehicletype string `json:"defaultvehicletype" gorm:"column:defaultvehicletype"` + Currentlatitude float64 `json:"currentlatitude" gorm:"column:currentlatitude"` + Currentlongitude float64 `json:"currentlongitude" gorm:"column:currentlongitude"` + Currentpincode string `json:"currentpincode" gorm:"column:currentpincode"` + Availabilitystatus string `json:"availabilitystatus" gorm:"column:availabilitystatus;default:Offline"` // Offline, Available, Assigned, On_Pickup, At_Customer, Picked_Up, On_Delivery, Break, Blocked + Rating float64 `json:"rating" gorm:"column:rating;default:5.00"` + Totalcompletedpickups int `json:"totalcompletedpickups" gorm:"column:totalcompletedpickups;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"` + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` + Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` } func (MilerProfile) TableName() string { @@ -117,6 +118,7 @@ type AppCustomer struct { Defaultlatitude float64 `json:"defaultlatitude" gorm:"column:defaultlatitude"` Defaultlongitude float64 `json:"defaultlongitude" gorm:"column:defaultlongitude"` 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 Configid int `json:"configid" gorm:"column:configid;default:1"` Lastloginat *time.Time `json:"lastloginat" gorm:"column:lastloginat"` diff --git a/routes/routes.go b/routes/routes.go index 3b8b403..cd464dd 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -7,9 +7,11 @@ import ( "doormile/config" "doormile/controllers" "doormile/db" + "doormile/internal/ws" "doormile/middlewares" "github.com/gofiber/fiber/v2" + "github.com/gofiber/websocket/v2" ) 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.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/:bookingid", controllers.GetCustomerBookingDetails) 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.Put("/profile", controllers.UpdateMilerProfile) + milerAuth.Put("/device-token", controllers.SaveMilerDeviceToken) + milerAuth.Put("/location", controllers.UpdateMilerLocation) milerAuth.Put("/availability", controllers.UpdateMilerAvailability) @@ -178,7 +184,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { // Bookings adminAuth.Get("/bookings", controllers.GetAdminBookings) - adminAuth.Post("/crmbooking", controllers.CreateCRMBooking) + adminAuth.Post("/crmbooking", middlewares.CityGateMiddleware, controllers.CreateCRMBooking) adminAuth.Get("/bookings/:id", controllers.GetAdminBookingDetails) adminAuth.Post("/bookings/:id/assign-miler", controllers.AdminAssignMiler) 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.Put("/clients/:id", controllers.UpdateClient) 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)) } diff --git a/seed_data.sql b/seed_data.sql new file mode 100644 index 0000000..fa0c0ba --- /dev/null +++ b/seed_data.sql @@ -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;