Hardening pass over the API surface. No route's auth requirements change.
Resilience:
- Add recover middleware. There was none, so an unhandled panic in any
handler propagated out of the process instead of becoming a 500.
- Add a centralized ErrorHandler so errors and recovered panics return the
same {success,message} envelope as the utils helpers, not Fiber's default
plain-text body. 5xx responses are logged with method and path.
Rate limiting:
- Global 300/min per IP as an abuse backstop, exempting health/readiness
probes and websocket upgrades.
- 10/min shared across every credential endpoint (customer/miler/admin/hub
login, verify-pin, reset-pin, email OTP). PINs are 4 digits, so the whole
keyspace was previously walkable in seconds. One shared limiter instance
means rotating between endpoints doesn't reset the budget.
- Add TRUSTED_PROXIES config. Limits key on c.IP(), which behind a TLS
terminator is the proxy, collapsing every client into one bucket. When set,
X-Forwarded-For is honoured only from those proxies so the header can't be
spoofed to dodge the limit. Logs a warning when unset.
Transactions:
- Check the error on all 51 previously-unchecked tx.Save/Create/Delete/
Model(...).Update/Commit calls across 6 controllers. A failed write inside
a transaction was silently ignored and the request still reported success;
an unchecked Commit could fail with the caller told everything worked.
Each site now rolls back and returns a specific message.
Pagination:
- Add utils.ParsePage/Paginated, reusing the pageno/pagesize convention
GetAdminBookings already established. Default 500, hard cap 1000.
- Apply to the previously unbounded consignments, tripsheets, exceptions,
app-users and clients endpoints. Defaults are high so existing consoles
that don't paginate keep working; the cap only stops a growing table from
being loaded wholesale. total is now a real COUNT, not len(data).
- GetClients also loaded the entire auth table to join in memory; it now
fetches only the current page's rows.
Tests (first in the repo):
- Extract the hyperlocal pincode rule out of BookingPickupComplete into
isHyperlocal so it is testable, covering the short/empty pincode fallback.
- Cover calculateVolumetricWeight and the ParsePage clamping rules.
Repo hygiene:
- Tag scratch/*.go with //go:build ignore. Each declared its own main(), so
`go build ./...` failed on redeclaration; it now passes repo-wide.
- Untrack scratch/node_modules (216 files) and ignore node_modules, test
artifacts, and the `doormile` binary `go build .` emits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
87 lines
2.7 KiB
Go
87 lines
2.7 KiB
Go
package controllers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"doormile/constants"
|
|
"doormile/db"
|
|
"doormile/internal/notify"
|
|
"doormile/models"
|
|
"doormile/utils"
|
|
)
|
|
|
|
// AssignMilerToBooking is the single source of truth for manually assigning a
|
|
// miler to a pickup booking — shared by the admin console (AdminAssignMiler)
|
|
// and the hub console (HubAssignMiler) so both go through identical DB
|
|
// updates, NATS publish, and FCM notify instead of duplicating the logic.
|
|
func AssignMilerToBooking(bookingID, milerUserID int, assignedByUserID *int) (*models.PickupBooking, error) {
|
|
tx := db.DB.Begin()
|
|
|
|
var booking models.PickupBooking
|
|
if err := tx.First(&booking, bookingID).Error; err != nil {
|
|
tx.Rollback()
|
|
return nil, fmt.Errorf("booking not found")
|
|
}
|
|
|
|
booking.Status = constants.BookingMilerAssigned
|
|
booking.Assignedmileruserid = &milerUserID
|
|
booking.Updatedat = time.Now()
|
|
if err := tx.Save(&booking).Error; err != nil {
|
|
tx.Rollback()
|
|
return nil, fmt.Errorf("failed to update booking: %w", err)
|
|
}
|
|
|
|
assignment := models.BookingAssignment{
|
|
Bookingid: booking.Bookingid,
|
|
Mileruserid: milerUserID,
|
|
Assignedbyuserid: assignedByUserID,
|
|
Assignmentstatus: constants.AssignmentAssigned,
|
|
}
|
|
if err := tx.Create(&assignment).Error; err != nil {
|
|
tx.Rollback()
|
|
return nil, fmt.Errorf("failed to create assignment: %w", err)
|
|
}
|
|
|
|
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
|
Update("availabilitystatus", constants.MilerAssigned).Error; err != nil {
|
|
tx.Rollback()
|
|
return nil, fmt.Errorf("failed to update miler availability: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return nil, fmt.Errorf("failed to commit miler assignment: %w", err)
|
|
}
|
|
|
|
if db.Js != nil {
|
|
payload := map[string]interface{}{
|
|
"booking_id": booking.Bookingid,
|
|
"booking_no": booking.Bookingno,
|
|
"status": booking.Status,
|
|
"miler_id": milerUserID,
|
|
"updated_at": time.Now().UnixMilli(),
|
|
}
|
|
if data, err := json.Marshal(payload); err == nil {
|
|
if _, err := db.Js.Publish("api.v1.bookings.update", data); err != nil {
|
|
utils.Warn("Failed to publish booking.update to NATS", "booking_id", booking.Bookingid, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
var miler models.MilerProfile
|
|
if db.DB.Where("userid = ?", milerUserID).First(&miler).Error == nil && miler.Devicetoken != "" {
|
|
if err := notify.SendToDevice(
|
|
miler.Devicetoken,
|
|
"New Pickup Assigned",
|
|
"New booking assigned — tap to view details",
|
|
map[string]string{"booking_id": fmt.Sprintf("%d", booking.Bookingid)},
|
|
); err != nil {
|
|
utils.Warn("FCM: failed to notify miler on manual assignment",
|
|
"miler_id", milerUserID, "booking_id", booking.Bookingid, "error", err)
|
|
}
|
|
}
|
|
|
|
return &booking, nil
|
|
}
|