Files
doormile_backend/controllers/clientController.go
Suriya 2c26cbe4ba fix: panic recovery, rate limiting, transaction error handling, pagination
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>
2026-07-27 12:13:39 +05:30

410 lines
11 KiB
Go

package controllers
import (
"strconv"
"strings"
"doormile/db"
"doormile/dto"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
func RegisterClient(c *fiber.Ctx) error {
var input dto.CreateClientRequest
if err := c.BodyParser(&input); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if input.FirstName == "" || input.Phone == "" {
return utils.BadRequest(c, "first_name and phone are required")
}
if input.RegistrationSource == "" {
input.RegistrationSource = DetermineSource(string(c.Request().Header.UserAgent()))
}
if input.DataConsent == "" {
input.DataConsent = "full"
}
if input.Status == "" {
input.Status = "newClient"
}
createAuth := input.Email != "" && input.Password != ""
var hashedPassword string
if createAuth {
var err error
hashedPassword, err = utils.HashPassword(input.Password)
if err != nil {
return utils.Internal(c, "failed to process registration")
}
}
tx := db.DB.Begin()
var existingClient models.DoormileClient
if tx.Where("phone = ?", input.Phone).First(&existingClient).Error == nil {
tx.Rollback()
return utils.Conflict(c, "a client with this phone number already exists")
}
if createAuth {
var existingAuth models.DoormileAuth
if tx.Where("email = ?", input.Email).First(&existingAuth).Error == nil {
tx.Rollback()
return utils.Conflict(c, "this email address is already registered")
}
}
client := models.DoormileClient{
FirstName: input.FirstName,
LastName: input.LastName,
Phone: input.Phone,
Address: input.Address,
City: input.City,
State: input.State,
Neighbourhood: input.Neighbourhood,
Pincode: input.Pincode,
SurveyLat: input.SurveyLat,
SurveyLong: input.SurveyLong,
SurveyAddress: input.SurveyAddress,
SurveyZone: input.SurveyZone,
SurveyPincode: input.SurveyPincode,
BusinessType: input.BusinessType,
Status: input.Status,
ShippingFrequency: input.ShippingFrequency,
LogisticsSegment: input.LogisticsSegment,
TransitFrom: input.TransitFrom,
TransitTo: input.TransitTo,
DataConsent: input.DataConsent,
RegistrationSource: input.RegistrationSource,
RegisteredByID: input.RegisteredByID,
}
if input.DataConsent == "full" {
client.ParcelVolume = input.ParcelVolume
client.ActiveContracts = input.ActiveContracts
client.LogisticsProvider = input.LogisticsProvider
client.ProviderEfficiency = input.ProviderEfficiency
client.Notes = input.Notes
} else {
client.ParcelVolume = 0
client.ActiveContracts = 0
client.LogisticsProvider = "Not disclosed"
client.ProviderEfficiency = ""
client.Notes = ""
}
if err := tx.Create(&client).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to create client")
}
email, role := "", ""
if createAuth {
auth := models.DoormileAuth{
ClientID: &client.ID,
Email: input.Email,
PasswordHash: hashedPassword,
Role: "user",
}
if err := tx.Create(&auth).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to create auth credentials")
}
email = auth.Email
role = auth.Role
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to register client")
}
return utils.Created(c, buildClientResponse(client, email, role))
}
func GetClients(c *fiber.Ctx) error {
page := utils.ParsePage(c)
var total int64
if err := db.DB.Model(&models.DoormileClient{}).Count(&total).Error; err != nil {
return utils.Internal(c, "failed to count clients")
}
var clients []models.DoormileClient
if err := page.Apply(db.DB).Find(&clients).Error; err != nil {
return utils.Internal(c, "failed to fetch clients")
}
// Bulk load auth records for just this page's clients, rather than the
// whole auth table, and map by client_id for O(1) lookup.
clientIDs := make([]uint64, 0, len(clients))
for _, client := range clients {
clientIDs = append(clientIDs, client.ID)
}
var auths []models.DoormileAuth
if len(clientIDs) > 0 {
if err := db.DB.Where("client_id IN ?", clientIDs).Find(&auths).Error; err != nil {
return utils.Internal(c, "failed to fetch client credentials")
}
}
authByClientID := make(map[uint64]models.DoormileAuth, len(auths))
for _, a := range auths {
if a.ClientID != nil {
authByClientID[*a.ClientID] = a
}
}
responses := make([]dto.ClientResponse, 0, len(clients))
for _, client := range clients {
auth := authByClientID[client.ID]
responses = append(responses, buildClientResponse(client, auth.Email, auth.Role))
}
return utils.Paginated(c, responses, total, page)
}
func GetClientDetails(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return utils.BadRequest(c, "invalid client ID")
}
var client models.DoormileClient
if err := db.DB.First(&client, id).Error; err != nil {
return utils.NotFound(c, "client not found")
}
var auth models.DoormileAuth
email, role := "", ""
if db.DB.Where("client_id = ?", id).First(&auth).Error == nil {
email = auth.Email
role = auth.Role
}
return utils.OK(c, buildClientResponse(client, email, role))
}
func UpdateClient(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return utils.BadRequest(c, "invalid client ID")
}
var client models.DoormileClient
if err := db.DB.First(&client, id).Error; err != nil {
return utils.NotFound(c, "client not found")
}
var input dto.CreateClientRequest
if err := c.BodyParser(&input); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if input.FirstName != "" {
client.FirstName = input.FirstName
}
if input.LastName != "" {
client.LastName = input.LastName
}
if input.Phone != "" {
client.Phone = input.Phone
}
if input.Address != "" {
client.Address = input.Address
}
if input.City != "" {
client.City = input.City
}
if input.State != "" {
client.State = input.State
}
if input.Neighbourhood != "" {
client.Neighbourhood = input.Neighbourhood
}
if input.Pincode != "" {
client.Pincode = input.Pincode
}
if input.SurveyLat != 0 {
client.SurveyLat = input.SurveyLat
}
if input.SurveyLong != 0 {
client.SurveyLong = input.SurveyLong
}
if input.SurveyAddress != "" {
client.SurveyAddress = input.SurveyAddress
}
if input.SurveyZone != "" {
client.SurveyZone = input.SurveyZone
}
if input.SurveyPincode != "" {
client.SurveyPincode = input.SurveyPincode
}
if input.BusinessType != "" {
client.BusinessType = input.BusinessType
}
if input.Status != "" {
client.Status = input.Status
}
if input.ShippingFrequency != "" {
client.ShippingFrequency = input.ShippingFrequency
}
if input.LogisticsSegment != "" {
client.LogisticsSegment = input.LogisticsSegment
}
if input.TransitFrom != "" {
client.TransitFrom = input.TransitFrom
}
if input.TransitTo != "" {
client.TransitTo = input.TransitTo
}
if input.RegistrationSource != "" {
client.RegistrationSource = input.RegistrationSource
}
if input.RegisteredByID != 0 {
client.RegisteredByID = input.RegisteredByID
}
client.Notes = input.Notes
if input.DataConsent != "" {
client.DataConsent = input.DataConsent
}
if client.DataConsent == "full" {
client.ParcelVolume = input.ParcelVolume
client.ActiveContracts = input.ActiveContracts
client.LogisticsProvider = input.LogisticsProvider
client.ProviderEfficiency = input.ProviderEfficiency
} else {
client.ParcelVolume = 0
client.ActiveContracts = 0
client.LogisticsProvider = "Not disclosed"
client.ProviderEfficiency = ""
client.Notes = ""
}
tx := db.DB.Begin()
if err := tx.Save(&client).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to update client")
}
var auth models.DoormileAuth
email, role := "", ""
if db.DB.Where("client_id = ?", id).First(&auth).Error == nil {
email = auth.Email
role = auth.Role
}
authUpdated := false
if input.Email != "" && input.Email != auth.Email {
auth.Email = input.Email
email = input.Email
authUpdated = true
}
if input.Password != "" {
hashed, err := utils.HashPassword(input.Password)
if err != nil {
tx.Rollback()
return utils.Internal(c, "failed to process password update")
}
auth.PasswordHash = hashed
authUpdated = true
}
if authUpdated && auth.ID != 0 {
if err := tx.Save(&auth).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to update credentials")
}
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to update client")
}
return utils.OK(c, buildClientResponse(client, email, role))
}
func DeleteClient(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return utils.BadRequest(c, "invalid client ID")
}
var client models.DoormileClient
if err := db.DB.First(&client, id).Error; err != nil {
return utils.NotFound(c, "client not found")
}
tx := db.DB.Begin()
if err := tx.Where("client_id = ?", id).Delete(&models.DoormileAuth{}).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to delete client credentials")
}
if err := tx.Delete(&client).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to delete client")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to delete client")
}
return utils.Message(c, "client deleted successfully")
}
func buildClientResponse(client models.DoormileClient, email, role string) dto.ClientResponse {
return dto.ClientResponse{
ID: client.ID,
CreatedAt: client.CreatedAt.Format("2006-01-02T15:04:05Z"),
LastUpdated: client.UpdatedAt.Format("2006-01-02"),
FirstName: client.FirstName,
LastName: client.LastName,
Email: email,
Phone: client.Phone,
Address: client.Address,
City: client.City,
State: client.State,
Neighbourhood: client.Neighbourhood,
Pincode: client.Pincode,
SurveyLat: client.SurveyLat,
SurveyLong: client.SurveyLong,
SurveyAddress: client.SurveyAddress,
SurveyZone: client.SurveyZone,
SurveyPincode: client.SurveyPincode,
BusinessType: client.BusinessType,
Status: client.Status,
ShippingFrequency: client.ShippingFrequency,
LogisticsSegment: client.LogisticsSegment,
TransitFrom: client.TransitFrom,
TransitTo: client.TransitTo,
ParcelVolume: client.ParcelVolume,
ActiveContracts: client.ActiveContracts,
LogisticsProvider: client.LogisticsProvider,
ProviderEfficiency: client.ProviderEfficiency,
Notes: client.Notes,
DataConsent: client.DataConsent,
RegistrationSource: client.RegistrationSource,
RegisteredByID: client.RegisteredByID,
Role: role,
}
}
func DetermineSource(userAgent string) string {
ua := strings.ToLower(userAgent)
if strings.Contains(ua, "dart") || strings.Contains(ua, "flutter") || strings.Contains(ua, "doormile") {
return "mobile"
}
if strings.Contains(ua, "mozilla") || strings.Contains(ua, "chrome") || strings.Contains(ua, "safari") {
return "web"
}
return "api_tool"
}