feat: filter console reads by tenant, for clients and for Doormile staff
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user