feat: give the console customer page a flat address to edit

The admin customer page collected an address (door no, street, suburb,
city, state, postcode, landmark, lat/lng) that the backend had nowhere to
store, so it was silently dropped on save. Add those fields flat onto
appcustomers, matching the reference console's shape, plus an applocationid
for zone scoping.

- GET /admin/customers: add ?applocationid= filter; emit firstname/lastname
  split and the address fields alongside the existing joined name.
- GET /admin/customers/summary (new): stat-tile counts (total/active/blocked)
  scoped like the list, so the client stops deriving them from the full page.
- PATCH /admin/customers/🆔 accept firstname/lastname directly (single name
  still splits as a fallback) and persist every address field; pointer fields
  so an omitted field is not confused with one cleared to empty.

Pagination and keyword search were already present. Additive, nullable
columns — AutoMigrate handles it, no data rewrite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Suriyakumarvijayanayagam
2026-08-17 11:05:51 +05:30
parent ba800aee66
commit cfdc99ec9a
4 changed files with 212 additions and 60 deletions

View File

@@ -2,20 +2,27 @@ package constants
// Miler Availability Statuses // Miler Availability Statuses
const ( const (
MilerOffline = "Offline" MilerOffline = "Offline"
MilerAvailable = "Available" MilerAvailable = "Available"
MilerAssigned = "Assigned" MilerAssigned = "Assigned"
MilerOnPickup = "On_Pickup" MilerOnPickup = "On_Pickup"
MilerAtCustomer = "At_Customer" MilerAtCustomer = "At_Customer"
MilerPickedUp = "Picked_Up" MilerPickedUp = "Picked_Up"
MilerOnDelivery = "On_Delivery" MilerOnDelivery = "On_Delivery"
MilerBreak = "Break" MilerBreak = "Break"
MilerBlocked = "Blocked" MilerBlocked = "Blocked"
)
// App (B2C) Customer Statuses
const (
CustomerStatusActive = "Active"
CustomerStatusBlocked = "Blocked"
CustomerStatusDeleted = "Deleted"
) )
// Booking Statuses // Booking Statuses
const ( const (
BookingPendingPickup = "Pending_Pickup" // customer requested pickup, delivery details not yet known BookingPendingPickup = "Pending_Pickup" // customer requested pickup, delivery details not yet known
BookingCreated = "Created" BookingCreated = "Created"
BookingMilerAssigned = "Miler_Assigned" BookingMilerAssigned = "Miler_Assigned"
BookingPickupScheduled = "Pickup_Scheduled" BookingPickupScheduled = "Pickup_Scheduled"
@@ -26,16 +33,16 @@ const (
// Consignment Statuses // Consignment Statuses
const ( const (
ConsignmentCreated = "Created" ConsignmentCreated = "Created"
ConsignmentInwardedAtHub = "Inwarded_at_Hub" ConsignmentInwardedAtHub = "Inwarded_at_Hub"
ConsignmentTripsheetLoaded = "Tripsheet_Loaded" ConsignmentTripsheetLoaded = "Tripsheet_Loaded"
ConsignmentInTransit = "In_Transit" ConsignmentInTransit = "In_Transit"
ConsignmentOutForDelivery = "Out_for_Delivery" ConsignmentOutForDelivery = "Out_for_Delivery"
ConsignmentDelivered = "Delivered" ConsignmentDelivered = "Delivered"
ConsignmentRTOInitiated = "RTO_Initiated" ConsignmentRTOInitiated = "RTO_Initiated"
ConsignmentReturnedToSender = "Returned_to_Sender" ConsignmentReturnedToSender = "Returned_to_Sender"
ConsignmentMissing = "Missing" ConsignmentMissing = "Missing"
ConsignmentDamaged = "Damaged" ConsignmentDamaged = "Damaged"
) )
// Payment Modes // Payment Modes
@@ -93,8 +100,8 @@ const (
// Exception Statuses // Exception Statuses
const ( const (
ExceptionOpen = "Open" ExceptionOpen = "Open"
ExceptionUnderInvestigation = "Under_Investigation" ExceptionUnderInvestigation = "Under_Investigation"
ExceptionResolved = "Resolved" ExceptionResolved = "Resolved"
ExceptionClosed = "Closed" ExceptionClosed = "Closed"
) )

View File

@@ -1135,6 +1135,10 @@ func GetAdminCustomers(c *fiber.Ctx) error {
like := "%" + keyword + "%" like := "%" + keyword + "%"
query = query.Where("firstname ILIKE ? OR lastname ILIKE ? OR phone ILIKE ?", like, like, like) query = query.Where("firstname ILIKE ? OR lastname ILIKE ? OR phone ILIKE ?", like, like, like)
} }
// Zone/city scoping. 0 means "all zones".
if applocationid := c.QueryInt("applocationid", 0); applocationid != 0 {
query = query.Where("applocationid = ?", applocationid)
}
// A client sees only the customers they have actually delivered to, not // A client sees only the customers they have actually delivered to, not
// Doormile's whole B2C address book. Doormile staff can ask for one // Doormile's whole B2C address book. Doormile staff can ask for one
// client's customers with ?tenantid=. // client's customers with ?tenantid=.
@@ -1186,8 +1190,21 @@ func GetAdminCustomers(c *fiber.Ctx) error {
data = append(data, fiber.Map{ data = append(data, fiber.Map{
"appcustomerid": cust.Appcustomerid, "appcustomerid": cust.Appcustomerid,
"name": strings.TrimSpace(cust.Firstname + " " + cust.Lastname), "name": strings.TrimSpace(cust.Firstname + " " + cust.Lastname),
"firstname": cust.Firstname,
"lastname": cust.Lastname,
"phone": cust.Phone, "phone": cust.Phone,
"email": cust.Email, "email": cust.Email,
"doorno": cust.Doorno,
"address": cust.Address,
"suburb": cust.Suburb,
"city": cust.City,
"state": cust.State,
"postcode": cust.Postcode,
"landmark": cust.Landmark,
"latitude": cust.Latitude,
"longitude": cust.Longitude,
"applocationid": cust.Applocationid,
"status": cust.Status,
"createdat": cust.Createdat, "createdat": cust.Createdat,
"totalbookings": bookingCounts[cust.Appcustomerid], "totalbookings": bookingCounts[cust.Appcustomerid],
}) })
@@ -1216,57 +1233,169 @@ func UpdateAdminCustomer(c *fiber.Ctx) error {
return utils.NotFound(c, "customer not found") return utils.NotFound(c, "customer not found")
} }
// Pointers so "field omitted" is distinguishable from "field cleared to
// empty" — the console sends only what it changed, and a missing address
// line must not blank a stored one.
req := new(struct { req := new(struct {
Name string `json:"name"` Name *string `json:"name"`
Phone string `json:"phone"` Firstname *string `json:"firstname"`
Email string `json:"email"` Lastname *string `json:"lastname"`
Phone *string `json:"phone"`
Email *string `json:"email"`
Doorno *string `json:"doorno"`
Address *string `json:"address"`
Suburb *string `json:"suburb"`
City *string `json:"city"`
State *string `json:"state"`
Postcode *string `json:"postcode"`
Landmark *string `json:"landmark"`
Latitude *float64 `json:"latitude"`
Longitude *float64 `json:"longitude"`
Applocationid *int `json:"applocationid"`
}) })
if err := c.BodyParser(req); err != nil { if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body") return utils.BadRequest(c, "invalid request body")
} }
if req.Phone != "" { updates := map[string]interface{}{}
if len(req.Phone) != 10 || strings.IndexFunc(req.Phone, func(r rune) bool { return r < '0' || r > '9' }) != -1 {
if req.Phone != nil {
phone := *req.Phone
if len(phone) != 10 || strings.IndexFunc(phone, func(r rune) bool { return r < '0' || r > '9' }) != -1 {
return utils.BadRequest(c, "phone must be 10 digits") return utils.BadRequest(c, "phone must be 10 digits")
} }
customer.Phone = req.Phone updates["phone"] = phone
} }
if req.Name != "" { // firstname/lastname sent explicitly win; a single name field is split as a
name := strings.TrimSpace(req.Name) // fallback for callers that still send it.
if req.Firstname != nil {
updates["firstname"] = strings.TrimSpace(*req.Firstname)
}
if req.Lastname != nil {
updates["lastname"] = strings.TrimSpace(*req.Lastname)
}
if req.Firstname == nil && req.Lastname == nil && req.Name != nil {
name := strings.TrimSpace(*req.Name)
parts := strings.SplitN(name, " ", 2) parts := strings.SplitN(name, " ", 2)
customer.Firstname = parts[0] updates["firstname"] = parts[0]
if len(parts) > 1 { if len(parts) > 1 {
customer.Lastname = parts[1] updates["lastname"] = parts[1]
} else { } else {
customer.Lastname = "" updates["lastname"] = ""
} }
} }
if req.Email != "" { if req.Email != nil {
customer.Email = req.Email updates["email"] = *req.Email
}
if req.Doorno != nil {
updates["doorno"] = *req.Doorno
}
if req.Address != nil {
updates["address"] = *req.Address
}
if req.Suburb != nil {
updates["suburb"] = *req.Suburb
}
if req.City != nil {
updates["city"] = *req.City
}
if req.State != nil {
updates["state"] = *req.State
}
if req.Postcode != nil {
updates["postcode"] = *req.Postcode
}
if req.Landmark != nil {
updates["landmark"] = *req.Landmark
}
if req.Latitude != nil {
updates["latitude"] = *req.Latitude
}
if req.Longitude != nil {
updates["longitude"] = *req.Longitude
}
if req.Applocationid != nil {
updates["applocationid"] = *req.Applocationid
} }
customer.Updatedat = time.Now() if len(updates) == 0 {
return utils.BadRequest(c, "no fields to update")
}
updates["updatedat"] = time.Now()
if err := db.DB.Model(&models.AppCustomer{}).Where("appcustomerid = ?", customer.Appcustomerid). if err := db.DB.Model(&models.AppCustomer{}).
Updates(map[string]interface{}{ Where("appcustomerid = ?", customer.Appcustomerid).
"firstname": customer.Firstname, Updates(updates).Error; err != nil {
"lastname": customer.Lastname,
"phone": customer.Phone,
"email": customer.Email,
"updatedat": customer.Updatedat,
}).Error; err != nil {
return utils.Internal(c, "failed to update customer") return utils.Internal(c, "failed to update customer")
} }
// Return the persisted row so the console reflects exactly what was stored.
if err := db.DB.First(&customer, customer.Appcustomerid).Error; err != nil {
return utils.Internal(c, "failed to reload customer")
}
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"success": true, "success": true,
"data": fiber.Map{ "data": fiber.Map{
"appcustomerid": customer.Appcustomerid, "appcustomerid": customer.Appcustomerid,
"name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname), "name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
"firstname": customer.Firstname,
"lastname": customer.Lastname,
"phone": customer.Phone, "phone": customer.Phone,
"email": customer.Email, "email": customer.Email,
"doorno": customer.Doorno,
"address": customer.Address,
"suburb": customer.Suburb,
"city": customer.City,
"state": customer.State,
"postcode": customer.Postcode,
"landmark": customer.Landmark,
"latitude": customer.Latitude,
"longitude": customer.Longitude,
"applocationid": customer.Applocationid,
"status": customer.Status,
},
})
}
// GetAdminCustomersSummary returns the stat-tile counts the console header
// shows, so the client does not have to page through the whole list to derive
// them. Scoped the same way as the list: a client sees only their own
// customers, Doormile staff can narrow with ?tenantid= / ?applocationid=.
func GetAdminCustomersSummary(c *fiber.Ctx) error {
base := db.DB.Model(&models.AppCustomer{})
tenantID, allowed := effectiveTenantID(c)
if !allowed {
return utils.Forbidden(c, "you can only view your own tenant")
}
if tenantID != 0 {
base = base.Where("appcustomerid IN (?)",
db.DB.Model(&models.PickupBooking{}).Select("appcustomerid").
Where("tenantid = ?", tenantID))
}
if applocationid := c.QueryInt("applocationid", 0); applocationid != 0 {
base = base.Where("applocationid = ?", applocationid)
}
count := func(status string) int64 {
var n int64
q := base.Session(&gorm.Session{})
if status != "" {
q = q.Where("status = ?", status)
}
q.Count(&n)
return n
}
return c.JSON(fiber.Map{
"success": true,
"data": fiber.Map{
"total": count(""),
"active": count(constants.CustomerStatusActive),
"blocked": count(constants.CustomerStatusBlocked),
}, },
}) })
} }

