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) } // scopeViaBookings restricts a query to rows whose foreign key appears on one of // the tenant's bookings. Customers carry no tenant of their own — the same // person can order from two different clients — so the relationship only exists // through the bookings placed for them. func scopeViaBookings(c *fiber.Ctx, query *gorm.DB, column string) *gorm.DB { tenantID := consoleTenantID(c) if tenantID == 0 { return query } return query.Where(column+" IN (?)", db.DB.Model(&models.PickupBooking{}).Select("appcustomerid").Where("tenantid = ?", tenantID)) } // scopeViaConsignments restricts a query to rows attached to one of the tenant's // consignments — used for exceptions, which inherit their owner from the parcel // they were raised against. func scopeViaConsignments(c *fiber.Ctx, query *gorm.DB, column string) *gorm.DB { tenantID := consoleTenantID(c) if tenantID == 0 { return query } return query.Where(column+" IN (?)", db.DB.Model(&models.Consignment{}).Select("consignmentid").Where("tenantid = ?", 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 { // The legacy customers table carries no tenant column and predates tenant // attribution, so there is no way to prove any row belongs to a given // client. Rather than hand a client the whole list, they get none of it. if !isDoormileConsoleStaff(c) { return utils.List(c, []models.Customer{}, 0) } 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) } // A client sees only the customers they have actually delivered to, not // Doormile's whole B2C address book. query = scopeViaBookings(c, query, "appcustomerid") 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) } // Riders belong to a client through their appusers row; a client sees their // own fleet, not Doormile's whole roster. if tenantID := consoleTenantID(c); tenantID != 0 { query = query.Where("userid IN (?)", db.DB.Model(&models.AppUser{}).Select("userid"). Where("tenantid = ? AND roleid = ?", tenantID, 5)) } 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")) // Addressed by id, so the ownership check has to happen before the write — // otherwise a client could move another client's parcel through the network. if own := consoleTenantID(c); own != 0 { var owner models.Consignment if err := db.DB.Select("consignmentid", "tenantid").First(&owner, id).Error; err != nil { return utils.NotFound(c, "consignment not found") } if owner.Tenantid != own { return utils.NotFound(c, "consignment not found") } } 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 { // A tripsheet is a Doormile vehicle run and routinely carries several // clients' parcels on the same manifest, so there is no honest way to show // one to a single client. Doormile staff only. if !isDoormileConsoleStaff(c) { return utils.List(c, []models.Tripsheet{}, 0) } 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) // An exception belongs to whoever owns the parcel it was raised against. var total int64 if err := scopeViaConsignments(c, db.DB.Model(&models.ConsignmentException{}), "consignmentid"). 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(scopeViaConsignments(c, db.DB, "consignmentid").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, }) }