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

40
middlewares/city_gate.go Normal file
View File

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

View File

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

36
middlewares/ws_auth.go Normal file
View File

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