Commit d6fd393a by Augusto

players transfers/career and rounds

parent 297c6c7e
...@@ -1103,6 +1103,106 @@ const docTemplate = `{ ...@@ -1103,6 +1103,106 @@ const docTemplate = `{
} }
} }
}, },
"/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.",
"tags": [
"Import"
],
"summary": "Import player career stats from Wyscout",
"parameters": [
{
"type": "integer",
"description": "Limit number of players processed",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Process only one Wyscout player wy_id",
"name": "playerWyId",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/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.",
"tags": [
"Import"
],
"summary": "Import player transfers from Wyscout",
"parameters": [
{
"type": "integer",
"description": "Limit number of players processed",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Process only one Wyscout player wy_id",
"name": "playerWyId",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/referees": { "/import/referees": {
"post": { "post": {
"description": "Imports referees either from Wyscout /v3/referees/{id} (recommended) or legacy TheSports referee list.", "description": "Imports referees either from Wyscout /v3/referees/{id} (recommended) or legacy TheSports referee list.",
...@@ -1169,6 +1269,62 @@ const docTemplate = `{ ...@@ -1169,6 +1269,62 @@ const docTemplate = `{
} }
} }
}, },
"/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.",
"tags": [
"Import"
],
"summary": "Import rounds from Wyscout",
"parameters": [
{
"type": "integer",
"description": "Wyscout round ID",
"name": "roundWyId",
"in": "query"
},
{
"type": "integer",
"description": "Limit number of rounds processed when roundWyId is omitted",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "DB upsert batch size (default 500)",
"name": "batchSize",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/seasons": { "/import/seasons": {
"post": { "post": {
"description": "Performs a season import using TheSports season list API. If ` + "`" + `since` + "`" + ` is provided (unix seconds), only seasons updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.", "description": "Performs a season import using TheSports season list API. If ` + "`" + `since` + "`" + ` is provided (unix seconds), only seasons updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.",
...@@ -1730,6 +1886,106 @@ const docTemplate = `{ ...@@ -1730,6 +1886,106 @@ const docTemplate = `{
"description": "Filter players by birth country name", "description": "Filter players by birth country name",
"name": "country", "name": "country",
"in": "query" "in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"description": "Filter players by role name (supports multiple: role=Defender\u0026role=Midfielder or role=Defender,Midfielder)",
"name": "role",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/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.",
"tags": [
"Players"
],
"summary": "List player career by Hudl ID",
"parameters": [
{
"type": "string",
"description": "Hudl player identifier (players.uid) or numeric wy_id",
"name": "hudlId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/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.",
"tags": [
"Players"
],
"summary": "List player transfers by Hudl ID",
"parameters": [
{
"type": "string",
"description": "Hudl player identifier (players.uid) or numeric wy_id",
"name": "hudlId",
"in": "path",
"required": true
} }
], ],
"responses": { "responses": {
...@@ -1740,6 +1996,24 @@ const docTemplate = `{ ...@@ -1740,6 +1996,24 @@ const docTemplate = `{
"additionalProperties": true "additionalProperties": true
} }
}, },
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": { "500": {
"description": "Internal Server Error", "description": "Internal Server Error",
"schema": { "schema": {
...@@ -3211,13 +3485,48 @@ const docTemplate = `{ ...@@ -3211,13 +3485,48 @@ const docTemplate = `{
"$ref": "#/definitions/handlers.matchTeamOut" "$ref": "#/definitions/handlers.matchTeamOut"
}, },
"round": { "round": {
"$ref": "#/definitions/handlers.matchEntityOut" "$ref": "#/definitions/handlers.matchRoundOut"
}, },
"season": { "season": {
"$ref": "#/definitions/handlers.matchEntityOut" "$ref": "#/definitions/handlers.matchEntityOut"
} }
} }
}, },
"handlers.matchRoundOut": {
"type": "object",
"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"
}
}
},
"handlers.matchTeamOut": { "handlers.matchTeamOut": {
"type": "object", "type": "object",
"properties": { "properties": {
......
...@@ -1096,6 +1096,106 @@ ...@@ -1096,6 +1096,106 @@
} }
} }
}, },
"/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.",
"tags": [
"Import"
],
"summary": "Import player career stats from Wyscout",
"parameters": [
{
"type": "integer",
"description": "Limit number of players processed",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Process only one Wyscout player wy_id",
"name": "playerWyId",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/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.",
"tags": [
"Import"
],
"summary": "Import player transfers from Wyscout",
"parameters": [
{
"type": "integer",
"description": "Limit number of players processed",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Process only one Wyscout player wy_id",
"name": "playerWyId",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/referees": { "/import/referees": {
"post": { "post": {
"description": "Imports referees either from Wyscout /v3/referees/{id} (recommended) or legacy TheSports referee list.", "description": "Imports referees either from Wyscout /v3/referees/{id} (recommended) or legacy TheSports referee list.",
...@@ -1162,6 +1262,62 @@ ...@@ -1162,6 +1262,62 @@
} }
} }
}, },
"/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.",
"tags": [
"Import"
],
"summary": "Import rounds from Wyscout",
"parameters": [
{
"type": "integer",
"description": "Wyscout round ID",
"name": "roundWyId",
"in": "query"
},
{
"type": "integer",
"description": "Limit number of rounds processed when roundWyId is omitted",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "DB upsert batch size (default 500)",
"name": "batchSize",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/seasons": { "/import/seasons": {
"post": { "post": {
"description": "Performs a season import using TheSports season list API. If `since` is provided (unix seconds), only seasons updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.", "description": "Performs a season import using TheSports season list API. If `since` is provided (unix seconds), only seasons updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.",
...@@ -1723,6 +1879,106 @@ ...@@ -1723,6 +1879,106 @@
"description": "Filter players by birth country name", "description": "Filter players by birth country name",
"name": "country", "name": "country",
"in": "query" "in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"description": "Filter players by role name (supports multiple: role=Defender\u0026role=Midfielder or role=Defender,Midfielder)",
"name": "role",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/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.",
"tags": [
"Players"
],
"summary": "List player career by Hudl ID",
"parameters": [
{
"type": "string",
"description": "Hudl player identifier (players.uid) or numeric wy_id",
"name": "hudlId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/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.",
"tags": [
"Players"
],
"summary": "List player transfers by Hudl ID",
"parameters": [
{
"type": "string",
"description": "Hudl player identifier (players.uid) or numeric wy_id",
"name": "hudlId",
"in": "path",
"required": true
} }
], ],
"responses": { "responses": {
...@@ -1733,6 +1989,24 @@ ...@@ -1733,6 +1989,24 @@
"additionalProperties": true "additionalProperties": true
} }
}, },
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": { "500": {
"description": "Internal Server Error", "description": "Internal Server Error",
"schema": { "schema": {
...@@ -3204,13 +3478,48 @@ ...@@ -3204,13 +3478,48 @@
"$ref": "#/definitions/handlers.matchTeamOut" "$ref": "#/definitions/handlers.matchTeamOut"
}, },
"round": { "round": {
"$ref": "#/definitions/handlers.matchEntityOut" "$ref": "#/definitions/handlers.matchRoundOut"
}, },
"season": { "season": {
"$ref": "#/definitions/handlers.matchEntityOut" "$ref": "#/definitions/handlers.matchEntityOut"
} }
} }
}, },
"handlers.matchRoundOut": {
"type": "object",
"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"
}
}
},
"handlers.matchTeamOut": { "handlers.matchTeamOut": {
"type": "object", "type": "object",
"properties": { "properties": {
......
...@@ -362,10 +362,33 @@ definitions: ...@@ -362,10 +362,33 @@ definitions:
homeTeam: homeTeam:
$ref: '#/definitions/handlers.matchTeamOut' $ref: '#/definitions/handlers.matchTeamOut'
round: round:
$ref: '#/definitions/handlers.matchEntityOut' $ref: '#/definitions/handlers.matchRoundOut'
season: season:
$ref: '#/definitions/handlers.matchEntityOut' $ref: '#/definitions/handlers.matchEntityOut'
type: object 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: handlers.matchTeamOut:
properties: properties:
id: id:
...@@ -1139,6 +1162,74 @@ paths: ...@@ -1139,6 +1162,74 @@ paths:
summary: Import players from TheSports summary: Import players from TheSports
tags: tags:
- Import - 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: /import/referees:
post: post:
description: Imports referees either from Wyscout /v3/referees/{id} (recommended) description: Imports referees either from Wyscout /v3/referees/{id} (recommended)
...@@ -1185,6 +1276,44 @@ paths: ...@@ -1185,6 +1276,44 @@ paths:
summary: Import referees summary: Import referees
tags: tags:
- Import - 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: /import/seasons:
post: post:
description: Performs a season import using TheSports season list API. If `since` description: Performs a season import using TheSports season list API. If `since`
...@@ -1578,6 +1707,14 @@ paths: ...@@ -1578,6 +1707,14 @@ paths:
in: query in: query
name: country name: country
type: string 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: responses:
"200": "200":
description: OK description: OK
...@@ -1623,6 +1760,82 @@ paths: ...@@ -1623,6 +1760,82 @@ paths:
summary: Get player by ID summary: Get player by ID
tags: tags:
- Players - 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}: /players/wyscout/{wyId}:
get: get:
description: Returns a single player by its provider (wy_id) identifier. description: Returns a single player by its provider (wy_id) identifier.
......
...@@ -10,6 +10,7 @@ require ( ...@@ -10,6 +10,7 @@ require (
github.com/swaggo/files v1.0.1 github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6 github.com/swaggo/swag v1.16.6
golang.org/x/text v0.27.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
...@@ -59,7 +60,6 @@ require ( ...@@ -59,7 +60,6 @@ require (
golang.org/x/net v0.42.0 // indirect golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.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 golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
......
...@@ -40,6 +40,7 @@ func Connect(cfg config.Config) (*gorm.DB, error) { ...@@ -40,6 +40,7 @@ func Connect(cfg config.Config) (*gorm.DB, error) {
&models.MatchLineupPlayer{}, &models.MatchLineupPlayer{},
&models.MatchFormation{}, &models.MatchFormation{},
&models.PlayerTransfer{}, &models.PlayerTransfer{},
&models.PlayerCareer{},
&models.TeamSquad{}, &models.TeamSquad{},
&models.Standing{}, &models.Standing{},
&models.SampleRecord{}, &models.SampleRecord{},
......
...@@ -2,16 +2,19 @@ package handlers ...@@ -2,16 +2,19 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"ScoutingSystemScoreData/internal/config" "ScoutingSystemScoreData/internal/config"
"ScoutingSystemScoreData/internal/models" "ScoutingSystemScoreData/internal/models"
...@@ -23,6 +26,967 @@ type ImportHandler struct { ...@@ -23,6 +26,967 @@ type ImportHandler struct {
Client *http.Client Client *http.Client
} }
func (h *ImportHandler) ImportTeamCareer(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
ctx := c.Request.Context()
teamWyID := 0
if v := strings.TrimSpace(c.Query("teamWyId")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid teamWyId"})
return
}
teamWyID = n
}
limit := 0
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
workers := 8
if v := strings.TrimSpace(c.Query("workers")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
workers = n
}
}
batchSize := 2000
if v := strings.TrimSpace(c.Query("batchSize")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
batchSize = n
}
}
rps := 12
if v := strings.TrimSpace(c.Query("rps")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
rps = n
}
}
log.Printf("ImportTeamCareer: start teamWyId=%d limit=%d workers=%d batchSize=%d rps=%d", teamWyID, limit, workers, batchSize, rps)
rateTokens := make(chan struct{}, rps)
interval := time.Second / time.Duration(rps)
if interval <= 0 {
interval = time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
go func() {
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
select {
case rateTokens <- struct{}{}:
default:
}
}
}
}()
truncate := func(b []byte) string {
if len(b) > 2048 {
b = b[:2048]
}
return string(b)
}
doGet := func(url string) ([]byte, int, error) {
select {
case <-ctx.Done():
return nil, 0, ctx.Err()
case <-rateTokens:
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
req.SetBasicAuth(user, secret)
resp, err := h.Client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return b, resp.StatusCode, nil
}
getInt := func(m map[string]any, key string) (int, bool) {
v, ok := m[key]
if !ok {
return 0, false
}
switch vv := v.(type) {
case float64:
return int(vv), true
case int:
return vv, true
default:
return 0, false
}
}
getString := func(m map[string]any, key string) (string, bool) {
v, ok := m[key]
if !ok {
return "", false
}
s, ok := v.(string)
if !ok {
return "", false
}
s = strings.TrimSpace(s)
if s == "" {
return "", false
}
return s, true
}
importForTeam := func(wyID int) (int, int) {
url := fmt.Sprintf("https://apirest.wyscout.com/v3/teams/%d/career?details=competition,season", wyID)
log.Printf("ImportTeamCareer: fetching teamWyId=%d url=%s", wyID, url)
body, status, err := doGet(url)
if err != nil {
log.Printf("ImportTeamCareer: failed call (teamWyId=%d): %v", wyID, err)
return 0, 1
}
if status != http.StatusOK {
log.Printf("ImportTeamCareer: non-200 (teamWyId=%d): status=%d body=%s", wyID, status, truncate(body))
return 0, 1
}
var payload struct {
Career []map[string]any `json:"career"`
}
if err := json.Unmarshal(body, &payload); err != nil {
log.Printf("ImportTeamCareer: invalid JSON (teamWyId=%d): %v body=%s", wyID, err, truncate(body))
return 0, 1
}
if len(payload.Career) == 0 {
return 0, 0
}
now := time.Now().UTC()
rows := make([]models.TeamCareer, 0, len(payload.Career))
for _, row := range payload.Career {
if row == nil {
continue
}
teamID, ok := getInt(row, "teamId")
if !ok || teamID <= 0 {
teamID = wyID
}
seasonID, ok := getInt(row, "seasonId")
if !ok || seasonID <= 0 {
continue
}
competitionID, ok := getInt(row, "competitionId")
if !ok || competitionID <= 0 {
continue
}
roundID, _ := getInt(row, "roundId")
groupID, _ := getInt(row, "groupId")
var roundName *string
if s, ok := getString(row, "roundName"); ok {
roundName = &s
}
var groupName *string
if s, ok := getString(row, "groupName"); ok {
groupName = &s
}
rank, _ := getInt(row, "rank")
matchTotal, _ := getInt(row, "matchTotal")
matchWon, _ := getInt(row, "matchWon")
matchDraw, _ := getInt(row, "matchDraw")
matchLost, _ := getInt(row, "matchLost")
goalPro, _ := getInt(row, "goalPro")
goalAgainst, _ := getInt(row, "goalAgainst")
points, _ := getInt(row, "points")
var seasonJSON json.RawMessage
if v, ok := row["season"]; ok && v != nil {
if b, err := json.Marshal(v); err == nil {
seasonJSON = b
}
}
var competitionJSON json.RawMessage
if v, ok := row["competition"]; ok && v != nil {
if b, err := json.Marshal(v); err == nil {
competitionJSON = b
}
}
tc := models.TeamCareer{
TeamWyID: teamID,
SeasonID: seasonID,
CompetitionID: competitionID,
RoundID: roundID,
RoundName: roundName,
GroupID: groupID,
GroupName: groupName,
Rank: rank,
MatchTotal: matchTotal,
MatchWon: matchWon,
MatchDraw: matchDraw,
MatchLost: matchLost,
GoalPro: goalPro,
GoalAgainst: goalAgainst,
Points: points,
SeasonJSON: seasonJSON,
CompetitionJSON: competitionJSON,
APILastSyncedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
rows = append(rows, tc)
}
if len(rows) == 0 {
return 0, 0
}
if err := h.DB.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "team_wy_id"}, {Name: "season_id"}, {Name: "competition_id"}, {Name: "round_id"}, {Name: "group_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"round_name",
"group_name",
"rank",
"match_total",
"match_won",
"match_draw",
"match_lost",
"goal_pro",
"goal_against",
"points",
"season_json",
"competition_json",
"api_last_synced_at",
"updated_at",
}),
}).
CreateInBatches(&rows, 200).Error; err != nil {
log.Printf("ImportTeamCareer: upsert failed (teamWyId=%d): %v", wyID, err)
return 0, 1
}
return len(rows), 0
}
createdTotal := int64(0)
errorsCount := int64(0)
processed := int64(0)
if teamWyID > 0 {
c1, e1 := importForTeam(teamWyID)
processed = 1
createdTotal = int64(c1)
errorsCount = int64(e1)
log.Printf("ImportTeamCareer: finished processed=%d upserted=%d errors=%d", processed, createdTotal, errorsCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Team career import completed",
"data": gin.H{
"processed": processed,
"upserted": createdTotal,
"errors": errorsCount,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
return
}
teamIDsCh := make(chan int, workers*4)
resultsCh := make(chan struct {
upserted int
errs int
}, workers*4)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for id := range teamIDsCh {
c1, e1 := importForTeam(id)
resultsCh <- struct {
upserted int
errs int
}{upserted: c1, errs: e1}
}
}()
}
go func() {
wg.Wait()
close(resultsCh)
}()
produceErr := make(chan error, 1)
go func() {
defer close(teamIDsCh)
errStop := errors.New("stop")
sent := make(map[int]struct{})
count := 0
sendID := func(id int) error {
if id <= 0 {
return nil
}
if _, ok := sent[id]; ok {
return nil
}
count++
if limit > 0 && count > limit {
return errStop
}
select {
case <-ctx.Done():
return ctx.Err()
case teamIDsCh <- id:
sent[id] = struct{}{}
return nil
}
}
// include team IDs from team_children first
var parentIDs []int
if err := h.DB.WithContext(ctx).Model(&models.TeamChild{}).Distinct().Pluck("parent_team_wy_id", &parentIDs).Error; err != nil {
produceErr <- err
return
}
for _, id := range parentIDs {
if err := sendID(id); err != nil {
if errors.Is(err, errStop) {
produceErr <- nil
return
}
produceErr <- err
return
}
}
var childIDs []int
if err := h.DB.WithContext(ctx).Model(&models.TeamChild{}).Distinct().Pluck("child_team_wy_id", &childIDs).Error; err != nil {
produceErr <- err
return
}
for _, id := range childIDs {
if err := sendID(id); err != nil {
if errors.Is(err, errStop) {
produceErr <- nil
return
}
produceErr <- err
return
}
}
// include team IDs from teams table (streamed)
var batchTeams []models.Team
q := h.DB.WithContext(ctx).Model(&models.Team{}).Select("wy_id").Where("wy_id IS NOT NULL")
err := q.FindInBatches(&batchTeams, batchSize, func(tx *gorm.DB, batch int) error {
for _, t := range batchTeams {
if t.WyID == nil || *t.WyID <= 0 {
continue
}
if err := sendID(*t.WyID); err != nil {
return err
}
}
return nil
}).Error
if err != nil && !errors.Is(err, errStop) {
produceErr <- err
return
}
produceErr <- nil
}()
for r := range resultsCh {
processed++
createdTotal += int64(r.upserted)
errorsCount += int64(r.errs)
if processed%50 == 0 {
log.Printf("ImportTeamCareer: progress processed=%d upserted=%d errors=%d", processed, createdTotal, errorsCount)
}
}
if err := <-produceErr; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team wyIds"})
return
}
log.Printf("ImportTeamCareer: finished processed=%d upserted=%d errors=%d", processed, createdTotal, errorsCount)
if ctx.Err() != nil {
c.JSON(http.StatusRequestTimeout, gin.H{"error": "request cancelled"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Team career import completed",
"data": gin.H{
"processed": processed,
"upserted": createdTotal,
"errors": errorsCount,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// ImportPlayerCareer imports player career stats from Wyscout.
// @Summary Import player career stats from Wyscout
// @Description Fetches /v3/players/{playerWyId}/career for players already present in the DB (players.wy_id not null). Upserts records into player_careers.
// @Tags Import
// @Param limit query int false "Limit number of players processed"
// @Param playerWyId query int false "Process only one Wyscout player wy_id"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /import/players/career [post]
func (h *ImportHandler) ImportPlayerCareer(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
playerWyID := 0
if v := strings.TrimSpace(c.Query("playerWyId")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid playerWyId"})
return
}
playerWyID = n
}
limit := 0
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
log.Printf("ImportPlayerCareer: start playerWyId=%d limit=%d", playerWyID, limit)
truncate := func(b []byte) string {
if len(b) > 2048 {
b = b[:2048]
}
return string(b)
}
doGet := func(url string) ([]byte, int, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
req.SetBasicAuth(user, secret)
resp, err := h.Client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return b, resp.StatusCode, nil
}
getInt := func(m map[string]any, key string) (int, bool) {
v, ok := m[key]
if !ok {
return 0, false
}
switch vv := v.(type) {
case float64:
return int(vv), true
case int:
return vv, true
default:
return 0, false
}
}
fetchAndUpsert := func(wyID int) (int, int, int) {
imported, updated, errorsCount := 0, 0, 0
url := fmt.Sprintf("https://apirest.wyscout.com/v3/players/%d/career", wyID)
log.Printf("ImportPlayerCareer: fetching career playerWyId=%d url=%s", wyID, url)
body, status, err := doGet(url)
if err != nil {
log.Printf("ImportPlayerCareer: failed Wyscout career call (playerWyId=%d): %v", wyID, err)
return 0, 0, 1
}
if status != http.StatusOK {
log.Printf("ImportPlayerCareer: non-200 (playerWyId=%d): status=%d body=%s", wyID, status, truncate(body))
return 0, 0, 1
}
var payload struct {
Career []map[string]any `json:"career"`
}
if err := json.Unmarshal(body, &payload); err != nil {
log.Printf("ImportPlayerCareer: invalid JSON (playerWyId=%d): %v body=%s", wyID, err, truncate(body))
return 0, 0, 1
}
if len(payload.Career) == 0 {
log.Printf("ImportPlayerCareer: playerWyId=%d career=0", wyID)
return 0, 0, 0
}
log.Printf("ImportPlayerCareer: playerWyId=%d career=%d", wyID, len(payload.Career))
now := time.Now().UTC()
for _, row := range payload.Career {
if row == nil {
errorsCount++
continue
}
pid, ok := getInt(row, "playerId")
if !ok || pid <= 0 {
pid = wyID
}
teamID, ok := getInt(row, "teamId")
if !ok || teamID <= 0 {
errorsCount++
continue
}
seasonID, ok := getInt(row, "seasonId")
if !ok || seasonID <= 0 {
errorsCount++
continue
}
competitionID, ok := getInt(row, "competitionId")
if !ok || competitionID <= 0 {
errorsCount++
continue
}
var rec models.PlayerCareer
if err := h.DB.
Where("player_wy_id = ? AND team_id = ? AND season_id = ? AND competition_id = ?", pid, teamID, seasonID, competitionID).
First(&rec).Error; err != nil {
if err != gorm.ErrRecordNotFound {
errorsCount++
continue
}
rec = models.PlayerCareer{}
}
rec.PlayerWyID = pid
rec.TeamID = teamID
rec.SeasonID = seasonID
rec.CompetitionID = competitionID
if v, ok := getInt(row, "shirtNumber"); ok {
rec.ShirtNumber = v
}
if v, ok := getInt(row, "goal"); ok {
rec.Goal = v
}
if v, ok := getInt(row, "penalties"); ok {
rec.Penalties = v
}
if v, ok := getInt(row, "appearances"); ok {
rec.Appearances = v
}
if v, ok := getInt(row, "yellowCard"); ok {
rec.YellowCard = v
}
if v, ok := getInt(row, "redCards"); ok {
rec.RedCards = v
}
if v, ok := getInt(row, "substituteIn"); ok {
rec.SubstituteIn = v
}
if v, ok := getInt(row, "substituteOut"); ok {
rec.SubstituteOut = v
}
if v, ok := getInt(row, "substituteOnBench"); ok {
rec.SubOnBench = v
}
if v, ok := getInt(row, "minutesPlayed"); ok {
rec.MinutesPlayed = v
}
rec.APILastSyncedAt = &now
if rec.ID == "" {
if err := h.DB.Create(&rec).Error; err != nil {
errorsCount++
continue
}
imported++
} else {
if err := h.DB.Save(&rec).Error; err != nil {
errorsCount++
continue
}
updated++
}
}
log.Printf("ImportPlayerCareer: playerWyId=%d done imported=%d updated=%d errors=%d", wyID, imported, updated, errorsCount)
return imported, updated, errorsCount
}
imported, updated, errorsCount := 0, 0, 0
processed := 0
if playerWyID > 0 {
i, u, e := fetchAndUpsert(playerWyID)
imported += i
updated += u
errorsCount += e
log.Printf("ImportPlayerCareer: finished single playerWyId=%d imported=%d updated=%d errors=%d", playerWyID, imported, updated, errorsCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Player career import completed",
"data": gin.H{
"imported": imported,
"updated": updated,
"errors": errorsCount,
"playersProcessed": 1,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
return
}
var players []models.Player
if err := h.DB.Select("wy_id").Where("wy_id IS NOT NULL").Find(&players).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list players"})
return
}
log.Printf("ImportPlayerCareer: players in DB with wy_id=%d", len(players))
for _, p := range players {
if p.WyID == nil || *p.WyID <= 0 {
continue
}
if limit > 0 && processed >= limit {
break
}
log.Printf("ImportPlayerCareer: progress player %d/%d wyId=%d", processed+1, len(players), *p.WyID)
i, u, e := fetchAndUpsert(*p.WyID)
imported += i
updated += u
errorsCount += e
processed++
}
log.Printf("ImportPlayerCareer: finished playersProcessed=%d imported=%d updated=%d errors=%d", processed, imported, updated, errorsCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Player career import completed",
"data": gin.H{
"imported": imported,
"updated": updated,
"errors": errorsCount,
"playersProcessed": processed,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// ImportPlayerTransfers imports player transfers from Wyscout.
// @Summary Import player transfers from Wyscout
// @Description Fetches /v3/players/{playerWyId}/transfers for players already present in the DB (players.wy_id not null). Upserts records into player_transfers.
// @Tags Import
// @Param limit query int false "Limit number of players processed"
// @Param playerWyId query int false "Process only one Wyscout player wy_id"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /import/players/transfers [post]
func (h *ImportHandler) ImportPlayerTransfers(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
playerWyID := 0
if v := strings.TrimSpace(c.Query("playerWyId")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid playerWyId"})
return
}
playerWyID = n
}
limit := 0
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
log.Printf("ImportPlayerTransfers: start playerWyId=%d limit=%d", playerWyID, limit)
truncate := func(b []byte) string {
if len(b) > 2048 {
b = b[:2048]
}
return string(b)
}
parseDateAny := func(v any) *time.Time {
switch vv := v.(type) {
case string:
vv = strings.TrimSpace(vv)
if vv == "" || vv == "0000-00-00" {
return nil
}
if t, err := time.Parse(time.RFC3339, vv); err == nil {
ut := t.UTC()
return &ut
}
if t, err := time.Parse("2006-01-02", vv); err == nil {
ut := t.UTC()
return &ut
}
return nil
case float64:
if vv <= 0 {
return nil
}
ut := time.Unix(int64(vv), 0).UTC()
return &ut
default:
return nil
}
}
doGet := func(url string) ([]byte, int, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
req.SetBasicAuth(user, secret)
resp, err := h.Client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return b, resp.StatusCode, nil
}
fetchAndUpsert := func(wyID int) (int, int, int) {
imported, updated, errorsCount := 0, 0, 0
url := fmt.Sprintf("https://apirest.wyscout.com/v3/players/%d/transfers", wyID)
log.Printf("ImportPlayerTransfers: fetching transfers playerWyId=%d url=%s", wyID, url)
body, status, err := doGet(url)
if err != nil {
log.Printf("ImportPlayerTransfers: failed Wyscout transfers call (playerWyId=%d): %v", wyID, err)
return 0, 0, 1
}
if status != http.StatusOK {
log.Printf("ImportPlayerTransfers: non-200 (playerWyId=%d): status=%d body=%s", wyID, status, truncate(body))
return 0, 0, 1
}
var payload struct {
Transfers []map[string]any `json:"transfer"`
WyID int `json:"wyId"`
}
if err := json.Unmarshal(body, &payload); err != nil {
log.Printf("ImportPlayerTransfers: invalid JSON (playerWyId=%d): %v body=%s", wyID, err, truncate(body))
return 0, 0, 1
}
if len(payload.Transfers) == 0 {
log.Printf("ImportPlayerTransfers: playerWyId=%d transfers=0", wyID)
return 0, 0, 0
}
log.Printf("ImportPlayerTransfers: playerWyId=%d transfers=%d", wyID, len(payload.Transfers))
now := time.Now().UTC()
for _, trow := range payload.Transfers {
if trow == nil {
errorsCount++
continue
}
transferID := 0
if v, ok := trow["transferId"].(float64); ok {
transferID = int(v)
}
if transferID <= 0 {
errorsCount++
continue
}
var rec models.PlayerTransfer
if err := h.DB.Where("transfer_id = ?", transferID).First(&rec).Error; err != nil {
if err != gorm.ErrRecordNotFound {
errorsCount++
continue
}
rec = models.PlayerTransfer{}
}
rec.TransferID = transferID
rec.PlayerWyID = wyID
if v, ok := trow["playerId"].(float64); ok {
// prefer API value if present
pid := int(v)
if pid > 0 {
rec.PlayerWyID = pid
}
}
if v, ok := trow["fromTeamId"].(float64); ok {
id := int(v)
rec.FromTeamID = &id
}
if v, ok := trow["toTeamId"].(float64); ok {
id := int(v)
rec.ToTeamID = &id
}
if v, ok := trow["fromTeamName"].(string); ok && strings.TrimSpace(v) != "" {
s := strings.TrimSpace(v)
rec.FromTeamName = &s
}
if v, ok := trow["toTeamName"].(string); ok && strings.TrimSpace(v) != "" {
s := strings.TrimSpace(v)
rec.ToTeamName = &s
}
if v, ok := trow["active"].(float64); ok {
rec.IsActive = int(v) == 1
} else if v, ok := trow["active"].(bool); ok {
rec.IsActive = v
}
rec.StartDate = parseDateAny(trow["startDate"])
rec.EndDate = parseDateAny(trow["endDate"])
if v, ok := trow["type"].(string); ok && strings.TrimSpace(v) != "" {
s := strings.TrimSpace(v)
rec.Type = &s
}
if v, ok := trow["value"].(float64); ok {
iv := int(v)
rec.Value = &iv
}
if v, ok := trow["currency"].(string); ok && strings.TrimSpace(v) != "" {
s := strings.TrimSpace(v)
rec.Currency = &s
}
rec.AnnounceDate = parseDateAny(trow["announceDate"])
rec.APILastSyncedAt = &now
if rec.ID == "" {
if err := h.DB.Create(&rec).Error; err != nil {
errorsCount++
continue
}
imported++
} else {
if err := h.DB.Save(&rec).Error; err != nil {
errorsCount++
continue
}
updated++
}
}
log.Printf("ImportPlayerTransfers: playerWyId=%d done imported=%d updated=%d errors=%d", wyID, imported, updated, errorsCount)
return imported, updated, errorsCount
}
imported, updated, errorsCount := 0, 0, 0
processed := 0
if playerWyID > 0 {
i, u, e := fetchAndUpsert(playerWyID)
imported += i
updated += u
errorsCount += e
log.Printf("ImportPlayerTransfers: finished single playerWyId=%d imported=%d updated=%d errors=%d", playerWyID, imported, updated, errorsCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Player transfers import completed",
"data": gin.H{
"imported": imported,
"updated": updated,
"errors": errorsCount,
"playersProcessed": 1,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
return
}
var players []models.Player
if err := h.DB.Select("wy_id").Where("wy_id IS NOT NULL").Find(&players).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list players"})
return
}
log.Printf("ImportPlayerTransfers: players in DB with wy_id=%d", len(players))
for _, p := range players {
if p.WyID == nil || *p.WyID <= 0 {
continue
}
if limit > 0 && processed >= limit {
break
}
log.Printf("ImportPlayerTransfers: progress player %d/%d wyId=%d", processed+1, len(players), *p.WyID)
i, u, e := fetchAndUpsert(*p.WyID)
imported += i
updated += u
errorsCount += e
processed++
}
log.Printf("ImportPlayerTransfers: finished playersProcessed=%d imported=%d updated=%d errors=%d", processed, imported, updated, errorsCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Player transfers import completed",
"data": gin.H{
"imported": imported,
"updated": updated,
"errors": errorsCount,
"playersProcessed": processed,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// ImportSeasons imports seasons from TheSports API. // ImportSeasons imports seasons from TheSports API.
// @Summary Import seasons from TheSports // @Summary Import seasons from TheSports
// @Description Performs a season import using TheSports season list API. If `since` is provided (unix seconds), only seasons updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination. // @Description Performs a season import using TheSports season list API. If `since` is provided (unix seconds), only seasons updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.
...@@ -950,12 +1914,16 @@ func RegisterImportRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg config.Config) { ...@@ -950,12 +1914,16 @@ func RegisterImportRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg config.Config) {
} }
imp := rg.Group("/import") imp := rg.Group("/import")
imp.POST("/rounds", h.ImportRounds)
imp.POST("/areas", h.ImportAreas) imp.POST("/areas", h.ImportAreas)
imp.POST("/seasons", h.ImportSeasons) imp.POST("/seasons", h.ImportSeasons)
imp.POST("/standings", h.ImportStandings) imp.POST("/standings", h.ImportStandings)
imp.POST("/competitions", h.ImportCompetitions) imp.POST("/competitions", h.ImportCompetitions)
imp.POST("/players", h.ImportPlayers) imp.POST("/players", h.ImportPlayers)
imp.POST("/players/transfers", h.ImportPlayerTransfers)
imp.POST("/players/career", h.ImportPlayerCareer)
imp.POST("/teams", h.ImportTeams) imp.POST("/teams", h.ImportTeams)
imp.POST("/teams/career", h.ImportTeamCareer)
imp.POST("/teams/images", h.ImportTeamImages) imp.POST("/teams/images", h.ImportTeamImages)
imp.POST("/coaches", h.ImportCoaches) imp.POST("/coaches", h.ImportCoaches)
imp.POST("/referees", h.ImportReferees) imp.POST("/referees", h.ImportReferees)
...@@ -968,6 +1936,236 @@ func RegisterImportRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg config.Config) { ...@@ -968,6 +1936,236 @@ func RegisterImportRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg config.Config) {
imp.POST("/matches/formations", h.ImportMatchFormations) imp.POST("/matches/formations", h.ImportMatchFormations)
} }
// ImportRounds imports round metadata from Wyscout.
//
// It fetches /v3/rounds/{roundWyId} for all distinct round_wy_id values found in matches,
// then upserts into rounds.
// @Summary Import rounds from Wyscout
// @Description Fetches /v3/rounds/{roundWyId} for all distinct matches.round_wy_id values (or a single roundWyId if provided) and upserts into rounds.
// @Tags Import
// @Param roundWyId query int false "Wyscout round ID"
// @Param limit query int false "Limit number of rounds processed when roundWyId is omitted"
// @Param batchSize query int false "DB upsert batch size (default 500)"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /import/rounds [post]
func (h *ImportHandler) ImportRounds(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
roundWyID := 0
if v := strings.TrimSpace(c.Query("roundWyId")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid roundWyId"})
return
}
roundWyID = n
}
limit := 0
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
batchSize := 500
if v := strings.TrimSpace(c.Query("batchSize")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
batchSize = n
}
}
truncate := func(b []byte) string {
if len(b) > 2048 {
b = b[:2048]
}
return string(b)
}
doGet := func(url string) ([]byte, int, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
req.SetBasicAuth(user, secret)
resp, err := h.Client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return b, resp.StatusCode, nil
}
type roundPayload struct {
WyID int `json:"wyId"`
Name string `json:"name"`
Type string `json:"type"`
StartDate string `json:"startDate"`
EndDate string `json:"endDate"`
CompetitionID int `json:"competitionId"`
SeasonID int `json:"seasonId"`
}
parseDate := func(s string) *time.Time {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return nil
}
tu := t.UTC()
return &tu
}
toRound := func(p roundPayload) *models.Round {
if p.WyID <= 0 {
return nil
}
rec := &models.Round{}
rec.WyID = p.WyID
rec.Name = p.Name
rec.IsActive = true
if strings.TrimSpace(p.Type) != "" {
rec.RoundType = strings.TrimSpace(p.Type)
}
if p.CompetitionID > 0 {
cwy := p.CompetitionID
rec.CompetitionWyID = &cwy
}
if p.SeasonID > 0 {
swy := p.SeasonID
rec.SeasonWyID = &swy
}
rec.StartDate = parseDate(p.StartDate)
rec.EndDate = parseDate(p.EndDate)
return rec
}
upsertBatch := func(batch []models.Round) error {
if len(batch) == 0 {
return nil
}
return h.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "wy_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"name",
"round_type",
"competition_wy_id",
"season_wy_id",
"start_date",
"end_date",
"updated_at",
}),
}).CreateInBatches(&batch, batchSize).Error
}
roundWyIDs := make([]int, 0, 1024)
if roundWyID > 0 {
roundWyIDs = append(roundWyIDs, roundWyID)
} else {
var rows []struct {
RoundWyID int `gorm:"column:round_wy_id"`
}
q := h.DB.Model(&models.Match{}).Select("DISTINCT round_wy_id").Where("round_wy_id IS NOT NULL")
if limit > 0 {
q = q.Limit(limit)
}
if err := q.Find(&rows).Error; err != nil {
log.Printf("ImportRounds: failed to list match round ids: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list match round ids"})
return
}
for _, r := range rows {
if r.RoundWyID > 0 {
roundWyIDs = append(roundWyIDs, r.RoundWyID)
}
}
}
log.Printf("ImportRounds: rounds to process=%d batchSize=%d", len(roundWyIDs), batchSize)
upserted := 0
errorsCount := 0
processed := 0
batch := make([]models.Round, 0, batchSize)
flush := func() {
if len(batch) == 0 {
return
}
if err := upsertBatch(batch); err != nil {
log.Printf("ImportRounds: batch upsert failed err=%v", err)
errorsCount += len(batch)
} else {
upserted += len(batch)
}
batch = batch[:0]
}
for _, id := range roundWyIDs {
url := fmt.Sprintf("https://apirest.wyscout.com/v3/rounds/%d", id)
log.Printf("ImportRounds: fetching roundWyId=%d", id)
body, status, err := doGet(url)
if err != nil {
log.Printf("ImportRounds: request failed roundWyId=%d err=%v", id, err)
errorsCount++
continue
}
if status != http.StatusOK {
log.Printf("ImportRounds: non-200 roundWyId=%d status=%d body=%s", id, status, truncate(body))
errorsCount++
continue
}
var payload roundPayload
if err := json.Unmarshal(body, &payload); err != nil {
log.Printf("ImportRounds: invalid JSON roundWyId=%d err=%v body=%s", id, err, truncate(body))
errorsCount++
continue
}
rec := toRound(payload)
if rec == nil {
errorsCount++
continue
}
batch = append(batch, *rec)
if len(batch) >= batchSize {
flush()
}
processed++
if limit > 0 && roundWyID == 0 && processed >= limit {
break
}
}
flush()
log.Printf("ImportRounds: done upserted=%d errors=%d processed=%d", upserted, errorsCount, processed)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Rounds import completed",
"data": gin.H{
"upserted": upserted,
"errors": errorsCount,
"roundsProcessed": processed,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// ImportMatchFormations imports match formations from Wyscout v4. // ImportMatchFormations imports match formations from Wyscout v4.
// @Summary Import match formations from Wyscout v4 // @Summary Import match formations from Wyscout v4
// @Description Fetches /v4/matches/{matchWyId}/formations for a given matchWyId. If matchWyId is omitted, processes matches in the DB with a wy_id. Stores formations in match_formations. // @Description Fetches /v4/matches/{matchWyId}/formations for a given matchWyId. If matchWyId is omitted, processes matches in the DB with a wy_id. Stores formations in match_formations.
...@@ -986,6 +2184,8 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) { ...@@ -986,6 +2184,8 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) {
return return
} }
ctx := c.Request.Context()
matchWyID := 0 matchWyID := 0
if v := strings.TrimSpace(c.Query("matchWyId")); v != "" { if v := strings.TrimSpace(c.Query("matchWyId")); v != "" {
n, err := strconv.Atoi(v) n, err := strconv.Atoi(v)
...@@ -1003,7 +2203,49 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) { ...@@ -1003,7 +2203,49 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) {
} }
} }
log.Printf("ImportMatchFormations: start matchWyId=%d limit=%d", matchWyID, limit) workers := 8
if v := strings.TrimSpace(c.Query("workers")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
workers = n
}
}
batchSize := 2000
if v := strings.TrimSpace(c.Query("batchSize")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
batchSize = n
}
}
rps := 12
if v := strings.TrimSpace(c.Query("rps")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
rps = n
}
}
log.Printf("ImportMatchFormations: start matchWyId=%d limit=%d workers=%d batchSize=%d rps=%d", matchWyID, limit, workers, batchSize, rps)
rateTokens := make(chan struct{}, rps)
interval := time.Second / time.Duration(rps)
if interval <= 0 {
interval = time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
go func() {
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
select {
case rateTokens <- struct{}{}:
default:
}
}
}
}()
doGet := func(url string) ([]byte, int, error) { doGet := func(url string) ([]byte, int, error) {
req, err := http.NewRequest(http.MethodGet, url, nil) req, err := http.NewRequest(http.MethodGet, url, nil)
...@@ -1068,7 +2310,7 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) { ...@@ -1068,7 +2310,7 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) {
return 0, 1 return 0, 1
} }
tx := h.DB.Begin() tx := h.DB.WithContext(ctx).Begin()
if err := tx.Error; err != nil { if err := tx.Error; err != nil {
log.Printf("ImportMatchFormations: matchWyId=%d tx begin failed: %v", matchWyID, err) log.Printf("ImportMatchFormations: matchWyId=%d tx begin failed: %v", matchWyID, err)
return 0, 1 return 0, 1
...@@ -1088,7 +2330,8 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) { ...@@ -1088,7 +2330,8 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) {
now := time.Now().UTC() now := time.Now().UTC()
created := 0 created := 0
persistSide := func(side string, segs []formationSegment) { persistSide := func(side string, segs []formationSegment) bool {
rows := make([]models.MatchFormation, 0, len(segs))
for _, seg := range segs { for _, seg := range segs {
if seg.ID <= 0 { if seg.ID <= 0 {
continue continue
...@@ -1106,7 +2349,7 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) { ...@@ -1106,7 +2349,7 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) {
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
log.Printf("ImportMatchFormations: matchWyId=%d failed to encode players wyId=%d: %v", matchWyID, seg.ID, err) log.Printf("ImportMatchFormations: matchWyId=%d failed to encode players wyId=%d: %v", matchWyID, seg.ID, err)
return return false
} }
mf := models.MatchFormation{ mf := models.MatchFormation{
...@@ -1116,6 +2359,8 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) { ...@@ -1116,6 +2359,8 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) {
APILastSyncedAt: &now, APILastSyncedAt: &now,
APISyncStatus: "synced", APISyncStatus: "synced",
PlayersJSON: playersRaw, PlayersJSON: playersRaw,
CreatedAt: now,
UpdatedAt: now,
} }
if scheme != "" { if scheme != "" {
mf.Scheme = &scheme mf.Scheme = &scheme
...@@ -1139,21 +2384,25 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) { ...@@ -1139,21 +2384,25 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) {
mf.PlayersOnField = &pof mf.PlayersOnField = &pof
} }
if err := tx.Create(&mf).Error; err != nil { rows = append(rows, mf)
tx.Rollback()
log.Printf("ImportMatchFormations: matchWyId=%d insert failed wyId=%d side=%s: %v", matchWyID, seg.ID, side, err)
return
} }
created++ if len(rows) == 0 {
return true
}
if err := tx.CreateInBatches(&rows, 200).Error; err != nil {
tx.Rollback()
log.Printf("ImportMatchFormations: matchWyId=%d batch insert failed side=%s: %v", matchWyID, side, err)
return false
} }
created += len(rows)
return true
} }
persistSide("home", payload.Home) if ok := persistSide("home", payload.Home); !ok {
if tx.Error != nil {
return created, 1 return created, 1
} }
persistSide("away", payload.Away) if ok := persistSide("away", payload.Away); !ok {
if tx.Error != nil {
return created, 1 return created, 1
} }
...@@ -1166,36 +2415,100 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) { ...@@ -1166,36 +2415,100 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) {
return created, 0 return created, 0
} }
matchIDs := make([]int, 0) createdTotal := int64(0)
errorsCount := int64(0)
processed := int64(0)
if matchWyID > 0 { if matchWyID > 0 {
matchIDs = append(matchIDs, matchWyID) c1, e1 := importForMatch(matchWyID)
} else { processed = 1
q := h.DB.Model(&models.Match{}).Select("wy_id").Where("wy_id IS NOT NULL") createdTotal = int64(c1)
if limit > 0 { errorsCount = int64(e1)
q = q.Limit(limit) log.Printf("ImportMatchFormations: finished processed=%d createdTotal=%d errors=%d", processed, createdTotal, errorsCount)
} c.JSON(http.StatusOK, gin.H{
if err := q.Pluck("wy_id", &matchIDs).Error; err != nil { "success": true,
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list match wyIds"}) "message": "Match formations import completed",
"data": gin.H{
"processed": processed,
"created": createdTotal,
"errors": errorsCount,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
return return
} }
matchIDsCh := make(chan int, workers*4)
resultsCh := make(chan struct {
created int
errs int
}, workers*4)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for id := range matchIDsCh {
c1, e1 := importForMatch(id)
resultsCh <- struct {
created int
errs int
}{created: c1, errs: e1}
}
}()
} }
createdTotal := 0 go func() {
errorsCount := 0 wg.Wait()
processed := 0 close(resultsCh)
for _, id := range matchIDs { }()
if id <= 0 {
produceErr := make(chan error, 1)
go func() {
defer close(matchIDsCh)
errStop := errors.New("stop")
count := 0
var batchMatches []models.Match
q := h.DB.WithContext(ctx).Model(&models.Match{}).Select("wy_id").Where("wy_id IS NOT NULL")
err := q.FindInBatches(&batchMatches, batchSize, func(tx *gorm.DB, batch int) error {
for _, m := range batchMatches {
if m.WyID == nil || *m.WyID <= 0 {
continue continue
} }
count++
if limit > 0 && count > limit {
return errStop
}
select {
case <-ctx.Done():
return ctx.Err()
case matchIDsCh <- *m.WyID:
}
}
return nil
}).Error
if err != nil && !errors.Is(err, errStop) {
produceErr <- err
return
}
produceErr <- nil
}()
for r := range resultsCh {
processed++ processed++
log.Printf("ImportMatchFormations: processing %d/%d matchWyId=%d", processed, len(matchIDs), id) createdTotal += int64(r.created)
created, errs := importForMatch(id) errorsCount += int64(r.errs)
createdTotal += created
errorsCount += errs
if processed%50 == 0 { if processed%50 == 0 {
log.Printf("ImportMatchFormations: progress processed=%d createdTotal=%d errors=%d", processed, createdTotal, errorsCount) log.Printf("ImportMatchFormations: progress processed=%d createdTotal=%d errors=%d", processed, createdTotal, errorsCount)
} }
} }
if err := <-produceErr; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list match wyIds"})
return
}
log.Printf("ImportMatchFormations: finished processed=%d createdTotal=%d errors=%d", processed, createdTotal, errorsCount) log.Printf("ImportMatchFormations: finished processed=%d createdTotal=%d errors=%d", processed, createdTotal, errorsCount)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
......
...@@ -54,12 +54,25 @@ type matchEntityOut struct { ...@@ -54,12 +54,25 @@ type matchEntityOut struct {
Name *string `json:"name,omitempty"` 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 { type matchOut struct {
Match models.Match `json:"-"` Match models.Match `json:"-"`
HomeTeam *matchTeamOut `json:"homeTeam,omitempty"` HomeTeam *matchTeamOut `json:"homeTeam,omitempty"`
AwayTeam *matchTeamOut `json:"awayTeam,omitempty"` AwayTeam *matchTeamOut `json:"awayTeam,omitempty"`
Season *matchEntityOut `json:"season,omitempty"` Season *matchEntityOut `json:"season,omitempty"`
Round *matchEntityOut `json:"round,omitempty"` Round *matchRoundOut `json:"round,omitempty"`
Competition *matchEntityOut `json:"competition,omitempty"` Competition *matchEntityOut `json:"competition,omitempty"`
} }
...@@ -98,6 +111,7 @@ func (m matchOut) MarshalJSON() ([]byte, error) { ...@@ -98,6 +111,7 @@ func (m matchOut) MarshalJSON() ([]byte, error) {
delete(base, "refereeTsId") delete(base, "refereeTsId")
delete(base, "relatedTsId") delete(base, "relatedTsId")
delete(base, "roundGroupNum") delete(base, "roundGroupNum")
delete(base, "roundNum")
delete(base, "roundStageTsId") delete(base, "roundStageTsId")
delete(base, "seasonTsId") delete(base, "seasonTsId")
delete(base, "statusId") delete(base, "statusId")
...@@ -215,21 +229,49 @@ func (h *MatchHandler) enrichMatches(ctx context.Context, matches []models.Match ...@@ -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 { if len(roundWyIDs) > 0 {
var rounds []struct { var rounds []struct {
ID string `gorm:"column:id"`
WyID int `gorm:"column:wy_id"` WyID int `gorm:"column:wy_id"`
Name string `gorm:"column:name"` 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). if err := h.DB.WithContext(ctx).
Model(&models.Round{}). 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). Where("wy_id IN ?", roundWyIDs).
Find(&rounds).Error; err != nil { Find(&rounds).Error; err != nil {
return nil, err return nil, err
} }
for _, r := range rounds { 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 ...@@ -350,13 +392,14 @@ func (h *MatchHandler) enrichMatches(ctx context.Context, matches []models.Match
season.Name = &n season.Name = &n
} }
} }
var round *matchEntityOut var round *matchRoundOut
if m.RoundWyID != nil { if m.RoundWyID != nil {
if r, ok := roundByWyID[*m.RoundWyID]; ok {
rr := r
round = &rr
} else {
wyID := *m.RoundWyID wyID := *m.RoundWyID
round = &matchEntityOut{WyID: &wyID} round = &matchRoundOut{WyID: &wyID}
if name, ok := roundByWyID[*m.RoundWyID]; ok && name != "" {
n := name
round.Name = &n
} }
} }
out = append(out, matchOut{ out = append(out, matchOut{
......
...@@ -15,6 +15,7 @@ import ( ...@@ -15,6 +15,7 @@ import (
) )
type PlayerHandler struct { type PlayerHandler struct {
DB *gorm.DB
Service services.PlayerService Service services.PlayerService
Teams services.TeamService Teams services.TeamService
Areas services.AreaService Areas services.AreaService
...@@ -24,12 +25,14 @@ func RegisterPlayerRoutes(rg *gin.RouterGroup, db *gorm.DB) { ...@@ -24,12 +25,14 @@ func RegisterPlayerRoutes(rg *gin.RouterGroup, db *gorm.DB) {
service := services.NewPlayerService(db) service := services.NewPlayerService(db)
teamService := services.NewTeamService(db) teamService := services.NewTeamService(db)
areaService := services.NewAreaService(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 := rg.Group("/players")
players.GET("", h.List) players.GET("", h.List)
players.GET("/wyscout/:wyId", h.GetByProviderID) players.GET("/wyscout/:wyId", h.GetByProviderID)
players.GET("/provider/:providerId", h.GetByAnyProviderID) 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) players.GET("/:id", h.GetByID)
} }
...@@ -69,6 +72,7 @@ type StructuredPlayer struct { ...@@ -69,6 +72,7 @@ type StructuredPlayer struct {
CurrentNationalTeamID *int `json:"-"` CurrentNationalTeamID *int `json:"-"`
CountryTsID *string `json:"-"` CountryTsID *string `json:"-"`
CountryName *string `json:"-"` CountryName *string `json:"-"`
Country *AreaSummary `json:"country,omitempty"`
Gender *string `json:"gender"` Gender *string `json:"gender"`
Status string `json:"status"` Status string `json:"status"`
JerseyNumber *int `json:"-"` JerseyNumber *int `json:"-"`
...@@ -261,6 +265,28 @@ func addPlayerCountries(structured []StructuredPlayer, players []models.Player, ...@@ -261,6 +265,28 @@ func addPlayerCountries(structured []StructuredPlayer, players []models.Player,
if a, ok := areasByTsID[tsID]; ok { if a, ok := areasByTsID[tsID]; ok {
name := a.Name name := a.Name
structured[i].CountryName = &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 { ...@@ -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 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 teamId query string false "Filter players by current team ID"
// @Param country query string false "Filter players by birth country name" // @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{} // @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
// @Router /players [get] // @Router /players [get]
...@@ -353,6 +380,23 @@ func (h *PlayerHandler) List(c *gin.Context) { ...@@ -353,6 +380,23 @@ func (h *PlayerHandler) List(c *gin.Context) {
name := c.Query("name") name := c.Query("name")
teamID := c.Query("teamId") teamID := c.Query("teamId")
country := c.Query("country") 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" endpoint := "/players"
if name != "" { if name != "" {
...@@ -369,6 +413,7 @@ func (h *PlayerHandler) List(c *gin.Context) { ...@@ -369,6 +413,7 @@ func (h *PlayerHandler) List(c *gin.Context) {
Name: name, Name: name,
TeamID: teamID, TeamID: teamID,
Country: country, Country: country,
Roles: roles,
}) })
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
...@@ -706,6 +751,209 @@ func (h *PlayerHandler) GetByID(c *gin.Context) { ...@@ -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) // GetByAnyProviderID returns a single player by wy_id (numeric) or ts_id (string)
func (h *PlayerHandler) GetByAnyProviderID(c *gin.Context) { func (h *PlayerHandler) GetByAnyProviderID(c *gin.Context) {
providerID := c.Param("providerId") providerID := c.Param("providerId")
......
...@@ -160,6 +160,43 @@ func (h *TeamHandler) ListPlayersByWyID(c *gin.Context) { ...@@ -160,6 +160,43 @@ func (h *TeamHandler) ListPlayersByWyID(c *gin.Context) {
structured = append(structured, toStructuredPlayer(p)) 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)) wyIDs := make([]int, 0, len(players))
wySeen := make(map[int]struct{}, len(players)) wySeen := make(map[int]struct{}, len(players))
for _, p := range players { for _, p := range players {
......
...@@ -495,35 +495,111 @@ func (mf *MatchFormation) BeforeCreate(tx *gorm.DB) (err error) { ...@@ -495,35 +495,111 @@ func (mf *MatchFormation) BeforeCreate(tx *gorm.DB) (err error) {
type PlayerTransfer struct { type PlayerTransfer struct {
ID string `gorm:"primaryKey;size:16" json:"id"` ID string `gorm:"primaryKey;size:16" json:"id"`
WyID int `gorm:"column:wy_id;uniqueIndex" json:"wyId"` TransferID int `gorm:"column:transfer_id;uniqueIndex" json:"transferId"`
TsID int `gorm:"column:ts_id;uniqueIndex" json:"tsId"` PlayerWyID int `gorm:"column:player_wy_id;index" json:"playerId"`
PlayerWyID *int `gorm:"column:player_wy_id" json:"playerWyId"` FromTeamID *int `gorm:"column:from_team_id" json:"fromTeamId"`
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"`
FromTeamName *string `gorm:"column:from_team_name" json:"fromTeamName"` 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"` 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"` EndDate *time.Time `gorm:"column:end_date" json:"endDate"`
TransferType *string `gorm:"column:transfer_type" json:"transferType"` Type *string `gorm:"column:type" json:"type"`
TransferFee *float64 `gorm:"column:transfer_fee" json:"transferFee"` Value *int `gorm:"column:value" json:"value"`
Currency string `gorm:"column:currency;default:EUR" json:"currency"` Currency *string `gorm:"column:currency" json:"currency"`
ContractLength *int `gorm:"column:contract_length" json:"contractLength"` AnnounceDate *time.Time `gorm:"column:announce_date" json:"announceDate"`
Season *string `gorm:"column:season" json:"season"` APILastSyncedAt *time.Time `gorm:"column:api_last_synced_at" json:"apiLastSyncedAt"`
IsActive bool `gorm:"column:is_active;default:false" json:"isActive"` CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
IsLoan bool `gorm:"column:is_loan;default:false" json:"isLoan"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
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"` func (pt *PlayerTransfer) BeforeCreate(tx *gorm.DB) (err error) {
AnnouncementDate *time.Time `gorm:"column:announcement_date" json:"announcementDate"` if pt.ID != "" {
SourceURL *string `gorm:"column:source_url" json:"sourceUrl"` 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"` APILastSyncedAt *time.Time `gorm:"column:api_last_synced_at" json:"apiLastSyncedAt"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` 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 { type TeamSquad struct {
ID string `gorm:"primaryKey;size:16" json:"id"` ID string `gorm:"primaryKey;size:16" json:"id"`
TeamWyID *int `gorm:"column:team_wy_id" json:"teamWyId"` TeamWyID *int `gorm:"column:team_wy_id" json:"teamWyId"`
...@@ -597,3 +673,17 @@ type Round struct { ...@@ -597,3 +673,17 @@ type Round struct {
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deletedAt"` 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 ...@@ -3,11 +3,13 @@ package services
import ( import (
"context" "context"
"strconv" "strconv"
"strings"
"gorm.io/gorm" "gorm.io/gorm"
"ScoutingSystemScoreData/internal/errors" "ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models" "ScoutingSystemScoreData/internal/models"
"ScoutingSystemScoreData/internal/utils"
) )
type CoachService interface { type CoachService interface {
...@@ -39,11 +41,59 @@ func (s *coachService) ListCoaches(ctx context.Context, opts ListCoachesOptions) ...@@ -39,11 +41,59 @@ func (s *coachService) ListCoaches(ctx context.Context, opts ListCoachesOptions)
query := s.db.WithContext(ctx).Model(&models.Coach{}) query := s.db.WithContext(ctx).Model(&models.Coach{})
if opts.Name != "" { if opts.Name != "" {
like := "%" + opts.Name + "%" // 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( query = query.Where(
"first_name ILIKE ? OR last_name ILIKE ? OR middle_name ILIKE ? OR short_name ILIKE ?", "LOWER(coaches.short_name) ILIKE ? OR LOWER(coaches.first_name) ILIKE ? OR LOWER(coaches.middle_name) ILIKE ? OR LOWER(coaches.last_name) ILIKE ?",
like, like, like, like, 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 != "" { } else if opts.TeamID != "" {
query = query.Where("current_team_wy_id = ?", opts.TeamID) query = query.Where("current_team_wy_id = ?", opts.TeamID)
} else if opts.Position != "" { } else if opts.Position != "" {
...@@ -54,18 +104,25 @@ func (s *coachService) ListCoaches(ctx context.Context, opts ListCoachesOptions) ...@@ -54,18 +104,25 @@ func (s *coachService) ListCoaches(ctx context.Context, opts ListCoachesOptions)
query = query.Where("is_active = ?", true) query = query.Where("is_active = ?", true)
} }
if opts.Name == "" {
query = query.Order("is_active DESC") 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 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("CASE WHEN years_experience IS NULL THEN 1 ELSE 0 END")
query = query.Order("years_experience DESC") query = query.Order("years_experience DESC")
query = query.Order("last_name ASC") query = query.Order("last_name ASC")
query = query.Order("first_name ASC") query = query.Order("first_name ASC")
}
var total int64 var total int64
if err := query.Count(&total).Error; err != nil { if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to count coaches", err) 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 { 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) return nil, 0, errors.Wrap(errors.CodeInternal, "failed to fetch coaches", err)
} }
......
...@@ -3,11 +3,14 @@ package services ...@@ -3,11 +3,14 @@ package services
import ( import (
"context" "context"
"strconv" "strconv"
"strings"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"ScoutingSystemScoreData/internal/errors" "ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models" "ScoutingSystemScoreData/internal/models"
"ScoutingSystemScoreData/internal/utils"
) )
type PlayerService interface { type PlayerService interface {
...@@ -15,6 +18,9 @@ type PlayerService interface { ...@@ -15,6 +18,9 @@ type PlayerService interface {
GetByID(ctx context.Context, id string) (models.Player, error) GetByID(ctx context.Context, id string) (models.Player, error)
GetByProviderID(ctx context.Context, wyID int) (models.Player, error) GetByProviderID(ctx context.Context, wyID int) (models.Player, error)
GetByAnyProviderID(ctx context.Context, providerID string) (models.Player, error) GetByAnyProviderID(ctx context.Context, providerID string) (models.Player, error)
GetByHudlID(ctx context.Context, hudlID string) (models.Player, error)
ListTransfersByHudlID(ctx context.Context, hudlID string) ([]models.PlayerTransfer, error)
ListCareerByHudlID(ctx context.Context, hudlID string) ([]models.PlayerCareer, error)
} }
type ListPlayersOptions struct { type ListPlayersOptions struct {
...@@ -23,6 +29,7 @@ type ListPlayersOptions struct { ...@@ -23,6 +29,7 @@ type ListPlayersOptions struct {
Name string Name string
TeamID string TeamID string
Country string Country string
Roles []string
} }
type playerService struct { type playerService struct {
...@@ -35,32 +42,136 @@ func NewPlayerService(db *gorm.DB) PlayerService { ...@@ -35,32 +42,136 @@ func NewPlayerService(db *gorm.DB) PlayerService {
func (s *playerService) ListPlayers(ctx context.Context, opts ListPlayersOptions) ([]models.Player, int64, error) { func (s *playerService) ListPlayers(ctx context.Context, opts ListPlayersOptions) ([]models.Player, int64, error) {
var players []models.Player var players []models.Player
query := s.db.WithContext(ctx).Model(&models.Player{}).Where("is_active = ?", true) baseQuery := s.db.WithContext(ctx).Model(&models.Player{}).Where("players.is_active = ?", true)
if len(opts.Roles) > 0 {
roles := make([]string, 0, len(opts.Roles))
seen := make(map[string]struct{}, len(opts.Roles))
for _, r := range opts.Roles {
r = strings.TrimSpace(strings.ToLower(r))
if r == "" {
continue
}
if _, ok := seen[r]; ok {
continue
}
seen[r] = struct{}{}
roles = append(roles, r)
}
if len(roles) > 0 {
baseQuery = baseQuery.Where("LOWER(players.role_name) IN ?", roles)
}
}
if opts.Name != "" { if opts.Name != "" {
likePattern := "%" + opts.Name + "%" // Normalize the search term for better matching
query = query.Where( normalizedSearch := utils.NormalizeText(opts.Name)
"short_name ILIKE ? OR first_name ILIKE ? OR middle_name ILIKE ? OR last_name ILIKE ?", searchTokens := utils.TokenizeSearchTerm(opts.Name)
// Build flexible search conditions
likePattern := "%" + normalizedSearch + "%"
// Create search conditions for normalized text
// Using LOWER() and unaccent-like matching through normalized comparison
searchConditions := s.db.Where(
"LOWER(REGEXP_REPLACE(players.short_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(players.first_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(players.middle_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ? OR "+
"LOWER(REGEXP_REPLACE(players.last_name, '[^a-zA-Z0-9 ]', '', 'g')) ILIKE ?",
likePattern, likePattern, likePattern, likePattern, likePattern, likePattern, likePattern, likePattern,
) )
query = query.Order("CASE WHEN market_value IS NULL THEN 1 ELSE 0 END")
query = query.Order("market_value DESC") // Also search with original pattern for exact matches
query = query.Order("CASE WHEN current_national_team_id IS NULL THEN 1 ELSE 0 END") originalPattern := "%" + strings.ToLower(opts.Name) + "%"
query = query.Order("CASE WHEN current_team_id IS NULL AND (team_ts_id IS NULL OR team_ts_id = '') THEN 1 ELSE 0 END") searchConditions = searchConditions.Or(
query = query.Order("last_name ASC") "players.short_name ILIKE ? OR players.first_name ILIKE ? OR players.middle_name ILIKE ? OR players.last_name ILIKE ?",
query = query.Order("first_name ASC") originalPattern, originalPattern, originalPattern, originalPattern,
)
// Build multi-token filter as:
// (phrase-like match across fields) OR (ALL tokens match somewhere across fields)
nameFilter := s.db.Where(searchConditions)
if len(searchTokens) > 1 {
tokenFilter := s.db
for _, token := range searchTokens {
tokenPattern := "%" + token + "%"
tokenFilter = tokenFilter.Where(
"LOWER(players.short_name) ILIKE ? OR LOWER(players.first_name) ILIKE ? OR LOWER(players.middle_name) ILIKE ? OR LOWER(players.last_name) ILIKE ?",
tokenPattern, tokenPattern, tokenPattern, tokenPattern,
)
}
nameFilter = nameFilter.Or(tokenFilter)
}
baseQuery = baseQuery.Where(nameFilter)
// Always rank best name match first.
// This prevents a "big competition" result from outranking an exact name hit.
fetchQuery := baseQuery
fetchQuery = fetchQuery.Order(clause.Expr{SQL: "CASE " +
"WHEN LOWER(REGEXP_REPLACE(players.short_name, '[^a-zA-Z0-9 ]', '', 'g')) = ? THEN 0 " +
"WHEN TRIM(LOWER(REGEXP_REPLACE(players.first_name, '[^a-zA-Z0-9 ]', '', 'g')) || ' ' || LOWER(REGEXP_REPLACE(players.last_name, '[^a-zA-Z0-9 ]', '', 'g'))) = ? THEN 1 " +
"WHEN TRIM(LOWER(REGEXP_REPLACE(players.first_name, '[^a-zA-Z0-9 ]', '', 'g')) || ' ' || COALESCE(LOWER(REGEXP_REPLACE(players.middle_name, '[^a-zA-Z0-9 ]', '', 'g')) || ' ', '') || LOWER(REGEXP_REPLACE(players.last_name, '[^a-zA-Z0-9 ]', '', 'g'))) = ? THEN 2 " +
"WHEN LOWER(REGEXP_REPLACE(players.last_name, '[^a-zA-Z0-9 ]', '', 'g')) = ? THEN 3 " +
"WHEN LOWER(REGEXP_REPLACE(players.first_name, '[^a-zA-Z0-9 ]', '', 'g')) = ? THEN 4 " +
"ELSE 5 END",
Vars: []interface{}{normalizedSearch, normalizedSearch, normalizedSearch, normalizedSearch, normalizedSearch},
})
// Prioritization logic:
// 1. Players with current team (especially from big competitions)
// 2. Players with market value (indicates active/professional status)
// 3. Players with national team
// 4. Alphabetical by name
// Join with teams to check for big competitions
fetchQuery = fetchQuery.Joins("LEFT JOIN teams ON teams.wy_id = players.current_team_id")
fetchQuery = fetchQuery.Joins("LEFT JOIN competitions ON competitions.wy_id = teams.competition_wy_id")
// Tie-breakers after name match rank.
// Priority 1: Big competitions (top 5 leagues + Champions League, etc.)
// Competition WyIDs for major leagues:
// Premier League: 364, La Liga: 795, Bundesliga: 426, Serie A: 524, Ligue 1: 412
// Champions League: 102, Europa League: 103, etc.
fetchQuery = fetchQuery.Order("CASE WHEN teams.competition_wy_id IN (364, 795, 426, 524, 412, 102, 103) THEN 0 ELSE 1 END")
// Priority 2: Has current team
fetchQuery = fetchQuery.Order("CASE WHEN players.current_team_id IS NOT NULL THEN 0 ELSE 1 END")
// Priority 3: Market value (higher is better)
fetchQuery = fetchQuery.Order("CASE WHEN players.market_value IS NULL THEN 1 ELSE 0 END")
fetchQuery = fetchQuery.Order("players.market_value DESC")
// Priority 4: Has national team
fetchQuery = fetchQuery.Order("CASE WHEN players.current_national_team_id IS NULL THEN 1 ELSE 0 END")
// Priority 5: Alphabetical
fetchQuery = fetchQuery.Order("players.last_name ASC")
fetchQuery = fetchQuery.Order("players.first_name ASC")
var total int64
if err := baseQuery.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to count players", err)
}
// Select only player fields to avoid conflicts with joined tables
fetchQuery = fetchQuery.Select("players.*")
if err := fetchQuery.Limit(opts.Limit).Offset(opts.Offset).Find(&players).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to fetch players", err)
}
return players, total, nil
} else if opts.TeamID != "" { } else if opts.TeamID != "" {
query = query.Where("current_team_id = ?", opts.TeamID) baseQuery = baseQuery.Where("current_team_id = ?", opts.TeamID)
} else if opts.Country != "" { } else if opts.Country != "" {
query = query.Joins("JOIN areas ON areas.wy_id = players.birth_area_wy_id").Where("areas.name = ?", opts.Country) baseQuery = baseQuery.Joins("JOIN areas ON areas.wy_id = players.birth_area_wy_id").Where("areas.name = ?", opts.Country)
} }
var total int64 var total int64
if err := query.Count(&total).Error; err != nil { if err := baseQuery.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to count players", err) return nil, 0, errors.Wrap(errors.CodeInternal, "failed to count players", err)
} }
if err := query.Limit(opts.Limit).Offset(opts.Offset).Find(&players).Error; err != nil { if err := baseQuery.Limit(opts.Limit).Offset(opts.Offset).Find(&players).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to fetch players", err) return nil, 0, errors.Wrap(errors.CodeInternal, "failed to fetch players", err)
} }
...@@ -110,3 +221,83 @@ func (s *playerService) GetByProviderID(ctx context.Context, wyID int) (models.P ...@@ -110,3 +221,83 @@ func (s *playerService) GetByProviderID(ctx context.Context, wyID int) (models.P
} }
return player, nil return player, nil
} }
func (s *playerService) GetByHudlID(ctx context.Context, hudlID string) (models.Player, error) {
hudlID = strings.TrimSpace(hudlID)
if hudlID == "" {
return models.Player{}, errors.New(errors.CodeInvalidInput, "hudlId is required")
}
var player models.Player
if err := s.db.WithContext(ctx).First(&player, "uid = ?", hudlID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Player{}, errors.New(errors.CodeNotFound, "player not found")
}
return models.Player{}, errors.Wrap(errors.CodeInternal, "failed to fetch player", err)
}
return player, nil
}
func (s *playerService) listTransfersByPlayerWyID(ctx context.Context, playerWyID int) ([]models.PlayerTransfer, error) {
if playerWyID <= 0 {
return nil, errors.New(errors.CodeInvalidInput, "player wy_id is required")
}
var transfers []models.PlayerTransfer
if err := s.db.WithContext(ctx).
Where("player_wy_id = ?", playerWyID).
Order("start_date DESC").
Find(&transfers).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternal, "failed to fetch player transfers", err)
}
return transfers, nil
}
func (s *playerService) listCareerByPlayerWyID(ctx context.Context, playerWyID int) ([]models.PlayerCareer, error) {
if playerWyID <= 0 {
return nil, errors.New(errors.CodeInvalidInput, "player wy_id is required")
}
var career []models.PlayerCareer
if err := s.db.WithContext(ctx).
Where("player_wy_id = ?", playerWyID).
Order("season_id DESC").
Find(&career).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternal, "failed to fetch player career", err)
}
return career, nil
}
func (s *playerService) ListTransfersByHudlID(ctx context.Context, hudlID string) ([]models.PlayerTransfer, error) {
hudlID = strings.TrimSpace(hudlID)
player, err := s.GetByHudlID(ctx, hudlID)
if err != nil {
if errors.IsCode(err, errors.CodeNotFound) {
if wyID, convErr := strconv.Atoi(hudlID); convErr == nil && wyID > 0 {
return s.listTransfersByPlayerWyID(ctx, wyID)
}
}
return nil, err
}
if player.WyID == nil || *player.WyID <= 0 {
return nil, errors.New(errors.CodeInvalidInput, "player does not have a wy_id")
}
return s.listTransfersByPlayerWyID(ctx, *player.WyID)
}
func (s *playerService) ListCareerByHudlID(ctx context.Context, hudlID string) ([]models.PlayerCareer, error) {
hudlID = strings.TrimSpace(hudlID)
player, err := s.GetByHudlID(ctx, hudlID)
if err != nil {
if errors.IsCode(err, errors.CodeNotFound) {
if wyID, convErr := strconv.Atoi(hudlID); convErr == nil && wyID > 0 {
return s.listCareerByPlayerWyID(ctx, wyID)
}
}
return nil, err
}
if player.WyID == nil || *player.WyID <= 0 {
return nil, errors.New(errors.CodeInvalidInput, "player does not have a wy_id")
}
return s.listCareerByPlayerWyID(ctx, *player.WyID)
}
...@@ -3,11 +3,13 @@ package services ...@@ -3,11 +3,13 @@ package services
import ( import (
"context" "context"
"strconv" "strconv"
"strings"
"gorm.io/gorm" "gorm.io/gorm"
"ScoutingSystemScoreData/internal/errors" "ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models" "ScoutingSystemScoreData/internal/models"
"ScoutingSystemScoreData/internal/utils"
) )
type RefereeService interface { type RefereeService interface {
...@@ -39,12 +41,54 @@ func (s *refereeService) ListReferees(ctx context.Context, opts ListRefereesOpti ...@@ -39,12 +41,54 @@ func (s *refereeService) ListReferees(ctx context.Context, opts ListRefereesOpti
query := s.db.WithContext(ctx).Model(&models.Referee{}) query := s.db.WithContext(ctx).Model(&models.Referee{})
if opts.Name != "" { if opts.Name != "" {
like := "%" + opts.Name + "%" // 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( query = query.Where(
"first_name ILIKE ? OR last_name ILIKE ? OR middle_name ILIKE ? OR short_name ILIKE ?", "LOWER(referees.short_name) ILIKE ? OR LOWER(referees.first_name) ILIKE ? OR LOWER(referees.middle_name) ILIKE ? OR LOWER(referees.last_name) ILIKE ?",
like, like, like, like, 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 { if opts.CountryWyID != nil {
query = query.Where("nationality_wy_id = ?", *opts.CountryWyID) 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