Milers now choose their own PIN the first time they log in, instead of the console assigning a shared default: - CreateMiler always creates a rider with an empty Password (PIN field removed from MilerCreateRequest); any client-supplied PIN is ignored, making "riders set their own PIN" a backend invariant, not a console convention. - LoginMiler returns `pin_set` so the app routes to enter-PIN vs set-PIN. - New POST /miler/set-pin (SetMilerPin): self-service first PIN, allowed ONLY when the account has none yet (409 otherwise, so it can't overwrite/take over an active account), then logs the rider in. Self-service and throttle-only is safe because of that guard; OTP-gate it once the SMS gateway is live. - verify-pin and set-pin share issueMilerSession so the two success responses can't drift. Also switches BookingPickupComplete's timestamp to DBNow() (IST) so the compatibility-flow inwardedat matches the reconciliation windows. Existing riders keep their PIN and are unaffected; blanking their password to move them onto self-set is a separate, deliberate DB step. go build, go vet and go test ./... all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
1964 lines
69 KiB
Go
1964 lines
69 KiB
Go
package controllers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"doormile/config"
|
|
"doormile/constants"
|
|
"doormile/db"
|
|
"doormile/dto"
|
|
"doormile/internal/assignment"
|
|
"doormile/internal/cxstage"
|
|
"doormile/internal/legs"
|
|
"doormile/internal/notify"
|
|
"doormile/internal/routing"
|
|
"doormile/models"
|
|
"doormile/utils"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
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")
|
|
}
|
|
|
|
// pin_set tells the app which screen to show next: true → enter-PIN
|
|
// (verify-pin login), false → set-PIN (first-time). A rider created without
|
|
// a PIN has an empty Password; every real PIN is a non-empty bcrypt hash.
|
|
return c.JSON(fiber.Map{
|
|
"success": true,
|
|
"message": "PIN verification required",
|
|
"phone": req.Phone,
|
|
"pin_set": user.Password != "",
|
|
})
|
|
}
|
|
}
|
|
|
|
// resolveTenantName returns the tenant's display name for a tenantid, or ""
|
|
// when the id is unset/unknown. The miler app resolves a rider's service
|
|
// profile (hyperlocal vs logistics) from this name in preference to the raw
|
|
// tenantid, so it must be present on the login and profile responses — a rider
|
|
// whose tenant we don't name falls back to id-matching, and any unmapped id
|
|
// falls back to logistics (no Start-delivery button). Cheap single-row lookup.
|
|
func resolveTenantName(tenantID int) string {
|
|
if tenantID == 0 {
|
|
return ""
|
|
}
|
|
var t models.Tenant
|
|
if err := db.DB.Select("tenantname").Where("tenantid = ?", tenantID).First(&t).Error; err != nil {
|
|
return ""
|
|
}
|
|
return t.Tenantname
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
return issueMilerSession(c, cfg, user, req.DeviceToken)
|
|
}
|
|
}
|
|
|
|
// issueMilerSession builds the authenticated-session response shared by
|
|
// verify-pin and set-pin: it mints the JWT, ensures a MilerProfile exists,
|
|
// refreshes the device token, and returns the single {token, user, profile}
|
|
// shape both entry points must agree on. tenantname drives the app's
|
|
// service-profile resolution (hyperlocal vs logistics), checked ahead of the
|
|
// raw tenantid and persisted client-side so a rider picks up a changed tenant
|
|
// name on next login.
|
|
func issueMilerSession(c *fiber.Ctx, cfg *config.Config, user models.AppUser, deviceToken string) error {
|
|
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 deviceToken != "" && profile.Devicetoken != deviceToken {
|
|
profile.Devicetoken = deviceToken
|
|
db.DB.Model(&profile).Update("device_token", deviceToken)
|
|
}
|
|
|
|
tenantName := resolveTenantName(user.Tenantid)
|
|
|
|
return c.JSON(fiber.Map{
|
|
"success": true,
|
|
"token": token,
|
|
"tenantid": user.Tenantid,
|
|
"tenantname": tenantName,
|
|
"user": fiber.Map{
|
|
"userid": user.Userid,
|
|
"authname": user.Authname,
|
|
"email": user.Email,
|
|
"contactno": user.Contactno,
|
|
"tenantid": user.Tenantid,
|
|
"tenantname": tenantName,
|
|
"profile": profile,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ResetMilerPin lets a miler who forgot their PIN set a new one from just
|
|
// their phone number, matching ResetCustomerPin's flow exactly (protected
|
|
// only by authThrottle at the route level, same as the customer version —
|
|
// no OTP verification wired in here either, consistent with the existing
|
|
// pattern rather than a change to it).
|
|
func ResetMilerPin(c *fiber.Ctx) error {
|
|
req := new(dto.MilerResetPinRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
if req.Phone == "" || req.NewPin == "" {
|
|
return utils.BadRequest(c, "phone and new_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")
|
|
}
|
|
|
|
pinHash, err := utils.HashPassword(req.NewPin)
|
|
if err != nil {
|
|
return utils.Internal(c, "failed to process PIN reset")
|
|
}
|
|
|
|
user.Password = pinHash
|
|
if err := db.DB.Save(&user).Error; err != nil {
|
|
return utils.Internal(c, "failed to reset PIN")
|
|
}
|
|
|
|
return utils.Message(c, "PIN reset successfully")
|
|
}
|
|
|
|
// SetMilerPin is the self-service first-login PIN creation, the counterpart to
|
|
// the ops-only ResetMilerPin. It is allowed ONLY when the account has no PIN yet
|
|
// (empty Password): because it can never overwrite an existing PIN, the
|
|
// phone-only unauthenticated path here cannot take over an already-active
|
|
// account the way an unguarded reset could — the worst case is a not-yet-used
|
|
// account being claimed by someone who knows the phone number, which OTP should
|
|
// close once the SMS gateway is live. A rider who already has a PIN is sent to
|
|
// verify-pin (or an ops reset) via 409. On success the rider is logged in
|
|
// immediately, so the app never has to make a second verify-pin call.
|
|
func SetMilerPin(cfg *config.Config) fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
req := new(dto.MilerSetPinRequest)
|
|
if err := c.BodyParser(req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
if req.Phone == "" || req.NewPin == "" {
|
|
return utils.BadRequest(c, "phone and new_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 user.Password != "" {
|
|
return utils.Conflict(c, "a PIN is already set for this account; use verify-pin to log in")
|
|
}
|
|
|
|
pinHash, err := utils.HashPassword(req.NewPin)
|
|
if err != nil {
|
|
return utils.Internal(c, "failed to set PIN")
|
|
}
|
|
user.Password = pinHash
|
|
if err := db.DB.Save(&user).Error; err != nil {
|
|
return utils.Internal(c, "failed to set PIN")
|
|
}
|
|
|
|
return issueMilerSession(c, cfg, user, req.DeviceToken)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// tenantname is included here too so the app can refresh it at launch via
|
|
// GET /miler/profile without forcing a re-login after the tenantname rollout.
|
|
return utils.OK(c, fiber.Map{
|
|
"userid": user.Userid,
|
|
"authname": user.Authname,
|
|
"email": user.Email,
|
|
"contactno": user.Contactno,
|
|
"tenantid": user.Tenantid,
|
|
"tenantname": resolveTenantName(user.Tenantid),
|
|
"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")
|
|
}
|
|
|
|
// Email and Address are pointers so an omitted field is left untouched rather
|
|
// than blanked. Email lives on AppUser (the login identity), Address on the
|
|
// profile — the edit screen collects both, so both are persisted here.
|
|
type ProfileUpdate struct {
|
|
Displayname string `json:"displayname"`
|
|
Profilephotourl string `json:"profilephotourl"`
|
|
Defaultvehicletype string `json:"defaultvehicletype"`
|
|
Phone string `json:"phone"`
|
|
Email *string `json:"email"`
|
|
Address *string `json:"address"`
|
|
}
|
|
|
|
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
|
|
}
|
|
if req.Address != nil {
|
|
profile.Address = *req.Address
|
|
}
|
|
profile.Profilephotourl = req.Profilephotourl
|
|
profile.Defaultvehicletype = req.Defaultvehicletype
|
|
profile.Updatedat = time.Now()
|
|
|
|
// Email is unique on appusers, so a collision must fail cleanly rather than
|
|
// 500. Only touch it when a non-empty value that actually changed is sent.
|
|
if req.Email != nil && *req.Email != "" {
|
|
var user models.AppUser
|
|
if err := db.DB.First(&user, milerUserID).Error; err == nil && !strings.EqualFold(user.Email, *req.Email) {
|
|
var clash int64
|
|
db.DB.Model(&models.AppUser{}).
|
|
Where("email = ? AND userid <> ?", *req.Email, milerUserID).Count(&clash)
|
|
if clash > 0 {
|
|
return utils.Fail(c, fiber.StatusConflict, constants.ErrEmailInUse, "that email is already in use")
|
|
}
|
|
if err := db.DB.Model(&models.AppUser{}).
|
|
Where("userid = ?", milerUserID).Update("email", *req.Email).Error; err != nil {
|
|
return utils.Internal(c, "failed to update email")
|
|
}
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
status := req.ResolvedStatus()
|
|
if 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 = 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
|
|
// Sequenced stops come first, in the road order internal/routing worked out,
|
|
// because that is the order the rider should actually ride them. Anything
|
|
// not yet sequenced (step 0 — a single stop, or the optimizer being
|
|
// unreachable) falls back to newest-first, which is the old behaviour.
|
|
if err := db.DB.Where("mileruserid = ? AND assignmentstatus IN ?", milerUserID,
|
|
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
|
Order("CASE WHEN step > 0 THEN 0 ELSE 1 END ASC, step ASC, assignedat DESC").
|
|
Find(&assignments).Error; err != nil {
|
|
return utils.Internal(c, "failed to fetch assignments")
|
|
}
|
|
|
|
// Enrich each assignment with its booking's consignment id + status so the app
|
|
// can act on the consignment (deliver/skip/start-delivery) straight from this
|
|
// list, without the extra per-order lookup it does today. Batched to two
|
|
// queries regardless of how many stops the rider holds.
|
|
bookingIDs := make([]int, 0, len(assignments))
|
|
for _, a := range assignments {
|
|
bookingIDs = append(bookingIDs, a.Bookingid)
|
|
}
|
|
type bookingRow struct {
|
|
Bookingid int
|
|
Consignmentid *int
|
|
Status string
|
|
}
|
|
bookingByID := map[int]bookingRow{}
|
|
consignmentIDs := make([]int, 0, len(bookingIDs))
|
|
if len(bookingIDs) > 0 {
|
|
var rows []bookingRow
|
|
db.DB.Model(&models.PickupBooking{}).
|
|
Select("bookingid, consignmentid, status").
|
|
Where("bookingid IN ?", bookingIDs).Scan(&rows)
|
|
for _, r := range rows {
|
|
bookingByID[r.Bookingid] = r
|
|
if r.Consignmentid != nil {
|
|
consignmentIDs = append(consignmentIDs, *r.Consignmentid)
|
|
}
|
|
}
|
|
}
|
|
consignmentStatusByID := map[int]string{}
|
|
if len(consignmentIDs) > 0 {
|
|
type cnRow struct {
|
|
Consignmentid int
|
|
Status string
|
|
}
|
|
var rows []cnRow
|
|
db.DB.Model(&models.Consignment{}).
|
|
Select("consignmentid, status").
|
|
Where("consignmentid IN ?", consignmentIDs).Scan(&rows)
|
|
for _, r := range rows {
|
|
consignmentStatusByID[r.Consignmentid] = r.Status
|
|
}
|
|
}
|
|
|
|
// Embedded so every existing assignment field stays flat at the top level and
|
|
// the app's current parsing is unaffected; the three new keys are additive.
|
|
type enrichedAssignment struct {
|
|
models.BookingAssignment
|
|
Consignmentid *int `json:"consignmentid"`
|
|
Consignmentstatus string `json:"consignmentstatus"`
|
|
Bookingstatus string `json:"bookingstatus"`
|
|
}
|
|
out := make([]enrichedAssignment, 0, len(assignments))
|
|
for _, a := range assignments {
|
|
e := enrichedAssignment{BookingAssignment: a}
|
|
if b, ok := bookingByID[a.Bookingid]; ok {
|
|
e.Bookingstatus = b.Status
|
|
e.Consignmentid = b.Consignmentid
|
|
if b.Consignmentid != nil {
|
|
e.Consignmentstatus = consignmentStatusByID[*b.Consignmentid]
|
|
}
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
|
|
return utils.List(c, out, int64(len(out)))
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// Accepting is the customer's "on the way": the rider has seen the job and
|
|
// is heading over. `assigned` was already recorded when the assignment was
|
|
// created (assignMilerTx / commitAssignment) and is re-asserted here only
|
|
// as a safety net for a booking assigned before this surface existed —
|
|
// Record dedupes, so it appends nothing when it is already on the timeline.
|
|
//
|
|
// Deriving on_the_way from the GPS stream instead would mean re-deriving it
|
|
// on every ping: thousands of writes to learn something the accept already
|
|
// said.
|
|
stageAt := utils.DBNow()
|
|
for _, stage := range []string{constants.CxStageAssigned, constants.CxStageOnTheWay} {
|
|
if err := cxstage.Record(tx, cxstage.Event{
|
|
BookingID: assignment.Bookingid,
|
|
Stage: stage,
|
|
ActorType: constants.CxActorMiler,
|
|
ActorID: &milerUserID,
|
|
Source: "POST /miler/assignments/{id}/accept",
|
|
At: stageAt,
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
utils.Error("AcceptMilerAssignment: could not record customer stage",
|
|
"booking_id", assignment.Bookingid, "stage", stage, "error", err)
|
|
return utils.Internal(c, "failed to accept assignment")
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return utils.Internal(c, "failed to commit assignment acceptance")
|
|
}
|
|
|
|
// No push here: `assigned` was already announced when the assignment was
|
|
// created, and on_the_way deliberately rolls up on the timeline. The
|
|
// existing "Miler Accepted" notification below is the one the customer gets.
|
|
|
|
// Accepting a stop moves it into the active set the optimizer orders over, so
|
|
// re-sequence the rider off the request path. No-op below two active stops.
|
|
routing.SequenceMilerStopsAsync(milerUserID)
|
|
|
|
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")
|
|
}
|
|
|
|
// The reason may arrive in the JSON body or as a ?reason= query string — the
|
|
// deployed contract and the app disagreed on which, so accept both and prefer
|
|
// whichever is non-empty. An absent/invalid body is not an error here.
|
|
var req struct {
|
|
Reason string `json:"reason"`
|
|
}
|
|
_ = c.BodyParser(&req)
|
|
if req.Reason == "" {
|
|
req.Reason = c.Query("reason")
|
|
}
|
|
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")
|
|
}
|
|
|
|
// MilerCancelAssignment lets a miler back out of a booking they've already
|
|
// accepted but not yet picked up (vehicle breakdown, can't reach the
|
|
// address, etc.). Distinct from RejectMilerAssignment, which only applies
|
|
// before acceptance — once the parcel is picked up the booking has become a
|
|
// consignment and this no longer applies (use MilerSkipDelivery instead for
|
|
// a failed delivery attempt on an in-flight consignment). Releases the
|
|
// booking for reassignment the same way RejectMilerAssignment does.
|
|
func MilerCancelAssignment(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 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 = "Cancelled by miler"
|
|
}
|
|
|
|
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 booking.Status == constants.BookingPickedUp || booking.Status == constants.BookingConvertedConsignment {
|
|
tx.Rollback()
|
|
return utils.BadRequest(c, "booking cannot be cancelled after pickup — the parcel is already in the network")
|
|
}
|
|
|
|
var ba models.BookingAssignment
|
|
if err := tx.Where("bookingid = ? AND mileruserid = ? AND assignmentstatus = ?",
|
|
bookingID, milerUserID, constants.AssignmentAccepted).First(&ba).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.BadRequest(c, "no accepted assignment found for this booking")
|
|
}
|
|
|
|
ba.Assignmentstatus = constants.AssignmentCancelled
|
|
ba.Remarks = req.Reason
|
|
if err := tx.Save(&ba).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to cancel assignment")
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// The pickup is NOT cancelled — it goes back into the pool. The customer's
|
|
// stage has to walk back with it, or they keep seeing "Miler assigned" and
|
|
// a rider card for someone who is no longer coming.
|
|
if err := cxstage.Release(tx, booking.Bookingid, req.Reason,
|
|
constants.CxActorMiler, &milerUserID,
|
|
"POST /miler/bookings/{id}/cancel"); err != nil {
|
|
tx.Rollback()
|
|
utils.Error("MilerCancelAssignment: could not release the customer stage", "booking_id", booking.Bookingid, "error", err)
|
|
return utils.Internal(c, "failed to commit cancellation")
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return utils.Internal(c, "failed to commit cancellation")
|
|
}
|
|
|
|
if booking.Bookingsource == "CRM_Console" {
|
|
go assignment.AssignCRMMiler(booking.Bookingid)
|
|
} else {
|
|
go assignment.AssignCustomerMiler(booking.Bookingid)
|
|
}
|
|
|
|
return utils.Message(c, "assignment cancelled and released for reassignment")
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// Rider's actual GPS at the moment of arrival. Optional in the body but the
|
|
// app sends it; stored as a durable arrival record, distinct from live
|
|
// telemetry. Zero is treated as "not supplied" and left null.
|
|
var req struct {
|
|
Latitude float64 `json:"latitude"`
|
|
Longitude float64 `json:"longitude"`
|
|
}
|
|
_ = c.BodyParser(&req)
|
|
|
|
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.Fail(c, fiber.StatusNotFound, constants.ErrBookingNotAssigned, "assigned booking not found")
|
|
}
|
|
|
|
// Persist the arrival as a FACT (timestamp + GPS), not as a status change.
|
|
// The rider app derives its "Arrived" rung from Pickup_Scheduled + a non-null
|
|
// reachedat, so the booking status is deliberately left untouched here —
|
|
// introducing an Arrived_At_Pickup status would break that derivation (the
|
|
// app classifies strictly by the known status strings) and would need a
|
|
// console mapping. Keeping only the fact makes Arrived survive an app restart
|
|
// with no new status and no console dependency. reachedat is refreshed on a
|
|
// repeat call; GPS is stored only when the app supplies it.
|
|
now := time.Now()
|
|
booking.Arrivedat = &now
|
|
if req.Latitude != 0 && req.Longitude != 0 {
|
|
lat, lng := req.Latitude, req.Longitude
|
|
booking.Arrivallatitude = &lat
|
|
booking.Arrivallongitude = &lng
|
|
}
|
|
booking.Updatedat = now
|
|
if err := tx.Save(&booking).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to record arrival")
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// Arrival is the LAST cancellable stage on the customer side, so it has to
|
|
// be recorded transactionally with the arrival fact itself. A gap between
|
|
// the two is a window in which the customer can still cancel a pickup the
|
|
// rider is already standing at.
|
|
if err := cxstage.Record(tx, cxstage.Event{
|
|
BookingID: booking.Bookingid,
|
|
Stage: constants.CxStageArrived,
|
|
ActorType: constants.CxActorMiler,
|
|
ActorID: &milerUserID,
|
|
Source: "POST /miler/bookings/{id}/reached",
|
|
At: utils.DBNow(),
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
utils.Error("BookingReachedCustomer: could not record arrived stage", "booking_id", booking.Bookingid, "error", err)
|
|
return utils.Internal(c, "failed to record arrival")
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return utils.Internal(c, "failed to confirm arrival")
|
|
}
|
|
|
|
go cxstage.Notify(booking.Bookingid, nil, constants.CxStageArrived)
|
|
|
|
return utils.OK(c, fiber.Map{
|
|
"bookingid": booking.Bookingid,
|
|
"status": booking.Status,
|
|
"reachedat": booking.Arrivedat,
|
|
})
|
|
}
|
|
|
|
// BookingUpdateAddresses lets the rider correct the pickup/delivery address,
|
|
// pincode, coordinates and delivery city at the door, before the parcel is
|
|
// converted to a consignment. Only fields actually sent are written (pointer
|
|
// DTO), so a partial update never wipes an existing value — a request that
|
|
// carries just corrected delivery coordinates leaves the address untouched.
|
|
//
|
|
// Must run before pickup-complete: once the booking is picked up / converted,
|
|
// the shipment's addresses are frozen on the consignment and this is rejected
|
|
// with INVALID_STATE. Correcting delivery coordinates here also feeds route
|
|
// sequencing, which skips stops sitting at 0,0.
|
|
func BookingUpdateAddresses(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 req struct {
|
|
Pickupaddress *string `json:"pickupaddress"`
|
|
Pickuppincode *string `json:"pickuppincode"`
|
|
Pickuplatitude *float64 `json:"pickuplatitude"`
|
|
Pickuplongitude *float64 `json:"pickuplongitude"`
|
|
Deliveryaddress *string `json:"deliveryaddress"`
|
|
Deliverypincode *string `json:"deliverypincode"`
|
|
Deliverylatitude *float64 `json:"deliverylatitude"`
|
|
Deliverylongitude *float64 `json:"deliverylongitude"`
|
|
Deliverycity *string `json:"deliverycity"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return utils.BadRequest(c, "invalid request body")
|
|
}
|
|
|
|
var booking models.PickupBooking
|
|
if err := db.DB.Where("bookingid = ? AND assignedmileruserid = ?", bookingID, milerUserID).First(&booking).Error; err != nil {
|
|
return utils.Fail(c, fiber.StatusNotFound, constants.ErrBookingNotAssigned, "assigned booking not found")
|
|
}
|
|
|
|
if booking.Status == constants.BookingPickedUp ||
|
|
booking.Status == constants.BookingConvertedConsignment {
|
|
return utils.Fail(c, fiber.StatusConflict, constants.ErrInvalidState,
|
|
"addresses can only be edited before pickup-complete")
|
|
}
|
|
|
|
// Build only the columns actually supplied. A non-empty string or a non-zero
|
|
// coordinate counts as supplied; an omitted field (nil pointer) is left as-is.
|
|
updates := map[string]interface{}{}
|
|
if req.Pickupaddress != nil && strings.TrimSpace(*req.Pickupaddress) != "" {
|
|
updates["pickupaddress"] = strings.TrimSpace(*req.Pickupaddress)
|
|
}
|
|
if req.Pickuppincode != nil && strings.TrimSpace(*req.Pickuppincode) != "" {
|
|
updates["pickuppincode"] = strings.TrimSpace(*req.Pickuppincode)
|
|
}
|
|
if req.Pickuplatitude != nil && *req.Pickuplatitude != 0 {
|
|
updates["pickuplatitude"] = *req.Pickuplatitude
|
|
}
|
|
if req.Pickuplongitude != nil && *req.Pickuplongitude != 0 {
|
|
updates["pickuplongitude"] = *req.Pickuplongitude
|
|
}
|
|
if req.Deliveryaddress != nil && strings.TrimSpace(*req.Deliveryaddress) != "" {
|
|
updates["deliveryaddress"] = strings.TrimSpace(*req.Deliveryaddress)
|
|
}
|
|
if req.Deliverypincode != nil && strings.TrimSpace(*req.Deliverypincode) != "" {
|
|
updates["deliverypincode"] = strings.TrimSpace(*req.Deliverypincode)
|
|
}
|
|
if req.Deliverylatitude != nil && *req.Deliverylatitude != 0 {
|
|
updates["deliverylatitude"] = *req.Deliverylatitude
|
|
}
|
|
if req.Deliverylongitude != nil && *req.Deliverylongitude != 0 {
|
|
updates["deliverylongitude"] = *req.Deliverylongitude
|
|
}
|
|
if req.Deliverycity != nil && strings.TrimSpace(*req.Deliverycity) != "" {
|
|
updates["deliverycity"] = strings.TrimSpace(*req.Deliverycity)
|
|
}
|
|
|
|
if len(updates) == 0 {
|
|
return utils.BadRequest(c, "no address fields supplied to update")
|
|
}
|
|
updates["updatedat"] = time.Now()
|
|
|
|
if err := db.DB.Model(&booking).Updates(updates).Error; err != nil {
|
|
return utils.Internal(c, "failed to update addresses")
|
|
}
|
|
|
|
return utils.OK(c, booking)
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// Photos are the evidence half of the receipt. Weight without a photograph
|
|
// is a number the customer has no way to check, and this is the only point
|
|
// in the flow where anyone is standing next to the parcel. Sent as storage
|
|
// keys from the presigned upload (POST /miler/uploads/sign), not as raw
|
|
// URLs: the customer is served a short-lived signed link derived from the
|
|
// key, never a permanent one.
|
|
type ParcelUpdate struct {
|
|
ParcelID int `json:"parcel_id"`
|
|
Weight float64 `json:"weight"`
|
|
Length float64 `json:"length"`
|
|
Width float64 `json:"width"`
|
|
Height float64 `json:"height"`
|
|
Photos []string `json:"photos"`
|
|
}
|
|
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 := utils.DBNow()
|
|
var totalChargeable float64
|
|
// Chargeable weight per destination, so each order settles on the weight of
|
|
// its own parcels rather than on the whole visit's total.
|
|
perDestination := map[int]float64{}
|
|
|
|
tx := db.DB.Begin()
|
|
|
|
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
|
|
if err := tx.Save(p).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to save parcel measurements")
|
|
}
|
|
|
|
volumetric := calculateVolumetricWeight(upd.Length, upd.Width, upd.Height)
|
|
chargeable := math.Max(upd.Weight, volumetric)
|
|
totalChargeable += chargeable
|
|
if p.Bookingdestinationid != nil {
|
|
perDestination[*p.Bookingdestinationid] += chargeable
|
|
}
|
|
|
|
for _, key := range upd.Photos {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
photo := models.BookingParcelPhoto{
|
|
Bookingid: bookingID,
|
|
Bookingdestinationid: p.Bookingdestinationid,
|
|
Objectkey: key,
|
|
Capturedbyuserid: &milerUserID,
|
|
Capturedat: now,
|
|
}
|
|
if err := tx.Create(&photo).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to save parcel photo")
|
|
}
|
|
}
|
|
}
|
|
|
|
// The verification block the customer's receipt reads. Written here, at the
|
|
// door, where the measurement was actually taken — pickup-complete restates
|
|
// it from the same parcel rows when the price settles, so the two cannot
|
|
// disagree.
|
|
for destinationID, weight := range perDestination {
|
|
if err := cxRecordVerification(tx, destinationID, weight, milerUserID, now); err != nil {
|
|
tx.Rollback()
|
|
utils.Error("BookingParcelConfirm: could not record verification", "booking_id", bookingID, "error", err)
|
|
return utils.Internal(c, "failed to record the parcel weight")
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return utils.Internal(c, "failed to confirm parcels")
|
|
}
|
|
|
|
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 {
|
|
return legs.SamePostalArea(pickupPincode, deliveryPincode)
|
|
}
|
|
|
|
// maxHyperlocalKM bounds the straight-line pickup→delivery distance under which
|
|
// a booking with a missing/short pincode is still treated as hyperlocal. It is
|
|
// used ONLY as a fallback when the pincode-prefix rule can't decide: console-
|
|
// created kitchen→customer bookings (e.g. DailyGrubs) frequently carry accurate
|
|
// coordinates but no delivery pincode, and must not be wrongly routed through a
|
|
// hub. When both pincodes are present the prefix rule still wins.
|
|
const maxHyperlocalKM = legs.MaxHyperlocalKM
|
|
|
|
// isHyperlocalBooking decides whether a booking can skip the hub and be carried
|
|
// straight to the final mile. It prefers the pincode-prefix rule (isHyperlocal)
|
|
// when both pincodes are present, and falls back to the straight-line distance
|
|
// between the pickup and delivery coordinates when a pincode is missing — so a
|
|
// same-area booking whose address carried no pincode isn't sent to a hub.
|
|
func isHyperlocalBooking(pickupPincode, deliveryPincode string, pLat, pLng, dLat, dLng float64) bool {
|
|
return legs.IsHyperlocal(pickupPincode, deliveryPincode, pLat, pLng, dLat, dLng)
|
|
}
|
|
|
|
// collectedStateEnabled gates the two-step hyperlocal delivery flow
|
|
// (Collected_By_Miler → start-delivery → Out_for_Delivery) and its knock-on
|
|
// changes: OTP issued at start-delivery instead of pickup, and Arrived_At_Pickup
|
|
// persisted on the reached action. Default OFF so this code deploys without
|
|
// changing behaviour — a hyperlocal pickup still goes straight to
|
|
// Out_for_Delivery and the *current* rider app keeps working. It MUST stay off
|
|
// until a rider-app build that calls start-delivery is live; flipping it early
|
|
// would strand every collected parcel in a state the old app can't advance.
|
|
//
|
|
// Read at request time (env MILER_COLLECTED_STATE_ENABLED=true) so it can be
|
|
// turned on the moment the app ships without a redeploy. The additive parts of
|
|
// this work — consignmentid on the lists, the GET consignment endpoint, stable
|
|
// error codes, idempotency — are NOT gated; they are safe for the old app and
|
|
// are what fixes the deliver-button P0 on their own.
|
|
func collectedStateEnabled() bool {
|
|
return strings.EqualFold(os.Getenv("MILER_COLLECTED_STATE_ENABLED"), "true")
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// IST wall-clock (DBNow), so the compatibility-flow inwardedat stamped below
|
|
// (and booking.updatedat) matches createdat and the reconciliation windows.
|
|
now := utils.DBNow()
|
|
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")
|
|
}
|
|
}
|
|
|
|
// One visit, N orders. A customer-app booking fans out into one consignment
|
|
// per destination — each with its own tracking number and its own journey —
|
|
// while a console-created booking, which has no destination rows, produces
|
|
// the single consignment it always did. cxPickupLegs is what decides which
|
|
// of those this is; nothing below needs to know.
|
|
legs, err := cxPickupLegs(tx, &booking)
|
|
if err != nil {
|
|
tx.Rollback()
|
|
utils.Error("BookingPickupComplete: could not resolve pickup legs", "booking_id", bookingID, "error", err)
|
|
return utils.Internal(c, "failed to read the parcels on this booking")
|
|
}
|
|
|
|
// The base this parcel belongs to. Backend decides — the app is told where to
|
|
// go and never picks a base itself. resolveHandoverHub prefers the base the
|
|
// booking was routed to, then the collecting rider's own base (the previous
|
|
// behaviour, and still the operational default), then the nearest active base
|
|
// to the pickup point — that last step replaces a fallback that took whichever
|
|
// hub row happened to come back first.
|
|
handoverHub := resolveHandoverHub(&booking, profile.Hubid)
|
|
var defaultHubID *int
|
|
if handoverHub != nil {
|
|
defaultHubID = &handoverHub.Hubid
|
|
} else {
|
|
utils.Warn("BookingPickupComplete: no base could be resolved for this pickup", "miler_user_id", milerUserID, "booking_id", bookingID)
|
|
}
|
|
|
|
// The consignment's tenant is the booking's own tenant (set explicitly at
|
|
// CreateExpressBooking time), not the completing miler's tenantid claim — a
|
|
// miler can carry parcels for tenants other than their own, and using
|
|
// their JWT tenantid here mis-attributed every such consignment. Falls
|
|
// back to the miler's own tenantid only for B2C bookings that don't carry
|
|
// one yet, matching the previous behavior for that case.
|
|
consignmentTenantID := c.Locals("tenantid").(int)
|
|
if booking.Tenantid != nil {
|
|
consignmentTenantID = *booking.Tenantid
|
|
}
|
|
|
|
var tenant models.Tenant
|
|
tenantNeedsOTP := false
|
|
if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil {
|
|
tenantNeedsOTP = tenant.Requiredeliveryotp
|
|
}
|
|
|
|
// Money collected at the door. Split across the legs below rather than
|
|
// stamped whole onto each one: a single payment covering a three-stop
|
|
// pickup must not appear three times in the books.
|
|
var payment models.BookingPayment
|
|
hasPayment := tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil
|
|
|
|
created := make([]models.Consignment, 0, len(legs))
|
|
trackingNos := make([]string, 0, len(legs))
|
|
riderMarkedBusy := false
|
|
assignmentStillOpen := false
|
|
|
|
for i, leg := range legs {
|
|
totalDead, totalChargeable, maxL, maxW, maxH := cxLegWeights(leg)
|
|
trackingNo := generateTrackingNo()
|
|
|
|
// A hub-routed parcel: with the hub-handover flow ON it stops at Created —
|
|
// collected, in the rider's hands, on its way to a base — and only reaches
|
|
// Inwarded_at_Hub when the handover is actually recorded. With it OFF
|
|
// (default, and what the current app expects) it is marked Inwarded_at_Hub
|
|
// here, which is not where the parcel physically is but is what the current
|
|
// app and the console's inbound views read.
|
|
consignmentStatus := constants.ConsignmentInwardedAtHub
|
|
if hubHandoverEnabled() {
|
|
consignmentStatus = constants.ConsignmentCreated
|
|
}
|
|
|
|
// Hyperlocal shortcut: pickup and delivery in the same postal area mean no
|
|
// hub-to-hub tripsheet leg is needed, so the same miler carries it to the
|
|
// final mile instead of parking it at the hub. Decided per leg, because on
|
|
// a multi-destination pickup one parcel can be going round the corner while
|
|
// another is going to another state.
|
|
//
|
|
// With the collected-state flow ON it lands in Collected_By_Miler — collected
|
|
// but not yet out for delivery — and the rider taps start-delivery to move it
|
|
// to Out_for_Delivery, which lets the console tell "collected" from "actively
|
|
// delivering". With it OFF (default, and what the current app expects) it goes
|
|
// straight to Out_for_Delivery exactly as before.
|
|
if isHyperlocalBooking(booking.Pickuppincode, leg.DeliveryPincode,
|
|
booking.Pickuplatitude, booking.Pickuplongitude,
|
|
leg.DeliveryLatitude, leg.DeliveryLongitude) {
|
|
if collectedStateEnabled() {
|
|
consignmentStatus = constants.ConsignmentCollectedByMiler
|
|
} else {
|
|
consignmentStatus = constants.ConsignmentOutForDelivery
|
|
}
|
|
}
|
|
|
|
// Carried over so the consignment stays traceable to the client site it was
|
|
// collected from — for a food client that's the kitchen, and "how many
|
|
// parcels went out of which kitchen" is unanswerable without it.
|
|
consignment := models.Consignment{
|
|
Trackingno: trackingNo,
|
|
Tenantid: consignmentTenantID,
|
|
Pickuplocationid: booking.Pickuplocationid,
|
|
Tenantlocationid: booking.Tenantlocationid,
|
|
Pickuplatitude: booking.Pickuplatitude,
|
|
Pickuplongitude: booking.Pickuplongitude,
|
|
Deliverylatitude: leg.DeliveryLatitude,
|
|
Deliverylongitude: leg.DeliveryLongitude,
|
|
Pickuppincode: booking.Pickuppincode,
|
|
Deliverypincode: leg.DeliveryPincode,
|
|
Length: maxL,
|
|
Width: maxW,
|
|
Height: maxH,
|
|
Deadweight: totalDead,
|
|
Volumetricweight: totalChargeable - totalDead,
|
|
Chargeableweight: totalChargeable,
|
|
Paymentmode: "Prepaid",
|
|
Status: consignmentStatus,
|
|
Estimateddeliveryat: cxEstimatedDelivery(leg, now),
|
|
Createdby: milerUserID,
|
|
Originhubid: defaultHubID,
|
|
Currenthubid: defaultHubID,
|
|
}
|
|
|
|
// COD the customer asked to have collected at THIS door, on their behalf.
|
|
// Doormile is the carrier, not the seller — this money is never Doormile's.
|
|
if leg.CodAmount > 0 {
|
|
consignment.Codamount = leg.CodAmount
|
|
consignment.Paymentmode = "COD"
|
|
}
|
|
|
|
// Under the compatibility flow the parcel is treated as received at the base
|
|
// the moment it is collected, so the received-at fact is stamped here too —
|
|
// otherwise every parcel inwarded this way would have a null handover time
|
|
// and base reconciliation would have nothing to compare against.
|
|
if consignmentStatus == constants.ConsignmentInwardedAtHub {
|
|
consignment.Inwardedat = &now
|
|
}
|
|
|
|
// The pickup fee the miler collected covers the whole visit, so it is
|
|
// recorded once — against the first order — rather than repeated on
|
|
// every leg.
|
|
if hasPayment && i == 0 {
|
|
if payment.Paymentstatus == constants.PaymentStatusPaid {
|
|
consignment.Codcollected = payment.Amount
|
|
} else {
|
|
consignment.Codamount += payment.Amount
|
|
consignment.Paymentmode = "COD"
|
|
}
|
|
}
|
|
|
|
// A parcel that goes straight out for delivery here (collected-state flow off)
|
|
// needs its receiver OTP before commit — same as before. When the flow is on,
|
|
// a hyperlocal parcel stops at Collected_By_Miler and its OTP is issued later
|
|
// at start-delivery instead, so this block simply doesn't fire. Only clients
|
|
// that ask for one get an OTP (Tenant.Requiredeliveryotp).
|
|
if consignmentStatus == constants.ConsignmentOutForDelivery && tenantNeedsOTP {
|
|
consignment.Deliveryotp = utils.GenerateNumericOTP(6)
|
|
}
|
|
|
|
if err := tx.Create(&consignment).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to convert booking to consignment")
|
|
}
|
|
|
|
if err := cxLinkLegToOrder(tx, leg, consignment.Consignmentid, trackingNo,
|
|
consignment.Estimateddeliveryat, now); err != nil {
|
|
tx.Rollback()
|
|
utils.Error("BookingPickupComplete: could not link destination to order", "booking_id", bookingID, "error", err)
|
|
return utils.Internal(c, "failed to link the destination to its order")
|
|
}
|
|
|
|
// What the miler weighed at this door, kept where the customer's receipt
|
|
// reads it. Without this the verification block stays empty and the
|
|
// receipt loses the evidence behind the settled price.
|
|
if leg.Destination != nil {
|
|
if err := cxRecordVerification(tx, leg.Destination.Bookingdestinationid,
|
|
totalChargeable, milerUserID, now); err != nil {
|
|
tx.Rollback()
|
|
utils.Error("BookingPickupComplete: could not record verification", "booking_id", bookingID, "error", err)
|
|
return utils.Internal(c, "failed to record the parcel weight")
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// Two separate questions, deliberately not merged.
|
|
//
|
|
// "Is the rider marked busy?" keeps the EXACT rule this endpoint has
|
|
// always applied — Created or Collected_By_Miler only. A hyperlocal
|
|
// parcel that goes straight to Out_for_Delivery has always left the
|
|
// rider Available here, even though the comment below says otherwise.
|
|
// That mismatch is pre-existing and is the default path today; changing
|
|
// it would alter live rider availability, which is not this work's
|
|
// business. Flagged in docs/customer-app-api.md, not silently fixed.
|
|
//
|
|
// "Is the assignment still open?" is the one that has to understand the
|
|
// fan-out: it closes only when every leg has been handed over at a base,
|
|
// which for a single-leg booking is identical to the previous behaviour.
|
|
if consignmentStatus == constants.ConsignmentCollectedByMiler ||
|
|
consignmentStatus == constants.ConsignmentCreated {
|
|
riderMarkedBusy = true
|
|
}
|
|
if consignmentStatus != constants.ConsignmentInwardedAtHub {
|
|
assignmentStillOpen = true
|
|
}
|
|
|
|
// The customer's per-order stage. in_transit is recorded here only on the
|
|
// compatibility flow, where the parcel really is treated as received at
|
|
// the base the instant it is collected; on the handover flow it waits for
|
|
// the rider to actually hand it over.
|
|
destinationID := cxDestinationIDFor(leg.Destination)
|
|
if err := cxstage.Record(tx, cxstage.Event{
|
|
BookingID: bookingID,
|
|
DestinationID: destinationID,
|
|
Stage: constants.CxStageOrderCreated,
|
|
ActorType: constants.CxActorMiler,
|
|
ActorID: &milerUserID,
|
|
Source: "POST /miler/bookings/{id}/pickup-complete",
|
|
At: utils.DBNow(),
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
utils.Error("BookingPickupComplete: could not record order_created", "booking_id", bookingID, "error", err)
|
|
return utils.Internal(c, "failed to record the pickup")
|
|
}
|
|
|
|
// On the compatibility flow a parcel is already past order_created the
|
|
// instant it is collected — hub-routed ones are stamped Inwarded_at_Hub
|
|
// here, and hyperlocal ones go straight to Out_for_Delivery. Recording
|
|
// what the status actually says keeps the timeline honest: those
|
|
// transitions really did happen at this moment, and omitting them would
|
|
// leave a parcel showing "Package collected" while the rider is already
|
|
// carrying it to the door.
|
|
if implied, ok := cxStageForConsignmentStatus(consignmentStatus); ok {
|
|
if err := cxstage.Record(tx, cxstage.Event{
|
|
BookingID: bookingID,
|
|
DestinationID: destinationID,
|
|
Stage: implied,
|
|
ActorType: constants.CxActorMiler,
|
|
ActorID: &milerUserID,
|
|
Source: "POST /miler/bookings/{id}/pickup-complete",
|
|
At: utils.DBNow(),
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
utils.Error("BookingPickupComplete: could not record per-order stage", "booking_id", bookingID, "error", err)
|
|
return utils.Internal(c, "failed to record the pickup")
|
|
}
|
|
}
|
|
|
|
created = append(created, consignment)
|
|
trackingNos = append(trackingNos, trackingNo)
|
|
}
|
|
|
|
// Recorded before order_created in wall-clock terms — the parcels were in the
|
|
// rider's hands before the orders existed — but written after, because the
|
|
// weights the price settles on are only known once the legs are built.
|
|
if err := cxstage.Record(tx, cxstage.Event{
|
|
BookingID: bookingID,
|
|
Stage: constants.CxStagePickedUp,
|
|
ActorType: constants.CxActorMiler,
|
|
ActorID: &milerUserID,
|
|
Source: "POST /miler/bookings/{id}/pickup-complete",
|
|
At: utils.DBNow(),
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
utils.Error("BookingPickupComplete: could not record picked_up", "booking_id", bookingID, "error", err)
|
|
return utils.Internal(c, "failed to record the pickup")
|
|
}
|
|
|
|
// pickupbookings.consignmentid names the FIRST order only. It is kept for
|
|
// the console and the legacy reads that still join on it; anything that
|
|
// needs the whole set goes through bookingdestinations.
|
|
first := created[0]
|
|
booking.Consignmentid = &first.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")
|
|
}
|
|
|
|
// Unchanged from before the fan-out: a parcel still on its way to a base
|
|
// keeps the rider marked busy; anything else frees them up.
|
|
postPickupAvailability := constants.MilerAvailable
|
|
if riderMarkedBusy {
|
|
postPickupAvailability = constants.MilerPickedUp
|
|
}
|
|
|
|
// A parcel that is already inwarded at the base ends this rider's leg, so the
|
|
// assignment closes with it. Without this the assignment stayed open forever
|
|
// on the compatibility flow and the rider could not go off duty — MilerEndDuty
|
|
// refuses while any assignment is still Assigned/Accepted. On the handover
|
|
// flow the assignment stays open on purpose and closes at inward-at-hub.
|
|
if !assignmentStillOpen {
|
|
if err := tx.Model(&models.BookingAssignment{}).
|
|
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?",
|
|
bookingID, milerUserID,
|
|
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
|
Updates(map[string]interface{}{
|
|
"assignmentstatus": constants.AssignmentCompleted,
|
|
"completedat": now,
|
|
}).Error; err != nil {
|
|
tx.Rollback()
|
|
return utils.Internal(c, "failed to close assignment")
|
|
}
|
|
}
|
|
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
|
Update("availabilitystatus", postPickupAvailability).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")
|
|
}
|
|
|
|
// One notification for the milestone, not one per order: three tracking
|
|
// numbers arriving as three buzzes for a single visit is noise, and
|
|
// order_created deliberately rolls up on the timeline.
|
|
go cxstage.Notify(bookingID, nil, constants.CxStagePickedUp)
|
|
|
|
// If an OTP was issued here (parcel went straight out for delivery), it goes to
|
|
// the receiver in this notification — the rider is told it at the door. When
|
|
// the collected-state flow is on, no OTP exists yet and the notification is
|
|
// just "collected"; the OTP rides the start-delivery notification instead.
|
|
notifyCustomerOnPickup(&booking, created, trackingNos)
|
|
|
|
// next_action says what the rider does next; next_hub says where. An
|
|
// inward_at_hub with no base named leaves a rider holding a parcel with
|
|
// nowhere to take it, so the two travel together — and next_hub carries all
|
|
// six fields, because the coordinates are the only thing that can drive
|
|
// Navigate and the address and pincode are what the rider reads at the gate.
|
|
//
|
|
// consignment_id is always present: the delivery leg is keyed on it, and
|
|
// without it the app cannot name the parcel it is about to act on.
|
|
//
|
|
// The single-consignment fields still describe the FIRST order, unchanged,
|
|
// so the deployed rider app keeps working exactly as before. `consignments`
|
|
// is additive and carries the full set for a build that can show them.
|
|
resp := fiber.Map{
|
|
"tracking_no": trackingNos[0],
|
|
"consignment_id": first.Consignmentid,
|
|
"consignmentstatus": first.Status,
|
|
"status": first.Status,
|
|
"booking_no": booking.Bookingno,
|
|
"booking_status": booking.Status,
|
|
"next_action": nextActionForConsignment(first.Status),
|
|
"consignments": renderPickupOrders(created, trackingNos),
|
|
}
|
|
if first.Status == constants.ConsignmentCreated ||
|
|
first.Status == constants.ConsignmentInwardedAtHub {
|
|
resp["next_hub"] = renderBase(handoverHub)
|
|
}
|
|
return utils.OK(c, resp)
|
|
}
|
|
|
|
// renderPickupOrders lists every order a pickup produced, so a rider carrying
|
|
// three parcels from one visit can be shown three stops rather than one.
|
|
func renderPickupOrders(consignments []models.Consignment, trackingNos []string) []fiber.Map {
|
|
out := make([]fiber.Map, 0, len(consignments))
|
|
for i := range consignments {
|
|
cn := consignments[i]
|
|
out = append(out, fiber.Map{
|
|
"consignment_id": cn.Consignmentid,
|
|
"tracking_no": trackingNos[i],
|
|
"consignmentstatus": cn.Status,
|
|
"next_action": nextActionForConsignment(cn.Status),
|
|
"delivery_pincode": cn.Deliverypincode,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// notifyCustomerOnPickup tells the customer their parcels were collected, and
|
|
// hands the receiver any delivery OTP that was issued. Best-effort: a push that
|
|
// fails must never fail a pickup that already committed.
|
|
func notifyCustomerOnPickup(booking *models.PickupBooking, consignments []models.Consignment, trackingNos []string) {
|
|
var customer models.AppCustomer
|
|
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err != nil {
|
|
return
|
|
}
|
|
if customer.Devicetoken == "" {
|
|
return
|
|
}
|
|
|
|
body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNos[0])
|
|
if len(trackingNos) > 1 {
|
|
body = fmt.Sprintf("%d parcels picked up — first tracking no: %s", len(trackingNos), trackingNos[0])
|
|
}
|
|
payload := map[string]string{
|
|
"booking_id": strconv.Itoa(booking.Bookingid),
|
|
"tracking_no": trackingNos[0],
|
|
"reference": booking.Bookingno,
|
|
}
|
|
if len(consignments) > 0 && consignments[0].Deliveryotp != "" {
|
|
body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignments[0].Deliveryotp)
|
|
payload["delivery_otp"] = consignments[0].Deliveryotp
|
|
}
|
|
if err := notify.SendToDevice(customer.Devicetoken, "Parcel Picked Up", body, payload); err != nil {
|
|
utils.Warn("FCM: failed to notify customer on pickup", "booking_id", booking.Bookingid, "error", err)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// The rider's identity comes from their token, never the body. Trusting a
|
|
// client-supplied userid let any authenticated miler write another miler's
|
|
// GPS trail, which feeds the location data dispatch reasons over.
|
|
log.UserID = c.Locals("userid").(int)
|
|
|
|
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")
|
|
}
|
|
|
|
// Identity from the token, not the body — otherwise one rider can set
|
|
// another rider's live status.
|
|
status.UserID = c.Locals("userid").(int)
|
|
|
|
if status.Status == "" {
|
|
return utils.BadRequest(c, "status is 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")
|
|
}
|
|
|
|
milerUserID := c.Locals("userid").(int)
|
|
|
|
pipe := db.Rdb.TxPipeline()
|
|
tx := db.DB.Begin()
|
|
|
|
for _, item := range input {
|
|
// Same rule as the other telemetry writers: the token owns the identity,
|
|
// so a batch can't be attributed to some other rider.
|
|
item.UserID = milerUserID
|
|
|
|
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")
|
|
}
|
|
|
|
// The path names a rider, so it has to be checked against the caller —
|
|
// otherwise any miler could read another miler's movement history simply by
|
|
// changing the number in the URL.
|
|
if userID != c.Locals("userid").(int) {
|
|
return utils.Forbidden(c, "you can only read your own consignment logs")
|
|
}
|
|
|
|
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")
|
|
}
|