feat: 14 new endpoints closing jupiter->Doormile API gaps, plus two tenant-scoping bug fixes
New endpoints: - Admin: partner CRUD (GET/POST /admin/partners, GET/PUT/DELETE /admin/partners/:id), bulk express booking create (POST /admin/expressbooking/bulk), bulk cancel (POST /admin/bookings/bulk-cancel), reports (GET /admin/reports), password change (PUT /admin/profile/password), miler notify (POST /admin/milers/:id/notify) - Miler: PIN reset (POST /miler/reset-pin), cancel assignment (POST /miler/bookings/:bookingid/cancel), skip delivery (POST /miler/consignments/:id/skip) - Hub: batch assign (POST /hub/bookings/batch-assign) - greedy nearest-rider queue clearing, capped per rider Bug fixes: - BookingPickupComplete now sets Consignment.Tenantid from the booking's tenant instead of the completing miler's own tenant (fixes cross-tenant shipment mis-attribution) - GetHubUnassignedBookings/GetHubBookingsRange now scoped via scopeBookingsToOwnTenant (fixes partner hub staff seeing other tenants' bookings) Also: CRM booking routes renamed to expressbooking to end the naming collision with the separate CRM clients feature; PickupBooking gains nullable Tenantid; adds CLAUDE.md project memory. Verified: go build ./... and go vet ./... both clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// hubAutoAssignTimeout bounds how long HubAutoAssign waits synchronously for
|
||||
@@ -211,6 +212,23 @@ func isDoormileStaff(staff *models.HubStaffAccount) bool {
|
||||
return staff.Tenantid == nil
|
||||
}
|
||||
|
||||
// scopeBookingsToOwnTenant restricts a bookings query to the requesting hub
|
||||
// staff's own tenant when they're partner staff (HubStaffAccount.Tenantid set)
|
||||
// — without this, a partner tenant's own hub staff could see every other
|
||||
// tenant's bookings passing through the same hub, which is exactly the kind
|
||||
// of cross-client leak a multi-tenant flow can't have. Doormile staff
|
||||
// (Tenantid nil) are unrestricted, same as everywhere else in this file.
|
||||
// Bookings created before Tenantid existed (nil) are only visible to
|
||||
// Doormile staff, never to partner staff, since they can't be proven to
|
||||
// belong to that partner.
|
||||
func scopeBookingsToOwnTenant(c *fiber.Ctx, query *gorm.DB) *gorm.DB {
|
||||
staff, err := getCurrentHubStaff(c)
|
||||
if err != nil || isDoormileStaff(staff) {
|
||||
return query
|
||||
}
|
||||
return query.Where("tenantid = ?", *staff.Tenantid)
|
||||
}
|
||||
|
||||
func GetHubDashboardStats(c *fiber.Ctx) error {
|
||||
hubID := c.Locals("hubid").(int)
|
||||
prefix := hubPincodePrefix(hubID)
|
||||
@@ -279,6 +297,7 @@ func GetHubUnassignedBookings(c *fiber.Ctx) error {
|
||||
if prefix != "" {
|
||||
query = query.Where("pickuppincode LIKE ?", prefix+"%")
|
||||
}
|
||||
query = scopeBookingsToOwnTenant(c, query)
|
||||
|
||||
var bookings []models.PickupBooking
|
||||
if err := query.Order("createdat DESC").Find(&bookings).Error; err != nil {
|
||||
@@ -343,6 +362,7 @@ func GetHubBookingsRange(c *fiber.Ctx) error {
|
||||
if prefix != "" {
|
||||
query = query.Where("pickuppincode LIKE ?", prefix+"%")
|
||||
}
|
||||
query = scopeBookingsToOwnTenant(c, query)
|
||||
|
||||
var bookings []models.PickupBooking
|
||||
if err := query.Order("createdat DESC").Find(&bookings).Error; err != nil {
|
||||
@@ -1878,6 +1898,128 @@ func HubAutoAssign(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// defaultBatchAssignCapPerRider caps how many bookings one rider can receive
|
||||
// in a single HubBatchAssign run, so the greedy pass doesn't pile every
|
||||
// pending pickup onto whichever rider happens to be closest to the first one.
|
||||
const defaultBatchAssignCapPerRider = 5
|
||||
|
||||
// batchRiderCandidate is a rider available for HubBatchAssign to consider,
|
||||
// tracked with a per-run assigned count so the cap can be enforced without a
|
||||
// DB round trip per booking.
|
||||
type batchRiderCandidate struct {
|
||||
userid int
|
||||
lat, lon float64
|
||||
assigned int
|
||||
}
|
||||
|
||||
// HubBatchAssign is a greedy nearest-available-rider batch dispatcher: for
|
||||
// every pending, unassigned booking at this hub (oldest first), it assigns
|
||||
// the closest rider who hasn't hit defaultBatchAssignCapPerRider yet in this
|
||||
// run, using the exact same transactional AssignMilerToBooking path
|
||||
// HubAssignMiler and AdminAssignMiler already use for one-at-a-time
|
||||
// assignment. This is a nearest-neighbor heuristic, not full multi-stop
|
||||
// route optimization (no route sequencing within a rider's stops, no
|
||||
// distance-matrix solver) — it answers "who's closest and free," not
|
||||
// "what's the optimal round for every rider." Good enough to clear a queue
|
||||
// of pending pickups without a human clicking through them one by one;
|
||||
// upgrade later if stop-sequencing quality becomes the actual bottleneck.
|
||||
func HubBatchAssign(c *fiber.Ctx) error {
|
||||
hubID := c.Locals("hubid").(int)
|
||||
staffID := c.Locals("userid").(int)
|
||||
prefix := hubPincodePrefix(hubID)
|
||||
|
||||
var req struct {
|
||||
Bookingids []int `json:"bookingids"`
|
||||
MaxPerRider int `json:"max_per_rider"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
capPerRider := req.MaxPerRider
|
||||
if capPerRider <= 0 {
|
||||
capPerRider = defaultBatchAssignCapPerRider
|
||||
}
|
||||
|
||||
bookingQuery := db.DB.Where("assignedmileruserid IS NULL AND status = ?", constants.BookingPendingPickup)
|
||||
if len(req.Bookingids) > 0 {
|
||||
bookingQuery = bookingQuery.Where("bookingid IN ?", req.Bookingids)
|
||||
} else if prefix != "" {
|
||||
bookingQuery = bookingQuery.Where("pickuppincode LIKE ?", prefix+"%")
|
||||
}
|
||||
bookingQuery = scopeBookingsToOwnTenant(c, bookingQuery)
|
||||
|
||||
var bookings []models.PickupBooking
|
||||
if err := bookingQuery.Order("createdat ASC").Find(&bookings).Error; err != nil {
|
||||
return utils.Internal(c, "failed to fetch pending bookings")
|
||||
}
|
||||
if len(bookings) == 0 {
|
||||
return utils.OK(c, fiber.Map{"assigned": 0, "skipped": 0, "results": []fiber.Map{}})
|
||||
}
|
||||
|
||||
var riderProfiles []models.MilerProfile
|
||||
if err := db.DB.Where("hubid = ? AND availabilitystatus = ?", hubID, constants.MilerAvailable).
|
||||
Find(&riderProfiles).Error; err != nil {
|
||||
return utils.Internal(c, "failed to fetch available riders")
|
||||
}
|
||||
|
||||
candidates := make([]*batchRiderCandidate, 0, len(riderProfiles))
|
||||
for _, mp := range riderProfiles {
|
||||
candidates = append(candidates, &batchRiderCandidate{
|
||||
userid: mp.Userid, lat: mp.Currentlatitude, lon: mp.Currentlongitude,
|
||||
})
|
||||
}
|
||||
|
||||
results := make([]fiber.Map, 0, len(bookings))
|
||||
assignedCount, skippedCount := 0, 0
|
||||
|
||||
for _, b := range bookings {
|
||||
var nearest *batchRiderCandidate
|
||||
nearestDist := math.MaxFloat64
|
||||
|
||||
for _, cand := range candidates {
|
||||
if cand.assigned >= capPerRider {
|
||||
continue
|
||||
}
|
||||
d := haversineKM(b.Pickuplatitude, b.Pickuplongitude, cand.lat, cand.lon)
|
||||
if d < nearestDist {
|
||||
nearestDist = d
|
||||
nearest = cand
|
||||
}
|
||||
}
|
||||
|
||||
if nearest == nil {
|
||||
results = append(results, fiber.Map{
|
||||
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
|
||||
"assigned": false, "reason": "no available rider under capacity",
|
||||
})
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := AssignMilerToBooking(b.Bookingid, nearest.userid, &staffID); err != nil {
|
||||
results = append(results, fiber.Map{
|
||||
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
|
||||
"assigned": false, "reason": err.Error(),
|
||||
})
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
nearest.assigned++
|
||||
results = append(results, fiber.Map{
|
||||
"bookingid": b.Bookingid, "bookingno": b.Bookingno,
|
||||
"assigned": true, "mileruserid": nearest.userid, "distance_km": nearestDist,
|
||||
})
|
||||
assignedCount++
|
||||
}
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
"assigned": assignedCount,
|
||||
"skipped": skippedCount,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// HUB REPORT EXPORT
|
||||
// --------------------
|
||||
|
||||
Reference in New Issue
Block a user