Files
doormile_backend/controllers/adminController.go
Suriya fd7cf3e35e fix: console tenant scoping, miler identity spoofing, delivery proof, timezone
Security
- Express console had no tenant scoping at all: LoginAdmin hardcoded tenantid 0
  into every JWT and none of the 85 admin handlers filtered by tenant, so any
  client given a console login would read every other client's bookings,
  customers, pricing and reports. Adds DoormileAuth.Tenantid (nil = Doormile
  staff, unrestricted; set = client, scoped), emits it in the token, and scopes
  reads, guards writes and pins tenantid on create.
- Miler telemetry (/miler/logs, /miler/status, /miler/consignments/logs) took
  userid from the request body, letting any authenticated rider write another
  rider's status and GPS trail — data the dispatch layer reasons over. Identity
  now comes from the token.
- POST /miler/reset-pin was unauthenticated and overwrote a PIN given only a
  phone number, so reset-pin + verify-pin took over any rider account. Now
  requires admin/manager/executive auth.

Correctness
- Date ranges compared the container's UTC clock against timestamps the DB
  writes as IST wall-clock (DSN sets TimeZone=Asia/Kolkata), so "today so far"
  ended 5h30m in the past and silently dropped everything created after noon
  IST from every report. Sets TZ in the image and adds utils.DBNow/DBToday,
  which stay correct regardless of container timezone.
- CreateMiler never set Configid, so console-created riders got the column
  default of 1 while LoginMiler looks up configid 1001 — every such rider was
  unable to log in, reported as "no miler account found".
- Delivery wrote no consignment history row, so a tracking timeline never
  showed the parcel arriving.

Features
- Delivery OTP is now real (crypto/rand, issued to the receiver, verified and
  cleared on delivery) but opt-in per client via Tenant.Requiredeliveryotp,
  defaulting off — friction worth it for a courier parcel, not a food order.
- Express bookings accept pickuplocationid, so the console can name a client
  site (a DailyGrubs kitchen) instead of retyping its address; validated
  against the tenant and carried through to the consignment.
- TenantLocation.Locationname, miler tenantid/hubid, Nagercoil (629) opened.
- PUT /miler/availability accepts both "status" and "availabilitystatus", and
  /miler/location no longer drops speed/heading — both were contract
  mismatches against the doc the Flutter dev was given.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:16:56 +05:30

3133 lines
93 KiB
Go

