rider partner

This commit is contained in:
2026-09-09 15:44:27 +05:30
parent 62a441d798
commit e7577fe0cf
11 changed files with 812 additions and 3 deletions

View File

@@ -1,6 +1,7 @@
package controllers
import (
"errors"
"nearle/models"
"nearle/services"
"net/http"
@@ -218,11 +219,35 @@ func (ctl *PartnerController) CreateRider(c *fiber.Ctx) error {
if tid, _ := strconv.Atoi(c.Query("tenantid")); tid != 0 {
rider.Tenantid = tid
}
if rider.Tenantid == 0 {
if pid, _ := strconv.Atoi(c.Query("partnerid")); pid != 0 {
rider.Partnerid = pid
}
// A rider belongs to a merchant OR to a delivery partner — never to
// neither, and never to both.
//
// This used to demand a tenantid outright, which made a PARTNER's rider
// impossible to create: a partner supplies riders to many merchants and
// their riders sit under no single one. It is the same endpoint because it
// is the same rider — the difference is only who they ride for, and that is
// what the assign screen later branches on.
//
// Both at once is refused rather than silently preferred. A rider carrying
// a tenantid AND a partnerid appears in two directories and two assign
// pickers, and nothing downstream says which one owns them.
switch {
case rider.Tenantid == 0 && rider.Partnerid == 0:
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"status": false,
"message": "a rider needs either a tenantid (the shop's own rider) " +
"or a partnerid (a delivery partner's rider)",
})
case rider.Tenantid != 0 && rider.Partnerid != 0:
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"status": false,
"message": "tenantid is required",
"message": "a rider belongs to a shop or to a partner, not to both",
})
}
@@ -310,3 +335,97 @@ func (ctl *PartnerController) GetRiderRoster(c *fiber.Ctx) error {
"details": result,
})
}
/* ── Onboarding a delivery partner ────────────────────────────────────────────
Platform-side only. A merchant does not create the company that supplies their
riders — they are assigned one, which is `AssignPartner` on the tenant. Both of
these live in the web group for the same reason `createrider` does: a partner is
onboarded from a console, not from a phone. */
// CreatePartner onboards a delivery partner and the regions they cover.
func (ctl *PartnerController) CreatePartner(c *fiber.Ctx) error {
var input models.NewPartner
if err := c.BodyParser(&input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
})
}
partnerid, err := ctl.partnerService.CreatePartner(input)
if err != nil {
// 400, not 500: every failure this produces is something the caller
// sent — a missing name, an unconfigured region, a number already in
// use. A 500 sends them to look at the server for 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, "message": "Successfully Created",
"details": fiber.Map{"partnerid": partnerid},
})
}
// UpdatePartner edits a partner and, when regions are sent, re-states them.
func (ctl *PartnerController) UpdatePartner(c *fiber.Ctx) error {
var input models.NewPartner
if err := c.BodyParser(&input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
})
}
if pid, _ := strconv.Atoi(c.Query("partnerid")); pid != 0 {
input.Partnerid = pid
}
if err := ctl.partnerService.UpdatePartner(input); 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": "Successfully Updated",
})
}
// GetPartnerLocations lists the regions one partner covers.
func (ctl *PartnerController) GetPartnerLocations(c *fiber.Ctx) error {
pid, _ := strconv.Atoi(c.Query("partnerid"))
if pid == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": "partnerid is required",
})
}
rows, err := ctl.partnerService.GetPartnerLocations(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": "Success", "details": rows,
})
}
// riderOwner enforces that a rider belongs to a shop or to a partner.
//
// Its own function so the rule can be tested without a request, and so both
// call sites — create today, anything that moves a rider tomorrow — cannot
// drift apart on it.
func riderOwner(tenantid, partnerid int) error {
switch {
case tenantid == 0 && partnerid == 0:
return errors.New("a rider needs either a tenantid (the shop's own rider) " +
"or a partnerid (a delivery partner's rider)")
case tenantid != 0 && partnerid != 0:
return errors.New("a rider belongs to a shop or to a partner, not to both")
}
return nil
}

View File

