Compare commits

...

6 Commits

Author SHA1 Message Date
Suriya
6d9232f3b7 fix: close the remaining express-console tenant leaks
The first scoping pass covered tables carrying a tenantid column. These five
have no such column and were still returning every client's data to a client
login, which is what made creating info@dailygrubs.com unsafe:

- GET /admin/customers — scoped through the bookings placed for them, since a
  customer carries no tenant of their own (the same person can order from two
  clients).
- GET /admin/exceptions — scoped through the consignment the exception was
  raised against.
- GET /admin/tenantcustomers — the legacy customers table predates tenant
  attribution entirely, so no row can be proven to belong to a client. Returns
  empty for client logins rather than handing over the whole list.
- GET /admin/tripsheets — a vehicle run routinely carries several clients'
  parcels on one manifest, so there is no honest per-client view. Doormile
  staff only.
- GET /admin/milers — scoped through appusers.tenantid, so a client sees their
  own riders rather than the whole roster.

Also guards PUT /admin/consignments/:id/status, which took the id straight from
the path and would have let a client move another client's parcel through the
network.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:39:34 +05:30
Suriya
9b3ec886ce fix: populate riderkms and ridercharges on delivery completion
riderkms, ridercharges and bonuspoints appeared in exactly three places in the
codebase — all three GET /miler/earnings reading them. Nothing ever wrote them,
so every completed job reported zero distance and zero value and the earnings
screen was permanently empty.

On delivery completion:
- riderkms is the distance ridden for the booking, pickup point to where the
  rider confirmed delivery, falling back to the booking's delivery coordinates
  when the app sends no position.
- ridercharges is the order amount the tenant is billed, supplied at creation
  as finalprice and already stored on the booking service option. Doormile does
  not compute it; this is pass-through.
- bonuspoints deliberately left at zero pending a decision on what earns them.

Also moves the monthly earnings window onto utils.DBNow, so it doesn't put a
rider in the wrong month for 5h30m either side of a month boundary.

Note historical rows keep whatever completedat they were written with before
the image gained TZ=Asia/Kolkata, so figures spanning today are mixed; not
backfilled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:33:01 +05:30
Suriya
39dcba3d80 fix: put the Redis user-cache utilities behind console auth
/api/v1/utils/users/redis exposed open CRUD — create, read, update and delete
on the cached-user store — to anonymous callers. The store is currently empty
and nothing in the console or apps calls it, so closing it breaks no client.

