Commit 383db895 by Augusto

Initial commit

parents
root = "."
[build]
cmd = "go build -o ./bin/server ./cmd/server"
entrypoint = "./bin/server"
include_ext = ["go"]
exclude_dir = ["vendor", "tmp"]
[log]
time = true
[color]
main = "magenta"
.env
.env.*
# Air
/tmp
.air.toml.tmp
# Go build artifacts
/bin/
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
# Go workspace
/go.work
/go.work.sum
# IDE
.vscode/
.idea/
*.swp
.DS_Store
# Logs
*.log
# Docker
**/.docker/
FROM golang:1.25-alpine AS builder
WORKDIR /src
RUN apk add --no-cache ca-certificates tzdata
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server ./cmd/server
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /out/server /app/server
EXPOSE 3003
CMD ["/app/server"]
-- SQL to clear lastName fields for referees and coaches
-- where the entire name was duplicated in both firstName and lastName
-- Clear lastName for referees where firstName = lastName
UPDATE referees
SET last_name = NULL
WHERE first_name = last_name;
-- Clear lastName for coaches where firstName = lastName
UPDATE coaches
SET last_name = NULL
WHERE first_name = last_name;
-- Optional: Verify the changes before running
-- (Uncomment to check what will be updated)
-- Show referees that will be updated
-- SELECT id, first_name, last_name
-- FROM referees
-- WHERE first_name = last_name;
-- Show coaches that will be updated
-- SELECT id, first_name, last_name
-- FROM coaches
-- WHERE first_name = last_name;
-- After running the updates, verify the changes
-- SELECT COUNT(*) as referees_updated
-- FROM referees
-- WHERE last_name IS NULL AND first_name IS NOT NULL;
-- SELECT COUNT(*) as coaches_updated
-- FROM coaches
-- WHERE last_name IS NULL AND first_name IS NOT NULL;
package main
import (
"log"
"os"
"github.com/joho/godotenv"
"ScoutingSystemScoreData/internal/config"
"ScoutingSystemScoreData/internal/database"
"ScoutingSystemScoreData/internal/router"
)
// @title Scouting System Score Data API
// @version 1.0
// @description API server for scouting system score data.
// @BasePath /api
func main() {
if err := godotenv.Load(".env"); err != nil {
log.Printf("warning: could not load .env file: %v", err)
}
cfg := config.Load()
db, err := database.Connect(cfg)
if err != nil {
log.Fatalf("failed to connect to database: %v", err)
}
r := router.New(db)
port := cfg.Port
if port == "" {
port = os.Getenv("PORT")
}
if port == "" {
port = "3003"
}
if err := r.Run(":" + port); err != nil {
log.Fatalf("failed to run server: %v", err)
}
}
services:
SSSData:
container_name: SSSData
build:
context: .
dockerfile: Dockerfile
env_file:
- ./.env
ports:
- "3003:3003"
restart: unless-stopped
postgres:
image: postgres:17-alpine
container_name: SSSData-Postgres
profiles:
- with-db
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
ports:
- "5432:5432"
volumes:
- sssdata_pgdata:/var/lib/postgresql/data
restart: unless-stopped
volumes:
sssdata_pgdata:
# Docker Setup (SSSData)
This project can be run locally and deployed to a VPS using Docker Compose.
## Prerequisites
- Docker installed and running
- Docker Compose v2 (`docker compose`)
## Files
- `Dockerfile`
- Multi-stage build that compiles the Go server (`./cmd/server`) into a small runtime image.
- `docker-compose.yml`
- Defines the `SSSData` service.
- Loads environment variables from `./.env`.
## Environment Variables
The application reads configuration from `.env` (not committed to git).
Common variables used by the server:
- `APP_PORT`
- `DB_HOST`
- `DB_PORT`
- `DB_USER`
- `DB_PASSWORD`
- `DB_NAME`
- `DB_SSLMODE` (defaults to `disable`)
- `ProviderUser`
- `ProviderSecret`
Notes:
- The container exposes port `3003`.
- If you change `APP_PORT`, ensure your `docker-compose.yml` port mapping matches.
## Run locally
From the repository root:
```bash
docker compose up -d --build
```
Endpoints:
- Health: `http://localhost:3003/health`
- Swagger: `http://localhost:3003/swagger/index.html`
View logs:
```bash
docker compose logs -f --tail=200
```
Stop:
```bash
docker compose down
```
## Run with Postgres in Docker (recommended for a VM / one-time install)
If you want the VM to have "everything in one place" (API + Postgres), use the `with-db` profile.
### 1) Set `.env` for the Docker Postgres
- Set `DB_HOST=postgres`
- Set `DB_PORT=5432`
- Set `DB_USER`, `DB_PASSWORD`, `DB_NAME`
The `postgres` container will use these values on first boot to create the database.
### 2) Start the full stack
```bash
docker compose --profile with-db up -d --build
```
This will create a persistent Docker volume named `sssdata_pgdata` to store your DB data.
### 3) Stop / reset
Stop containers:
```bash
docker compose down
```
If you want to delete the DB data (DANGER: wipes DB):
```bash
docker compose down -v
```
## Deploy to a VPS (build on VPS)
This is the "Option 2" flow: the VPS builds the image from source.
### First time
1. Clone the repo on the VPS
2. Create a `.env` file on the VPS (production values)
3. Build and start:
```bash
docker compose up -d --build
```
### Update / redeploy
```bash
git pull
docker compose up -d --build
```
If you need to force a clean rebuild (no cache):
```bash
docker compose build --no-cache
docker compose up -d
```
## DB host guidance (important)
This project connects to Postgres using `DB_HOST`.
- If Postgres runs as a container in the same `docker-compose.yml`, set `DB_HOST` to the Postgres service name (example: `postgres`).
- If Postgres runs outside Docker (on the host or managed DB), set `DB_HOST` to the correct reachable hostname/IP.
When using the built-in Docker Postgres service:
- run with: `docker compose --profile with-db up -d --build`
- set: `DB_HOST=postgres`
On Linux hosts, `host.docker.internal` may not be available by default.
If you need to connect from a container to a database on the host machine, you may need to:
- use the host IP address, or
- add an `extra_hosts` entry in `docker-compose.yml` (ask the sysadmin / adjust per environment).
This diff is collapsed. Click to expand it.
module ScoutingSystemScoreData
go 1.25.0
require (
github.com/gin-gonic/gin v1.11.0
github.com/joho/godotenv v1.5.1
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
This diff is collapsed. Click to expand it.
package config
import "os"
type Config struct {
Port string
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
SSLMode string
ProviderUser string
ProviderSecret string
}
func Load() Config {
return Config{
Port: os.Getenv("APP_PORT"),
DBHost: os.Getenv("DB_HOST"),
DBPort: os.Getenv("DB_PORT"),
DBUser: os.Getenv("DB_USER"),
DBPassword: os.Getenv("DB_PASSWORD"),
DBName: os.Getenv("DB_NAME"),
SSLMode: envOrDefault("DB_SSLMODE", "disable"),
ProviderUser: os.Getenv("ProviderUser"),
ProviderSecret: os.Getenv("ProviderSecret"),
}
}
func envOrDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
package database
import (
"fmt"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/config"
"ScoutingSystemScoreData/internal/models"
)
func Connect(cfg config.Config) (*gorm.DB, error) {
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
cfg.DBHost,
cfg.DBPort,
cfg.DBUser,
cfg.DBPassword,
cfg.DBName,
cfg.SSLMode,
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
}
if err := db.AutoMigrate(
&models.Area{},
&models.Competition{},
&models.Season{},
&models.Round{},
&models.Team{},
&models.Coach{},
&models.Referee{},
&models.Player{},
&models.Match{},
&models.MatchTeam{},
&models.MatchLineupPlayer{},
&models.PlayerTransfer{},
&models.TeamSquad{},
&models.Standing{},
&models.SampleRecord{},
); err != nil {
return nil, err
}
return db, nil
}
package errors
import "errors"
type Code string
const (
CodeNotFound Code = "NOT_FOUND"
CodeInvalidInput Code = "INVALID_INPUT"
CodeConflict Code = "CONFLICT"
CodeInternal Code = "INTERNAL"
)
type AppError struct {
Code Code
Message string
Err error
}
func (e *AppError) Error() string {
return e.Message
}
func Wrap(code Code, msg string, err error) *AppError {
return &AppError{Code: code, Message: msg, Err: err}
}
func New(code Code, msg string) *AppError {
return &AppError{Code: code, Message: msg}
}
func IsCode(err error, code Code) bool {
var ae *AppError
if errors.As(err, &ae) {
return ae.Code == code
}
return false
}
package handlers
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/services"
)
type AreaHandler struct {
Service services.AreaService
}
func RegisterAreaRoutes(rg *gin.RouterGroup, db *gorm.DB) {
service := services.NewAreaService(db)
h := &AreaHandler{Service: service}
areas := rg.Group("/areas")
areas.GET("", h.List)
areas.GET("/:id", h.GetByID)
areas.GET("/provider/:providerId", h.GetByProviderID)
}
// List areas
// @Summary List areas
// @Description Returns a paginated list of areas, optionally filtered by name or alpha codes.
// @Tags Areas
// @Param limit query int false "Maximum number of items to return (default 100)"
// @Param offset query int false "Number of items to skip before starting to collect the result set (default 0)"
// @Param name query string false "Filter areas by name (case-insensitive, partial match)"
// @Param alpha2code query string false "Filter areas by 2-letter country code"
// @Param alpha3code query string false "Filter areas by 3-letter country code"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /areas [get]
func (h *AreaHandler) List(c *gin.Context) {
limitStr := c.DefaultQuery("limit", "100")
offsetStr := c.DefaultQuery("offset", "0")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
limit = 100
}
offset, err := strconv.Atoi(offsetStr)
if err != nil || offset < 0 {
offset = 0
}
name := c.Query("name")
alpha2 := c.Query("alpha2code")
alpha3 := c.Query("alpha3code")
endpoint := "/areas"
if name != "" {
endpoint = fmt.Sprintf("/areas?name=%s", name)
} else if alpha2 != "" {
endpoint = fmt.Sprintf("/areas?alpha2code=%s", alpha2)
} else if alpha3 != "" {
endpoint = fmt.Sprintf("/areas?alpha3code=%s", alpha3)
}
areas, total, err := h.Service.ListAreas(c.Request.Context(), services.ListAreasOptions{
Limit: limit,
Offset: offset,
Name: name,
Alpha2: alpha2,
Alpha3: alpha3,
})
if err != nil {
respondError(c, err)
return
}
page := offset/limit + 1
hasMore := int64(offset+limit) < total
timestamp := time.Now().UTC().Format(time.RFC3339)
c.JSON(http.StatusOK, gin.H{
"data": areas,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
"totalItems": total,
"page": page,
"limit": limit,
"hasMore": hasMore,
},
})
}
// GetByID returns a single area by internal ID
// @Summary Get area by ID
// @Description Returns a single area by its internal ID.
// @Tags Areas
// @Param id path string true "Area internal identifier"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /areas/{id} [get]
func (h *AreaHandler) GetByID(c *gin.Context) {
id := c.Param("id")
area, err := h.Service.GetByID(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/areas/%s", id)
c.JSON(http.StatusOK, gin.H{
"data": area,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
// GetByProviderID returns a single area by wy_id provider ID
// @Summary Get area by provider ID
// @Description Returns a single area by its provider (wy_id) identifier.
// @Tags Areas
// @Param providerId path string true "Provider (wy_id) identifier"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /areas/provider/{providerId} [get]
func (h *AreaHandler) GetByProviderID(c *gin.Context) {
providerID := c.Param("providerId")
area, err := h.Service.GetByProviderID(c.Request.Context(), providerID)
if err != nil {
respondError(c, err)
return
}
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/areas/provider/%s", providerID)
c.JSON(http.StatusOK, gin.H{
"data": area,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
package handlers
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/models"
"ScoutingSystemScoreData/internal/services"
)
type CoachHandler struct {
Service services.CoachService
}
func RegisterCoachRoutes(rg *gin.RouterGroup, db *gorm.DB) {
service := services.NewCoachService(db)
h := &CoachHandler{Service: service}
coaches := rg.Group("/coaches")
coaches.GET("", h.List)
coaches.GET("/wyscout/:wyId", h.GetByWyID)
coaches.GET("/provider/:providerId", h.GetByProviderID)
coaches.GET("/:id", h.GetByID)
}
type StructuredCoach struct {
ID string `json:"id"`
WyID *int `json:"wyId"`
TsID string `json:"tsId"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
MiddleName *string `json:"middleName"`
ShortName *string `json:"shortName"`
DateOfBirth *time.Time `json:"dateOfBirth"`
NationalityWyID *int `json:"nationalityWyId"`
CurrentTeamWyID *int `json:"currentTeamWyId"`
Position string `json:"position"`
CoachingLicense *string `json:"coachingLicense"`
YearsExperience *int `json:"yearsExperience"`
PreferredFormation *string `json:"preferredFormation"`
JoinedAt *time.Time `json:"joinedAt"`
ContractUntil *time.Time `json:"contractUntil"`
UID *string `json:"uid"`
Deathday *time.Time `json:"deathday"`
Status string `json:"status"`
ImageDataURL *string `json:"imageDataUrl"`
APILastSyncedAt *time.Time `json:"apiLastSyncedAt"`
APISyncStatus string `json:"apiSyncStatus"`
IsActive bool `json:"isActive"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt *time.Time `json:"deletedAt"`
}
func toStructuredCoach(c models.Coach) StructuredCoach {
return StructuredCoach{
ID: c.ID,
WyID: nilIfZero(c.WyID),
TsID: c.TsID,
FirstName: c.FirstName,
LastName: c.LastName,
MiddleName: c.MiddleName,
ShortName: c.ShortName,
DateOfBirth: c.DateOfBirth,
NationalityWyID: c.NationalityWyID,
CurrentTeamWyID: c.CurrentTeamWyID,
Position: c.Position,
CoachingLicense: c.CoachingLicense,
YearsExperience: c.YearsExperience,
PreferredFormation: c.PreferredFormation,
JoinedAt: c.JoinedAt,
ContractUntil: c.ContractUntil,
UID: c.UID,
Deathday: c.Deathday,
Status: c.Status,
ImageDataURL: c.ImageDataURL,
APILastSyncedAt: c.APILastSyncedAt,
APISyncStatus: c.APISyncStatus,
IsActive: c.IsActive,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
DeletedAt: c.DeletedAt,
}
}
func nilIfZero(v *int) *int {
if v == nil {
return nil
}
if *v == 0 {
return nil
}
return v
}
// List coaches
// @Summary List coaches
// @Description Returns a paginated list of coaches, optionally filtered by name, team, position, or active status.
// @Tags Coaches
// @Param limit query int false "Maximum number of items to return (default 100)"
// @Param offset query int false "Number of items to skip before starting to collect the result set (default 0)"
// @Param name query string false "Filter coaches by name (first, last, middle, or short)"
// @Param teamId query string false "Filter coaches by current team ID (wy_id)"
// @Param position query string false "Filter coaches by position (head_coach, assistant_coach, etc.)"
// @Param active query bool false "Filter active coaches only"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /coaches [get]
func (h *CoachHandler) List(c *gin.Context) {
limitStr := c.DefaultQuery("limit", "100")
offsetStr := c.DefaultQuery("offset", "0")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
limit = 100
}
offset, err := strconv.Atoi(offsetStr)
if err != nil || offset < 0 {
offset = 0
}
name := c.Query("name")
teamID := c.Query("teamId")
position := c.Query("position")
activeStr := c.Query("active")
activeOnly := activeStr == "true" || activeStr == "1"
endpoint := "/coaches"
if name != "" {
endpoint = fmt.Sprintf("/coaches?name=%s", name)
} else if teamID != "" {
endpoint = fmt.Sprintf("/coaches?teamId=%s", teamID)
} else if position != "" {
endpoint = fmt.Sprintf("/coaches?position=%s", position)
} else if activeOnly {
endpoint = "/coaches?active=true"
}
coaches, total, err := h.Service.ListCoaches(c.Request.Context(), services.ListCoachesOptions{
Limit: limit,
Offset: offset,
Name: name,
TeamID: teamID,
Position: position,
ActiveOnly: activeOnly,
})
if err != nil {
respondError(c, err)
return
}
structured := make([]StructuredCoach, 0, len(coaches))
for _, coach := range coaches {
structured = append(structured, toStructuredCoach(coach))
}
page := offset/limit + 1
hasMore := int64(offset+limit) < total
timestamp := time.Now().UTC().Format(time.RFC3339)
c.JSON(http.StatusOK, gin.H{
"data": structured,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
"totalItems": total,
"page": page,
"limit": limit,
"hasMore": hasMore,
},
})
}
// GetByID returns a single coach by internal ID
// @Summary Get coach by ID
// @Description Returns a single coach by its internal ID.
// @Tags Coaches
// @Param id path string true "Coach internal identifier"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /coaches/{id} [get]
func (h *CoachHandler) GetByID(c *gin.Context) {
id := c.Param("id")
coach, err := h.Service.GetByID(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
structured := toStructuredCoach(coach)
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/coaches/%s", id)
c.JSON(http.StatusOK, gin.H{
"data": structured,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
// GetByProviderID returns a single coach by wy_id (numeric) or ts_id (string)
// @Summary Get coach by provider ID
// @Description Returns a single coach by its provider identifier: numeric values are treated as Wyscout wy_id, non-numeric as TheSports ts_id.
// @Tags Coaches
// @Param providerId path string true "Provider identifier (wy_id or ts_id)"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /coaches/provider/{providerId} [get]
func (h *CoachHandler) GetByProviderID(c *gin.Context) {
providerID := c.Param("providerId")
coach, err := h.Service.GetByAnyProviderID(c.Request.Context(), providerID)
if err != nil {
respondError(c, err)
return
}
structured := toStructuredCoach(coach)
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/coaches/provider/%s", providerID)
c.JSON(http.StatusOK, gin.H{
"data": structured,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
// GetByWyID returns a single coach by WyScout WyID
// @Summary Get coach by Wyscout ID
// @Description Returns a single coach by its Wyscout wy_id identifier.
// @Tags Coaches
// @Param wyId path int true "Wyscout wy_id identifier"
// @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 /coaches/wyscout/{wyId} [get]
func (h *CoachHandler) GetByWyID(c *gin.Context) {
wyIDStr := c.Param("wyId")
wyID, err := strconv.Atoi(wyIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid wyId"})
return
}
coach, err := h.Service.GetByWyID(c.Request.Context(), wyID)
if err != nil {
respondError(c, err)
return
}
structured := toStructuredCoach(coach)
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/coaches/wyscout/%d", wyID)
c.JSON(http.StatusOK, gin.H{
"data": structured,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
appErrors "ScoutingSystemScoreData/internal/errors"
)
func respondError(c *gin.Context, err error) {
if appErrors.IsCode(err, appErrors.CodeNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
if appErrors.IsCode(err, appErrors.CodeInvalidInput) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
}
package handlers
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/models"
"ScoutingSystemScoreData/internal/services"
)
type PlayerHandler struct {
Service services.PlayerService
}
func RegisterPlayerRoutes(rg *gin.RouterGroup, db *gorm.DB) {
service := services.NewPlayerService(db)
h := &PlayerHandler{Service: service}
players := rg.Group("/players")
players.GET("", h.List)
players.GET("/wyscout/:wyId", h.GetByProviderID)
players.GET("/provider/:providerId", h.GetByAnyProviderID)
players.GET("/:id", h.GetByID)
}
type PlayerRole struct {
Name string `json:"name"`
Code2 string `json:"code2"`
Code3 string `json:"code3"`
}
// Meta represents metadata for paginated responses.
type Meta struct {
Timestamp string `json:"timestamp"`
Endpoint string `json:"endpoint"`
Method string `json:"method"`
TotalItems int64 `json:"totalItems,omitempty"`
Page int `json:"page,omitempty"`
Limit int `json:"limit,omitempty"`
HasMore bool `json:"hasMore,omitempty"`
}
type StructuredPlayer struct {
ID string `json:"id"`
WyID *int `json:"wyId"`
GSMID *int `json:"gsmId"`
ShortName string `json:"shortName"`
FirstName string `json:"firstName"`
MiddleName *string `json:"middleName"`
LastName string `json:"lastName"`
Height *int `json:"height"`
Weight *float64 `json:"weight"`
BirthDate *time.Time `json:"birthDate"`
Role PlayerRole `json:"role"`
Position *string `json:"position"`
Foot *string `json:"foot"`
CurrentTeamID *int `json:"currentTeamId"`
CurrentNationalTeamID *int `json:"currentNationalTeamId"`
Gender *string `json:"gender"`
Status string `json:"status"`
JerseyNumber *int `json:"jerseyNumber"`
ImageDataURL *string `json:"imageDataURL"`
APILastSyncedAt *time.Time `json:"apiLastSyncedAt"`
APISyncStatus string `json:"apiSyncStatus"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// New TheSports-related fields
MarketValue *int `json:"marketValue"`
MarketValueCurrency *string `json:"marketValueCurrency"`
ContractUntil *time.Time `json:"contractUntil"`
AbilityJSON *string `json:"abilityJson"`
CharacteristicsJSON *string `json:"characteristicsJson"`
UID *string `json:"uid"`
Deathday *time.Time `json:"deathday"`
RetireTime *time.Time `json:"retireTime"`
}
func toStructuredPlayer(p models.Player) StructuredPlayer {
role := PlayerRole{
Name: "",
Code2: "",
Code3: "",
}
if p.RoleName != nil {
role.Name = *p.RoleName
}
if p.RoleCode2 != nil {
role.Code2 = *p.RoleCode2
}
if p.RoleCode3 != nil {
role.Code3 = *p.RoleCode3
}
status := p.Status
if status == "" {
if p.IsActive {
status = "active"
} else {
status = "inactive"
}
}
return StructuredPlayer{
ID: p.ID,
WyID: p.WyID,
GSMID: p.GSMID,
ShortName: valueOrDefault(p.ShortName, ""),
FirstName: p.FirstName,
MiddleName: p.MiddleName,
LastName: p.LastName,
Height: p.HeightCM,
Weight: p.WeightKG,
BirthDate: p.DateOfBirth,
Role: role,
Position: p.Position,
Foot: p.Foot,
CurrentTeamID: p.CurrentTeamID,
CurrentNationalTeamID: p.CurrentNationalTeamID,
Gender: p.Gender,
Status: status,
JerseyNumber: p.JerseyNumber,
ImageDataURL: p.ImageDataURL,
APILastSyncedAt: p.APILastSyncedAt,
APISyncStatus: p.APISyncStatus,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
MarketValue: p.MarketValue,
MarketValueCurrency: p.MarketValueCurrency,
ContractUntil: p.ContractUntil,
AbilityJSON: p.AbilityJSON,
CharacteristicsJSON: p.CharacteristicsJSON,
UID: p.UID,
Deathday: p.Deathday,
RetireTime: p.RetireTime,
}
}
func valueOrDefault(s *string, def string) string {
if s != nil {
return *s
}
return def
}
// List players
// @Summary List players
// @Description Returns a paginated list of players, optionally filtered by name, team, or country.
// @Tags Players
// @Param limit query int false "Maximum number of items to return (default 100)"
// @Param offset query int false "Number of items to skip before starting to collect the result set (default 0)"
// @Param name query string false "Filter players by name (short, first, middle, or last)"
// @Param teamId query string false "Filter players by current team ID"
// @Param country query string false "Filter players by birth country name"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /players [get]
func (h *PlayerHandler) List(c *gin.Context) {
limitStr := c.DefaultQuery("limit", "100")
offsetStr := c.DefaultQuery("offset", "0")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
limit = 100
}
offset, err := strconv.Atoi(offsetStr)
if err != nil || offset < 0 {
offset = 0
}
name := c.Query("name")
teamID := c.Query("teamId")
country := c.Query("country")
endpoint := "/players"
if name != "" {
endpoint = fmt.Sprintf("/players?name=%s", name)
} else if teamID != "" {
endpoint = fmt.Sprintf("/players?teamId=%s", teamID)
} else if country != "" {
endpoint = fmt.Sprintf("/players?country=%s", country)
}
players, total, err := h.Service.ListPlayers(c.Request.Context(), services.ListPlayersOptions{
Limit: limit,
Offset: offset,
Name: name,
TeamID: teamID,
Country: country,
})
if err != nil {
respondError(c, err)
return
}
structured := make([]StructuredPlayer, 0, len(players))
for _, p := range players {
structured = append(structured, toStructuredPlayer(p))
}
page := offset/limit + 1
hasMore := int64(offset+limit) < total
timestamp := time.Now().UTC().Format(time.RFC3339)
c.JSON(http.StatusOK, gin.H{
"data": structured,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
"totalItems": total,
"page": page,
"limit": limit,
"hasMore": hasMore,
},
})
}
// GetByID returns a single player by internal ID
// @Summary Get player by ID
// @Description Returns a single player by its internal ID.
// @Tags Players
// @Param id path string true "Player internal identifier"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /players/{id} [get]
func (h *PlayerHandler) GetByID(c *gin.Context) {
id := c.Param("id")
player, err := h.Service.GetByID(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
structured := toStructuredPlayer(player)
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/players/%s", id)
c.JSON(http.StatusOK, gin.H{
"data": structured,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
// GetByAnyProviderID returns a single player by wy_id (numeric) or ts_id (string)
func (h *PlayerHandler) GetByAnyProviderID(c *gin.Context) {
providerID := c.Param("providerId")
player, err := h.Service.GetByAnyProviderID(c.Request.Context(), providerID)
if err != nil {
respondError(c, err)
return
}
structured := toStructuredPlayer(player)
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/players/provider/%s", providerID)
c.JSON(http.StatusOK, gin.H{
"data": structured,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
// GetByProviderID returns a single player by WyScout provider ID
// @Summary Get player by provider ID
// @Description Returns a single player by its provider (wy_id) identifier.
// @Tags Players
// @Param wyId path int true "Provider (wy_id) identifier"
// @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/wyscout/{wyId} [get]
func (h *PlayerHandler) GetByProviderID(c *gin.Context) {
wyIDStr := c.Param("wyId")
wyID, err := strconv.Atoi(wyIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid wyId"})
return
}
player, err := h.Service.GetByProviderID(c.Request.Context(), wyID)
if err != nil {
respondError(c, err)
return
}
structured := toStructuredPlayer(player)
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/players/wyscout/%d", wyID)
c.JSON(http.StatusOK, gin.H{
"data": structured,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
package handlers
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/models"
"ScoutingSystemScoreData/internal/services"
)
type RefereeHandler struct {
Service services.RefereeService
}
func RegisterRefereeRoutes(rg *gin.RouterGroup, db *gorm.DB) {
service := services.NewRefereeService(db)
h := &RefereeHandler{Service: service}
referees := rg.Group("/referees")
referees.GET("", h.List)
referees.GET("/wyscout/:wyId", h.GetByWyID)
referees.GET("/provider/:providerId", h.GetByProviderID)
referees.GET(":id", h.GetByID)
}
type StructuredReferee struct {
ID string `json:"id"`
WyID *int `json:"wyId"`
TsID string `json:"tsId"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
MiddleName *string `json:"middleName"`
ShortName *string `json:"shortName"`
DateOfBirth *time.Time `json:"dateOfBirth"`
NationalityWyID *int `json:"nationalityWyId"`
RefereeType string `json:"refereeType"`
FIFACategory *string `json:"fifaCategory"`
ExperienceYears *int `json:"experienceYears"`
UID *string `json:"uid"`
Status string `json:"status"`
ImageDataURL *string `json:"imageDataUrl"`
APILastSyncedAt *time.Time `json:"apiLastSyncedAt"`
APISyncStatus string `json:"apiSyncStatus"`
IsActive bool `json:"isActive"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt *time.Time `json:"deletedAt"`
}
func toStructuredReferee(r models.Referee) StructuredReferee {
return StructuredReferee{
ID: r.ID,
WyID: nilIfZero(r.WyID),
TsID: r.TsID,
FirstName: r.FirstName,
LastName: r.LastName,
MiddleName: r.MiddleName,
ShortName: r.ShortName,
DateOfBirth: r.DateOfBirth,
NationalityWyID: r.NationalityWyID,
RefereeType: r.RefereeType,
FIFACategory: r.FIFACategory,
ExperienceYears: r.ExperienceYears,
UID: r.UID,
Status: r.Status,
ImageDataURL: r.ImageDataURL,
APILastSyncedAt: r.APILastSyncedAt,
APISyncStatus: r.APISyncStatus,
IsActive: r.IsActive,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
DeletedAt: r.DeletedAt,
}
}
// List referees
// @Summary List referees
// @Description Returns a paginated list of referees, optionally filtered by name, referee type, nationality, or active status.
// @Tags Referees
// @Param limit query int false "Maximum number of items to return (default 100)"
// @Param offset query int false "Number of items to skip before starting to collect the result set (default 0)"
// @Param name query string false "Filter referees by name (first, last, middle, or short)"
// @Param countryWyId query int false "Filter referees by nationality WyID"
// @Param type query string false "Filter referees by type (main, assistant, var, etc.)"
// @Param active query bool false "Filter active referees only"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /referees [get]
func (h *RefereeHandler) List(c *gin.Context) {
limitStr := c.DefaultQuery("limit", "100")
offsetStr := c.DefaultQuery("offset", "0")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
limit = 100
}
offset, err := strconv.Atoi(offsetStr)
if err != nil || offset < 0 {
offset = 0
}
name := c.Query("name")
countryWyIDStr := c.Query("countryWyId")
refType := c.Query("type")
activeStr := c.Query("active")
activeOnly := activeStr == "true" || activeStr == "1"
var countryWyID *int
if countryWyIDStr != "" {
if v, err := strconv.Atoi(countryWyIDStr); err == nil {
countryWyID = &v
}
}
endpoint := "/referees"
if name != "" {
endpoint = fmt.Sprintf("/referees?name=%s", name)
} else if countryWyIDStr != "" {
endpoint = fmt.Sprintf("/referees?countryWyId=%s", countryWyIDStr)
} else if refType != "" {
endpoint = fmt.Sprintf("/referees?type=%s", refType)
} else if activeOnly {
endpoint = "/referees?active=true"
}
referees, total, err := h.Service.ListReferees(c.Request.Context(), services.ListRefereesOptions{
Limit: limit,
Offset: offset,
Name: name,
CountryWyID: countryWyID,
Type: refType,
ActiveOnly: activeOnly,
})
if err != nil {
respondError(c, err)
return
}
structured := make([]StructuredReferee, 0, len(referees))
for _, r := range referees {
structured = append(structured, toStructuredReferee(r))
}
page := offset/limit + 1
hasMore := int64(offset+limit) < total
timestamp := time.Now().UTC().Format(time.RFC3339)
c.JSON(http.StatusOK, gin.H{
"data": structured,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
"totalItems": total,
"page": page,
"limit": limit,
"hasMore": hasMore,
},
})
}
// GetByID returns a single referee by internal ID
// @Summary Get referee by ID
// @Description Returns a single referee by its internal ID.
// @Tags Referees
// @Param id path string true "Referee internal identifier"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /referees/{id} [get]
func (h *RefereeHandler) GetByID(c *gin.Context) {
id := c.Param("id")
referee, err := h.Service.GetByID(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
structured := toStructuredReferee(referee)
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/referees/%s", id)
c.JSON(http.StatusOK, gin.H{
"data": structured,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
// GetByProviderID returns a single referee by wy_id (numeric) or ts_id (string)
// @Summary Get referee by provider ID
// @Description Returns a single referee by its provider identifier: numeric values are treated as Wyscout wy_id, non-numeric as TheSports ts_id.
// @Tags Referees
// @Param providerId path string true "Provider identifier (wy_id or ts_id)"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /referees/provider/{providerId} [get]
func (h *RefereeHandler) GetByProviderID(c *gin.Context) {
providerID := c.Param("providerId")
referee, err := h.Service.GetByAnyProviderID(c.Request.Context(), providerID)
if err != nil {
respondError(c, err)
return
}
structured := toStructuredReferee(referee)
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/referees/provider/%s", providerID)
c.JSON(http.StatusOK, gin.H{
"data": structured,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
// GetByWyID returns a single referee by WyScout WyID
// @Summary Get referee by Wyscout ID
// @Description Returns a single referee by its Wyscout wy_id identifier.
// @Tags Referees
// @Param wyId path int true "Wyscout wy_id identifier"
// @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 /referees/wyscout/{wyId} [get]
func (h *RefereeHandler) GetByWyID(c *gin.Context) {
wyIDStr := c.Param("wyId")
wyID, err := strconv.Atoi(wyIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid wyId"})
return
}
referee, err := h.Service.GetByWyID(c.Request.Context(), wyID)
if err != nil {
respondError(c, err)
return
}
structured := toStructuredReferee(referee)
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/referees/wyscout/%d", wyID)
c.JSON(http.StatusOK, gin.H{
"data": structured,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
package handlers
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/services"
)
type TeamHandler struct {
Service services.TeamService
}
func RegisterTeamRoutes(rg *gin.RouterGroup, db *gorm.DB) {
service := services.NewTeamService(db)
h := &TeamHandler{Service: service}
teams := rg.Group("/teams")
teams.GET("", h.List)
teams.GET("/wyscout/:wyId", h.GetByWyID)
teams.GET("/provider/:providerId", h.GetByProviderID)
teams.GET("/:id", h.GetByID)
}
// List teams
// @Summary List teams
// @Description Returns a list of teams with optional pagination.
// @Tags Teams
// @Param limit query int false "Maximum number of items to return (default 100)"
// @Param offset query int false "Number of items to skip before starting to collect the result set (default 0)"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /teams [get]
func (h *TeamHandler) List(c *gin.Context) {
limitStr := c.DefaultQuery("limit", "100")
offsetStr := c.DefaultQuery("offset", "0")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
limit = 100
}
offset, err := strconv.Atoi(offsetStr)
if err != nil || offset < 0 {
offset = 0
}
teams, total, err := h.Service.ListTeams(c.Request.Context(), limit, offset)
if err != nil {
respondError(c, err)
return
}
page := offset/limit + 1
hasMore := int64(offset+limit) < total
timestamp := time.Now().UTC().Format(time.RFC3339)
c.JSON(http.StatusOK, gin.H{
"data": teams,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": "/teams",
"method": "GET",
"totalItems": total,
"page": page,
"limit": limit,
"hasMore": hasMore,
},
})
}
// GetByID returns a single team by internal ID
// @Summary Get team by ID
// @Description Returns a single team by its internal ID.
// @Tags Teams
// @Param id path string true "Team internal identifier"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /teams/{id} [get]
func (h *TeamHandler) GetByID(c *gin.Context) {
id := c.Param("id")
team, err := h.Service.GetByID(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/teams/%s", id)
c.JSON(http.StatusOK, gin.H{
"data": team,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
// GetByProviderID returns a single team by wy_id (numeric) or ts_id (string)
func (h *TeamHandler) GetByProviderID(c *gin.Context) {
providerID := c.Param("providerId")
team, err := h.Service.GetByAnyProviderID(c.Request.Context(), providerID)
if err != nil {
respondError(c, err)
return
}
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/teams/provider/%s", providerID)
c.JSON(http.StatusOK, gin.H{
"data": team,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
// GetByWyID returns a single team by WyScout WyID
// @Summary Get team by Wyscout ID
// @Description Returns a single team by its Wyscout wy_id identifier.
// @Tags Teams
// @Param wyId path int true "Wyscout wy_id identifier"
// @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 /teams/wyscout/{wyId} [get]
func (h *TeamHandler) GetByWyID(c *gin.Context) {
wyIDStr := c.Param("wyId")
wyID, err := strconv.Atoi(wyIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid wyId"})
return
}
team, err := h.Service.GetByWyID(c.Request.Context(), wyID)
if err != nil {
respondError(c, err)
return
}
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/teams/wyscout/%d", wyID)
c.JSON(http.StatusOK, gin.H{
"data": team,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
package models
import (
"time"
gonanoid "github.com/matoous/go-nanoid/v2"
"gorm.io/gorm"
)
type SampleRecord struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s *SampleRecord) BeforeCreate(tx *gorm.DB) (err error) {
if s.ID != "" {
return nil
}
id, err := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 15)
if err != nil {
return err
}
s.ID = id
return nil
}
package router
import (
"encoding/base64"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
_ "ScoutingSystemScoreData/docs"
"ScoutingSystemScoreData/internal/config"
"ScoutingSystemScoreData/internal/handlers"
)
func decodeEnvMaybeBase64(raw string) string {
raw = strings.TrimSpace(raw)
raw = strings.Trim(raw, "\"'")
if raw == "" {
return raw
}
decoded, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return raw
}
return string(decoded)
}
func basicAuthMiddleware() gin.HandlerFunc {
rawUser := os.Getenv("API_USERNAME")
rawPass := os.Getenv("API_PASSWORD")
username := decodeEnvMaybeBase64(rawUser)
password := decodeEnvMaybeBase64(rawPass)
// If not configured, do not enforce auth.
if username == "" && password == "" {
return func(c *gin.Context) { c.Next() }
}
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if !strings.HasPrefix(authHeader, "Basic ") {
c.Header("WWW-Authenticate", "Basic")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Basic authentication required"})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[1] == "" {
c.Header("WWW-Authenticate", "Basic")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid authentication credentials"})
return
}
decoded, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
c.Header("WWW-Authenticate", "Basic")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid authentication credentials"})
return
}
creds := string(decoded)
up := strings.SplitN(creds, ":", 2)
if len(up) != 2 {
c.Header("WWW-Authenticate", "Basic")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid authentication credentials"})
return
}
if up[0] != username || up[1] != password {
c.Header("WWW-Authenticate", "Basic")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
return
}
c.Next()
}
}
func New(db *gorm.DB) *gin.Engine {
r := gin.Default()
r.GET("/health", func(c *gin.Context) {
if sqlDB, err := db.DB(); err == nil {
if err := sqlDB.Ping(); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unhealthy", "error": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, ginSwagger.PersistAuthorization(true)))
r.GET("/docs.html", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "/swagger/index.html")
})
r.GET("/docs", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "/swagger/index.html")
})
api := r.Group("/api")
api.Use(basicAuthMiddleware())
appCfg := config.Load()
handlers.RegisterAreaRoutes(api, db)
handlers.RegisterTeamRoutes(api, db)
handlers.RegisterPlayerRoutes(api, db)
handlers.RegisterCoachRoutes(api, db)
handlers.RegisterRefereeRoutes(api, db)
handlers.RegisterMatchRoutes(api, db)
handlers.RegisterImportRoutes(api, db, appCfg)
return r
}
package services
import (
"context"
"strconv"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models"
)
type ListAreasOptions struct {
Limit int
Offset int
Name string
Alpha2 string
Alpha3 string
}
type AreaService interface {
ListAreas(ctx context.Context, opts ListAreasOptions) ([]models.Area, int64, error)
GetByID(ctx context.Context, id string) (models.Area, error)
GetByProviderID(ctx context.Context, providerID string) (models.Area, error)
}
type areaService struct {
db *gorm.DB
}
func NewAreaService(db *gorm.DB) AreaService {
return &areaService{db: db}
}
func (s *areaService) ListAreas(ctx context.Context, opts ListAreasOptions) ([]models.Area, int64, error) {
var areas []models.Area
query := s.db.WithContext(ctx).Model(&models.Area{})
if opts.Name != "" {
query = query.Where("name ILIKE ?", "%"+opts.Name+"%")
} else if opts.Alpha2 != "" {
query = query.Where("alpha2code = ?", opts.Alpha2)
} else if opts.Alpha3 != "" {
query = query.Where("alpha3code = ?", opts.Alpha3)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to count areas", err)
}
if err := query.Limit(opts.Limit).Offset(opts.Offset).Find(&areas).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to fetch areas", err)
}
return areas, total, nil
}
func (s *areaService) GetByID(ctx context.Context, id string) (models.Area, error) {
var area models.Area
if err := s.db.WithContext(ctx).First(&area, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Area{}, errors.New(errors.CodeNotFound, "area not found")
}
return models.Area{}, errors.Wrap(errors.CodeInternal, "failed to fetch area", err)
}
return area, nil
}
func (s *areaService) GetByProviderID(ctx context.Context, providerID string) (models.Area, error) {
var area models.Area
// If providerID is numeric, treat as wy_id; otherwise, treat as ts_id
if wyID, err := strconv.Atoi(providerID); err == nil {
if err := s.db.WithContext(ctx).First(&area, "wy_id = ?", wyID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Area{}, errors.New(errors.CodeNotFound, "area not found")
}
return models.Area{}, errors.Wrap(errors.CodeInternal, "failed to fetch area", err)
}
return area, nil
}
if err := s.db.WithContext(ctx).First(&area, "ts_id = ?", providerID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Area{}, errors.New(errors.CodeNotFound, "area not found")
}
return models.Area{}, errors.Wrap(errors.CodeInternal, "failed to fetch area", err)
}
return area, nil
}
package services
import (
"context"
"strconv"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models"
)
type CoachService interface {
ListCoaches(ctx context.Context, opts ListCoachesOptions) ([]models.Coach, int64, error)
GetByID(ctx context.Context, id string) (models.Coach, error)
GetByWyID(ctx context.Context, wyID int) (models.Coach, error)
GetByAnyProviderID(ctx context.Context, providerID string) (models.Coach, error)
}
type ListCoachesOptions struct {
Limit int
Offset int
Name string
TeamID string
Position string
ActiveOnly bool
}
type coachService struct {
db *gorm.DB
}
func NewCoachService(db *gorm.DB) CoachService {
return &coachService{db: db}
}
func (s *coachService) ListCoaches(ctx context.Context, opts ListCoachesOptions) ([]models.Coach, int64, error) {
var coaches []models.Coach
query := s.db.WithContext(ctx).Model(&models.Coach{})
if opts.Name != "" {
like := "%" + opts.Name + "%"
query = query.Where(
"first_name ILIKE ? OR last_name ILIKE ? OR middle_name ILIKE ? OR short_name ILIKE ?",
like, like, like, like,
)
} else if opts.TeamID != "" {
query = query.Where("current_team_wy_id = ?", opts.TeamID)
} else if opts.Position != "" {
query = query.Where("position = ?", opts.Position)
}
if opts.ActiveOnly {
query = query.Where("is_active = ?", true)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to count coaches", err)
}
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 coaches, total, nil
}
func (s *coachService) GetByID(ctx context.Context, id string) (models.Coach, error) {
var coach models.Coach
if err := s.db.WithContext(ctx).First(&coach, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Coach{}, errors.New(errors.CodeNotFound, "coach not found")
}
return models.Coach{}, errors.Wrap(errors.CodeInternal, "failed to fetch coach", err)
}
return coach, nil
}
func (s *coachService) GetByAnyProviderID(ctx context.Context, providerID string) (models.Coach, error) {
if wyID, err := strconv.Atoi(providerID); err == nil {
return s.GetByWyID(ctx, wyID)
}
var coach models.Coach
if err := s.db.WithContext(ctx).First(&coach, "ts_id = ?", providerID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Coach{}, errors.New(errors.CodeNotFound, "coach not found")
}
return models.Coach{}, errors.Wrap(errors.CodeInternal, "failed to fetch coach", err)
}
return coach, nil
}
func (s *coachService) GetByWyID(ctx context.Context, wyID int) (models.Coach, error) {
var coach models.Coach
if err := s.db.WithContext(ctx).First(&coach, "wy_id = ?", wyID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Coach{}, errors.New(errors.CodeNotFound, "coach not found")
}
return models.Coach{}, errors.Wrap(errors.CodeInternal, "failed to fetch coach", err)
}
return coach, nil
}
package services
import (
"context"
"time"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models"
)
type MatchService interface {
ListMatches(ctx context.Context, opts ListMatchesOptions) ([]models.Match, int64, error)
GetByID(ctx context.Context, id string) (models.Match, error)
GetByTsID(ctx context.Context, matchTsID string) (models.Match, error)
GetHeadToHeadMostRecent(ctx context.Context, teamATsID string, teamBTsID string) (models.Match, error)
GetLineup(ctx context.Context, matchTsID string) (models.Match, []models.MatchTeam, []models.MatchLineupPlayer, error)
GetLineupByID(ctx context.Context, id string) (models.Match, []models.MatchTeam, []models.MatchLineupPlayer, error)
}
type ListMatchesOptions struct {
Limit int
Offset int
CompetitionWyID *int
SeasonWyID *int
TeamTsID *string
FromDate *time.Time
ToDate *time.Time
Status *string
Order string // asc|desc
}
type matchService struct {
db *gorm.DB
}
func NewMatchService(db *gorm.DB) MatchService {
return &matchService{db: db}
}
func (s *matchService) ListMatches(ctx context.Context, opts ListMatchesOptions) ([]models.Match, int64, error) {
var matches []models.Match
query := s.db.WithContext(ctx).Model(&models.Match{})
if opts.CompetitionWyID != nil {
query = query.Where("competition_wy_id = ?", *opts.CompetitionWyID)
}
if opts.SeasonWyID != nil {
query = query.Where("season_wy_id = ?", *opts.SeasonWyID)
}
if opts.TeamTsID != nil && *opts.TeamTsID != "" {
query = query.Where("home_team_ts_id = ? OR away_team_ts_id = ?", *opts.TeamTsID, *opts.TeamTsID)
}
if opts.FromDate != nil {
query = query.Where("match_date >= ?", *opts.FromDate)
}
if opts.ToDate != nil {
query = query.Where("match_date <= ?", *opts.ToDate)
}
if opts.Status != nil && *opts.Status != "" {
query = query.Where("status = ?", *opts.Status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to count matches", err)
}
order := "match_date desc"
if opts.Order == "asc" {
order = "match_date asc"
}
if err := query.Order(order).Limit(opts.Limit).Offset(opts.Offset).Find(&matches).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to fetch matches", err)
}
return matches, total, nil
}
func (s *matchService) GetByTsID(ctx context.Context, matchTsID string) (models.Match, error) {
var match models.Match
if err := s.db.WithContext(ctx).First(&match, "ts_id = ?", matchTsID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Match{}, errors.New(errors.CodeNotFound, "match not found")
}
return models.Match{}, errors.Wrap(errors.CodeInternal, "failed to fetch match", err)
}
return match, nil
}
func (s *matchService) GetByID(ctx context.Context, id string) (models.Match, error) {
var match models.Match
if err := s.db.WithContext(ctx).First(&match, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Match{}, errors.New(errors.CodeNotFound, "match not found")
}
return models.Match{}, errors.Wrap(errors.CodeInternal, "failed to fetch match", err)
}
return match, nil
}
func (s *matchService) GetHeadToHeadMostRecent(ctx context.Context, teamATsID string, teamBTsID string) (models.Match, error) {
var match models.Match
q := s.db.WithContext(ctx).Model(&models.Match{}).
Where("(home_team_ts_id = ? AND away_team_ts_id = ?) OR (home_team_ts_id = ? AND away_team_ts_id = ?)", teamATsID, teamBTsID, teamBTsID, teamATsID).
Order("match_date desc")
if err := q.First(&match).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Match{}, errors.New(errors.CodeNotFound, "match not found")
}
return models.Match{}, errors.Wrap(errors.CodeInternal, "failed to fetch head-to-head match", err)
}
return match, nil
}
func (s *matchService) GetLineup(ctx context.Context, matchTsID string) (models.Match, []models.MatchTeam, []models.MatchLineupPlayer, error) {
match, err := s.GetByTsID(ctx, matchTsID)
if err != nil {
return models.Match{}, nil, nil, err
}
var teams []models.MatchTeam
if err := s.db.WithContext(ctx).Where("match_ts_id = ?", matchTsID).Order("side asc").Find(&teams).Error; err != nil {
return models.Match{}, nil, nil, errors.Wrap(errors.CodeInternal, "failed to fetch match teams", err)
}
var lineup []models.MatchLineupPlayer
if err := s.db.WithContext(ctx).
Where("match_ts_id = ?", matchTsID).
Order("is_starter desc").
Order("sub_order asc").
Order("shirt_number asc").
Find(&lineup).Error; err != nil {
return models.Match{}, nil, nil, errors.Wrap(errors.CodeInternal, "failed to fetch lineup players", err)
}
return match, teams, lineup, nil
}
func (s *matchService) GetLineupByID(ctx context.Context, id string) (models.Match, []models.MatchTeam, []models.MatchLineupPlayer, error) {
match, err := s.GetByID(ctx, id)
if err != nil {
return models.Match{}, nil, nil, err
}
if match.TsID == "" {
return models.Match{}, nil, nil, errors.New(errors.CodeNotFound, "match not found")
}
return s.GetLineup(ctx, match.TsID)
}
package services
import (
"context"
"strconv"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models"
)
type PlayerService interface {
ListPlayers(ctx context.Context, opts ListPlayersOptions) ([]models.Player, int64, error)
GetByID(ctx context.Context, id string) (models.Player, error)
GetByProviderID(ctx context.Context, wyID int) (models.Player, error)
GetByAnyProviderID(ctx context.Context, providerID string) (models.Player, error)
}
type ListPlayersOptions struct {
Limit int
Offset int
Name string
TeamID string
Country string
}
type playerService struct {
db *gorm.DB
}
func NewPlayerService(db *gorm.DB) PlayerService {
return &playerService{db: db}
}
func (s *playerService) ListPlayers(ctx context.Context, opts ListPlayersOptions) ([]models.Player, int64, error) {
var players []models.Player
query := s.db.WithContext(ctx).Model(&models.Player{}).Where("is_active = ?", true)
if opts.Name != "" {
likePattern := "%" + opts.Name + "%"
query = query.Where(
"short_name ILIKE ? OR first_name ILIKE ? OR middle_name ILIKE ? OR last_name ILIKE ?",
likePattern, likePattern, likePattern, likePattern,
)
} else if opts.TeamID != "" {
query = query.Where("current_team_id = ?", opts.TeamID)
} else if opts.Country != "" {
query = query.Joins("JOIN areas ON areas.wy_id = players.birth_area_wy_id").Where("areas.name = ?", opts.Country)
}
var total int64
if err := query.Count(&total).Error; err != nil {
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 {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to fetch players", err)
}
return players, total, nil
}
func (s *playerService) GetByID(ctx context.Context, id string) (models.Player, error) {
var player models.Player
if err := s.db.WithContext(ctx).First(&player, "id = ?", id).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) GetByAnyProviderID(ctx context.Context, providerID string) (models.Player, error) {
// If providerID is numeric, treat as wy_id; otherwise, treat as ts_id
if wyID, err := strconv.Atoi(providerID); err == nil {
return s.GetByProviderID(ctx, wyID)
}
var player models.Player
if err := s.db.WithContext(ctx).First(&player, "ts_id = ?", providerID).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) GetByProviderID(ctx context.Context, wyID int) (models.Player, error) {
var player models.Player
if err := s.db.WithContext(ctx).First(&player, "wy_id = ?", wyID).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
}
package services
import (
"context"
"strconv"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models"
)
type RefereeService interface {
ListReferees(ctx context.Context, opts ListRefereesOptions) ([]models.Referee, int64, error)
GetByID(ctx context.Context, id string) (models.Referee, error)
GetByWyID(ctx context.Context, wyID int) (models.Referee, error)
GetByAnyProviderID(ctx context.Context, providerID string) (models.Referee, error)
}
type ListRefereesOptions struct {
Limit int
Offset int
Name string
CountryWyID *int
Type string
ActiveOnly bool
}
type refereeService struct {
db *gorm.DB
}
func NewRefereeService(db *gorm.DB) RefereeService {
return &refereeService{db: db}
}
func (s *refereeService) ListReferees(ctx context.Context, opts ListRefereesOptions) ([]models.Referee, int64, error) {
var referees []models.Referee
query := s.db.WithContext(ctx).Model(&models.Referee{})
if opts.Name != "" {
like := "%" + opts.Name + "%"
query = query.Where(
"first_name ILIKE ? OR last_name ILIKE ? OR middle_name ILIKE ? OR short_name ILIKE ?",
like, like, like, like,
)
}
if opts.CountryWyID != nil {
query = query.Where("nationality_wy_id = ?", *opts.CountryWyID)
}
if opts.Type != "" {
query = query.Where("referee_type = ?", opts.Type)
}
if opts.ActiveOnly {
query = query.Where("is_active = ?", true)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to count referees", err)
}
if err := query.Limit(opts.Limit).Offset(opts.Offset).Find(&referees).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to fetch referees", err)
}
return referees, total, nil
}
func (s *refereeService) GetByID(ctx context.Context, id string) (models.Referee, error) {
var referee models.Referee
if err := s.db.WithContext(ctx).First(&referee, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Referee{}, errors.New(errors.CodeNotFound, "referee not found")
}
return models.Referee{}, errors.Wrap(errors.CodeInternal, "failed to fetch referee", err)
}
return referee, nil
}
func (s *refereeService) GetByAnyProviderID(ctx context.Context, providerID string) (models.Referee, error) {
if wyID, err := strconv.Atoi(providerID); err == nil {
return s.GetByWyID(ctx, wyID)
}
var referee models.Referee
if err := s.db.WithContext(ctx).First(&referee, "ts_id = ?", providerID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Referee{}, errors.New(errors.CodeNotFound, "referee not found")
}
return models.Referee{}, errors.Wrap(errors.CodeInternal, "failed to fetch referee", err)
}
return referee, nil
}
func (s *refereeService) GetByWyID(ctx context.Context, wyID int) (models.Referee, error) {
var referee models.Referee
if err := s.db.WithContext(ctx).First(&referee, "wy_id = ?", wyID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Referee{}, errors.New(errors.CodeNotFound, "referee not found")
}
return models.Referee{}, errors.Wrap(errors.CodeInternal, "failed to fetch referee", err)
}
return referee, nil
}
package services
import (
"context"
"strconv"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models"
)
type TeamService interface {
ListTeams(ctx context.Context, limit, offset int) ([]models.Team, int64, error)
GetByID(ctx context.Context, id string) (models.Team, error)
GetByWyID(ctx context.Context, wyID int) (models.Team, error)
GetByAnyProviderID(ctx context.Context, providerID string) (models.Team, error)
}
type teamService struct {
db *gorm.DB
}
func NewTeamService(db *gorm.DB) TeamService {
return &teamService{db: db}
}
func (s *teamService) ListTeams(ctx context.Context, limit, offset int) ([]models.Team, int64, error) {
var teams []models.Team
query := s.db.WithContext(ctx).Model(&models.Team{})
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to count teams", err)
}
if err := query.Limit(limit).Offset(offset).Find(&teams).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to fetch teams", err)
}
return teams, total, nil
}
func (s *teamService) GetByID(ctx context.Context, id string) (models.Team, error) {
var team models.Team
if err := s.db.WithContext(ctx).First(&team, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Team{}, errors.New(errors.CodeNotFound, "team not found")
}
return models.Team{}, errors.Wrap(errors.CodeInternal, "failed to fetch team", err)
}
return team, nil
}
func (s *teamService) GetByAnyProviderID(ctx context.Context, providerID string) (models.Team, error) {
// If providerID is numeric, treat as wy_id; otherwise, treat as ts_id
if wyID, err := strconv.Atoi(providerID); err == nil {
return s.GetByWyID(ctx, wyID)
}
var team models.Team
if err := s.db.WithContext(ctx).First(&team, "ts_id = ?", providerID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Team{}, errors.New(errors.CodeNotFound, "team not found")
}
return models.Team{}, errors.Wrap(errors.CodeInternal, "failed to fetch team", err)
}
return team, nil
}
func (s *teamService) GetByWyID(ctx context.Context, wyID int) (models.Team, error) {
var team models.Team
if err := s.db.WithContext(ctx).First(&team, "wy_id = ?", wyID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Team{}, errors.New(errors.CodeNotFound, "team not found")
}
return models.Team{}, errors.Wrap(errors.CodeInternal, "failed to fetch team", err)
}
return team, nil
}
-- Migration 0001: add TheSports-related fields to players
-- Also rename players.team_wy_id -> team_provider_id to match new Go field.
ALTER TABLE players
RENAME COLUMN team_wy_id TO team_provider_id;
ALTER TABLE players
ADD COLUMN IF NOT EXISTS market_value integer,
ADD COLUMN IF NOT EXISTS market_value_currency varchar(16),
ADD COLUMN IF NOT EXISTS contract_until timestamp with time zone,
ADD COLUMN IF NOT EXISTS ability_json jsonb,
ADD COLUMN IF NOT EXISTS characteristics_json jsonb,
ADD COLUMN IF NOT EXISTS uid text,
ADD COLUMN IF NOT EXISTS deathday timestamp with time zone,
ADD COLUMN IF NOT EXISTS retire_time timestamp with time zone;
\ No newline at end of file
-- Migration 0002: add TheSports country id to areas
ALTER TABLE areas
ADD COLUMN IF NOT EXISTS ts_id varchar(64);
-- Migration 0003: add Phase 1 indexes for common filters and lookups
-- Players: common filters
CREATE INDEX IF NOT EXISTS idx_players_is_active ON players (is_active);
CREATE INDEX IF NOT EXISTS idx_players_current_team_id ON players (current_team_id);
CREATE INDEX IF NOT EXISTS idx_players_birth_area_wy_id ON players (birth_area_wy_id);
-- Areas: common lookups and join support (provider ids are optional, but indexed)
CREATE INDEX IF NOT EXISTS idx_areas_wy_id ON areas (wy_id);
CREATE INDEX IF NOT EXISTS idx_areas_ts_id ON areas (ts_id);
-- Teams: common lookups
CREATE INDEX IF NOT EXISTS idx_teams_ts_id ON teams (ts_id);
CREATE INDEX IF NOT EXISTS idx_teams_wy_id ON teams (wy_id);
-- Migration 0004: add indexes to support large-table match queries
-- Matches: most recent match between two teams.
-- Supports queries like:
-- WHERE home_team_ts_id = ? AND away_team_ts_id = ? ORDER BY match_date DESC LIMIT 1
-- and the reversed team order.
CREATE INDEX IF NOT EXISTS idx_matches_home_away_date_desc ON matches (home_team_ts_id, away_team_ts_id, match_date DESC);
CREATE INDEX IF NOT EXISTS idx_matches_away_home_date_desc ON matches (away_team_ts_id, home_team_ts_id, match_date DESC);
-- Matches: matches by competition (and date ordering).
-- Supports queries like:
-- WHERE competition_wy_id = ? ORDER BY match_date DESC LIMIT ?
CREATE INDEX IF NOT EXISTS idx_matches_competition_wy_date_desc ON matches (competition_wy_id, match_date DESC);
-- Matches: matches by season (common usage when competition has multiple seasons).
CREATE INDEX IF NOT EXISTS idx_matches_season_wy_date_desc ON matches (season_wy_id, match_date DESC);
-- Match lineup players: fast retrieval for a match lineup and for player appearance lookups.
CREATE INDEX IF NOT EXISTS idx_match_lineup_players_match_ts_id ON match_lineup_players (match_ts_id);
CREATE INDEX IF NOT EXISTS idx_match_lineup_players_team_ts_match_ts ON match_lineup_players (team_ts_id, match_ts_id);
CREATE INDEX IF NOT EXISTS idx_match_lineup_players_player_ts_id ON match_lineup_players (player_ts_id);
File added
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