@@ -0,0 +1,41 @@
package controllers
import "testing"
/*
A rider belongs to a shop or to a delivery partner — never to neither, never to
both.
`CreateRider` used to demand a tenantid outright, which made a partner's rider
impossible to create: a partner supplies riders to many merchants and their
riders sit under no single one. The two ids are now exclusive, and both mistakes
are refused rather than one being silently preferred — a rider carrying both
appears in two directories and two assign pickers, with nothing downstream
saying which owns them.
`riderOwner` is the rule on its own so it can be checked without a request.
*/
func TestRiderOwnerAcceptsAShopsOwnRider(t *testing.T) {
if err := riderOwner(1147, 0); err != nil {
t.Fatalf("a tenant's own rider is valid: %v", err)
}
}
func TestRiderOwnerAcceptsAPartnersRider(t *testing.T) {
if err := riderOwner(0, 44); err != nil {
t.Fatalf("a partner's rider is valid: %v", err)
}
}
func TestRiderOwnerRefusesNeither(t *testing.T) {
if err := riderOwner(0, 0); err == nil {
t.Fatal("a rider owned by nobody appears in no directory and must be refused")
}
}
func TestRiderOwnerRefusesBoth(t *testing.T) {
if err := riderOwner(1147, 44); err == nil {
t.Fatal("a rider owned by both appears in two pickers and must be refused")
}
}

View File

