From e7577fe0cf0ef2bb9dcf47cdb24d30c2a495ad81 Mon Sep 17 00:00:00 2001 From: abhishek Date: Wed, 9 Sep 2026 15:44:27 +0530 Subject: [PATCH] rider partner --- controllers/partnerController.go | 123 ++++++++- controllers/riderOwnership_test.go | 41 +++ controllers/tenantController.go | 47 ++++ models/partner.go | 78 ++++++ repositories/partnerOnboard_test.go | 41 +++ repositories/partnerRepository.go | 397 +++++++++++++++++++++++++++- repositories/tenantRepository.go | 41 +++ routes/partnerroutes.go | 12 + routes/tenantroutes.go | 7 + services/partnerService.go | 19 ++ services/tenantService.go | 9 + 11 files changed, 812 insertions(+), 3 deletions(-) create mode 100644 controllers/riderOwnership_test.go create mode 100644 repositories/partnerOnboard_test.go diff --git a/controllers/partnerController.go b/controllers/partnerController.go index 84aaa69..f016e3f 100644 --- a/controllers/partnerController.go +++ b/controllers/partnerController.go @@ -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 +} diff --git a/controllers/riderOwnership_test.go b/controllers/riderOwnership_test.go new file mode 100644 index 0000000..1306f9a --- /dev/null +++ b/controllers/riderOwnership_test.go @@ -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") + } +} diff --git a/controllers/tenantController.go b/controllers/tenantController.go index 0a47ad0..7bdcb10 100644 --- a/controllers/tenantController.go +++ b/controllers/tenantController.go @@ -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", + }) +} diff --git a/models/partner.go b/models/partner.go index 2bf51a4..c4d862c 100644 --- a/models/partner.go +++ b/models/partner.go @@ -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"` +} diff --git a/repositories/partnerOnboard_test.go b/repositories/partnerOnboard_test.go new file mode 100644 index 0000000..fabc429 --- /dev/null +++ b/repositories/partnerOnboard_test.go @@ -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) + } +} diff --git a/repositories/partnerRepository.go b/repositories/partnerRepository.go index 67ad206..c9de69f 100644 --- a/repositories/partnerRepository.go +++ b/repositories/partnerRepository.go @@ -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 +} diff --git a/repositories/tenantRepository.go b/repositories/tenantRepository.go index b05365d..709bc64 100644 --- a/repositories/tenantRepository.go +++ b/repositories/tenantRepository.go @@ -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 +} diff --git a/routes/partnerroutes.go b/routes/partnerroutes.go index be74ff2..704e17a 100644 --- a/routes/partnerroutes.go +++ b/routes/partnerroutes.go @@ -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) diff --git a/routes/tenantroutes.go b/routes/tenantroutes.go index b0f6e56..ccfafa6 100644 --- a/routes/tenantroutes.go +++ b/routes/tenantroutes.go @@ -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. diff --git a/services/partnerService.go b/services/partnerService.go index 3a40636..f60e1e2 100644 --- a/services/partnerService.go +++ b/services/partnerService.go @@ -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) +} diff --git a/services/tenantService.go b/services/tenantService.go index 0f2bc04..cff6aec 100644 --- a/services/tenantService.go +++ b/services/tenantService.go @@ -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) +}