View File

@@ -117,21 +117,36 @@ func (MilerProfile) TableName() string {
} }
type AppCustomer struct { type AppCustomer struct {
Appcustomerid int `json:"appcustomerid" gorm:"primaryKey;column:appcustomerid"` Appcustomerid int `json:"appcustomerid" gorm:"primaryKey;column:appcustomerid"`
Firstname string `json:"firstname" gorm:"column:firstname;not null"` Firstname string `json:"firstname" gorm:"column:firstname;not null"`
Lastname string `json:"lastname" gorm:"column:lastname"` Lastname string `json:"lastname" gorm:"column:lastname"`
Phone string `json:"phone" gorm:"column:phone;unique;not null"` Phone string `json:"phone" gorm:"column:phone;unique;not null"`
Email string `json:"email" gorm:"column:email"` Email string `json:"email" gorm:"column:email"`
Loginpinhash string `json:"-" gorm:"column:loginpinhash"` Loginpinhash string `json:"-" gorm:"column:loginpinhash"`
Defaultlatitude float64 `json:"defaultlatitude" gorm:"column:defaultlatitude"` Defaultlatitude float64 `json:"defaultlatitude" gorm:"column:defaultlatitude"`
Defaultlongitude float64 `json:"defaultlongitude" gorm:"column:defaultlongitude"` Defaultlongitude float64 `json:"defaultlongitude" gorm:"column:defaultlongitude"`
Defaultpincode string `json:"defaultpincode" gorm:"column:defaultpincode"` Defaultpincode string `json:"defaultpincode" gorm:"column:defaultpincode"`
Devicetoken string `json:"device_token,omitempty" gorm:"column:device_token"` // Flat address on the customer record, kept for parity with the console's
Status string `json:"status" gorm:"column:status;default:Active"` // Active, Blocked, Deleted // customer page (the reference stored the address this way). Separate from
Configid int `json:"configid" gorm:"column:configid;default:1"` // the normalized appcustomerlocations table, which holds a customer's many
Lastloginat *time.Time `json:"lastloginat" gorm:"column:lastloginat"` // saved delivery addresses; this is the single profile address the console
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` // edits.
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` Doorno string `json:"doorno" gorm:"column:doorno"`
Address string `json:"address" gorm:"column:address"`
Suburb string `json:"suburb" gorm:"column:suburb"`
City string `json:"city" gorm:"column:city"`
State string `json:"state" gorm:"column:state"`
Postcode string `json:"postcode" gorm:"column:postcode"`
Landmark string `json:"landmark" gorm:"column:landmark"`
Latitude float64 `json:"latitude" gorm:"column:latitude"`
Longitude float64 `json:"longitude" gorm:"column:longitude"`
Applocationid int `json:"applocationid" gorm:"column:applocationid"` // city/zone, for console scoping
Devicetoken string `json:"device_token,omitempty" gorm:"column:device_token"`
Status string `json:"status" gorm:"column:status;default:Active"` // Active, Blocked, Deleted
Configid int `json:"configid" gorm:"column:configid;default:1"`
Lastloginat *time.Time `json:"lastloginat" gorm:"column:lastloginat"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
} }
func (AppCustomer) TableName() string { func (AppCustomer) TableName() string {

View File

@@ -235,6 +235,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
// B2C App customers // B2C App customers
adminAuth.Get("/customers", controllers.GetAdminCustomers) adminAuth.Get("/customers", controllers.GetAdminCustomers)
adminAuth.Get("/customers/summary", controllers.GetAdminCustomersSummary)
adminAuth.Patch("/customers/:id", controllers.UpdateAdminCustomer) adminAuth.Patch("/customers/:id", controllers.UpdateAdminCustomer)
// Hubs // Hubs