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

@@ -112,6 +112,157 @@ func GetAdminDashboard(c *fiber.Ctx) error {
})
}
// --------------------
// REPORTS
// --------------------
// GetAdminReports gives operations dashboard-style aggregates over a date
// range (defaults to today via parseHubDateRange, same helper GetHubReport
// uses), optionally scoped to one tenant/hub, broken down by hub, tenant, and
// rider. Replaces the old system's separate getreportsummary /
// getriderlocationsummary / getridersummary endpoints with one
// parameterized view instead of three fixed ones.
func GetAdminReports(c *fiber.Ctx) error {
from, to, err := parseHubDateRange(c)
if err != nil {
return utils.BadRequest(c, err.Error())
}
tenantID := c.Query("tenantid")
hubID := c.Query("hubid")
var totalBookings int64
db.DB.Model(&models.PickupBooking{}).Where("createdat BETWEEN ? AND ?", from, to).Count(&totalBookings)
var delivered int64
db.DB.Model(&models.PickupBooking{}).
Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingConvertedConsignment, from, to).
Count(&delivered)
var cancelled int64
db.DB.Model(&models.PickupBooking{}).
Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingCancelled, from, to).
Count(&cancelled)
consignmentQuery := db.DB.Model(&models.Consignment{}).Where("createdat BETWEEN ? AND ?", from, to)
if tenantID != "" {
consignmentQuery = consignmentQuery.Where("tenantid = ?", tenantID)
}
if hubID != "" {
consignmentQuery = consignmentQuery.Where("currenthubid = ?", hubID)
}
var totalConsignments int64
consignmentQuery.Count(&totalConsignments)
var codCollected float64
db.DB.Model(&models.BookingPayment{}).
Where("paymentstatus = ? AND createdat BETWEEN ? AND ?", constants.PaymentStatusPaid, from, to).
Select("COALESCE(SUM(amount), 0)").Scan(&codCollected)
var openExceptions int64
db.DB.Model(&models.ConsignmentException{}).
Where("status != ? AND createdat BETWEEN ? AND ?", constants.ExceptionClosed, from, to).
Count(&openExceptions)
completionRate := 0.0
if totalBookings > 0 {
completionRate = float64(delivered) / float64(totalBookings) * 100
}
// ---- by hub: parcels delivered through each hub in range ----
type hubRow struct {
Hubid int `gorm:"column:hubid"`
Hubname string `gorm:"column:hubname"`
Delivered int64 `gorm:"column:delivered"`
}
var hubRows []hubRow
db.DB.Raw(`
SELECT h.hubid AS hubid, h.hubname AS hubname, COUNT(c.consignmentid) AS delivered
FROM hubs h
LEFT JOIN consignments c ON c.currenthubid = h.hubid AND c.status = ? AND c.updatedat BETWEEN ? AND ?
WHERE h.deletedat IS NULL
GROUP BY h.hubid, h.hubname
ORDER BY delivered DESC
`, constants.ConsignmentDelivered, from, to).Scan(&hubRows)
byHub := make([]fiber.Map, 0, len(hubRows))
for _, r := range hubRows {
byHub = append(byHub, fiber.Map{"hubid": r.Hubid, "hubname": r.Hubname, "delivered": r.Delivered})
}
// ---- by tenant: consignments shipped for each tenant in range ----
type tenantRow struct {
Tenantid int `gorm:"column:tenantid"`
Tenantname string `gorm:"column:tenantname"`
Bookings int64 `gorm:"column:bookings"`
}
var tenantRows []tenantRow
db.DB.Raw(`
SELECT t.tenantid AS tenantid, t.tenantname AS tenantname, COUNT(c.consignmentid) AS bookings
FROM tenants t
LEFT JOIN consignments c ON c.tenantid = t.tenantid AND c.createdat BETWEEN ? AND ?
GROUP BY t.tenantid, t.tenantname
ORDER BY bookings DESC
`, from, to).Scan(&tenantRows)
byTenant := make([]fiber.Map, 0, len(tenantRows))
for _, r := range tenantRows {
byTenant = append(byTenant, fiber.Map{"tenantid": r.Tenantid, "tenantname": r.Tenantname, "bookings": r.Bookings})
}
// ---- by rider: completed stops/kms/earnings per rider in range,
// optionally scoped to one hub ----
type riderRow struct {
Userid int `gorm:"column:userid"`
Displayname string `gorm:"column:displayname"`
CompletedStops int64 `gorm:"column:completed_stops"`
TotalKms float64 `gorm:"column:total_kms"`
TotalEarnings float64 `gorm:"column:total_earnings"`
}
var riderRows []riderRow
riderQuery := `
SELECT mp.userid AS userid, mp.displayname AS displayname,
COUNT(ba.bookingassignmentid) AS completed_stops,
COALESCE(SUM(ba.riderkms),0) AS total_kms,
COALESCE(SUM(ba.ridercharges),0) AS total_earnings
FROM milerprofiles mp
JOIN bookingassignments ba ON ba.mileruserid = mp.userid
AND ba.assignmentstatus = ? AND ba.completedat BETWEEN ? AND ?
`
args := []interface{}{constants.AssignmentCompleted, from, to}
if hubID != "" {
riderQuery += " WHERE mp.hubid = ? "
args = append(args, hubID)
}
riderQuery += " GROUP BY mp.userid, mp.displayname ORDER BY completed_stops DESC LIMIT 50"
db.DB.Raw(riderQuery, args...).Scan(&riderRows)
byRider := make([]fiber.Map, 0, len(riderRows))
for _, r := range riderRows {
byRider = append(byRider, fiber.Map{
"userid": r.Userid, "displayname": r.Displayname,
"completed_stops": r.CompletedStops, "total_kms": r.TotalKms, "total_earnings": r.TotalEarnings,
})
}
return utils.OK(c, fiber.Map{
"from": from.Format("2006-01-02"),
"to": to.Format("2006-01-02"),
"summary": fiber.Map{
"total_bookings": totalBookings,
"delivered": delivered,
"cancelled": cancelled,
"total_consignments": totalConsignments,
"cod_collected": codCollected,
"open_exceptions": openExceptions,
"completion_rate": completionRate,
},
"by_hub": byHub,
"by_tenant": byTenant,
"by_rider": byRider,
})
}
// --------------------
// APP USERS MANAGEMENT
// --------------------
@@ -665,6 +816,120 @@ func DeleteTenantCustomer(c *fiber.Ctx) error {
return utils.Message(c, "customer deleted successfully")
}
// --------------------
// PARTNERS CRUD
// --------------------
// PartnerInfo has no Deletedat column (unlike Hub/Vehicle), so delete here is
// a hard delete, matching DeleteTenant's pattern for the same reason.
func GetPartners(c *fiber.Ctx) error {
query := db.DB.Model(&models.PartnerInfo{})
if status := c.Query("status"); status != "" {
query = query.Where("status = ?", status)
}
if keyword := c.Query("keyword"); keyword != "" {
query = query.Where("partnername ILIKE ?", "%"+keyword+"%")
}
var partners []models.PartnerInfo
if err := query.Find(&partners).Error; err != nil {
return utils.Internal(c, "failed to fetch partners")
}
return utils.List(c, partners, int64(len(partners)))
}
func CreatePartner(c *fiber.Ctx) error {
req := new(dto.PartnerCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Partnername == "" {
return utils.BadRequest(c, "partnername is required")
}
partner := models.PartnerInfo{
Partnername: req.Partnername,
Partnertypeid: req.Partnertypeid,
Contactno: req.Contactno,
Status: req.Status,
}
if partner.Status == "" {
partner.Status = "Active"
}
if err := db.DB.Create(&partner).Error; err != nil {
return utils.Internal(c, "failed to create partner")
}
return utils.Created(c, partner)
}
func GetPartnerDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var partner models.PartnerInfo
if err := db.DB.Where("partnerid = ?", id).First(&partner).Error; err != nil {
return utils.NotFound(c, "partner not found")
}
var vehicleCount int64
db.DB.Model(&models.Vehicle{}).Where("partnerid = ? AND deletedat IS NULL", id).Count(&vehicleCount)
return utils.OK(c, fiber.Map{
"partner": partner,
"vehicle_count": vehicleCount,
})
}
func UpdatePartner(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var partner models.PartnerInfo
if err := db.DB.Where("partnerid = ?", id).First(&partner).Error; err != nil {
return utils.NotFound(c, "partner not found")
}
req := new(dto.PartnerCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Partnername != "" {
partner.Partnername = req.Partnername
}
if req.Partnertypeid != 0 {
partner.Partnertypeid = req.Partnertypeid
}
if req.Contactno != "" {
partner.Contactno = req.Contactno
}
if req.Status != "" {
partner.Status = req.Status
}
partner.Updatedat = time.Now()
db.DB.Save(&partner)
return utils.OK(c, partner)
}
func DeletePartner(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var partner models.PartnerInfo
if err := db.DB.First(&partner, id).Error; err != nil {
return utils.NotFound(c, "partner not found")
}
var vehicleCount int64
db.DB.Model(&models.Vehicle{}).Where("partnerid = ? AND deletedat IS NULL", id).Count(&vehicleCount)
if vehicleCount > 0 {
return utils.BadRequest(c, "cannot delete a partner with vehicles still assigned to them")
}
if err := db.DB.Delete(&partner).Error; err != nil {
return utils.Internal(c, "failed to delete partner")
}
return utils.Message(c, "partner deleted successfully")
}
// --------------------
// HUBS CRUD
// --------------------
@@ -957,6 +1222,44 @@ func GetMilerDetails(c *fiber.Ctx) error {
return utils.OK(c, profile)
}
// AdminNotifyMiler lets console staff push a one-off notification to a
// specific miler directly (not tied to a booking, unlike InternalNotify
// which is machine-to-machine and booking-scoped). :id is the milerprofileid,
// matching every other /admin/milers/:id route.
func AdminNotifyMiler(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.BadRequest(c, "invalid miler ID")
}
var req struct {
Title string `json:"title"`
Message string `json:"message"`
Data map[string]string `json:"data"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Title == "" || req.Message == "" {
return utils.BadRequest(c, "title and message are required")
}
var profile models.MilerProfile
if err := db.DB.Where("milerprofileid = ?", id).First(&profile).Error; err != nil {
return utils.NotFound(c, "miler not found")
}
if profile.Devicetoken == "" {
return utils.BadRequest(c, "this miler has no registered device to notify")
}
if err := notify.SendToDevice(profile.Devicetoken, req.Title, req.Message, req.Data); err != nil {
return utils.Internal(c, "failed to send notification")
}
return utils.Message(c, "notification sent")
}
func UpdateMiler(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var profile models.MilerProfile
@@ -1053,13 +1356,18 @@ 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)
}
var total int64
if err := db.DB.Model(&models.PickupBooking{}).Count(&total).Error; err != nil {
if err := query.Count(&total).Error; err != nil {
return utils.Internal(c, "failed to count bookings")
}
var bookings []models.PickupBooking
if err := db.DB.Preload("Parcels").Preload("ServiceOptions").
if err := query.Preload("Parcels").Preload("ServiceOptions").
Offset(offset).Limit(pagesize).Find(&bookings).Error; err != nil {
return utils.Internal(c, "failed to fetch bookings")
}
@@ -1076,42 +1384,60 @@ func GetAdminBookings(c *fiber.Ctx) error {
})
}
func CreateCRMBooking(c *fiber.Ctx) error {
type AdminBookingRequest struct {
Appcustomerid int `json:"appcustomerid"`
CustomerPhone string `json:"customer_phone"`
CustomerName string `json:"customer_name"`
Pickupaddress string `json:"pickupaddress"`
Pickuppincode string `json:"pickuppincode"`
Pickuplatitude float64 `json:"pickuplatitude"`
Pickuplongitude float64 `json:"pickuplongitude"`
Deliveryaddress string `json:"deliveryaddress"`
Deliverypincode string `json:"deliverypincode"`
Deliverycity string `json:"deliverycity"`
Deliverylatitude float64 `json:"deliverylatitude"`
Deliverylongitude float64 `json:"deliverylongitude"`
Providercompany string `json:"providercompany"`
Providerlocation string `json:"providerlocation"`
Notes string `json:"notes"`
ServiceOption string `json:"service_option"`
Finalprice float64 `json:"finalprice"`
Insuranceamount float64 `json:"insuranceamount"`
Preferredpickupfrom *time.Time `json:"preferredpickupfrom"`
Preferredpickupto *time.Time `json:"preferredpickupto"`
Parcels []dto.ParcelRequest `json:"parcels"`
}
// AdminBookingRequest is the express-console booking payload, shared by CreateExpressBooking
// (one booking) and AdminBulkCreateBookings (many) — was previously a type
// local to CreateExpressBooking, promoted to package level so both can use it.
type AdminBookingRequest struct {
Tenantid int `json:"tenantid"`
Appcustomerid int `json:"appcustomerid"`
CustomerPhone string `json:"customer_phone"`
CustomerName string `json:"customer_name"`
Pickupaddress string `json:"pickupaddress"`
Pickuppincode string `json:"pickuppincode"`
Pickuplatitude float64 `json:"pickuplatitude"`
Pickuplongitude float64 `json:"pickuplongitude"`
Deliveryaddress string `json:"deliveryaddress"`
Deliverypincode string `json:"deliverypincode"`
Deliverycity string `json:"deliverycity"`
Deliverylatitude float64 `json:"deliverylatitude"`
Deliverylongitude float64 `json:"deliverylongitude"`
Providercompany string `json:"providercompany"`
Providerlocation string `json:"providerlocation"`
Notes string `json:"notes"`
ServiceOption string `json:"service_option"`
Finalprice float64 `json:"finalprice"`
Insuranceamount float64 `json:"insuranceamount"`
Preferredpickupfrom *time.Time `json:"preferredpickupfrom"`
Preferredpickupto *time.Time `json:"preferredpickupto"`
Parcels []dto.ParcelRequest `json:"parcels"`
}
req := new(AdminBookingRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
// expressBookingValidationError marks a createExpressBooking failure as a bad
// request (missing/invalid input) rather than a server-side failure, so
// CreateExpressBooking can still return the right HTTP status after the
// validation logic moved into the shared helper below.
type expressBookingValidationError struct{ msg string }
func (e *expressBookingValidationError) Error() string { return e.msg }
// createExpressBooking holds the actual booking-creation logic, shared by
// CreateExpressBooking (one booking, used by the "New Booking" form) and
// AdminBulkCreateBookings (many, used by CSV/bulk import). Takes no
// *fiber.Ctx — the original function never touched c after BodyParser, so
// both callers can use this identically.
func createExpressBooking(req AdminBookingRequest) (*models.PickupBooking, error) {
if req.Pickupaddress == "" || req.Pickuppincode == "" {
return utils.BadRequest(c, "pickup address and pincode are required")
return nil, &expressBookingValidationError{"pickup address and pincode are required"}
}
if len(req.Parcels) == 0 {
return utils.BadRequest(c, "at least one parcel is required")
return nil, &expressBookingValidationError{"at least one parcel is required"}
}
if req.Tenantid == 0 {
return nil, &expressBookingValidationError{"tenantid is required for express-console bookings"}
}
var tenant models.Tenant
if err := db.DB.Where("tenantid = ?", req.Tenantid).First(&tenant).Error; err != nil {
return nil, &expressBookingValidationError{"tenantid does not match a known tenant"}
}
tx := db.DB.Begin()
@@ -1135,14 +1461,16 @@ func CreateCRMBooking(c *fiber.Ctx) error {
}
if err := tx.Create(&newCustomer).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to create customer record")
return nil, fmt.Errorf("failed to create customer record")
}
customerID = newCustomer.Appcustomerid
}
}
tenantID := req.Tenantid
booking := models.PickupBooking{
Bookingno: generateBookingNo(),
Tenantid: &tenantID,
Appcustomerid: customerID,
Pickupaddress: req.Pickupaddress,
Pickuppincode: req.Pickuppincode,
@@ -1164,7 +1492,7 @@ func CreateCRMBooking(c *fiber.Ctx) error {
if err := tx.Create(&booking).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to create booking")
return nil, fmt.Errorf("failed to create booking")
}
var totalWeight float64
@@ -1197,7 +1525,7 @@ func CreateCRMBooking(c *fiber.Ctx) error {
parcel.Insuranceamount = p.Declaredvalue * 0.01
}
// If explicit insurance amount is provided from CRM, apply it to the first parcel
// If explicit insurance amount is provided in the request, apply it to the first parcel
if req.Insuranceamount > 0 && totalWeight == math.Max(p.Weight, calculateVolumetricWeight(p.Length, p.Width, p.Height)) {
parcel.Insuranceamount = req.Insuranceamount
parcel.Needsinsurance = true
@@ -1205,7 +1533,7 @@ func CreateCRMBooking(c *fiber.Ctx) error {
if err := tx.Create(&parcel).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to save parcel details")
return nil, fmt.Errorf("failed to save parcel details")
}
}
@@ -1265,7 +1593,7 @@ func CreateCRMBooking(c *fiber.Ctx) error {
if err := tx.Create(&srvOption).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to save service option")
return nil, fmt.Errorf("failed to save service option")
}
if requiresLargeVehicle || totalWeight > 20.0 {
@@ -1277,12 +1605,12 @@ func CreateCRMBooking(c *fiber.Ctx) error {
}
if err := tx.Create(&reqVeh).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to save vehicle requirement")
return nil, fmt.Errorf("failed to save vehicle requirement")
}
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to create booking")
return nil, fmt.Errorf("failed to create booking")
}
go assignment.AssignCRMMiler(booking.Bookingid)
@@ -1304,9 +1632,65 @@ func CreateCRMBooking(c *fiber.Ctx) error {
db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, booking.Bookingid)
return &booking, nil
}
func CreateExpressBooking(c *fiber.Ctx) error {
req := new(AdminBookingRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
booking, err := createExpressBooking(*req)
if err != nil {
if _, ok := err.(*expressBookingValidationError); ok {
return utils.BadRequest(c, err.Error())
}
return utils.Internal(c, err.Error())
}
return utils.Created(c, booking)
}
// AdminBulkCreateBookings creates several express-console bookings in one call — the
// console's CSV/bulk-import flow. Each item is processed independently, same
// per-item-result shape as AdminBulkCancelBookings, so one bad row (missing
// address, unknown tenantid) doesn't block the rest of the batch.
func AdminBulkCreateBookings(c *fiber.Ctx) error {
var req struct {
Bookings []AdminBookingRequest `json:"bookings"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if len(req.Bookings) == 0 {
return utils.BadRequest(c, "bookings is required and must not be empty")
}
if len(req.Bookings) > 200 {
return utils.BadRequest(c, "maximum 200 bookings per bulk request")
}
type result struct {
Index int `json:"index"`
Success bool `json:"success"`
Bookingid int `json:"bookingid,omitempty"`
Bookingno string `json:"bookingno,omitempty"`
Error string `json:"error,omitempty"`
}
results := make([]result, 0, len(req.Bookings))
for i, item := range req.Bookings {
booking, err := createExpressBooking(item)
if err != nil {
results = append(results, result{Index: i, Success: false, Error: err.Error()})
continue
}
results = append(results, result{Index: i, Success: true, Bookingid: booking.Bookingid, Bookingno: booking.Bookingno})
}
return utils.OK(c, fiber.Map{"results": results})
}
func GetAdminBookingDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var booking models.PickupBooking
@@ -1443,6 +1827,76 @@ func AdminCancelBooking(c *fiber.Ctx) error {
return utils.Message(c, "booking cancelled")
}
// AdminBulkCancelBookings cancels several bookings in one call — the console's
// multi-select "cancel selected" action. Each id is processed independently
// so one bad id (already shipped, already cancelled, not found) doesn't block
// the rest; the response reports success/failure per id rather than failing
// the whole batch on the first error.
func AdminBulkCancelBookings(c *fiber.Ctx) error {
var req struct {
Bookingids []int `json:"bookingids"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if len(req.Bookingids) == 0 {
return utils.BadRequest(c, "bookingids is required and must not be empty")
}
type result struct {
Bookingid int `json:"bookingid"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
results := make([]result, 0, len(req.Bookingids))
for _, id := range req.Bookingids {
var booking models.PickupBooking
if err := db.DB.First(&booking, id).Error; err != nil {
results = append(results, result{Bookingid: id, Success: false, Error: "booking not found"})
continue
}
if booking.Status == constants.BookingConvertedConsignment || booking.Status == constants.BookingCancelled {
results = append(results, result{Bookingid: id, Success: false, Error: "cannot cancel a delivered or already cancelled booking"})
continue
}
booking.Status = constants.BookingCancelled
booking.Updatedat = time.Now()
if err := db.DB.Save(&booking).Error; err != nil {
results = append(results, result{Bookingid: id, Success: false, Error: "failed to save"})
continue
}
if booking.Assignedmileruserid != nil {
db.DB.Model(&models.MilerProfile{}).
Where("userid = ?", *booking.Assignedmileruserid).
Update("availabilitystatus", constants.MilerAvailable)
}
if db.Js != nil {
payload := map[string]interface{}{"bookingid": booking.Bookingid, "reason": "admin_bulk_cancelled"}
if data, err := json.Marshal(payload); err == nil {
if _, err := db.Js.Publish("booking.cancelled", data); err != nil {
utils.Warn("AdminBulkCancelBookings: NATS publish failed", "booking_id", booking.Bookingid, "error", err)
}
}
}
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
if err := notify.SendToDevice(customer.Devicetoken, "Booking Cancelled", "Your booking has been cancelled by operations", nil); err != nil {
utils.Warn("AdminBulkCancelBookings: failed to notify customer", "booking_id", booking.Bookingid, "error", err)
}
}
results = append(results, result{Bookingid: id, Success: true})
}
return utils.OK(c, fiber.Map{"results": results})
}
// --------------------
// CONSIGNMENTS
// --------------------
@@ -2227,6 +2681,60 @@ func GetAdminProfile(c *fiber.Ctx) error {
return utils.OK(c, user)
}
// AdminChangePassword lets a logged-in admin console user change their own
// password. Unlike ResetCustomerPin/ResetMilerPin — open, phone-only reset
// endpoints matching what those two apps already do — this requires an
// active session and the current password. Admin accounts touch tenant,
// pricing, and financial data, so an open "reset by email" endpoint here
// would be a much bigger blast radius than a customer or miler PIN reset;
// intentionally not mirroring that pattern for this one.
func AdminChangePassword(c *fiber.Ctx) error {
userID, ok := c.Locals("userid").(int)
if !ok {
return utils.Unauthorized(c, "authentication required")
}
var req struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.CurrentPassword == "" || req.NewPassword == "" {
return utils.BadRequest(c, "current_password and new_password are required")
}
if len(req.NewPassword) < 8 {
return utils.BadRequest(c, "new_password must be at least 8 characters")
}
var user models.AppUser
if err := db.DB.Where("userid = ?", userID).First(&user).Error; err != nil {
return utils.NotFound(c, "user not found")
}
var auth models.DoormileAuth
if err := db.DB.Where("email = ?", user.Email).First(&auth).Error; err != nil {
return utils.NotFound(c, "admin credentials not found for this account")
}
if !utils.CheckPasswordHash(req.CurrentPassword, auth.PasswordHash) {
return utils.Unauthorized(c, "current password is incorrect")
}
newHash, err := utils.HashPassword(req.NewPassword)
if err != nil {
return utils.Internal(c, "failed to process password change")
}
auth.PasswordHash = newHash
if err := db.DB.Save(&auth).Error; err != nil {
return utils.Internal(c, "failed to update password")
}
return utils.Message(c, "password changed successfully")
}
// InternalNotify sends FCM push notifications on behalf of the Python agent system.
// The caller specifies target = "customer", "miler", or "both".
// Auth: X-Internal-Key header (see InternalKeyAuth middleware).