package controllers
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"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"
"gorm.io/gorm"
)
// consoleTenantID returns the tenant a console login is restricted to, or 0
// for Doormile's own staff, who are unrestricted. Client logins carry their
// tenant in the JWT (see LoginAdmin); Doormile staff have DoormileAuth.Tenantid
// nil and so authenticate with 0.
func consoleTenantID(c *fiber.Ctx) int {
tenantID, ok := c.Locals("tenantid").(int)
if !ok {
return 0
}
return tenantID
}
// isDoormileConsoleStaff reports whether the caller sees every tenant's data.
func isDoormileConsoleStaff(c *fiber.Ctx) bool {
return consoleTenantID(c) == 0
}
// scopeToOwnTenant restricts a query on a tenant-owned table to the requesting
// console user's own tenant. Doormile staff are unrestricted. This is the
// admin-console counterpart of scopeBookingsToOwnTenant in hubController.go —
// without it, any client given an express-console login reads every other
// client's data.
//
// The column name is taken as a parameter because the tenant key is not always
// literally "tenantid": on the tenants table itself it is the primary key.
func scopeToOwnTenant(c *fiber.Ctx, query *gorm.DB, column string) *gorm.DB {
tenantID := consoleTenantID(c)
if tenantID == 0 {
return query
}
return query.Where(column+" = ?", tenantID)
}
// canAccessTenant reports whether the caller may act on the given tenant.
// Used where the tenant is addressed by a path parameter or request body rather
// than filtered in a query — scoping a WHERE clause does nothing when the
// caller names the tenant directly.
func canAccessTenant(c *fiber.Ctx, tenantID int) bool {
own := consoleTenantID(c)
return own == 0 || own == tenantID
}
// assertBookingAccess checks that the caller may act on a booking addressed by
// id. Returns nil for Doormile staff. Mutating handlers take the booking id
// straight from the path, so a scoped SELECT elsewhere in the handler does not
// protect them — this has to run before the write.
func assertBookingAccess(c *fiber.Ctx, bookingID int) error {
own := consoleTenantID(c)
if own == 0 {
return nil
}
var booking models.PickupBooking
if err := db.DB.Select("bookingid", "tenantid").First(&booking, bookingID).Error; err != nil {
return utils.NotFound(c, "booking not found")
}
// A booking with no tenant predates tenant attribution and can't be proven
// to belong to this client, so it stays invisible to them.
if booking.Tenantid == nil || *booking.Tenantid != own {
return utils.NotFound(c, "booking not found")
}
return nil
}
// Helper to generate tripsheet number
func generateTripsheetNo() string {
b := make([]byte, 4)
rand.Read(b)
return fmt.Sprintf("DM-TS-%X-%d", b, time.Now().Unix()%100000)
}
func LoginAdmin(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
req := new(dto.AdminLoginRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Email == "" || req.Password == "" {
return utils.BadRequest(c, "email and password are required")
}
var auth models.DoormileAuth
if err := db.DB.Where("email = ?", req.Email).First(&auth).Error; err != nil {
return utils.Unauthorized(c, "incorrect email or password")
}
if auth.Role != "admin" && auth.Role != "manager" && auth.Role != "executive" {
return utils.Forbidden(c, "access restricted to admin console users")
}
if !utils.CheckPasswordHash(req.Password, auth.PasswordHash) {
return utils.Unauthorized(c, "incorrect email or password")
}
roleId := 3
if auth.Role == "admin" {
roleId = 1
} else if auth.Role == "executive" {
roleId = 4
}
var appUser models.AppUser
db.DB.Where("email = ?", req.Email).First(&appUser)
userName := "Admin"
if appUser.Authname != "" {
userName = appUser.Authname
}
// A client's console login carries their tenant so handlers can scope to
// it; Doormile's own staff have Tenantid nil and keep tenantID 0, which
// scopeToOwnTenant reads as "unrestricted". Emitting 0 unconditionally
// (as this did) is what left every console login able to read every
// tenant's data.
tenantID := 0
if auth.Tenantid != nil {
tenantID = *auth.Tenantid
}
// Important: use appUser.Userid instead of auth.ID to ensure consistent IDs across the system
token, err := utils.GenerateToken(int(appUser.Userid), auth.Email, roleId, tenantID, 1, cfg.JWTSecret)
if err != nil {
return utils.Internal(c, "failed to generate token")
}
return c.JSON(fiber.Map{
"success": true,
"token": token,
"user": fiber.Map{
"id": appUser.Userid,
"name": userName,
"email": auth.Email,
"role": auth.Role,
"tenantid": auth.Tenantid,
},
})
}
}
func GetAdminDashboard(c *fiber.Ctx) error {
var totalTenants int64
var totalCustomers int64
var totalMilers int64
var totalBookings int64
var totalConsignments int64
var openExceptions int64
scopeToOwnTenant(c, db.DB.Model(&models.Tenant{}), "tenantid").Count(&totalTenants)
scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid").Count(&totalBookings)
scopeToOwnTenant(c, db.DB.Model(&models.Consignment{}), "tenantid").Count(&totalConsignments)
// Customers, milers and exceptions have no tenant column, so there is no
// way to attribute them to one client here. Rather than show a client
// Doormile-wide totals, these are reported as zero for client logins; the
// per-client versions need a join through bookings and are not built yet.
if isDoormileConsoleStaff(c) {
db.DB.Model(&models.AppCustomer{}).Count(&totalCustomers)
db.DB.Model(&models.AppUser{}).Where("roleid = 5").Count(&totalMilers)
db.DB.Model(&models.ConsignmentException{}).Where("status = ?", "Open").Count(&openExceptions)
}
return utils.OK(c, fiber.Map{
"tenants": totalTenants,
"customers": totalCustomers,
"milers": totalMilers,
"bookings": totalBookings,
"consignments": totalConsignments,
"exceptions": openExceptions,
})
}
// --------------------
// REPORTS
// --------------------
// GetAdminReports gives operations dashboard-style aggregates over a date
// range (defaults to today via parseHubDateRange, same helper GetHubReport
// uses), optionally scoped to one tenant/hub, broken down by hub, tenant, and
// rider. Replaces the old system's separate getreportsummary /
// getriderlocationsummary / getridersummary endpoints with one
// parameterized view instead of three fixed ones.
func GetAdminReports(c *fiber.Ctx) error {
from, to, err := parseHubDateRange(c)
if err != nil {
return utils.BadRequest(c, err.Error())
}
tenantID := c.Query("tenantid")
hubID := c.Query("hubid")
// ownTenant is 0 for Doormile staff (whole-network view) and the client's
// tenant for a client login, which every figure below is restricted to.
ownTenant := consoleTenantID(c)
var totalBookings int64
scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid").
Where("createdat BETWEEN ? AND ?", from, to).Count(&totalBookings)
var delivered int64
scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid").
Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingConvertedConsignment, from, to).
Count(&delivered)
var cancelled int64
scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid").
Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingCancelled, from, to).
Count(&cancelled)
consignmentQuery := scopeToOwnTenant(c, db.DB.Model(&models.Consignment{}), "tenantid").
Where("createdat BETWEEN ? AND ?", from, to)
if tenantID != "" {
consignmentQuery = consignmentQuery.Where("tenantid = ?", tenantID)
}
if hubID != "" {
consignmentQuery = consignmentQuery.Where("currenthubid = ?", hubID)
}
var totalConsignments int64
consignmentQuery.Count(&totalConsignments)
// Payments and exceptions carry no tenant column, so they're restricted
// through the bookings/consignments they belong to. COD in particular is a
// figure a client genuinely needs, so it's joined rather than suppressed.
var codCollected float64
codQuery := db.DB.Model(&models.BookingPayment{}).
Where("paymentstatus = ? AND createdat BETWEEN ? AND ?", constants.PaymentStatusPaid, from, to)
if ownTenant != 0 {
codQuery = codQuery.Where("bookingid IN (?)",
db.DB.Model(&models.PickupBooking{}).Select("bookingid").Where("tenantid = ?", ownTenant))
}
codQuery.Select("COALESCE(SUM(amount), 0)").Scan(&codCollected)
var openExceptions int64
excQuery := db.DB.Model(&models.ConsignmentException{}).
Where("status != ? AND createdat BETWEEN ? AND ?", constants.ExceptionClosed, from, to)
if ownTenant != 0 {
excQuery = excQuery.Where("consignmentid IN (?)",
db.DB.Model(&models.Consignment{}).Select("consignmentid").Where("tenantid = ?", ownTenant))
}
excQuery.Count(&openExceptions)
completionRate := 0.0
if totalBookings > 0 {
completionRate = float64(delivered) / float64(totalBookings) * 100
}
// ---- by hub: parcels delivered through each hub in range ----
type hubRow struct {
Hubid int `gorm:"column:hubid"`
Hubname string `gorm:"column:hubname"`
Delivered int64 `gorm:"column:delivered"`
}
var hubRows []hubRow
// The tenant filter belongs in the JOIN condition, not a WHERE — in a WHERE
// it would drop hubs with no matching parcels instead of showing them as
// zero.
hubSQL := `
SELECT h.hubid AS hubid, h.hubname AS hubname, COUNT(c.consignmentid) AS delivered
FROM hubs h
LEFT JOIN consignments c ON c.currenthubid = h.hubid AND c.status = ? AND c.updatedat BETWEEN ? AND ?`
hubArgs := []interface{}{constants.ConsignmentDelivered, from, to}
if ownTenant != 0 {
hubSQL += ` AND c.tenantid = ?`
hubArgs = append(hubArgs, ownTenant)
}
hubSQL += `
WHERE h.deletedat IS NULL
GROUP BY h.hubid, h.hubname
ORDER BY delivered DESC`
db.DB.Raw(hubSQL, hubArgs...).Scan(&hubRows)
byHub := make([]fiber.Map, 0, len(hubRows))
for _, r := range hubRows {
byHub = append(byHub, fiber.Map{"hubid": r.Hubid, "hubname": r.Hubname, "delivered": r.Delivered})
}
// ---- by tenant: consignments shipped for each tenant in range ----
type tenantRow struct {
Tenantid int `gorm:"column:tenantid"`
Tenantname string `gorm:"column:tenantname"`
Bookings int64 `gorm:"column:bookings"`
}
var tenantRows []tenantRow
tenantSQL := `
SELECT t.tenantid AS tenantid, t.tenantname AS tenantname, COUNT(c.consignmentid) AS bookings
FROM tenants t
LEFT JOIN consignments c ON c.tenantid = t.tenantid AND c.createdat BETWEEN ? AND ?`
tenantArgs := []interface{}{from, to}
if ownTenant != 0 {
tenantSQL += ` WHERE t.tenantid = ?`
tenantArgs = append(tenantArgs, ownTenant)
}
tenantSQL += `
GROUP BY t.tenantid, t.tenantname
ORDER BY bookings DESC`
db.DB.Raw(tenantSQL, tenantArgs...).Scan(&tenantRows)
byTenant := make([]fiber.Map, 0, len(tenantRows))
for _, r := range tenantRows {
byTenant = append(byTenant, fiber.Map{"tenantid": r.Tenantid, "tenantname": r.Tenantname, "bookings": r.Bookings})
}
// ---- by rider: completed stops/kms/earnings per rider in range,
// optionally scoped to one hub ----
type riderRow struct {
Userid int `gorm:"column:userid"`
Displayname string `gorm:"column:displayname"`
CompletedStops int64 `gorm:"column:completed_stops"`
TotalKms float64 `gorm:"column:total_kms"`
TotalEarnings float64 `gorm:"column:total_earnings"`
}
// Rider earnings, kms and completed stops are Doormile's own workforce data
// — a client has no business reading them, and there's no per-client view
// of a rider who works across tenants. Clients get an empty list.
var riderRows []riderRow
riderQuery := `
SELECT mp.userid AS userid, mp.displayname AS displayname,
COUNT(ba.bookingassignmentid) AS completed_stops,
COALESCE(SUM(ba.riderkms),0) AS total_kms,
COALESCE(SUM(ba.ridercharges),0) AS total_earnings
FROM milerprofiles mp
JOIN bookingassignments ba ON ba.mileruserid = mp.userid
AND ba.assignmentstatus = ? AND ba.completedat BETWEEN ? AND ?
`
args := []interface{}{constants.AssignmentCompleted, from, to}
if hubID != "" {
riderQuery += " WHERE mp.hubid = ? "
args = append(args, hubID)
}
riderQuery += " GROUP BY mp.userid, mp.displayname ORDER BY completed_stops DESC LIMIT 50"
if isDoormileConsoleStaff(c) {
db.DB.Raw(riderQuery, args...).Scan(&riderRows)
}
byRider := make([]fiber.Map, 0, len(riderRows))
for _, r := range riderRows {
byRider = append(byRider, fiber.Map{
"userid": r.Userid, "displayname": r.Displayname,
"completed_stops": r.CompletedStops, "total_kms": r.TotalKms, "total_earnings": r.TotalEarnings,
})
}
return utils.OK(c, fiber.Map{
"from": from.Format("2006-01-02"),
"to": to.Format("2006-01-02"),
"summary": fiber.Map{
"total_bookings": totalBookings,
"delivered": delivered,
"cancelled": cancelled,
"total_consignments": totalConsignments,
"cod_collected": codCollected,
"open_exceptions": openExceptions,
"completion_rate": completionRate,
},
"by_hub": byHub,
"by_tenant": byTenant,
"by_rider": byRider,
})
}
// --------------------
// APP USERS MANAGEMENT
// --------------------
func GetAppUsers(c *fiber.Ctx) error {
page := utils.ParsePage(c)
var total int64
if err := scopeToOwnTenant(c, db.DB.Model(&models.AppUser{}), "tenantid").
Where("roleid != ?", 5).Count(&total).Error; err != nil {
return utils.Internal(c, "failed to count users")
}
var users []models.AppUser
// Exclude Milers (Roleid = 5) from the CRM user list. Scoped as well, so a
// client sees only their own people, not Doormile's staff directory.
if err := page.Apply(scopeToOwnTenant(c, db.DB, "tenantid").Where("roleid != ?", 5)).
Find(&users).Error; err != nil {
return utils.Internal(c, "failed to fetch users")
}
response := make([]fiber.Map, 0, len(users))
for _, u := range users {
roleName := "unknown"
if u.Roleid == 1 {
roleName = "admin"
} else if u.Roleid == 3 {
roleName = "manager"
} else if u.Roleid == 4 {
roleName = "rep"
} else if u.Roleid == 5 {
roleName = "miler"
}
response = append(response, fiber.Map{
"id": u.Userid,
"first_name": u.Authname,
"email": u.Email,
"phone": u.Contactno,
"role": roleName,
"status": u.Status,
})
}
return utils.Paginated(c, response, total, page)
}
func CreateAppUser(c *fiber.Ctx) error {
type UserRequest struct {
FirstName string `json:"first_name"`
Email string `json:"email"`
Phone string `json:"phone"`
Role string `json:"role"`
Password string `json:"password"`
}
req := new(UserRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
roleId := 4
if req.Role == "admin" {
roleId = 1
} else if req.Role == "manager" {
roleId = 3
} else if req.Role == "rep" {
roleId = 4
}
passHash, _ := utils.HashPassword(req.Password)
if passHash == "" {
passHash, _ = utils.HashPassword("defaultPassword123")
}
user := models.AppUser{
Authname: req.FirstName,
Email: req.Email,
Contactno: req.Phone,
Password: passHash,
Roleid: roleId,
Status: "Active",
Applocationid: 1,
}
if err := db.DB.Create(&user).Error; err != nil {
return utils.Internal(c, "failed to create user")
}
return utils.Created(c, fiber.Map{
"id": user.Userid,
"first_name": user.Authname,
"email": user.Email,
"phone": user.Contactno,
"role": req.Role,
})
}
func UpdateAppUser(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var user models.AppUser
if err := db.DB.First(&user, id).Error; err != nil {
return utils.NotFound(c, "user not found")
}
type UserRequest struct {
FirstName string `json:"first_name"`
Email string `json:"email"`
Phone string `json:"phone"`
Role string `json:"role"`
}
req := new(UserRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.FirstName != "" {
user.Authname = req.FirstName
}
if req.Email != "" {
user.Email = req.Email
}
if req.Phone != "" {
user.Contactno = req.Phone
}
if req.Role != "" {
if req.Role == "admin" {
user.Roleid = 1
} else if req.Role == "manager" {
user.Roleid = 3
} else if req.Role == "rep" {
user.Roleid = 4
}
}
user.Updatedat = time.Now()
if err := db.DB.Save(&user).Error; err != nil {
return utils.Internal(c, "failed to update user")
}
return utils.OK(c, fiber.Map{
"id": user.Userid,
"first_name": user.Authname,
"email": user.Email,
"phone": user.Contactno,
"role": req.Role,
})
}
func DeleteAppUser(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
if err := db.DB.Delete(&models.AppUser{}, id).Error; err != nil {
return utils.Internal(c, "failed to delete user")
}
return utils.Message(c, "user deleted successfully")
}
// --------------------
// TENANT MANAGEMENT
// --------------------
func GetTenants(c *fiber.Ctx) error {
var tenants []models.Tenant
// A client login sees only its own tenant row; the tenant key here is the
// primary key, not a "tenantid" foreign column.
if err := scopeToOwnTenant(c, db.DB, "tenantid").Find(&tenants).Error; err != nil {
return utils.Internal(c, "failed to fetch tenants")
}
return utils.List(c, tenants, int64(len(tenants)))
}
func CreateTenant(c *fiber.Ctx) error {
req := new(dto.TenantCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
tenant := models.Tenant{
Tenantname: req.Tenantname,
Primaryemail: req.Primaryemail,
Primarycontact: req.Primarycontact,
Status: req.Status,
}
if tenant.Status == "" {
tenant.Status = "Active"
}
if req.Requiredeliveryotp != nil {
tenant.Requiredeliveryotp = *req.Requiredeliveryotp
}
if err := db.DB.Create(&tenant).Error; err != nil {
return utils.Internal(c, "failed to create tenant")
}
return utils.Created(c, tenant)
}
func GetTenantDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var tenant models.Tenant
if err := scopeToOwnTenant(c, db.DB, "tenantid").First(&tenant, id).Error; err != nil {
return utils.NotFound(c, "tenant not found")
}
return utils.OK(c, tenant)
}
func UpdateTenant(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
if !canAccessTenant(c, id) {
return utils.Forbidden(c, "not permitted for this tenant")
}
var tenant models.Tenant
if err := db.DB.First(&tenant, id).Error; err != nil {
return utils.NotFound(c, "tenant not found")
}
req := new(dto.TenantCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Tenantname != "" {
tenant.Tenantname = req.Tenantname
}
if req.Primaryemail != "" {
tenant.Primaryemail = req.Primaryemail
}
if req.Primarycontact != "" {
tenant.Primarycontact = req.Primarycontact
}
if req.Status != "" {
tenant.Status = req.Status
}
if req.Requiredeliveryotp != nil {
tenant.Requiredeliveryotp = *req.Requiredeliveryotp
}
tenant.Updatedat = time.Now()
if err := db.DB.Save(&tenant).Error; err != nil {
return utils.Internal(c, "failed to update tenant")
}
return utils.OK(c, tenant)
}
func DeleteTenant(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
// Deleting your own tenant is not a client operation either — this is
// Doormile-staff only.
if !isDoormileConsoleStaff(c) {
return utils.Forbidden(c, "not permitted for this tenant")
}
var tenant models.Tenant
if err := db.DB.First(&tenant, id).Error; err != nil {
return utils.NotFound(c, "tenant not found")
}
if err := db.DB.Delete(&tenant).Error; err != nil {
return utils.Internal(c, "failed to delete tenant")
}
return utils.Message(c, "tenant deleted successfully")
}
func GetTenantLocations(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(c.Params("id"))
if !canAccessTenant(c, tenantID) {
return utils.Forbidden(c, "not permitted for this tenant")
}
var locations []models.TenantLocation
if err := db.DB.Where("tenantid = ?", tenantID).Find(&locations).Error; err != nil {
return utils.Internal(c, "failed to fetch locations")
}
return utils.List(c, locations, int64(len(locations)))
}
func CreateTenantLocation(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(c.Params("id"))
if !canAccessTenant(c, tenantID) {
return utils.Forbidden(c, "not permitted for this tenant")
}
req := new(dto.TenantLocationCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
location := models.TenantLocation{
Tenantid: tenantID,
Locationname: req.Locationname,
Address: req.Address,
City: req.City,
State: req.State,
Pincode: req.Pincode,
Latitude: req.Latitude,
Longitude: req.Longitude,
Isprimary: req.Isprimary,
Status: req.Status,
}
if location.Status == "" {
location.Status = "Active"
}
if req.Isprimary {
db.DB.Model(&models.TenantLocation{}).Where("tenantid = ?", tenantID).Update("isprimary", false)
}
if err := db.DB.Create(&location).Error; err != nil {
return utils.Internal(c, "failed to create location")
}
return utils.Created(c, location)
}
func UpdateTenantLocation(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var location models.TenantLocation
if err := db.DB.First(&location, id).Error; err != nil {
return utils.NotFound(c, "tenant location not found")
}
// The location is addressed by its own id, so the tenant guard has to run
// against the row we loaded rather than a path parameter.
if !canAccessTenant(c, location.Tenantid) {
return utils.Forbidden(c, "not permitted for this tenant")
}
req := new(dto.TenantLocationCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Locationname != "" {
location.Locationname = req.Locationname
}
if req.Address != "" {
location.Address = req.Address
}
if req.City != "" {
location.City = req.City
}
if req.State != "" {
location.State = req.State
}
if req.Pincode != "" {
location.Pincode = req.Pincode
}
if req.Latitude != 0 {
location.Latitude = req.Latitude
}
if req.Longitude != 0 {
location.Longitude = req.Longitude
}
location.Isprimary = req.Isprimary
if req.Status != "" {
location.Status = req.Status
}
location.Updatedat = time.Now()
if req.Isprimary {
db.DB.Model(&models.TenantLocation{}).Where("tenantid = ?", location.Tenantid).Update("isprimary", false)
}
if err := db.DB.Save(&location).Error; err != nil {
return utils.Internal(c, "failed to update location")
}
return utils.OK(c, location)
}
// --------------------
// TENANT CUSTOMERS
// --------------------
func GetTenantCustomers(c *fiber.Ctx) error {
var customers []models.Customer
if err := db.DB.Find(&customers).Error; err != nil {
return utils.Internal(c, "failed to fetch customers")
}
return utils.List(c, customers, int64(len(customers)))
}
// --------------------
// B2C APP CUSTOMERS
// --------------------
func GetAdminCustomers(c *fiber.Ctx) error {
pageno := max(1, c.QueryInt("pageno", 1))
pagesize := min(100, max(1, c.QueryInt("pagesize", 20)))
offset := (pageno - 1) * pagesize
keyword := c.Query("keyword")
query := db.DB.Model(&models.AppCustomer{})
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("firstname ILIKE ? OR lastname ILIKE ? OR phone ILIKE ?", like, like, like)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return utils.Internal(c, "failed to count customers")
}
var customers []models.AppCustomer
if err := query.Offset(offset).Limit(pagesize).Find(&customers).Error; err != nil {
return utils.Internal(c, "failed to fetch customers")
}
ids := make([]int, 0, len(customers))
for _, cust := range customers {
ids = append(ids, cust.Appcustomerid)
}
bookingCounts := make(map[int]int64, len(ids))
if len(ids) > 0 {
type countRow struct {
Appcustomerid int
Cnt int64
}
var rows []countRow
if err := db.DB.Model(&models.PickupBooking{}).
Select("appcustomerid, count(*) as cnt").
Where("appcustomerid IN ?", ids).
Group("appcustomerid").
Scan(&rows).Error; err == nil {
for _, r := range rows {
bookingCounts[r.Appcustomerid] = r.Cnt
}
}
}
data := make([]fiber.Map, 0, len(customers))
for _, cust := range customers {
data = append(data, fiber.Map{
"appcustomerid": cust.Appcustomerid,
"name": strings.TrimSpace(cust.Firstname + " " + cust.Lastname),
"phone": cust.Phone,
"email": cust.Email,
"createdat": cust.Createdat,
"totalbookings": bookingCounts[cust.Appcustomerid],
})
}
pages := int(math.Ceil(float64(total) / float64(pagesize)))
return c.JSON(fiber.Map{
"success": true,
"data": data,
"total": total,
"pageno": pageno,
"pagesize": pagesize,
"pages": pages,
})
}
func UpdateAdminCustomer(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.BadRequest(c, "invalid customer id")
}
var customer models.AppCustomer
if err := db.DB.First(&customer, id).Error; err != nil {
return utils.NotFound(c, "customer not found")
}
req := new(struct {
Name string `json:"name"`
Phone string `json:"phone"`
Email string `json:"email"`
})
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Phone != "" {
if len(req.Phone) != 10 || strings.IndexFunc(req.Phone, func(r rune) bool { return r < '0' || r > '9' }) != -1 {
return utils.BadRequest(c, "phone must be 10 digits")
}
customer.Phone = req.Phone
}
if req.Name != "" {
name := strings.TrimSpace(req.Name)
parts := strings.SplitN(name, " ", 2)
customer.Firstname = parts[0]
if len(parts) > 1 {
customer.Lastname = parts[1]
} else {
customer.Lastname = ""
}
}
if req.Email != "" {
customer.Email = req.Email
}
customer.Updatedat = time.Now()
if err := db.DB.Model(&models.AppCustomer{}).Where("appcustomerid = ?", customer.Appcustomerid).
Updates(map[string]interface{}{
"firstname": customer.Firstname,
"lastname": customer.Lastname,
"phone": customer.Phone,
"email": customer.Email,
"updatedat": customer.Updatedat,
}).Error; err != nil {
return utils.Internal(c, "failed to update customer")
}
return c.JSON(fiber.Map{
"success": true,
"data": fiber.Map{
"appcustomerid": customer.Appcustomerid,
"name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
"phone": customer.Phone,
"email": customer.Email,
},
})
}
func CreateTenantCustomer(c *fiber.Ctx) error {
req := new(dto.TenantCustomerCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
customer := models.Customer{
Firstname: req.Firstname,
Lastname: req.Lastname,
Contactno: req.Phone,
Email: req.Email,
Status: 1,
Createdat: time.Now(),
Updatedat: time.Now(),
}
if err := db.DB.Create(&customer).Error; err != nil {
return utils.Internal(c, "failed to create customer")
}
return utils.Created(c, customer)
}
func GetTenantCustomerDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var customer models.Customer
if err := db.DB.First(&customer, id).Error; err != nil {
return utils.NotFound(c, "customer not found")
}
return utils.OK(c, customer)
}
func UpdateTenantCustomer(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var customer models.Customer
if err := db.DB.First(&customer, id).Error; err != nil {
return utils.NotFound(c, "customer not found")
}
req := new(dto.TenantCustomerCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Firstname != "" {
customer.Firstname = req.Firstname
}
if req.Lastname != "" {
customer.Lastname = req.Lastname
}
if req.Phone != "" {
customer.Contactno = req.Phone
}
if req.Email != "" {
customer.Email = req.Email
}
customer.Updatedat = time.Now()
if err := db.DB.Save(&customer).Error; err != nil {
return utils.Internal(c, "failed to update customer")
}
return utils.OK(c, customer)
}
func DeleteTenantCustomer(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var customer models.Customer
if err := db.DB.First(&customer, id).Error; err != nil {
return utils.NotFound(c, "customer not found")
}
if err := db.DB.Delete(&customer).Error; err != nil {
return utils.Internal(c, "failed to delete customer")
}
return utils.Message(c, "customer deleted successfully")
}
// --------------------
// PARTNERS CRUD
// --------------------
// PartnerInfo has no Deletedat column (unlike Hub/Vehicle), so delete here is
// a hard delete, matching DeleteTenant's pattern for the same reason.
func GetPartners(c *fiber.Ctx) error {
query := db.DB.Model(&models.PartnerInfo{})
if status := c.Query("status"); status != "" {
query = query.Where("status = ?", status)
}
if keyword := c.Query("keyword"); keyword != "" {
query = query.Where("partnername ILIKE ?", "%"+keyword+"%")
}
var partners []models.PartnerInfo
if err := query.Find(&partners).Error; err != nil {
return utils.Internal(c, "failed to fetch partners")
}
return utils.List(c, partners, int64(len(partners)))
}
func CreatePartner(c *fiber.Ctx) error {
req := new(dto.PartnerCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Partnername == "" {
return utils.BadRequest(c, "partnername is required")
}
partner := models.PartnerInfo{
Partnername: req.Partnername,
Partnertypeid: req.Partnertypeid,
Contactno: req.Contactno,
Status: req.Status,
}
if partner.Status == "" {
partner.Status = "Active"
}
if err := db.DB.Create(&partner).Error; err != nil {
return utils.Internal(c, "failed to create partner")
}
return utils.Created(c, partner)
}
func GetPartnerDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var partner models.PartnerInfo
if err := db.DB.Where("partnerid = ?", id).First(&partner).Error; err != nil {
return utils.NotFound(c, "partner not found")
}
var vehicleCount int64
db.DB.Model(&models.Vehicle{}).Where("partnerid = ? AND deletedat IS NULL", id).Count(&vehicleCount)
return utils.OK(c, fiber.Map{
"partner": partner,
"vehicle_count": vehicleCount,
})
}
func UpdatePartner(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var partner models.PartnerInfo
if err := db.DB.Where("partnerid = ?", id).First(&partner).Error; err != nil {
return utils.NotFound(c, "partner not found")
}
req := new(dto.PartnerCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Partnername != "" {
partner.Partnername = req.Partnername
}
if req.Partnertypeid != 0 {
partner.Partnertypeid = req.Partnertypeid
}
if req.Contactno != "" {
partner.Contactno = req.Contactno
}
if req.Status != "" {
partner.Status = req.Status
}
partner.Updatedat = time.Now()
db.DB.Save(&partner)
return utils.OK(c, partner)
}
func DeletePartner(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var partner models.PartnerInfo
if err := db.DB.First(&partner, id).Error; err != nil {
return utils.NotFound(c, "partner not found")
}
var vehicleCount int64
db.DB.Model(&models.Vehicle{}).Where("partnerid = ? AND deletedat IS NULL", id).Count(&vehicleCount)
if vehicleCount > 0 {
return utils.BadRequest(c, "cannot delete a partner with vehicles still assigned to them")
}
if err := db.DB.Delete(&partner).Error; err != nil {
return utils.Internal(c, "failed to delete partner")
}
return utils.Message(c, "partner deleted successfully")
}
// --------------------
// HUBS CRUD
// --------------------
func GetHubs(c *fiber.Ctx) error {
var hubs []models.Hub
query := db.DB.Where("deletedat IS NULL")
if appLocationID := c.Query("applocationid"); appLocationID != "" {
query = query.Where("applocationid = ?", appLocationID)
}
if status := c.Query("status"); status != "" {
query = query.Where("status = ?", status)
}
if hubType := c.Query("hubtype"); hubType != "" {
query = query.Where("hubtype = ?", hubType)
}
if err := query.Find(&hubs).Error; err != nil {
return utils.Internal(c, "failed to fetch hubs")
}
return utils.List(c, hubs, int64(len(hubs)))
}
func CreateHub(c *fiber.Ctx) error {
req := new(dto.HubCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
hub := models.Hub{
Hubname: req.Hubname,
Hubtype: req.Hubtype,
Applocationid: req.Applocationid,
Contactno: req.Contactno,
Address: req.Address,
Latitude: req.Latitude,
Longitude: req.Longitude,
Pincode: req.Pincode,
Status: req.Status,
}
if hub.Status == "" {
hub.Status = "Active"
}
if err := db.DB.Create(&hub).Error; err != nil {
return utils.Internal(c, "failed to create hub")
}
return utils.Created(c, hub)
}
func GetHubDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var hub models.Hub
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", id).First(&hub).Error; err != nil {
return utils.NotFound(c, "hub not found")
}
return utils.OK(c, hub)
}
func UpdateHub(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var hub models.Hub
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", id).First(&hub).Error; err != nil {
return utils.NotFound(c, "hub not found")
}
req := new(dto.HubCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Hubname != "" {
hub.Hubname = req.Hubname
}
if req.Hubtype != "" {
hub.Hubtype = req.Hubtype
}
if req.Applocationid != 0 {
hub.Applocationid = req.Applocationid
}
if req.Address != "" {
hub.Address = req.Address
}
if req.Contactno != "" {
hub.Contactno = req.Contactno
}
if req.Latitude != 0 {
hub.Latitude = req.Latitude
}
if req.Longitude != 0 {
hub.Longitude = req.Longitude
}
if req.Pincode != "" {
hub.Pincode = req.Pincode
}
if req.Status != "" {
hub.Status = req.Status
}
hub.Updatedat = time.Now()
db.DB.Save(&hub)
return utils.OK(c, hub)
}
func DeleteHub(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var hub models.Hub
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", id).First(&hub).Error; err != nil {
return utils.NotFound(c, "hub not found")
}
now := time.Now()
hub.Deletedat = &now
db.DB.Save(&hub)
return utils.Message(c, "hub deleted successfully")
}
// --------------------
// VEHICLES CRUD
// --------------------
func GetVehicles(c *fiber.Ctx) error {
var vehicles []models.Vehicle
if err := db.DB.Where("deletedat IS NULL").Find(&vehicles).Error; err != nil {
return utils.Internal(c, "failed to fetch vehicles")
}
return utils.List(c, vehicles, int64(len(vehicles)))
}
func CreateVehicle(c *fiber.Ctx) error {
req := new(dto.VehicleCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
vehicle := models.Vehicle{
Vehicleno: req.Vehicleno,
Vehicletype: req.Vehicletype,
Maxweight: req.Maxweight,
Maxvolume: req.Maxvolume,
Partnerid: req.Partnerid,
Batterypercentage: req.Batterypercentage,
Status: req.Status,
}
if vehicle.Status == "" {
vehicle.Status = "Available"
}
if err := db.DB.Create(&vehicle).Error; err != nil {
return utils.Internal(c, "failed to create vehicle")
}
return utils.Created(c, vehicle)
}
func GetVehicleDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var vehicle models.Vehicle
if err := db.DB.Where("vehicleid = ? AND deletedat IS NULL", id).First(&vehicle).Error; err != nil {
return utils.NotFound(c, "vehicle not found")
}
return utils.OK(c, vehicle)
}
func UpdateVehicle(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var vehicle models.Vehicle
if err := db.DB.Where("vehicleid = ? AND deletedat IS NULL", id).First(&vehicle).Error; err != nil {
return utils.NotFound(c, "vehicle not found")
}
req := new(dto.VehicleCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Vehicleno != "" {
vehicle.Vehicleno = req.Vehicleno
}
if req.Vehicletype != "" {
vehicle.Vehicletype = req.Vehicletype
}
if req.Maxweight != 0 {
vehicle.Maxweight = req.Maxweight
}
if req.Maxvolume != 0 {
vehicle.Maxvolume = req.Maxvolume
}
vehicle.Partnerid = req.Partnerid
if req.Batterypercentage != 0 {
vehicle.Batterypercentage = req.Batterypercentage
}
if req.Status != "" {
vehicle.Status = req.Status
}
vehicle.Updatedat = time.Now()
if err := db.DB.Save(&vehicle).Error; err != nil {
return utils.Internal(c, "failed to update vehicle")
}
return utils.OK(c, vehicle)
}
func DeleteVehicle(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var vehicle models.Vehicle
if err := db.DB.Where("vehicleid = ? AND deletedat IS NULL", id).First(&vehicle).Error; err != nil {
return utils.NotFound(c, "vehicle not found")
}
now := time.Now()
vehicle.Deletedat = &now
db.DB.Save(&vehicle)
return utils.Message(c, "vehicle deleted successfully")
}
// --------------------
// MILERS MANAGEMENT
// --------------------
func GetMilers(c *fiber.Ctx) error {
var profiles []models.MilerProfile
query := db.DB
if appLocStr := c.Query("applocationid"); appLocStr != "" {
query = query.Where("applocationid = ?", appLocStr)
}
if err := query.Find(&profiles).Error; err != nil {
return utils.Internal(c, "failed to fetch milers")
}
return utils.List(c, profiles, int64(len(profiles)))
}
func CreateMiler(c *fiber.Ctx) error {
req := new(dto.MilerCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
passHash, _ := utils.HashPassword(req.Password)
tx := db.DB.Begin()
appLocID := req.Applocationid
if appLocID == 0 {
appLocID = 1
}
// A client login may only create riders under its own tenant.
tenantID := req.Tenantid
if own := consoleTenantID(c); own != 0 {
tenantID = own
}
// Configid must match what LoginMiler looks up by — it queries
// "contactno = ? AND configid = ?" defaulting to 1001. Left unset, AppUser's
// column default of 1 applies and the rider can never log in, which is what
// happened to every miler created through this endpoint until now.
configID := req.Configid
if configID == 0 {
configID = 1001
}
user := models.AppUser{
Authname: req.Authname,
Email: req.Email,
Contactno: req.Contactno,
Password: passHash,
Roleid: 5, // Miler
Status: "Active",
Applocationid: appLocID,
Tenantid: tenantID,
Hubid: req.Hubid,
Configid: configID,
}
if err := tx.Create(&user).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to create miler account")
}
profile := models.MilerProfile{
Userid: user.Userid,
Displayname: req.Displayname,
Phone: req.Contactno,
Defaultvehicletype: req.Defaultvehicletype,
Availabilitystatus: constants.MilerOffline,
Rating: 5.00,
Applocationid: appLocID,
Hubid: req.Hubid,
}
if err := tx.Create(&profile).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to create miler profile")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to create miler")
}
return utils.Created(c, profile)
}
func GetMilerDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var profile models.MilerProfile
if err := db.DB.Where("milerprofileid = ?", id).First(&profile).Error; err != nil {
return utils.NotFound(c, "miler not found")
}
return utils.OK(c, profile)
}
// AdminNotifyMiler lets console staff push a one-off notification to a
// specific miler directly (not tied to a booking, unlike InternalNotify
// which is machine-to-machine and booking-scoped). :id is the milerprofileid,
// matching every other /admin/milers/:id route.
func AdminNotifyMiler(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.BadRequest(c, "invalid miler ID")
}
var req struct {
Title string `json:"title"`
Message string `json:"message"`
Data map[string]string `json:"data"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Title == "" || req.Message == "" {
return utils.BadRequest(c, "title and message are required")
}
var profile models.MilerProfile
if err := db.DB.Where("milerprofileid = ?", id).First(&profile).Error; err != nil {
return utils.NotFound(c, "miler not found")
}
if profile.Devicetoken == "" {
return utils.BadRequest(c, "this miler has no registered device to notify")
}
if err := notify.SendToDevice(profile.Devicetoken, req.Title, req.Message, req.Data); err != nil {
return utils.Internal(c, "failed to send notification")
}
return utils.Message(c, "notification sent")
}
func UpdateMiler(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var profile models.MilerProfile
if err := db.DB.Where("milerprofileid = ?", id).First(&profile).Error; err != nil {
return utils.NotFound(c, "miler not found")
}
type MilerUpdate struct {
Displayname string `json:"displayname"`
Defaultvehicletype string `json:"defaultvehicletype"`
Hubid *int `json:"hubid"`
}
req := new(MilerUpdate)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Displayname != "" {
profile.Displayname = req.Displayname
}
if req.Defaultvehicletype != "" {
profile.Defaultvehicletype = req.Defaultvehicletype
}
if req.Hubid != nil {
profile.Hubid = req.Hubid
}
profile.Updatedat = time.Now()
if err := db.DB.Save(&profile).Error; err != nil {
return utils.Internal(c, "failed to update miler")
}
return utils.OK(c, profile)
}
func BlockMiler(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
tx := db.DB.Begin()
var profile models.MilerProfile
if err := tx.Where("milerprofileid = ?", id).First(&profile).Error; err != nil {
tx.Rollback()
return utils.NotFound(c, "miler not found")
}
profile.Availabilitystatus = constants.MilerBlocked
profile.Updatedat = time.Now()
if err := tx.Save(&profile).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to block miler profile")
}
if err := tx.Model(&models.AppUser{}).Where("userid = ?", profile.Userid).
Update("status", "Blocked").Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to block miler account")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to block miler")
}
return utils.Message(c, "miler blocked successfully")
}
func AssignMilerVehicle(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
type VehicleAssign struct {
Vehicleid int `json:"vehicleid"`
}
req := new(VehicleAssign)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
var profile models.MilerProfile
if err := db.DB.Where("milerprofileid = ?", id).First(&profile).Error; err != nil {
return utils.NotFound(c, "miler not found")
}
profile.Vehicleid = &req.Vehicleid
profile.Updatedat = time.Now()
db.DB.Save(&profile)
return utils.OK(c, profile)
}
// --------------------
// BOOKINGS MANAGEMENT
// --------------------
func GetAdminBookings(c *fiber.Ctx) error {
pageno := max(1, c.QueryInt("pageno", 1))
pagesize := min(100, max(1, c.QueryInt("pagesize", 20)))
offset := (pageno - 1) * pagesize
query := db.DB.Model(&models.PickupBooking{})
if tenantID := c.Query("tenantid"); tenantID != "" {
query = query.Where("tenantid = ?", tenantID)
}
// Applied after the caller's own ?tenantid= filter so a client login can
// narrow within their tenant but never widen past it.
query = scopeToOwnTenant(c, query, "tenantid")
var total int64
if err := query.Count(&total).Error; err != nil {
return utils.Internal(c, "failed to count bookings")
}
var bookings []models.PickupBooking
if err := query.Preload("Parcels").Preload("ServiceOptions").
Offset(offset).Limit(pagesize).Find(&bookings).Error; err != nil {
return utils.Internal(c, "failed to fetch bookings")
}
pages := int(math.Ceil(float64(total) / float64(pagesize)))
return c.JSON(fiber.Map{
"success": true,
"data": bookings,
"total": total,
"pageno": pageno,
"pagesize": pagesize,
"pages": pages,
})
}
// AdminBookingRequest is the express-console booking payload, shared by CreateExpressBooking
// (one booking) and AdminBulkCreateBookings (many) — was previously a type
// local to CreateExpressBooking, promoted to package level so both can use it.
type AdminBookingRequest struct {
Tenantid int `json:"tenantid"`
Appcustomerid int `json:"appcustomerid"`
CustomerPhone string `json:"customer_phone"`
CustomerName string `json:"customer_name"`
// Pickuplocationid names the client site the parcel is collected from — a
// DailyGrubs kitchen, for instance. Optional, but supplying it lets the
// address/pincode/coordinates be filled from the stored location instead of
// retyped, and is the only thing that makes per-site reporting possible.
Pickuplocationid *int `json:"pickuplocationid"`
Pickupaddress string `json:"pickupaddress"`
Pickuppincode string `json:"pickuppincode"`
Pickuplatitude float64 `json:"pickuplatitude"`
Pickuplongitude float64 `json:"pickuplongitude"`
Deliveryaddress string `json:"deliveryaddress"`
Deliverypincode string `json:"deliverypincode"`
Deliverycity string `json:"deliverycity"`
Deliverylatitude float64 `json:"deliverylatitude"`
Deliverylongitude float64 `json:"deliverylongitude"`
Providercompany string `json:"providercompany"`
Providerlocation string `json:"providerlocation"`
Notes string `json:"notes"`
ServiceOption string `json:"service_option"`
Finalprice float64 `json:"finalprice"`
Insuranceamount float64 `json:"insuranceamount"`
Preferredpickupfrom *time.Time `json:"preferredpickupfrom"`
Preferredpickupto *time.Time `json:"preferredpickupto"`
Parcels []dto.ParcelRequest `json:"parcels"`
}
// expressBookingValidationError marks a createExpressBooking failure as a bad
// request (missing/invalid input) rather than a server-side failure, so
// CreateExpressBooking can still return the right HTTP status after the
// validation logic moved into the shared helper below.
type expressBookingValidationError struct{ msg string }
func (e *expressBookingValidationError) Error() string { return e.msg }
// createExpressBooking holds the actual booking-creation logic, shared by
// CreateExpressBooking (one booking, used by the "New Booking" form) and
// AdminBulkCreateBookings (many, used by CSV/bulk import). Takes no
// *fiber.Ctx — the original function never touched c after BodyParser, so
// both callers can use this identically.
func createExpressBooking(req AdminBookingRequest) (*models.PickupBooking, error) {
if len(req.Parcels) == 0 {
return nil, &expressBookingValidationError{"at least one parcel is required"}
}
if req.Tenantid == 0 {
return nil, &expressBookingValidationError{"tenantid is required for express-console bookings"}
}
var tenant models.Tenant
if err := db.DB.Where("tenantid = ?", req.Tenantid).First(&tenant).Error; err != nil {
return nil, &expressBookingValidationError{"tenantid does not match a known tenant"}
}
// A named pickup location fills in whatever the caller left blank, so the
// console can send a kitchen id instead of restating its address every time.
// It must belong to the booking's tenant — otherwise one client could book
// against another client's site.
if req.Pickuplocationid != nil {
var loc models.TenantLocation
if err := db.DB.Where("tenantlocationid = ?", *req.Pickuplocationid).First(&loc).Error; err != nil {
return nil, &expressBookingValidationError{"pickuplocationid does not match a known location"}
}
if loc.Tenantid != req.Tenantid {
return nil, &expressBookingValidationError{"pickuplocationid does not belong to this tenant"}
}
if req.Pickupaddress == "" {
req.Pickupaddress = loc.Address
}
if req.Pickuppincode == "" {
req.Pickuppincode = loc.Pincode
}
if req.Pickuplatitude == 0 && req.Pickuplongitude == 0 {
req.Pickuplatitude, req.Pickuplongitude = loc.Latitude, loc.Longitude
}
}
// Checked after the location fill-in, so a caller supplying only a kitchen
// id is not rejected for an address it never needed to send.
if req.Pickupaddress == "" || req.Pickuppincode == "" {
return nil, &expressBookingValidationError{"pickup address and pincode are required"}
}
tx := db.DB.Begin()
customerID := req.Appcustomerid
if customerID == 0 && req.CustomerPhone != "" {
// Try to find customer by phone
var customer models.AppCustomer
if err := tx.Where("phone = ?", req.CustomerPhone).First(&customer).Error; err == nil {
customerID = customer.Appcustomerid
} else {
// Create dummy customer
newCustomer := models.AppCustomer{
Firstname: req.CustomerName,
Phone: req.CustomerPhone,
Status: "Active",
Configid: 1001,
}
if req.CustomerName == "" {
newCustomer.Firstname = "Guest"
}
if err := tx.Create(&newCustomer).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("failed to create customer record")
}
customerID = newCustomer.Appcustomerid
}
}
tenantID := req.Tenantid
booking := models.PickupBooking{
Bookingno: generateBookingNo(),
Tenantid: &tenantID,
Appcustomerid: customerID,
Pickuplocationid: req.Pickuplocationid,
Pickupaddress: req.Pickupaddress,
Pickuppincode: req.Pickuppincode,
Pickuplatitude: req.Pickuplatitude,
Pickuplongitude: req.Pickuplongitude,
Deliveryaddress: req.Deliveryaddress,
Deliverypincode: req.Deliverypincode,
Deliverycity: req.Deliverycity,
Deliverylatitude: req.Deliverylatitude,
Deliverylongitude: req.Deliverylongitude,
Bookingsource: "CRM_Console",
Providercompany: req.Providercompany,
Providerlocation: req.Providerlocation,
Notes: req.Notes,
Status: constants.BookingPendingPickup,
Preferredpickupfrom: req.Preferredpickupfrom,
Preferredpickupto: req.Preferredpickupto,
}
if err := tx.Create(&booking).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("failed to create booking")
}
var totalWeight float64
var totalVolume float64
var requiresLargeVehicle bool
for _, p := range req.Parcels {
volumetric := calculateVolumetricWeight(p.Length, p.Width, p.Height)
totalWeight += math.Max(p.Weight, volumetric)
totalVolume += p.Length * p.Width * p.Height
parcel := models.BookingParcel{
Bookingid: booking.Bookingid,
Itemcategory: p.Itemcategory,
Itemdescription: p.Itemdescription,
Declaredvalue: p.Declaredvalue,
Weight: p.Weight,
Length: p.Length,
Width: p.Width,
Height: p.Height,
Isfragile: p.Isfragile,
Needsinsurance: p.Needsinsurance,
Requireslargevehicle: p.Requireslargevehicle,
}
if p.Requireslargevehicle {
requiresLargeVehicle = true
}
if p.Needsinsurance {
parcel.Insuranceamount = p.Declaredvalue * 0.01
}
// If explicit insurance amount is provided in the request, apply it to the first parcel
if req.Insuranceamount > 0 && totalWeight == math.Max(p.Weight, calculateVolumetricWeight(p.Length, p.Width, p.Height)) {
parcel.Insuranceamount = req.Insuranceamount
parcel.Needsinsurance = true
}
if err := tx.Create(&parcel).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("failed to save parcel details")
}
}
var distance float64
if booking.Deliverylatitude != 0 && booking.Deliverylongitude != 0 {
distance = calculateDistance(booking.Pickuplatitude, booking.Pickuplongitude, booking.Deliverylatitude, booking.Deliverylongitude)
}
var pricing models.Pricing
err := tx.Where("status = ? AND ? BETWEEN effectivefrom AND effectiveto", "Active", time.Now()).Order("priority DESC").First(&pricing).Error
var estimatedPrice float64
var pricingID *int
if err == nil {
pricingID = &pricing.Pricingid
kmExtra := math.Max(0, distance-pricing.Basedistance)
kgExtra := math.Max(0, totalWeight-pricing.Baseweight)
estimatedPrice = pricing.Baseprice + (kmExtra * pricing.Priceperkm) + (kgExtra * pricing.Priceperkg) + pricing.Handlingcharges
} else {
estimatedPrice = 50.0 + (distance * 5.0) + (totalWeight * 10.0)
}
if req.Finalprice > 0 {
estimatedPrice = req.Finalprice
}
serviceType := req.ServiceOption
if serviceType == "" {
serviceType = "Normal"
}
if serviceType == "Fast" {
estimatedPrice *= 1.25
} else if serviceType == "Superfast" {
estimatedPrice *= 1.5
}
now := time.Now()
estDelivery := now.Add(24 * time.Hour)
slaDue := now.Add(36 * time.Hour)
if serviceType == "Fast" {
estDelivery = now.Add(12 * time.Hour)
slaDue = now.Add(18 * time.Hour)
} else if serviceType == "Superfast" {
estDelivery = now.Add(6 * time.Hour)
slaDue = now.Add(9 * time.Hour)
}
srvOption := models.BookingServiceOption{
Bookingid: booking.Bookingid,
Servicetype: serviceType,
Estimatedprice: estimatedPrice,
Estimateddeliveryat: &estDelivery,
Sladueat: &slaDue,
Pricingid: pricingID,
}
if err := tx.Create(&srvOption).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("failed to save service option")
}
if requiresLargeVehicle || totalWeight > 20.0 {
reqVeh := models.BookingVehicleRequirement{
Bookingid: booking.Bookingid,
Requiredvehicletype: "truck",
Reason: "Oversized package / heavy weight",
Status: "Required",
}
if err := tx.Create(&reqVeh).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("failed to save vehicle requirement")
}
}
if err := tx.Commit().Error; err != nil {
return nil, fmt.Errorf("failed to create booking")
}
go assignment.AssignCRMMiler(booking.Bookingid)
if db.Js != nil {
payload := map[string]interface{}{
"booking_id": booking.Bookingid,
"booking_no": booking.Bookingno,
"customer_id": booking.Appcustomerid,
"pickup_address": booking.Pickupaddress,
"pickup_pincode": booking.Pickuppincode,
"status": constants.BookingPendingPickup,
"created_at": time.Now().UnixMilli(),
}
if data, err := json.Marshal(payload); err == nil {
db.Js.Publish("api.v1.bookings.create", data)
}
}
db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, booking.Bookingid)
return &booking, nil
}
func CreateExpressBooking(c *fiber.Ctx) error {
req := new(AdminBookingRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
// A client login may only book under its own tenant. Left unchecked, the
// tenantid is caller-supplied, so a client could attribute bookings — and
// their cost — to another client.
if own := consoleTenantID(c); own != 0 {
req.Tenantid = own
}
booking, err := createExpressBooking(*req)
if err != nil {
if _, ok := err.(*expressBookingValidationError); ok {
return utils.BadRequest(c, err.Error())
}
return utils.Internal(c, err.Error())
}
return utils.Created(c, booking)
}
// AdminBulkCreateBookings creates several express-console bookings in one call — the
// console's CSV/bulk-import flow. Each item is processed independently, same
// per-item-result shape as AdminBulkCancelBookings, so one bad row (missing
// address, unknown tenantid) doesn't block the rest of the batch.
func AdminBulkCreateBookings(c *fiber.Ctx) error {
var req struct {
Bookings []AdminBookingRequest `json:"bookings"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if len(req.Bookings) == 0 {
return utils.BadRequest(c, "bookings is required and must not be empty")
}
if len(req.Bookings) > 200 {
return utils.BadRequest(c, "maximum 200 bookings per bulk request")
}
type result struct {
Index int `json:"index"`
Success bool `json:"success"`
Bookingid int `json:"bookingid,omitempty"`
Bookingno string `json:"bookingno,omitempty"`
Error string `json:"error,omitempty"`
}
results := make([]result, 0, len(req.Bookings))
ownTenant := consoleTenantID(c)
for i, item := range req.Bookings {
// Same tenant pin as the single-booking path — a bulk import must not
// be a way around it.
if ownTenant != 0 {
item.Tenantid = ownTenant
}
booking, err := createExpressBooking(item)
if err != nil {
results = append(results, result{Index: i, Success: false, Error: err.Error()})
continue
}
results = append(results, result{Index: i, Success: true, Bookingid: booking.Bookingid, Bookingno: booking.Bookingno})
}
return utils.OK(c, fiber.Map{"results": results})
}
func GetAdminBookingDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var booking models.PickupBooking
q := scopeToOwnTenant(c, db.DB.Preload("Parcels").Preload("ServiceOptions").Preload("Payments"), "tenantid")
if err := q.First(&booking, id).Error; err != nil {
return utils.NotFound(c, "booking not found")
}
return utils.OK(c, booking)
}
func AdminAssignMiler(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
if err := assertBookingAccess(c, id); err != nil {
return err
}
type MilerAssign struct {
Mileruserid int `json:"mileruserid"`
}
req := new(MilerAssign)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
adminUserID := c.Locals("userid").(int)
booking, err := AssignMilerToBooking(id, req.Mileruserid, &adminUserID)
if err != nil {
return utils.NotFound(c, "booking not found")
}
return utils.OK(c, booking)
}
func AdminAssignVehicle(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
if err := assertBookingAccess(c, id); err != nil {
return err
}
type VehicleAssign struct {
Vehicleid int `json:"vehicleid"`
Driveruserid int `json:"driveruserid"`
}
req := new(VehicleAssign)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
var reqVeh models.BookingVehicleRequirement
if err := db.DB.Where("bookingid = ? AND status = ?", id, "Required").First(&reqVeh).Error; err != nil {
return utils.NotFound(c, "no active vehicle requirement found for this booking")
}
reqVeh.Assignedvehicleid = &req.Vehicleid
reqVeh.Assigneddriveruserid = &req.Driveruserid
reqVeh.Status = "Assigned"
reqVeh.Updatedat = time.Now()
db.DB.Save(&reqVeh)
return utils.OK(c, reqVeh)
}
func AdminUpdateBookingStatus(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
if err := assertBookingAccess(c, id); err != nil {
return err
}
type StatusUpdate struct {
Status string `json:"status"`
}
req := new(StatusUpdate)
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 booking models.PickupBooking
if err := db.DB.First(&booking, id).Error; err != nil {
return utils.NotFound(c, "booking not found")
}
booking.Status = req.Status
booking.Updatedat = time.Now()
db.DB.Save(&booking)
return utils.OK(c, booking)
}
// AdminCancelBooking cancels a booking on behalf of operations, frees up the
// assigned miler (if any), and notifies downstream systems (NATS + customer push).
// A booking that has already been converted to a consignment (shipped) or is
// already cancelled cannot be cancelled again.
func AdminCancelBooking(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.BadRequest(c, "invalid booking ID")
}
if err := assertBookingAccess(c, id); err != nil {
return err
}
var booking models.PickupBooking
if err := db.DB.First(&booking, id).Error; err != nil {
return utils.NotFound(c, "booking not found")
}
if booking.Status == constants.BookingConvertedConsignment || booking.Status == constants.BookingCancelled {
return utils.BadRequest(c, "cannot cancel a delivered or already cancelled booking")
}
booking.Status = constants.BookingCancelled
booking.Updatedat = time.Now()
if err := db.DB.Save(&booking).Error; err != nil {
return utils.Internal(c, "failed to cancel booking")
}
if booking.Assignedmileruserid != nil {
db.DB.Model(&models.MilerProfile{}).
Where("userid = ?", *booking.Assignedmileruserid).
Update("availabilitystatus", constants.MilerAvailable)
}
if db.Js != nil {
payload := map[string]interface{}{
"bookingid": booking.Bookingid,
"reason": "admin_cancelled",
}
if data, err := json.Marshal(payload); err == nil {
if _, err := db.Js.Publish("booking.cancelled", data); err != nil {
utils.Warn("AdminCancelBooking: NATS publish failed", "booking_id", booking.Bookingid, "error", err)
}
}
}
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
if err := notify.SendToDevice(customer.Devicetoken, "Booking Cancelled", "Your booking has been cancelled by operations", nil); err != nil {
utils.Warn("AdminCancelBooking: failed to notify customer", "booking_id", booking.Bookingid, "error", err)
}
}
return utils.Message(c, "booking cancelled")
}
// AdminBulkCancelBookings cancels several bookings in one call — the console's
// multi-select "cancel selected" action. Each id is processed independently
// so one bad id (already shipped, already cancelled, not found) doesn't block
// the rest; the response reports success/failure per id rather than failing
// the whole batch on the first error.
func AdminBulkCancelBookings(c *fiber.Ctx) error {
var req struct {
Bookingids []int `json:"bookingids"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if len(req.Bookingids) == 0 {
return utils.BadRequest(c, "bookingids is required and must not be empty")
}
type result struct {
Bookingid int `json:"bookingid"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
results := make([]result, 0, len(req.Bookingids))
ownTenant := consoleTenantID(c)
for _, id := range req.Bookingids {
var booking models.PickupBooking
if err := db.DB.First(&booking, id).Error; err != nil {
results = append(results, result{Bookingid: id, Success: false, Error: "booking not found"})
continue
}
// Reported as not-found rather than forbidden, so a client can't probe
// which booking ids belong to other tenants.
if ownTenant != 0 && (booking.Tenantid == nil || *booking.Tenantid != ownTenant) {
results = append(results, result{Bookingid: id, Success: false, Error: "booking not found"})
continue
}
if booking.Status == constants.BookingConvertedConsignment || booking.Status == constants.BookingCancelled {
results = append(results, result{Bookingid: id, Success: false, Error: "cannot cancel a delivered or already cancelled booking"})
continue
}
booking.Status = constants.BookingCancelled
booking.Updatedat = time.Now()
if err := db.DB.Save(&booking).Error; err != nil {
results = append(results, result{Bookingid: id, Success: false, Error: "failed to save"})
continue
}
if booking.Assignedmileruserid != nil {
db.DB.Model(&models.MilerProfile{}).
Where("userid = ?", *booking.Assignedmileruserid).
Update("availabilitystatus", constants.MilerAvailable)
}
if db.Js != nil {
payload := map[string]interface{}{"bookingid": booking.Bookingid, "reason": "admin_bulk_cancelled"}
if data, err := json.Marshal(payload); err == nil {
if _, err := db.Js.Publish("booking.cancelled", data); err != nil {
utils.Warn("AdminBulkCancelBookings: NATS publish failed", "booking_id", booking.Bookingid, "error", err)
}
}
}
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
if err := notify.SendToDevice(customer.Devicetoken, "Booking Cancelled", "Your booking has been cancelled by operations", nil); err != nil {
utils.Warn("AdminBulkCancelBookings: failed to notify customer", "booking_id", booking.Bookingid, "error", err)
}
}
results = append(results, result{Bookingid: id, Success: true})
}
return utils.OK(c, fiber.Map{"results": results})
}
// --------------------
// CONSIGNMENTS
// --------------------
func GetAdminConsignments(c *fiber.Ctx) error {
page := utils.ParsePage(c)
var total int64
if err := scopeToOwnTenant(c, db.DB.Model(&models.Consignment{}), "tenantid").
Count(&total).Error; err != nil {
return utils.Internal(c, "failed to count consignments")
}
var list []models.Consignment
if err := page.Apply(scopeToOwnTenant(c, db.DB, "tenantid")).Find(&list).Error; err != nil {
return utils.Internal(c, "failed to fetch consignments")
}
return utils.Paginated(c, list, total, page)
}
func GetAdminConsignmentDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var csg models.Consignment
if err := scopeToOwnTenant(c, db.DB, "tenantid").First(&csg, id).Error; err != nil {
return utils.NotFound(c, "consignment not found")
}
return utils.OK(c, csg)
}
func GetAdminConsignmentTracking(c *fiber.Ctx) error {
trackingNo := c.Params("trackingno")
var consignment models.Consignment
if err := scopeToOwnTenant(c, db.DB, "tenantid").
Where("trackingno = ?", trackingNo).First(&consignment).Error; err != nil {
return utils.NotFound(c, "consignment not found")
}
var history []models.ConsignmentHistory
db.DB.Where("consignmentid = ?", consignment.Consignmentid).Order("createdat DESC").Find(&history)
return utils.OK(c, fiber.Map{"consignment": consignment, "history": history})
}
func AdminUpdateConsignmentStatus(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
type StatusUpdate struct {
Status string `json:"status"`
Remarks string `json:"remarks"`
}
req := new(StatusUpdate)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Status == "" {
return utils.BadRequest(c, "status is required")
}
tx := db.DB.Begin()
var consignment models.Consignment
if err := tx.First(&consignment, id).Error; err != nil {
tx.Rollback()
return utils.NotFound(c, "consignment not found")
}
consignment.Status = req.Status
consignment.Updatedat = time.Now()
if err := tx.Save(&consignment).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to update consignment status")
}
adminUserID := c.Locals("userid").(int)
history := models.ConsignmentHistory{
Consignmentid: consignment.Consignmentid,
Userid: &adminUserID,
Eventstatus: req.Status,
Remarks: req.Remarks,
}
if err := tx.Create(&history).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record consignment history")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to update consignment")
}
return utils.OK(c, consignment)
}
// --------------------
// TRIPSHEETS (MANIFEST)
// --------------------
func GetTripsheets(c *fiber.Ctx) error {
page := utils.ParsePage(c)
var total int64
if err := db.DB.Model(&models.Tripsheet{}).Where("deletedat IS NULL").Count(&total).Error; err != nil {
return utils.Internal(c, "failed to count tripsheets")
}
var list []models.Tripsheet
if err := page.Apply(db.DB.Where("deletedat IS NULL")).Find(&list).Error; err != nil {
return utils.Internal(c, "failed to fetch tripsheets")
}
return utils.Paginated(c, list, total, page)
}
func CreateTripsheet(c *fiber.Ctx) error {
req := new(dto.TripsheetCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
adminUserID := c.Locals("userid").(int)
tripsheet := models.Tripsheet{
Tripsheetno: generateTripsheetNo(),
Sourcehubid: req.Sourcehubid,
Destinationhubid: req.Destinationhubid,
Vehicleid: req.Vehicleid,
Driveruserid: req.Driveruserid,
Status: constants.TripsheetDraft,
Createdby: adminUserID,
Updatedby: adminUserID,
}
if err := db.DB.Create(&tripsheet).Error; err != nil {
return utils.Internal(c, "failed to create tripsheet")
}
return utils.Created(c, tripsheet)
}
func GetTripsheetDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var tripsheet models.Tripsheet
if err := db.DB.Where("tripsheetid = ? AND deletedat IS NULL", id).First(&tripsheet).Error; err != nil {
return utils.NotFound(c, "tripsheet not found")
}
var items []models.TripsheetItem
db.DB.Where("tripsheetid = ?", id).Find(&items)
return utils.OK(c, fiber.Map{"tripsheet": tripsheet, "items": items})
}
func AddTripsheetItem(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
req := new(dto.TripsheetItemAddRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
adminUserID := c.Locals("userid").(int)
item := models.TripsheetItem{
Tripsheetid: id,
Consignmentid: req.Consignmentid,
Scanstatus: constants.ScanPending,
Createdby: adminUserID,
Updatedby: adminUserID,
}
if err := db.DB.Create(&item).Error; err != nil {
return utils.Internal(c, "failed to add item to tripsheet")
}
return utils.Created(c, item)
}
func DeleteTripsheetItem(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
itemID, _ := strconv.Atoi(c.Params("itemid"))
var item models.TripsheetItem
if err := db.DB.Where("tripsheetid = ? AND tripsheetitemid = ?", id, itemID).First(&item).Error; err != nil {
return utils.NotFound(c, "item not found on this tripsheet")
}
db.DB.Delete(&item)
return utils.Message(c, "item removed from tripsheet")
}
func DispatchTripsheet(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
tx := db.DB.Begin()
var tripsheet models.Tripsheet
if err := tx.Where("tripsheetid = ?", id).First(&tripsheet).Error; err != nil {
tx.Rollback()
return utils.NotFound(c, "tripsheet not found")
}
now := time.Now()
tripsheet.Status = constants.TripsheetDispatched
tripsheet.Dispatchtime = &now
tripsheet.Updatedat = now
if err := tx.Save(&tripsheet).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to dispatch tripsheet")
}
// Fetch all loaded items
var items []models.TripsheetItem
if err := tx.Where("tripsheetid = ?", id).Find(&items).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to load tripsheet items")
}
adminUserID := c.Locals("userid").(int)
for _, item := range items {
// Update item scanning
if err := tx.Model(&item).Updates(map[string]interface{}{
"scanstatus": constants.ScanLoaded,
"scannedat": &now,
}).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to update tripsheet item scan status")
}
// Update consignment status to In_Transit
if err := tx.Model(&models.Consignment{}).Where("consignmentid = ?", item.Consignmentid).Updates(map[string]interface{}{
"status": constants.ConsignmentInTransit,
"updatedat": now,
}).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to update consignment status")
}
// Log history
history := models.ConsignmentHistory{
Consignmentid: item.Consignmentid,
Tripsheetid: &id,
Userid: &adminUserID,
Eventstatus: constants.ConsignmentInTransit,
Remarks: fmt.Sprintf("Consignment dispatched on Tripsheet %s", tripsheet.Tripsheetno),
}
if err := tx.Create(&history).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record consignment history")
}
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to dispatch tripsheet")
}
return utils.OK(c, tripsheet)
}
func ArriveTripsheet(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
tx := db.DB.Begin()
var tripsheet models.Tripsheet
if err := tx.Where("tripsheetid = ?", id).First(&tripsheet).Error; err != nil {
tx.Rollback()
return utils.NotFound(c, "tripsheet not found")
}
now := time.Now()
tripsheet.Status = constants.TripsheetArrived
tripsheet.Arrivaltime = &now
tripsheet.Updatedat = now
if err := tx.Save(&tripsheet).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to mark tripsheet arrived")
}
// Fetch items
var items []models.TripsheetItem
if err := tx.Where("tripsheetid = ?", id).Find(&items).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to load tripsheet items")
}
adminUserID := c.Locals("userid").(int)
for _, item := range items {
if err := tx.Model(&item).Updates(map[string]interface{}{
"scanstatus": constants.ScanUnloaded,
"scannedat": &now,
}).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to update tripsheet item scan status")
}
// Update consignment status back to Inwarded_at_Hub at destination
if err := tx.Model(&models.Consignment{}).Where("consignmentid = ?", item.Consignmentid).Updates(map[string]interface{}{
"status": constants.ConsignmentInwardedAtHub,
"currenthubid": tripsheet.Destinationhubid,
"updatedat": now,
}).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to update consignment status")
}
// Log history
history := models.ConsignmentHistory{
Consignmentid: item.Consignmentid,
Tripsheetid: &id,
Hubid: &tripsheet.Destinationhubid,
Userid: &adminUserID,
Eventstatus: constants.ConsignmentInwardedAtHub,
Remarks: fmt.Sprintf("Consignment arrived at Hub on Tripsheet %s", tripsheet.Tripsheetno),
}
if err := tx.Create(&history).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record consignment history")
}
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to mark tripsheet arrived")
}
return utils.OK(c, tripsheet)
}
// --------------------
// PRICING CRUD
// --------------------
func GetPricing(c *fiber.Ctx) error {
var pricing []models.Pricing
// Rates are commercially sensitive: one client must never see what another
// is charged.
if err := scopeToOwnTenant(c, db.DB, "tenantid").
Where("deletedat IS NULL").Find(&pricing).Error; err != nil {
return utils.Internal(c, "failed to fetch pricing schedules")
}
return utils.List(c, pricing, int64(len(pricing)))
}
func CreatePricing(c *fiber.Ctx) error {
req := new(dto.PricingCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
adminUserID := c.Locals("userid").(int)
pricing := models.Pricing{
Tenantid: req.Tenantid,
Applocationid: req.Applocationid,
Vehicletype: req.Vehicletype,
Baseprice: req.Baseprice,
Baseweight: req.Baseweight,
Priceperkg: req.Priceperkg,
Basedistance: req.Basedistance,
Priceperkm: req.Priceperkm,
Handlingcharges: req.Handlingcharges,
Effectivefrom: req.Effectivefrom,
Effectiveto: req.Effectiveto,
Currency: req.Currency,
Priority: req.Priority,
Status: req.Status,
Createdby: adminUserID,
Updatedby: adminUserID,
}
if pricing.Currency == "" {
pricing.Currency = "INR"
}
if pricing.Status == "" {
pricing.Status = "Active"
}
if err := db.DB.Create(&pricing).Error; err != nil {
return utils.Internal(c, "failed to create pricing schedule")
}
return utils.Created(c, pricing)
}
func UpdatePricing(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var pricing models.Pricing
if err := db.DB.Where("pricingid = ? AND deletedat IS NULL", id).First(&pricing).Error; err != nil {
return utils.NotFound(c, "pricing schedule not found")
}
req := new(dto.PricingCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Baseprice != 0 {
pricing.Baseprice = req.Baseprice
}
if req.Baseweight != 0 {
pricing.Baseweight = req.Baseweight
}
if req.Priceperkg != 0 {
pricing.Priceperkg = req.Priceperkg
}
if req.Basedistance != 0 {
pricing.Basedistance = req.Basedistance
}
if req.Priceperkm != 0 {
pricing.Priceperkm = req.Priceperkm
}
pricing.Handlingcharges = req.Handlingcharges
if !req.Effectivefrom.IsZero() {
pricing.Effectivefrom = req.Effectivefrom
}
if !req.Effectiveto.IsZero() {
pricing.Effectiveto = req.Effectiveto
}
if req.Status != "" {
pricing.Status = req.Status
}
pricing.Updatedat = time.Now()
pricing.Updatedby = c.Locals("userid").(int)
if err := db.DB.Save(&pricing).Error; err != nil {
return utils.Internal(c, "failed to update pricing schedule")
}
return utils.OK(c, pricing)
}
func DeletePricing(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var pricing models.Pricing
if err := db.DB.Where("pricingid = ? AND deletedat IS NULL", id).First(&pricing).Error; err != nil {
return utils.NotFound(c, "pricing schedule not found")
}
now := time.Now()
pricing.Deletedat = &now
db.DB.Save(&pricing)
return utils.Message(c, "pricing schedule deleted successfully")
}
func GetPricingQuoteSimulate(c *fiber.Ctx) error {
req := new(dto.PricingQuoteRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
distance := calculateDistance(req.Pickuplatitude, req.Pickuplongitude, req.Deliverylatitude, req.Deliverylongitude)
var totalWeight float64
for _, p := range req.Parcels {
volumetric := calculateVolumetricWeight(p.Length, p.Width, p.Height)
totalWeight += math.Max(p.Weight, volumetric)
}
var pricing models.Pricing
err := db.DB.Where("status = ? AND ? BETWEEN effectivefrom AND effectiveto", "Active", time.Now()).Order("priority DESC").First(&pricing).Error
var baseQuote float64
var pricingIDPtr *int
if err == nil {
pricingIDPtr = &pricing.Pricingid
kmExtra := math.Max(0, distance-pricing.Basedistance)
kgExtra := math.Max(0, totalWeight-pricing.Baseweight)
baseQuote = pricing.Baseprice + (kmExtra * pricing.Priceperkm) + (kgExtra * pricing.Priceperkg) + pricing.Handlingcharges
} else {
// Fallback simple pricing engine
baseQuote = 50.0 + (distance * 5.0) + (totalWeight * 10.0)
}
return utils.OK(c, fiber.Map{
"distance_km": distance,
"chargeable_weight": totalWeight,
"base_quote": baseQuote,
"pricing_id": pricingIDPtr,
})
}
// --------------------
// EXCEPTIONS
// --------------------
func GetExceptions(c *fiber.Ctx) error {
page := utils.ParsePage(c)
var total int64
if err := db.DB.Model(&models.ConsignmentException{}).Where("deletedat IS NULL").Count(&total).Error; err != nil {
return utils.Internal(c, "failed to count exceptions")
}
var list []models.ConsignmentException
if err := page.Apply(db.DB.Where("deletedat IS NULL")).Find(&list).Error; err != nil {
return utils.Internal(c, "failed to fetch exceptions")
}
return utils.Paginated(c, list, total, page)
}
func CreateException(c *fiber.Ctx) error {
req := new(dto.ExceptionCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
adminUserID := c.Locals("userid").(int)
exception := models.ConsignmentException{
Consignmentid: req.Consignmentid,
Tripsheetid: req.Tripsheetid,
Hubid: req.Hubid,
Reportedbyuserid: &adminUserID,
Exceptiontype: req.Exceptiontype,
Severity: req.Severity,
Description: req.Description,
Status: constants.ExceptionOpen,
Createdby: adminUserID,
Updatedby: adminUserID,
}
if exception.Severity == "" {
exception.Severity = "Medium"
}
tx := db.DB.Begin()
if err := tx.Create(&exception).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record exception")
}
// Update consignment status to Missing or Damaged if critical exception type matches
var cStatus string
if req.Exceptiontype == constants.ExceptionLost {
cStatus = constants.ConsignmentMissing
} else if req.Exceptiontype == constants.ExceptionDamaged {
cStatus = constants.ConsignmentDamaged
}
if cStatus != "" {
if err := tx.Model(&models.Consignment{}).Where("consignmentid = ?", req.Consignmentid).
Update("status", cStatus).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to update consignment status")
}
// Log history
history := models.ConsignmentHistory{
Consignmentid: req.Consignmentid,
Tripsheetid: req.Tripsheetid,
Hubid: req.Hubid,
Userid: &adminUserID,
Eventstatus: cStatus,
Remarks: fmt.Sprintf("Exception reported: %s. Description: %s", req.Exceptiontype, req.Description),
}
if err := tx.Create(&history).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record consignment history")
}
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to record exception")
}
return utils.Created(c, exception)
}
func GetExceptionDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var exception models.ConsignmentException
if err := db.DB.Where("exceptionid = ? AND deletedat IS NULL", id).First(&exception).Error; err != nil {
return utils.NotFound(c, "exception not found")
}
return utils.OK(c, exception)
}
func ResolveException(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
req := new(dto.ExceptionResolveRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
adminUserID := c.Locals("userid").(int)
var exception models.ConsignmentException
if err := db.DB.Where("exceptionid = ? AND deletedat IS NULL", id).First(&exception).Error; err != nil {
return utils.NotFound(c, "exception not found")
}
exception.Resolution = req.Resolution
exception.Status = req.Status
exception.Updatedby = adminUserID
exception.Updatedat = time.Now()
if err := db.DB.Save(&exception).Error; err != nil {
return utils.Internal(c, "failed to resolve exception")
}
return utils.OK(c, exception)
}
func CreateUserRedis(c *fiber.Ctx) error {
ctx := context.Background()
var userData models.CachedUser
if err := c.BodyParser(&userData); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if userData.UserID == 0 || userData.Username == "" {
return utils.BadRequest(c, "userid and username are required")
}
if db.Rdb == nil {
return utils.Internal(c, "cache service unavailable")
}
jsonData, err := json.Marshal(userData)
if err != nil {
return utils.Internal(c, "failed to serialize user data")
}
userKey := fmt.Sprintf("user:%d", userData.UserID)
if err := db.Rdb.Set(ctx, userKey, jsonData, 0).Err(); err != nil {
return utils.Internal(c, "failed to store user in cache")
}
db.Rdb.ZAdd(ctx, "user", redis.Z{
Score: float64(time.Now().Unix()),
Member: fmt.Sprintf("%d", userData.UserID),
})
return utils.Created(c, userData)
}
func GetUserRedis(c *fiber.Ctx) error {
ctx := context.Background()
if db.Rdb == nil {
return utils.Internal(c, "cache service unavailable")
}
userIDStr := c.Query("userid")
if userIDStr != "" {
userID, err := strconv.Atoi(userIDStr)
if err != nil || userID == 0 {
return utils.BadRequest(c, "invalid userid parameter")
}
userKey := fmt.Sprintf("user:%d", userID)
userData, err := db.Rdb.Get(ctx, userKey).Result()
if err != nil {
return utils.NotFound(c, "user not found in cache")
}
var user map[string]interface{}
json.Unmarshal([]byte(userData), &user)
return utils.OK(c, user)
}
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
}
userIDs, err := db.Rdb.ZRevRange(ctx, "user", start, end).Result()
if err != nil {
return utils.Internal(c, "failed to fetch user list")
}
if len(userIDs) == 0 {
return utils.List(c, []interface{}{}, 0)
}
var keys []string
for _, id := range userIDs {
keys = append(keys, fmt.Sprintf("user:%s", id))
}
values, err := db.Rdb.MGet(ctx, keys...).Result()
if err != nil {
return utils.Internal(c, "failed to retrieve user data")
}
var users []map[string]interface{}
for _, val := range values {
if val == nil {
continue
}
var user map[string]interface{}
json.Unmarshal([]byte(val.(string)), &user)
users = append(users, user)
}
return utils.List(c, users, int64(len(users)))
}
func UpdateUserRedis(c *fiber.Ctx) error {
ctx := context.Background()
userID, err := strconv.Atoi(c.Params("userid"))
if err != nil || userID == 0 {
return utils.BadRequest(c, "invalid userid parameter")
}
var userData map[string]interface{}
if err := c.BodyParser(&userData); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if db.Rdb == nil {
return utils.Internal(c, "cache service unavailable")
}
userKey := fmt.Sprintf("user:%d", userID)
exists, err := db.Rdb.Exists(ctx, userKey).Result()
if err != nil || exists == 0 {
return utils.NotFound(c, "user not found in cache")
}
userData["userid"] = userID
jsonData, _ := json.Marshal(userData)
if err := db.Rdb.Set(ctx, userKey, jsonData, 0).Err(); err != nil {
return utils.Internal(c, "failed to update user in cache")
}
return utils.OK(c, userData)
}
func DeleteUserRedis(c *fiber.Ctx) error {
ctx := context.Background()
userID, err := strconv.Atoi(c.Params("userid"))
if err != nil || userID == 0 {
return utils.BadRequest(c, "invalid userid parameter")
}
if db.Rdb == nil {
return utils.Internal(c, "cache service unavailable")
}
userKey := fmt.Sprintf("user:%d", userID)
db.Rdb.Del(ctx, userKey)
db.Rdb.ZRem(ctx, "user", fmt.Sprintf("%d", userID))
return utils.Message(c, "user removed from cache successfully")
}
func GetAllUsersRedis(c *fiber.Ctx) error {
ctx := context.Background()
if db.Rdb == nil {
return utils.Internal(c, "cache service unavailable")
}
userIDs, err := db.Rdb.ZRange(ctx, "user", 0, -1).Result()
if err != nil {
return utils.Internal(c, "failed to fetch user index")
}
var users []map[string]interface{}
for _, idStr := range userIDs {
userKey := fmt.Sprintf("user:%s", idStr)
userData, err := db.Rdb.Get(ctx, userKey).Result()
if err != nil {
continue
}
var user map[string]interface{}
json.Unmarshal([]byte(userData), &user)
users = append(users, user)
}
return utils.List(c, users, int64(len(users)))
}
func GetAdminProfile(c *fiber.Ctx) error {
userID, ok := c.Locals("userid").(int)
if !ok {
return utils.Unauthorized(c, "authentication required")
}
var user models.AppUser
if err := db.DB.Where("userid = ?", userID).First(&user).Error; err != nil {
return utils.NotFound(c, "user not found")
}
return utils.OK(c, user)
}
// AdminChangePassword lets a logged-in admin console user change their own
// password. Unlike ResetCustomerPin/ResetMilerPin — open, phone-only reset
// endpoints matching what those two apps already do — this requires an
// active session and the current password. Admin accounts touch tenant,
// pricing, and financial data, so an open "reset by email" endpoint here
// would be a much bigger blast radius than a customer or miler PIN reset;
// intentionally not mirroring that pattern for this one.
func AdminChangePassword(c *fiber.Ctx) error {
userID, ok := c.Locals("userid").(int)
if !ok {
return utils.Unauthorized(c, "authentication required")
}
var req struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.CurrentPassword == "" || req.NewPassword == "" {
return utils.BadRequest(c, "current_password and new_password are required")
}
if len(req.NewPassword) < 8 {
return utils.BadRequest(c, "new_password must be at least 8 characters")
}
var user models.AppUser
if err := db.DB.Where("userid = ?", userID).First(&user).Error; err != nil {
return utils.NotFound(c, "user not found")
}
var auth models.DoormileAuth
if err := db.DB.Where("email = ?", user.Email).First(&auth).Error; err != nil {
return utils.NotFound(c, "admin credentials not found for this account")
}
if !utils.CheckPasswordHash(req.CurrentPassword, auth.PasswordHash) {
return utils.Unauthorized(c, "current password is incorrect")
}
newHash, err := utils.HashPassword(req.NewPassword)
if err != nil {
return utils.Internal(c, "failed to process password change")
}
auth.PasswordHash = newHash
if err := db.DB.Save(&auth).Error; err != nil {
return utils.Internal(c, "failed to update password")
}
return utils.Message(c, "password changed successfully")
}
// InternalNotify sends FCM push notifications on behalf of the Python agent system.
// The caller specifies target = "customer", "miler", or "both".
// Auth: X-Internal-Key header (see InternalKeyAuth middleware).
//
// POST /api/v1/internal/notify
func InternalNotify(c *fiber.Ctx) error {
type req struct {
BookingID int `json:"booking_id"`
Target string `json:"target"`
Title string `json:"title"`
Message string `json:"message"`
Data map[string]string `json:"data"`
}
body := new(req)
if err := c.BodyParser(body); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if body.BookingID == 0 {
return utils.BadRequest(c, "booking_id is required")
}
if body.Target != "customer" && body.Target != "miler" && body.Target != "both" {
return utils.BadRequest(c, "target must be customer, miler, or both")
}
if body.Title == "" || body.Message == "" {
return utils.BadRequest(c, "title and message are required")
}
var booking models.PickupBooking
if err := db.DB.First(&booking, body.BookingID).Error; err != nil {
return utils.NotFound(c, "booking not found")
}
data := body.Data
if data == nil {
data = map[string]string{}
}
data["booking_id"] = strconv.Itoa(booking.Bookingid)
sent := make([]string, 0, 2)
if body.Target == "customer" || body.Target == "both" {
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
if err := notify.SendToDevice(customer.Devicetoken, body.Title, body.Message, data); err != nil {
utils.Warn("InternalNotify: failed to notify customer", "booking_id", booking.Bookingid, "error", err)
} else {
sent = append(sent, "customer")
}
}
}
if body.Target == "miler" || body.Target == "both" {
if booking.Assignedmileruserid != nil {
var profile models.MilerProfile
if err := db.DB.Where("userid = ?", *booking.Assignedmileruserid).First(&profile).Error; err == nil && profile.Devicetoken != "" {
if err := notify.SendToDevice(profile.Devicetoken, body.Title, body.Message, data); err != nil {
utils.Warn("InternalNotify: failed to notify miler", "booking_id", booking.Bookingid, "error", err)
} else {
sent = append(sent, "miler")
}
}
}
}
return utils.OK(c, fiber.Map{
"sent": true,
"targets": sent,
})
}
// InternalReassign releases the current miler assignment and re-triggers the
// auto-assignment engine for a stalled booking. Only valid when the booking is
// in Miler_Assigned or Pickup_Scheduled state.
// Auth: X-Internal-Key header (see InternalKeyAuth middleware).
//
// POST /api/v1/internal/bookings/:id/reassign
func InternalReassign(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.BadRequest(c, "invalid booking ID")
}
tx := db.DB.Begin()
var booking models.PickupBooking
if err := tx.First(&booking, id).Error; err != nil {
tx.Rollback()
return utils.NotFound(c, "booking not found")
}
if booking.Status != constants.BookingMilerAssigned && booking.Status != constants.BookingPickupScheduled {
tx.Rollback()
return utils.BadRequest(c, "booking must be in Miler_Assigned or Pickup_Scheduled state to reassign")
}
now := time.Now()
if booking.Assignedmileruserid != nil {
if err := tx.Model(&models.MilerProfile{}).
Where("userid = ?", *booking.Assignedmileruserid).
Updates(map[string]interface{}{
"availabilitystatus": constants.MilerAvailable,
"updatedat": now,
}).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to free previous miler")
}
if err := tx.Delete(&models.BookingAssignment{},
"bookingid = ? AND assignmentstatus IN ?",
booking.Bookingid,
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted},
).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to clear previous assignment")
}
}
booking.Status = constants.BookingCreated
booking.Assignedmileruserid = nil
booking.Updatedat = now
if err := tx.Save(&booking).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to reset booking")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to reassign booking")
}
if booking.Bookingsource == "CRM_Console" {
go assignment.AssignCRMMiler(booking.Bookingid)
} else {
go assignment.AssignCustomerMiler(booking.Bookingid)
}
utils.Info("InternalReassign: reassignment triggered",
"booking_id", booking.Bookingid,
"booking_source", booking.Bookingsource,
)
return utils.OK(c, fiber.Map{
"reassignment": "triggered",
"booking_id": booking.Bookingid,
})
}