Files
backend_fiesta/repositories/partnerRepository.go
2026-09-09 15:44:27 +05:30

989 lines
33 KiB
Go

package repositories
import (
"errors"
"fmt"
"nearle/models"
"strconv"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type PartnerRepository interface {
GetActiveRiders(partnerid, aid, uid, tid int) ([]models.RiderInfo, error)
GetPartners(aid, pid, uid int) ([]models.Partnerinfo, error)
GetRiderShifts(aid int) ([]models.Ridershifts, error)
GetLocationConfig(uid, cid int) ([]models.Locationconfigs, error)
GetRiderLogs(pid, aid int, fdate, tdate string) ([]models.RiderlogDetails, error)
GetRiderInfo(userid int) (models.RiderInfo, error)
GetFleetSummary(aid, tid int, fdate, tdate string) (models.FleetSummary, error)
CreateRider(rider models.NewRider) (int, error)
UpdateRider(rider models.NewRider) error
GetRiderRoster(tid, aid, pid int) ([]models.RiderRosterRow, error)
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 {
db *gorm.DB
}
func NewPartnerRepository(db *gorm.DB) PartnerRepository {
return &partnerRepository{db: db}
}
func (r *partnerRepository) GetActiveRiders(partnerid, aid, uid, tid int) ([]models.RiderInfo, error) {
var data []models.RiderInfo
var q1 string
const riders = `SELECT DISTINCT b.poolid, a.userid, a.firstname, a.lastname,CONCAT(a.firstname, ' ', a.lastname) AS fullname,a.contactno, a.userfcmtoken, a.partnerid, a.applocationid,
c.identificationno, c.registrationno, c.licenseno, c.vehiclename, c.vehicleno,d.shiftid, d.starttime, d.endtime, d.shifthours, d.basefare, d.fuelcharge,e.logdate,
e.login, e.logout, e.workhours, e.shorthours, e.logstatus, a.status,f.locationname AS applocation
FROM app_users a
INNER JOIN app_userpools b ON a.userid = b.userid
INNER JOIN ridersettings c ON a.userid = c.userid
INNER JOIN ridershifts d ON c.shiftid = d.shiftid
INNER JOIN (
SELECT r1.*
FROM riderlogs r1
INNER JOIN (
SELECT userid, MAX(logdate) AS max_logdate
FROM riderlogs
WHERE logdate::date = CURRENT_DATE
GROUP BY userid
) r2 ON r1.userid = r2.userid AND r1.logdate = r2.max_logdate
) e ON a.userid = e.userid
INNER JOIN app_location f ON a.applocationid = f.applocationid
INNER JOIN app_locationconfig g ON g.applocationid = f.applocationid`
if aid != 0 {
q1 = riders + ` WHERE a.configid = 6 AND a.status = 'Active' AND b.onduty = 1
AND e.logdate::date = CURRENT_DATE AND e.logstatus = 0 AND a.applocationid = ` + strconv.Itoa(aid)
} else if partnerid != 0 {
q1 = riders + ` WHERE a.configid = 6 AND a.status = 'Active' AND b.onduty = 1
AND e.logdate::date = CURRENT_DATE AND e.logstatus = 0 AND a.partnerid = ` + strconv.Itoa(partnerid)
} else if tid != 0 {
q1 = riders + ` WHERE a.configid = 6 AND a.status = 'Active' AND b.onduty = 1
AND e.logdate::date = CURRENT_DATE AND e.logstatus = 0 AND a.tenantid = ` + strconv.Itoa(tid)
} else {
q1 = riders + ` WHERE g.status = 'Active' AND a.configid = 6 AND a.status = 'Active'
AND b.onduty = 1 AND e.logdate::date = CURRENT_DATE AND e.logstatus = 0 AND g.userid = ` + strconv.Itoa(uid)
}
err := r.db.Raw(q1).Find(&data).Error
if err != nil {
return nil, err
}
return data, nil
}
func (r *partnerRepository) GetPartners(aid, pid, uid int) ([]models.Partnerinfo, error) {
var data []models.Partnerinfo
var q1 string
var args []interface{}
if pid != 0 {
q1 = `select partnerid,applocationid,partnertypeid,partnername,primarycontact,primaryemail,
contactno,address,suburb,state,city,partnerimage
from partnerinfo where status='Active' and partnerid=?`
args = append(args, pid)
} else if aid != 0 {
q1 = `select partnerid,applocationid,partnertypeid,partnername,primarycontact,primaryemail,
contactno,address,suburb,state,city,partnerimage
from partnerinfo where status='Active' and applocationid=?`
args = append(args, aid)
} else {
q1 = `select partnerid,applocationid,partnertypeid,partnername,primarycontact,primaryemail,
contactno,address,suburb,state,city,partnerimage
from partnerinfo where status='Active'`
}
err := r.db.Raw(q1, args...).Find(&data).Error
if err != nil {
return nil, err
}
print(q1)
return data, nil
}
func (r *partnerRepository) GetRiderShifts(aid int) ([]models.Ridershifts, error) {
var data []models.Ridershifts
q1 := `Select a.*,concat(a.starttime,'-',a.endtime) as shiftname from ridershifts a where a.applocationid=` + strconv.Itoa(aid)
err := r.db.Raw(q1).Find(&data).Error
if err != nil {
return nil, err
}
print(q1)
return data, nil
}
func (r *partnerRepository) GetLocationConfig(uid, cid int) ([]models.Locationconfigs, error) {
var data []models.Locationconfigs
q1 := fmt.Sprintf(`SELECT a.applocationid,a.locationname FROM app_location a
inner join app_locationconfig b ON a.applocationid=b.applocationid
WHERE b.status='Active' and b.userid=%d`, uid)
err := r.db.Raw(q1).Find(&data).Error
if err != nil {
return nil, err
}
print(q1)
return data, nil
}
func (r *partnerRepository) GetRiderLogs(pid, aid int, fdate, tdate string) ([]models.RiderlogDetails, error) {
var data []models.RiderlogDetails
var args []interface{}
baseQuery := `
SELECT a.*, b.*, CONCAT(b.firstname,' ',b.lastname) AS username,
COALESCE(SUM(c.breakhours),0) AS breakhours
FROM riderlogs a
INNER JOIN app_users b ON a.userid = b.userid
LEFT JOIN riderbreaks c ON a.logid = c.logid AND a.userid = c.userid
WHERE 1=1
`
if pid != 0 {
baseQuery += " AND a.partnerid = ?"
args = append(args, pid)
}
if aid != 0 {
baseQuery += " AND b.applocationid = ?"
args = append(args, aid)
}
if fdate != "" && tdate != "" {
baseQuery += " AND logdate::date BETWEEN ? AND ?"
args = append(args, fdate, tdate)
} else {
baseQuery += " AND a.logdate::date = CURRENT_DATE"
}
// GROUP BY the two PRIMARY KEYS, not by a.userid.
//
// The select is "a.*, b.*" — every column of riderlogs and app_users — and
// grouping by a.userid leaves all of them unaggregated: userid is not
// riderlogs' key (logid is), so it does not functionally determine a.logid
// or anything else. Postgres refuses the whole statement, so this endpoint
// answered 500 "column a.logid must appear in the GROUP BY clause" on every
// call it has ever received. Verified on production 2026-09-04.
//
// Grouping by both primary keys is what the query means: one row per LOG
// (the model is RiderlogDetails and the sort is by logid), carrying that
// log's break hours summed. Postgres treats a PK in GROUP BY as determining
// the rest of its table's columns, so "a.*, b.*" is then legal.
baseQuery += " GROUP BY a.logid, b.userid ORDER BY a.logid ASC"
if err := r.db.Raw(baseQuery, args...).Find(&data).Error; err != nil {
return nil, err
}
return data, nil
}
func (r *partnerRepository) GetRiderInfo(userid int) (models.RiderInfo, error) {
var data models.RiderInfo
q1 := `SELECT a.userid,a.firstname,a.lastname, CONCAT(a.firstname,' ',a.lastname) as fullname,
a.partnerid,a.configid,a.contactno,
a.address,a.suburb,a.city,a.state,a.postcode,a.latitude,a.longitude,
b.identificationno,b.vehicleno,b.vehiclename,b.licenseno,b.insuranceno,b.insurancedate,
c.shiftid,c.starttime,c.endtime,c.shifthours,c.basefare,c.additionalcharges,c.orders,c.fuelcharge,a.status,
a.applocationid,d.locationname as applocation,d.logseconds
FROM app_users a
INNER JOIN ridersettings b ON a.userid=b.userid
INNER JOIN ridershifts c ON b.shiftid=c.shiftid
INNER JOIN app_location d ON a.applocationid=d.applocationid
WHERE a.userid = ?`
if err := r.db.Raw(q1, userid).Scan(&data).Error; err != nil {
return models.RiderInfo{}, err
}
return data, nil
}
func (r *partnerRepository) GetFleetSummary(aid, tid int, fdate, tdate string) (models.FleetSummary, error) {
var summary models.FleetSummary
// --- Fleet membership filter (app_users aliased as a) ---
memberWhere := "a.configid = 6"
var memberArgs []interface{}
if aid != 0 {
memberWhere += " AND a.applocationid = ?"
memberArgs = append(memberArgs, aid)
}
if tid != 0 {
memberWhere += " AND a.tenantid = ?"
memberArgs = append(memberArgs, tid)
}
countsQuery := `
SELECT
COUNT(*) AS totalriders,
COUNT(*) FILTER (WHERE a.status = 'Active') AS activeriders
FROM app_users a
WHERE ` + memberWhere
if err := r.db.Raw(countsQuery, memberArgs...).Scan(&summary.Counts).Error; err != nil {
return models.FleetSummary{}, err
}
// --- Log-range filter (app_users aliased as b, riderlogs as a) ---
logWhere := "b.configid = 6"
var logArgs []interface{}
if aid != 0 {
logWhere += " AND b.applocationid = ?"
logArgs = append(logArgs, aid)
}
if tid != 0 {
logWhere += " AND b.tenantid = ?"
logArgs = append(logArgs, tid)
}
if fdate != "" && tdate != "" {
logWhere += " AND a.logdate::date BETWEEN ? AND ?"
logArgs = append(logArgs, fdate, tdate)
} else {
logWhere += " AND a.logdate::date = CURRENT_DATE"
}
var worked struct {
Ridersworked int `json:"ridersworked"`
Totallogins int `json:"totallogins"`
}
workedQuery := `
SELECT
COUNT(DISTINCT a.userid) AS ridersworked,
COUNT(*) AS totallogins
FROM riderlogs a
INNER JOIN app_users b ON a.userid = b.userid
WHERE ` + logWhere
if err := r.db.Raw(workedQuery, logArgs...).Scan(&worked).Error; err != nil {
return models.FleetSummary{}, err
}
summary.Counts.Ridersworked = worked.Ridersworked
summary.Counts.Totallogins = worked.Totallogins
// --- Per-rider breakdown over the range ---
// Breaks are pre-aggregated per log so the join cannot inflate the work/short-hour sums.
ridersQuery := `
SELECT
b.userid,
CONCAT(b.firstname, ' ', b.lastname) AS fullname,
b.contactno,
b.partnerid,
b.status,
rs.vehicleno,
rs.vehiclename,
COUNT(DISTINCT a.logdate::date) AS dayslogged,
COALESCE(SUM(a.workhours), 0) AS totalworkhours,
COALESCE(SUM(a.shorthours), 0) AS totalshorthours,
COALESCE(SUM(bk.breakhours), 0) AS totalbreakhours
FROM riderlogs a
INNER JOIN app_users b ON a.userid = b.userid
LEFT JOIN ridersettings rs ON b.userid = rs.userid
LEFT JOIN (
SELECT logid, userid, SUM(breakhours) AS breakhours
FROM riderbreaks
GROUP BY logid, userid
) bk ON a.logid = bk.logid AND a.userid = bk.userid
WHERE ` + logWhere + `
GROUP BY b.userid, b.firstname, b.lastname, b.contactno, b.partnerid, b.status, rs.vehicleno, rs.vehiclename
ORDER BY fullname ASC`
if err := r.db.Raw(ridersQuery, logArgs...).Find(&summary.Riders).Error; err != nil {
return models.FleetSummary{}, err
}
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,
Locationid: rider.Locationid,
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.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,
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 tenantlocations g ON a.locationid = g.locationid
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
}
// 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)
}
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
}
/* ── 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
}