rider creation

This commit is contained in:
2026-09-04 16:02:47 +05:30
parent e51ad615ed
commit 593b11f1b8
5 changed files with 535 additions and 0 deletions

View File

@@ -1,6 +1,7 @@
package controllers package controllers
import ( import (
"nearle/models"
"nearle/services" "nearle/services"
"net/http" "net/http"
"strconv" "strconv"
@@ -193,3 +194,119 @@ func (ctl *PartnerController) GetRiderInfo(c *fiber.Ctx) error {
"details": result, "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,
})
}

View File

@@ -129,3 +129,110 @@ type RiderlogDetails struct {
Breakhours float32 `json:"breakhours"` Breakhours float32 `json:"breakhours"`
Logstatus int `json:"logstatus"` 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"`
}

View File

@@ -1,9 +1,11 @@
package repositories package repositories
import ( import (
"errors"
"fmt" "fmt"
"nearle/models" "nearle/models"
"strconv" "strconv"
"strings"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -16,6 +18,9 @@ type PartnerRepository interface {
GetRiderLogs(pid, aid int, fdate, tdate string) ([]models.RiderlogDetails, error) GetRiderLogs(pid, aid int, fdate, tdate string) ([]models.RiderlogDetails, error)
GetRiderInfo(userid int) (models.RiderInfo, error) GetRiderInfo(userid int) (models.RiderInfo, error)
GetFleetSummary(aid, tid int, fdate, tdate string) (models.FleetSummary, 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 { type partnerRepository struct {
@@ -308,3 +313,281 @@ func (r *partnerRepository) GetFleetSummary(aid, tid int, fdate, tdate string) (
return summary, nil 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
}

View File

@@ -17,6 +17,19 @@ func RegisterPartnerRoutes(api fiber.Router, f *facade.Facade) {
partner.Get("/getriderlogs", f.PartnerController.GetRiderLogs) partner.Get("/getriderlogs", f.PartnerController.GetRiderLogs)
partner.Get("/getfleetsummary", f.PartnerController.GetFleetSummary) 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 = api.Group("/v1/mob/partners")
partner.Get("/getpartners", f.PartnerController.GetPartners) partner.Get("/getpartners", f.PartnerController.GetPartners)

View File

@@ -13,6 +13,9 @@ type PartnerService interface {
GetRiderLogs(pid, aid int, fdate, tdate string) ([]models.RiderlogDetails, error) GetRiderLogs(pid, aid int, fdate, tdate string) ([]models.RiderlogDetails, error)
GetRiderInfo(userid int) (models.RiderInfo, error) GetRiderInfo(userid int) (models.RiderInfo, error)
GetFleetSummary(aid, tid int, fdate, tdate string) (models.FleetSummary, 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 { 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) { func (s *partnerService) GetFleetSummary(aid, tid int, fdate, tdate string) (models.FleetSummary, error) {
return s.repo.GetFleetSummary(aid, tid, fdate, tdate) 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)
}