Commit d6fd393a by Augusto

players transfers/career and rounds

parent 297c6c7e
......@@ -362,10 +362,33 @@ definitions:
homeTeam:
$ref: '#/definitions/handlers.matchTeamOut'
round:
$ref: '#/definitions/handlers.matchEntityOut'
$ref: '#/definitions/handlers.matchRoundOut'
season:
$ref: '#/definitions/handlers.matchEntityOut'
type: object
handlers.matchRoundOut:
properties:
competitionWyId:
type: integer
endDate:
type: string
id:
type: string
isActive:
type: boolean
name:
type: string
roundNumber:
type: integer
roundType:
type: string
seasonWyId:
type: integer
startDate:
type: string
wyId:
type: integer
type: object
handlers.matchTeamOut:
properties:
id:
......@@ -1139,6 +1162,74 @@ paths:
summary: Import players from TheSports
tags:
- Import
/import/players/career:
post:
description: Fetches /v3/players/{playerWyId}/career for players already present
in the DB (players.wy_id not null). Upserts records into player_careers.
parameters:
- description: Limit number of players processed
in: query
name: limit
type: integer
- description: Process only one Wyscout player wy_id
in: query
name: playerWyId
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import player career stats from Wyscout
tags:
- Import
/import/players/transfers:
post:
description: Fetches /v3/players/{playerWyId}/transfers for players already
present in the DB (players.wy_id not null). Upserts records into player_transfers.
parameters:
- description: Limit number of players processed
in: query
name: limit
type: integer
- description: Process only one Wyscout player wy_id
in: query
name: playerWyId
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import player transfers from Wyscout
tags:
- Import
/import/referees:
post:
description: Imports referees either from Wyscout /v3/referees/{id} (recommended)
......@@ -1185,6 +1276,44 @@ paths:
summary: Import referees
tags:
- Import
/import/rounds:
post:
description: Fetches /v3/rounds/{roundWyId} for all distinct matches.round_wy_id
values (or a single roundWyId if provided) and upserts into rounds.
parameters:
- description: Wyscout round ID
in: query
name: roundWyId
type: integer
- description: Limit number of rounds processed when roundWyId is omitted
in: query
name: limit
type: integer
- description: DB upsert batch size (default 500)
in: query
name: batchSize
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import rounds from Wyscout
tags:
- Import
/import/seasons:
post:
description: Performs a season import using TheSports season list API. If `since`
......@@ -1578,6 +1707,14 @@ paths:
in: query
name: country
type: string
- collectionFormat: csv
description: 'Filter players by role name (supports multiple: role=Defender&role=Midfielder
or role=Defender,Midfielder)'
in: query
items:
type: string
name: role
type: array
responses:
"200":
description: OK
......@@ -1623,6 +1760,82 @@ paths:
summary: Get player by ID
tags:
- Players
/players/hudl/{hudlId}/career:
get:
description: Returns career statistics by season for a player resolved from
the given Hudl ID (players.uid). If no player is found and hudlId is numeric,
it is treated as a Wyscout wy_id.
parameters:
- description: Hudl player identifier (players.uid) or numeric wy_id
in: path
name: hudlId
required: true
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: List player career by Hudl ID
tags:
- Players
/players/hudl/{hudlId}/transfers:
get:
description: Returns transfer history for a player resolved from the given Hudl
ID (players.uid). If no player is found and hudlId is numeric, it is treated
as a Wyscout wy_id.
parameters:
- description: Hudl player identifier (players.uid) or numeric wy_id
in: path
name: hudlId
required: true
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: List player transfers by Hudl ID
tags:
- Players
/players/wyscout/{wyId}:
get:
description: Returns a single player by its provider (wy_id) identifier.
......
......@@ -10,6 +10,7 @@ require (
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
golang.org/x/text v0.27.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
......@@ -59,7 +60,6 @@ require (
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
......
......@@ -40,6 +40,7 @@ func Connect(cfg config.Config) (*gorm.DB, error) {
&models.MatchLineupPlayer{},
&models.MatchFormation{},
&models.PlayerTransfer{},
&models.PlayerCareer{},
&models.TeamSquad{},
&models.Standing{},
&models.SampleRecord{},
......
......@@ -54,12 +54,25 @@ type matchEntityOut struct {
Name *string `json:"name,omitempty"`
}
type matchRoundOut struct {
ID *string `json:"id,omitempty"`
WyID *int `json:"wyId,omitempty"`
Name *string `json:"name,omitempty"`
RoundType *string `json:"roundType,omitempty"`
RoundNumber *int `json:"roundNumber,omitempty"`
StartDate *time.Time `json:"startDate,omitempty"`
EndDate *time.Time `json:"endDate,omitempty"`
IsActive *bool `json:"isActive,omitempty"`
CompetitionWyID *int `json:"competitionWyId,omitempty"`
SeasonWyID *int `json:"seasonWyId,omitempty"`
}
type matchOut struct {
Match models.Match `json:"-"`
HomeTeam *matchTeamOut `json:"homeTeam,omitempty"`
AwayTeam *matchTeamOut `json:"awayTeam,omitempty"`
Season *matchEntityOut `json:"season,omitempty"`
Round *matchEntityOut `json:"round,omitempty"`
Round *matchRoundOut `json:"round,omitempty"`
Competition *matchEntityOut `json:"competition,omitempty"`
}
......@@ -98,6 +111,7 @@ func (m matchOut) MarshalJSON() ([]byte, error) {
delete(base, "refereeTsId")
delete(base, "relatedTsId")
delete(base, "roundGroupNum")
delete(base, "roundNum")
delete(base, "roundStageTsId")
delete(base, "seasonTsId")
delete(base, "statusId")
......@@ -215,21 +229,49 @@ func (h *MatchHandler) enrichMatches(ctx context.Context, matches []models.Match
}
}
roundByWyID := map[int]string{}
roundByWyID := map[int]matchRoundOut{}
if len(roundWyIDs) > 0 {
var rounds []struct {
WyID int `gorm:"column:wy_id"`
Name string `gorm:"column:name"`
ID string `gorm:"column:id"`
WyID int `gorm:"column:wy_id"`
Name string `gorm:"column:name"`
RoundType string `gorm:"column:round_type"`
RoundNumber *int `gorm:"column:round_number"`
StartDate *time.Time `gorm:"column:start_date"`
EndDate *time.Time `gorm:"column:end_date"`
IsActive bool `gorm:"column:is_active"`
CompetitionWyID *int `gorm:"column:competition_wy_id"`
SeasonWyID *int `gorm:"column:season_wy_id"`
}
if err := h.DB.WithContext(ctx).
Model(&models.Round{}).
Select("wy_id", "name").
Select("id", "wy_id", "name", "round_type", "round_number", "start_date", "end_date", "is_active", "competition_wy_id", "season_wy_id").
Where("wy_id IN ?", roundWyIDs).
Find(&rounds).Error; err != nil {
return nil, err
}
for _, r := range rounds {
roundByWyID[r.WyID] = r.Name
id := r.ID
wyID := r.WyID
name := r.Name
var typ *string
if r.RoundType != "" {
t := r.RoundType
typ = &t
}
active := r.IsActive
roundByWyID[r.WyID] = matchRoundOut{
ID: &id,
WyID: &wyID,
Name: &name,
RoundType: typ,
RoundNumber: r.RoundNumber,
StartDate: r.StartDate,
EndDate: r.EndDate,
IsActive: &active,
CompetitionWyID: r.CompetitionWyID,
SeasonWyID: r.SeasonWyID,
}
}
}
......@@ -350,13 +392,14 @@ func (h *MatchHandler) enrichMatches(ctx context.Context, matches []models.Match
season.Name = &n
}
}
var round *matchEntityOut
var round *matchRoundOut
if m.RoundWyID != nil {
wyID := *m.RoundWyID
round = &matchEntityOut{WyID: &wyID}
if name, ok := roundByWyID[*m.RoundWyID]; ok && name != "" {
n := name
round.Name = &n
if r, ok := roundByWyID[*m.RoundWyID]; ok {
rr := r
round = &rr
} else {
wyID := *m.RoundWyID
round = &matchRoundOut{WyID: &wyID}
}
}
out = append(out, matchOut{
......
......@@ -15,6 +15,7 @@ import (
)
type PlayerHandler struct {
DB *gorm.DB
Service services.PlayerService
Teams services.TeamService
Areas services.AreaService
......@@ -24,12 +25,14 @@ func RegisterPlayerRoutes(rg *gin.RouterGroup, db *gorm.DB) {
service := services.NewPlayerService(db)
teamService := services.NewTeamService(db)
areaService := services.NewAreaService(db)
h := &PlayerHandler{Service: service, Teams: teamService, Areas: areaService}
h := &PlayerHandler{DB: db, Service: service, Teams: teamService, Areas: areaService}
players := rg.Group("/players")
players.GET("", h.List)
players.GET("/wyscout/:wyId", h.GetByProviderID)
players.GET("/provider/:providerId", h.GetByAnyProviderID)
players.GET("/hudl/:hudlId/transfers", h.ListTransfersByHudlID)
players.GET("/hudl/:hudlId/career", h.ListCareerByHudlID)
players.GET("/:id", h.GetByID)
}
......@@ -69,6 +72,7 @@ type StructuredPlayer struct {
CurrentNationalTeamID *int `json:"-"`
CountryTsID *string `json:"-"`
CountryName *string `json:"-"`
Country *AreaSummary `json:"country,omitempty"`
Gender *string `json:"gender"`
Status string `json:"status"`
JerseyNumber *int `json:"-"`
......@@ -261,6 +265,28 @@ func addPlayerCountries(structured []StructuredPlayer, players []models.Player,
if a, ok := areasByTsID[tsID]; ok {
name := a.Name
structured[i].CountryName = &name
s := toAreaSummary(a)
structured[i].Country = &s
}
}
}
func addPlayerBirthPassportAreas(structured []StructuredPlayer, players []models.Player, areasByWyID map[int]models.Area) {
if len(structured) != len(players) {
return
}
for i := range structured {
if players[i].BirthAreaWyID != nil {
if a, ok := areasByWyID[*players[i].BirthAreaWyID]; ok {
s := toAreaSummary(a)
structured[i].BirthArea = &s
}
}
if players[i].PassportAreaWyID != nil {
if a, ok := areasByWyID[*players[i].PassportAreaWyID]; ok {
s := toAreaSummary(a)
structured[i].PassportArea = &s
}
}
}
}
......@@ -334,6 +360,7 @@ func valueOrDefault(s *string, def string) string {
// @Param name query string false "Filter players by name (short, first, middle, or last)"
// @Param teamId query string false "Filter players by current team ID"
// @Param country query string false "Filter players by birth country name"
// @Param role query []string false "Filter players by role name (supports multiple: role=Defender&role=Midfielder or role=Defender,Midfielder)"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /players [get]
......@@ -353,6 +380,23 @@ func (h *PlayerHandler) List(c *gin.Context) {
name := c.Query("name")
teamID := c.Query("teamId")
country := c.Query("country")
rolesQuery := c.QueryArray("role")
if len(rolesQuery) == 0 {
rolesQuery = c.QueryArray("role[]")
}
if len(rolesQuery) == 0 {
rolesQuery = c.QueryArray("roles")
}
roles := make([]string, 0, len(rolesQuery))
for _, raw := range rolesQuery {
for _, r := range strings.Split(raw, ",") {
r = strings.TrimSpace(r)
if r == "" {
continue
}
roles = append(roles, r)
}
}
endpoint := "/players"
if name != "" {
......@@ -369,6 +413,7 @@ func (h *PlayerHandler) List(c *gin.Context) {
Name: name,
TeamID: teamID,
Country: country,
Roles: roles,
})
if err != nil {
respondError(c, err)
......@@ -706,6 +751,209 @@ func (h *PlayerHandler) GetByID(c *gin.Context) {
})
}
// ListTransfersByHudlID returns player transfers for a player identified by Hudl ID.
// @Summary List player transfers by Hudl ID
// @Description Returns transfer history for a player resolved from the given Hudl ID (players.uid). If no player is found and hudlId is numeric, it is treated as a Wyscout wy_id.
// @Tags Players
// @Param hudlId path string true "Hudl player identifier (players.uid) or numeric wy_id"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /players/hudl/{hudlId}/transfers [get]
func (h *PlayerHandler) ListTransfersByHudlID(c *gin.Context) {
hudlID := c.Param("hudlId")
transfers, err := h.Service.ListTransfersByHudlID(c.Request.Context(), hudlID)
if err != nil {
respondError(c, err)
return
}
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/players/hudl/%s/transfers", hudlID)
c.JSON(http.StatusOK, gin.H{
"data": transfers,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
// ListCareerByHudlID returns player career stats for a player identified by Hudl ID.
// @Summary List player career by Hudl ID
// @Description Returns career statistics by season for a player resolved from the given Hudl ID (players.uid). If no player is found and hudlId is numeric, it is treated as a Wyscout wy_id.
// @Tags Players
// @Param hudlId path string true "Hudl player identifier (players.uid) or numeric wy_id"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /players/hudl/{hudlId}/career [get]
func (h *PlayerHandler) ListCareerByHudlID(c *gin.Context) {
hudlID := c.Param("hudlId")
career, err := h.Service.ListCareerByHudlID(c.Request.Context(), hudlID)
if err != nil {
respondError(c, err)
return
}
type teamSummary struct {
WyID int `json:"wyId"`
Name *string `json:"name,omitempty"`
Image *string `json:"image,omitempty"`
}
type competitionSummary struct {
WyID int `json:"wyId"`
Name *string `json:"name,omitempty"`
Logo *string `json:"logo,omitempty"`
}
type seasonSummary struct {
WyID int `json:"wyId"`
Name *string `json:"name,omitempty"`
}
type careerOut struct {
models.PlayerCareer
Team *teamSummary `json:"team,omitempty"`
Season *seasonSummary `json:"season,omitempty"`
Competition *competitionSummary `json:"competition,omitempty"`
}
teamIDs := make([]int, 0, len(career))
seasonIDs := make([]int, 0, len(career))
competitionIDs := make([]int, 0, len(career))
seenTeam := map[int]struct{}{}
seenSeason := map[int]struct{}{}
seenCompetition := map[int]struct{}{}
for _, row := range career {
if row.TeamID > 0 {
if _, ok := seenTeam[row.TeamID]; !ok {
seenTeam[row.TeamID] = struct{}{}
teamIDs = append(teamIDs, row.TeamID)
}
}
if row.SeasonID > 0 {
if _, ok := seenSeason[row.SeasonID]; !ok {
seenSeason[row.SeasonID] = struct{}{}
seasonIDs = append(seasonIDs, row.SeasonID)
}
}
if row.CompetitionID > 0 {
if _, ok := seenCompetition[row.CompetitionID]; !ok {
seenCompetition[row.CompetitionID] = struct{}{}
competitionIDs = append(competitionIDs, row.CompetitionID)
}
}
}
teamsByWyID := map[int]teamSummary{}
if len(teamIDs) > 0 {
var teams []struct {
WyID int `gorm:"column:wy_id"`
Name string `gorm:"column:name"`
ImageDataURL *string `gorm:"column:image_data_url"`
}
if err := h.DB.WithContext(c.Request.Context()).
Model(&models.Team{}).
Select("wy_id", "name", "image_data_url").
Where("wy_id IN ?", teamIDs).
Find(&teams).Error; err == nil {
for _, t := range teams {
name := t.Name
var img *string
if t.ImageDataURL != nil && *t.ImageDataURL != "" {
v := *t.ImageDataURL
img = &v
}
teamsByWyID[t.WyID] = teamSummary{WyID: t.WyID, Name: &name, Image: img}
}
}
}
seasonsByWyID := map[int]seasonSummary{}
if len(seasonIDs) > 0 {
var seasons []struct {
WyID int `gorm:"column:wy_id"`
Name string `gorm:"column:name"`
}
if err := h.DB.WithContext(c.Request.Context()).
Model(&models.Season{}).
Select("wy_id", "name").
Where("wy_id IN ?", seasonIDs).
Find(&seasons).Error; err == nil {
for _, s := range seasons {
name := s.Name
seasonsByWyID[s.WyID] = seasonSummary{WyID: s.WyID, Name: &name}
}
}
}
competitionsByWyID := map[int]competitionSummary{}
if len(competitionIDs) > 0 {
var comps []struct {
WyID int `gorm:"column:wy_id"`
Name string `gorm:"column:name"`
Logo *string `gorm:"column:logo"`
}
if err := h.DB.WithContext(c.Request.Context()).
Model(&models.Competition{}).
Select("wy_id", "name", "logo").
Where("wy_id IN ?", competitionIDs).
Find(&comps).Error; err == nil {
for _, comp := range comps {
name := comp.Name
var logo *string
if comp.Logo != nil && *comp.Logo != "" {
v := *comp.Logo
logo = &v
}
competitionsByWyID[comp.WyID] = competitionSummary{WyID: comp.WyID, Name: &name, Logo: logo}
}
}
}
out := make([]careerOut, 0, len(career))
for _, row := range career {
var team *teamSummary
if v, ok := teamsByWyID[row.TeamID]; ok {
tmp := v
team = &tmp
}
var season *seasonSummary
if v, ok := seasonsByWyID[row.SeasonID]; ok {
tmp := v
season = &tmp
}
var competition *competitionSummary
if v, ok := competitionsByWyID[row.CompetitionID]; ok {
tmp := v
competition = &tmp
}
out = append(out, careerOut{PlayerCareer: row, Team: team, Season: season, Competition: competition})
}
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/players/hudl/%s/career", hudlID)
c.JSON(http.StatusOK, gin.H{
"data": out,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
// GetByAnyProviderID returns a single player by wy_id (numeric) or ts_id (string)
func (h *PlayerHandler) GetByAnyProviderID(c *gin.Context) {
providerID := c.Param("providerId")
......
......@@ -160,6 +160,43 @@ func (h *TeamHandler) ListPlayersByWyID(c *gin.Context) {
structured = append(structured, toStructuredPlayer(p))
}
playerAreaWyIDs := make([]int, 0, len(players)*2)
playerAreaSeen := make(map[int]struct{}, len(players)*2)
for _, p := range players {
if p.BirthAreaWyID != nil && *p.BirthAreaWyID > 0 {
if _, ok := playerAreaSeen[*p.BirthAreaWyID]; !ok {
playerAreaSeen[*p.BirthAreaWyID] = struct{}{}
playerAreaWyIDs = append(playerAreaWyIDs, *p.BirthAreaWyID)
}
}
if p.PassportAreaWyID != nil && *p.PassportAreaWyID > 0 {
if _, ok := playerAreaSeen[*p.PassportAreaWyID]; !ok {
playerAreaSeen[*p.PassportAreaWyID] = struct{}{}
playerAreaWyIDs = append(playerAreaWyIDs, *p.PassportAreaWyID)
}
}
}
if len(playerAreaWyIDs) > 0 {
areas, err := h.Areas.GetByWyIDs(c.Request.Context(), playerAreaWyIDs)
if err != nil {
respondError(c, err)
return
}
areasByWyIDForPlayers := make(map[int]models.Area, len(areas))
for _, a := range areas {
wyStr := strings.TrimSpace(a.WyID)
if wyStr == "" {
continue
}
wy, err := strconv.Atoi(wyStr)
if err != nil {
continue
}
areasByWyIDForPlayers[wy] = a
}
addPlayerBirthPassportAreas(structured, players, areasByWyIDForPlayers)
}
wyIDs := make([]int, 0, len(players))
wySeen := make(map[int]struct{}, len(players))
for _, p := range players {
......
......@@ -495,35 +495,111 @@ func (mf *MatchFormation) BeforeCreate(tx *gorm.DB) (err error) {
type PlayerTransfer struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
WyID int `gorm:"column:wy_id;uniqueIndex" json:"wyId"`
TsID int `gorm:"column:ts_id;uniqueIndex" json:"tsId"`
PlayerWyID *int `gorm:"column:player_wy_id" json:"playerWyId"`
PlayerTsID *int `gorm:"column:player_ts_id" json:"playerTsId"`
FromTeamWyID *int `gorm:"column:from_team_wy_id" json:"fromTeamWyId"`
FromTeamTsID *int `gorm:"column:from_team_ts_id" json:"fromTeamTsId"`
ToTeamWyID *int `gorm:"column:to_team_wy_id" json:"toTeamWyId"`
ToTeamTsID *int `gorm:"column:to_team_ts_id" json:"toTeamTsId"`
TransferID int `gorm:"column:transfer_id;uniqueIndex" json:"transferId"`
PlayerWyID int `gorm:"column:player_wy_id;index" json:"playerId"`
FromTeamID *int `gorm:"column:from_team_id" json:"fromTeamId"`
FromTeamName *string `gorm:"column:from_team_name" json:"fromTeamName"`
ToTeamID *int `gorm:"column:to_team_id" json:"toTeamId"`
ToTeamName *string `gorm:"column:to_team_name" json:"toTeamName"`
TransferDate *time.Time `gorm:"column:transfer_date" json:"transferDate"`
IsActive bool `gorm:"column:is_active;default:false" json:"active"`
StartDate *time.Time `gorm:"column:start_date" json:"startDate"`
EndDate *time.Time `gorm:"column:end_date" json:"endDate"`
TransferType *string `gorm:"column:transfer_type" json:"transferType"`
TransferFee *float64 `gorm:"column:transfer_fee" json:"transferFee"`
Currency string `gorm:"column:currency;default:EUR" json:"currency"`
ContractLength *int `gorm:"column:contract_length" json:"contractLength"`
Season *string `gorm:"column:season" json:"season"`
IsActive bool `gorm:"column:is_active;default:false" json:"isActive"`
IsLoan bool `gorm:"column:is_loan;default:false" json:"isLoan"`
LoanDuration *int `gorm:"column:loan_duration" json:"loanDuration"`
HasOptionToBuy bool `gorm:"column:has_option_to_buy;default:false" json:"hasOptionToBuy"`
OptionToBuyFee *float64 `gorm:"column:option_to_buy_fee" json:"optionToBuyFee"`
AnnouncementDate *time.Time `gorm:"column:announcement_date" json:"announcementDate"`
SourceURL *string `gorm:"column:source_url" json:"sourceUrl"`
Type *string `gorm:"column:type" json:"type"`
Value *int `gorm:"column:value" json:"value"`
Currency *string `gorm:"column:currency" json:"currency"`
AnnounceDate *time.Time `gorm:"column:announce_date" json:"announceDate"`
APILastSyncedAt *time.Time `gorm:"column:api_last_synced_at" json:"apiLastSyncedAt"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (pt *PlayerTransfer) BeforeCreate(tx *gorm.DB) (err error) {
if pt.ID != "" {
return nil
}
id, err := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 15)
if err != nil {
return err
}
pt.ID = id
return nil
}
type PlayerCareer struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
PlayerWyID int `gorm:"column:player_wy_id;uniqueIndex:uidx_player_careers;index" json:"playerId"`
TeamID int `gorm:"column:team_id;uniqueIndex:uidx_player_careers;index" json:"teamId"`
SeasonID int `gorm:"column:season_id;uniqueIndex:uidx_player_careers;index" json:"seasonId"`
CompetitionID int `gorm:"column:competition_id;uniqueIndex:uidx_player_careers;index" json:"competitionId"`
ShirtNumber int `gorm:"column:shirt_number" json:"shirtNumber"`
Goal int `gorm:"column:goal" json:"goal"`
Penalties int `gorm:"column:penalties" json:"penalties"`
Appearances int `gorm:"column:appearances" json:"appearances"`
YellowCard int `gorm:"column:yellow_card" json:"yellowCard"`
RedCards int `gorm:"column:red_cards" json:"redCards"`
SubstituteIn int `gorm:"column:substitute_in" json:"substituteIn"`
SubstituteOut int `gorm:"column:substitute_out" json:"substituteOut"`
SubOnBench int `gorm:"column:substitute_on_bench" json:"substituteOnBench"`
MinutesPlayed int `gorm:"column:minutes_played" json:"minutesPlayed"`
APILastSyncedAt *time.Time `gorm:"column:api_last_synced_at" json:"apiLastSyncedAt"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
type TeamCareer struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
TeamWyID int `gorm:"column:team_wy_id;uniqueIndex:uidx_team_careers;index" json:"teamWyId"`
SeasonID int `gorm:"column:season_id;uniqueIndex:uidx_team_careers;index" json:"seasonId"`
CompetitionID int `gorm:"column:competition_id;uniqueIndex:uidx_team_careers;index" json:"competitionId"`
RoundID int `gorm:"column:round_id;uniqueIndex:uidx_team_careers" json:"roundId"`
RoundName *string `gorm:"column:round_name" json:"roundName"`
GroupID int `gorm:"column:group_id;uniqueIndex:uidx_team_careers" json:"groupId"`
GroupName *string `gorm:"column:group_name" json:"groupName"`
Rank int `gorm:"column:rank" json:"rank"`
MatchTotal int `gorm:"column:match_total" json:"matchTotal"`
MatchWon int `gorm:"column:match_won" json:"matchWon"`
MatchDraw int `gorm:"column:match_draw" json:"matchDraw"`
MatchLost int `gorm:"column:match_lost" json:"matchLost"`
GoalPro int `gorm:"column:goal_pro" json:"goalPro"`
GoalAgainst int `gorm:"column:goal_against" json:"goalAgainst"`
Points int `gorm:"column:points" json:"points"`
SeasonJSON json.RawMessage `gorm:"column:season_json;type:jsonb" json:"season" swaggertype:"object"`
CompetitionJSON json.RawMessage `gorm:"column:competition_json;type:jsonb" json:"competition" swaggertype:"object"`
APILastSyncedAt *time.Time `gorm:"column:api_last_synced_at" json:"apiLastSyncedAt"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (tc *TeamCareer) BeforeCreate(tx *gorm.DB) (err error) {
if tc.ID != "" {
return nil
}
id, err := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 15)
if err != nil {
return err
}
tc.ID = id
return nil
}
func (pc *PlayerCareer) BeforeCreate(tx *gorm.DB) (err error) {
if pc.ID != "" {
return nil
}
id, err := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 15)
if err != nil {
return err
}
pc.ID = id
return nil
}
type TeamSquad struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
TeamWyID *int `gorm:"column:team_wy_id" json:"teamWyId"`
......@@ -597,3 +673,17 @@ type Round struct {
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deletedAt"`
}
func (r *Round) BeforeCreate(tx *gorm.DB) (err error) {
if r.ID != "" {
return nil
}
id, err := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 15)
if err != nil {
return err
}
r.ID = id
return nil
}
......@@ -3,11 +3,13 @@ package services
import (
"context"
"strconv"
"strings"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models"
"ScoutingSystemScoreData/internal/utils"
)
type CoachService interface {
......@@ -39,11 +41,59 @@ func (s *coachService) ListCoaches(ctx context.Context, opts ListCoachesOptions)
query := s.db.WithContext(ctx).Model(&models.Coach{})
if opts.Name != "" {
like := "%" + opts.Name + "%"
query = query.Where(
"first_name ILIKE ? OR last_name ILIKE ? OR middle_name ILIKE ? OR short_name ILIKE ?",
like, like, like, like,
// Normalize the search term for better matching
normalizedSearch := utils.NormalizeText(opts.Name)
searchTokens := utils.TokenizeSearchTerm(opts.Name)
// Build flexible search conditions
likePattern := "%" + normalizedSearch + "%"
// Create search conditions for normalized text
searchConditions := s.db.Where(
"LOWER(REGEXP_REPLACE(coaches.short_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(coaches.first_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(coaches.middle_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(coaches.last_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ?",
likePattern, likePattern, likePattern, likePattern,
)
// Also search with original pattern for exact matches
originalPattern := "%" + strings.ToLower(opts.Name) + "%"
searchConditions = searchConditions.Or(
"coaches.short_name ILIKE ? OR coaches.first_name ILIKE ? OR coaches.middle_name ILIKE ? OR coaches.last_name ILIKE ?",
originalPattern, originalPattern, originalPattern, originalPattern,
)
// Add token-based search for multi-word queries
if len(searchTokens) > 1 {
// Intentionally do NOT OR tokens together.
// For multi-token input (e.g. "pablo rosario"), we require ALL tokens to match
// somewhere across the name fields.
}
query = query.Where(searchConditions)
if len(searchTokens) > 1 {
for _, token := range searchTokens {
tokenPattern := "%" + token + "%"
query = query.Where(
"LOWER(coaches.short_name) ILIKE ? OR LOWER(coaches.first_name) ILIKE ? OR LOWER(coaches.middle_name) ILIKE ? OR LOWER(coaches.last_name) ILIKE ?",
tokenPattern, tokenPattern, tokenPattern, tokenPattern,
)
}
}
// Join with teams to check for big competitions
query = query.Joins("LEFT JOIN teams ON teams.wy_id = coaches.current_team_wy_id")
query = query.Joins("LEFT JOIN competitions ON competitions.wy_id = teams.competition_wy_id")
// Prioritization logic for coaches
query = query.Order("coaches.is_active DESC")
query = query.Order("CASE WHEN teams.competition_wy_id IN (364, 795, 426, 524, 412, 102, 103) THEN 0 ELSE 1 END")
query = query.Order("CASE WHEN coaches.current_team_wy_id IS NULL THEN 1 ELSE 0 END")
query = query.Order("CASE WHEN coaches.years_experience IS NULL THEN 1 ELSE 0 END")
query = query.Order("coaches.years_experience DESC")
query = query.Order("coaches.last_name ASC")
query = query.Order("coaches.first_name ASC")
} else if opts.TeamID != "" {
query = query.Where("current_team_wy_id = ?", opts.TeamID)
} else if opts.Position != "" {
......@@ -54,18 +104,25 @@ func (s *coachService) ListCoaches(ctx context.Context, opts ListCoachesOptions)
query = query.Where("is_active = ?", true)
}
query = query.Order("is_active DESC")
query = query.Order("CASE WHEN current_team_wy_id IS NULL THEN 1 ELSE 0 END")
query = query.Order("CASE WHEN years_experience IS NULL THEN 1 ELSE 0 END")
query = query.Order("years_experience DESC")
query = query.Order("last_name ASC")
query = query.Order("first_name ASC")
if opts.Name == "" {
query = query.Order("is_active DESC")
query = query.Order("CASE WHEN current_team_wy_id IS NULL THEN 1 ELSE 0 END")
query = query.Order("CASE WHEN years_experience IS NULL THEN 1 ELSE 0 END")
query = query.Order("years_experience DESC")
query = query.Order("last_name ASC")
query = query.Order("first_name ASC")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to count coaches", err)
}
// Select only coach fields to avoid conflicts with joined tables
if opts.Name != "" {
query = query.Select("coaches.*")
}
if err := query.Limit(opts.Limit).Offset(opts.Offset).Find(&coaches).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to fetch coaches", err)
}
......
......@@ -3,11 +3,13 @@ package services
import (
"context"
"strconv"
"strings"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models"
"ScoutingSystemScoreData/internal/utils"
)
type RefereeService interface {
......@@ -39,11 +41,53 @@ func (s *refereeService) ListReferees(ctx context.Context, opts ListRefereesOpti
query := s.db.WithContext(ctx).Model(&models.Referee{})
if opts.Name != "" {
like := "%" + opts.Name + "%"
query = query.Where(
"first_name ILIKE ? OR last_name ILIKE ? OR middle_name ILIKE ? OR short_name ILIKE ?",
like, like, like, like,
// Normalize the search term for better matching
normalizedSearch := utils.NormalizeText(opts.Name)
searchTokens := utils.TokenizeSearchTerm(opts.Name)
// Build flexible search conditions
likePattern := "%" + normalizedSearch + "%"
// Create search conditions for normalized text
searchConditions := s.db.Where(
"LOWER(REGEXP_REPLACE(referees.short_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(referees.first_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(referees.middle_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(referees.last_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ?",
likePattern, likePattern, likePattern, likePattern,
)
// Also search with original pattern for exact matches
originalPattern := "%" + strings.ToLower(opts.Name) + "%"
searchConditions = searchConditions.Or(
"referees.short_name ILIKE ? OR referees.first_name ILIKE ? OR referees.middle_name ILIKE ? OR referees.last_name ILIKE ?",
originalPattern, originalPattern, originalPattern, originalPattern,
)
// Add token-based search for multi-word queries
if len(searchTokens) > 1 {
// Intentionally do NOT OR tokens together.
// For multi-token input (e.g. "pablo rosario"), we require ALL tokens to match
// somewhere across the name fields.
}
query = query.Where(searchConditions)
if len(searchTokens) > 1 {
for _, token := range searchTokens {
tokenPattern := "%" + token + "%"
query = query.Where(
"LOWER(referees.short_name) ILIKE ? OR LOWER(referees.first_name) ILIKE ? OR LOWER(referees.middle_name) ILIKE ? OR LOWER(referees.last_name) ILIKE ?",
tokenPattern, tokenPattern, tokenPattern, tokenPattern,
)
}
}
// Prioritization logic for referees
query = query.Order("referees.is_active DESC")
query = query.Order("CASE WHEN referees.experience_years IS NULL THEN 1 ELSE 0 END")
query = query.Order("referees.experience_years DESC")
query = query.Order("referees.last_name ASC")
query = query.Order("referees.first_name ASC")
}
if opts.CountryWyID != nil {
query = query.Where("nationality_wy_id = ?", *opts.CountryWyID)
......
package utils
import (
"strings"
"unicode"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
)
// NormalizeText removes accents, converts to lowercase, and normalizes whitespace
func NormalizeText(text string) string {
// Convert to lowercase
text = strings.ToLower(text)
// Remove accents/diacritics
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
normalized, _, _ := transform.String(t, text)
// Normalize whitespace
normalized = strings.Join(strings.Fields(normalized), " ")
return normalized
}
// TokenizeSearchTerm splits a search term into tokens for better matching
func TokenizeSearchTerm(text string) []string {
normalized := NormalizeText(text)
tokens := strings.Fields(normalized)
return tokens
}
// CalculateNameMatchScore calculates a match score between search term and name fields
// Returns a score from 0-100, where higher is better
func CalculateNameMatchScore(searchTerm, shortName, firstName, middleName, lastName string) int {
normalizedSearch := NormalizeText(searchTerm)
normalizedShort := NormalizeText(shortName)
normalizedFirst := NormalizeText(firstName)
normalizedMiddle := NormalizeText(middleName)
normalizedLast := NormalizeText(lastName)
normalizedFull := strings.TrimSpace(normalizedFirst + " " + normalizedMiddle + " " + normalizedLast)
// Exact match on short name (highest priority)
if normalizedSearch == normalizedShort {
return 100
}
// Exact match on full name
if normalizedSearch == normalizedFull {
return 95
}
// Exact match on first + last
normalizedFirstLast := strings.TrimSpace(normalizedFirst + " " + normalizedLast)
if normalizedSearch == normalizedFirstLast {
return 90
}
// Exact match on last name
if normalizedSearch == normalizedLast {
return 85
}
// Exact match on first name
if normalizedSearch == normalizedFirst {
return 80
}
// Check if all search tokens are present in any name field
searchTokens := TokenizeSearchTerm(searchTerm)
if len(searchTokens) == 0 {
return 0
}
allTokensMatch := true
for _, token := range searchTokens {
found := false
if strings.Contains(normalizedShort, token) ||
strings.Contains(normalizedFirst, token) ||
strings.Contains(normalizedMiddle, token) ||
strings.Contains(normalizedLast, token) {
found = true
}
if !found {
allTokensMatch = false
break
}
}
if allTokensMatch {
// Score based on how many tokens matched
if len(searchTokens) >= 2 {
return 70
}
return 60
}
// Partial match - at least one token matches
for _, token := range searchTokens {
if strings.Contains(normalizedShort, token) {
return 50
}
if strings.Contains(normalizedLast, token) {
return 45
}
if strings.Contains(normalizedFirst, token) {
return 40
}
if strings.Contains(normalizedMiddle, token) {
return 35
}
}
// Check for prefix matches
for _, token := range searchTokens {
if strings.HasPrefix(normalizedLast, token) {
return 30
}
if strings.HasPrefix(normalizedFirst, token) {
return 25
}
}
return 0
}
// BuildSearchPattern creates a SQL ILIKE pattern from search term
func BuildSearchPattern(searchTerm string) string {
normalized := NormalizeText(searchTerm)
return "%" + normalized + "%"
}
-- Migration 0010: create player_transfers table for Wyscout player transfers
CREATE TABLE IF NOT EXISTS player_transfers (
id varchar(16) PRIMARY KEY,
transfer_id integer NOT NULL UNIQUE,
player_wy_id integer NOT NULL,
from_team_id integer,
from_team_name text,
to_team_id integer,
to_team_name text,
is_active boolean NOT NULL DEFAULT false,
start_date date,
end_date date,
type text,
value integer,
currency varchar(16),
announce_date date,
api_last_synced_at timestamp with time zone,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_player_transfers_player_wy_id ON player_transfers (player_wy_id);
CREATE INDEX IF NOT EXISTS idx_player_transfers_from_team_id ON player_transfers (from_team_id);
CREATE INDEX IF NOT EXISTS idx_player_transfers_to_team_id ON player_transfers (to_team_id);
-- Migration 0011: create player_careers table for Wyscout player career stats
CREATE TABLE IF NOT EXISTS player_careers (
id varchar(16) PRIMARY KEY,
player_wy_id integer NOT NULL,
team_id integer NOT NULL,
season_id integer NOT NULL,
competition_id integer NOT NULL,
shirt_number integer NOT NULL DEFAULT 0,
goal integer NOT NULL DEFAULT 0,
penalties integer NOT NULL DEFAULT 0,
appearances integer NOT NULL DEFAULT 0,
yellow_card integer NOT NULL DEFAULT 0,
red_cards integer NOT NULL DEFAULT 0,
substitute_in integer NOT NULL DEFAULT 0,
substitute_out integer NOT NULL DEFAULT 0,
substitute_on_bench integer NOT NULL DEFAULT 0,
minutes_played integer NOT NULL DEFAULT 0,
api_last_synced_at timestamp with time zone,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT uidx_player_careers UNIQUE (player_wy_id, team_id, season_id, competition_id)
);
CREATE INDEX IF NOT EXISTS idx_player_careers_player_wy_id ON player_careers (player_wy_id);
CREATE INDEX IF NOT EXISTS idx_player_careers_season_id ON player_careers (season_id);
CREATE INDEX IF NOT EXISTS idx_player_careers_team_id ON player_careers (team_id);
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'round_type') THEN
IF NOT EXISTS (
SELECT 1
FROM pg_type t
JOIN pg_enum e ON e.enumtypid = t.oid
WHERE t.typname = 'round_type' AND e.enumlabel = 'cup'
) THEN
ALTER TYPE round_type ADD VALUE 'cup';
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_type t
JOIN pg_enum e ON e.enumtypid = t.oid
WHERE t.typname = 'round_type' AND e.enumlabel = 'table'
) THEN
ALTER TYPE round_type ADD VALUE 'table';
END IF;
END IF;
END $$;
-- Migration 0013: create team_careers table for Wyscout team career stats
CREATE TABLE IF NOT EXISTS team_careers (
id varchar(16) PRIMARY KEY,
team_wy_id integer NOT NULL,
season_id integer NOT NULL,
competition_id integer NOT NULL,
round_id integer NOT NULL DEFAULT 0,
round_name text,
group_id integer NOT NULL DEFAULT 0,
group_name text,
rank integer NOT NULL DEFAULT 0,
match_total integer NOT NULL DEFAULT 0,
match_won integer NOT NULL DEFAULT 0,
match_draw integer NOT NULL DEFAULT 0,
match_lost integer NOT NULL DEFAULT 0,
goal_pro integer NOT NULL DEFAULT 0,
goal_against integer NOT NULL DEFAULT 0,
points integer NOT NULL DEFAULT 0,
season_json jsonb,
competition_json jsonb,
api_last_synced_at timestamp with time zone,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT uidx_team_careers UNIQUE (team_wy_id, season_id, competition_id, round_id, group_id)
);
CREATE INDEX IF NOT EXISTS idx_team_careers_team_wy_id ON team_careers (team_wy_id);
CREATE INDEX IF NOT EXISTS idx_team_careers_season_id ON team_careers (season_id);
CREATE INDEX IF NOT EXISTS idx_team_careers_competition_id ON team_careers (competition_id);
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment