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:
2026-08-05 12:22:39 +05:30
parent 77a723e047
commit c272a33fa6
10 changed files with 1404 additions and 43 deletions

View File

@@ -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
// --------------------