Third of the three route groups flagged in the 2026-07-27 audit. /crm/* stays
open by explicit decision: the field-sales Flutter app sends no credentials.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:23:59 +05:30
Suriya
4d03676e60 fix: put the booking cache endpoints behind console auth
/api/v1/bookings/cache/* was open "for testing" but is live in production:
listing it returns real bookings including customer delivery addresses, and
/customer/:customer_id takes the customer straight from the URL, so anyone
could enumerate a given customer's bookings without credentials.

Now requires a console token (roles 1/3/4), matching the rest of the admin
surface. The /crm/* group stays open by deliberate decision — the field-sales
Flutter app authenticates with nothing and would break; revisit when that app
can send a key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:22:42 +05:30
Suriya
c8a9b5d797 fix: customer PIN-reset takeover, booking-quote and consignment-log IDORs
- POST /customer/reset-pin was unauthenticated and overwrote a customer's PIN
  given only their phone number — which is the login identifier, not a secret —
  so reset-pin followed by verify-pin took over any customer account. Exactly
  the miler flaw fixed in fd7cf3e, on the B2C side. It now requires the account's
  registered email to have been verified through the existing
  send-email-otp/verify-email-otp flow; the verification is recorded in Redis
  for 10 minutes and consumed on use, so one verification authorises one reset.
  Accounts with no email on file are directed to support rather than left open.

- GET /customer/bookings/:id/price had no ownership check, unlike every other
  customer booking route, so any signed-in customer could read the price quoted
  on anyone else's booking by walking the id.

- GET /miler/consignments/userlogs/:userid took the rider from the URL and never
  compared it to the caller, letting any miler read another miler's movement
  history.

Verified as already correct while sweeping: miler assignment and booking-flow
handlers all scope by mileruserid/assignedmileruserid, customer booking detail
and cancel scope by appcustomerid, /internal sits behind InternalKeyAuth, and
CreateHubStaffAccount already refuses non-Doormile staff.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:16:56 +05:30
16 changed files with 770 additions and 191 deletions

View File

@@ -9,8 +9,14 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o server .
# Second Stage: Run the compiled binary inside alpine
FROM alpine
# Fix: Alpine needs ca-certificates to verify SSL certificates
RUN apk add --no-cache ca-certificates
# Fix: Alpine needs ca-certificates to verify SSL certificates.
# tzdata + TZ: the database connection sets TimeZone=Asia/Kolkata, so
# CURRENT_TIMESTAMP defaults write IST wall-clock into the timestamp columns.
# With the container defaulting to UTC, every time.Now() the app wrote was 5h30m
# behind those defaults, and "today so far" date ranges ended 5h30m in the past —
# which silently dropped anything created after noon IST from every report.
RUN apk add --no-cache ca-certificates tzdata
ENV TZ=Asia/Kolkata
# Copy from first stage
COPY --from=0 /app/server /app/server

View File

@@ -21,8 +21,97 @@ 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)
}
// 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)
@@ -68,8 +157,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")
}
@@ -82,6 +181,7 @@ func LoginAdmin(cfg *config.Config) fiber.Handler {
"name": userName,
"email": auth.Email,
"role": auth.Role,
"tenantid": auth.Tenantid,
},
})
}
@@ -95,12 +195,19 @@ func GetAdminDashboard(c *fiber.Ctx) error {
var totalConsignments int64
var openExceptions int64
db.DB.Model(&models.Tenant{}).Count(&totalTenants)
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.PickupBooking{}).Count(&totalBookings)
db.DB.Model(&models.Consignment{}).Count(&totalConsignments)
db.DB.Model(&models.ConsignmentException{}).Where("status = ?", "Open").Count(&openExceptions)
}
return utils.OK(c, fiber.Map{
"tenants": totalTenants,
@@ -131,20 +238,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 +267,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 +300,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 +330,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 +358,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 +377,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"
if isDoormileConsoleStaff(c) {
db.DB.Raw(riderQuery, args...).Scan(&riderRows)
}
byRider := make([]fiber.Map, 0, len(riderRows))
for _, r := range riderRows {
@@ -271,13 +415,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 +567,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 +590,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 +603,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 +611,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 +636,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 +649,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 +667,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,6 +679,9 @@ 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")
@@ -520,6 +689,7 @@ func CreateTenantLocation(c *fiber.Ctx) error {
location := models.TenantLocation{
Tenantid: tenantID,
Locationname: req.Locationname,
Address: req.Address,
City: req.City,
State: req.State,
@@ -549,12 +719,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
}
@@ -594,6 +772,12 @@ func UpdateTenantLocation(c *fiber.Ctx) error {
// --------------------
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")
@@ -616,6 +800,9 @@ func GetAdminCustomers(c *fiber.Ctx) error {
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 {
@@ -1156,6 +1343,13 @@ func GetMilers(c *fiber.Ctx) error {
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")
}
@@ -1177,6 +1371,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 +1394,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 +1412,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 +1573,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 {
@@ -1392,6 +1608,11 @@ type AdminBookingRequest struct {
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 +1647,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 +1658,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 +1719,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 +1889,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 +1934,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 +1956,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 +1965,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 +1989,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 +2018,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 +2055,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 +2125,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 +2134,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 +2189,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 +2204,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 +2213,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
@@ -1939,6 +2225,18 @@ func GetAdminConsignmentTracking(c *fiber.Ctx) error {
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"`
@@ -1991,6 +2289,13 @@ func AdminUpdateConsignmentStatus(c *fiber.Ctx) error {
// --------------------
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
@@ -2218,7 +2523,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)))
@@ -2366,13 +2674,16 @@ func GetPricingQuoteSimulate(c *fiber.Ctx) error {
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 := db.DB.Model(&models.ConsignmentException{}).Where("deletedat IS NULL").Count(&total).Error; err != nil {
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(db.DB.Where("deletedat IS NULL")).Find(&list).Error; err != nil {
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)

View File

@@ -189,6 +189,19 @@ func ResetCustomerPin(c *fiber.Ctx) error {
return utils.NotFound(c, "customer not found")
}
// Proof of identity is required before overwriting a login credential.
// Without it this endpoint reset any customer's PIN from their phone number
// alone — and phone numbers are the login identifier, not a secret — so
// reset-pin followed by verify-pin was a complete account takeover.
// The caller must first pass /customer/send-email-otp and
// /customer/verify-email-otp for this account's registered address.
if customer.Email == "" {
return utils.Forbidden(c, "this account has no registered email to verify against — contact support to reset the PIN")
}
if !ConsumeEmailVerification(customer.Email) {
return utils.Forbidden(c, "verify your registered email first via /customer/send-email-otp and /customer/verify-email-otp")
}
pinHash, err := utils.HashPassword(req.NewPin)
if err != nil {
return utils.Internal(c, "failed to process PIN reset")
@@ -620,11 +633,22 @@ func CancelCustomerBooking(c *fiber.Ctx) error {
}
func GetCustomerBookingQuote(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
bookingID, err := strconv.Atoi(c.Params("bookingid"))
if err != nil {
return utils.BadRequest(c, "invalid booking ID")
}
// Ownership is checked here as it is on the other booking routes — without
// it any signed-in customer could read the price quoted on anyone else's
// booking just by walking the id.
var booking models.PickupBooking
if err := db.DB.Select("bookingid").
Where("bookingid = ? AND appcustomerid = ?", bookingID, customerID).
First(&booking).Error; err != nil {
return utils.NotFound(c, "booking not found")
}
var serviceOpt models.BookingServiceOption
if err := db.DB.Where("bookingid = ?", bookingID).Order("createdat DESC").First(&serviceOpt).Error; err != nil {
return utils.NotFound(c, "price quote not found for this booking")

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,17 +325,57 @@ 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")
}
// riderkms is the distance actually ridden for this booking, measured from
// the pickup point to where the rider stood when they confirmed delivery
// (falling back to the booking's delivery coordinates if the app sent none).
// ridercharges is the order amount the tenant is billed, passed in at
// creation as finalprice and stored on the service option — Doormile does
// not compute it. Both were only ever read by GET /miler/earnings and never
// written, so every completed job reported zero distance and zero value.
dropLat, dropLon := req.Lat, req.Lon
if dropLat == 0 && dropLon == 0 {
dropLat, dropLon = consignment.Deliverylatitude, consignment.Deliverylongitude
}
riderKms := haversineKM(consignment.Pickuplatitude, consignment.Pickuplongitude, dropLat, dropLon)
var serviceOpt models.BookingServiceOption
orderAmount := 0.0
if tx.Where("bookingid = ?", booking.Bookingid).
Order("createdat DESC").First(&serviceOpt).Error == nil {
orderAmount = serviceOpt.Estimatedprice
}
if err := tx.Model(&models.BookingAssignment{}).
Where("bookingid = ? AND mileruserid = ?", booking.Bookingid, milerUserID).
Updates(map[string]interface{}{
"assignmentstatus": constants.AssignmentCompleted,
"completedat": time.Now(),
"riderkms": riderKms,
"ridercharges": orderAmount,
}).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to close assignment")
@@ -477,7 +525,10 @@ func MilerGetEarnings(c *fiber.Ctx) error {
period := c.Query("period", "daily")
var start, end time.Time
now := time.Now()
// Database-local, not container-local — see utils.DBNow. The rest of the
// range logic already moved off the container clock; this branch would
// otherwise put a rider in the wrong month for 5h30m either side of it.
now := utils.DBNow()
switch period {
case "weekly":

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{
body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo)
payload := map[string]string{
"booking_id": strconv.Itoa(bookingID),
"tracking_no": trackingNo,
},
); notifyErr != nil {
}
// 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()
@@ -1170,6 +1204,13 @@ func GetUserConsignmentLogs(c *fiber.Ctx) error {
return utils.BadRequest(c, "invalid user ID")
}
// The path names a rider, so it has to be checked against the caller —
// otherwise any miler could read another miler's movement history simply by
// changing the number in the URL.
if userID != c.Locals("userid").(int) {
return utils.Forbidden(c, "you can only read your own consignment logs")
}
if db.Rdb == nil {
return utils.Internal(c, "cache service unavailable")
}

View File

@@ -20,6 +20,11 @@ import (
const (
otpTTL = 5 * time.Minute
otpMaxAttempts = 5
// otpVerifiedTTL is how long a successful email verification stays usable as
// proof of identity for a follow-up action such as a PIN reset. Long enough
// to type a new PIN, short enough that a stale verification can't be
// redeemed later.
otpVerifiedTTL = 10 * time.Minute
)
func generateOtpCode() string {
@@ -33,6 +38,22 @@ func generateOtpCode() string {
func otpKey(email string) string { return fmt.Sprintf("otp:email:%s", email) }
func otpAttemptsKey(email string) string { return fmt.Sprintf("otp:email:%s:attempts", email) }
// otpVerifiedKey marks an email as recently proven. Verification previously
// left no trace at all, so nothing downstream could require it — which is why
// ResetCustomerPin was able to overwrite a PIN on nothing but a phone number.
func otpVerifiedKey(email string) string { return fmt.Sprintf("otp:email:%s:verified", email) }
// ConsumeEmailVerification reports whether the email was verified recently, and
// clears the marker so a single verification can authorise exactly one action.
func ConsumeEmailVerification(email string) bool {
if db.Rdb == nil || email == "" {
return false
}
ctx := context.Background()
n, err := db.Rdb.Del(ctx, otpVerifiedKey(email)).Result()
return err == nil && n > 0
}
func SendCustomerEmailOtp(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
req := new(dto.SendEmailOtpRequest)
@@ -100,6 +121,8 @@ func VerifyCustomerEmailOtp() fiber.Handler {
}
db.Rdb.Del(ctx, key, otpAttemptsKey(req.Email))
// Recorded so a follow-up PIN reset can prove this email was verified.
db.Rdb.Set(ctx, otpVerifiedKey(req.Email), "1", otpVerifiedTTL)
return utils.Message(c, "email verified successfully")
}
}

View File

@@ -7,9 +7,15 @@ type TenantCreateRequest struct {
Primaryemail string `json:"primaryemail"`
Primarycontact string `json:"primarycontact"`
Status string `json:"status"`
// Requiredeliveryotp is a pointer so an update that omits it leaves the
// existing setting alone rather than silently switching OTPs off.
Requiredeliveryotp *bool `json:"requiredeliveryotp"`
}
type TenantLocationCreateRequest struct {
// Locationname is the client's own label for the site — "DailyGrubs
// Peelamedu Kitchen" — since an address alone doesn't identify a branch.
Locationname string `json:"locationname"`
Address string `json:"address"`
City string `json:"city"`
State string `json:"state"`
@@ -62,8 +68,18 @@ type MilerCreateRequest struct {
Contactno string `json:"contactno"`
Password string `json:"password"`
Displayname string `json:"displayname"`
// Tenantid attaches a rider to the client they deliver for — riders migrated
// from jupiter belong to a specific client (DailyGrubs, Bawa Medicals)
// rather than to Doormile's general pool. Zero leaves them unattached.
Tenantid int `json:"tenantid"`
Defaultvehicletype string `json:"defaultvehicletype"`
Applocationid int `json:"applocationid"`
// Configid partitions logins; defaults to 1001, which is what the miler app
// authenticates against. Only set this if you know why you're changing it.
Configid int `json:"configid"`
// Hubid is optional: a rider with no hub is still assignable from the
// express console, but is invisible to the hub console's miler list.
Hubid *int `json:"hubid"`
}
type PricingCreateRequest struct {

View File

@@ -55,10 +55,28 @@ type MilerLocationUpdateRequest struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Pincode string `json:"pincode"`
// Speed and Heading are sent by the rider app and were previously dropped on
// the floor, since unknown JSON fields parse silently. Accepted here so the
// values at least reach the periodic-log telemetry rather than vanishing.
Speed float64 `json:"speed"`
Heading float64 `json:"heading"`
}
type MilerAvailabilityRequest struct {
Status string `json:"status"` // Offline, Available, Break, etc.
// Availabilitystatus is the field name the published Miler App API Contract
// v1.0 told the Flutter dev to send, while the code only ever read "status"
// — so the documented request 400s. Both are accepted rather than picking a
// winner, because either side may already be built against either name.
Availabilitystatus string `json:"availabilitystatus"`
}
// ResolvedStatus returns whichever of the two accepted field names was sent.
func (r MilerAvailabilityRequest) ResolvedStatus() string {
if r.Status != "" {
return r.Status
}
return r.Availabilitystatus
}
type PricingQuoteRequest struct {

View File

@@ -13,6 +13,7 @@ var operatingCityPrefixes = map[string]string{
"600": "Chennai",
"560": "Bengaluru",
"500": "Hyderabad",
"629": "Nagercoil",
}
// CityGateMiddleware rejects bookings from pincodes outside Doormile's operating cities.

View File

@@ -69,6 +69,11 @@ type Consignment struct {
Parentconsignmentid *int `json:"parentconsignmentid" gorm:"column:parentconsignmentid"`
Condition string `json:"condition" gorm:"column:condition;size:50"` // recorded at hub inbound scan: Good, Damaged, etc.
Shelf string `json:"shelf" gorm:"column:shelf;size:50"` // hub storage location assigned at inbound scan
// Deliveryotp is issued when the consignment goes out for delivery and is
// given to the receiver, not the rider — it is the only proof the parcel
// reached the right person. Never serialised outward: returning it in an API
// response would hand the rider the code they are supposed to be told.
Deliveryotp string `json:"-" gorm:"column:deliveryotp;size:6"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
Createdby int `json:"createdby" gorm:"column:createdby"`

View File

@@ -63,6 +63,11 @@ type DoormileAuth struct {
Email string `gorm:"uniqueIndex;size:255;not null" json:"email"`
PasswordHash string `gorm:"not null" json:"-"`
Role string `gorm:"default:'user'" json:"role"`
// Tenantid scopes an express-console login to one client, using the same
// convention as HubStaffAccount.Tenantid: null = Doormile's own staff, who
// see every tenant's data; set = a client's own login, restricted to their
// tenant. Without this every console login sees all tenants.
Tenantid *int `gorm:"column:tenantid;index" json:"tenantid"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -9,6 +9,12 @@ type Tenant struct {
Primaryemail string `json:"primaryemail" gorm:"column:primaryemail"`
Primarycontact string `json:"primarycontact" gorm:"column:primarycontact"`
Status string `json:"status" gorm:"column:status;default:Active"`
// Requiredeliveryotp decides whether the receiver must read a code back to
// the rider. Worth the friction for a courier handing over a valuable
// parcel; not for a food order, where it just slows every drop down.
// Defaults off: turning it on platform-wide would block deliveries for
// clients whose customer app has no way to show the code yet.
Requiredeliveryotp bool `json:"requiredeliveryotp" gorm:"column:requiredeliveryotp;default:false"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}

View File

@@ -158,6 +158,7 @@ func (AppCustomerLocation) TableName() string {
type TenantLocation struct {
Tenantlocationid int `json:"tenantlocationid" gorm:"primaryKey;column:tenantlocationid"`
Tenantid int `json:"tenantid" gorm:"column:tenantid;not null"`
Locationname string `json:"locationname" gorm:"column:locationname"`
Address string `json:"address" gorm:"column:address;not null"`
City string `json:"city" gorm:"column:city;not null"`
State string `json:"state" gorm:"column:state;not null"`

View File

@@ -113,7 +113,15 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
miler := api.Group("/miler")
miler.Post("/login", authThrottle, controllers.LoginMiler(cfg))
miler.Post("/verify-pin", authThrottle, controllers.VerifyMilerPin(cfg))
miler.Post("/reset-pin", authThrottle, controllers.ResetMilerPin)
// PIN reset is console-operated, NOT self-service: ResetMilerPin overwrites
// the PIN given only a phone number, and phone numbers are the miler login
// identifier rather than a secret. Left unauthenticated, two calls
// (reset-pin then verify-pin) take over any miler account. Ops resets a
// rider's PIN on request instead, so this carries admin/manager/executive
// auth even though it sits under the /miler prefix.
miler.Post("/reset-pin", authThrottle,
middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(1, 3, 4),
controllers.ResetMilerPin)
// Authenticated Miler App routes
milerAuth := miler.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(5))
@@ -351,8 +359,11 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
hubAuth.Get("/report", controllers.GetHubReport)
// Redis active user caching utilities
redisUsers := api.Group("/utils/users/redis")
// Redis active user caching utilities — console/ops only. Open CRUD on a
// user cache with no authentication has no legitimate anonymous caller;
// the store is currently empty, so closing it breaks nothing.
redisUsers := api.Group("/utils/users/redis",
middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(1, 3, 4))
redisUsers.Post("/", controllers.CreateUserRedis)
redisUsers.Get("/", controllers.GetUserRedis)
redisUsers.Put("/:userid", controllers.UpdateUserRedis)
@@ -365,9 +376,13 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
api.Post("/pricing/check", controllers.CheckPrice)
// --------------------
// BOOKING CACHE APIS — no auth required (testing)
// BOOKING CACHE APIS — console/ops only
// --------------------
bookingCache := api.Group("/bookings/cache")
// Previously open "for testing", but live: listing the cache returns real
// bookings including customer delivery addresses, and the per-customer route
// takes a customer id straight from the URL. Behind console auth now.
bookingCache := api.Group("/bookings/cache",
middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(1, 3, 4))
bookingCache.Get("/", controllers.ListAllBookingsFromCache)
bookingCache.Get("/customer/:customer_id", controllers.GetCustomerBookingsFromCache)
bookingCache.Get("/:booking_id", controllers.GetBookingFromCache)

View File

@@ -1,7 +1,9 @@
package utils
import (
"crypto/rand"
"errors"
"math/big"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -13,6 +15,55 @@ func HashPassword(password string) (string, error) {
return string(bytes), err
}
// dbLocation is the timezone the database records wall-clock timestamps in: the
// connection DSN sets TimeZone=Asia/Kolkata, so CURRENT_TIMESTAMP column
// defaults write IST wall-clock into timestamp-without-timezone columns.
var dbLocation = func() *time.Location {
if loc, err := time.LoadLocation("Asia/Kolkata"); err == nil {
return loc
}
// A container without tzdata can't load the database; IST observes no DST,
// so a fixed +05:30 offset is exact rather than an approximation.
return time.FixedZone("IST", 5*3600+30*60)
}()
// DBNow returns the current moment expressed as the wall-clock the database
// stores, tagged UTC so the driver sends exactly those digits. Use it for any
// comparison against a stored timestamp: comparing the container's UTC clock
// against IST-stamped rows is what made date-range reports undercount.
//
// Deliberately independent of the container's own TZ, so it stays correct
// whether or not TZ=Asia/Kolkata is set.
func DBNow() time.Time {
n := time.Now().In(dbLocation)
return time.Date(n.Year(), n.Month(), n.Day(), n.Hour(), n.Minute(), n.Second(), n.Nanosecond(), time.UTC)
}
// DBToday returns midnight at the start of the current database-local day.
func DBToday() time.Time {
n := DBNow()
return time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, time.UTC)
}
// GenerateNumericOTP returns a random n-digit code, leading zeros preserved.
// crypto/rand rather than math/rand: this is the only thing standing between a
// parcel and someone claiming it was delivered, so a predictable sequence would
// defeat the point.
func GenerateNumericOTP(n int) string {
const digits = "0123456789"
out := make([]byte, n)
for i := range out {
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(digits))))
if err != nil {
// A failing system RNG must not silently downgrade to a guessable
// code; the caller treats an empty OTP as "not issued".
return ""
}
out[i] = digits[idx.Int64()]
}
return string(out)
}
func CheckPasswordHash(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil