From cfdc99ec9adbd24d940e5ca7d01a0366916b80f3 Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Mon, 17 Aug 2026 11:05:51 +0530 Subject: [PATCH] feat: give the console customer page a flat address to edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/:id: 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 --- constants/constants.go | 53 +++++----- controllers/adminController.go | 173 ++++++++++++++++++++++++++++----- models/users.go | 45 ++++++--- routes/routes.go | 1 + 4 files changed, 212 insertions(+), 60 deletions(-) diff --git a/constants/constants.go b/constants/constants.go index db2935a..0b75b28 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -2,20 +2,27 @@ package constants // Miler Availability Statuses const ( - MilerOffline = "Offline" - MilerAvailable = "Available" - MilerAssigned = "Assigned" - MilerOnPickup = "On_Pickup" - MilerAtCustomer = "At_Customer" - MilerPickedUp = "Picked_Up" - MilerOnDelivery = "On_Delivery" - MilerBreak = "Break" - MilerBlocked = "Blocked" + MilerOffline = "Offline" + MilerAvailable = "Available" + MilerAssigned = "Assigned" + MilerOnPickup = "On_Pickup" + MilerAtCustomer = "At_Customer" + MilerPickedUp = "Picked_Up" + MilerOnDelivery = "On_Delivery" + MilerBreak = "Break" + MilerBlocked = "Blocked" +) + +// App (B2C) Customer Statuses +const ( + CustomerStatusActive = "Active" + CustomerStatusBlocked = "Blocked" + CustomerStatusDeleted = "Deleted" ) // Booking Statuses 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" BookingMilerAssigned = "Miler_Assigned" BookingPickupScheduled = "Pickup_Scheduled" @@ -26,16 +33,16 @@ const ( // Consignment Statuses const ( - ConsignmentCreated = "Created" - ConsignmentInwardedAtHub = "Inwarded_at_Hub" - ConsignmentTripsheetLoaded = "Tripsheet_Loaded" - ConsignmentInTransit = "In_Transit" - ConsignmentOutForDelivery = "Out_for_Delivery" - ConsignmentDelivered = "Delivered" - ConsignmentRTOInitiated = "RTO_Initiated" - ConsignmentReturnedToSender = "Returned_to_Sender" - ConsignmentMissing = "Missing" - ConsignmentDamaged = "Damaged" + ConsignmentCreated = "Created" + ConsignmentInwardedAtHub = "Inwarded_at_Hub" + ConsignmentTripsheetLoaded = "Tripsheet_Loaded" + ConsignmentInTransit = "In_Transit" + ConsignmentOutForDelivery = "Out_for_Delivery" + ConsignmentDelivered = "Delivered" + ConsignmentRTOInitiated = "RTO_Initiated" + ConsignmentReturnedToSender = "Returned_to_Sender" + ConsignmentMissing = "Missing" + ConsignmentDamaged = "Damaged" ) // Payment Modes @@ -93,8 +100,8 @@ const ( // Exception Statuses const ( - ExceptionOpen = "Open" + ExceptionOpen = "Open" ExceptionUnderInvestigation = "Under_Investigation" - ExceptionResolved = "Resolved" - ExceptionClosed = "Closed" + ExceptionResolved = "Resolved" + ExceptionClosed = "Closed" ) diff --git a/controllers/adminController.go b/controllers/adminController.go index ed32b1e..48a0093 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -1135,6 +1135,10 @@ func GetAdminCustomers(c *fiber.Ctx) error { like := "%" + keyword + "%" 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 // Doormile's whole B2C address book. Doormile staff can ask for one // client's customers with ?tenantid=. @@ -1186,8 +1190,21 @@ func GetAdminCustomers(c *fiber.Ctx) error { data = append(data, fiber.Map{ "appcustomerid": cust.Appcustomerid, "name": strings.TrimSpace(cust.Firstname + " " + cust.Lastname), + "firstname": cust.Firstname, + "lastname": cust.Lastname, "phone": cust.Phone, "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, "totalbookings": bookingCounts[cust.Appcustomerid], }) @@ -1216,57 +1233,169 @@ func UpdateAdminCustomer(c *fiber.Ctx) error { 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 { - Name string `json:"name"` - Phone string `json:"phone"` - Email string `json:"email"` + Name *string `json:"name"` + Firstname *string `json:"firstname"` + 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 { return utils.BadRequest(c, "invalid request body") } - if req.Phone != "" { - if len(req.Phone) != 10 || strings.IndexFunc(req.Phone, func(r rune) bool { return r < '0' || r > '9' }) != -1 { + updates := map[string]interface{}{} + + 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") } - customer.Phone = req.Phone + updates["phone"] = phone } - if req.Name != "" { - name := strings.TrimSpace(req.Name) + // firstname/lastname sent explicitly win; a single name field is split as a + // 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) - customer.Firstname = parts[0] + updates["firstname"] = parts[0] if len(parts) > 1 { - customer.Lastname = parts[1] + updates["lastname"] = parts[1] } else { - customer.Lastname = "" + updates["lastname"] = "" } } - if req.Email != "" { - customer.Email = req.Email + if req.Email != nil { + 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). - Updates(map[string]interface{}{ - "firstname": customer.Firstname, - "lastname": customer.Lastname, - "phone": customer.Phone, - "email": customer.Email, - "updatedat": customer.Updatedat, - }).Error; err != nil { + if err := db.DB.Model(&models.AppCustomer{}). + Where("appcustomerid = ?", customer.Appcustomerid). + Updates(updates).Error; err != nil { 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{ "success": true, "data": fiber.Map{ "appcustomerid": customer.Appcustomerid, "name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname), + "firstname": customer.Firstname, + "lastname": customer.Lastname, "phone": customer.Phone, "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), }, }) } diff --git a/models/users.go b/models/users.go index 5f915af..d758895 100644 --- a/models/users.go +++ b/models/users.go @@ -117,21 +117,36 @@ func (MilerProfile) TableName() string { } type AppCustomer struct { - Appcustomerid int `json:"appcustomerid" gorm:"primaryKey;column:appcustomerid"` - Firstname string `json:"firstname" gorm:"column:firstname;not null"` - Lastname string `json:"lastname" gorm:"column:lastname"` - Phone string `json:"phone" gorm:"column:phone;unique;not null"` - Email string `json:"email" gorm:"column:email"` - Loginpinhash string `json:"-" gorm:"column:loginpinhash"` - Defaultlatitude float64 `json:"defaultlatitude" gorm:"column:defaultlatitude"` - Defaultlongitude float64 `json:"defaultlongitude" gorm:"column:defaultlongitude"` - Defaultpincode string `json:"defaultpincode" gorm:"column:defaultpincode"` - 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"` + Appcustomerid int `json:"appcustomerid" gorm:"primaryKey;column:appcustomerid"` + Firstname string `json:"firstname" gorm:"column:firstname;not null"` + Lastname string `json:"lastname" gorm:"column:lastname"` + Phone string `json:"phone" gorm:"column:phone;unique;not null"` + Email string `json:"email" gorm:"column:email"` + Loginpinhash string `json:"-" gorm:"column:loginpinhash"` + Defaultlatitude float64 `json:"defaultlatitude" gorm:"column:defaultlatitude"` + Defaultlongitude float64 `json:"defaultlongitude" gorm:"column:defaultlongitude"` + Defaultpincode string `json:"defaultpincode" gorm:"column:defaultpincode"` + // Flat address on the customer record, kept for parity with the console's + // customer page (the reference stored the address this way). Separate from + // the normalized appcustomerlocations table, which holds a customer's many + // saved delivery addresses; this is the single profile address the console + // edits. + 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 { diff --git a/routes/routes.go b/routes/routes.go index f1cee94..53b1724 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -235,6 +235,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { // B2C App customers adminAuth.Get("/customers", controllers.GetAdminCustomers) + adminAuth.Get("/customers/summary", controllers.GetAdminCustomersSummary) adminAuth.Patch("/customers/:id", controllers.UpdateAdminCustomer) // Hubs