Hardening pass over the API surface. No route's auth requirements change.
Resilience:
- Add recover middleware. There was none, so an unhandled panic in any
handler propagated out of the process instead of becoming a 500.
- Add a centralized ErrorHandler so errors and recovered panics return the
same {success,message} envelope as the utils helpers, not Fiber's default
plain-text body. 5xx responses are logged with method and path.
Rate limiting:
- Global 300/min per IP as an abuse backstop, exempting health/readiness
probes and websocket upgrades.
- 10/min shared across every credential endpoint (customer/miler/admin/hub
login, verify-pin, reset-pin, email OTP). PINs are 4 digits, so the whole
keyspace was previously walkable in seconds. One shared limiter instance
means rotating between endpoints doesn't reset the budget.
- Add TRUSTED_PROXIES config. Limits key on c.IP(), which behind a TLS
terminator is the proxy, collapsing every client into one bucket. When set,
X-Forwarded-For is honoured only from those proxies so the header can't be
spoofed to dodge the limit. Logs a warning when unset.
Transactions:
- Check the error on all 51 previously-unchecked tx.Save/Create/Delete/
Model(...).Update/Commit calls across 6 controllers. A failed write inside
a transaction was silently ignored and the request still reported success;
an unchecked Commit could fail with the caller told everything worked.
Each site now rolls back and returns a specific message.
Pagination:
- Add utils.ParsePage/Paginated, reusing the pageno/pagesize convention
GetAdminBookings already established. Default 500, hard cap 1000.
- Apply to the previously unbounded consignments, tripsheets, exceptions,
app-users and clients endpoints. Defaults are high so existing consoles
that don't paginate keep working; the cap only stops a growing table from
being loaded wholesale. total is now a real COUNT, not len(data).
- GetClients also loaded the entire auth table to join in memory; it now
fetches only the current page's rows.
Tests (first in the repo):
- Extract the hyperlocal pincode rule out of BookingPickupComplete into
isHyperlocal so it is testable, covering the short/empty pincode fallback.
- Cover calculateVolumetricWeight and the ParsePage clamping rules.
Repo hygiene:
- Tag scratch/*.go with //go:build ignore. Each declared its own main(), so
`go build ./...` failed on redeclaration; it now passes repo-wide.
- Untrack scratch/node_modules (216 files) and ignore node_modules, test
artifacts, and the `doormile` binary `go build .` emits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1087 lines
31 KiB
Go
1087 lines
31 KiB
Go
package controllers
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"time"
|
|
|
|
"doormile/config"
|
|
"doormile/constants"
|
|
"doormile/db"
|
|
"doormile/dto"
|
|
"doormile/internal/assignment"
|
|
"doormile/internal/notify"
|
|
"doormile/models"
|
|
"doormile/utils"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
func generateTrackingNo() string {
|
|
b := make([]byte, 4)
|
|
rand.Read(b)
|
|
return fmt.Sprintf("DM-TRK-%X-%d", b, time.Now().Unix()%100000)
|
|
}
|
|
|
|
func LoginMiler(cfg *config.Config) fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
req := new(dto.MilerLoginRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Phone == "" {
|
|
return utils.BadRequest(c, "phone is required")
|
|
}
|
|
|
|
configID := req.Configid
|
|
if configID == 0 {
|
|
configID = 1001
|
|
}
|
|
|
|
var user models.AppUser
|
|
if err := db.DB.Where("contactno = ? AND configid = ?", req.Phone, configID).First(&user).Error; err != nil {
|
|
return utils.NotFound(c, "no miler account found for this phone number")
|
|
}
|
|
|
|
if user.Roleid != 5 {
|
|
return utils.Forbidden(c, "this endpoint is restricted to miler accounts")
|
|
}
|
|
|
|
if user.Status != "Active" {
|
|
return utils.Forbidden(c, "miler account is not active")
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"success": true,
|
|
"message": "PIN verification required",
|
|
"phone": req.Phone,
|
|
})
|
|
}
|
|
}
|
|
|
|
func VerifyMilerPin(cfg *config.Config) fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
req := new(dto.MilerPinVerifyRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Phone == "" || req.Pin == "" {
|
|
return utils.BadRequest(c, "phone and pin are required")
|
|
}
|
|
|
|
configID := req.Configid
|
|
if configID == 0 {
|
|
configID = 1001
|
|
}
|
|
|
|
var user models.AppUser
|
|
if err := db.DB.Where("contactno = ? AND configid = ?", req.Phone, configID).First(&user).Error; err != nil {
|
|
return utils.NotFound(c, "no miler account found for this phone number")
|
|
}
|
|
|
|
if user.Roleid != 5 {
|
|
return utils.Forbidden(c, "this endpoint is restricted to miler accounts")
|
|
}
|
|
|
|
if user.Status != "Active" {
|
|
return utils.Forbidden(c, "miler account is not active")
|
|
}
|
|
|
|
if !utils.CheckPasswordHash(req.Pin, user.Password) {
|
|
return utils.Unauthorized(c, "incorrect PIN")
|
|
}
|
|
|
|
token, err := utils.GenerateToken(user.Userid, user.Email, user.Roleid, user.Tenantid, user.Configid, cfg.JWTSecret)
|
|
if err != nil {
|
|
return utils.Internal(c, "failed to generate token")
|
|
}
|
|
|
|
var profile models.MilerProfile
|
|
if err := db.DB.Where("userid = ?", user.Userid).First(&profile).Error; err != nil {
|
|
profile = models.MilerProfile{
|
|
Userid: user.Userid,
|
|
Displayname: user.Authname,
|
|
Phone: user.Contactno,
|
|
Availabilitystatus: constants.MilerOffline,
|
|
Rating: 5.0,
|
|
Applocationid: user.Applocationid,
|
|
}
|
|
db.DB.Create(&profile)
|
|
}
|
|
if req.DeviceToken != "" && profile.Devicetoken != req.DeviceToken {
|
|
profile.Devicetoken = req.DeviceToken
|
|
db.DB.Model(&profile).Update("device_token", req.DeviceToken)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"success": true,
|
|
"token": token,
|
|
"user": fiber.Map{
|
|
"userid": user.Userid,
|
|
"authname": user.Authname,
|
|
"email": user.Email,
|
|
"contactno": user.Contactno,
|
|
"profile": profile,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
func GetMilerProfile(c *fiber.Ctx) error {
|
|
milerUserID := c.Locals("userid").(int)
|
|
|
|
var user models.AppUser
|
|
if err := db.DB.First(&user, milerUserID).Error; err != nil {
|
|
return utils.NotFound(c, "user not found")
|
|
}
|
|
|
|
var profile models.MilerProfile
|
|
if err := db.DB.Where("userid = ?", milerUserID).First(&profile).Error; err != nil {
|
|
return utils.NotFound(c, "miler profile not found")
|
|
}
|
|
|
|
return utils.OK(c, fiber.Map{
|
|
"userid": user.Userid,
|
|
"authname": user.Authname,
|
|
"email": user.Email,
|
|
"contactno": user.Contactno,
|
|
"profile": profile,
|
|
})
|
|
}
|
|
|
|
func UpdateMilerProfile(c *fiber.Ctx) error {
|
|
milerUserID := c.Locals("userid").(int)
|
|
|
|
var profile models.MilerProfile
|
|
if err := db.DB.Where("userid = ?", milerUserID).First(&profile).Error; err != nil {
|
|
return utils.NotFound(c, "miler profile not found")
|
|
}
|
|
|
|
type ProfileUpdate struct {
|
|
Displayname string `json:"displayname"`
|
|
Profilephotourl string `json:"profilephotourl"`
|
|
Defaultvehicletype string `json:"defaultvehicletype"`
|
|
Phone string `json:"phone"`
|
|
}
|
|
|
|
req := new(ProfileUpdate)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Displayname != "" {
|
|
profile.Displayname = req.Displayname
|
|
}
|
|
if req.Phone != "" {
|
|
profile.Phone = req.Phone
|
|
}
|
|
profile.Profilephotourl = req.Profilephotourl
|
|
profile.Defaultvehicletype = req.Defaultvehicletype
|
|
profile.Updatedat = time.Now()
|
|
|
|
if err := db.DB.Save(&profile).Error; err != nil {
|
|
return utils.Internal(c, "failed to update profile")
|
|
}
|
|
|
|
return utils.OK(c, profile)
|
|
}
|
|
|
|
func UpdateMilerLocation(c *fiber.Ctx) error {
|
|
milerUserID := c.Locals("userid").(int)
|
|
|
|
req := new(dto.MilerLocationUpdateRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Latitude == 0 || req.Longitude == 0 {
|
|
return utils.BadRequest(c, "latitude and longitude are required")
|
|
}
|
|
|
|
var profile models.MilerProfile
|
|
if err := db.DB.Where("userid = ?", milerUserID).First(&profile).Error; err != nil {
|
|
return utils.NotFound(c, "miler profile not found")
|
|
}
|
|
|
|
profile.Currentlatitude = req.Latitude
|
|
profile.Currentlongitude = req.Longitude
|
|
profile.Currentpincode = req.Pincode
|
|
now := time.Now()
|
|
profile.Lastlocationupdatedat = &now
|
|
profile.Updatedat = now
|
|
|
|
if err := db.DB.Save(&profile).Error; err != nil {
|
|
return utils.Internal(c, "failed to update location")
|
|
}
|
|
|
|
if db.Rdb != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
redisKey := fmt.Sprintf("miler:gps:%d", milerUserID)
|
|
val := fmt.Sprintf("%f,%f", req.Latitude, req.Longitude)
|
|
db.Rdb.Set(ctx, redisKey, val, 30*time.Minute)
|
|
|
|
db.Rdb.GeoAdd(ctx, "milers:locations", &redis.GeoLocation{
|
|
Name: strconv.Itoa(milerUserID),
|
|
Latitude: req.Latitude,
|
|
Longitude: req.Longitude,
|
|
})
|
|
}
|
|
|
|
return utils.OK(c, fiber.Map{
|
|
"latitude": req.Latitude,
|
|
"longitude": req.Longitude,
|
|
"pincode": req.Pincode,
|
|
})
|
|
}
|
|
|
|
func UpdateMilerAvailability(c *fiber.Ctx) error {
|
|
milerUserID := c.Locals("userid").(int)
|
|
|
|
req := new(dto.MilerAvailabilityRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Status == "" {
|
|
return utils.BadRequest(c, "status is required")
|
|
}
|
|
|
|
var profile models.MilerProfile
|
|
if err := db.DB.Where("userid = ?", milerUserID).First(&profile).Error; err != nil {
|
|
return utils.NotFound(c, "miler profile not found")
|
|
}
|
|
|
|
profile.Availabilitystatus = req.Status
|
|
profile.Updatedat = time.Now()
|
|
if err := db.DB.Save(&profile).Error; err != nil {
|
|
return utils.Internal(c, "failed to update availability")
|
|
}
|
|
|
|
var user models.AppUser
|
|
if err := db.DB.First(&user, milerUserID).Error; err == nil {
|
|
if req.Status == constants.MilerOffline || req.Status == constants.MilerBlocked {
|
|
user.Onduty = 0
|
|
} else {
|
|
user.Onduty = 1
|
|
}
|
|
db.DB.Save(&user)
|
|
}
|
|
|
|
return utils.OK(c, profile)
|
|
}
|
|
|
|
func GetMilerAssignments(c *fiber.Ctx) error {
|
|
milerUserID := c.Locals("userid").(int)
|
|
|
|
var assignments []models.BookingAssignment
|
|
if err := db.DB.Where("mileruserid = ? AND assignmentstatus IN ?", milerUserID,
|
|
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
|
Order("assignedat DESC").Find(&assignments).Error; err != nil {
|
|
return utils.Internal(c, "failed to fetch assignments")
|
|
}
|
|
|
|
return utils.List(c, assignments, int64(len(assignments)))
|
|
}
|
|
|
|
func GetMilerAssignmentDetails(c *fiber.Ctx) error {
|
|
milerUserID := c.Locals("userid").(int)
|
|
assignmentID, err := strconv.Atoi(c.Params("id"))
|
|
if err != nil {
|
|
return utils.BadRequest(c, "invalid assignment ID")
|
|
}
|
|
|
|
var assignment models.BookingAssignment
|
|
if err := db.DB.Where("bookingassignmentid = ? AND mileruserid = ?", assignmentID, milerUserID).First(&assignment).Error; err != nil {
|
|
return utils.NotFound(c, "assignment not found")
|
|
}
|
|
|
|
var booking models.PickupBooking
|
|
db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, assignment.Bookingid)
|
|
|
|
return utils.OK(c, fiber.Map{
|
|
"assignment": assignment,
|
|
"booking": booking,
|
|
})
|
|
}
|
|
|
|
func AcceptMilerAssignment(c *fiber.Ctx) error {
|
|
milerUserID := c.Locals("userid").(int)
|
|
assignmentID, err := strconv.Atoi(c.Params("id"))
|
|
if err != nil {
|
|
return utils.BadRequest(c, "invalid assignment ID")
|
|
}
|
|
|
|
tx := db.DB.Begin()
|
|
|
|
var assignment models.BookingAssignment
|
|
if err := tx.Where("bookingassignmentid = ? AND mileruserid = ?", assignmentID, milerUserID).First(&assignment).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.NotFound(c, "assignment not found")
|
|
}
|
|
|
|
if assignment.Assignmentstatus != constants.AssignmentAssigned {
|
|
tx.Rollback()
|
|
return utils.BadRequest(c, fmt.Sprintf("assignment is not pending acceptance (current status: %s)", assignment.Assignmentstatus))
|
|
}
|
|
|
|
now := time.Now()
|
|
assignment.Assignmentstatus = constants.AssignmentAccepted
|
|
assignment.Acceptedat = &now
|
|
if err := tx.Save(&assignment).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to accept assignment")
|
|
}
|
|
|
|
var booking models.PickupBooking
|
|
if err := tx.First(&booking, assignment.Bookingid).Error; err == nil {
|
|
booking.Status = constants.BookingPickupScheduled
|
|
booking.Assignedmileruserid = &milerUserID
|
|
booking.Updatedat = now
|
|
if err := tx.Save(&booking).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to update booking")
|
|
}
|
|
}
|
|
|
|
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
|
Update("availabilitystatus", constants.MilerAssigned).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to update miler availability")
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return utils.Internal(c, "failed to commit assignment acceptance")
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
func RejectMilerAssignment(c *fiber.Ctx) error {
|
|
milerUserID := c.Locals("userid").(int)
|
|
assignmentID, err := strconv.Atoi(c.Params("id"))
|
|
if err != nil {
|
|
return utils.BadRequest(c, "invalid assignment ID")
|
|
}
|
|
|
|
var req struct {
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
if req.Reason == "" {
|
|
req.Reason = "Rejected by rider"
|
|
}
|
|
|
|
tx := db.DB.Begin()
|
|
|
|
var ba models.BookingAssignment
|
|
if err := tx.Where("bookingassignmentid = ? AND mileruserid = ?", assignmentID, milerUserID).First(&ba).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.NotFound(c, "assignment not found")
|
|
}
|
|
|
|
ba.Assignmentstatus = constants.AssignmentRejected
|
|
ba.Remarks = req.Reason
|
|
if err := tx.Save(&ba).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to reject assignment")
|
|
}
|
|
|
|
var booking models.PickupBooking
|
|
if err := tx.First(&booking, ba.Bookingid).Error; err == nil {
|
|
booking.Status = constants.BookingCreated
|
|
booking.Assignedmileruserid = nil
|
|
booking.Updatedat = time.Now()
|
|
if err := tx.Save(&booking).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to release booking")
|
|
}
|
|
}
|
|
|
|
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
|
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to update miler availability")
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return utils.Internal(c, "failed to commit assignment rejection")
|
|
}
|
|
|
|
if booking.Bookingid != 0 {
|
|
if booking.Bookingsource == "CRM_Console" {
|
|
go assignment.AssignCRMMiler(booking.Bookingid)
|
|
} else {
|
|
go assignment.AssignCustomerMiler(booking.Bookingid)
|
|
}
|
|
}
|
|
|
|
return utils.Message(c, "assignment rejected")
|
|
}
|
|
|
|
func BookingReachedCustomer(c *fiber.Ctx) error {
|
|
milerUserID := c.Locals("userid").(int)
|
|
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
|
if err != nil {
|
|
return utils.BadRequest(c, "invalid booking ID")
|
|
}
|
|
|
|
tx := db.DB.Begin()
|
|
|
|
var booking models.PickupBooking
|
|
if err := tx.Where("bookingid = ? AND assignedmileruserid = ?", bookingID, milerUserID).First(&booking).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.NotFound(c, "assigned booking not found")
|
|
}
|
|
|
|
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
|
Update("availabilitystatus", constants.MilerAtCustomer).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to update miler availability")
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return utils.Internal(c, "failed to confirm arrival")
|
|
}
|
|
return utils.Message(c, "arrival at customer confirmed")
|
|
}
|
|
|
|
func BookingParcelConfirm(c *fiber.Ctx) error {
|
|
milerUserID := c.Locals("userid").(int)
|
|
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
|
if err != nil {
|
|
return utils.BadRequest(c, "invalid booking ID")
|
|
}
|
|
|
|
var booking models.PickupBooking
|
|
if err := db.DB.Where("bookingid = ? AND assignedmileruserid = ?", bookingID, milerUserID).First(&booking).Error; err != nil {
|
|
return utils.NotFound(c, "assigned booking not found")
|
|
}
|
|
|
|
type ParcelUpdate struct {
|
|
ParcelID int `json:"parcel_id"`
|
|
Weight float64 `json:"weight"`
|
|
Length float64 `json:"length"`
|
|
Width float64 `json:"width"`
|
|
Height float64 `json:"height"`
|
|
}
|
|
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 parcels []models.BookingParcel
|
|
if err := db.DB.Where("bookingid = ?", bookingID).Find(&parcels).Error; err != nil {
|
|
return utils.NotFound(c, "parcel details not found")
|
|
}
|
|
|
|
// 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]
|
|
}
|
|
|
|
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 {
|
|
milerUserID := c.Locals("userid").(int)
|
|
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
|
if err != nil {
|
|
return utils.BadRequest(c, "invalid booking ID")
|
|
}
|
|
|
|
var booking models.PickupBooking
|
|
if err := db.DB.Where("bookingid = ? AND assignedmileruserid = ?", bookingID, milerUserID).First(&booking).Error; err != nil {
|
|
return utils.NotFound(c, "assigned booking not found")
|
|
}
|
|
|
|
req := new(dto.PaymentRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Amount <= 0 {
|
|
return utils.BadRequest(c, "payment amount must be greater than zero")
|
|
}
|
|
|
|
now := time.Now()
|
|
payment := models.BookingPayment{
|
|
Bookingid: bookingID,
|
|
Amount: req.Amount,
|
|
Paymentmode: req.Paymentmode,
|
|
Paymentstatus: constants.PaymentStatusPaid,
|
|
Collectedbyuserid: &milerUserID,
|
|
Transactionref: req.Transactionref,
|
|
Paidat: &now,
|
|
}
|
|
|
|
if err := db.DB.Create(&payment).Error; err != nil {
|
|
return utils.Internal(c, "failed to record payment")
|
|
}
|
|
|
|
return utils.Created(c, payment)
|
|
}
|
|
|
|
// isHyperlocal reports whether a pickup and delivery pincode fall in the same
|
|
// 3-digit postal area, following the same zone-prefix convention as
|
|
// hubPincodePrefix in hubController.go. A same-area booking needs no
|
|
// hub-to-hub tripsheet leg, so the collecting miler can carry it straight to
|
|
// final-mile delivery. Pincodes shorter than 3 characters are treated as
|
|
// unknown rather than matching, so bad data falls back to the safe hub route.
|
|
func isHyperlocal(pickupPincode, deliveryPincode string) bool {
|
|
if len(pickupPincode) < 3 || len(deliveryPincode) < 3 {
|
|
return false
|
|
}
|
|
return pickupPincode[:3] == deliveryPincode[:3]
|
|
}
|
|
|
|
func BookingPickupComplete(c *fiber.Ctx) error {
|
|
milerUserID := c.Locals("userid").(int)
|
|
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
|
if err != nil {
|
|
return utils.BadRequest(c, "invalid booking ID")
|
|
}
|
|
|
|
tx := db.DB.Begin()
|
|
|
|
var booking models.PickupBooking
|
|
if err := tx.Where("bookingid = ? AND assignedmileruserid = ?", bookingID, milerUserID).First(&booking).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.NotFound(c, "assigned booking not found")
|
|
}
|
|
|
|
now := time.Now()
|
|
booking.Status = constants.BookingPickedUp
|
|
booking.Updatedat = now
|
|
if err := tx.Save(&booking).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to update booking status")
|
|
}
|
|
|
|
var profile models.MilerProfile
|
|
if err := tx.Where("userid = ?", milerUserID).First(&profile).Error; err == nil {
|
|
profile.Totalcompletedpickups += 1
|
|
profile.Availabilitystatus = constants.MilerPickedUp
|
|
profile.Updatedat = now
|
|
if err := tx.Save(&profile).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to update miler profile")
|
|
}
|
|
}
|
|
|
|
var parcels []models.BookingParcel
|
|
tx.Where("bookingid = ?", bookingID).Find(&parcels)
|
|
|
|
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()
|
|
|
|
var defaultHubID *int
|
|
if profile.Hubid != nil {
|
|
defaultHubID = profile.Hubid
|
|
} else {
|
|
utils.Warn("BookingPickupComplete: miler has no assigned hub, falling back to first hub row", "miler_user_id", milerUserID, "booking_id", bookingID)
|
|
var hub models.Hub
|
|
if tx.First(&hub).Error == nil {
|
|
defaultHubID = &hub.Hubid
|
|
}
|
|
}
|
|
|
|
// Hyperlocal shortcut: pickup and delivery in the same postal area mean
|
|
// no hub-to-hub tripsheet leg is needed, so the same miler goes straight
|
|
// to final-mile delivery instead of parking the consignment at the hub.
|
|
consignmentStatus := constants.ConsignmentInwardedAtHub
|
|
if isHyperlocal(booking.Pickuppincode, booking.Deliverypincode) {
|
|
consignmentStatus = constants.ConsignmentOutForDelivery
|
|
}
|
|
|
|
consignment := models.Consignment{
|
|
Trackingno: trackingNo,
|
|
Tenantid: c.Locals("tenantid").(int),
|
|
Pickuplatitude: booking.Pickuplatitude,
|
|
Pickuplongitude: booking.Pickuplongitude,
|
|
Deliverylatitude: booking.Deliverylatitude,
|
|
Deliverylongitude: booking.Deliverylongitude,
|
|
Pickuppincode: booking.Pickuppincode,
|
|
Deliverypincode: booking.Deliverypincode,
|
|
Length: maxL,
|
|
Width: maxW,
|
|
Height: maxH,
|
|
Deadweight: totalDead,
|
|
Volumetricweight: totalChargeable - totalDead,
|
|
Chargeableweight: totalChargeable,
|
|
Paymentmode: "Prepaid",
|
|
Status: consignmentStatus,
|
|
Estimateddeliveryat: nil,
|
|
Createdby: milerUserID,
|
|
Originhubid: defaultHubID,
|
|
Currenthubid: defaultHubID,
|
|
}
|
|
|
|
var payment models.BookingPayment
|
|
if tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil {
|
|
if payment.Paymentstatus == constants.PaymentStatusPaid {
|
|
consignment.Codcollected = payment.Amount
|
|
} else {
|
|
consignment.Codamount = payment.Amount
|
|
consignment.Paymentmode = "COD"
|
|
}
|
|
}
|
|
|
|
if err := tx.Create(&consignment).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to convert booking to consignment")
|
|
}
|
|
|
|
booking.Consignmentid = &consignment.Consignmentid
|
|
booking.Status = constants.BookingConvertedConsignment
|
|
if err := tx.Save(&booking).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to link booking to consignment")
|
|
}
|
|
|
|
history := models.ConsignmentHistory{
|
|
Consignmentid: consignment.Consignmentid,
|
|
Hubid: defaultHubID,
|
|
Userid: &milerUserID,
|
|
Eventstatus: consignmentStatus,
|
|
Remarks: "Package collected by miler and converted to consignment",
|
|
}
|
|
if err := tx.Create(&history).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to record consignment history")
|
|
}
|
|
|
|
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
|
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to update miler availability")
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return utils.Internal(c, "failed to complete pickup")
|
|
}
|
|
|
|
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,
|
|
"booking_no": booking.Bookingno,
|
|
})
|
|
}
|
|
|
|
func BookingVehicleRequiredEscalate(c *fiber.Ctx) error {
|
|
milerUserID := c.Locals("userid").(int)
|
|
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
|
if err != nil {
|
|
return utils.BadRequest(c, "invalid booking ID")
|
|
}
|
|
|
|
var booking models.PickupBooking
|
|
if err := db.DB.Where("bookingid = ? AND assignedmileruserid = ?", bookingID, milerUserID).First(&booking).Error; err != nil {
|
|
return utils.NotFound(c, "assigned booking not found")
|
|
}
|
|
|
|
reqVeh := models.BookingVehicleRequirement{
|
|
Bookingid: bookingID,
|
|
Requiredvehicletype: c.Query("type", "truck"),
|
|
Reason: c.Query("reason", "Package is too large for bike rider"),
|
|
Status: "Required",
|
|
}
|
|
|
|
if err := db.DB.Create(&reqVeh).Error; err != nil {
|
|
return utils.Internal(c, "failed to register vehicle requirement")
|
|
}
|
|
|
|
return utils.Created(c, reqVeh)
|
|
}
|
|
|
|
func CreateMilerPeriodicLog(c *fiber.Ctx) error {
|
|
ctx := context.Background()
|
|
|
|
var log models.MilerLog
|
|
if err := c.BodyParser(&log); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
t, err := time.Parse("2006-01-02 15:04:05", log.LogDate)
|
|
if err != nil {
|
|
return utils.BadRequest(c, "invalid logdate format — expected YYYY-MM-DD HH:MM:SS")
|
|
}
|
|
|
|
timestamp := t.Unix()
|
|
logKey := fmt.Sprintf("miler_periodic_log:%d:%d", log.UserID, timestamp)
|
|
data, _ := json.Marshal(log)
|
|
|
|
if db.Rdb != nil {
|
|
if err := db.Rdb.Set(ctx, logKey, data, 0).Err(); err != nil {
|
|
return utils.Internal(c, "failed to store log")
|
|
}
|
|
|
|
userZsetKey := fmt.Sprintf("miler_periodic_logs:%d", log.UserID)
|
|
db.Rdb.ZAdd(ctx, userZsetKey, redis.Z{Score: float64(timestamp), Member: logKey})
|
|
db.Rdb.ZAdd(ctx, "miler_periodic_logs_all", redis.Z{Score: float64(timestamp), Member: logKey})
|
|
}
|
|
|
|
return utils.Message(c, "miler periodic log stored successfully")
|
|
}
|
|
|
|
func GetMilerPeriodicLogs(c *fiber.Ctx) error {
|
|
ctx := context.Background()
|
|
|
|
if db.Rdb == nil {
|
|
return utils.Internal(c, "cache service unavailable")
|
|
}
|
|
|
|
userID := c.Query("userid")
|
|
|
|
var keys []string
|
|
var err error
|
|
|
|
if userID != "" {
|
|
zsetKey := fmt.Sprintf("miler_periodic_logs:%s", userID)
|
|
keys, err = db.Rdb.ZRevRange(ctx, zsetKey, 0, 0).Result()
|
|
} else {
|
|
keys, err = db.Rdb.ZRevRange(ctx, "miler_periodic_logs_all", 0, 0).Result()
|
|
}
|
|
|
|
if err != nil {
|
|
return utils.Internal(c, "failed to fetch logs")
|
|
}
|
|
|
|
if len(keys) == 0 {
|
|
return utils.List(c, []interface{}{}, 0)
|
|
}
|
|
|
|
val, err := db.Rdb.Get(ctx, keys[0]).Result()
|
|
if err != nil {
|
|
return utils.Internal(c, "failed to retrieve log data")
|
|
}
|
|
|
|
var log map[string]interface{}
|
|
json.Unmarshal([]byte(val), &log)
|
|
|
|
return utils.OK(c, log)
|
|
}
|
|
|
|
func CreateMilerStatus(c *fiber.Ctx) error {
|
|
ctx := context.Background()
|
|
|
|
var status models.MilerStatus
|
|
if err := c.BodyParser(&status); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if status.UserID == 0 || status.Status == "" {
|
|
return utils.BadRequest(c, "userid and status are required")
|
|
}
|
|
|
|
key := fmt.Sprintf("miler_status:%d", status.UserID)
|
|
data, _ := json.Marshal(status)
|
|
|
|
if db.Rdb != nil {
|
|
if err := db.Rdb.Set(ctx, key, data, 0).Err(); err != nil {
|
|
return utils.Internal(c, "failed to store status")
|
|
}
|
|
|
|
db.Rdb.ZAdd(ctx, "miler_status_all", redis.Z{
|
|
Score: float64(time.Now().Unix()),
|
|
Member: key,
|
|
})
|
|
}
|
|
|
|
return utils.Message(c, "miler status updated successfully")
|
|
}
|
|
|
|
func GetMilerStatus(c *fiber.Ctx) error {
|
|
ctx := context.Background()
|
|
|
|
if db.Rdb == nil {
|
|
return utils.Internal(c, "cache service unavailable")
|
|
}
|
|
|
|
userIDStr := c.Query("userid")
|
|
|
|
if userIDStr != "" {
|
|
key := fmt.Sprintf("miler_status:%s", userIDStr)
|
|
|
|
val, err := db.Rdb.Get(ctx, key).Result()
|
|
if err != nil {
|
|
return utils.NotFound(c, "status not found for this miler")
|
|
}
|
|
|
|
var data map[string]interface{}
|
|
json.Unmarshal([]byte(val), &data)
|
|
|
|
return utils.OK(c, data)
|
|
}
|
|
|
|
pageStr := c.Query("page")
|
|
pageSizeStr := c.Query("pagesize")
|
|
|
|
page, _ := strconv.Atoi(pageStr)
|
|
pageSize, _ := strconv.Atoi(pageSizeStr)
|
|
|
|
var start, end int64
|
|
if page > 0 && pageSize > 0 {
|
|
offset := (page - 1) * pageSize
|
|
start = int64(offset)
|
|
end = int64(offset + pageSize - 1)
|
|
} else {
|
|
start = 0
|
|
end = -1
|
|
}
|
|
|
|
keys, err := db.Rdb.ZRevRange(ctx, "miler_status_all", start, end).Result()
|
|
if err != nil {
|
|
return utils.Internal(c, "failed to fetch statuses")
|
|
}
|
|
|
|
if len(keys) == 0 {
|
|
return utils.List(c, []interface{}{}, 0)
|
|
}
|
|
|
|
values, err := db.Rdb.MGet(ctx, keys...).Result()
|
|
if err != nil {
|
|
return utils.Internal(c, "failed to retrieve status data")
|
|
}
|
|
|
|
var result []map[string]interface{}
|
|
for _, val := range values {
|
|
if val == nil {
|
|
continue
|
|
}
|
|
var item map[string]interface{}
|
|
json.Unmarshal([]byte(val.(string)), &item)
|
|
result = append(result, item)
|
|
}
|
|
|
|
return utils.List(c, result, int64(len(result)))
|
|
}
|
|
|
|
func PublishConsignmentLogs(c *fiber.Ctx) error {
|
|
var input []models.ConsignmentLog
|
|
if err := c.BodyParser(&input); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if len(input) == 0 {
|
|
return utils.BadRequest(c, "at least one log entry is required")
|
|
}
|
|
|
|
if db.Rdb == nil {
|
|
return utils.Internal(c, "cache service unavailable")
|
|
}
|
|
|
|
pipe := db.Rdb.TxPipeline()
|
|
tx := db.DB.Begin()
|
|
|
|
for _, item := range input {
|
|
logTime, err := time.Parse("2006-01-02 15:04:05", item.LogDate)
|
|
if err != nil {
|
|
logTime = time.Now()
|
|
}
|
|
|
|
ts := logTime.Unix()
|
|
logKey := "Consignmentlogs:" + strconv.Itoa(item.ConsignmentID)
|
|
userIndexKey := "user:consignmentlogs:" + strconv.Itoa(item.UserID)
|
|
|
|
jsonData, _ := json.Marshal(item)
|
|
|
|
pipe.RPush(db.Ctx, logKey, jsonData)
|
|
pipe.ZAdd(db.Ctx, userIndexKey, redis.Z{
|
|
Score: float64(ts),
|
|
Member: item.ConsignmentID,
|
|
})
|
|
|
|
history := models.ConsignmentHistory{
|
|
Consignmentid: item.ConsignmentID,
|
|
Userid: &item.UserID,
|
|
Eventstatus: item.Status,
|
|
Remarks: fmt.Sprintf("GPS Update: Lat %s, Lon %s. Speed %s. Remarks: %s", item.Latitude, item.Longitude, item.Speed, item.Remarks),
|
|
Createdat: logTime,
|
|
}
|
|
if err := tx.Create(&history).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to persist consignment log")
|
|
}
|
|
}
|
|
|
|
if _, err := pipe.Exec(db.Ctx); err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to publish logs to cache")
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return utils.Internal(c, "failed to publish consignment logs")
|
|
}
|
|
return utils.Message(c, "consignment logs published successfully")
|
|
}
|
|
|
|
func GetConsignmentLogs(c *fiber.Ctx) error {
|
|
consignmentID, err := strconv.Atoi(c.Params("consignmentid"))
|
|
if err != nil {
|
|
return utils.BadRequest(c, "invalid consignment ID")
|
|
}
|
|
|
|
if db.Rdb == nil {
|
|
return utils.Internal(c, "cache service unavailable")
|
|
}
|
|
|
|
logKey := "Consignmentlogs:" + strconv.Itoa(consignmentID)
|
|
redisList, err := db.Rdb.LRange(db.Ctx, logKey, 0, -1).Result()
|
|
|
|
if err == nil && len(redisList) > 0 {
|
|
var logs []map[string]interface{}
|
|
for _, raw := range redisList {
|
|
var m map[string]interface{}
|
|
json.Unmarshal([]byte(raw), &m)
|
|
logs = append(logs, m)
|
|
}
|
|
return utils.List(c, logs, int64(len(logs)))
|
|
}
|
|
|
|
var history []models.ConsignmentHistory
|
|
if err := db.DB.Where("consignmentid = ?", consignmentID).Order("createdat ASC").Find(&history).Error; err != nil {
|
|
return utils.Internal(c, "failed to fetch consignment logs")
|
|
}
|
|
|
|
return utils.List(c, history, int64(len(history)))
|
|
}
|
|
|
|
func GetUserConsignmentLogs(c *fiber.Ctx) error {
|
|
userID, err := strconv.Atoi(c.Params("userid"))
|
|
if err != nil {
|
|
return utils.BadRequest(c, "invalid user ID")
|
|
}
|
|
|
|
if db.Rdb == nil {
|
|
return utils.Internal(c, "cache service unavailable")
|
|
}
|
|
|
|
userIndexKey := "user:consignmentlogs:" + strconv.Itoa(userID)
|
|
members, err := db.Rdb.ZRevRange(db.Ctx, userIndexKey, 0, -1).Result()
|
|
if err != nil {
|
|
return utils.Internal(c, "failed to fetch consignment log index")
|
|
}
|
|
|
|
var logs []map[string]interface{}
|
|
for _, consignmentIDStr := range members {
|
|
logKey := "Consignmentlogs:" + consignmentIDStr
|
|
rawList, err := db.Rdb.LRange(db.Ctx, logKey, -1, -1).Result()
|
|
if err == nil && len(rawList) > 0 {
|
|
var m map[string]interface{}
|
|
json.Unmarshal([]byte(rawList[0]), &m)
|
|
logs = append(logs, m)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|