Commit 297c6c7e by Augusto

first WS version

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 3001
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:
SSEData:
container_name: SSEData
build:
context: .
dockerfile: Dockerfile
env_file:
- ./.env
ports:
- "3001:3001"
restart: unless-stopped
postgres:
image: postgres:17-alpine
container_name: SSEData-Postgres
profiles:
- with-db
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
ports:
- "5433:5432"
volumes:
- ssedata_pgdata:/var/lib/postgresql/data
restart: unless-stopped
volumes:
ssedata_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 source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
module.exports = {
apps: [
{
name: "sssdata",
cwd: __dirname,
script: "./server",
interpreter: "none",
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: "300M",
time: true,
env: {
APP_PORT: process.env.APP_PORT || "3003",
DB_HOST: process.env.DB_HOST,
DB_PORT: process.env.DB_PORT,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
DB_SSLMODE: process.env.DB_SSLMODE,
ProviderUser: process.env.ProviderUser,
ProviderSecret: process.env.ProviderSecret,
API_USERNAME: process.env.API_USERNAME,
API_PASSWORD: process.env.API_PASSWORD,
},
},
],
};
module ScoutingSystemScoreData
go 1.25.0
require (
github.com/gin-contrib/cors v1.7.0
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.TeamChild{},
&models.Coach{},
&models.Referee{},
&models.Player{},
&models.Match{},
&models.MatchTeam{},
&models.MatchLineupPlayer{},
&models.MatchFormation{},
&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)
areas.GET("/wyscout/:wyId", h.GetByWyID)
}
// 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,
},
})
}
// GetByWyID returns a single area by Wyscout wy_id
// @Summary Get area by Wyscout wy_id
// @Description Returns a single area by its Wyscout wy_id identifier.
// @Tags Areas
// @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 /areas/wyscout/{wyId} [get]
func (h *AreaHandler) GetByWyID(c *gin.Context) {
wyIDStr := c.Param("wyId")
wyID, err := strconv.Atoi(wyIDStr)
if err != nil || wyID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid wyId"})
return
}
area, err := h.Service.GetByProviderID(c.Request.Context(), strconv.Itoa(wyID))
if err != nil {
respondError(c, err)
return
}
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/areas/wyscout/%d", wyID)
c.JSON(http.StatusOK, gin.H{
"data": area,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
// 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 (
"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"})
}
This source diff could not be displayed because it is too large. You can view the blob instead.
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 {
tsID := ""
if r.TsID != nil {
tsID = *r.TsID
}
return StructuredReferee{
ID: r.ID,
WyID: nilIfZero(r.WyID),
TsID: 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 SeasonHandler struct {
Service services.SeasonService
}
func RegisterSeasonRoutes(rg *gin.RouterGroup, db *gorm.DB) {
service := services.NewSeasonService(db)
h := &SeasonHandler{Service: service}
seasons := rg.Group("/seasons")
seasons.GET("", h.List)
seasons.GET("/wyscout/:wyId", h.GetByWyID)
seasons.GET("/provider/:providerId", h.GetByProviderID)
seasons.GET("/:id", h.GetByID)
}
// List seasons
// @Summary List seasons
// @Description Returns a list of seasons with optional pagination.
// @Tags Seasons
// @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 by season name (case-insensitive, partial match)"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /seasons [get]
func (h *SeasonHandler) List(c *gin.Context) {
limitStr := c.DefaultQuery("limit", "100")
offsetStr := c.DefaultQuery("offset", "0")
nameStr := c.Query("name")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
limit = 100
}
offset, err := strconv.Atoi(offsetStr)
if err != nil || offset < 0 {
offset = 0
}
var name *string
if nameStr != "" {
name = &nameStr
}
seasons, total, err := h.Service.ListSeasons(c.Request.Context(), limit, offset, name)
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": seasons,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": "/seasons",
"method": "GET",
"totalItems": total,
"page": page,
"limit": limit,
"hasMore": hasMore,
},
})
}
// GetByID returns a single season by internal ID
// @Summary Get season by ID
// @Description Returns a single season by its internal ID.
// @Tags Seasons
// @Param id path string true "Season internal identifier"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /seasons/{id} [get]
func (h *SeasonHandler) GetByID(c *gin.Context) {
id := c.Param("id")
season, 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("/seasons/%s", id)
c.JSON(http.StatusOK, gin.H{
"data": season,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
// GetByProviderID returns a single season by wy_id (numeric) or ts_id (string)
func (h *SeasonHandler) GetByProviderID(c *gin.Context) {
providerID := c.Param("providerId")
season, 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("/seasons/provider/%s", providerID)
c.JSON(http.StatusOK, gin.H{
"data": season,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
// GetByWyID returns a single season by WyScout WyID
// @Summary Get season by Wyscout ID
// @Description Returns a single season by its Wyscout wy_id identifier.
// @Tags Seasons
// @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 /seasons/wyscout/{wyId} [get]
func (h *SeasonHandler) 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
}
season, 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("/seasons/wyscout/%d", wyID)
c.JSON(http.StatusOK, gin.H{
"data": season,
"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-contrib/cors"
"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.Use(cors.New(cors.Config{
AllowAllOrigins: true,
AllowMethods: []string{
http.MethodGet,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
http.MethodOptions,
},
AllowHeaders: []string{"*"},
ExposeHeaders: []string{"*"},
AllowCredentials: false,
}))
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.RegisterCompetitionRoutes(api, db)
handlers.RegisterSeasonRoutes(api, db)
handlers.RegisterTeamRoutes(api, db)
handlers.RegisterPlayerRoutes(api, db)
handlers.RegisterCoachRoutes(api, db)
handlers.RegisterRefereeRoutes(api, db)
handlers.RegisterMatchRoutes(api, db)
handlers.RegisterStandingRoutes(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)
GetByTsIDs(ctx context.Context, tsIDs []string) ([]models.Area, error)
GetByWyIDs(ctx context.Context, wyIDs []int) ([]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 _, err := strconv.Atoi(providerID); err == nil {
if err := s.db.WithContext(ctx).First(&area, "wy_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
}
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
}
func (s *areaService) GetByTsIDs(ctx context.Context, tsIDs []string) ([]models.Area, error) {
if len(tsIDs) == 0 {
return []models.Area{}, nil
}
var areas []models.Area
if err := s.db.WithContext(ctx).Where("ts_id IN ?", tsIDs).Find(&areas).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternal, "failed to fetch areas by ts_id", err)
}
return areas, nil
}
func (s *areaService) GetByWyIDs(ctx context.Context, wyIDs []int) ([]models.Area, error) {
if len(wyIDs) == 0 {
return []models.Area{}, nil
}
wyIDStrs := make([]string, 0, len(wyIDs))
for _, id := range wyIDs {
if id > 0 {
wyIDStrs = append(wyIDStrs, strconv.Itoa(id))
}
}
if len(wyIDStrs) == 0 {
return []models.Area{}, nil
}
var areas []models.Area
if err := s.db.WithContext(ctx).Where("wy_id IN ?", wyIDStrs).Find(&areas).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternal, "failed to fetch areas by wy_id", err)
}
return areas, 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)
}
query = query.Order("is_active DESC")
query = query.Order("CASE WHEN current_team_wy_id IS NULL THEN 1 ELSE 0 END")
query = query.Order("CASE WHEN years_experience IS NULL THEN 1 ELSE 0 END")
query = query.Order("years_experience DESC")
query = query.Order("last_name ASC")
query = query.Order("first_name ASC")
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to count coaches", err)
}
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"
"strconv"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models"
)
type CompetitionService interface {
ListCompetitions(ctx context.Context, limit, offset int, name *string, areaWyID *int) ([]models.Competition, int64, error)
GetByID(ctx context.Context, id string) (models.Competition, error)
GetByWyID(ctx context.Context, wyID int) (models.Competition, error)
GetByAnyProviderID(ctx context.Context, providerID string) (models.Competition, error)
}
type competitionService struct {
db *gorm.DB
}
func NewCompetitionService(db *gorm.DB) CompetitionService {
return &competitionService{db: db}
}
func (s *competitionService) ListCompetitions(ctx context.Context, limit, offset int, name *string, areaWyID *int) ([]models.Competition, int64, error) {
var competitions []models.Competition
query := s.db.WithContext(ctx).Model(&models.Competition{})
if name != nil && *name != "" {
query = query.Where("name ILIKE ?", "%"+*name+"%")
}
if areaWyID != nil && *areaWyID > 0 {
query = query.Where("area_wy_id = ?", *areaWyID)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to count competitions", err)
}
if err := query.Limit(limit).Offset(offset).Find(&competitions).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to fetch competitions", err)
}
return competitions, total, nil
}
func (s *competitionService) GetByID(ctx context.Context, id string) (models.Competition, error) {
var comp models.Competition
if err := s.db.WithContext(ctx).First(&comp, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Competition{}, errors.New(errors.CodeNotFound, "competition not found")
}
return models.Competition{}, errors.Wrap(errors.CodeInternal, "failed to fetch competition", err)
}
return comp, nil
}
func (s *competitionService) GetByAnyProviderID(ctx context.Context, providerID string) (models.Competition, 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 comp models.Competition
if err := s.db.WithContext(ctx).First(&comp, "ts_id = ?", providerID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Competition{}, errors.New(errors.CodeNotFound, "competition not found")
}
return models.Competition{}, errors.Wrap(errors.CodeInternal, "failed to fetch competition", err)
}
return comp, nil
}
func (s *competitionService) GetByWyID(ctx context.Context, wyID int) (models.Competition, error) {
var comp models.Competition
if err := s.db.WithContext(ctx).First(&comp, "wy_id = ?", wyID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Competition{}, errors.New(errors.CodeNotFound, "competition not found")
}
return models.Competition{}, errors.Wrap(errors.CodeInternal, "failed to fetch competition", err)
}
return comp, nil
}
package services
import (
"context"
"fmt"
"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)
GetByWyID(ctx context.Context, wyID int) (models.Match, error)
GetByTsID(ctx context.Context, matchTsID string) (models.Match, error)
GetHeadToHeadMostRecent(ctx context.Context, teamHomeWyID int, teamAwayWyID int, from *time.Time, to *time.Time) (models.Match, error)
ListHeadToHead(ctx context.Context, teamHomeWyID int, teamAwayWyID int, from *time.Time, to *time.Time, limit int, offset int) ([]models.Match, int64, 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)
GetLineupByWyID(ctx context.Context, wyID int) (models.Match, []models.MatchTeam, []models.MatchLineupPlayer, error)
}
type ListMatchesOptions struct {
Limit int
Offset int
CompetitionWyID *int
SeasonWyID *int
TeamTsID *string
TeamWyID *int
TeamSide *string // home|away|either
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)
}
teamSide := "either"
if opts.TeamSide != nil && *opts.TeamSide != "" {
teamSide = *opts.TeamSide
}
if teamSide != "home" && teamSide != "away" && teamSide != "either" {
return nil, 0, errors.New(errors.CodeInvalidInput, "invalid teamSide (use home, away, or either)")
}
if opts.TeamWyID != nil {
if teamSide == "home" {
query = query.Where("home_team_wy_id = ?", *opts.TeamWyID)
} else if teamSide == "away" {
query = query.Where("away_team_wy_id = ?", *opts.TeamWyID)
} else {
query = query.Where("home_team_wy_id = ? OR away_team_wy_id = ?", *opts.TeamWyID, *opts.TeamWyID)
}
} else if opts.TeamTsID != nil && *opts.TeamTsID != "" {
if teamSide == "home" {
query = query.Where("home_team_ts_id = ?", *opts.TeamTsID)
} else if teamSide == "away" {
query = query.Where("away_team_ts_id = ?", *opts.TeamTsID)
} else {
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) GetByWyID(ctx context.Context, wyID int) (models.Match, error) {
var match models.Match
if err := s.db.WithContext(ctx).First(&match, "wy_id = ?", wyID).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, teamHomeWyID int, teamAwayWyID int, from *time.Time, to *time.Time) (models.Match, error) {
var match models.Match
q := s.db.WithContext(ctx).Model(&models.Match{}).
Where("home_team_wy_id = ? AND away_team_wy_id = ?", teamHomeWyID, teamAwayWyID).
Order("match_date desc")
if from != nil {
q = q.Where("match_date >= ?", *from)
}
if to != nil {
q = q.Where("match_date <= ?", *to)
}
if err := q.First(&match).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Match{}, errors.New(errors.CodeNotFound, fmt.Sprintf("match not found for home=%d away=%d", teamHomeWyID, teamAwayWyID))
}
return models.Match{}, errors.Wrap(errors.CodeInternal, "failed to fetch head-to-head match", err)
}
return match, nil
}
func (s *matchService) ListHeadToHead(ctx context.Context, teamHomeWyID int, teamAwayWyID int, from *time.Time, to *time.Time, limit int, offset int) ([]models.Match, int64, error) {
var matches []models.Match
q := s.db.WithContext(ctx).Model(&models.Match{}).
Where("home_team_wy_id = ? AND away_team_wy_id = ?", teamHomeWyID, teamAwayWyID)
if from != nil {
q = q.Where("match_date >= ?", *from)
}
if to != nil {
q = q.Where("match_date <= ?", *to)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to count head-to-head matches", err)
}
if err := q.Order("match_date desc").Limit(limit).Offset(offset).Find(&matches).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to fetch head-to-head matches", err)
}
return matches, total, 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 == nil || *match.TsID == "" {
return models.Match{}, nil, nil, errors.New(errors.CodeNotFound, "match not found")
}
return s.GetLineup(ctx, *match.TsID)
}
func (s *matchService) GetLineupByWyID(ctx context.Context, wyID int) (models.Match, []models.MatchTeam, []models.MatchLineupPlayer, error) {
match, err := s.GetByWyID(ctx, wyID)
if err != nil {
return models.Match{}, nil, nil, err
}
if match.TsID == nil || *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,
)
query = query.Order("CASE WHEN market_value IS NULL THEN 1 ELSE 0 END")
query = query.Order("market_value DESC")
query = query.Order("CASE WHEN current_national_team_id IS NULL THEN 1 ELSE 0 END")
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")
query = query.Order("last_name ASC")
query = query.Order("first_name ASC")
} 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 {
if err := s.db.WithContext(ctx).First(&player, "uid = ?", 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
}
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 SeasonService interface {
ListSeasons(ctx context.Context, limit, offset int, name *string) ([]models.Season, int64, error)
GetByID(ctx context.Context, id string) (models.Season, error)
GetByWyID(ctx context.Context, wyID int) (models.Season, error)
GetByAnyProviderID(ctx context.Context, providerID string) (models.Season, error)
}
type seasonService struct {
db *gorm.DB
}
func NewSeasonService(db *gorm.DB) SeasonService {
return &seasonService{db: db}
}
func (s *seasonService) ListSeasons(ctx context.Context, limit, offset int, name *string) ([]models.Season, int64, error) {
var seasons []models.Season
query := s.db.WithContext(ctx).Model(&models.Season{})
if name != nil && *name != "" {
query = query.Where("name ILIKE ?", "%"+*name+"%")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to count seasons", err)
}
if err := query.Limit(limit).Offset(offset).Find(&seasons).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to fetch seasons", err)
}
return seasons, total, nil
}
func (s *seasonService) GetByID(ctx context.Context, id string) (models.Season, error) {
var season models.Season
if err := s.db.WithContext(ctx).First(&season, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Season{}, errors.New(errors.CodeNotFound, "season not found")
}
return models.Season{}, errors.Wrap(errors.CodeInternal, "failed to fetch season", err)
}
return season, nil
}
func (s *seasonService) GetByAnyProviderID(ctx context.Context, providerID string) (models.Season, 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 season models.Season
if err := s.db.WithContext(ctx).First(&season, "ts_id = ?", providerID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Season{}, errors.New(errors.CodeNotFound, "season not found")
}
return models.Season{}, errors.Wrap(errors.CodeInternal, "failed to fetch season", err)
}
return season, nil
}
func (s *seasonService) GetByWyID(ctx context.Context, wyID int) (models.Season, error) {
var season models.Season
if err := s.db.WithContext(ctx).First(&season, "wy_id = ?", wyID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return models.Season{}, errors.New(errors.CodeNotFound, "season not found")
}
return models.Season{}, errors.Wrap(errors.CodeInternal, "failed to fetch season", err)
}
return season, 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, name string) ([]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)
GetByTsIDs(ctx context.Context, tsIDs []string) ([]models.Team, error)
GetByGsmIDs(ctx context.Context, gsmIDs []int) ([]models.Team, error)
GetByWyIDs(ctx context.Context, wyIDs []int) ([]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, name string) ([]models.Team, int64, error) {
var teams []models.Team
query := s.db.WithContext(ctx).Model(&models.Team{})
if name != "" {
query = query.Where("name ILIKE ?", "%"+name+"%")
}
query = query.Order("CASE WHEN market_value IS NULL THEN 1 ELSE 0 END")
query = query.Order("market_value DESC")
query = query.Order("is_active DESC")
query = query.Order("CASE WHEN competition_ts_id IS NULL OR competition_ts_id = '' THEN 1 ELSE 0 END")
query = query.Order("CASE WHEN league IS NULL OR league = '' THEN 1 ELSE 0 END")
query = query.Order("name ASC")
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
}
func (s *teamService) GetByTsIDs(ctx context.Context, tsIDs []string) ([]models.Team, error) {
if len(tsIDs) == 0 {
return []models.Team{}, nil
}
var teams []models.Team
if err := s.db.WithContext(ctx).Where("ts_id IN ?", tsIDs).Find(&teams).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternal, "failed to fetch teams by ts_id", err)
}
return teams, nil
}
func (s *teamService) GetByWyIDs(ctx context.Context, wyIDs []int) ([]models.Team, error) {
if len(wyIDs) == 0 {
return []models.Team{}, nil
}
var teams []models.Team
if err := s.db.WithContext(ctx).Where("wy_id IN ?", wyIDs).Find(&teams).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternal, "failed to fetch teams by wy_id", err)
}
return teams, nil
}
func (s *teamService) GetByGsmIDs(ctx context.Context, gsmIDs []int) ([]models.Team, error) {
if len(gsmIDs) == 0 {
return []models.Team{}, nil
}
var teams []models.Team
if err := s.db.WithContext(ctx).Where("gsm_id IN ?", gsmIDs).Find(&teams).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternal, "failed to fetch teams by gsm_id", err)
}
return teams, 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);
-- Migration 0005: add TheSports country_id field to players
ALTER TABLE players
ADD COLUMN IF NOT EXISTS country_ts_id varchar(64);
ALTER TABLE teams
ADD COLUMN IF NOT EXISTS country_ts_id VARCHAR(64),
ADD COLUMN IF NOT EXISTS competition_ts_id VARCHAR(64),
ADD COLUMN IF NOT EXISTS coach_ts_id VARCHAR(64),
ADD COLUMN IF NOT EXISTS venue_ts_id VARCHAR(64),
ADD COLUMN IF NOT EXISTS website TEXT,
ADD COLUMN IF NOT EXISTS market_value INTEGER,
ADD COLUMN IF NOT EXISTS market_value_currency TEXT,
ADD COLUMN IF NOT EXISTS total_players INTEGER,
ADD COLUMN IF NOT EXISTS foreign_players INTEGER,
ADD COLUMN IF NOT EXISTS national_players INTEGER;
ALTER TABLE coaches
ADD COLUMN IF NOT EXISTS country_ts_id VARCHAR(64),
ADD COLUMN IF NOT EXISTS team_ts_id VARCHAR(64);
ALTER TABLE competitions
ADD COLUMN IF NOT EXISTS ts_category_id VARCHAR(64),
ADD COLUMN IF NOT EXISTS country_ts_id VARCHAR(64),
ADD COLUMN IF NOT EXISTS short_name TEXT,
ADD COLUMN IF NOT EXISTS logo TEXT,
ADD COLUMN IF NOT EXISTS ts_type INTEGER,
ADD COLUMN IF NOT EXISTS cur_season_ts_id VARCHAR(64),
ADD COLUMN IF NOT EXISTS cur_stage_ts_id VARCHAR(64),
ADD COLUMN IF NOT EXISTS cur_round INTEGER,
ADD COLUMN IF NOT EXISTS round_count INTEGER,
ADD COLUMN IF NOT EXISTS title_holder_json JSONB,
ADD COLUMN IF NOT EXISTS most_titles_json JSONB,
ADD COLUMN IF NOT EXISTS newcomers_json JSONB,
ADD COLUMN IF NOT EXISTS divisions_json JSONB,
ADD COLUMN IF NOT EXISTS host_json JSONB,
ADD COLUMN IF NOT EXISTS uid TEXT,
ADD COLUMN IF NOT EXISTS ts_updated_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS match_formations (
id VARCHAR(16) PRIMARY KEY,
match_wy_id INTEGER NOT NULL,
side TEXT NOT NULL,
wy_id INTEGER,
scheme TEXT,
start_sec INTEGER,
end_sec INTEGER,
match_period_start TEXT,
match_period_end TEXT,
team_wy_id INTEGER,
players_on_field INTEGER,
players_json JSONB,
api_last_synced_at TIMESTAMPTZ,
api_sync_status TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_match_formations_match_wy_id ON match_formations(match_wy_id);
CREATE INDEX IF NOT EXISTS idx_match_formations_team_wy_id ON match_formations(team_wy_id);
CREATE INDEX IF NOT EXISTS idx_match_formations_match_wy_id_side ON match_formations(match_wy_id, side);
ALTER TABLE match_formations
ADD CONSTRAINT match_formations_match_wy_id_side_wy_id_unique
UNIQUE (match_wy_id, side, wy_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