package repositories import ( "errors" "fmt" "nearle/models" "strconv" "strings" "gorm.io/gorm" ) 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) } 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, Applocationid: rider.Applocationid, Partnerid: rider.Partnerid, Shiftid: rider.Shiftid, Status: status, } if err := tx.Table("app_users").Create(&user).Error; err != nil { tx.Rollback() return 0, err } if user.Userid == 0 { tx.Rollback() return 0, errors.New("the rider account was written without an id") } settings := models.Ridersettings{ Userid: user.Userid, Partnerid: rider.Partnerid, Shiftid: rider.Shiftid, Identificationno: rider.Identificationno, Vehiclename: rider.Vehiclename, Vehicleno: rider.Vehicleno, Licenseno: rider.Licenseno, Registrationno: rider.Registrationno, } if err := tx.Table("ridersettings").Create(&settings).Error; err != nil { tx.Rollback() return 0, err } // onduty 1 means "available for work", not "on shift now" — that second // question is answered by riderlogs, which the rider's own app writes when // they clock on. A rider created here is therefore correctly absent from // getriders until they start a shift. pool := models.Appuserpools{ Userid: user.Userid, Partnerid: rider.Partnerid, Onduty: 1, Status: status, } if err := tx.Table("app_userpools").Create(&pool).Error; err != nil { tx.Rollback() return 0, err } if err := tx.Commit().Error; err != nil { return 0, err } return user.Userid, nil } // GetRiderRoster lists every rider, on duty or not. // // Distinct from GetActiveRiders, and the difference is the whole point. That // one INNER JOINs a riderlog dated today with logstatus 0 — it answers "who can // I give this delivery to right now", which is correct for an assignment picker // and useless for a staff directory: somebody hired this morning, or simply not // working today, is absent from it. A directory that hides the person you just // created reads as a failed save. // // So every join here is LEFT except the rider's own settings, and the duty // state is returned as facts — `onduty`, the last log date, and whether that // log is today — rather than used as a filter. func (r *partnerRepository) GetRiderRoster(tid, aid, pid int) ([]models.RiderRosterRow, error) { var data []models.RiderRosterRow q1 := `SELECT a.userid, a.firstname, a.lastname, CONCAT(a.firstname, ' ', a.lastname) AS fullname, a.contactno, a.email, a.tenantid, a.applocationid, a.partnerid, a.status, f.locationname AS applocation, p.partnername, c.identificationno, c.vehiclename, c.vehicleno, c.licenseno, c.registrationno, c.shiftid, CONCAT(d.starttime, ' - ', d.endtime) AS shiftname, COALESCE(b.onduty, 0) AS onduty, e.logdate AS lastlogdate, COALESCE(e.logdate::date = CURRENT_DATE AND e.logstatus = 0, false) AS isonduty FROM app_users a INNER JOIN ridersettings c ON a.userid = c.userid LEFT JOIN app_userpools b ON a.userid = b.userid LEFT JOIN ridershifts d ON c.shiftid = d.shiftid LEFT JOIN app_location f ON a.applocationid = f.applocationid LEFT JOIN partnerinfo p ON a.partnerid = p.partnerid LEFT JOIN ( SELECT r1.userid, r1.logdate, r1.logstatus FROM riderlogs r1 INNER JOIN ( SELECT userid, MAX(logdate) AS max_logdate FROM riderlogs GROUP BY userid ) r2 ON r1.userid = r2.userid AND r1.logdate = r2.max_logdate ) e ON a.userid = e.userid WHERE a.configid = 6` var args []interface{} // Scoped by whichever id the caller has. Tenant first: it is the narrowest, // and it is the scope a merchant's own directory wants. if tid != 0 { q1 += ` AND a.tenantid = ?` args = append(args, tid) } else if aid != 0 { q1 += ` AND a.applocationid = ?` args = append(args, aid) } else if pid != 0 { q1 += ` AND a.partnerid = ?` args = append(args, pid) } q1 += ` ORDER BY a.firstname, a.lastname` if err := r.db.Raw(q1, args...).Scan(&data).Error; err != nil { return nil, err } return data, nil } // UpdateRider edits a rider across both of their tables. // // Only the fields a person can change from the console: their contact details, // their vehicle and licence, their shift, and their status. The identity // columns and the tenant are not editable — moving a rider between merchants is // not an edit, and doing it silently through a profile form is how a rider ends // up on somebody else's books. func (r *partnerRepository) UpdateRider(rider models.NewRider) error { if rider.Userid == 0 { return errors.New("userid is required") } if rider.Shiftid != 0 { var shifts int64 if err := r.db.Table("ridershifts").Where("shiftid = ?", rider.Shiftid).Count(&shifts).Error; err != nil { return err } if shifts == 0 { return fmt.Errorf("shift %d does not exist", rider.Shiftid) } } tx := r.db.Begin() if tx.Error != nil { return tx.Error } // Built as a map rather than a struct: Updates() with a struct skips every // zero value, so clearing a licence number or blanking an email would // silently do nothing. A map says exactly what to write. user := map[string]interface{}{ "firstname": strings.TrimSpace(rider.Firstname), "lastname": strings.TrimSpace(rider.Lastname), "contactno": strings.TrimSpace(rider.Contactno), "email": strings.TrimSpace(rider.Email), "address": rider.Address, "suburb": rider.Suburb, "city": rider.City, "state": rider.State, "postcode": rider.Postcode, } if rider.Shiftid != 0 { user["shiftid"] = rider.Shiftid } if strings.TrimSpace(rider.Status) != "" { user["status"] = strings.TrimSpace(rider.Status) } res := tx.Table("app_users").Where("userid = ? AND configid = 6", rider.Userid).Updates(user) if res.Error != nil { tx.Rollback() return res.Error } // An UPDATE matching no rows is not an SQL error, so editing a userid that // is not a rider would report success and change nothing. if res.RowsAffected == 0 { tx.Rollback() return fmt.Errorf("rider %d not found", rider.Userid) } settings := map[string]interface{}{ "identificationno": rider.Identificationno, "vehiclename": rider.Vehiclename, "vehicleno": rider.Vehicleno, "licenseno": rider.Licenseno, "registrationno": rider.Registrationno, } if rider.Shiftid != 0 { settings["shiftid"] = rider.Shiftid } if err := tx.Table("ridersettings").Where("userid = ?", rider.Userid).Updates(settings).Error; err != nil { tx.Rollback() return err } return tx.Commit().Error }