Add assignment engine, FCM, WebSockets, city gate, and internal APIs

- internal/assignment: GEORADIUS miler assignment with retry/escalation,
  customer-side provider scoring, FCM notifications on assign
- internal/notify: Firebase Admin SDK (FCM) client initialisation
- internal/ws: WebSocket handlers for live parcel tracking and
  customer↔miler chat
- middlewares: city gate (pincode prefix validation), internal API key
  auth, WebSocket JWT auth
- controllers: InternalNotify + InternalReassign for machine-to-machine
  calls; pricing helpers wired into CreateCustomerBooking and CreateCRMBooking
- routes: /internal/*, /ws/bookings/:id/track, /ws/bookings/:id/chat
- models/users, models/doormile_pricing: new fields for device tokens,
  assignment state, pricing bands
- seed_data.sql: initial pricing seed rows

.env and Firebase service-account JSON intentionally excluded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 12:31:53 +05:30
parent c577d47b75
commit c91c887726
21 changed files with 2399 additions and 75 deletions

View File

@@ -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,
})
}