feat: hub console backend — complete API surface
This commit is contained in:
@@ -17,6 +17,13 @@ REDIS_PORT=6379
|
|||||||
REDIS_USER=admin
|
REDIS_USER=admin
|
||||||
REDIS_PASSWORD=Package@321#
|
REDIS_PASSWORD=Package@321#
|
||||||
|
|
||||||
|
# SMTP Configuration (email OTP verification)
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_USER=your-email@gmail.com
|
||||||
|
SMTP_PASSWORD=your-16-char-app-password
|
||||||
|
SMTP_FROM=your-email@gmail.com
|
||||||
|
|
||||||
|
|
||||||
$env:PATH += ";C:\Program Files\Docker\Docker\resources\bin"
|
$env:PATH += ";C:\Program Files\Docker\Docker\resources\bin"
|
||||||
>>
|
>>
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ type Config struct {
|
|||||||
NatsUser string
|
NatsUser string
|
||||||
NatsPassword string
|
NatsPassword string
|
||||||
AILayerBaseURL string // AI decision-engine service base URL (e.g. http://rider-api:8082)
|
AILayerBaseURL string // AI decision-engine service base URL (e.g. http://rider-api:8082)
|
||||||
|
SMTPHost string
|
||||||
|
SMTPPort string
|
||||||
|
SMTPUser string
|
||||||
|
SMTPPassword string
|
||||||
|
SMTPFrom string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
@@ -41,6 +46,11 @@ func Load() *Config {
|
|||||||
NatsUser: getEnv("NATS_USER", "doormile"),
|
NatsUser: getEnv("NATS_USER", "doormile"),
|
||||||
NatsPassword: getEnv("NATS_PASSWORD", "Package@321#"),
|
NatsPassword: getEnv("NATS_PASSWORD", "Package@321#"),
|
||||||
AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", "https://routemate.workolik.com"),
|
AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", "https://routemate.workolik.com"),
|
||||||
|
SMTPHost: getEnv("SMTP_HOST", ""),
|
||||||
|
SMTPPort: getEnv("SMTP_PORT", "465"),
|
||||||
|
SMTPUser: getEnv("SMTP_USER", ""),
|
||||||
|
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
|
||||||
|
SMTPFrom: getEnv("SMTP_FROM", ""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1144,47 +1144,11 @@ func AdminAssignMiler(c *fiber.Ctx) error {
|
|||||||
return utils.BadRequest(c, "invalid request body")
|
return utils.BadRequest(c, "invalid request body")
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
booking.Status = constants.BookingMilerAssigned
|
|
||||||
booking.Assignedmileruserid = &req.Mileruserid
|
|
||||||
booking.Updatedat = time.Now()
|
|
||||||
tx.Save(&booking)
|
|
||||||
|
|
||||||
adminUserID := c.Locals("userid").(int)
|
adminUserID := c.Locals("userid").(int)
|
||||||
|
|
||||||
assignment := models.BookingAssignment{
|
booking, err := AssignMilerToBooking(id, req.Mileruserid, &adminUserID)
|
||||||
Bookingid: booking.Bookingid,
|
if err != nil {
|
||||||
Mileruserid: req.Mileruserid,
|
return utils.NotFound(c, "booking not found")
|
||||||
Assignedbyuserid: &adminUserID,
|
|
||||||
Assignmentstatus: constants.AssignmentAssigned,
|
|
||||||
}
|
|
||||||
tx.Create(&assignment)
|
|
||||||
|
|
||||||
// Update Miler status
|
|
||||||
tx.Model(&models.MilerProfile{}).Where("userid = ?", req.Mileruserid).Update("availabilitystatus", constants.MilerAssigned)
|
|
||||||
|
|
||||||
tx.Commit()
|
|
||||||
|
|
||||||
if db.Js != nil {
|
|
||||||
payload := map[string]interface{}{
|
|
||||||
"booking_id": booking.Bookingid,
|
|
||||||
"booking_no": booking.Bookingno,
|
|
||||||
"status": booking.Status,
|
|
||||||
"miler_id": req.Mileruserid,
|
|
||||||
"updated_at": time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
if data, err := json.Marshal(payload); err == nil {
|
|
||||||
if _, err := db.Js.Publish("api.v1.bookings.update", data); err != nil {
|
|
||||||
utils.Warn("Failed to publish booking.update to NATS", "booking_id", booking.Bookingid, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return utils.OK(c, booking)
|
return utils.OK(c, booking)
|
||||||
|
|||||||
84
controllers/booking_assignment_service.go
Normal file
84
controllers/booking_assignment_service.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"doormile/constants"
|
||||||
|
"doormile/db"
|
||||||
|
"doormile/internal/notify"
|
||||||
|
"doormile/models"
|
||||||
|
"doormile/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AssignMilerToBooking is the single source of truth for manually assigning a
|
||||||
|
// miler to a pickup booking — shared by the admin console (AdminAssignMiler)
|
||||||
|
// and the hub console (HubAssignMiler) so both go through identical DB
|
||||||
|
// updates, NATS publish, and FCM notify instead of duplicating the logic.
|
||||||
|
func AssignMilerToBooking(bookingID, milerUserID int, assignedByUserID *int) (*models.PickupBooking, error) {
|
||||||
|
tx := db.DB.Begin()
|
||||||
|
|
||||||
|
var booking models.PickupBooking
|
||||||
|
if err := tx.First(&booking, bookingID).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, fmt.Errorf("booking not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
booking.Status = constants.BookingMilerAssigned
|
||||||
|
booking.Assignedmileruserid = &milerUserID
|
||||||
|
booking.Updatedat = time.Now()
|
||||||
|
if err := tx.Save(&booking).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, fmt.Errorf("failed to update booking: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment := models.BookingAssignment{
|
||||||
|
Bookingid: booking.Bookingid,
|
||||||
|
Mileruserid: milerUserID,
|
||||||
|
Assignedbyuserid: assignedByUserID,
|
||||||
|
Assignmentstatus: constants.AssignmentAssigned,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&assignment).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, fmt.Errorf("failed to create assignment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
||||||
|
Update("availabilitystatus", constants.MilerAssigned).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, fmt.Errorf("failed to update miler availability: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.Commit()
|
||||||
|
|
||||||
|
if db.Js != nil {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"booking_id": booking.Bookingid,
|
||||||
|
"booking_no": booking.Bookingno,
|
||||||
|
"status": booking.Status,
|
||||||
|
"miler_id": milerUserID,
|
||||||
|
"updated_at": time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
if data, err := json.Marshal(payload); err == nil {
|
||||||
|
if _, err := db.Js.Publish("api.v1.bookings.update", data); err != nil {
|
||||||
|
utils.Warn("Failed to publish booking.update to NATS", "booking_id", booking.Bookingid, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var miler models.MilerProfile
|
||||||
|
if db.DB.Where("userid = ?", milerUserID).First(&miler).Error == nil && miler.Devicetoken != "" {
|
||||||
|
if err := notify.SendToDevice(
|
||||||
|
miler.Devicetoken,
|
||||||
|
"New Pickup Assigned",
|
||||||
|
"New booking assigned — tap to view details",
|
||||||
|
map[string]string{"booking_id": fmt.Sprintf("%d", booking.Bookingid)},
|
||||||
|
); err != nil {
|
||||||
|
utils.Warn("FCM: failed to notify miler on manual assignment",
|
||||||
|
"miler_id", milerUserID, "booking_id", booking.Bookingid, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &booking, nil
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,12 +12,87 @@ import (
|
|||||||
"doormile/constants"
|
"doormile/constants"
|
||||||
"doormile/db"
|
"doormile/db"
|
||||||
"doormile/dto"
|
"doormile/dto"
|
||||||
|
"doormile/internal/assignment"
|
||||||
"doormile/models"
|
"doormile/models"
|
||||||
"doormile/utils"
|
"doormile/utils"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// hubAutoAssignTimeout bounds how long HubAutoAssign waits synchronously for
|
||||||
|
// TryAssignOnce (GEOSEARCH + AI decision + commit) before returning 202 and
|
||||||
|
// letting the assignment finish in the background.
|
||||||
|
const hubAutoAssignTimeout = 8 * time.Second
|
||||||
|
|
||||||
|
// assignableBookingStatuses gates both HubAssignMiler and HubAutoAssign.
|
||||||
|
// Pending_Assignment / Assignment_Failed aren't statuses this codebase
|
||||||
|
// currently sets anywhere (only Pending_Pickup is real today) but are kept
|
||||||
|
// here so the gate doesn't need touching if those states get introduced later.
|
||||||
|
var assignableBookingStatuses = map[string]bool{
|
||||||
|
constants.BookingPendingPickup: true,
|
||||||
|
"Pending_Assignment": true,
|
||||||
|
"Assignment_Failed": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultJourneyMinutes is the assumed total truck transit time until real
|
||||||
|
// GPS telemetry (EMQX) is wired up; used to interpolate in-transit position.
|
||||||
|
const defaultJourneyMinutes = 480 // 8 hours
|
||||||
|
|
||||||
|
// zoneNames maps a handful of known pincodes in our 4 operating cities to
|
||||||
|
// human-readable locality names. Unmapped pincodes fall back to "Zone <code>".
|
||||||
|
var zoneNames = map[string]string{
|
||||||
|
"641001": "Gandhipuram",
|
||||||
|
"641002": "RS Puram",
|
||||||
|
"641004": "Peelamedu",
|
||||||
|
"641012": "Jupiter Nagar",
|
||||||
|
"641035": "Saravanampatti",
|
||||||
|
"500003": "Secunderabad",
|
||||||
|
"500032": "Financial District",
|
||||||
|
"500034": "Banjara Hills",
|
||||||
|
"500081": "Gachibowli",
|
||||||
|
"560034": "Koramangala",
|
||||||
|
"560038": "Indiranagar",
|
||||||
|
"560066": "Whitefield",
|
||||||
|
"560100": "Electronic City",
|
||||||
|
"600017": "T Nagar",
|
||||||
|
"600032": "Guindy",
|
||||||
|
"600040": "Anna Nagar",
|
||||||
|
"600042": "Velachery",
|
||||||
|
}
|
||||||
|
|
||||||
|
func zoneName(pincode string) string {
|
||||||
|
if name, ok := zoneNames[pincode]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return "Zone " + pincode
|
||||||
|
}
|
||||||
|
|
||||||
|
// haversineKM returns the great-circle distance between two lat/lon points in km.
|
||||||
|
func haversineKM(lat1, lon1, lat2, lon2 float64) float64 {
|
||||||
|
const earthRadiusKM = 6371.0
|
||||||
|
toRad := func(deg float64) float64 { return deg * math.Pi / 180 }
|
||||||
|
dLat := toRad(lat2 - lat1)
|
||||||
|
dLon := toRad(lon2 - lon1)
|
||||||
|
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||||
|
math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||||
|
return earthRadiusKM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||||
|
}
|
||||||
|
|
||||||
|
// humanizeRelativeTime renders a timestamp as "5 min ago" / "2 hrs ago" / "3 days ago".
|
||||||
|
func humanizeRelativeTime(t time.Time) string {
|
||||||
|
d := time.Since(t)
|
||||||
|
switch {
|
||||||
|
case d < time.Minute:
|
||||||
|
return "just now"
|
||||||
|
case d < time.Hour:
|
||||||
|
return fmt.Sprintf("%d min ago", int(d.Minutes()))
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
return fmt.Sprintf("%d hrs ago", int(d.Hours()))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// todayMidnight returns the start of the current day in server local time,
|
// todayMidnight returns the start of the current day in server local time,
|
||||||
// used to scope "today" counters on the hub dashboard.
|
// used to scope "today" counters on the hub dashboard.
|
||||||
func todayMidnight() time.Time {
|
func todayMidnight() time.Time {
|
||||||
@@ -196,7 +273,37 @@ func GetHubInboundToday(c *fiber.Ctx) error {
|
|||||||
return utils.Internal(c, "failed to fetch inbound consignments")
|
return utils.Internal(c, "failed to fetch inbound consignments")
|
||||||
}
|
}
|
||||||
|
|
||||||
return utils.List(c, consignments, int64(len(consignments)))
|
response := make([]fiber.Map, 0, len(consignments))
|
||||||
|
for _, cs := range consignments {
|
||||||
|
originName := fmt.Sprintf("Direct pickup (%s)", cs.Pickuppincode)
|
||||||
|
if cs.Originhubid != nil {
|
||||||
|
var oh models.Hub
|
||||||
|
if db.DB.Where("hubid = ?", *cs.Originhubid).First(&oh).Error == nil {
|
||||||
|
originName = oh.Hubname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response = append(response, fiber.Map{
|
||||||
|
"consignmentid": cs.Consignmentid,
|
||||||
|
"trackingno": cs.Trackingno,
|
||||||
|
// sendername is empty: consignments carry no FK back to any
|
||||||
|
// customer/booking record (Senderid/Receiverid are never
|
||||||
|
// populated anywhere in this codebase).
|
||||||
|
"sendername": "",
|
||||||
|
"originname": originName,
|
||||||
|
// destinationname: consignments store no free-text delivery
|
||||||
|
// address, only deliverypincode — used as the best available label.
|
||||||
|
"destinationname": cs.Deliverypincode,
|
||||||
|
"condition": cs.Condition,
|
||||||
|
"shelf": cs.Shelf,
|
||||||
|
// temperature: no column exists anywhere yet for this.
|
||||||
|
"temperature": "N/A",
|
||||||
|
"status": cs.Status,
|
||||||
|
"updatedat": cs.Updatedat,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.List(c, response, int64(len(response)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateInboundScan(c *fiber.Ctx) error {
|
func CreateInboundScan(c *fiber.Ctx) error {
|
||||||
@@ -433,15 +540,100 @@ func UpdateBatchStatus(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetHubMilers is the hub-scoped equivalent of admin's GetMilers, filtered to
|
// GetHubMilers is the hub-scoped equivalent of admin's GetMilers, filtered to
|
||||||
// only the milers assigned to the requesting hub staff's own hub.
|
// only the milers assigned to the requesting hub staff's own hub, enriched
|
||||||
|
// with computed operational stats per miler.
|
||||||
func GetHubMilers(c *fiber.Ctx) error {
|
func GetHubMilers(c *fiber.Ctx) error {
|
||||||
hubID := c.Locals("hubid").(int)
|
hubID := c.Locals("hubid").(int)
|
||||||
|
midnight := todayMidnight()
|
||||||
|
weekAgo := time.Now().AddDate(0, 0, -7)
|
||||||
|
|
||||||
var profiles []models.MilerProfile
|
var profiles []models.MilerProfile
|
||||||
if err := db.DB.Where("hubid = ?", hubID).Find(&profiles).Error; err != nil {
|
if err := db.DB.Where("hubid = ?", hubID).Find(&profiles).Error; err != nil {
|
||||||
return utils.Internal(c, "failed to fetch milers")
|
return utils.Internal(c, "failed to fetch milers")
|
||||||
}
|
}
|
||||||
return utils.List(c, profiles, int64(len(profiles)))
|
|
||||||
|
response := make([]fiber.Map, 0, len(profiles))
|
||||||
|
for _, mp := range profiles {
|
||||||
|
var zones []string
|
||||||
|
db.DB.Model(&models.BookingAssignment{}).
|
||||||
|
Joins("JOIN pickupbookings pb ON pb.bookingid = bookingassignments.bookingid").
|
||||||
|
Where("bookingassignments.mileruserid = ? AND bookingassignments.assignedat >= ?", mp.Userid, weekAgo).
|
||||||
|
Distinct("pb.pickuppincode").
|
||||||
|
Pluck("pb.pickuppincode", &zones)
|
||||||
|
if zones == nil {
|
||||||
|
zones = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assignmentstatus (BookingAssignment's real status column) only has
|
||||||
|
// Assigned/Accepted/Rejected/Reassigned/Completed/Cancelled — treated
|
||||||
|
// Assigned+Accepted as "still active load".
|
||||||
|
var assignedLoad int64
|
||||||
|
db.DB.Model(&models.BookingAssignment{}).
|
||||||
|
Where("mileruserid = ? AND assignmentstatus IN ?", mp.Userid,
|
||||||
|
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
||||||
|
Count(&assignedLoad)
|
||||||
|
|
||||||
|
// Pending_Pickup lives on pickupbookings.status, not on the
|
||||||
|
// assignment itself, so this counts via a join.
|
||||||
|
var pickupsPending int64
|
||||||
|
db.DB.Model(&models.BookingAssignment{}).
|
||||||
|
Joins("JOIN pickupbookings pb ON pb.bookingid = bookingassignments.bookingid").
|
||||||
|
Where("bookingassignments.mileruserid = ? AND pb.status = ?", mp.Userid, constants.BookingPendingPickup).
|
||||||
|
Count(&pickupsPending)
|
||||||
|
|
||||||
|
// bookingpayments has no mileruserid column; collectedbyuserid is the
|
||||||
|
// app user id of whoever collected it, which for miler collections is
|
||||||
|
// the miler's own userid.
|
||||||
|
var codCollected float64
|
||||||
|
db.DB.Model(&models.BookingPayment{}).
|
||||||
|
Where("collectedbyuserid = ? AND paymentstatus = ? AND createdat >= ?", mp.Userid, constants.PaymentStatusPaid, midnight).
|
||||||
|
Select("COALESCE(SUM(amount), 0)").Scan(&codCollected)
|
||||||
|
|
||||||
|
var codPending float64
|
||||||
|
db.DB.Model(&models.BookingPayment{}).
|
||||||
|
Where("collectedbyuserid = ? AND paymentstatus = ?", mp.Userid, constants.PaymentStatusPending).
|
||||||
|
Select("COALESCE(SUM(amount), 0)").Scan(&codPending)
|
||||||
|
|
||||||
|
var firstAssignment models.BookingAssignment
|
||||||
|
var checkinAt *time.Time
|
||||||
|
if db.DB.Where("mileruserid = ? AND assignedat >= ?", mp.Userid, midnight).
|
||||||
|
Order("assignedat ASC").First(&firstAssignment).Error == nil {
|
||||||
|
checkinAt = &firstAssignment.Assignedat
|
||||||
|
}
|
||||||
|
|
||||||
|
hoursActive := 0.0
|
||||||
|
if checkinAt != nil {
|
||||||
|
hoursActive = time.Since(*checkinAt).Hours()
|
||||||
|
}
|
||||||
|
|
||||||
|
response = append(response, fiber.Map{
|
||||||
|
"milerprofileid": mp.Milerprofileid,
|
||||||
|
"userid": mp.Userid,
|
||||||
|
"displayname": mp.Displayname,
|
||||||
|
"phone": mp.Phone,
|
||||||
|
"vehicleid": mp.Vehicleid,
|
||||||
|
"hubid": mp.Hubid,
|
||||||
|
"defaultvehicletype": mp.Defaultvehicletype,
|
||||||
|
"currentlatitude": mp.Currentlatitude,
|
||||||
|
"currentlongitude": mp.Currentlongitude,
|
||||||
|
"currentpincode": mp.Currentpincode,
|
||||||
|
"availabilitystatus": mp.Availabilitystatus,
|
||||||
|
"rating": mp.Rating,
|
||||||
|
"totalcompletedpickups": mp.Totalcompletedpickups,
|
||||||
|
"totalcancelledpickups": mp.Totalcancelledpickups,
|
||||||
|
"zones": zones,
|
||||||
|
"assignedload": assignedLoad,
|
||||||
|
"capacity": 30, // hardcoded until vehicle capacity is modelled
|
||||||
|
"pickupspending": pickupsPending,
|
||||||
|
"codcollected": codCollected,
|
||||||
|
"codpending": codPending,
|
||||||
|
"checkinat": checkinAt,
|
||||||
|
"hoursactive": hoursActive,
|
||||||
|
"isverified": mp.Devicetoken != "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.List(c, response, int64(len(response)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------
|
// --------------------
|
||||||
@@ -537,6 +729,8 @@ func GetHubsInCity(c *fiber.Ctx) error {
|
|||||||
"pincode": h.Pincode,
|
"pincode": h.Pincode,
|
||||||
"status": h.Status,
|
"status": h.Status,
|
||||||
"capacity": h.Capacity,
|
"capacity": h.Capacity,
|
||||||
|
"lat": h.Latitude,
|
||||||
|
"lon": h.Longitude,
|
||||||
"has_staff": staffCount > 0,
|
"has_staff": staffCount > 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -591,3 +785,732 @@ func CreateCityHub(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
return utils.Created(c, hub)
|
return utils.Created(c, hub)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------
|
||||||
|
// IN-TRANSIT TRACKING & INBOUND VEHICLES
|
||||||
|
// --------------------
|
||||||
|
|
||||||
|
// GetInTransitTripsheets returns tripsheets currently moving to/from this hub,
|
||||||
|
// with a linearly-interpolated position along the source→dest line since we
|
||||||
|
// don't have real truck GPS yet. Swap in real coordinates later — shape stays.
|
||||||
|
func GetInTransitTripsheets(c *fiber.Ctx) error {
|
||||||
|
hubID := c.Locals("hubid").(int)
|
||||||
|
|
||||||
|
var tripsheets []models.Tripsheet
|
||||||
|
if err := db.DB.Where("(sourcehubid = ? OR destinationhubid = ?) AND status IN ? AND deletedat IS NULL",
|
||||||
|
hubID, hubID, []string{constants.TripsheetDispatched, "In_Transit"}).
|
||||||
|
Order("dispatchtime DESC").Find(&tripsheets).Error; err != nil {
|
||||||
|
return utils.Internal(c, "failed to fetch in-transit tripsheets")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]fiber.Map, 0, len(tripsheets))
|
||||||
|
for _, ts := range tripsheets {
|
||||||
|
var source, dest models.Hub
|
||||||
|
db.DB.Where("hubid = ?", ts.Sourcehubid).First(&source)
|
||||||
|
db.DB.Where("hubid = ?", ts.Destinationhubid).First(&dest)
|
||||||
|
|
||||||
|
var vehicleNo string
|
||||||
|
if ts.Vehicleid != nil {
|
||||||
|
var v models.Vehicle
|
||||||
|
if db.DB.Where("vehicleid = ?", *ts.Vehicleid).First(&v).Error == nil {
|
||||||
|
vehicleNo = v.Vehicleno
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemCount int64
|
||||||
|
db.DB.Model(&models.TripsheetItem{}).Where("tripsheetid = ?", ts.Tripsheetid).Count(&itemCount)
|
||||||
|
|
||||||
|
elapsedMinutes := 0.0
|
||||||
|
if ts.Dispatchtime != nil {
|
||||||
|
elapsedMinutes = time.Since(*ts.Dispatchtime).Minutes()
|
||||||
|
}
|
||||||
|
progressPct := int(math.Min(95, (elapsedMinutes/defaultJourneyMinutes)*100))
|
||||||
|
if progressPct < 0 {
|
||||||
|
progressPct = 0
|
||||||
|
}
|
||||||
|
etaMinutes := int(defaultJourneyMinutes - elapsedMinutes)
|
||||||
|
if etaMinutes < 0 {
|
||||||
|
etaMinutes = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
frac := float64(progressPct) / 100.0
|
||||||
|
currentLat := source.Latitude + (dest.Latitude-source.Latitude)*frac
|
||||||
|
currentLon := source.Longitude + (dest.Longitude-source.Longitude)*frac
|
||||||
|
|
||||||
|
response = append(response, fiber.Map{
|
||||||
|
"tripsheetid": ts.Tripsheetid,
|
||||||
|
"tripsheetno": ts.Tripsheetno,
|
||||||
|
"label": fmt.Sprintf("%s → %s", source.Hubname, dest.Hubname),
|
||||||
|
"originlat": source.Latitude,
|
||||||
|
"originlon": source.Longitude,
|
||||||
|
"destlat": dest.Latitude,
|
||||||
|
"destlon": dest.Longitude,
|
||||||
|
"currentlat": currentLat,
|
||||||
|
"currentlon": currentLon,
|
||||||
|
"status": ts.Status,
|
||||||
|
"progresspct": progressPct,
|
||||||
|
"vehicleno": vehicleNo,
|
||||||
|
"itemcount": itemCount,
|
||||||
|
"dispatchtime": ts.Dispatchtime,
|
||||||
|
"eta_minutes": etaMinutes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.List(c, response, int64(len(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInboundVehicles returns vehicles arriving at or expected at this hub.
|
||||||
|
func GetInboundVehicles(c *fiber.Ctx) error {
|
||||||
|
hubID := c.Locals("hubid").(int)
|
||||||
|
|
||||||
|
var tripsheets []models.Tripsheet
|
||||||
|
if err := db.DB.Where("destinationhubid = ? AND status IN ? AND deletedat IS NULL",
|
||||||
|
hubID, []string{constants.TripsheetDispatched, "In_Transit", constants.TripsheetArrived}).
|
||||||
|
Order("dispatchtime DESC").Limit(20).Find(&tripsheets).Error; err != nil {
|
||||||
|
return utils.Internal(c, "failed to fetch inbound vehicles")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]fiber.Map, 0, len(tripsheets))
|
||||||
|
for _, ts := range tripsheets {
|
||||||
|
var source models.Hub
|
||||||
|
db.DB.Where("hubid = ?", ts.Sourcehubid).First(&source)
|
||||||
|
|
||||||
|
var vehicleNo, vehicleType string
|
||||||
|
if ts.Vehicleid != nil {
|
||||||
|
var v models.Vehicle
|
||||||
|
if db.DB.Where("vehicleid = ?", *ts.Vehicleid).First(&v).Error == nil {
|
||||||
|
vehicleNo = v.Vehicleno
|
||||||
|
vehicleType = v.Vehicletype
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalItems, unloadedItems int64
|
||||||
|
db.DB.Model(&models.TripsheetItem{}).Where("tripsheetid = ?", ts.Tripsheetid).Count(&totalItems)
|
||||||
|
|
||||||
|
status := "On the way"
|
||||||
|
unloadedPct := 0
|
||||||
|
if ts.Status == constants.TripsheetArrived {
|
||||||
|
status = "Unloading"
|
||||||
|
db.DB.Model(&models.TripsheetItem{}).
|
||||||
|
Where("tripsheetid = ? AND scanstatus = ?", ts.Tripsheetid, constants.ScanUnloaded).
|
||||||
|
Count(&unloadedItems)
|
||||||
|
if totalItems > 0 {
|
||||||
|
unloadedPct = int(float64(unloadedItems) / float64(totalItems) * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eta := "Arrived"
|
||||||
|
if ts.Status != constants.TripsheetArrived && ts.Dispatchtime != nil {
|
||||||
|
remaining := defaultJourneyMinutes - int(time.Since(*ts.Dispatchtime).Minutes())
|
||||||
|
if remaining < 0 {
|
||||||
|
remaining = 0
|
||||||
|
}
|
||||||
|
eta = fmt.Sprintf("%d hrs %d min", remaining/60, remaining%60)
|
||||||
|
}
|
||||||
|
|
||||||
|
response = append(response, fiber.Map{
|
||||||
|
"vehicleno": vehicleNo,
|
||||||
|
"vehicletype": vehicleType,
|
||||||
|
"origin": source.Hubname,
|
||||||
|
"tripsheetid": ts.Tripsheetid,
|
||||||
|
"status": status,
|
||||||
|
"eta": eta,
|
||||||
|
"unloadedpct": unloadedPct,
|
||||||
|
"itemcount": totalItems,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.List(c, response, int64(len(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------
|
||||||
|
// HUB ACTIVITY FEED
|
||||||
|
// --------------------
|
||||||
|
|
||||||
|
type activityEntry struct {
|
||||||
|
Time time.Time `gorm:"column:time"`
|
||||||
|
Type string `gorm:"column:type"`
|
||||||
|
Text string `gorm:"column:text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHubActivity aggregates real events from 4 sources (inbound scans,
|
||||||
|
// dispatches, exceptions, miler assignments) into one timeline.
|
||||||
|
func GetHubActivity(c *fiber.Ctx) error {
|
||||||
|
hubID := c.Locals("hubid").(int)
|
||||||
|
limit, err := strconv.Atoi(c.Query("limit", "10"))
|
||||||
|
if err != nil || limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
midnight := todayMidnight()
|
||||||
|
|
||||||
|
// Source 4 (miler assignments) is scoped by mp.hubid via a join — the
|
||||||
|
// ticket's version had no hub filter at all, which would leak every
|
||||||
|
// hub's assignment activity into every other hub's feed.
|
||||||
|
query := `
|
||||||
|
(SELECT c.updatedat AS time, 'inbound' AS type,
|
||||||
|
('Received parcel ' || c.trackingno || ' from ' || COALESCE(oh.hubname, 'Direct Pickup')) AS text
|
||||||
|
FROM consignments c
|
||||||
|
LEFT JOIN hubs oh ON c.originhubid = oh.hubid
|
||||||
|
WHERE c.currenthubid = ? AND c.status = ? AND c.updatedat > ?)
|
||||||
|
UNION ALL
|
||||||
|
(SELECT t.dispatchtime AS time, 'dispatch' AS type,
|
||||||
|
('Dispatched batch ' || t.tripsheetno || ' to ' || COALESCE(t.destinationlabel, '')) AS text
|
||||||
|
FROM tripsheets t
|
||||||
|
WHERE t.sourcehubid = ? AND t.dispatchtime > ?)
|
||||||
|
UNION ALL
|
||||||
|
(SELECT ce.createdat AS time, 'exception' AS type,
|
||||||
|
('Exception raised: ' || ce.exceptiontype || ' on ' || c.trackingno) AS text
|
||||||
|
FROM consignmentexceptions ce
|
||||||
|
JOIN consignments c ON ce.consignmentid = c.consignmentid
|
||||||
|
WHERE ce.hubid = ? AND ce.createdat > ?)
|
||||||
|
UNION ALL
|
||||||
|
(SELECT ba.assignedat AS time, 'sorting' AS type,
|
||||||
|
('Assigned booking #' || ba.bookingid || ' to miler ' || mp.displayname) AS text
|
||||||
|
FROM bookingassignments ba
|
||||||
|
JOIN milerprofiles mp ON ba.mileruserid = mp.userid
|
||||||
|
WHERE mp.hubid = ? AND ba.assignedat > ?)
|
||||||
|
ORDER BY time DESC
|
||||||
|
LIMIT ?
|
||||||
|
`
|
||||||
|
|
||||||
|
var entries []activityEntry
|
||||||
|
if err := db.DB.Raw(query,
|
||||||
|
hubID, constants.ConsignmentInwardedAtHub, midnight,
|
||||||
|
hubID, midnight,
|
||||||
|
hubID, midnight,
|
||||||
|
hubID, midnight,
|
||||||
|
limit,
|
||||||
|
).Scan(&entries).Error; err != nil {
|
||||||
|
return utils.Internal(c, "failed to fetch hub activity")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]fiber.Map, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
response = append(response, fiber.Map{
|
||||||
|
"time": e.Time,
|
||||||
|
"type": e.Type,
|
||||||
|
"text": e.Text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.List(c, response, int64(len(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------
|
||||||
|
// DELIVERY ZONES
|
||||||
|
// --------------------
|
||||||
|
|
||||||
|
// GetHubZones returns delivery zone breakdown for this hub's milers, falling
|
||||||
|
// back to static known zones for the hub's city when there's too little
|
||||||
|
// assignment data yet (new hub, few bookings).
|
||||||
|
func GetHubZones(c *fiber.Ctx) error {
|
||||||
|
hubID := c.Locals("hubid").(int)
|
||||||
|
|
||||||
|
type zoneRow struct {
|
||||||
|
Zone string `gorm:"column:zone"`
|
||||||
|
Milers int64 `gorm:"column:milers"`
|
||||||
|
Parcels int64 `gorm:"column:parcels"`
|
||||||
|
Status string `gorm:"column:status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ba.pickuppincode / ba.status don't exist on bookingassignments —
|
||||||
|
// pincode lives on pickupbookings (joined here) and the real status
|
||||||
|
// column is assignmentstatus.
|
||||||
|
query := `
|
||||||
|
SELECT pb.pickuppincode AS zone,
|
||||||
|
COUNT(DISTINCT ba.mileruserid) AS milers,
|
||||||
|
COUNT(ba.bookingassignmentid) AS parcels,
|
||||||
|
CASE WHEN COUNT(DISTINCT ba.mileruserid) = 0 THEN 'Need Milers' ELSE 'Active' END AS status
|
||||||
|
FROM bookingassignments ba
|
||||||
|
JOIN milerprofiles mp ON ba.mileruserid = mp.userid
|
||||||
|
JOIN pickupbookings pb ON pb.bookingid = ba.bookingid
|
||||||
|
WHERE mp.hubid = ? AND ba.assignmentstatus IN ?
|
||||||
|
GROUP BY pb.pickuppincode
|
||||||
|
ORDER BY parcels DESC
|
||||||
|
LIMIT 10
|
||||||
|
`
|
||||||
|
|
||||||
|
var rows []zoneRow
|
||||||
|
db.DB.Raw(query, hubID, []string{constants.AssignmentAssigned, constants.AssignmentAccepted}).Scan(&rows)
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
response := make([]fiber.Map, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
seen[r.Zone] = true
|
||||||
|
response = append(response, fiber.Map{
|
||||||
|
"zone": r.Zone,
|
||||||
|
"zonename": zoneName(r.Zone),
|
||||||
|
"parcels": r.Parcels,
|
||||||
|
"milers": r.Milers,
|
||||||
|
"status": r.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(response) < 3 {
|
||||||
|
prefix := hubPincodePrefix(hubID)
|
||||||
|
for pincode := range zoneNames {
|
||||||
|
if strings.HasPrefix(pincode, prefix) && !seen[pincode] {
|
||||||
|
response = append(response, fiber.Map{
|
||||||
|
"zone": pincode,
|
||||||
|
"zonename": zoneName(pincode),
|
||||||
|
"parcels": 0,
|
||||||
|
"milers": 0,
|
||||||
|
"status": "Need Milers",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.List(c, response, int64(len(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------
|
||||||
|
// NOTIFICATIONS (synthetic — no dedicated table yet)
|
||||||
|
// --------------------
|
||||||
|
|
||||||
|
type notificationEntry struct {
|
||||||
|
Title string
|
||||||
|
Type string
|
||||||
|
Time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHubNotifications generates notifications from real events across 4
|
||||||
|
// sources since there's no dedicated notifications table yet.
|
||||||
|
func GetHubNotifications(c *fiber.Ctx) error {
|
||||||
|
hubID := c.Locals("hubid").(int)
|
||||||
|
midnight := todayMidnight()
|
||||||
|
last24h := time.Now().Add(-24 * time.Hour)
|
||||||
|
|
||||||
|
var notifications []notificationEntry
|
||||||
|
|
||||||
|
var exceptions []models.ConsignmentException
|
||||||
|
db.DB.Where("hubid = ? AND createdat > ?", hubID, last24h).Find(&exceptions)
|
||||||
|
for _, e := range exceptions {
|
||||||
|
notifications = append(notifications, notificationEntry{
|
||||||
|
Title: "Exception: " + e.Exceptiontype,
|
||||||
|
Type: "exception",
|
||||||
|
Time: e.Createdat,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var inbound []models.Tripsheet
|
||||||
|
db.DB.Where("destinationhubid = ? AND status IN ?", hubID, []string{constants.TripsheetDispatched, "In_Transit"}).Find(&inbound)
|
||||||
|
for _, ts := range inbound {
|
||||||
|
var source models.Hub
|
||||||
|
db.DB.Where("hubid = ?", ts.Sourcehubid).First(&source)
|
||||||
|
t := ts.Updatedat
|
||||||
|
if ts.Dispatchtime != nil {
|
||||||
|
t = *ts.Dispatchtime
|
||||||
|
}
|
||||||
|
notifications = append(notifications, notificationEntry{
|
||||||
|
Title: "Truck arriving from " + source.Hubname,
|
||||||
|
Type: "inbound",
|
||||||
|
Time: t,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var offlineMilers []models.MilerProfile
|
||||||
|
db.DB.Where("hubid = ? AND availabilitystatus = ?", hubID, constants.MilerOffline).Find(&offlineMilers)
|
||||||
|
for _, mp := range offlineMilers {
|
||||||
|
var hadActivityToday int64
|
||||||
|
db.DB.Model(&models.BookingAssignment{}).Where("mileruserid = ? AND assignedat >= ?", mp.Userid, midnight).Count(&hadActivityToday)
|
||||||
|
if hadActivityToday > 0 {
|
||||||
|
notifications = append(notifications, notificationEntry{
|
||||||
|
Title: "Miler " + mp.Displayname + " went offline",
|
||||||
|
Type: "warning",
|
||||||
|
Time: mp.Updatedat,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := hubPincodePrefix(hubID)
|
||||||
|
thirtyMinAgo := time.Now().Add(-30 * time.Minute)
|
||||||
|
var stalePending []models.PickupBooking
|
||||||
|
staleQuery := db.DB.Where("assignedmileruserid IS NULL AND status = ? AND createdat < ?", constants.BookingPendingPickup, thirtyMinAgo)
|
||||||
|
if prefix != "" {
|
||||||
|
staleQuery = staleQuery.Where("pickuppincode LIKE ?", prefix+"%")
|
||||||
|
}
|
||||||
|
staleQuery.Find(&stalePending)
|
||||||
|
for _, b := range stalePending {
|
||||||
|
notifications = append(notifications, notificationEntry{
|
||||||
|
Title: "Pickup waiting 30+ min",
|
||||||
|
Type: "alert",
|
||||||
|
Time: b.Createdat,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(notifications, func(i, j int) bool { return notifications[i].Time.After(notifications[j].Time) })
|
||||||
|
if len(notifications) > 20 {
|
||||||
|
notifications = notifications[:20]
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]fiber.Map, 0, len(notifications))
|
||||||
|
for i, n := range notifications {
|
||||||
|
response = append(response, fiber.Map{
|
||||||
|
"id": i + 1,
|
||||||
|
"title": n.Title,
|
||||||
|
"type": n.Type,
|
||||||
|
"time": humanizeRelativeTime(n.Time),
|
||||||
|
"read": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.List(c, response, int64(len(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkNotificationRead is a stub until a notifications table with read-state
|
||||||
|
// exists — always succeeds without persisting anything.
|
||||||
|
func MarkNotificationRead(c *fiber.Ctx) error {
|
||||||
|
return c.JSON(fiber.Map{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------
|
||||||
|
// PARCEL ROUTING (SCAN)
|
||||||
|
// --------------------
|
||||||
|
|
||||||
|
// resolveNextHop compares the delivery pincode's city prefix against the
|
||||||
|
// requesting hub's own city prefix to decide local delivery vs. transfer.
|
||||||
|
func resolveNextHop(destPrefix, myPrefix string) string {
|
||||||
|
if destPrefix == "" || destPrefix == myPrefix {
|
||||||
|
return "Local delivery"
|
||||||
|
}
|
||||||
|
var hub models.Hub
|
||||||
|
if db.DB.Where("pincode LIKE ?", destPrefix+"%").First(&hub).Error == nil {
|
||||||
|
return "Transfer to " + hub.Hubname
|
||||||
|
}
|
||||||
|
return "Transfer to Regional Hub"
|
||||||
|
}
|
||||||
|
|
||||||
|
func recommendShelfForRouting(condition string, isColdChain bool, nextHop string) string {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(condition, "Damaged"):
|
||||||
|
return "Exception Area"
|
||||||
|
case isColdChain:
|
||||||
|
return "Zone C (Cold Room)"
|
||||||
|
case nextHop == "Local delivery":
|
||||||
|
return "Zone B"
|
||||||
|
default:
|
||||||
|
return "Zone A"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoutingInfo scans a parcel and returns its sort destination, checking
|
||||||
|
// both consignments.trackingno and pickupbookings.bookingno (a booking not
|
||||||
|
// yet converted to a consignment has no trackingno of its own).
|
||||||
|
func GetRoutingInfo(c *fiber.Ctx) error {
|
||||||
|
hubID := c.Locals("hubid").(int)
|
||||||
|
trackingNo := c.Params("trackingno")
|
||||||
|
myPrefix := hubPincodePrefix(hubID)
|
||||||
|
|
||||||
|
var consignment models.Consignment
|
||||||
|
if db.DB.Where("trackingno = ?", trackingNo).First(&consignment).Error == nil {
|
||||||
|
destPrefix := ""
|
||||||
|
if len(consignment.Deliverypincode) >= 3 {
|
||||||
|
destPrefix = consignment.Deliverypincode[:3]
|
||||||
|
}
|
||||||
|
nextHop := resolveNextHop(destPrefix, myPrefix)
|
||||||
|
|
||||||
|
shelf := consignment.Shelf
|
||||||
|
if shelf == "" {
|
||||||
|
shelf = recommendShelfForRouting(consignment.Condition, false, nextHop)
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.OK(c, fiber.Map{
|
||||||
|
"trackingno": consignment.Trackingno,
|
||||||
|
"destination": consignment.Deliverypincode,
|
||||||
|
"recommendedshelf": shelf,
|
||||||
|
"nexthop": nextHop,
|
||||||
|
"condition": consignment.Condition,
|
||||||
|
"iscoldchain": false,
|
||||||
|
"weight": fmt.Sprintf("%.1f kg", consignment.Chargeableweight),
|
||||||
|
// no FK from consignments back to a customer/booking record
|
||||||
|
"customername": "",
|
||||||
|
"bookingid": nil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var booking models.PickupBooking
|
||||||
|
found := db.DB.Where("bookingno = ?", trackingNo).First(&booking).Error == nil
|
||||||
|
if !found {
|
||||||
|
if id, err := strconv.Atoi(trackingNo); err == nil {
|
||||||
|
found = db.DB.Where("bookingid = ?", id).First(&booking).Error == nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return utils.NotFound(c, "tracking number not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
destPrefix := ""
|
||||||
|
if len(booking.Deliverypincode) >= 3 {
|
||||||
|
destPrefix = booking.Deliverypincode[:3]
|
||||||
|
}
|
||||||
|
nextHop := resolveNextHop(destPrefix, myPrefix)
|
||||||
|
shelf := recommendShelfForRouting("", false, nextHop)
|
||||||
|
|
||||||
|
var customer models.AppCustomer
|
||||||
|
db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer)
|
||||||
|
customerName := strings.TrimSpace(customer.Firstname + " " + customer.Lastname)
|
||||||
|
|
||||||
|
weight := "N/A"
|
||||||
|
var parcel models.BookingParcel
|
||||||
|
if db.DB.Where("bookingid = ?", booking.Bookingid).First(&parcel).Error == nil {
|
||||||
|
weight = fmt.Sprintf("%.1f kg", parcel.Weight)
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.OK(c, fiber.Map{
|
||||||
|
"trackingno": booking.Bookingno,
|
||||||
|
"destination": fmt.Sprintf("%s, %s", booking.Deliveryaddress, booking.Deliverypincode),
|
||||||
|
"recommendedshelf": shelf,
|
||||||
|
"nexthop": nextHop,
|
||||||
|
"condition": "Pending Scan",
|
||||||
|
"iscoldchain": false,
|
||||||
|
"weight": weight,
|
||||||
|
"customername": customerName,
|
||||||
|
"bookingid": booking.Bookingid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------
|
||||||
|
// RIDER ROUTES
|
||||||
|
// --------------------
|
||||||
|
|
||||||
|
// buildMilerRoute computes today's planned stops for one miler. eta_minutes
|
||||||
|
// for pending stops is a simple 15-min-per-stop placeholder until a real
|
||||||
|
// routing engine is wired up — same spirit as the in-transit interpolation.
|
||||||
|
func buildMilerRoute(mp models.MilerProfile) fiber.Map {
|
||||||
|
var assignments []models.BookingAssignment
|
||||||
|
db.DB.Where("mileruserid = ? AND assignedat >= ?", mp.Userid, todayMidnight()).
|
||||||
|
Order("assignedat ASC").Find(&assignments)
|
||||||
|
|
||||||
|
stops := make([]fiber.Map, 0, len(assignments))
|
||||||
|
var coords [][2]float64
|
||||||
|
completedCount := 0
|
||||||
|
pendingEtaMinutes := 0
|
||||||
|
|
||||||
|
for i, a := range assignments {
|
||||||
|
var booking models.PickupBooking
|
||||||
|
if db.DB.Where("bookingid = ?", a.Bookingid).First(&booking).Error != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
itemCount := 0
|
||||||
|
var parcel models.BookingParcel
|
||||||
|
if db.DB.Where("bookingid = ?", a.Bookingid).First(&parcel).Error == nil {
|
||||||
|
itemCount = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "pending"
|
||||||
|
switch booking.Status {
|
||||||
|
case constants.BookingPickedUp, constants.BookingConvertedConsignment:
|
||||||
|
status = "completed"
|
||||||
|
case constants.BookingPickupScheduled:
|
||||||
|
status = "in_progress"
|
||||||
|
}
|
||||||
|
|
||||||
|
etaMinutes := 0
|
||||||
|
if status == "completed" {
|
||||||
|
completedCount++
|
||||||
|
} else {
|
||||||
|
pendingEtaMinutes += 15
|
||||||
|
etaMinutes = pendingEtaMinutes
|
||||||
|
}
|
||||||
|
|
||||||
|
coords = append(coords, [2]float64{booking.Pickuplatitude, booking.Pickuplongitude})
|
||||||
|
|
||||||
|
stops = append(stops, fiber.Map{
|
||||||
|
"seq": i + 1,
|
||||||
|
"address": booking.Pickupaddress,
|
||||||
|
"lat": booking.Pickuplatitude,
|
||||||
|
"lon": booking.Pickuplongitude,
|
||||||
|
"items": itemCount,
|
||||||
|
"bookingid": booking.Bookingid,
|
||||||
|
"status": status,
|
||||||
|
"eta_minutes": etaMinutes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
totalDistance := 0.0
|
||||||
|
for i := 1; i < len(coords); i++ {
|
||||||
|
totalDistance += haversineKM(coords[i-1][0], coords[i-1][1], coords[i][0], coords[i][1])
|
||||||
|
}
|
||||||
|
|
||||||
|
return fiber.Map{
|
||||||
|
"mileruserid": mp.Userid,
|
||||||
|
"milername": mp.Displayname,
|
||||||
|
"mode": "pickup",
|
||||||
|
"totalstops": len(stops),
|
||||||
|
"completedstops": completedCount,
|
||||||
|
"totaldistance_km": math.Round(totalDistance*10) / 10,
|
||||||
|
"stops": stops,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMilerRoute returns today's planned stops for a specific miler (:id is
|
||||||
|
// the mileruserid, matching bookingassignments.mileruserid).
|
||||||
|
func GetMilerRoute(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.Atoi(c.Params("id"))
|
||||||
|
if err != nil {
|
||||||
|
return utils.BadRequest(c, "invalid miler id")
|
||||||
|
}
|
||||||
|
|
||||||
|
var mp models.MilerProfile
|
||||||
|
if err := db.DB.Where("userid = ?", id).First(&mp).Error; err != nil {
|
||||||
|
return utils.NotFound(c, "miler not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.OK(c, buildMilerRoute(mp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllRiderRoutes returns the same route structure for every miler at this
|
||||||
|
// hub today, for the Rider Routes overview page.
|
||||||
|
func GetAllRiderRoutes(c *fiber.Ctx) error {
|
||||||
|
hubID := c.Locals("hubid").(int)
|
||||||
|
|
||||||
|
var profiles []models.MilerProfile
|
||||||
|
if err := db.DB.Where("hubid = ?", hubID).Find(&profiles).Error; err != nil {
|
||||||
|
return utils.Internal(c, "failed to fetch milers")
|
||||||
|
}
|
||||||
|
|
||||||
|
routes := make([]fiber.Map, 0, len(profiles))
|
||||||
|
for _, mp := range profiles {
|
||||||
|
routes = append(routes, buildMilerRoute(mp))
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.List(c, routes, int64(len(routes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------
|
||||||
|
// HUB-SCOPED ASSIGNMENT (manual override + auto-assign)
|
||||||
|
// --------------------
|
||||||
|
|
||||||
|
// HubAssignMiler is the hub console's manual assignment override — hub staff
|
||||||
|
// (role 6) can't call POST /admin/bookings/:id/assign-miler (admin-only) or
|
||||||
|
// rely on POST /internal/bookings/:id/reassign (requires an already-assigned
|
||||||
|
// booking), so this exists for a hub-scoped path onto the same shared
|
||||||
|
// AssignMilerToBooking logic AdminAssignMiler uses.
|
||||||
|
func HubAssignMiler(c *fiber.Ctx) error {
|
||||||
|
hubID := c.Locals("hubid").(int)
|
||||||
|
|
||||||
|
bookingID, err := strconv.Atoi(c.Params("id"))
|
||||||
|
if err != nil {
|
||||||
|
return utils.BadRequest(c, "invalid booking id")
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssignRequest struct {
|
||||||
|
Mileruserid int `json:"mileruserid"`
|
||||||
|
}
|
||||||
|
req := new(AssignRequest)
|
||||||
|
if err := c.BodyParser(req); err != nil {
|
||||||
|
return utils.BadRequest(c, "invalid request body")
|
||||||
|
}
|
||||||
|
if req.Mileruserid == 0 {
|
||||||
|
return utils.BadRequest(c, "mileruserid is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var booking models.PickupBooking
|
||||||
|
if err := db.DB.First(&booking, bookingID).Error; err != nil {
|
||||||
|
return utils.NotFound(c, "booking not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !assignableBookingStatuses[booking.Status] {
|
||||||
|
return utils.BadRequest(c, "booking is not in an assignable state")
|
||||||
|
}
|
||||||
|
|
||||||
|
var miler models.MilerProfile
|
||||||
|
if err := db.DB.Where("userid = ? AND hubid = ?", req.Mileruserid, hubID).First(&miler).Error; err != nil {
|
||||||
|
return utils.Forbidden(c, "miler does not belong to this hub")
|
||||||
|
}
|
||||||
|
|
||||||
|
// assignedbyuserid carries a real FK to appusers(userid); hub staff
|
||||||
|
// accounts live in a separate hubstaffaccounts id space, so passing that
|
||||||
|
// id here would violate the constraint (or worse, silently misattribute
|
||||||
|
// to whatever appuser happens to share that id). Pass nil instead — the
|
||||||
|
// field is nullable and this is the honest option until hub staff get a
|
||||||
|
// mirrored appusers row.
|
||||||
|
updatedBooking, err := AssignMilerToBooking(bookingID, req.Mileruserid, nil)
|
||||||
|
if err != nil {
|
||||||
|
return utils.Internal(c, "failed to assign miler")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"success": true,
|
||||||
|
"message": "miler assigned",
|
||||||
|
"data": fiber.Map{
|
||||||
|
"bookingid": updatedBooking.Bookingid,
|
||||||
|
"mileruserid": req.Mileruserid,
|
||||||
|
"milername": miler.Displayname,
|
||||||
|
"status": updatedBooking.Status,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HubAutoAssign triggers the AI assignment engine for one booking that wasn't
|
||||||
|
// automatically assigned at creation time. It runs TryAssignOnce (a single
|
||||||
|
// GEOSEARCH + AI-decision + commit attempt, no retry loop) in a goroutine and
|
||||||
|
// waits up to hubAutoAssignTimeout — long enough for the typical sub-second
|
||||||
|
// Redis GEO query plus the AI layer's own 5s HTTP timeout, but bounded so the
|
||||||
|
// HTTP handler never hangs indefinitely. If it doesn't finish in time, the
|
||||||
|
// goroutine keeps running in the background and the assignment still lands.
|
||||||
|
func HubAutoAssign(c *fiber.Ctx) error {
|
||||||
|
bookingID, err := strconv.Atoi(c.Params("id"))
|
||||||
|
if err != nil {
|
||||||
|
return utils.BadRequest(c, "invalid booking id")
|
||||||
|
}
|
||||||
|
|
||||||
|
var booking models.PickupBooking
|
||||||
|
if err := db.DB.First(&booking, bookingID).Error; err != nil {
|
||||||
|
return utils.NotFound(c, "booking not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !assignableBookingStatuses[booking.Status] {
|
||||||
|
return utils.BadRequest(c, "booking is not in an assignable state")
|
||||||
|
}
|
||||||
|
|
||||||
|
type outcome struct {
|
||||||
|
res assignment.AutoAssignResult
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
resultCh := make(chan outcome, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
res, err := assignment.TryAssignOnce(bookingID)
|
||||||
|
resultCh <- outcome{res, err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case out := <-resultCh:
|
||||||
|
if out.err != nil {
|
||||||
|
return utils.Internal(c, "assignment failed: "+out.err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !out.res.Assigned {
|
||||||
|
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
|
||||||
|
"success": false,
|
||||||
|
"message": "No eligible miler found in range",
|
||||||
|
"data": fiber.Map{
|
||||||
|
"searched_radius_km": out.res.SearchedRadiusKm,
|
||||||
|
"candidates_found": out.res.CandidatesFound,
|
||||||
|
"reasoning": out.res.Reasoning,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"success": true,
|
||||||
|
"data": fiber.Map{
|
||||||
|
"mileruserid": out.res.MilerUserID,
|
||||||
|
"milername": out.res.MilerName,
|
||||||
|
"distance_km": out.res.DistanceKm,
|
||||||
|
// no confidence field: the AI decision engine's response has
|
||||||
|
// no such value anywhere in this pipeline to report honestly.
|
||||||
|
"reasoning": out.res.Reasoning,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
case <-time.After(hubAutoAssignTimeout):
|
||||||
|
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{
|
||||||
|
"success": true,
|
||||||
|
"message": "assignment in progress",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
105
controllers/otpController.go
Normal file
105
controllers/otpController.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"doormile/config"
|
||||||
|
"doormile/db"
|
||||||
|
"doormile/dto"
|
||||||
|
"doormile/internal/mail"
|
||||||
|
"doormile/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
otpTTL = 5 * time.Minute
|
||||||
|
otpMaxAttempts = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
func generateOtpCode() string {
|
||||||
|
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
|
||||||
|
if err != nil {
|
||||||
|
return "000000"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%06d", n.Int64())
|
||||||
|
}
|
||||||
|
|
||||||
|
func otpKey(email string) string { return fmt.Sprintf("otp:email:%s", email) }
|
||||||
|
func otpAttemptsKey(email string) string { return fmt.Sprintf("otp:email:%s:attempts", email) }
|
||||||
|
|
||||||
|
func SendCustomerEmailOtp(cfg *config.Config) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
req := new(dto.SendEmailOtpRequest)
|
||||||
|
if err := c.BodyParser(req); err != nil {
|
||||||
|
return utils.BadRequest(c, "invalid request body")
|
||||||
|
}
|
||||||
|
if req.Email == "" {
|
||||||
|
return utils.BadRequest(c, "email is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if db.Rdb == nil {
|
||||||
|
return utils.Internal(c, "verification service unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
code := generateOtpCode()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := db.Rdb.Set(ctx, otpKey(req.Email), code, otpTTL).Err(); err != nil {
|
||||||
|
return utils.Internal(c, "failed to generate verification code")
|
||||||
|
}
|
||||||
|
db.Rdb.Del(ctx, otpAttemptsKey(req.Email))
|
||||||
|
|
||||||
|
if err := mail.SendOTPEmail(cfg, req.Email, code); err != nil {
|
||||||
|
utils.Warn("failed to send OTP email", "email", req.Email, "error", err)
|
||||||
|
return utils.Internal(c, "failed to send verification email")
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Message(c, "verification code sent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func VerifyCustomerEmailOtp() fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
req := new(dto.VerifyEmailOtpRequest)
|
||||||
|
if err := c.BodyParser(req); err != nil {
|
||||||
|
return utils.BadRequest(c, "invalid request body")
|
||||||
|
}
|
||||||
|
if req.Email == "" || req.Otp == "" {
|
||||||
|
return utils.BadRequest(c, "email and otp are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if db.Rdb == nil {
|
||||||
|
return utils.Internal(c, "verification service unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
key := otpKey(req.Email)
|
||||||
|
|
||||||
|
stored, err := db.Rdb.Get(ctx, key).Result()
|
||||||
|
if err == redis.Nil {
|
||||||
|
return utils.BadRequest(c, "verification code expired or not found, please resend")
|
||||||
|
} else if err != nil {
|
||||||
|
return utils.Internal(c, "failed to verify code")
|
||||||
|
}
|
||||||
|
|
||||||
|
if stored != req.Otp {
|
||||||
|
attemptsKey := otpAttemptsKey(req.Email)
|
||||||
|
attempts, _ := db.Rdb.Incr(ctx, attemptsKey).Result()
|
||||||
|
db.Rdb.Expire(ctx, attemptsKey, otpTTL)
|
||||||
|
if attempts >= otpMaxAttempts {
|
||||||
|
db.Rdb.Del(ctx, key, attemptsKey)
|
||||||
|
return utils.BadRequest(c, "too many incorrect attempts, please request a new code")
|
||||||
|
}
|
||||||
|
return utils.Unauthorized(c, "incorrect verification code")
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Rdb.Del(ctx, key, otpAttemptsKey(req.Email))
|
||||||
|
return utils.Message(c, "email verified successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
10
dto/auth.go
10
dto/auth.go
@@ -27,6 +27,16 @@ type CustomerResetPinRequest struct {
|
|||||||
Configid int `json:"configid"`
|
Configid int `json:"configid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SendEmailOtpRequest struct {
|
||||||
|
Email string `json:"email" xml:"email" form:"email"`
|
||||||
|
Phone string `json:"phone" xml:"phone" form:"phone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerifyEmailOtpRequest struct {
|
||||||
|
Email string `json:"email" xml:"email" form:"email"`
|
||||||
|
Otp string `json:"otp" xml:"otp" form:"otp"`
|
||||||
|
}
|
||||||
|
|
||||||
type MilerLoginRequest struct {
|
type MilerLoginRequest struct {
|
||||||
Phone string `json:"phone" xml:"phone" form:"phone"`
|
Phone string `json:"phone" xml:"phone" form:"phone"`
|
||||||
Configid int `json:"configid"`
|
Configid int `json:"configid"`
|
||||||
|
|||||||
@@ -111,6 +111,79 @@ func tryAssign(bookingID int) (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AutoAssignResult carries enough detail for a synchronous caller (the hub
|
||||||
|
// console's manual "auto-assign" trigger) to report what happened, since that
|
||||||
|
// caller can't rely on the fire-and-forget logging AssignCRMMiler normally uses.
|
||||||
|
type AutoAssignResult struct {
|
||||||
|
Assigned bool
|
||||||
|
Escalated bool
|
||||||
|
MilerUserID int
|
||||||
|
MilerName string
|
||||||
|
DistanceKm float64
|
||||||
|
Reasoning string
|
||||||
|
SearchedRadiusKm float64
|
||||||
|
CandidatesFound int
|
||||||
|
}
|
||||||
|
|
||||||
|
// TryAssignOnce performs a single assignment attempt (GEOSEARCH → AI decision
|
||||||
|
// → commit) with no retry loop, for a booking that wasn't auto-assigned at
|
||||||
|
// creation time. AssignCRMMiler/AssignCustomerMiler retry over ~10 minutes,
|
||||||
|
// which is too slow for a hub staff member waiting on a synchronous response;
|
||||||
|
// this wraps the same single-attempt core (tryAssign) used internally by both.
|
||||||
|
func TryAssignOnce(bookingID int) (AutoAssignResult, error) {
|
||||||
|
var booking models.PickupBooking
|
||||||
|
if err := db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, bookingID).Error; err != nil {
|
||||||
|
return AutoAssignResult{}, fmt.Errorf("load booking: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if booking.Status == constants.BookingCancelled || booking.Assignedmileruserid != nil {
|
||||||
|
return AutoAssignResult{Assigned: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if booking.Pickuplatitude == 0 || booking.Pickuplongitude == 0 {
|
||||||
|
return AutoAssignResult{}, fmt.Errorf("booking %d has no pickup coordinates", bookingID)
|
||||||
|
}
|
||||||
|
|
||||||
|
nearby, err := queryNearbyMilers(booking.Pickuplatitude, booking.Pickuplongitude)
|
||||||
|
if err != nil {
|
||||||
|
return AutoAssignResult{Escalated: true, Reasoning: "miler location search unavailable", SearchedRadiusKm: geoRadiusKm}, nil
|
||||||
|
}
|
||||||
|
if len(nearby) == 0 {
|
||||||
|
return AutoAssignResult{Escalated: true, Reasoning: "no milers within search radius", SearchedRadiusKm: geoRadiusKm}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate, agentDecisionID, found := selectMilerWithAI(&booking, nearby)
|
||||||
|
if !found {
|
||||||
|
return AutoAssignResult{
|
||||||
|
Escalated: true,
|
||||||
|
Reasoning: "no eligible miler after evaluation",
|
||||||
|
SearchedRadiusKm: geoRadiusKm,
|
||||||
|
CandidatesFound: len(nearby),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := commitAssignment(&booking, candidate, agentDecisionID); err != nil {
|
||||||
|
return AutoAssignResult{}, fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reasoning := ""
|
||||||
|
if agentDecisionID != nil {
|
||||||
|
var ad models.AgentDecision
|
||||||
|
if db.DB.Where("id = ?", *agentDecisionID).First(&ad).Error == nil {
|
||||||
|
reasoning = ad.Reasoning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AutoAssignResult{
|
||||||
|
Assigned: true,
|
||||||
|
MilerUserID: candidate.profile.Userid,
|
||||||
|
MilerName: candidate.profile.Displayname,
|
||||||
|
DistanceKm: candidate.distanceKm,
|
||||||
|
Reasoning: reasoning,
|
||||||
|
CandidatesFound: len(nearby),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// queryNearbyMilers runs GEOSEARCH on milers:locations and returns up to geoMaxCount
|
// queryNearbyMilers runs GEOSEARCH on milers:locations and returns up to geoMaxCount
|
||||||
// milers within geoRadiusKm km, sorted nearest-first, with distances populated.
|
// milers within geoRadiusKm km, sorted nearest-first, with distances populated.
|
||||||
func queryNearbyMilers(lat, lon float64) ([]redis.GeoLocation, error) {
|
func queryNearbyMilers(lat, lon float64) ([]redis.GeoLocation, error) {
|
||||||
|
|||||||
70
internal/mail/mail.go
Normal file
70
internal/mail/mail.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net/smtp"
|
||||||
|
|
||||||
|
"doormile/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SendOTPEmail(cfg *config.Config, toEmail, code string) error {
|
||||||
|
subject := "Your Doormile verification code"
|
||||||
|
body := fmt.Sprintf(
|
||||||
|
"Your Doormile verification code is %s.\r\n\r\nIt expires in 5 minutes. If you didn't request this, you can ignore this email.",
|
||||||
|
code,
|
||||||
|
)
|
||||||
|
|
||||||
|
msg := []byte(fmt.Sprintf(
|
||||||
|
"From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=\"utf-8\"\r\n\r\n%s\r\n",
|
||||||
|
cfg.SMTPFrom, toEmail, subject, body,
|
||||||
|
))
|
||||||
|
|
||||||
|
auth := smtp.PlainAuth("", cfg.SMTPUser, cfg.SMTPPassword, cfg.SMTPHost)
|
||||||
|
|
||||||
|
// Port 465 is implicit TLS (encrypted from the first byte); everything else
|
||||||
|
// (587, 25) goes through smtp.SendMail's opportunistic STARTTLS upgrade.
|
||||||
|
if cfg.SMTPPort == "465" {
|
||||||
|
return sendImplicitTLS(cfg, auth, toEmail, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%s", cfg.SMTPHost, cfg.SMTPPort)
|
||||||
|
return smtp.SendMail(addr, auth, cfg.SMTPFrom, []string{toEmail}, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendImplicitTLS(cfg *config.Config, auth smtp.Auth, toEmail string, msg []byte) error {
|
||||||
|
addr := fmt.Sprintf("%s:%s", cfg.SMTPHost, cfg.SMTPPort)
|
||||||
|
|
||||||
|
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.SMTPHost})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tls dial failed: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
client, err := smtp.NewClient(conn, cfg.SMTPHost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp client init failed: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
if err := client.Auth(auth); err != nil {
|
||||||
|
return fmt.Errorf("smtp auth failed: %w", err)
|
||||||
|
}
|
||||||
|
if err := client.Mail(cfg.SMTPFrom); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := client.Rcpt(toEmail); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write(msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return client.Quit()
|
||||||
|
}
|
||||||
@@ -64,6 +64,8 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
customer.Post("/login", controllers.LoginCustomer)
|
customer.Post("/login", controllers.LoginCustomer)
|
||||||
customer.Post("/verify-pin", controllers.VerifyCustomerPin(cfg))
|
customer.Post("/verify-pin", controllers.VerifyCustomerPin(cfg))
|
||||||
customer.Post("/reset-pin", controllers.ResetCustomerPin)
|
customer.Post("/reset-pin", controllers.ResetCustomerPin)
|
||||||
|
customer.Post("/send-email-otp", controllers.SendCustomerEmailOtp(cfg))
|
||||||
|
customer.Post("/verify-email-otp", controllers.VerifyCustomerEmailOtp())
|
||||||
|
|
||||||
// Authenticated Customer App routes
|
// Authenticated Customer App routes
|
||||||
customerAuth := customer.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(9))
|
customerAuth := customer.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(9))
|
||||||
@@ -249,6 +251,8 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
hubAuth.Get("/bookings/unassigned", controllers.GetHubUnassignedBookings)
|
hubAuth.Get("/bookings/unassigned", controllers.GetHubUnassignedBookings)
|
||||||
hubAuth.Get("/inbound/today", controllers.GetHubInboundToday)
|
hubAuth.Get("/inbound/today", controllers.GetHubInboundToday)
|
||||||
hubAuth.Post("/bookings/:id/inbound", controllers.CreateInboundScan)
|
hubAuth.Post("/bookings/:id/inbound", controllers.CreateInboundScan)
|
||||||
|
hubAuth.Post("/bookings/:id/assign-miler", controllers.HubAssignMiler)
|
||||||
|
hubAuth.Post("/bookings/:id/auto-assign", controllers.HubAutoAssign)
|
||||||
hubAuth.Get("/batches", controllers.GetHubBatches)
|
hubAuth.Get("/batches", controllers.GetHubBatches)
|
||||||
hubAuth.Post("/batches", controllers.CreateHubBatch)
|
hubAuth.Post("/batches", controllers.CreateHubBatch)
|
||||||
hubAuth.Patch("/batches/:id/status", controllers.UpdateBatchStatus)
|
hubAuth.Patch("/batches/:id/status", controllers.UpdateBatchStatus)
|
||||||
@@ -259,6 +263,16 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
hubAuth.Get("/hubs", controllers.GetHubsInCity)
|
hubAuth.Get("/hubs", controllers.GetHubsInCity)
|
||||||
hubAuth.Post("/hubs", controllers.CreateCityHub)
|
hubAuth.Post("/hubs", controllers.CreateCityHub)
|
||||||
|
|
||||||
|
hubAuth.Get("/tripsheets/in-transit", controllers.GetInTransitTripsheets)
|
||||||
|
hubAuth.Get("/inbound/vehicles", controllers.GetInboundVehicles)
|
||||||
|
hubAuth.Get("/activity", controllers.GetHubActivity)
|
||||||
|
hubAuth.Get("/zones", controllers.GetHubZones)
|
||||||
|
hubAuth.Get("/notifications", controllers.GetHubNotifications)
|
||||||
|
hubAuth.Patch("/notifications/:id/read", controllers.MarkNotificationRead)
|
||||||
|
hubAuth.Get("/routing/:trackingno", controllers.GetRoutingInfo)
|
||||||
|
hubAuth.Get("/rider-routes", controllers.GetAllRiderRoutes)
|
||||||
|
hubAuth.Get("/milers/:id/route", controllers.GetMilerRoute)
|
||||||
|
|
||||||
// Redis active user caching utilities
|
// Redis active user caching utilities
|
||||||
redisUsers := api.Group("/utils/users/redis")
|
redisUsers := api.Group("/utils/users/redis")
|
||||||
redisUsers.Post("/", controllers.CreateUserRedis)
|
redisUsers.Post("/", controllers.CreateUserRedis)
|
||||||
|
|||||||
Reference in New Issue
Block a user