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

@@ -134,6 +134,48 @@ func VerifyMilerPin(cfg *config.Config) fiber.Handler {
}
}
// ResetMilerPin lets a miler who forgot their PIN set a new one from just
// their phone number, matching ResetCustomerPin's flow exactly (protected
// only by authThrottle at the route level, same as the customer version —
// no OTP verification wired in here either, consistent with the existing
// pattern rather than a change to it).
func ResetMilerPin(c *fiber.Ctx) error {
req := new(dto.MilerResetPinRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Phone == "" || req.NewPin == "" {
return utils.BadRequest(c, "phone and new_pin are required")
}
configID := req.Configid
if configID == 0 {
configID = 1001
}
var user models.AppUser
if err := db.DB.Where("contactno = ? AND configid = ?", req.Phone, configID).First(&user).Error; err != nil {
return utils.NotFound(c, "no miler account found for this phone number")
}
if user.Roleid != 5 {
return utils.Forbidden(c, "this endpoint is restricted to miler accounts")
}
pinHash, err := utils.HashPassword(req.NewPin)
if err != nil {
return utils.Internal(c, "failed to process PIN reset")
}
user.Password = pinHash
if err := db.DB.Save(&user).Error; err != nil {
return utils.Internal(c, "failed to reset PIN")
}
return utils.Message(c, "PIN reset successfully")
}
func GetMilerProfile(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
@@ -443,6 +485,84 @@ func RejectMilerAssignment(c *fiber.Ctx) error {
return utils.Message(c, "assignment rejected")
}
// MilerCancelAssignment lets a miler back out of a booking they've already
// accepted but not yet picked up (vehicle breakdown, can't reach the
// address, etc.). Distinct from RejectMilerAssignment, which only applies
// before acceptance — once the parcel is picked up the booking has become a
// consignment and this no longer applies (use MilerSkipDelivery instead for
// a failed delivery attempt on an in-flight consignment). Releases the
// booking for reassignment the same way RejectMilerAssignment does.
func MilerCancelAssignment(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
bookingID, err := strconv.Atoi(c.Params("bookingid"))
if err != nil {
return utils.BadRequest(c, "invalid booking ID")
}
var req struct {
Reason string `json:"reason"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Reason == "" {
req.Reason = "Cancelled by miler"
}
tx := db.DB.Begin()
var booking models.PickupBooking
if err := tx.Where("bookingid = ? AND assignedmileruserid = ?", bookingID, milerUserID).First(&booking).Error; err != nil {
tx.Rollback()
return utils.NotFound(c, "assigned booking not found")
}
if booking.Status == constants.BookingPickedUp || booking.Status == constants.BookingConvertedConsignment {
tx.Rollback()
return utils.BadRequest(c, "booking cannot be cancelled after pickup — the parcel is already in the network")
}
var ba models.BookingAssignment
if err := tx.Where("bookingid = ? AND mileruserid = ? AND assignmentstatus = ?",
bookingID, milerUserID, constants.AssignmentAccepted).First(&ba).Error; err != nil {
tx.Rollback()
return utils.BadRequest(c, "no accepted assignment found for this booking")
}
ba.Assignmentstatus = constants.AssignmentCancelled
ba.Remarks = req.Reason
if err := tx.Save(&ba).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to cancel assignment")
}
booking.Status = constants.BookingCreated
booking.Assignedmileruserid = nil
booking.Updatedat = time.Now()
if err := tx.Save(&booking).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to release booking")
}
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to update miler availability")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to commit cancellation")
}
if booking.Bookingsource == "CRM_Console" {
go assignment.AssignCRMMiler(booking.Bookingid)
} else {
go assignment.AssignCustomerMiler(booking.Bookingid)
}
return utils.Message(c, "assignment cancelled and released for reassignment")
}
func BookingReachedCustomer(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
bookingID, err := strconv.Atoi(c.Params("bookingid"))
@@ -665,9 +785,20 @@ func BookingPickupComplete(c *fiber.Ctx) error {
consignmentStatus = constants.ConsignmentOutForDelivery
}
// The consignment's tenant is the booking's own tenant (set explicitly at
// CreateExpressBooking time), not the completing miler's tenantid claim — a
// miler can carry parcels for tenants other than their own, and using
// their JWT tenantid here mis-attributed every such consignment. Falls
// back to the miler's own tenantid only for B2C bookings that don't carry
// one yet, matching the previous behavior for that case.
consignmentTenantID := c.Locals("tenantid").(int)
if booking.Tenantid != nil {
consignmentTenantID = *booking.Tenantid
}
consignment := models.Consignment{
Trackingno: trackingNo,
Tenantid: c.Locals("tenantid").(int),
Tenantid: consignmentTenantID,
Pickuplatitude: booking.Pickuplatitude,
Pickuplongitude: booking.Pickuplongitude,
Deliverylatitude: booking.Deliverylatitude,