@@ -710,3 +710,50 @@ func (ctl *TenantController) UpdateOwnProfile(c *fiber.Ctx) error {
}
return c.JSON(fiber.Map{"code": 200, "status": true, "message": "Success"})
}
// AssignPartner puts a merchant under a delivery partner, or takes them out of
// one.
//
// `partnerid` 0 is a real instruction here — it means "this merchant uses their
// own riders" — so it is read as sent rather than treated as absent. Everywhere
// else in the tenant API a zero means "not supplied"; this is the exception and
// it is the reason the route exists separately.
func (ctl *TenantController) AssignPartner(c *fiber.Ctx) error {
var body struct {
Tenantid int `json:"tenantid"`
Partnerid *int `json:"partnerid"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
})
}
if tid, _ := strconv.Atoi(c.Query("tenantid")); tid != 0 {
body.Tenantid = tid
}
if body.Tenantid <= 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": "tenantid is required",
})
}
// A pointer, so "no partner" and "field omitted" are different requests.
// Sent as a plain int, an omitted field would read as 0 and quietly unassign
// a merchant's partner.
if body.Partnerid == nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "partnerid is required — send 0 to take the partner away",
})
}
if err := ctl.tenantService.AssignPartner(body.Tenantid, *body.Partnerid); 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": "Successfully Updated",
})
}

View File

@@ -61,6 +61,15 @@ type Partnerinfo struct {
Postcode string `json:"postcode"`
Partnerinfo string `json:"partnerinfo"`
Partnerimage string `json:"partnerimage"`
// Read back so a directory can show who the partner is and whether they
// are trading. Both columns have always been on the table; the struct had
// no field for them, so `getpartners` could not report either.
Companyname string `json:"companyname"`
Status string `json:"status"`
// Every region this partner covers, from `partnerlocations`. A partner is
// not confined to the one city on their own row — that column records where
// they were set up, and the link table records where they actually work.
Locations []PartnerLocation `json:"locations" gorm:"-"`
}
type Ridershifts struct {
@@ -156,7 +165,16 @@ type NewRider struct {
Postcode string `json:"postcode"`
// Whose rider they are, where they ride, and who they ride for.
//
// Tenantid and Partnerid are exclusive: a rider belongs to a shop or to a
// delivery partner, never to both and never to neither. The controller
// refuses either mistake.
//
// Locationid is the branch an OWN rider works out of. It is meaningless for
// a partner's rider — a partner supplies several merchants and is not tied
// to any one shop's outlet — so it is only ever set alongside a tenantid.
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
Applocationid int `json:"applocationid"`
Partnerid int `json:"partnerid"`
Shiftid int `json:"shiftid"`
@@ -185,6 +203,8 @@ type RiderRosterRow struct {
Contactno string `json:"contactno"`
Email string `json:"email"`
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
Locationname string `json:"locationname"`
Applocationid int `json:"applocationid"`
Applocation string `json:"applocation"`
Partnerid int `json:"partnerid"`
@@ -236,3 +256,61 @@ type Appuserpools struct {
Onduty int `json:"onduty"`
Status string `json:"status"`
}
// PartnerLocation is one region a partner covers — a row of `partnerlocations`.
type PartnerLocation struct {
Partnerlocationid int `json:"partnerlocationid" gorm:"column:partnerlocationid"`
Partnerid int `json:"partnerid" gorm:"column:partnerid"`
Applocationid int `json:"applocationid" gorm:"column:applocationid"`
Applocation string `json:"applocation" gorm:"column:applocation"`
}
// NewPartner is everything the console collects to onboard a delivery partner.
//
// Separate from `Partnerinfo`, which is the READ shape. Binding a create to the
// read model is how a caller ends up able to set columns nobody meant to expose
// — `allocationid`, `partnertypeid`, the billing links — because a form happened
// to send them.
//
// A partner is a company that supplies riders. It is onboarded by the platform,
// never by a merchant: the merchant's side of this is being ASSIGNED one, which
// is `AssignPartner` on the tenant.
type NewPartner struct {
Partnerid int `json:"partnerid"`
Partnername string `json:"partnername"`
Companyname string `json:"companyname"`
Registrationno string `json:"registrationno"`
Primarycontact string `json:"primarycontact"`
Primaryemail string `json:"primaryemail"`
Contactno string `json:"contactno"`
Address string `json:"address"`
Suburb string `json:"suburb"`
City string `json:"city"`
State string `json:"state"`
Postcode int `json:"postcode"`
Partnerinfo string `json:"partnerinfo"`
Partnerimage string `json:"partnerimage"`
Status string `json:"status"`
/*
Where they work — ONE district, not a set.
`Applocationid` is the home region and goes on the partner row itself,
because `GetPartners` filters on it and the rider app reads it.
`Applocationids` is every region they cover and goes to
`partnerlocations` — one partner routinely serves several cities, and
that is the whole reason the link table exists.
*/
Applocationid int `json:"applocationid"`
/*
The district by NAME, for one that is not open yet.
Nearle runs three of Tamil Nadu's thirty-eight districts, and a partner
could only be placed in those three because `partnerinfo.applocationid`
has to point at an `app_location` row. Sending the name instead opens the
district — see `EnsureRegion` — so the form can offer all 38 and mean it.
Ignored when `Applocationid` is set, which is the ordinary case.
*/
District string `json:"district"`
}

View File

@@ -0,0 +1,41 @@
package repositories
import (
"testing"
"nearle/models"
)
/*
One partner, one district.
The form used to ask for a home region AND a coverage set, which put the same
partner in `partnerinfo.applocationid` and several `partnerlocations` rows. A
partner works one district — that is the operating rule — so the two writes
carry the same single id and `regionsOf` is what guarantees they cannot
disagree.
*/
func TestRegionsOfIsTheOneDistrict(t *testing.T) {
got := regionsOf(models.NewPartner{Applocationid: 2})
if len(got) != 1 || got[0] != 2 {
t.Fatalf("got %v, want [2]", got)
}
}
// Zero is not a district. It arrives from a form field nobody filled in, and a
// partnerlocations row pointing at region 0 would join to nothing for ever.
func TestRegionsOfIgnoresZero(t *testing.T) {
if got := regionsOf(models.NewPartner{Applocationid: 0}); len(got) != 0 {
t.Fatalf("got %v, want none", got)
}
}
// A district sent only by NAME has not been resolved yet — CreatePartner opens
// it and sets the id before this is reached. Until then there is no region, and
// saying so beats guessing one.
func TestRegionsOfDoesNotInventOneFromAName(t *testing.T) {
if got := regionsOf(models.NewPartner{District: "Erode"}); len(got) != 0 {
t.Fatalf("got %v, want none until the district is opened", got)
}
}

View File

@@ -8,6 +8,7 @@ import (
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type PartnerRepository interface {
@@ -21,6 +22,10 @@ type PartnerRepository interface {
CreateRider(rider models.NewRider) (int, error)
UpdateRider(rider models.NewRider) error
GetRiderRoster(tid, aid, pid int) ([]models.RiderRosterRow, error)
CreatePartner(input models.NewPartner) (int, error)
UpdatePartner(input models.NewPartner) error
GetPartnerLocations(partnerid int) ([]models.PartnerLocation, error)
EnsureRegion(district string) (int, error)
}
type partnerRepository struct {
@@ -399,6 +404,7 @@ func (r *partnerRepository) CreateRider(rider models.NewRider) (int, error) {
Postcode: rider.Postcode,
Configid: 6,
Tenantid: rider.Tenantid,
Locationid: rider.Locationid,
Applocationid: rider.Applocationid,
Partnerid: rider.Partnerid,
Shiftid: rider.Shiftid,
@@ -468,8 +474,9 @@ func (r *partnerRepository) GetRiderRoster(tid, aid, pid int) ([]models.RiderRos
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,
a.contactno, a.email, a.tenantid, a.locationid, a.applocationid, a.partnerid, a.status,
f.locationname AS applocation,
COALESCE(g.locationname, '') AS locationname,
p.partnername,
c.identificationno, c.vehiclename, c.vehicleno, c.licenseno, c.registrationno,
c.shiftid, CONCAT(d.starttime, ' - ', d.endtime) AS shiftname,
@@ -482,6 +489,7 @@ func (r *partnerRepository) GetRiderRoster(tid, aid, pid int) ([]models.RiderRos
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 tenantlocations g ON a.locationid = g.locationid
LEFT JOIN (
SELECT r1.userid, r1.logdate, r1.logstatus
FROM riderlogs r1
@@ -558,6 +566,12 @@ func (r *partnerRepository) UpdateRider(rider models.NewRider) error {
if rider.Shiftid != 0 {
user["shiftid"] = rider.Shiftid
}
// The branch an own rider works out of. Moving them between a merchant's
// outlets is an ordinary edit; moving them between MERCHANTS is not, which
// is why tenantid and partnerid stay out of this map.
if rider.Locationid != 0 {
user["locationid"] = rider.Locationid
}
if strings.TrimSpace(rider.Status) != "" {
user["status"] = strings.TrimSpace(rider.Status)
}
@@ -591,3 +605,384 @@ func (r *partnerRepository) UpdateRider(rider models.NewRider) error {
return tx.Commit().Error
}
/* ── Onboarding a delivery partner ────────────────────────────────────────────
A partner is a company that supplies riders, and until now the platform could
only READ them: `getpartners` has always existed and nothing could create one.
The five partners live today were inserted by hand, which is also why two of
them are named "Test".
Where a partner works is recorded twice, on purpose and not by accident:
partnerinfo.applocationid their home region — `GetPartners` filters on it
and the rider app reads it
partnerlocations every region they cover
Both are kept in step here. Writing only the first would confine a partner to
one city, and writing only the second would hide them from every existing
query. */
// CreatePartner onboards a delivery partner and records the regions they cover.
func (r *partnerRepository) CreatePartner(input models.NewPartner) (int, error) {
if strings.TrimSpace(input.Partnername) == "" {
return 0, errors.New("the partner needs a name")
}
if strings.TrimSpace(input.Primarycontact) == "" {
return 0, errors.New("the partner needs a contact number")
}
// A district that is not open yet is opened here rather than refused — the
// form offers all 38 and this is what makes that true. Resolved before the
// checks below, so everything after works on a region that exists.
if input.Applocationid == 0 && strings.TrimSpace(input.District) != "" {
opened, err := r.EnsureRegion(input.District)
if err != nil {
return 0, err
}
input.Applocationid = opened
}
if input.Applocationid == 0 {
return 0, errors.New("the partner needs a district to work in")
}
// A region with no config row hides every rider placed in it — the same
// guard `CreateRider` applies, for the same reason.
regions := regionsOf(input)
for _, aid := range regions {
var configs int64
if err := r.db.Table("app_locationconfig").Where("applocationid = ?", aid).Count(&configs).Error; err != nil {
return 0, err
}
if configs == 0 {
return 0, fmt.Errorf("region %d is not configured, so riders placed in it would not appear anywhere", aid)
}
}
// Two partners on one number is how the wrong company gets the work.
var clash int64
if err := r.db.Table("partnerinfo").
Where("primarycontact = ?", strings.TrimSpace(input.Primarycontact)).
Count(&clash).Error; err != nil {
return 0, err
}
if clash > 0 {
return 0, fmt.Errorf("a partner already uses %s", strings.TrimSpace(input.Primarycontact))
}
status := strings.TrimSpace(input.Status)
if status == "" {
status = "Active"
}
tx := r.db.Begin()
if tx.Error != nil {
return 0, tx.Error
}
row := map[string]any{
"partnername": strings.TrimSpace(input.Partnername),
"companyname": strings.TrimSpace(input.Companyname),
"registrationno": strings.TrimSpace(input.Registrationno),
"primarycontact": strings.TrimSpace(input.Primarycontact),
"primaryemail": strings.TrimSpace(input.Primaryemail),
"contactno": strings.TrimSpace(input.Contactno),
"address": input.Address,
"suburb": input.Suburb,
"city": input.City,
"state": input.State,
"postcode": input.Postcode,
"partnerinfo": input.Partnerinfo,
"partnerimage": input.Partnerimage,
"applocationid": regions[0],
"status": status,
}
var partnerid int
if err := tx.Table("partnerinfo").
Clauses(clause.Returning{Columns: []clause.Column{{Name: "partnerid"}}}).
Create(&row).Error; err != nil {
tx.Rollback()
return 0, err
}
if id, ok := row["partnerid"]; ok {
partnerid = toInt(id)
}
if partnerid == 0 {
tx.Rollback()
return 0, errors.New("the partner was written without an id")
}
if err := replaceLocations(tx, partnerid, regions, input); err != nil {
tx.Rollback()
return 0, err
}
if err := tx.Commit().Error; err != nil {
return 0, err
}
return partnerid, nil
}
// UpdatePartner edits a partner and, when regions are supplied, re-states them.
//
// Regions are replaced rather than merged: the console sends the whole set it
// is showing, and a merge would make removing a region impossible. Sending none
// leaves them alone, so an edit that only changes a phone number cannot empty
// the list by omission.
func (r *partnerRepository) UpdatePartner(input models.NewPartner) error {
if input.Partnerid == 0 {
return errors.New("partnerid is required")
}
if input.Applocationid == 0 && strings.TrimSpace(input.District) != "" {
opened, err := r.EnsureRegion(input.District)
if err != nil {
return err
}
input.Applocationid = opened
}
fields := map[string]any{}
set := func(key, value string) {
if strings.TrimSpace(value) != "" {
fields[key] = strings.TrimSpace(value)
}
}
set("partnername", input.Partnername)
set("companyname", input.Companyname)
set("registrationno", input.Registrationno)
set("primarycontact", input.Primarycontact)
set("primaryemail", input.Primaryemail)
set("contactno", input.Contactno)
set("address", input.Address)
set("suburb", input.Suburb)
set("city", input.City)
set("state", input.State)
set("partnerinfo", input.Partnerinfo)
set("partnerimage", input.Partnerimage)
set("status", input.Status)
if input.Postcode > 0 {
fields["postcode"] = input.Postcode
}
if input.Applocationid > 0 {
fields["applocationid"] = input.Applocationid
}
regions := regionsOf(input)
if len(fields) == 0 && len(regions) == 0 {
return errors.New("nothing to update")
}
tx := r.db.Begin()
if tx.Error != nil {
return tx.Error
}
if len(fields) > 0 {
fields["updated"] = gorm.Expr("NOW()")
res := tx.Table("partnerinfo").Where("partnerid = ?", input.Partnerid).Updates(fields)
if res.Error != nil {
tx.Rollback()
return res.Error
}
if res.RowsAffected == 0 {
tx.Rollback()
return fmt.Errorf("no partner with partnerid %d", input.Partnerid)
}
}
if len(regions) > 0 {
if err := replaceLocations(tx, input.Partnerid, regions, input); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit().Error
}
// GetPartnerLocations lists the regions a partner covers, named.
func (r *partnerRepository) GetPartnerLocations(partnerid int) ([]models.PartnerLocation, error) {
var data []models.PartnerLocation
err := r.db.Raw(`
SELECT a.partnerlocationid, a.partnerid, a.applocationid,
COALESCE(b.locationname, '') AS applocation
FROM partnerlocations a
LEFT JOIN app_location b ON a.applocationid = b.applocationid
WHERE a.partnerid = ?
ORDER BY b.locationname`, partnerid).Scan(&data).Error
return data, err
}
/* ── Helpers ─────────────────────────────────────────────────────────────── */
// regionsOf is every region the partner covers, home region first and no
// duplicates. One list, so the row and the link table cannot disagree.
func regionsOf(input models.NewPartner) []int {
if input.Applocationid > 0 {
return []int{input.Applocationid}
}
return []int{}
}
// replaceLocations re-states a partner's regions inside the caller's tx.
func replaceLocations(tx *gorm.DB, partnerid int, regions []int, input models.NewPartner) error {
if len(regions) == 0 {
return nil
}
if err := tx.Table("partnerlocations").Where("partnerid = ?", partnerid).Delete(nil).Error; err != nil {
return err
}
rows := make([]map[string]any, 0, len(regions))
for _, aid := range regions {
rows = append(rows, map[string]any{
"partnerid": partnerid,
"applocationid": aid,
"address": input.Address,
"suburb": input.Suburb,
"city": input.City,
"state": input.State,
"postcode": strconv.Itoa(input.Postcode),
})
}
return tx.Table("partnerlocations").Create(&rows).Error
}
// toInt reads the id a RETURNING clause handed back, whatever numeric type the
// driver chose for it.
func toInt(value any) int {
switch n := value.(type) {
case int:
return n
case int32:
return int(n)
case int64:
return int(n)
case float64:
return int(n)
}
return 0
}
/*
Opening a district.
`app_location` is not a geography table — it is the list of places Nearle
actually runs, each with a radius, opening hours and an image. Three rows exist.
That is why a partner could only be placed in three of Tamil Nadu's thirty-eight
districts: `partnerinfo.applocationid` has to point at one of these rows, every
rider query joins through it, and `CreateRider` refuses a region with no
`app_locationconfig`.
So onboarding a partner in a new district OPENS the district: it writes both
rows, copying the operating defaults from a region already running rather than
inventing them. The alternative was a form that lists 38 districts and accepts
3, which is a form that lies.
── Why the id is computed rather than defaulted ────────────────────────────
`app_location.applocationid` is a plain bigint: no identity, no default, no
sequence — the same shape as `productcategories.categoryid`. Every insert has to
supply one, so two people onboarding partners at the same moment would both read
the same MAX and write the same id. The advisory lock serialises that, and the
name lookup inside it makes a repeat call return the existing row instead of a
second Erode.
*/
// EnsureRegion returns the applocationid for a district, opening it if needed.
func (r *partnerRepository) EnsureRegion(district string) (int, error) {
name := strings.TrimSpace(district)
if name == "" {
return 0, errors.New("a district name is required")
}
if id := r.regionByName(r.db, name); id > 0 {
return id, nil
}
tx := r.db.Begin()
if tx.Error != nil {
return 0, tx.Error
}
// One writer at a time. The key is arbitrary and constant — it names this
// operation, not a row.
if err := tx.Exec(`SELECT pg_advisory_xact_lock(?)`, 8412771).Error; err != nil {
tx.Rollback()
return 0, err
}
// Checked again INSIDE the lock: the request that was waiting for it may
// have been opening the same district.
if id := r.regionByName(tx, name); id > 0 {
tx.Rollback()
return id, nil
}
var nextID int
if err := tx.Raw(`SELECT COALESCE(MAX(applocationid), 0) + 1 FROM app_location`).
Scan(&nextID).Error; err != nil {
tx.Rollback()
return 0, err
}
// Operating defaults copied from a region already running, so a new
// district behaves like the ones that work rather than like a blank row.
var template struct {
Countryid int
Radius int
Opentime string
Closetime string
}
if err := tx.Raw(`
SELECT COALESCE(countryid, 0) AS countryid, COALESCE(radius, 18) AS radius,
COALESCE(opentime, '08:00:00') AS opentime,
COALESCE(closetime, '23:59:00') AS closetime
FROM app_location WHERE status = 'Active' ORDER BY applocationid LIMIT 1`).
Scan(&template).Error; err != nil {
tx.Rollback()
return 0, err
}
if template.Radius == 0 {
template.Radius = 18
}
if err := tx.Exec(`
INSERT INTO app_location
(applocationid, countryid, locationname, city, state, radius,
deliveryradius, opentime, closetime, status)
VALUES (?, ?, ?, ?, 'Tamil Nadu', ?, ?, ?, ?, 'Active')`,
nextID, template.Countryid, name, name, template.Radius, template.Radius,
template.Opentime, template.Closetime).Error; err != nil {
tx.Rollback()
return 0, err
}
// Without this row every rider in the district is invisible — `getriders`
// joins it, and `CreateRider` refuses a region that lacks it. Opening a
// district means both rows or neither.
var nextConfig int
if err := tx.Raw(`SELECT COALESCE(MAX(applocationconfigid), 0) + 1 FROM app_locationconfig`).
Scan(&nextConfig).Error; err != nil {
tx.Rollback()
return 0, err
}
if err := tx.Exec(`
INSERT INTO app_locationconfig (applocationconfigid, applocationid, configid, status)
VALUES (?, ?, 1, 'Active')`, nextConfig, nextID).Error; err != nil {
tx.Rollback()
return 0, err
}
if err := tx.Commit().Error; err != nil {
return 0, err
}
return nextID, nil
}
// regionByName finds a district by name, case-insensitively and space-tolerant.
func (r *partnerRepository) regionByName(db *gorm.DB, name string) int {
var id int
db.Raw(`SELECT applocationid FROM app_location
WHERE LOWER(TRIM(locationname)) = LOWER(TRIM(?)) LIMIT 1`, name).Scan(&id)
return id
}

View File

@@ -34,6 +34,7 @@ type TenantRepository interface {
CreateTenantUser(data models.Tenants) (bool, error)
GetUserByNo(cno string) models.UserInfo
GetTenantByID(tid int, locationid int, userid int) (models.Tenantinfo, error)
AssignPartner(tenantID, partnerID int) error
GetTenantByKeyword(keyword string) ([]models.TenantSearch, error)
}
@@ -956,3 +957,43 @@ func (r *tenantRepository) UpdateOwnProfile(userID, tenantID int, fields map[str
}
return nil
}
// AssignPartner sets which delivery partner supplies a merchant's riders.
//
// A route of its own rather than a field on `updatetenant`, and that is the
// whole point. `partnerid` is deliberately absent from `editableTenantFields`:
// a merchant who could set it would move themselves under another partner's
// billing and pick up their riders. This is the platform's decision, so it gets
// the platform's endpoint.
//
// `partnerid` 0 is meaningful and allowed — it takes the partner away, which a
// merchant switching to their own riders needs. So the value is written rather
// than skipped when zero, unlike everywhere else in this file.
func (r *tenantRepository) AssignPartner(tenantID, partnerID int) error {
if tenantID <= 0 {
return errors.New("tenantid is required")
}
// A partner that does not exist would silently orphan every rider lookup
// the merchant makes afterwards — `getriders?partnerid=` would answer 200
// with nothing, which reads as "no riders on duty".
if partnerID > 0 {
var found int64
if err := r.db.Table("partnerinfo").Where("partnerid = ?", partnerID).Count(&found).Error; err != nil {
return err
}
if found == 0 {
return fmt.Errorf("no delivery partner with partnerid %d", partnerID)
}
}
res := r.db.Table("tenants").Where("tenantid = ?", tenantID).
Updates(map[string]any{"partnerid": partnerID, "updated": gorm.Expr("NOW()")})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return fmt.Errorf("no business with tenantid %d", tenantID)
}
return nil
}

View File

@@ -26,6 +26,18 @@ func RegisterPartnerRoutes(api fiber.Router, f *facade.Facade) {
//
// 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.
// Onboarding the COMPANY that supplies riders, as opposed to the riders
// themselves. `getpartners` has always existed and nothing could create
// one, which is why the five partners live today were inserted by hand —
// two of them are still named "Test".
//
// Web group only, and deliberately not in the mob group below: a partner is
// onboarded by the platform from a console, never from a phone and never by
// a merchant. A merchant's side of this is being assigned one.
partner.Post("/createpartner", f.PartnerController.CreatePartner)
partner.Put("/updatepartner", f.PartnerController.UpdatePartner)
partner.Get("/getpartnerlocations", f.PartnerController.GetPartnerLocations)
partner.Post("/createrider", f.PartnerController.CreateRider)
partner.Put("/updaterider", f.PartnerController.UpdateRider)
partner.Get("/getriderroster", f.PartnerController.GetRiderRoster)

View File

@@ -29,6 +29,13 @@ func RegisterTenantRoutes(api fiber.Router, f *facade.Facade) {
// two was simply not found. A profile screen cannot be built on that.
tenant.Get("/gettenantinfo", f.TenantController.GetTenantInfo)
// Which delivery partner supplies this merchant's riders.
//
// Its own route rather than a field on updatetenant: `partnerid` is kept
// out of the merchant-editable allowlist on purpose, because a merchant
// who could set it would move themselves under another partner.
tenant.Put("/assignpartner", f.TenantController.AssignPartner)
// A merchant editing their own business record. The first write path the
// `tenants` table has ever had — see UpdateTenantProfile for what may be
// set, and what deliberately may not.

View File

@@ -16,6 +16,9 @@ type PartnerService interface {
CreateRider(rider models.NewRider) (int, error)
UpdateRider(rider models.NewRider) error
GetRiderRoster(tid, aid, pid int) ([]models.RiderRosterRow, error)
CreatePartner(input models.NewPartner) (int, error)
UpdatePartner(input models.NewPartner) error
GetPartnerLocations(partnerid int) ([]models.PartnerLocation, error)
}
type partnerService struct {
@@ -73,3 +76,19 @@ func (s *partnerService) UpdateRider(rider models.NewRider) error {
func (s *partnerService) GetRiderRoster(tid, aid, pid int) ([]models.RiderRosterRow, error) {
return s.repo.GetRiderRoster(tid, aid, pid)
}
/* ── Partners ─────────────────────────────────────────────────────────────── */
// CreatePartner onboards a delivery partner. Platform-side only — a merchant is
// ASSIGNED a partner, never allowed to invent one.
func (s *partnerService) CreatePartner(input models.NewPartner) (int, error) {
return s.repo.CreatePartner(input)
}
func (s *partnerService) UpdatePartner(input models.NewPartner) error {
return s.repo.UpdatePartner(input)
}
func (s *partnerService) GetPartnerLocations(partnerid int) ([]models.PartnerLocation, error) {
return s.repo.GetPartnerLocations(partnerid)
}

View File

@@ -16,6 +16,7 @@ type TenantService interface {
GetTenantLocations(tid int) ([]models.Tenantlocations, error)
GetTenantSlot() (models.Tenantslot, error)
CreateTenantCustomer(req models.CreateTenantCustomerRequest) (*models.Tenantcustomer, error)
AssignPartner(tenantID, partnerID int) error
GetCustomerTenants(customerID int, categoryID int, tenantFlag int) (*models.CustomerTenantResponse, error)
GetTenantPricing(tid, aid int) (models.Tenantpricing, error)
UpdateLocation(input models.Tenantlocations) error
@@ -259,3 +260,11 @@ func (s *tenantService) UpdateOwnProfile(userID, tenantID int, fields map[string
}
return s.repo.UpdateOwnProfile(userID, tenantID, clean)
}
// AssignPartner records which delivery partner supplies a merchant's riders.
//
// Not part of UpdateTenantProfile: that endpoint is the merchant's own, and
// which partner they sit under is the platform's call. See the repository.
func (s *tenantService) AssignPartner(tenantID, partnerID int) error {
return s.repo.AssignPartner(tenantID, partnerID)
}