From 42f2a41ff6a233e6db2652d2038acfd1c4cd7acd Mon Sep 17 00:00:00 2001 From: Suriya Date: Thu, 6 Aug 2026 12:01:13 +0530 Subject: [PATCH] feat: filter console reads by tenant, for clients and for Doormile staff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the same thing. A client login was already pinned to its own tenant on most reads, but the roster, the B2C customer list and the dashboard counters were not — a DailyGrubs login listed the whole network's riders. The other half was missing entirely: Doormile's own staff had no way to look at one client's slice. Reports accepted ?tenantid= but applied it only to the consignment count, and bookings accepted it while milers, customers, consignments and the dashboard ignored it. effectiveTenantID(c) now resolves both cases in one place — the caller's own tenant for a client login, the requested one for Doormile staff, 0 for the whole network. A client asking for someone else's tenantid is refused rather than silently handed their own data back under the wrong label. Applied to: milers, customers, bookings, consignments, dashboard, reports and the rider summary. Co-Authored-By: Claude Opus 5 --- controllers/adminController.go | 161 +++++++++++++++++++------ controllers/adminMilerOpsController.go | 26 ++-- 2 files changed, 137 insertions(+), 50 deletions(-) diff --git a/controllers/adminController.go b/controllers/adminController.go index 0e22920..c39c183 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -41,6 +41,45 @@ func isDoormileConsoleStaff(c *fiber.Ctx) bool { return consoleTenantID(c) == 0 } +// effectiveTenantID is the tenant a request should be read as: the caller's own +// for a client login, or whatever ?tenantid= asks for when Doormile staff want +// one client's slice of the network. Returns 0 for "no restriction", which only +// Doormile staff can reach. +// +// A client passing ?tenantid= for someone else is refused rather than silently +// ignored — a filter that quietly returns the wrong tenant's data is worse than +// an error either way. +func effectiveTenantID(c *fiber.Ctx) (int, error) { + own := consoleTenantID(c) + requested := c.QueryInt("tenantid", 0) + + if own != 0 { + if requested != 0 && requested != own { + return 0, utils.Forbidden(c, "you can only view your own tenant") + } + return own, nil + } + return requested, nil +} + +// scopeToTenant restricts a query to one tenant by its own tenant column, +// no-oping when tenantID is 0. +func scopeToTenant(query *gorm.DB, column string, tenantID int) *gorm.DB { + if tenantID == 0 { + return query + } + return query.Where(column+" = ?", tenantID) +} + +// milerUserIDsForTenant lists the appusers.userid of a tenant's riders. Riders +// belong to a client through their appusers row, not the miler profile. +func milerUserIDsForTenant(tenantID int) []int { + var ids []int + db.DB.Model(&models.AppUser{}). + Where("tenantid = ? AND roleid = ?", tenantID, 5).Pluck("userid", &ids) + return ids +} + // 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 — @@ -188,6 +227,13 @@ func LoginAdmin(cfg *config.Config) fiber.Handler { } func GetAdminDashboard(c *fiber.Ctx) error { + // Pinned to the caller's own tenant for a client login; Doormile staff pass + // ?tenantid= to see one client's numbers, or omit it for the whole network. + tenantID, terr := effectiveTenantID(c) + if terr != nil { + return terr + } + var totalTenants int64 var totalCustomers int64 var totalMilers int64 @@ -195,27 +241,34 @@ func GetAdminDashboard(c *fiber.Ctx) error { var totalConsignments int64 var openExceptions int64 - scopeToOwnTenant(c, db.DB.Model(&models.Tenant{}), "tenantid").Count(&totalTenants) - scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid").Count(&totalBookings) - scopeToOwnTenant(c, db.DB.Model(&models.Consignment{}), "tenantid").Count(&totalConsignments) + scopeToTenant(db.DB.Model(&models.Tenant{}), "tenantid", tenantID).Count(&totalTenants) + scopeToTenant(db.DB.Model(&models.PickupBooking{}), "tenantid", tenantID).Count(&totalBookings) + scopeToTenant(db.DB.Model(&models.Consignment{}), "tenantid", tenantID).Count(&totalConsignments) // Customers and exceptions carry no tenant column of their own, so they are // counted through the bookings and consignments that do. Riders link to a - // client through appusers.tenantid. Reporting these as zero (which this did) - // left a client's dashboard looking like an empty account. - if isDoormileConsoleStaff(c) { + // client through appusers.tenantid. Reporting these as zero (which this did + // for every client login) left a client's dashboard looking like an empty + // account on the day they first signed in. + if tenantID == 0 { 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) } else { - own := consoleTenantID(c) - scopeViaBookings(c, db.DB.Model(&models.AppCustomer{}), "appcustomerid").Count(&totalCustomers) - db.DB.Model(&models.AppUser{}).Where("roleid = 5 AND tenantid = ?", own).Count(&totalMilers) - scopeViaConsignments(c, db.DB.Model(&models.ConsignmentException{}), "consignmentid"). - Where("status = ?", "Open").Count(&openExceptions) + db.DB.Model(&models.AppCustomer{}). + Where("appcustomerid IN (?)", db.DB.Model(&models.PickupBooking{}). + Select("appcustomerid").Where("tenantid = ?", tenantID)). + Count(&totalCustomers) + db.DB.Model(&models.AppUser{}). + Where("roleid = 5 AND tenantid = ?", tenantID).Count(&totalMilers) + db.DB.Model(&models.ConsignmentException{}). + Where("status = ? AND consignmentid IN (?)", "Open", db.DB.Model(&models.Consignment{}). + Select("consignmentid").Where("tenantid = ?", tenantID)). + Count(&openExceptions) } return utils.OK(c, fiber.Map{ + "tenantid": tenantID, "tenants": totalTenants, "customers": totalCustomers, "milers": totalMilers, @@ -241,32 +294,32 @@ func GetAdminReports(c *fiber.Ctx) error { return utils.BadRequest(c, err.Error()) } - tenantID := c.Query("tenantid") hubID := c.Query("hubid") - // ownTenant is 0 for Doormile staff (whole-network view) and the client's - // tenant for a client login, which every figure below is restricted to. - ownTenant := consoleTenantID(c) + // ownTenant is 0 for a Doormile-staff whole-network view, the client's own + // tenant for a client login, or the tenant Doormile staff asked for with + // ?tenantid=. Every figure below is restricted to it. + ownTenant, terr := effectiveTenantID(c) + if terr != nil { + return terr + } var totalBookings int64 - scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid"). + scopeToTenant(db.DB.Model(&models.PickupBooking{}), "tenantid", ownTenant). Where("createdat BETWEEN ? AND ?", from, to).Count(&totalBookings) var delivered int64 - scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid"). + scopeToTenant(db.DB.Model(&models.PickupBooking{}), "tenantid", ownTenant). Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingConvertedConsignment, from, to). Count(&delivered) var cancelled int64 - scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid"). + scopeToTenant(db.DB.Model(&models.PickupBooking{}), "tenantid", ownTenant). Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingCancelled, from, to). Count(&cancelled) - consignmentQuery := scopeToOwnTenant(c, db.DB.Model(&models.Consignment{}), "tenantid"). + consignmentQuery := scopeToTenant(db.DB.Model(&models.Consignment{}), "tenantid", ownTenant). Where("createdat BETWEEN ? AND ?", from, to) - if tenantID != "" { - consignmentQuery = consignmentQuery.Where("tenantid = ?", tenantID) - } if hubID != "" { consignmentQuery = consignmentQuery.Where("currenthubid = ?", hubID) } @@ -807,8 +860,17 @@ func GetAdminCustomers(c *fiber.Ctx) error { 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") + // Doormile's whole B2C address book. Doormile staff can ask for one + // client's customers with ?tenantid=. + tenantID, terr := effectiveTenantID(c) + if terr != nil { + return terr + } + if tenantID != 0 { + query = query.Where("appcustomerid IN (?)", + db.DB.Model(&models.PickupBooking{}).Select("appcustomerid"). + Where("tenantid = ?", tenantID)) + } var total int64 if err := query.Count(&total).Error; err != nil { @@ -1344,19 +1406,31 @@ func DeleteVehicle(c *fiber.Ctx) error { // -------------------- func GetMilers(c *fiber.Ctx) error { + tenantID, terr := effectiveTenantID(c) + if terr != nil { + return terr + } + var profiles []models.MilerProfile - query := db.DB + query := db.DB.Model(&models.MilerProfile{}) 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 hubID := c.Query("hubid"); hubID != "" { + query = query.Where("hubid = ?", hubID) } - if err := query.Find(&profiles).Error; err != nil { + // Riders belong to a client through their appusers row, so the fleet filter + // is a subquery on that rather than a column here. A client login is pinned + // to its own tenant; Doormile staff pass ?tenantid= to see one client's + // fleet, or omit it for the whole roster. + if tenantID != 0 { + ids := milerUserIDsForTenant(tenantID) + if len(ids) == 0 { + return utils.List(c, []models.MilerProfile{}, 0) + } + query = query.Where("userid IN ?", ids) + } + if err := query.Order("displayname").Find(&profiles).Error; err != nil { return utils.Internal(c, "failed to fetch milers") } return utils.List(c, profiles, int64(len(profiles))) @@ -1587,13 +1661,14 @@ func GetAdminBookings(c *fiber.Ctx) error { pagesize := min(100, max(1, c.QueryInt("pagesize", 20))) offset := (pageno - 1) * pagesize - query := db.DB.Model(&models.PickupBooking{}) - if tenantID := c.Query("tenantid"); tenantID != "" { - query = query.Where("tenantid = ?", tenantID) + tenantID, terr := effectiveTenantID(c) + if terr != nil { + return terr + } + query := scopeToTenant(db.DB.Model(&models.PickupBooking{}), "tenantid", tenantID) + if status := c.Query("status"); status != "" { + query = query.Where("status = ?", status) } - // 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 { @@ -2206,14 +2281,20 @@ func AdminBulkCancelBookings(c *fiber.Ctx) error { func GetAdminConsignments(c *fiber.Ctx) error { page := utils.ParsePage(c) + tenantID, terr := effectiveTenantID(c) + if terr != nil { + return terr + } + var total int64 - if err := scopeToOwnTenant(c, db.DB.Model(&models.Consignment{}), "tenantid"). + if err := scopeToTenant(db.DB.Model(&models.Consignment{}), "tenantid", tenantID). Count(&total).Error; err != nil { return utils.Internal(c, "failed to count consignments") } var list []models.Consignment - if err := page.Apply(scopeToOwnTenant(c, db.DB, "tenantid")).Find(&list).Error; err != nil { + if err := page.Apply(scopeToTenant(db.DB.Model(&models.Consignment{}), "tenantid", tenantID)). + Find(&list).Error; err != nil { return utils.Internal(c, "failed to fetch consignments") } return utils.Paginated(c, list, total, page) diff --git a/controllers/adminMilerOpsController.go b/controllers/adminMilerOpsController.go index 95f954b..dcc8d66 100644 --- a/controllers/adminMilerOpsController.go +++ b/controllers/adminMilerOpsController.go @@ -53,16 +53,18 @@ func assertMilerAccess(c *fiber.Ctx, milerProfileID int) (*models.MilerProfile, return &profile, nil } -// visibleMilerUserIDs lists the appusers.userid of every rider the caller may -// see. Returns nil for Doormile staff, meaning "no restriction". -func visibleMilerUserIDs(c *fiber.Ctx) []int { - own := consoleTenantID(c) - if own == 0 { - return nil +// visibleMilerUserIDs lists the appusers.userid of every rider the request +// should cover — the caller's own fleet for a client login, or the tenant +// Doormile staff asked for with ?tenantid=. Returns nil for "no restriction". +func visibleMilerUserIDs(c *fiber.Ctx) ([]int, error) { + tenantID, err := effectiveTenantID(c) + if err != nil { + return nil, err } - var ids []int - db.DB.Model(&models.AppUser{}).Where("tenantid = ? AND roleid = ?", own, 5).Pluck("userid", &ids) - return ids + if tenantID == 0 { + return nil, nil + } + return milerUserIDsForTenant(tenantID), nil } // milerSummaryRow is one line of the roster table — the shape the console's @@ -113,7 +115,11 @@ func GetMilerSummary(c *fiber.Ctx) error { if hubID := c.Query("hubid"); hubID != "" { query = query.Where("hubid = ?", hubID) } - if visible := visibleMilerUserIDs(c); visible != nil { + visible, verr := visibleMilerUserIDs(c) + if verr != nil { + return verr + } + if visible != nil { if len(visible) == 0 { return utils.List(c, []milerSummaryRow{}, 0) }