Files
backend_fiesta/repositories/partnerRepository.go
2026-09-24 15:51:47 +05:30

1206 lines
42 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
CreateRiderShift(shift models.Ridershifts) (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{}
// Every variant joins partnerlocations, and that join is the whole point.
//
// ── It is what separates our partners from somebody else's ──────────────
//
// `partnerinfo` is shared. It has no column saying which product a row
// belongs to — no configid, no appid — so a partner created by another app
// on this database is indistinguishable from ours by its own fields, and
// this read used to return every Active row on the platform. The console
// made that worse rather than better: it asks `getapplocations` for EVERY
// region and then fetches partners region by region, so the applocationid
// filter below never narrowed anything.
//
// `partnerlocations` is the difference. Only `CreatePartner` writes it —
// one row per region, in the same transaction as the partner — so a row in
// that table means "registered through this console". The partners that
// predate it were inserted by hand and have none, which is why two of them
// are called "Test".
//
// ── The region filter reads the link table, not the home region ─────────
//
// `partnerinfo.applocationid` is the HOME region — CreatePartner writes
// `regions[0]` there — while partnerlocations holds every region covered.
// Those are not the same thing, and not only in theory: partner 44,
// Xpress-Cbe-Main, has a home region of 1 and link rows for 1 AND 2, so
// filtering on the partner row hid them from every Madurai query. That is
// the case the link table exists for.
//
// DISTINCT because such a partner has one row per region in the join and is
// still one partner. Only partnerinfo columns are selected, so there is
// nothing per-region for it to fail to collapse.
//
// A caller fanning out over regions and concatenating the answers still has
// to dedupe — the same partner is legitimately in two of them. The console's
// `useAllPartners` does; it listed Xpress-Cbe-Main twice until it did.
const columns = `select distinct p.partnerid,p.applocationid,p.partnertypeid,p.partnername,
p.primarycontact,p.primaryemail,p.contactno,p.address,p.suburb,p.state,p.city,p.partnerimage
from partnerinfo p
inner join partnerlocations l on l.partnerid = p.partnerid
where p.status='Active'`
if pid != 0 {
// Scoped the same way on purpose: asking for a partner by id must not
// be a way round the separation above.
q1 = columns + ` and p.partnerid=?`
args = append(args, pid)
} else if aid != 0 {
q1 = columns + ` and l.applocationid=?`
args = append(args, aid)
} else {
q1 = columns
}
q1 += ` order by p.partnername, p.partnerid`
err := r.db.Raw(q1, args...).Find(&data).Error
if err != nil {
return nil, err
}
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 — 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 the rider app.
`GetPartners` reads the SECOND: it joins partnerlocations, which both scopes a
region query to every city a partner actually covers and — because only this
function writes that table — separates partners registered here from the ones
another product put in the shared `partnerinfo`. So the link rows are not
bookkeeping; they are what makes a partner ours. */
// 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
}
// ── Rider shifts ────────────────────────────────────────────────────────────
//
// A shift is the window a rider works, and `CreateRider` refuses a rider
// without one — `getriders` joins `ridershifts` through `ridersettings.shiftid`,
// so a rider on a shift that does not exist is a rider nobody can see.
//
// Until now the table could only be READ. There was no endpoint, no service
// method and not even a field for `applocationid` on the model, so a region
// that shipped without shift rows could never have a rider added to it at all:
// the console showed "No shifts set up for this region" and there was nothing
// anybody could do from the product to change that. Till staff had
// `createstaffshift` from the beginning; riders were simply missed.
// riderShiftClock is a start or end time as the column stores it.
//
// Accepts `9:00`, `09:00` and `09:00:00` and normalises to `HH:MM`. The rows
// inserted by hand over the years use all three spellings, and `GetRiderShifts`
// builds its label by concatenating the two columns raw — so `9:00-17:00` and
// `09:00-17:00` are two different labels for one window in the same dropdown.
func riderShiftClock(raw string) (string, error) {
text := strings.TrimSpace(raw)
if text == "" {
return "", errors.New("a shift needs a start and an end time")
}
parts := strings.Split(text, ":")
if len(parts) < 2 || len(parts) > 3 {
return "", fmt.Errorf("%q is not a time — write it as HH:MM", raw)
}
hour, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil || hour < 0 || hour > 23 {
return "", fmt.Errorf("%q is not a time — the hour must be 0 to 23", raw)
}
minute, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil || minute < 0 || minute > 59 {
return "", fmt.Errorf("%q is not a time — the minutes must be 0 to 59", raw)
}
return fmt.Sprintf("%02d:%02d", hour, minute), nil
}
// riderShiftHours is how long the window runs, in hours.
//
// Computed rather than asked for, because it is the one field a person gets
// wrong and nothing downstream checks: `shifthours` feeds rider pay, and a
// window of 09:00–17:00 recorded as 4 hours underpays every rider on it.
//
// A window that ends before it starts crosses midnight and is measured that
// way — a 22:00–06:00 night shift is eight hours, not minus sixteen.
func riderShiftHours(start, end string) float32 {
toMinutes := func(clock string) int {
parts := strings.Split(clock, ":")
hour, _ := strconv.Atoi(parts[0])
minute, _ := strconv.Atoi(parts[1])
return hour*60 + minute
}
span := toMinutes(end) - toMinutes(start)
if span <= 0 {
span += 24 * 60
}
return float32(span) / 60
}
// validateRiderShift checks everything that does not need the database.
//
// Split out so the rules are testable without one, and returns the shift with
// its times normalised and its hours worked out rather than reporting on a copy
// the caller then has to rebuild.
func validateRiderShift(shift models.Ridershifts) (models.Ridershifts, error) {
if shift.Applocationid == 0 {
return shift, errors.New("a shift needs a delivery region")
}
start, err := riderShiftClock(shift.Starttime)
if err != nil {
return shift, err
}
end, err := riderShiftClock(shift.Endtime)
if err != nil {
return shift, err
}
if start == end {
return shift, errors.New("a shift cannot start and end at the same time")
}
shift.Starttime = start
shift.Endtime = end
// Always recomputed, never taken from the request. A caller that sends its
// own number is a caller that can disagree with the window it just sent.
shift.Shifthours = riderShiftHours(start, end)
if shift.Basefare < 0 || shift.Additionalcharges < 0 || shift.Fuelcharge < 0 {
return shift, errors.New("pay cannot be negative")
}
return shift, nil
}
// CreateRiderShift opens a shift window in one region.
func (r *partnerRepository) CreateRiderShift(shift models.Ridershifts) (models.Ridershifts, error) {
shift, err := validateRiderShift(shift)
if err != nil {
return models.Ridershifts{}, err
}
// Same guard `CreateRider` applies to a rider's region, for the same reason:
// `getriders` joins app_locationconfig, so a shift in a region with no
// config row would be offered in the dropdown and then hide every rider put
// on it.
var configs int64
if err := r.db.Table("app_locationconfig").
Where("applocationid = ?", shift.Applocationid).Count(&configs).Error; err != nil {
return models.Ridershifts{}, err
}
if configs == 0 {
return models.Ridershifts{}, fmt.Errorf(
"delivery region %d is not configured, so a shift there would hide every rider on it", shift.Applocationid)
}
// The dropdown labels a shift by its times alone, so a duplicate window is
// two identical-looking choices and no way to tell which one a rider is on.
var clash int64
if err := r.db.Table("ridershifts").
Where("applocationid = ? AND starttime = ? AND endtime = ?",
shift.Applocationid, shift.Starttime, shift.Endtime).
Count(&clash).Error; err != nil {
return models.Ridershifts{}, err
}
if clash > 0 {
return models.Ridershifts{}, fmt.Errorf(
"a %s-%s shift already exists in this region", shift.Starttime, shift.Endtime)
}
// A column map with RETURNING, the same way CreatePartner writes its row,
// rather than inserting the struct.
//
// Inserting the struct would carry `shiftid` at zero into the statement and
// leave whether the sequence is used to how GORM reads a `Primary_Key` tag
// written in the v1 spelling — which is exactly the kind of thing that works
// on one driver and writes a row with id 0 on another. Naming the columns
// removes the question: the id is the database's to assign.
row := map[string]any{
"applocationid": shift.Applocationid,
"shiftdate": shift.Shiftdate,
"starttime": shift.Starttime,
"endtime": shift.Endtime,
"shifthours": shift.Shifthours,
"basefare": shift.Basefare,
"additionalkm": shift.Additionalkm,
"additionalcharges": shift.Additionalcharges,
"orders": shift.Orders,
"fuelcharge": shift.Fuelcharge,
}
if err := r.db.Table("ridershifts").
Clauses(clause.Returning{Columns: []clause.Column{{Name: "shiftid"}}}).
Create(&row).Error; err != nil {
return models.Ridershifts{}, err
}
id, ok := row["shiftid"]
if !ok || toInt(id) == 0 {
// The rider form selects the new shift by id the moment this returns. A
// shift written without one would leave the drawer selecting nothing and
// reading as a failed save.
return models.Ridershifts{}, errors.New("the shift was written without an id")
}
shift.Shiftid = toInt(id)
// The label the dropdown shows, built the same way GetRiderShifts builds it
// so a shift reads identically the moment it is created and after a reload.
shift.Shiftname = shift.Starttime + "-" + shift.Endtime
return shift, nil
}