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>
This commit is contained in:
Suriya
2026-08-05 18:16:56 +05:30
parent e1fd4dc5d0
commit fd7cf3e35e
14 changed files with 614 additions and 184 deletions

View File

@@ -21,8 +21,72 @@ import (
"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)
@@ -68,8 +132,18 @@ func LoginAdmin(cfg *config.Config) fiber.Handler {
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, 0, 1, cfg.JWTSecret)
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")
}
@@ -78,10 +152,11 @@ func LoginAdmin(cfg *config.Config) fiber.Handler {
"success": true,
"token": token,
"user": fiber.Map{
"id": appUser.Userid,
"name": userName,
"email": auth.Email,
"role": auth.Role,
"id": appUser.Userid,
"name": userName,
"email": auth.Email,
"role": auth.Role,
"tenantid": auth.Tenantid,
},
})
}
@@ -95,12 +170,19 @@ func GetAdminDashboard(c *fiber.Ctx) error {
var totalConsignments int64
var openExceptions int64
db.DB.Model(&models.Tenant{}).Count(&totalTenants)
db.DB.Model(&models.AppCustomer{}).Count(&totalCustomers)
db.DB.Model(&models.AppUser{}).Where("roleid = 5").Count(&totalMilers)
db.DB.Model(&models.PickupBooking{}).Count(&totalBookings)
db.DB.Model(&models.Consignment{}).Count(&totalConsignments)
db.DB.Model(&models.ConsignmentException{}).Where("status = ?", "Open").Count(&openExceptions)
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,
@@ -131,20 +213,26 @@ func GetAdminReports(c *fiber.Ctx) 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
db.DB.Model(&models.PickupBooking{}).Where("createdat BETWEEN ? AND ?", from, to).Count(&totalBookings)
scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid").
Where("createdat BETWEEN ? AND ?", from, to).Count(&totalBookings)
var delivered int64
db.DB.Model(&models.PickupBooking{}).
scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid").
Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingConvertedConsignment, from, to).
Count(&delivered)
var cancelled int64
db.DB.Model(&models.PickupBooking{}).
scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid").
Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingCancelled, from, to).
Count(&cancelled)
consignmentQuery := db.DB.Model(&models.Consignment{}).Where("createdat BETWEEN ? AND ?", from, to)
consignmentQuery := scopeToOwnTenant(c, db.DB.Model(&models.Consignment{}), "tenantid").
Where("createdat BETWEEN ? AND ?", from, to)
if tenantID != "" {
consignmentQuery = consignmentQuery.Where("tenantid = ?", tenantID)
}
@@ -154,15 +242,26 @@ func GetAdminReports(c *fiber.Ctx) error {
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
db.DB.Model(&models.BookingPayment{}).
Where("paymentstatus = ? AND createdat BETWEEN ? AND ?", constants.PaymentStatusPaid, from, to).
Select("COALESCE(SUM(amount), 0)").Scan(&codCollected)
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
db.DB.Model(&models.ConsignmentException{}).
Where("status != ? AND createdat BETWEEN ? AND ?", constants.ExceptionClosed, from, to).
Count(&openExceptions)
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 {
@@ -176,14 +275,23 @@ func GetAdminReports(c *fiber.Ctx) error {
Delivered int64 `gorm:"column:delivered"`
}
var hubRows []hubRow
db.DB.Raw(`
// 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 ?
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
`, constants.ConsignmentDelivered, from, to).Scan(&hubRows)
ORDER BY delivered DESC`
db.DB.Raw(hubSQL, hubArgs...).Scan(&hubRows)
byHub := make([]fiber.Map, 0, len(hubRows))
for _, r := range hubRows {
@@ -197,13 +305,19 @@ func GetAdminReports(c *fiber.Ctx) error {
Bookings int64 `gorm:"column:bookings"`
}
var tenantRows []tenantRow
db.DB.Raw(`
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 ?
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
`, from, to).Scan(&tenantRows)
ORDER BY bookings DESC`
db.DB.Raw(tenantSQL, tenantArgs...).Scan(&tenantRows)
byTenant := make([]fiber.Map, 0, len(tenantRows))
for _, r := range tenantRows {
@@ -219,6 +333,9 @@ func GetAdminReports(c *fiber.Ctx) error {
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,
@@ -235,7 +352,9 @@ func GetAdminReports(c *fiber.Ctx) error {
args = append(args, hubID)
}
riderQuery += " GROUP BY mp.userid, mp.displayname ORDER BY completed_stops DESC LIMIT 50"
db.DB.Raw(riderQuery, args...).Scan(&riderRows)
if isDoormileConsoleStaff(c) {
db.DB.Raw(riderQuery, args...).Scan(&riderRows)
}
byRider := make([]fiber.Map, 0, len(riderRows))
for _, r := range riderRows {
@@ -271,13 +390,16 @@ func GetAppUsers(c *fiber.Ctx) error {
page := utils.ParsePage(c)
var total int64
if err := db.DB.Model(&models.AppUser{}).Where("roleid != ?", 5).Count(&total).Error; err != nil {
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
if err := page.Apply(db.DB.Where("roleid != ?", 5)).Find(&users).Error; err != nil {
// 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")
}
@@ -420,7 +542,9 @@ func DeleteAppUser(c *fiber.Ctx) error {
func GetTenants(c *fiber.Ctx) error {
var tenants []models.Tenant
if err := db.DB.Find(&tenants).Error; err != nil {
// 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)))
@@ -441,6 +565,9 @@ func CreateTenant(c *fiber.Ctx) error {
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")
@@ -451,7 +578,7 @@ func CreateTenant(c *fiber.Ctx) error {
func GetTenantDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var tenant models.Tenant
if err := db.DB.First(&tenant, id).Error; err != nil {
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)
@@ -459,6 +586,9 @@ func GetTenantDetails(c *fiber.Ctx) error {
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")
@@ -481,6 +611,9 @@ func UpdateTenant(c *fiber.Ctx) error {
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 {
@@ -491,6 +624,11 @@ func UpdateTenant(c *fiber.Ctx) error {
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")
@@ -504,6 +642,9 @@ func DeleteTenant(c *fiber.Ctx) error {
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")
@@ -513,21 +654,25 @@ func GetTenantLocations(c *fiber.Ctx) error {
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,
Address: req.Address,
City: req.City,
State: req.State,
Pincode: req.Pincode,
Latitude: req.Latitude,
Longitude: req.Longitude,
Isprimary: req.Isprimary,
Status: req.Status,
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"
@@ -549,12 +694,20 @@ func UpdateTenantLocation(c *fiber.Ctx) error {
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
}
@@ -1177,6 +1330,21 @@ func CreateMiler(c *fiber.Ctx) error {
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,
@@ -1185,6 +1353,9 @@ func CreateMiler(c *fiber.Ctx) error {
Roleid: 5, // Miler
Status: "Active",
Applocationid: appLocID,
Tenantid: tenantID,
Hubid: req.Hubid,
Configid: configID,
}
if err := tx.Create(&user).Error; err != nil {
@@ -1200,6 +1371,7 @@ func CreateMiler(c *fiber.Ctx) error {
Availabilitystatus: constants.MilerOffline,
Rating: 5.00,
Applocationid: appLocID,
Hubid: req.Hubid,
}
if err := tx.Create(&profile).Error; err != nil {
@@ -1360,6 +1532,9 @@ func GetAdminBookings(c *fiber.Ctx) error {
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 {
@@ -1388,10 +1563,15 @@ func GetAdminBookings(c *fiber.Ctx) error {
// (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"`
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"`
@@ -1426,9 +1606,6 @@ func (e *expressBookingValidationError) Error() string { return e.msg }
// *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 req.Pickupaddress == "" || req.Pickuppincode == "" {
return nil, &expressBookingValidationError{"pickup address and pincode are required"}
}
if len(req.Parcels) == 0 {
return nil, &expressBookingValidationError{"at least one parcel is required"}
}
@@ -1440,6 +1617,35 @@ func createExpressBooking(req AdminBookingRequest) (*models.PickupBooking, error
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
@@ -1472,6 +1678,7 @@ func createExpressBooking(req AdminBookingRequest) (*models.PickupBooking, error
Bookingno: generateBookingNo(),
Tenantid: &tenantID,
Appcustomerid: customerID,
Pickuplocationid: req.Pickuplocationid,
Pickupaddress: req.Pickupaddress,
Pickuppincode: req.Pickuppincode,
Pickuplatitude: req.Pickuplatitude,
@@ -1641,6 +1848,13 @@ func CreateExpressBooking(c *fiber.Ctx) error {
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 {
@@ -1679,7 +1893,14 @@ func AdminBulkCreateBookings(c *fiber.Ctx) error {
}
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()})
@@ -1694,7 +1915,8 @@ func AdminBulkCreateBookings(c *fiber.Ctx) error {
func GetAdminBookingDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var booking models.PickupBooking
if err := db.DB.Preload("Parcels").Preload("ServiceOptions").Preload("Payments").First(&booking, id).Error; err != nil {
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)
@@ -1702,6 +1924,9 @@ func GetAdminBookingDetails(c *fiber.Ctx) error {
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"`
@@ -1723,6 +1948,9 @@ func AdminAssignMiler(c *fiber.Ctx) error {
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"`
@@ -1749,6 +1977,9 @@ func AdminAssignVehicle(c *fiber.Ctx) error {
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"`
@@ -1783,6 +2014,9 @@ func AdminCancelBooking(c *fiber.Ctx) error {
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 {
@@ -1850,6 +2084,8 @@ func AdminBulkCancelBookings(c *fiber.Ctx) error {
}
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 {
@@ -1857,6 +2093,13 @@ func AdminBulkCancelBookings(c *fiber.Ctx) error {
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
@@ -1905,12 +2148,13 @@ func GetAdminConsignments(c *fiber.Ctx) error {
page := utils.ParsePage(c)
var total int64
if err := db.DB.Model(&models.Consignment{}).Count(&total).Error; err != nil {
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(db.DB).Find(&list).Error; err != nil {
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)
@@ -1919,7 +2163,7 @@ func GetAdminConsignments(c *fiber.Ctx) error {
func GetAdminConsignmentDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var csg models.Consignment
if err := db.DB.First(&csg, id).Error; err != nil {
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)
@@ -1928,7 +2172,8 @@ func GetAdminConsignmentDetails(c *fiber.Ctx) error {
func GetAdminConsignmentTracking(c *fiber.Ctx) error {
trackingNo := c.Params("trackingno")
var consignment models.Consignment
if err := db.DB.Where("trackingno = ?", trackingNo).First(&consignment).Error; err != nil {
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
@@ -2218,7 +2463,10 @@ func ArriveTripsheet(c *fiber.Ctx) error {
func GetPricing(c *fiber.Ctx) error {
var pricing []models.Pricing
if err := db.DB.Where("deletedat IS NULL").Find(&pricing).Error; err != nil {
// 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)))

View File

@@ -96,9 +96,11 @@ func humanizeRelativeTime(t time.Time) string {
// todayMidnight returns the start of the current day in server local time,
// used to scope "today" counters on the hub dashboard.
// todayMidnight is the start of the current day as the database records it —
// see utils.DBNow. Using the container's own clock here dropped every row
// created after noon IST out of "today so far".
func todayMidnight() time.Time {
now := time.Now()
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
return utils.DBToday()
}
// parseHubDateRange parses optional from/to (YYYY-MM-DD) query params shared
@@ -110,17 +112,20 @@ func parseHubDateRange(c *fiber.Ctx) (time.Time, time.Time, error) {
toStr := c.Query("to")
if fromStr == "" && toStr == "" {
return todayMidnight(), time.Now(), nil
return todayMidnight(), utils.DBNow(), nil
}
if fromStr == "" || toStr == "" {
return time.Time{}, time.Time{}, fmt.Errorf("both from and to query params are required (YYYY-MM-DD)")
}
from, err := time.ParseInLocation("2006-01-02", fromStr, time.Local)
// Parsed as UTC, not time.Local: stored timestamps are bare wall-clock
// digits, so the bounds must be too — otherwise the window silently shifts
// with whatever timezone the container happens to run in.
from, err := time.ParseInLocation("2006-01-02", fromStr, time.UTC)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid from date, expected YYYY-MM-DD")
}
toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local)
toDate, err := time.ParseInLocation("2006-01-02", toStr, time.UTC)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid to date, expected YYYY-MM-DD")
}

View File

@@ -274,9 +274,6 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Otp == "" {
return utils.BadRequest(c, "otp is required")
}
if req.Deliveredtoname == "" {
return utils.BadRequest(c, "deliveredtoname is required")
}
@@ -296,17 +293,28 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
return utils.BadRequest(c, "consignment is not out for delivery")
}
// An OTP is only present when the client asked for one (Tenant.Requiredeliveryotp),
// so an empty one means this delivery was never meant to need a code — that
// covers food clients like DailyGrubs as well as parcels already in the
// network from before OTPs existed, which would otherwise be unclosable.
if consignment.Deliveryotp != "" {
if req.Otp == "" {
return utils.BadRequest(c, "otp is required for this delivery")
}
if req.Otp != consignment.Deliveryotp {
return utils.BadRequest(c, "incorrect delivery OTP")
}
}
tx := db.DB.Begin()
// OTP generation/storage is Phase 2 — no delivery_otp field exists yet on
// consignments, so acceptance of a non-empty OTP is treated as verified.
proof := models.DeliveryProof{
Consignmentid: consignment.Consignmentid,
Deliveredat: time.Now(),
Deliveredtoname: req.Deliveredtoname,
Receiversignatureurl: req.Receiversignatureurl,
Photourl: req.Photourl,
Otpverified: true,
Otpverified: consignment.Deliveryotp != "",
Geolatitude: req.Lat,
Geolongitude: req.Lon,
Createdby: milerUserID,
@@ -317,12 +325,30 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
}
consignment.Status = constants.ConsignmentDelivered
// Cleared once redeemed so the same code can't close out a second attempt.
consignment.Deliveryotp = ""
consignment.Updatedat = time.Now()
if err := tx.Save(&consignment).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to mark consignment delivered")
}
// Every other state change on a consignment writes a history row; delivery
// did not, so a customer following the tracking timeline never saw the
// parcel arrive — it just stopped at Out_for_Delivery.
deliveredEvent := models.ConsignmentHistory{
Consignmentid: consignment.Consignmentid,
Hubid: consignment.Currenthubid,
Userid: &milerUserID,
Eventstatus: constants.ConsignmentDelivered,
Remarks: fmt.Sprintf("Delivered to %s at (%.5f, %.5f)",
req.Deliveredtoname, req.Lat, req.Lon),
}
if err := tx.Create(&deliveredEvent).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record delivery history")
}
if err := tx.Model(&models.BookingAssignment{}).
Where("bookingid = ? AND mileruserid = ?", booking.Bookingid, milerUserID).
Updates(map[string]interface{}{

View File

@@ -293,7 +293,8 @@ func UpdateMilerAvailability(c *fiber.Ctx) error {
return utils.BadRequest(c, "invalid request body")
}
if req.Status == "" {
status := req.ResolvedStatus()
if status == "" {
return utils.BadRequest(c, "status is required")
}
@@ -302,7 +303,7 @@ func UpdateMilerAvailability(c *fiber.Ctx) error {
return utils.NotFound(c, "miler profile not found")
}
profile.Availabilitystatus = req.Status
profile.Availabilitystatus = status
profile.Updatedat = time.Now()
if err := db.DB.Save(&profile).Error; err != nil {
return utils.Internal(c, "failed to update availability")
@@ -796,9 +797,13 @@ func BookingPickupComplete(c *fiber.Ctx) error {
consignmentTenantID = *booking.Tenantid
}
// Carried over so the consignment stays traceable to the client site it was
// collected from — for a food client that's the kitchen, and "how many
// parcels went out of which kitchen" is unanswerable without it.
consignment := models.Consignment{
Trackingno: trackingNo,
Tenantid: consignmentTenantID,
Pickuplocationid: booking.Pickuplocationid,
Pickuplatitude: booking.Pickuplatitude,
Pickuplongitude: booking.Pickuplongitude,
Deliverylatitude: booking.Deliverylatitude,
@@ -829,6 +834,17 @@ func BookingPickupComplete(c *fiber.Ctx) error {
}
}
// A hyperlocal parcel goes straight out for delivery, so its receiver OTP has
// to exist before this transaction commits. Anything routed via a hub gets
// its OTP when it actually leaves for the final mile instead. Only issued
// for clients that ask for it — see Tenant.Requiredeliveryotp.
if consignmentStatus == constants.ConsignmentOutForDelivery {
var tenant models.Tenant
if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil && tenant.Requiredeliveryotp {
consignment.Deliveryotp = utils.GenerateNumericOTP(6)
}
}
if err := tx.Create(&consignment).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to convert booking to consignment")
@@ -865,15 +881,18 @@ func BookingPickupComplete(c *fiber.Ctx) error {
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
if notifyErr := notify.SendToDevice(
customer.Devicetoken,
"Parcel Picked Up",
fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo),
map[string]string{
"booking_id": strconv.Itoa(bookingID),
"tracking_no": trackingNo,
},
); notifyErr != nil {
body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo)
payload := map[string]string{
"booking_id": strconv.Itoa(bookingID),
"tracking_no": trackingNo,
}
// The OTP goes to the receiver and only the receiver — the rider has to
// be told it at the door, which is what makes it proof of handover.
if consignment.Deliveryotp != "" {
body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignment.Deliveryotp)
payload["delivery_otp"] = consignment.Deliveryotp
}
if notifyErr := notify.SendToDevice(customer.Devicetoken, "Parcel Picked Up", body, payload); notifyErr != nil {
utils.Warn("FCM: failed to notify customer on pickup", "booking_id", bookingID, "error", notifyErr)
}
}
@@ -919,6 +938,11 @@ func CreateMilerPeriodicLog(c *fiber.Ctx) error {
return utils.BadRequest(c, "invalid request body")
}
// The rider's identity comes from their token, never the body. Trusting a
// client-supplied userid let any authenticated miler write another miler's
// GPS trail, which feeds the location data dispatch reasons over.
log.UserID = c.Locals("userid").(int)
t, err := time.Parse("2006-01-02 15:04:05", log.LogDate)
if err != nil {
return utils.BadRequest(c, "invalid logdate format — expected YYYY-MM-DD HH:MM:SS")
@@ -987,8 +1011,12 @@ func CreateMilerStatus(c *fiber.Ctx) error {
return utils.BadRequest(c, "invalid request body")
}
if status.UserID == 0 || status.Status == "" {
return utils.BadRequest(c, "userid and status are required")
// Identity from the token, not the body — otherwise one rider can set
// another rider's live status.
status.UserID = c.Locals("userid").(int)
if status.Status == "" {
return utils.BadRequest(c, "status is required")
}
key := fmt.Sprintf("miler_status:%d", status.UserID)
@@ -1088,10 +1116,16 @@ func PublishConsignmentLogs(c *fiber.Ctx) error {
return utils.Internal(c, "cache service unavailable")
}
milerUserID := c.Locals("userid").(int)
pipe := db.Rdb.TxPipeline()
tx := db.DB.Begin()
for _, item := range input {
// Same rule as the other telemetry writers: the token owns the identity,
// so a batch can't be attributed to some other rider.
item.UserID = milerUserID
logTime, err := time.Parse("2006-01-02 15:04:05", item.LogDate)
if err != nil {
logTime = time.Now()