From 593b11f1b8582cfd8302e2524f1ff6c75d13ef71 Mon Sep 17 00:00:00 2001 From: abhishek Date: Fri, 4 Sep 2026 16:02:47 +0530 Subject: [PATCH] rider creation --- controllers/partnerController.go | 117 ++++++++++++ models/partner.go | 107 +++++++++++ repositories/partnerRepository.go | 283 ++++++++++++++++++++++++++++++ routes/partnerroutes.go | 13 ++ services/partnerService.go | 15 ++ 5 files changed, 535 insertions(+) diff --git a/controllers/partnerController.go b/controllers/partnerController.go index 08bd5f4..84aaa69 100644 --- a/controllers/partnerController.go +++ b/controllers/partnerController.go @@ -1,6 +1,7 @@ package controllers import ( + "nearle/models" "nearle/services" "net/http" "strconv" @@ -193,3 +194,119 @@ func (ctl *PartnerController) GetRiderInfo(c *fiber.Ctx) error { "details": result, }) } + +// CreateRider hires a rider — three tables in one transaction. +// +// `tenantid` comes from the query string, which is where this console's other +// scoped writes take it from. It is required: a rider with no tenant belongs to +// the region and to no merchant, which is the state the 84 existing riders are +// already in and not one worth creating more of. +func (ctl *PartnerController) CreateRider(c *fiber.Ctx) error { + var rider models.NewRider + + if err := c.BodyParser(&rider); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, + "status": false, + "message": "Invalid request body", + }) + } + + // Taken from the scope rather than trusted from the body. A store admin + // must not be able to put a rider on another merchant's books by editing a + // payload, and the body is the caller's to edit. + if tid, _ := strconv.Atoi(c.Query("tenantid")); tid != 0 { + rider.Tenantid = tid + } + if rider.Tenantid == 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, + "status": false, + "message": "tenantid is required", + }) + } + + userid, err := ctl.partnerService.CreateRider(rider) + if err != nil { + // 400, not 500: every failure this can produce is something the caller + // sent — a shift that does not exist, an unconfigured region, a phone + // number already in use. Answering 500 would send them to look at the + // server for a problem in their own form. + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, + "status": false, + "message": err.Error(), + }) + } + + return c.Status(http.StatusCreated).JSON(fiber.Map{ + "code": http.StatusCreated, + "status": true, + // Said plainly because it is the single most confusing thing about this + // flow: the rider is created and will NOT show in the on-duty fleet + // until they open the app and start a shift. + "message": "Rider created. They appear in the fleet once they sign in and start a shift.", + "details": fiber.Map{"userid": userid}, + }) +} + +func (ctl *PartnerController) UpdateRider(c *fiber.Ctx) error { + var rider models.NewRider + + if err := c.BodyParser(&rider); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, + "status": false, + "message": "Invalid request body", + }) + } + + if err := ctl.partnerService.UpdateRider(rider); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, + "status": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "code": http.StatusOK, + "status": true, + "message": "Success", + }) +} + +// GetRiderRoster lists riders whether or not they are working today. +// +// The scope is required. Unscoped this returns every rider on the platform, +// which is not a merchant's business — the same guard the other tenant-scoped +// reads carry. +func (ctl *PartnerController) GetRiderRoster(c *fiber.Ctx) error { + tid, _ := strconv.Atoi(c.Query("tenantid")) + aid, _ := strconv.Atoi(c.Query("applocationid")) + pid, _ := strconv.Atoi(c.Query("partnerid")) + + if tid == 0 && aid == 0 && pid == 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, + "status": false, + "message": "One of tenantid, applocationid or partnerid is required", + }) + } + + result, err := ctl.partnerService.GetRiderRoster(tid, aid, pid) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ + "code": http.StatusInternalServerError, + "status": false, + "message": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "code": http.StatusOK, + "status": true, + "message": "Successful", + "details": result, + }) +} diff --git a/models/partner.go b/models/partner.go index 6da4340..2bf51a4 100644 --- a/models/partner.go +++ b/models/partner.go @@ -129,3 +129,110 @@ type RiderlogDetails struct { Breakhours float32 `json:"breakhours"` Logstatus int `json:"logstatus"` } + +// NewRider is one rider being onboarded, as the console sends it. +// +// A rider is three rows, not one. `app_users` holds the person, `ridersettings` +// the vehicle and licence, and `app_userpools` their place in the availability +// pool — and `getriders` INNER JOINs all three, so a rider missing any of them +// is not a partial rider, they are no rider at all. The flat shape here is +// deliberate: the caller should not have to know the table layout to hire +// somebody. +// +// `Tenantid` is filled in by the controller from the caller's scope, never read +// from the body — a store admin must not be able to put a rider on another +// merchant's books by editing a payload. +type NewRider struct { + Userid int `json:"userid"` + Firstname string `json:"firstname"` + Lastname string `json:"lastname"` + Contactno string `json:"contactno"` + Email string `json:"email"` + Password string `json:"password"` + Address string `json:"address"` + Suburb string `json:"suburb"` + City string `json:"city"` + State string `json:"state"` + Postcode string `json:"postcode"` + + // Whose rider they are, where they ride, and who they ride for. + Tenantid int `json:"tenantid"` + Applocationid int `json:"applocationid"` + Partnerid int `json:"partnerid"` + Shiftid int `json:"shiftid"` + + // The vehicle half — `ridersettings`. + Identificationno string `json:"identificationno"` + Vehiclename string `json:"vehiclename"` + Vehicleno string `json:"vehicleno"` + Licenseno string `json:"licenseno"` + Registrationno string `json:"registrationno"` + + Status string `json:"status"` +} + +// RiderRosterRow is one rider in the directory. +// +// Distinct from RiderInfo, which is the ON-DUTY read: that one requires a +// riderlog stamped today, which is right for an assignment picker and wrong for +// a staff list, where somebody who has not started their shift must still +// appear. This carries the last log rather than requiring one. +type RiderRosterRow struct { + Userid int `json:"userid"` + Firstname string `json:"firstname"` + Lastname string `json:"lastname"` + Fullname string `json:"fullname"` + Contactno string `json:"contactno"` + Email string `json:"email"` + Tenantid int `json:"tenantid"` + Applocationid int `json:"applocationid"` + Applocation string `json:"applocation"` + Partnerid int `json:"partnerid"` + Partnername string `json:"partnername"` + Shiftid int `json:"shiftid"` + Shiftname string `json:"shiftname"` + + Identificationno string `json:"identificationno"` + Vehiclename string `json:"vehiclename"` + Vehicleno string `json:"vehicleno"` + Licenseno string `json:"licenseno"` + Registrationno string `json:"registrationno"` + + // Duty state, as facts rather than as a filter. + Onduty int `json:"onduty"` + Lastlogdate string `json:"lastlogdate"` + // True when there is a log dated today with logstatus 0 — on shift right now. + Isonduty bool `json:"isonduty"` + Status string `json:"status"` +} + +// Ridersettings is the vehicle-and-licence half of a rider. +// +// `riderid` is DELIBERATELY ABSENT. It is a GENERATED ALWAYS identity column, +// and including it makes GORM send a zero, which Postgres rejects outright: +// "cannot insert a non-DEFAULT value into column riderid". Leaving it off the +// struct is what keeps the insert to the columns that are actually ours to set. +type Ridersettings struct { + Userid int `json:"userid"` + Partnerid int `json:"partnerid"` + Shiftid int `json:"shiftid"` + Identificationno string `json:"identificationno"` + Vehiclename string `json:"vehiclename"` + Vehicleno string `json:"vehicleno"` + Licenseno string `json:"licenseno"` + Registrationno string `json:"registrationno"` +} + +// Appuserpools is a rider's place in the availability pool. +// +// `poolid` is absent for the same reason as `riderid` above — same identity +// column, same rejection. +// +// `Onduty` means "may be given work", not "on shift right now". The second +// question is answered by riderlogs, which the rider's own app writes. +type Appuserpools struct { + Userid int `json:"userid"` + Partnerid int `json:"partnerid"` + Onduty int `json:"onduty"` + Status string `json:"status"` +} diff --git a/repositories/partnerRepository.go b/repositories/partnerRepository.go index a118f33..67ad206 100644 --- a/repositories/partnerRepository.go +++ b/repositories/partnerRepository.go @@ -1,9 +1,11 @@ package repositories import ( + "errors" "fmt" "nearle/models" "strconv" + "strings" "gorm.io/gorm" ) @@ -16,6 +18,9 @@ type PartnerRepository interface { GetRiderLogs(pid, aid int, fdate, tdate string) ([]models.RiderlogDetails, error) GetRiderInfo(userid int) (models.RiderInfo, error) GetFleetSummary(aid, tid int, fdate, tdate string) (models.FleetSummary, error) + CreateRider(rider models.NewRider) (int, error) + UpdateRider(rider models.NewRider) error + GetRiderRoster(tid, aid, pid int) ([]models.RiderRosterRow, error) } type partnerRepository struct { @@ -308,3 +313,281 @@ func (r *partnerRepository) GetFleetSummary(aid, tid int, fdate, tdate string) ( return summary, nil } + +// CreateRider hires one rider, in one transaction. +// +// Three tables, all or nothing. Before this existed the only way to make a +// rider was POST /users/create, which writes `app_users` and stops — so it +// answered 201 Created and produced somebody every rider query ignored forever, +// because `getriders` INNER JOINs `ridersettings` and `app_userpools` as well. +// The old console papered over that by generating an SQL script for an operator +// to run by hand; the script named three columns that do not exist, so it never +// worked either. +// +// The guards below all catch the same class of fault: a write that succeeds and +// then cannot be seen. Postgres will not complain about any of them — a +// `shiftid` pointing nowhere is a perfectly good integer — but each one produces +// a rider who is invisible the moment the transaction commits. +func (r *partnerRepository) CreateRider(rider models.NewRider) (int, error) { + if strings.TrimSpace(rider.Firstname) == "" { + return 0, errors.New("the rider needs a name") + } + if strings.TrimSpace(rider.Contactno) == "" { + return 0, errors.New("the rider needs a contact number") + } + if rider.Applocationid == 0 { + return 0, errors.New("the rider needs a delivery region") + } + if rider.Shiftid == 0 { + return 0, errors.New("the rider needs a shift") + } + + // A shift that does not exist takes the rider out of every listing: + // `getriders` joins ridershifts through ridersettings.shiftid. + var shifts int64 + if err := r.db.Table("ridershifts").Where("shiftid = ?", rider.Shiftid).Count(&shifts).Error; err != nil { + return 0, err + } + if shifts == 0 { + return 0, fmt.Errorf("shift %d does not exist", rider.Shiftid) + } + + // Same again for the region, which is joined twice — app_location AND + // app_locationconfig. A region with no config row hides every rider in it. + var configs int64 + if err := r.db.Table("app_locationconfig").Where("applocationid = ?", rider.Applocationid).Count(&configs).Error; err != nil { + return 0, err + } + if configs == 0 { + return 0, fmt.Errorf("delivery region %d is not configured, so a rider added to it would not appear anywhere", rider.Applocationid) + } + + // Riders are looked up by phone more than by anything else, and two accounts + // on one number is how the wrong person gets the job. + var clash int64 + if err := r.db.Table("app_users"). + Where("contactno = ? AND configid = 6", strings.TrimSpace(rider.Contactno)). + Count(&clash).Error; err != nil { + return 0, err + } + if clash > 0 { + return 0, fmt.Errorf("a rider already uses %s", strings.TrimSpace(rider.Contactno)) + } + + status := strings.TrimSpace(rider.Status) + if status == "" { + status = "Active" + } + + tx := r.db.Begin() + if tx.Error != nil { + return 0, tx.Error + } + + // configid 6 is what identifies a rider — there is no Rider row in + // app_roles, and `getriders` keys on the configid rather than on a role. + user := models.User{ + Firstname: strings.TrimSpace(rider.Firstname), + Lastname: strings.TrimSpace(rider.Lastname), + Contactno: strings.TrimSpace(rider.Contactno), + Email: strings.TrimSpace(rider.Email), + Password: rider.Password, + Address: rider.Address, + Suburb: rider.Suburb, + City: rider.City, + State: rider.State, + Postcode: rider.Postcode, + Configid: 6, + Tenantid: rider.Tenantid, + Applocationid: rider.Applocationid, + Partnerid: rider.Partnerid, + Shiftid: rider.Shiftid, + Status: status, + } + + if err := tx.Table("app_users").Create(&user).Error; err != nil { + tx.Rollback() + return 0, err + } + if user.Userid == 0 { + tx.Rollback() + return 0, errors.New("the rider account was written without an id") + } + + settings := models.Ridersettings{ + Userid: user.Userid, + Partnerid: rider.Partnerid, + Shiftid: rider.Shiftid, + Identificationno: rider.Identificationno, + Vehiclename: rider.Vehiclename, + Vehicleno: rider.Vehicleno, + Licenseno: rider.Licenseno, + Registrationno: rider.Registrationno, + } + if err := tx.Table("ridersettings").Create(&settings).Error; err != nil { + tx.Rollback() + return 0, err + } + + // onduty 1 means "available for work", not "on shift now" — that second + // question is answered by riderlogs, which the rider's own app writes when + // they clock on. A rider created here is therefore correctly absent from + // getriders until they start a shift. + pool := models.Appuserpools{ + Userid: user.Userid, + Partnerid: rider.Partnerid, + Onduty: 1, + Status: status, + } + if err := tx.Table("app_userpools").Create(&pool).Error; err != nil { + tx.Rollback() + return 0, err + } + + if err := tx.Commit().Error; err != nil { + return 0, err + } + + return user.Userid, nil +} + +// GetRiderRoster lists every rider, on duty or not. +// +// Distinct from GetActiveRiders, and the difference is the whole point. That +// one INNER JOINs a riderlog dated today with logstatus 0 — it answers "who can +// I give this delivery to right now", which is correct for an assignment picker +// and useless for a staff directory: somebody hired this morning, or simply not +// working today, is absent from it. A directory that hides the person you just +// created reads as a failed save. +// +// So every join here is LEFT except the rider's own settings, and the duty +// state is returned as facts — `onduty`, the last log date, and whether that +// log is today — rather than used as a filter. +func (r *partnerRepository) GetRiderRoster(tid, aid, pid int) ([]models.RiderRosterRow, error) { + var data []models.RiderRosterRow + + q1 := `SELECT a.userid, a.firstname, a.lastname, + CONCAT(a.firstname, ' ', a.lastname) AS fullname, + a.contactno, a.email, a.tenantid, a.applocationid, a.partnerid, a.status, + f.locationname AS applocation, + p.partnername, + c.identificationno, c.vehiclename, c.vehicleno, c.licenseno, c.registrationno, + c.shiftid, CONCAT(d.starttime, ' - ', d.endtime) AS shiftname, + COALESCE(b.onduty, 0) AS onduty, + e.logdate AS lastlogdate, + COALESCE(e.logdate::date = CURRENT_DATE AND e.logstatus = 0, false) AS isonduty + FROM app_users a + INNER JOIN ridersettings c ON a.userid = c.userid + LEFT JOIN app_userpools b ON a.userid = b.userid + LEFT JOIN ridershifts d ON c.shiftid = d.shiftid + LEFT JOIN app_location f ON a.applocationid = f.applocationid + LEFT JOIN partnerinfo p ON a.partnerid = p.partnerid + LEFT JOIN ( + SELECT r1.userid, r1.logdate, r1.logstatus + FROM riderlogs r1 + INNER JOIN ( + SELECT userid, MAX(logdate) AS max_logdate FROM riderlogs GROUP BY userid + ) r2 ON r1.userid = r2.userid AND r1.logdate = r2.max_logdate + ) e ON a.userid = e.userid + WHERE a.configid = 6` + + var args []interface{} + // Scoped by whichever id the caller has. Tenant first: it is the narrowest, + // and it is the scope a merchant's own directory wants. + if tid != 0 { + q1 += ` AND a.tenantid = ?` + args = append(args, tid) + } else if aid != 0 { + q1 += ` AND a.applocationid = ?` + args = append(args, aid) + } else if pid != 0 { + q1 += ` AND a.partnerid = ?` + args = append(args, pid) + } + + q1 += ` ORDER BY a.firstname, a.lastname` + + if err := r.db.Raw(q1, args...).Scan(&data).Error; err != nil { + return nil, err + } + + return data, nil +} + +// UpdateRider edits a rider across both of their tables. +// +// Only the fields a person can change from the console: their contact details, +// their vehicle and licence, their shift, and their status. The identity +// columns and the tenant are not editable — moving a rider between merchants is +// not an edit, and doing it silently through a profile form is how a rider ends +// up on somebody else's books. +func (r *partnerRepository) UpdateRider(rider models.NewRider) error { + if rider.Userid == 0 { + return errors.New("userid is required") + } + + if rider.Shiftid != 0 { + var shifts int64 + if err := r.db.Table("ridershifts").Where("shiftid = ?", rider.Shiftid).Count(&shifts).Error; err != nil { + return err + } + if shifts == 0 { + return fmt.Errorf("shift %d does not exist", rider.Shiftid) + } + } + + tx := r.db.Begin() + if tx.Error != nil { + return tx.Error + } + + // Built as a map rather than a struct: Updates() with a struct skips every + // zero value, so clearing a licence number or blanking an email would + // silently do nothing. A map says exactly what to write. + user := map[string]interface{}{ + "firstname": strings.TrimSpace(rider.Firstname), + "lastname": strings.TrimSpace(rider.Lastname), + "contactno": strings.TrimSpace(rider.Contactno), + "email": strings.TrimSpace(rider.Email), + "address": rider.Address, + "suburb": rider.Suburb, + "city": rider.City, + "state": rider.State, + "postcode": rider.Postcode, + } + if rider.Shiftid != 0 { + user["shiftid"] = rider.Shiftid + } + if strings.TrimSpace(rider.Status) != "" { + user["status"] = strings.TrimSpace(rider.Status) + } + + res := tx.Table("app_users").Where("userid = ? AND configid = 6", rider.Userid).Updates(user) + if res.Error != nil { + tx.Rollback() + return res.Error + } + // An UPDATE matching no rows is not an SQL error, so editing a userid that + // is not a rider would report success and change nothing. + if res.RowsAffected == 0 { + tx.Rollback() + return fmt.Errorf("rider %d not found", rider.Userid) + } + + settings := map[string]interface{}{ + "identificationno": rider.Identificationno, + "vehiclename": rider.Vehiclename, + "vehicleno": rider.Vehicleno, + "licenseno": rider.Licenseno, + "registrationno": rider.Registrationno, + } + if rider.Shiftid != 0 { + settings["shiftid"] = rider.Shiftid + } + if err := tx.Table("ridersettings").Where("userid = ?", rider.Userid).Updates(settings).Error; err != nil { + tx.Rollback() + return err + } + + return tx.Commit().Error +} diff --git a/routes/partnerroutes.go b/routes/partnerroutes.go index cfb9c6e..be74ff2 100644 --- a/routes/partnerroutes.go +++ b/routes/partnerroutes.go @@ -17,6 +17,19 @@ func RegisterPartnerRoutes(api fiber.Router, f *facade.Facade) { partner.Get("/getriderlogs", f.PartnerController.GetRiderLogs) partner.Get("/getfleetsummary", f.PartnerController.GetFleetSummary) + // Hiring a rider, and the directory that shows them before they clock on. + // + // Until these existed the only way to create a rider was POST /users/create, + // which writes app_users and stops — it answered 201 and produced somebody + // every rider query ignored, because getriders joins ridersettings and + // app_userpools too. + // + // Web group only. A rider is onboarded from the merchant's console, not from + // a phone, so the mobile group below stays read-only as it was. + partner.Post("/createrider", f.PartnerController.CreateRider) + partner.Put("/updaterider", f.PartnerController.UpdateRider) + partner.Get("/getriderroster", f.PartnerController.GetRiderRoster) + partner = api.Group("/v1/mob/partners") partner.Get("/getpartners", f.PartnerController.GetPartners) diff --git a/services/partnerService.go b/services/partnerService.go index da7c4f2..3a40636 100644 --- a/services/partnerService.go +++ b/services/partnerService.go @@ -13,6 +13,9 @@ type PartnerService interface { GetRiderLogs(pid, aid int, fdate, tdate string) ([]models.RiderlogDetails, error) GetRiderInfo(userid int) (models.RiderInfo, error) GetFleetSummary(aid, tid int, fdate, tdate string) (models.FleetSummary, error) + CreateRider(rider models.NewRider) (int, error) + UpdateRider(rider models.NewRider) error + GetRiderRoster(tid, aid, pid int) ([]models.RiderRosterRow, error) } type partnerService struct { @@ -58,3 +61,15 @@ func (s *partnerService) GetRiderInfo(userid int) (models.RiderInfo, error) { func (s *partnerService) GetFleetSummary(aid, tid int, fdate, tdate string) (models.FleetSummary, error) { return s.repo.GetFleetSummary(aid, tid, fdate, tdate) } + +func (s *partnerService) CreateRider(rider models.NewRider) (int, error) { + return s.repo.CreateRider(rider) +} + +func (s *partnerService) UpdateRider(rider models.NewRider) error { + return s.repo.UpdateRider(rider) +} + +func (s *partnerService) GetRiderRoster(tid, aid, pid int) ([]models.RiderRosterRow, error) { + return s.repo.GetRiderRoster(tid, aid, pid) +}