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).
// Package docs Code generated by swaggo/swag. DO NOT EDIT
package docs
import "github.com/swaggo/swag"
const docTemplate = `{
"schemes": {{ marshal .Schemes }},
"swagger": "2.0",
"info": {
"description": "{{escape .Description}}",
"title": "{{.Title}}",
"contact": {},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/areas": {
"get": {
"description": "Returns a paginated list of areas, optionally filtered by name or alpha codes.",
"tags": [
"Areas"
],
"summary": "List areas",
"parameters": [
{
"type": "integer",
"description": "Maximum number of items to return (default 100)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of items to skip before starting to collect the result set (default 0)",
"name": "offset",
"in": "query"
},
{
"type": "string",
"description": "Filter areas by name (case-insensitive, partial match)",
"name": "name",
"in": "query"
},
{
"type": "string",
"description": "Filter areas by 2-letter country code",
"name": "alpha2code",
"in": "query"
},
{
"type": "string",
"description": "Filter areas by 3-letter country code",
"name": "alpha3code",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/areas/provider/{providerId}": {
"get": {
"description": "Returns a single area by its provider (wy_id) identifier.",
"tags": [
"Areas"
],
"summary": "Get area by provider ID",
"parameters": [
{
"type": "string",
"description": "Provider (wy_id) identifier",
"name": "providerId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/areas/{id}": {
"get": {
"description": "Returns a single area by its internal ID.",
"tags": [
"Areas"
],
"summary": "Get area by ID",
"parameters": [
{
"type": "string",
"description": "Area internal identifier",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/coaches": {
"get": {
"description": "Returns a paginated list of coaches, optionally filtered by name, team, position, or active status.",
"tags": [
"Coaches"
],
"summary": "List coaches",
"parameters": [
{
"type": "integer",
"description": "Maximum number of items to return (default 100)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of items to skip before starting to collect the result set (default 0)",
"name": "offset",
"in": "query"
},
{
"type": "string",
"description": "Filter coaches by name (first, last, middle, or short)",
"name": "name",
"in": "query"
},
{
"type": "string",
"description": "Filter coaches by current team ID (wy_id)",
"name": "teamId",
"in": "query"
},
{
"type": "string",
"description": "Filter coaches by position (head_coach, assistant_coach, etc.)",
"name": "position",
"in": "query"
},
{
"type": "boolean",
"description": "Filter active coaches only",
"name": "active",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/coaches/provider/{providerId}": {
"get": {
"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"
],
"summary": "Get coach by provider ID",
"parameters": [
{
"type": "string",
"description": "Provider identifier (wy_id or ts_id)",
"name": "providerId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/coaches/wyscout/{wyId}": {
"get": {
"description": "Returns a single coach by its Wyscout wy_id identifier.",
"tags": [
"Coaches"
],
"summary": "Get coach by Wyscout ID",
"parameters": [
{
"type": "integer",
"description": "Wyscout wy_id identifier",
"name": "wyId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/coaches/{id}": {
"get": {
"description": "Returns a single coach by its internal ID.",
"tags": [
"Coaches"
],
"summary": "Get coach by ID",
"parameters": [
{
"type": "string",
"description": "Coach internal identifier",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/areas": {
"post": {
"description": "Imports all countries/regions from TheSports football country list API.",
"tags": [
"Import"
],
"summary": "Import areas from TheSports",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/coaches": {
"post": {
"description": "Performs a coach import using TheSports coach list API. If ` + "`" + `since` + "`" + ` is provided (unix seconds), only coaches updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.",
"tags": [
"Import"
],
"summary": "Import coaches from TheSports",
"parameters": [
{
"type": "integer",
"description": "Page size per request (default 100, only used for full imports)",
"name": "pageSize",
"in": "query"
},
{
"type": "integer",
"description": "Unix timestamp (seconds) to import only coaches updated since this time",
"name": "since",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/competitions": {
"post": {
"description": "Performs a competition import using TheSports competition additional list API. If ` + "`" + `since` + "`" + ` is provided (unix seconds), only competitions updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.",
"tags": [
"Import"
],
"summary": "Import competitions from TheSports",
"parameters": [
{
"type": "integer",
"description": "Page size per request (default 100, only used for full imports)",
"name": "pageSize",
"in": "query"
},
{
"type": "integer",
"description": "Unix timestamp (seconds) to import only competitions updated since this time",
"name": "since",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/matches/diary": {
"post": {
"description": "Performs a matches import using TheSports match/diary API for a given date or 24h window. The ` + "`" + `date` + "`" + ` query parameter (YYYY-MM-DD) is recommended; if omitted, the provider default will be used (usually current day).",
"tags": [
"Import"
],
"summary": "Import matches diary from TheSports",
"parameters": [
{
"type": "string",
"description": "Date in YYYY-MM-DD format for which to import the schedule/results",
"name": "date",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/matches/lineup": {
"post": {
"description": "Performs a lineup import using TheSports match/lineup/detail API. If 'matchTsId' is provided, imports lineup for a specific match. If omitted, processes matches from the last 30 days only (API limitation). Use 'limit' for testing and 'batchSize' to control memory usage.",
"tags": [
"Import"
],
"summary": "Import match lineups from TheSports",
"parameters": [
{
"type": "string",
"description": "TheSports match id (tsId) for which to import the lineup (optional; if omitted, processes matches from last 30 days)",
"name": "matchTsId",
"in": "query"
},
{
"type": "integer",
"description": "Maximum number of matches to process in batch mode (default: no limit; useful for debugging)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of matches to load per batch (default: 1000; lower for memory constraints)",
"name": "batchSize",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/matches/list": {
"post": {
"description": "Performs a full import of all matches from TheSports match/list API using pagination. This is intended for one-time initial sync to get all historical matches. The API returns 1000 matches per page and stops when total is 0. Use startPage to resume from a specific page if the import was interrupted.",
"tags": [
"Import"
],
"summary": "Import all matches from TheSports (one-time full sync)",
"parameters": [
{
"type": "integer",
"description": "Starting page number (default: 1, use to resume interrupted import)",
"name": "startPage",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/matches/recent": {
"post": {
"description": "Performs a matches import using TheSports match/recent/list API. If ` + "`" + `since` + "`" + ` is provided (unix seconds), only matches updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination (last 30 days).",
"tags": [
"Import"
],
"summary": "Import recent matches from TheSports",
"parameters": [
{
"type": "integer",
"description": "Page size per request (default 100, only used for full imports)",
"name": "pageSize",
"in": "query"
},
{
"type": "integer",
"description": "Unix timestamp (seconds) to import only matches updated since this time",
"name": "since",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/players": {
"post": {
"description": "Performs a player import using TheSports player with_stat list API. If ` + "`" + `since` + "`" + ` is provided (unix seconds), only players updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.",
"tags": [
"Import"
],
"summary": "Import players from TheSports",
"parameters": [
{
"type": "integer",
"description": "Page size per request (default 100, only used for full imports)",
"name": "pageSize",
"in": "query"
},
{
"type": "integer",
"description": "Unix timestamp (seconds) to import only players updated since this time",
"name": "since",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/referees": {
"post": {
"description": "Performs a referee import using TheSports referee list API. If ` + "`" + `since` + "`" + ` is provided (unix seconds), only referees updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.",
"tags": [
"Import"
],
"summary": "Import referees from TheSports",
"parameters": [
{
"type": "integer",
"description": "Page size per request (default 100, only used for full imports)",
"name": "pageSize",
"in": "query"
},
{
"type": "integer",
"description": "Unix timestamp (seconds) to import only referees updated since this time",
"name": "since",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/seasons": {
"post": {
"description": "Performs a season import using TheSports season list API. If ` + "`" + `since` + "`" + ` is provided (unix seconds), only seasons updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.",
"tags": [
"Import"
],
"summary": "Import seasons from TheSports",
"parameters": [
{
"type": "integer",
"description": "Page size per request (default 100, only used for full imports)",
"name": "pageSize",
"in": "query"
},
{
"type": "integer",
"description": "Unix timestamp (seconds) to import only seasons updated since this time",
"name": "since",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/teams": {
"post": {
"description": "Performs a team import using TheSports team additional list API. If ` + "`" + `since` + "`" + ` is provided (unix seconds), only teams updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.",
"tags": [
"Import"
],
"summary": "Import teams from TheSports",
"parameters": [
{
"type": "integer",
"description": "Page size per request (default 100, only used for full imports)",
"name": "pageSize",
"in": "query"
},
{
"type": "integer",
"description": "Unix timestamp (seconds) to import only teams updated since this time",
"name": "since",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/matches": {
"get": {
"description": "Returns a paginated list of matches, optionally filtered by competitionWyId, seasonWyId, teamTsId and date range.",
"tags": [
"Matches"
],
"summary": "List matches",
"parameters": [
{
"type": "integer",
"description": "Maximum number of items to return (default 100)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of items to skip before starting to collect the result set (default 0)",
"name": "offset",
"in": "query"
},
{
"type": "integer",
"description": "Filter by competition wy_id",
"name": "competitionWyId",
"in": "query"
},
{
"type": "integer",
"description": "Filter by season wy_id",
"name": "seasonWyId",
"in": "query"
},
{
"type": "string",
"description": "Filter by team ts_id (matches where team is home or away)",
"name": "teamTsId",
"in": "query"
},
{
"type": "string",
"description": "Filter matches on/after date (RFC3339 or YYYY-MM-DD)",
"name": "from",
"in": "query"
},
{
"type": "string",
"description": "Filter matches on/before date (RFC3339 or YYYY-MM-DD)",
"name": "to",
"in": "query"
},
{
"type": "string",
"description": "Filter by match status",
"name": "status",
"in": "query"
},
{
"type": "string",
"description": "Sort order by match_date: asc|desc (default desc)",
"name": "order",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/matches/head-to-head": {
"get": {
"description": "Returns the most recent match between teamA and teamB (by ts_id), regardless of home/away.",
"tags": [
"Matches"
],
"summary": "Most recent match between two teams",
"parameters": [
{
"type": "string",
"description": "Team A ts_id",
"name": "teamA",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Team B ts_id",
"name": "teamB",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/matches/id/{id}/lineup": {
"get": {
"description": "Returns the stored lineup data for a match (teams/formations/coaches + players) by internal match id.",
"tags": [
"Matches"
],
"summary": "Get match lineup by internal match id",
"parameters": [
{
"type": "string",
"description": "Internal match id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/matches/{matchTsId}/lineup": {
"get": {
"description": "Returns the stored lineup data for a match (teams/formations/coaches + players) by match ts_id.",
"tags": [
"Matches"
],
"summary": "Get match lineup by match id",
"parameters": [
{
"type": "string",
"description": "TheSports match id (tsId)",
"name": "matchTsId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/players": {
"get": {
"description": "Returns a paginated list of players, optionally filtered by name, team, or country.",
"tags": [
"Players"
],
"summary": "List players",
"parameters": [
{
"type": "integer",
"description": "Maximum number of items to return (default 100)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of items to skip before starting to collect the result set (default 0)",
"name": "offset",
"in": "query"
},
{
"type": "string",
"description": "Filter players by name (short, first, middle, or last)",
"name": "name",
"in": "query"
},
{
"type": "string",
"description": "Filter players by current team ID",
"name": "teamId",
"in": "query"
},
{
"type": "string",
"description": "Filter players by birth country name",
"name": "country",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/players/wyscout/{wyId}": {
"get": {
"description": "Returns a single player by its provider (wy_id) identifier.",
"tags": [
"Players"
],
"summary": "Get player by provider ID",
"parameters": [
{
"type": "integer",
"description": "Provider (wy_id) identifier",
"name": "wyId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/players/{id}": {
"get": {
"description": "Returns a single player by its internal ID.",
"tags": [
"Players"
],
"summary": "Get player by ID",
"parameters": [
{
"type": "string",
"description": "Player internal identifier",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/referees": {
"get": {
"description": "Returns a paginated list of referees, optionally filtered by name, referee type, nationality, or active status.",
"tags": [
"Referees"
],
"summary": "List referees",
"parameters": [
{
"type": "integer",
"description": "Maximum number of items to return (default 100)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of items to skip before starting to collect the result set (default 0)",
"name": "offset",
"in": "query"
},
{
"type": "string",
"description": "Filter referees by name (first, last, middle, or short)",
"name": "name",
"in": "query"
},
{
"type": "integer",
"description": "Filter referees by nationality WyID",
"name": "countryWyId",
"in": "query"
},
{
"type": "string",
"description": "Filter referees by type (main, assistant, var, etc.)",
"name": "type",
"in": "query"
},
{
"type": "boolean",
"description": "Filter active referees only",
"name": "active",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/referees/provider/{providerId}": {
"get": {
"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"
],
"summary": "Get referee by provider ID",
"parameters": [
{
"type": "string",
"description": "Provider identifier (wy_id or ts_id)",
"name": "providerId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/referees/wyscout/{wyId}": {
"get": {
"description": "Returns a single referee by its Wyscout wy_id identifier.",
"tags": [
"Referees"
],
"summary": "Get referee by Wyscout ID",
"parameters": [
{
"type": "integer",
"description": "Wyscout wy_id identifier",
"name": "wyId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/referees/{id}": {
"get": {
"description": "Returns a single referee by its internal ID.",
"tags": [
"Referees"
],
"summary": "Get referee by ID",
"parameters": [
{
"type": "string",
"description": "Referee internal identifier",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/teams": {
"get": {
"description": "Returns a list of teams with optional pagination.",
"tags": [
"Teams"
],
"summary": "List teams",
"parameters": [
{
"type": "integer",
"description": "Maximum number of items to return (default 100)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of items to skip before starting to collect the result set (default 0)",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/teams/wyscout/{wyId}": {
"get": {
"description": "Returns a single team by its Wyscout wy_id identifier.",
"tags": [
"Teams"
],
"summary": "Get team by Wyscout ID",
"parameters": [
{
"type": "integer",
"description": "Wyscout wy_id identifier",
"name": "wyId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/teams/{id}": {
"get": {
"description": "Returns a single team by its internal ID.",
"tags": [
"Teams"
],
"summary": "Get team by ID",
"parameters": [
{
"type": "string",
"description": "Team internal identifier",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
}
}`
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "1.0",
Host: "",
BasePath: "/api",
Schemes: []string{},
Title: "Scouting System Score Data API",
Description: "API server for scouting system score data.",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
RightDelim: "}}",
}
func init() {
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
}
{
"swagger": "2.0",
"info": {
"description": "API server for scouting system score data.",
"title": "Scouting System Score Data API",
"contact": {},
"version": "1.0"
},
"basePath": "/api",
"paths": {
"/areas": {
"get": {
"description": "Returns a paginated list of areas, optionally filtered by name or alpha codes.",
"tags": [
"Areas"
],
"summary": "List areas",
"parameters": [
{
"type": "integer",
"description": "Maximum number of items to return (default 100)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of items to skip before starting to collect the result set (default 0)",
"name": "offset",
"in": "query"
},
{
"type": "string",
"description": "Filter areas by name (case-insensitive, partial match)",
"name": "name",
"in": "query"
},
{
"type": "string",
"description": "Filter areas by 2-letter country code",
"name": "alpha2code",
"in": "query"
},
{
"type": "string",
"description": "Filter areas by 3-letter country code",
"name": "alpha3code",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/areas/provider/{providerId}": {
"get": {
"description": "Returns a single area by its provider (wy_id) identifier.",
"tags": [
"Areas"
],
"summary": "Get area by provider ID",
"parameters": [
{
"type": "string",
"description": "Provider (wy_id) identifier",
"name": "providerId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/areas/{id}": {
"get": {
"description": "Returns a single area by its internal ID.",
"tags": [
"Areas"
],
"summary": "Get area by ID",
"parameters": [
{
"type": "string",
"description": "Area internal identifier",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/coaches": {
"get": {
"description": "Returns a paginated list of coaches, optionally filtered by name, team, position, or active status.",
"tags": [
"Coaches"
],
"summary": "List coaches",
"parameters": [
{
"type": "integer",
"description": "Maximum number of items to return (default 100)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of items to skip before starting to collect the result set (default 0)",
"name": "offset",
"in": "query"
},
{
"type": "string",
"description": "Filter coaches by name (first, last, middle, or short)",
"name": "name",
"in": "query"
},
{
"type": "string",
"description": "Filter coaches by current team ID (wy_id)",
"name": "teamId",
"in": "query"
},
{
"type": "string",
"description": "Filter coaches by position (head_coach, assistant_coach, etc.)",
"name": "position",
"in": "query"
},
{
"type": "boolean",
"description": "Filter active coaches only",
"name": "active",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/coaches/provider/{providerId}": {
"get": {
"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"
],
"summary": "Get coach by provider ID",
"parameters": [
{
"type": "string",
"description": "Provider identifier (wy_id or ts_id)",
"name": "providerId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/coaches/wyscout/{wyId}": {
"get": {
"description": "Returns a single coach by its Wyscout wy_id identifier.",
"tags": [
"Coaches"
],
"summary": "Get coach by Wyscout ID",
"parameters": [
{
"type": "integer",
"description": "Wyscout wy_id identifier",
"name": "wyId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/coaches/{id}": {
"get": {
"description": "Returns a single coach by its internal ID.",
"tags": [
"Coaches"
],
"summary": "Get coach by ID",
"parameters": [
{
"type": "string",
"description": "Coach internal identifier",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/areas": {
"post": {
"description": "Imports all countries/regions from TheSports football country list API.",
"tags": [
"Import"
],
"summary": "Import areas from TheSports",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/coaches": {
"post": {
"description": "Performs a coach import using TheSports coach list API. If `since` is provided (unix seconds), only coaches updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.",
"tags": [
"Import"
],
"summary": "Import coaches from TheSports",
"parameters": [
{
"type": "integer",
"description": "Page size per request (default 100, only used for full imports)",
"name": "pageSize",
"in": "query"
},
{
"type": "integer",
"description": "Unix timestamp (seconds) to import only coaches updated since this time",
"name": "since",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/competitions": {
"post": {
"description": "Performs a competition import using TheSports competition additional list API. If `since` is provided (unix seconds), only competitions updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.",
"tags": [
"Import"
],
"summary": "Import competitions from TheSports",
"parameters": [
{
"type": "integer",
"description": "Page size per request (default 100, only used for full imports)",
"name": "pageSize",
"in": "query"
},
{
"type": "integer",
"description": "Unix timestamp (seconds) to import only competitions updated since this time",
"name": "since",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/matches/diary": {
"post": {
"description": "Performs a matches import using TheSports match/diary API for a given date or 24h window. The `date` query parameter (YYYY-MM-DD) is recommended; if omitted, the provider default will be used (usually current day).",
"tags": [
"Import"
],
"summary": "Import matches diary from TheSports",
"parameters": [
{
"type": "string",
"description": "Date in YYYY-MM-DD format for which to import the schedule/results",
"name": "date",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/matches/lineup": {
"post": {
"description": "Performs a lineup import using TheSports match/lineup/detail API. If 'matchTsId' is provided, imports lineup for a specific match. If omitted, processes matches from the last 30 days only (API limitation). Use 'limit' for testing and 'batchSize' to control memory usage.",
"tags": [
"Import"
],
"summary": "Import match lineups from TheSports",
"parameters": [
{
"type": "string",
"description": "TheSports match id (tsId) for which to import the lineup (optional; if omitted, processes matches from last 30 days)",
"name": "matchTsId",
"in": "query"
},
{
"type": "integer",
"description": "Maximum number of matches to process in batch mode (default: no limit; useful for debugging)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of matches to load per batch (default: 1000; lower for memory constraints)",
"name": "batchSize",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/matches/list": {
"post": {
"description": "Performs a full import of all matches from TheSports match/list API using pagination. This is intended for one-time initial sync to get all historical matches. The API returns 1000 matches per page and stops when total is 0. Use startPage to resume from a specific page if the import was interrupted.",
"tags": [
"Import"
],
"summary": "Import all matches from TheSports (one-time full sync)",
"parameters": [
{
"type": "integer",
"description": "Starting page number (default: 1, use to resume interrupted import)",
"name": "startPage",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/matches/recent": {
"post": {
"description": "Performs a matches import using TheSports match/recent/list API. If `since` is provided (unix seconds), only matches updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination (last 30 days).",
"tags": [
"Import"
],
"summary": "Import recent matches from TheSports",
"parameters": [
{
"type": "integer",
"description": "Page size per request (default 100, only used for full imports)",
"name": "pageSize",
"in": "query"
},
{
"type": "integer",
"description": "Unix timestamp (seconds) to import only matches updated since this time",
"name": "since",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/players": {
"post": {
"description": "Performs a player import using TheSports player with_stat list API. If `since` is provided (unix seconds), only players updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.",
"tags": [
"Import"
],
"summary": "Import players from TheSports",
"parameters": [
{
"type": "integer",
"description": "Page size per request (default 100, only used for full imports)",
"name": "pageSize",
"in": "query"
},
{
"type": "integer",
"description": "Unix timestamp (seconds) to import only players updated since this time",
"name": "since",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/referees": {
"post": {
"description": "Performs a referee import using TheSports referee list API. If `since` is provided (unix seconds), only referees updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.",
"tags": [
"Import"
],
"summary": "Import referees from TheSports",
"parameters": [
{
"type": "integer",
"description": "Page size per request (default 100, only used for full imports)",
"name": "pageSize",
"in": "query"
},
{
"type": "integer",
"description": "Unix timestamp (seconds) to import only referees updated since this time",
"name": "since",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/seasons": {
"post": {
"description": "Performs a season import using TheSports season list API. If `since` is provided (unix seconds), only seasons updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.",
"tags": [
"Import"
],
"summary": "Import seasons from TheSports",
"parameters": [
{
"type": "integer",
"description": "Page size per request (default 100, only used for full imports)",
"name": "pageSize",
"in": "query"
},
{
"type": "integer",
"description": "Unix timestamp (seconds) to import only seasons updated since this time",
"name": "since",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/import/teams": {
"post": {
"description": "Performs a team import using TheSports team additional list API. If `since` is provided (unix seconds), only teams updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.",
"tags": [
"Import"
],
"summary": "Import teams from TheSports",
"parameters": [
{
"type": "integer",
"description": "Page size per request (default 100, only used for full imports)",
"name": "pageSize",
"in": "query"
},
{
"type": "integer",
"description": "Unix timestamp (seconds) to import only teams updated since this time",
"name": "since",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/matches": {
"get": {
"description": "Returns a paginated list of matches, optionally filtered by competitionWyId, seasonWyId, teamTsId and date range.",
"tags": [
"Matches"
],
"summary": "List matches",
"parameters": [
{
"type": "integer",
"description": "Maximum number of items to return (default 100)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of items to skip before starting to collect the result set (default 0)",
"name": "offset",
"in": "query"
},
{
"type": "integer",
"description": "Filter by competition wy_id",
"name": "competitionWyId",
"in": "query"
},
{
"type": "integer",
"description": "Filter by season wy_id",
"name": "seasonWyId",
"in": "query"
},
{
"type": "string",
"description": "Filter by team ts_id (matches where team is home or away)",
"name": "teamTsId",
"in": "query"
},
{
"type": "string",
"description": "Filter matches on/after date (RFC3339 or YYYY-MM-DD)",
"name": "from",
"in": "query"
},
{
"type": "string",
"description": "Filter matches on/before date (RFC3339 or YYYY-MM-DD)",
"name": "to",
"in": "query"
},
{
"type": "string",
"description": "Filter by match status",
"name": "status",
"in": "query"
},
{
"type": "string",
"description": "Sort order by match_date: asc|desc (default desc)",
"name": "order",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/matches/head-to-head": {
"get": {
"description": "Returns the most recent match between teamA and teamB (by ts_id), regardless of home/away.",
"tags": [
"Matches"
],
"summary": "Most recent match between two teams",
"parameters": [
{
"type": "string",
"description": "Team A ts_id",
"name": "teamA",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Team B ts_id",
"name": "teamB",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/matches/id/{id}/lineup": {
"get": {
"description": "Returns the stored lineup data for a match (teams/formations/coaches + players) by internal match id.",
"tags": [
"Matches"
],
"summary": "Get match lineup by internal match id",
"parameters": [
{
"type": "string",
"description": "Internal match id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/matches/{matchTsId}/lineup": {
"get": {
"description": "Returns the stored lineup data for a match (teams/formations/coaches + players) by match ts_id.",
"tags": [
"Matches"
],
"summary": "Get match lineup by match id",
"parameters": [
{
"type": "string",
"description": "TheSports match id (tsId)",
"name": "matchTsId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/players": {
"get": {
"description": "Returns a paginated list of players, optionally filtered by name, team, or country.",
"tags": [
"Players"
],
"summary": "List players",
"parameters": [
{
"type": "integer",
"description": "Maximum number of items to return (default 100)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of items to skip before starting to collect the result set (default 0)",
"name": "offset",
"in": "query"
},
{
"type": "string",
"description": "Filter players by name (short, first, middle, or last)",
"name": "name",
"in": "query"
},
{
"type": "string",
"description": "Filter players by current team ID",
"name": "teamId",
"in": "query"
},
{
"type": "string",
"description": "Filter players by birth country name",
"name": "country",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/players/wyscout/{wyId}": {
"get": {
"description": "Returns a single player by its provider (wy_id) identifier.",
"tags": [
"Players"
],
"summary": "Get player by provider ID",
"parameters": [
{
"type": "integer",
"description": "Provider (wy_id) identifier",
"name": "wyId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/players/{id}": {
"get": {
"description": "Returns a single player by its internal ID.",
"tags": [
"Players"
],
"summary": "Get player by ID",
"parameters": [
{
"type": "string",
"description": "Player internal identifier",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/referees": {
"get": {
"description": "Returns a paginated list of referees, optionally filtered by name, referee type, nationality, or active status.",
"tags": [
"Referees"
],
"summary": "List referees",
"parameters": [
{
"type": "integer",
"description": "Maximum number of items to return (default 100)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of items to skip before starting to collect the result set (default 0)",
"name": "offset",
"in": "query"
},
{
"type": "string",
"description": "Filter referees by name (first, last, middle, or short)",
"name": "name",
"in": "query"
},
{
"type": "integer",
"description": "Filter referees by nationality WyID",
"name": "countryWyId",
"in": "query"
},
{
"type": "string",
"description": "Filter referees by type (main, assistant, var, etc.)",
"name": "type",
"in": "query"
},
{
"type": "boolean",
"description": "Filter active referees only",
"name": "active",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/referees/provider/{providerId}": {
"get": {
"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"
],
"summary": "Get referee by provider ID",
"parameters": [
{
"type": "string",
"description": "Provider identifier (wy_id or ts_id)",
"name": "providerId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/referees/wyscout/{wyId}": {
"get": {
"description": "Returns a single referee by its Wyscout wy_id identifier.",
"tags": [
"Referees"
],
"summary": "Get referee by Wyscout ID",
"parameters": [
{
"type": "integer",
"description": "Wyscout wy_id identifier",
"name": "wyId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/referees/{id}": {
"get": {
"description": "Returns a single referee by its internal ID.",
"tags": [
"Referees"
],
"summary": "Get referee by ID",
"parameters": [
{
"type": "string",
"description": "Referee internal identifier",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/teams": {
"get": {
"description": "Returns a list of teams with optional pagination.",
"tags": [
"Teams"
],
"summary": "List teams",
"parameters": [
{
"type": "integer",
"description": "Maximum number of items to return (default 100)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of items to skip before starting to collect the result set (default 0)",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/teams/wyscout/{wyId}": {
"get": {
"description": "Returns a single team by its Wyscout wy_id identifier.",
"tags": [
"Teams"
],
"summary": "Get team by Wyscout ID",
"parameters": [
{
"type": "integer",
"description": "Wyscout wy_id identifier",
"name": "wyId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/teams/{id}": {
"get": {
"description": "Returns a single team by its internal ID.",
"tags": [
"Teams"
],
"summary": "Get team by ID",
"parameters": [
{
"type": "string",
"description": "Team internal identifier",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
}
}
\ No newline at end of file
basePath: /api
info:
contact: {}
description: API server for scouting system score data.
title: Scouting System Score Data API
version: "1.0"
paths:
/areas:
get:
description: Returns a paginated list of areas, optionally filtered by name
or alpha codes.
parameters:
- description: Maximum number of items to return (default 100)
in: query
name: limit
type: integer
- description: Number of items to skip before starting to collect the result
set (default 0)
in: query
name: offset
type: integer
- description: Filter areas by name (case-insensitive, partial match)
in: query
name: name
type: string
- description: Filter areas by 2-letter country code
in: query
name: alpha2code
type: string
- description: Filter areas by 3-letter country code
in: query
name: alpha3code
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: List areas
tags:
- Areas
/areas/{id}:
get:
description: Returns a single area by its internal ID.
parameters:
- description: Area internal identifier
in: path
name: id
required: true
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get area by ID
tags:
- Areas
/areas/provider/{providerId}:
get:
description: Returns a single area by its provider (wy_id) identifier.
parameters:
- description: Provider (wy_id) identifier
in: path
name: providerId
required: true
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get area by provider ID
tags:
- Areas
/coaches:
get:
description: Returns a paginated list of coaches, optionally filtered by name,
team, position, or active status.
parameters:
- description: Maximum number of items to return (default 100)
in: query
name: limit
type: integer
- description: Number of items to skip before starting to collect the result
set (default 0)
in: query
name: offset
type: integer
- description: Filter coaches by name (first, last, middle, or short)
in: query
name: name
type: string
- description: Filter coaches by current team ID (wy_id)
in: query
name: teamId
type: string
- description: Filter coaches by position (head_coach, assistant_coach, etc.)
in: query
name: position
type: string
- description: Filter active coaches only
in: query
name: active
type: boolean
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: List coaches
tags:
- Coaches
/coaches/{id}:
get:
description: Returns a single coach by its internal ID.
parameters:
- description: Coach internal identifier
in: path
name: id
required: true
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get coach by ID
tags:
- Coaches
/coaches/provider/{providerId}:
get:
description: 'Returns a single coach by its provider identifier: numeric values
are treated as Wyscout wy_id, non-numeric as TheSports ts_id.'
parameters:
- description: Provider identifier (wy_id or ts_id)
in: path
name: providerId
required: true
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get coach by provider ID
tags:
- Coaches
/coaches/wyscout/{wyId}:
get:
description: Returns a single coach by its Wyscout wy_id identifier.
parameters:
- description: Wyscout wy_id identifier
in: path
name: wyId
required: true
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get coach by Wyscout ID
tags:
- Coaches
/import/areas:
post:
description: Imports all countries/regions from TheSports football country list
API.
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import areas from TheSports
tags:
- Import
/import/coaches:
post:
description: Performs a coach import using TheSports coach list API. If `since`
is provided (unix seconds), only coaches updated since that time are fetched
using the time-based endpoint. Otherwise, a full import is performed using
page-based pagination.
parameters:
- description: Page size per request (default 100, only used for full imports)
in: query
name: pageSize
type: integer
- description: Unix timestamp (seconds) to import only coaches updated since
this time
in: query
name: since
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import coaches from TheSports
tags:
- Import
/import/competitions:
post:
description: Performs a competition import using TheSports competition additional
list API. If `since` is provided (unix seconds), only competitions updated
since that time are fetched using the time-based endpoint. Otherwise, a full
import is performed using page-based pagination.
parameters:
- description: Page size per request (default 100, only used for full imports)
in: query
name: pageSize
type: integer
- description: Unix timestamp (seconds) to import only competitions updated
since this time
in: query
name: since
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import competitions from TheSports
tags:
- Import
/import/matches/diary:
post:
description: Performs a matches import using TheSports match/diary API for a
given date or 24h window. The `date` query parameter (YYYY-MM-DD) is recommended;
if omitted, the provider default will be used (usually current day).
parameters:
- description: Date in YYYY-MM-DD format for which to import the schedule/results
in: query
name: date
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import matches diary from TheSports
tags:
- Import
/import/matches/lineup:
post:
description: Performs a lineup import using TheSports match/lineup/detail API.
If 'matchTsId' is provided, imports lineup for a specific match. If omitted,
processes matches from the last 30 days only (API limitation). Use 'limit'
for testing and 'batchSize' to control memory usage.
parameters:
- description: TheSports match id (tsId) for which to import the lineup (optional;
if omitted, processes matches from last 30 days)
in: query
name: matchTsId
type: string
- description: 'Maximum number of matches to process in batch mode (default:
no limit; useful for debugging)'
in: query
name: limit
type: integer
- description: 'Number of matches to load per batch (default: 1000; lower for
memory constraints)'
in: query
name: batchSize
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import match lineups from TheSports
tags:
- Import
/import/matches/list:
post:
description: Performs a full import of all matches from TheSports match/list
API using pagination. This is intended for one-time initial sync to get all
historical matches. The API returns 1000 matches per page and stops when total
is 0. Use startPage to resume from a specific page if the import was interrupted.
parameters:
- description: 'Starting page number (default: 1, use to resume interrupted
import)'
in: query
name: startPage
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import all matches from TheSports (one-time full sync)
tags:
- Import
/import/matches/recent:
post:
description: Performs a matches import using TheSports match/recent/list API.
If `since` is provided (unix seconds), only matches updated since that time
are fetched using the time-based endpoint. Otherwise, a full import is performed
using page-based pagination (last 30 days).
parameters:
- description: Page size per request (default 100, only used for full imports)
in: query
name: pageSize
type: integer
- description: Unix timestamp (seconds) to import only matches updated since
this time
in: query
name: since
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import recent matches from TheSports
tags:
- Import
/import/players:
post:
description: Performs a player import using TheSports player with_stat list
API. If `since` is provided (unix seconds), only players updated since that
time are fetched using the time-based endpoint. Otherwise, a full import is
performed using page-based pagination.
parameters:
- description: Page size per request (default 100, only used for full imports)
in: query
name: pageSize
type: integer
- description: Unix timestamp (seconds) to import only players updated since
this time
in: query
name: since
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import players from TheSports
tags:
- Import
/import/referees:
post:
description: Performs a referee import using TheSports referee list API. If
`since` is provided (unix seconds), only referees updated since that time
are fetched using the time-based endpoint. Otherwise, a full import is performed
using page-based pagination.
parameters:
- description: Page size per request (default 100, only used for full imports)
in: query
name: pageSize
type: integer
- description: Unix timestamp (seconds) to import only referees updated since
this time
in: query
name: since
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import referees from TheSports
tags:
- Import
/import/seasons:
post:
description: Performs a season import using TheSports season list API. If `since`
is provided (unix seconds), only seasons updated since that time are fetched
using the time-based endpoint. Otherwise, a full import is performed using
page-based pagination.
parameters:
- description: Page size per request (default 100, only used for full imports)
in: query
name: pageSize
type: integer
- description: Unix timestamp (seconds) to import only seasons updated since
this time
in: query
name: since
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import seasons from TheSports
tags:
- Import
/import/teams:
post:
description: Performs a team import using TheSports team additional list API.
If `since` is provided (unix seconds), only teams updated since that time
are fetched using the time-based endpoint. Otherwise, a full import is performed
using page-based pagination.
parameters:
- description: Page size per request (default 100, only used for full imports)
in: query
name: pageSize
type: integer
- description: Unix timestamp (seconds) to import only teams updated since this
time
in: query
name: since
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Import teams from TheSports
tags:
- Import
/matches:
get:
description: Returns a paginated list of matches, optionally filtered by competitionWyId,
seasonWyId, teamTsId and date range.
parameters:
- description: Maximum number of items to return (default 100)
in: query
name: limit
type: integer
- description: Number of items to skip before starting to collect the result
set (default 0)
in: query
name: offset
type: integer
- description: Filter by competition wy_id
in: query
name: competitionWyId
type: integer
- description: Filter by season wy_id
in: query
name: seasonWyId
type: integer
- description: Filter by team ts_id (matches where team is home or away)
in: query
name: teamTsId
type: string
- description: Filter matches on/after date (RFC3339 or YYYY-MM-DD)
in: query
name: from
type: string
- description: Filter matches on/before date (RFC3339 or YYYY-MM-DD)
in: query
name: to
type: string
- description: Filter by match status
in: query
name: status
type: string
- description: 'Sort order by match_date: asc|desc (default desc)'
in: query
name: order
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: List matches
tags:
- Matches
/matches/{matchTsId}/lineup:
get:
description: Returns the stored lineup data for a match (teams/formations/coaches
+ players) by match ts_id.
parameters:
- description: TheSports match id (tsId)
in: path
name: matchTsId
required: true
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get match lineup by match id
tags:
- Matches
/matches/head-to-head:
get:
description: Returns the most recent match between teamA and teamB (by ts_id),
regardless of home/away.
parameters:
- description: Team A ts_id
in: query
name: teamA
required: true
type: string
- description: Team B ts_id
in: query
name: teamB
required: true
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Most recent match between two teams
tags:
- Matches
/matches/id/{id}/lineup:
get:
description: Returns the stored lineup data for a match (teams/formations/coaches
+ players) by internal match id.
parameters:
- description: Internal match id
in: path
name: id
required: true
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get match lineup by internal match id
tags:
- Matches
/players:
get:
description: Returns a paginated list of players, optionally filtered by name,
team, or country.
parameters:
- description: Maximum number of items to return (default 100)
in: query
name: limit
type: integer
- description: Number of items to skip before starting to collect the result
set (default 0)
in: query
name: offset
type: integer
- description: Filter players by name (short, first, middle, or last)
in: query
name: name
type: string
- description: Filter players by current team ID
in: query
name: teamId
type: string
- description: Filter players by birth country name
in: query
name: country
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: List players
tags:
- Players
/players/{id}:
get:
description: Returns a single player by its internal ID.
parameters:
- description: Player internal identifier
in: path
name: id
required: true
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get player by ID
tags:
- Players
/players/wyscout/{wyId}:
get:
description: Returns a single player by its provider (wy_id) identifier.
parameters:
- description: Provider (wy_id) identifier
in: path
name: wyId
required: true
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get player by provider ID
tags:
- Players
/referees:
get:
description: Returns a paginated list of referees, optionally filtered by name,
referee type, nationality, or active status.
parameters:
- description: Maximum number of items to return (default 100)
in: query
name: limit
type: integer
- description: Number of items to skip before starting to collect the result
set (default 0)
in: query
name: offset
type: integer
- description: Filter referees by name (first, last, middle, or short)
in: query
name: name
type: string
- description: Filter referees by nationality WyID
in: query
name: countryWyId
type: integer
- description: Filter referees by type (main, assistant, var, etc.)
in: query
name: type
type: string
- description: Filter active referees only
in: query
name: active
type: boolean
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: List referees
tags:
- Referees
/referees/{id}:
get:
description: Returns a single referee by its internal ID.
parameters:
- description: Referee internal identifier
in: path
name: id
required: true
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get referee by ID
tags:
- Referees
/referees/provider/{providerId}:
get:
description: 'Returns a single referee by its provider identifier: numeric values
are treated as Wyscout wy_id, non-numeric as TheSports ts_id.'
parameters:
- description: Provider identifier (wy_id or ts_id)
in: path
name: providerId
required: true
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get referee by provider ID
tags:
- Referees
/referees/wyscout/{wyId}:
get:
description: Returns a single referee by its Wyscout wy_id identifier.
parameters:
- description: Wyscout wy_id identifier
in: path
name: wyId
required: true
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get referee by Wyscout ID
tags:
- Referees
/teams:
get:
description: Returns a list of teams with optional pagination.
parameters:
- description: Maximum number of items to return (default 100)
in: query
name: limit
type: integer
- description: Number of items to skip before starting to collect the result
set (default 0)
in: query
name: offset
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: List teams
tags:
- Teams
/teams/{id}:
get:
description: Returns a single team by its internal ID.
parameters:
- description: Team internal identifier
in: path
name: id
required: true
type: string
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get team by ID
tags:
- Teams
/teams/wyscout/{wyId}:
get:
description: Returns a single team by its Wyscout wy_id identifier.
parameters:
- description: Wyscout wy_id identifier
in: path
name: wyId
required: true
type: integer
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
summary: Get team by Wyscout ID
tags:
- Teams
swagger: "2.0"
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
)
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
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 (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/config"
"ScoutingSystemScoreData/internal/models"
)
type ImportHandler struct {
DB *gorm.DB
Cfg config.Config
Client *http.Client
}
// ImportSeasons imports seasons from TheSports API.
// @Summary Import seasons from TheSports
// @Description Performs a season import using TheSports season list API. If `since` is provided (unix seconds), only seasons updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.
// @Tags Import
// @Param pageSize query int false "Page size per request (default 100, only used for full imports)"
// @Param since query int false "Unix timestamp (seconds) to import only seasons updated since this time"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /import/seasons [post]
func (h *ImportHandler) ImportSeasons(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
pageSizeStr := c.DefaultQuery("pageSize", "100")
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil || pageSize <= 0 {
pageSize = 100
}
sinceStr := c.Query("since")
var since *int64
if sinceStr != "" {
if v, err := strconv.ParseInt(sinceStr, 10, 64); err == nil && v > 0 {
since = &v
}
}
imported, updated, errorsCount := 0, 0, 0
fetchAndUpsert := func(url string) error {
resp, err := h.Client.Get(url)
if err != nil {
log.Printf("failed to call TheSports seasons API: %v", err)
return err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return fmt.Errorf("provider returned status %d", resp.StatusCode)
}
var payload struct {
Query struct {
Total int `json:"total"`
} `json:"query"`
Results []struct {
ID string `json:"id"`
CompetitionID string `json:"competition_id"`
Year string `json:"year"`
HasPlayerStats int `json:"has_player_stats"`
HasTeamStats int `json:"has_team_stats"`
HasTable int `json:"has_table"`
IsCurrent int `json:"is_current"`
StartTime *int64 `json:"start_time"`
EndTime *int64 `json:"end_time"`
UpdatedAt *int64 `json:"updated_at"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
resp.Body.Close()
return err
}
resp.Body.Close()
if payload.Query.Total == 0 || len(payload.Results) == 0 {
return fmt.Errorf("no more results")
}
for _, r := range payload.Results {
var season models.Season
if err := h.DB.Where("ts_id = ?", r.ID).First(&season).Error; err != nil {
if err == gorm.ErrRecordNotFound {
season = models.Season{
TsID: r.ID,
Name: r.Year,
}
if r.StartTime != nil {
st := time.Unix(*r.StartTime, 0).UTC()
season.StartDate = &st
}
if r.EndTime != nil {
et := time.Unix(*r.EndTime, 0).UTC()
season.EndDate = &et
}
if err := h.DB.Create(&season).Error; err != nil {
errorsCount++
continue
}
imported++
continue
}
errorsCount++
continue
}
// update existing season
season.Name = r.Year
if r.StartTime != nil {
st := time.Unix(*r.StartTime, 0).UTC()
season.StartDate = &st
}
if r.EndTime != nil {
et := time.Unix(*r.EndTime, 0).UTC()
season.EndDate = &et
}
if err := h.DB.Save(&season).Error; err != nil {
errorsCount++
continue
}
updated++
}
return nil
}
if since != nil {
// incremental, time-based import
url := fmt.Sprintf("https://api.thesports.com/v1/football/season/list?user=%s&secret=%s&type=time&time=%d", user, secret, *since)
_ = fetchAndUpsert(url)
} else {
// full page-based import
for page := 1; ; page++ {
url := fmt.Sprintf("https://api.thesports.com/v1/football/season/list?user=%s&secret=%s&type=page&page=%d&page_size=%d", user, secret, page, pageSize)
if err := fetchAndUpsert(url); err != nil {
break
}
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Seasons import completed",
"data": gin.H{
"imported": imported,
"updated": updated,
"errors": errorsCount,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// ImportMatchLineup imports match lineups from TheSports API (match/lineup/detail).
// @Summary Import match lineups from TheSports
// @Description Performs a lineup import using TheSports match/lineup/detail API. If 'matchTsId' is provided, imports lineup for a specific match. If omitted, processes matches from the last 30 days only (API limitation). Use 'limit' for testing and 'batchSize' to control memory usage.
// @Tags Import
// @Param matchTsId query string false "TheSports match id (tsId) for which to import the lineup (optional; if omitted, processes matches from last 30 days)"
// @Param limit query int false "Maximum number of matches to process in batch mode (default: no limit; useful for debugging)"
// @Param batchSize query int false "Number of matches to load per batch (default: 1000; lower for memory constraints)"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /import/matches/lineup [post]
func (h *ImportHandler) ImportMatchLineup(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
matchTsIDParam := c.Query("matchTsId")
limitStr := c.DefaultQuery("limit", "")
batchSizeStr := c.DefaultQuery("batchSize", "1000")
var limit int
if limitStr != "" {
if v, err := strconv.Atoi(limitStr); err == nil && v > 0 {
limit = v
}
}
batchSize, err := strconv.Atoi(batchSizeStr)
if err != nil || batchSize <= 0 || batchSize > 10000 {
batchSize = 1000 // default and sanity check
}
// TheSports lineup detail: we focus on team and player-level fields we need; other fields are ignored.
type lineupPlayer struct {
ID string `json:"id"`
ShirtNumber int `json:"shirt_number"`
Position string `json:"position"`
X int `json:"x"`
Y int `json:"y"`
First int `json:"first"` // 1 for starter, 0 for sub
Captain int `json:"captain"` // 1 for captain, 0 for not
Rating string `json:"rating"`
Incidents []struct {
Type int `json:"type"`
Time string `json:"time"`
Minute int `json:"minute"`
AddTime int `json:"addtime"`
Belong int `json:"belong"`
HomeScore int `json:"home_score"`
AwayScore int `json:"away_score"`
} `json:"incidents"`
}
var payload struct {
Code int `json:"code"`
Results struct {
HomeFormation string `json:"home_formation"`
AwayFormation string `json:"away_formation"`
CoachID struct {
Home string `json:"home"`
Away string `json:"away"`
} `json:"coach_id"`
Lineup struct {
Home []lineupPlayer `json:"home"`
Away []lineupPlayer `json:"away"`
} `json:"lineup"`
} `json:"results"`
}
// helper to upsert team and players for one side
processSide := func(matchTsID string, side string, players []lineupPlayer, coachID, formation string, createdTeams *int, createdPlayers *int, errorsCount *int) {
if len(players) == 0 {
return
}
// First, get the team ID from the first player (we'll need to map this)
// For now, we'll need to find the team from the match
var match models.Match
if err := h.DB.Where("ts_id = ?", matchTsID).First(&match).Error; err != nil {
fmt.Printf("ImportMatchLineup: ERROR - failed to get match for team mapping: %v\n", err)
(*errorsCount)++
return
}
// Determine team TsID based on side
var teamTsID *string
if side == "home" {
teamTsID = match.HomeTeamTsID
} else {
teamTsID = match.AwayTeamTsID
}
if teamTsID == nil || *teamTsID == "" {
fmt.Printf("ImportMatchLineup: ERROR - no team TsID found for %s side\n", side)
(*errorsCount)++
return
}
mt := models.MatchTeam{
MatchTsID: matchTsID,
Side: side,
}
mt.TeamTsID = teamTsID
if formation != "" {
mt.Formation = &formation
}
// resolve team WyID if available
var team models.Team
if err := h.DB.Where("ts_id = ?", *teamTsID).First(&team).Error; err == nil {
mt.TeamWyID = team.WyID
}
// resolve coach WyID if available
if coachID != "" {
var coach models.Coach
if err := h.DB.Where("ts_id = ?", coachID).First(&coach).Error; err == nil {
mt.CoachWyID = coach.WyID
mt.CoachTsID = &coachID
}
}
if err := h.DB.Create(&mt).Error; err != nil {
fmt.Printf("ImportMatchLineup: ERROR - failed to create match team for %s: %v\n", side, err)
(*errorsCount)++
return
}
(*createdTeams)++
// Process players
subOrder := 1
for _, p := range players {
mlu := models.MatchLineupPlayer{
MatchTsID: matchTsID,
}
if mt.TeamWyID != nil {
mlu.TeamWyID = mt.TeamWyID
}
if mt.TeamTsID != nil {
mlu.TeamTsID = mt.TeamTsID
}
if p.ID != "" {
mlu.PlayerTsID = &p.ID
var pl models.Player
if err := h.DB.Where("ts_id = ?", p.ID).First(&pl).Error; err == nil {
mlu.PlayerWyID = pl.WyID
}
}
// Map shirt number
shirtNum := p.ShirtNumber
mlu.ShirtNumber = &shirtNum
// Map position
if p.Position != "" {
pos := p.Position
mlu.Position = &pos
}
// Map coordinates (convert int to float64)
posX := float64(p.X)
posY := float64(p.Y)
mlu.PosX = &posX
mlu.PosY = &posY
// Map starter status
mlu.IsStarter = (p.First == 1)
// Handle incidents for substitution times
if len(p.Incidents) > 0 {
for _, incident := range p.Incidents {
// Type 8 is substitution (based on typical sports API codes)
if incident.Type == 8 {
if incident.Minute > 0 {
mlu.MinuteIn = &incident.Minute
}
if incident.AddTime > 0 {
totalMinute := incident.Minute + incident.AddTime
mlu.MinuteOut = &totalMinute
}
}
}
}
so := subOrder
mlu.SubOrder = &so
subOrder++
if err := h.DB.Create(&mlu).Error; err != nil {
fmt.Printf("ImportMatchLineup: ERROR - failed to create match lineup player %s: %v\n", p.ID, err)
(*errorsCount)++
continue
}
(*createdPlayers)++
}
}
// helper to import lineup for a single matchTsID
importForMatch := func(matchTsID string) (int, int, int) {
createdTeams := 0
createdPlayers := 0
errorsCount := 0
// ensure match exists
var match models.Match
if err := h.DB.Where("ts_id = ?", matchTsID).First(&match).Error; err != nil {
if err == gorm.ErrRecordNotFound {
fmt.Printf("ImportMatchLineup: ERROR - match not found for ts_id=%s\n", matchTsID)
return 0, 0, 1
}
fmt.Printf("ImportMatchLineup: ERROR - failed to retrieve match for ts_id=%s: %v\n", matchTsID, err)
return 0, 0, 1
}
url := fmt.Sprintf("https://api.thesports.com/v1/football/match/lineup/detail?user=%s&secret=%s&uuid=%s", user, secret, matchTsID)
resp, err := h.Client.Get(url)
if err != nil {
fmt.Printf("ImportMatchLineup: ERROR - failed to call TheSports match lineup API for %s: %v\n", matchTsID, err)
return 0, 0, 1
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
fmt.Printf("ImportMatchLineup: SKIP - match not found in TheSports API for matchTsId=%s (404)\n", matchTsID)
return 0, 0, 0 // Don't count as error, just skip
}
fmt.Printf("ImportMatchLineup: provider returned status %d for matchTsId=%s\n", resp.StatusCode, matchTsID)
return 0, 0, 1
}
// Read response body
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("ImportMatchLineup: ERROR - failed to read response body for %s: %v\n", matchTsID, err)
return 0, 0, 1
}
resp.Body.Close()
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
fmt.Printf("ImportMatchLineup: ERROR - failed to decode provider response for matchTsId=%s: %v\n", matchTsID, err)
return 0, 0, 1
}
if payload.Code != 0 {
fmt.Printf("ImportMatchLineup: provider returned code=%d for matchTsId=%s\n", payload.Code, matchTsID)
return 0, 0, 1
}
// clear existing lineup for this match
if err := h.DB.Where("match_ts_id = ?", matchTsID).Delete(&models.MatchLineupPlayer{}).Error; err != nil {
fmt.Printf("ImportMatchLineup: ERROR - failed to clear existing lineup data for matchTsId=%s: %v\n", matchTsID, err)
return 0, 0, 1
}
if err := h.DB.Where("match_ts_id = ?", matchTsID).Delete(&models.MatchTeam{}).Error; err != nil {
fmt.Printf("ImportMatchLineup: ERROR - failed to clear existing match teams for matchTsId=%s: %v\n", matchTsID, err)
return 0, 0, 1
}
// Process home and away lineups with the correct structure
processSide(matchTsID, "home", payload.Results.Lineup.Home,
payload.Results.CoachID.Home, payload.Results.HomeFormation,
&createdTeams, &createdPlayers, &errorsCount)
processSide(matchTsID, "away", payload.Results.Lineup.Away,
payload.Results.CoachID.Away, payload.Results.AwayFormation,
&createdTeams, &createdPlayers, &errorsCount)
fmt.Printf("ImportMatchLineup: SUCCESS - imported lineup for matchTsId=%s (teams: %d, players: %d)\n", matchTsID, createdTeams, createdPlayers)
return createdTeams, createdPlayers, errorsCount
}
totalTeams := 0
totalPlayers := 0
totalErrors := 0
matchesProcessed := 0
matchesFailed := 0
if matchTsIDParam != "" {
// single-match mode
ct, cp, ce := importForMatch(matchTsIDParam)
matchesProcessed++
if ce > 0 {
matchesFailed++
}
totalTeams += ct
totalPlayers += cp
totalErrors += ce
} else {
// batch mode: process matches from last 30 days only (API limitation)
thirtyDaysAgo := time.Now().UTC().AddDate(0, 0, -30)
fmt.Printf("ImportMatchLineup: starting batch import for matches since %s (last 30 days) with pageSize=%d\n",
thirtyDaysAgo.Format("2006-01-02"), batchSize)
offset := 0
processedCount := 0
for {
var matches []models.Match
query := h.DB.Where("ts_id <> '' AND match_date >= ?", thirtyDaysAgo).
Offset(offset).Limit(batchSize)
if limit > 0 && processedCount+batchSize > limit {
remaining := limit - processedCount
if remaining <= 0 {
break
}
query = query.Limit(remaining)
}
if err := query.Find(&matches).Error; err != nil {
fmt.Printf("ImportMatchLineup: ERROR - failed to fetch batch at offset %d: %v\n", offset, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list matches for lineup import"})
return
}
if len(matches) == 0 {
break // no more matches
}
fmt.Printf("ImportMatchLineup: processing batch %d-%d (%d matches)\n",
offset+1, offset+len(matches), len(matches))
for _, m := range matches {
if m.TsID == "" {
continue
}
ct, cp, ce := importForMatch(m.TsID)
matchesProcessed++
if ce > 0 {
matchesFailed++
}
totalTeams += ct
totalPlayers += cp
totalErrors += ce
// Progress indicator
if matchesProcessed%100 == 0 {
fmt.Printf("ImportMatchLineup: progress - %d matches processed\n", matchesProcessed)
}
}
processedCount += len(matches)
offset += batchSize
// Break if we've hit the limit
if limit > 0 && processedCount >= limit {
break
}
// If we got fewer than batchSize, we're done
if len(matches) < batchSize {
break
}
}
fmt.Printf("ImportMatchLineup: batch import completed - processed %d matches from last 30 days\n", matchesProcessed)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Match lineup import completed",
"data": gin.H{
"teams": totalTeams,
"players": totalPlayers,
"errors": totalErrors,
"matchesProcessed": matchesProcessed,
"matchesFailed": matchesFailed,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// ImportMatchesDiary imports matches for a given day from TheSports API (match/diary).
// @Summary Import matches diary from TheSports
// @Description Performs a matches import using TheSports match/diary API for a given date or 24h window. The `date` query parameter (YYYY-MM-DD) is recommended; if omitted, the provider default will be used (usually current day).
// @Tags Import
// @Param date query string false "Date in YYYY-MM-DD format for which to import the schedule/results"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /import/matches/diary [post]
func (h *ImportHandler) ImportMatchesDiary(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
date := c.Query("date")
baseURL := "https://api.thesports.com/v1/football/match/diary"
url := fmt.Sprintf("%s?user=%s&secret=%s&type=diary", baseURL, user, secret)
if date != "" {
url = fmt.Sprintf("%s&date=%s", url, date)
}
imported, updated, errorsCount := 0, 0, 0
resp, err := h.Client.Get(url)
if err != nil {
log.Printf("failed to call TheSports matches diary API: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to call provider API"})
return
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("provider returned status %d", resp.StatusCode)})
return
}
var payload struct {
Query struct {
Total int `json:"total"`
} `json:"query"`
Results []struct {
ID string `json:"id"`
SeasonID string `json:"season_id"`
CompetitionID string `json:"competition_id"`
HomeTeamID string `json:"home_team_id"`
AwayTeamID string `json:"away_team_id"`
StatusID int `json:"status_id"`
MatchTime int64 `json:"match_time"`
VenueID string `json:"venue_id"`
RefereeID string `json:"referee_id"`
Neutral int `json:"neutral"`
Note string `json:"note"`
HomeScores []int `json:"home_scores"`
AwayScores []int `json:"away_scores"`
HomePosition string `json:"home_position"`
AwayPosition string `json:"away_position"`
Coverage struct {
MLive int `json:"mlive"`
Lineup int `json:"lineup"`
} `json:"coverage"`
Round struct {
StageID string `json:"stage_id"`
GroupNum int `json:"group_num"`
RoundNum int `json:"round_num"`
} `json:"round"`
RelatedID string `json:"related_id"`
AggScore []int `json:"agg_score"`
Environment struct {
Weather int `json:"weather"`
Pressure string `json:"pressure"`
Temperature string `json:"temperature"`
Wind string `json:"wind"`
Humidity string `json:"humidity"`
} `json:"environment"`
TBD int `json:"tbd"`
HasOT int `json:"has_ot"`
Ended int `json:"ended"`
TeamReverse int `json:"team_reverse"`
Loss int `json:"loss"`
UpdatedAt int64 `json:"updated_at"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
resp.Body.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decode provider response"})
return
}
resp.Body.Close()
if payload.Query.Total == 0 || len(payload.Results) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Matches diary import completed (no results)",
"data": gin.H{
"imported": 0,
"updated": 0,
"errors": 0,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
return
}
for _, r := range payload.Results {
var match models.Match
if err := h.DB.Where("ts_id = ?", r.ID).First(&match).Error; err != nil {
if err == gorm.ErrRecordNotFound {
match = models.Match{
TsID: r.ID,
}
if r.MatchTime > 0 {
mt := time.Unix(r.MatchTime, 0).UTC()
match.MatchDate = mt
}
if r.HomeTeamID != "" {
match.HomeTeamTsID = &r.HomeTeamID
var team models.Team
if err := h.DB.Where("ts_id = ?", r.HomeTeamID).First(&team).Error; err == nil {
match.HomeTeamWyID = team.WyID
}
}
if r.AwayTeamID != "" {
match.AwayTeamTsID = &r.AwayTeamID
var team models.Team
if err := h.DB.Where("ts_id = ?", r.AwayTeamID).First(&team).Error; err == nil {
match.AwayTeamWyID = team.WyID
}
}
if len(r.HomeScores) > 0 {
match.HomeScore = r.HomeScores[0]
}
if len(r.AwayScores) > 0 {
match.AwayScore = r.AwayScores[0]
}
if len(r.HomeScores) > 6 {
match.HomeScorePenalties = r.HomeScores[6]
}
if len(r.AwayScores) > 6 {
match.AwayScorePenalties = r.AwayScores[6]
}
if r.Environment.Temperature != "" {
if temp, err := strconv.ParseFloat(r.Environment.Temperature, 64); err == nil {
match.Temperature = &temp
}
}
if r.Environment.Weather != 0 {
w := fmt.Sprintf("%d", r.Environment.Weather)
match.Weather = &w
}
if r.UpdatedAt > 0 {
ut := time.Unix(r.UpdatedAt, 0).UTC()
match.APILastSyncedAt = &ut
match.APISyncStatus = "synced"
}
if err := h.DB.Create(&match).Error; err != nil {
errorsCount++
continue
}
imported++
continue
}
errorsCount++
continue
}
// update existing match
if r.MatchTime > 0 {
mt := time.Unix(r.MatchTime, 0).UTC()
match.MatchDate = mt
}
if r.HomeTeamID != "" {
match.HomeTeamTsID = &r.HomeTeamID
var team models.Team
if err := h.DB.Where("ts_id = ?", r.HomeTeamID).First(&team).Error; err == nil {
match.HomeTeamWyID = team.WyID
}
}
if r.AwayTeamID != "" {
match.AwayTeamTsID = &r.AwayTeamID
var team models.Team
if err := h.DB.Where("ts_id = ?", r.AwayTeamID).First(&team).Error; err == nil {
match.AwayTeamWyID = team.WyID
}
}
if len(r.HomeScores) > 0 {
match.HomeScore = r.HomeScores[0]
}
if len(r.AwayScores) > 0 {
match.AwayScore = r.AwayScores[0]
}
if len(r.HomeScores) > 6 {
match.HomeScorePenalties = r.HomeScores[6]
}
if len(r.AwayScores) > 6 {
match.AwayScorePenalties = r.AwayScores[6]
}
if r.Environment.Temperature != "" {
if temp, err := strconv.ParseFloat(r.Environment.Temperature, 64); err == nil {
match.Temperature = &temp
}
}
if r.Environment.Weather != 0 {
w := fmt.Sprintf("%d", r.Environment.Weather)
match.Weather = &w
}
if r.UpdatedAt > 0 {
ut := time.Unix(r.UpdatedAt, 0).UTC()
match.APILastSyncedAt = &ut
match.APISyncStatus = "synced"
}
if err := h.DB.Save(&match).Error; err != nil {
errorsCount++
continue
}
updated++
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Matches diary import completed",
"data": gin.H{
"imported": imported,
"updated": updated,
"errors": errorsCount,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
func RegisterImportRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg config.Config) {
h := &ImportHandler{
DB: db,
Cfg: cfg,
Client: &http.Client{Timeout: 30 * time.Second},
}
imp := rg.Group("/import")
imp.POST("/areas", h.ImportAreas)
imp.POST("/seasons", h.ImportSeasons)
imp.POST("/competitions", h.ImportCompetitions)
imp.POST("/players", h.ImportPlayers)
imp.POST("/teams", h.ImportTeams)
imp.POST("/coaches", h.ImportCoaches)
imp.POST("/referees", h.ImportReferees)
imp.POST("/matches/recent", h.ImportMatchesRecent)
imp.POST("/matches/diary", h.ImportMatchesDiary)
imp.POST("/matches/lineup", h.ImportMatchLineup)
imp.POST("/matches/list", h.ImportMatchesList)
}
// ImportMatchesList imports all matches from TheSports API (match/list) for one-time full sync.
// @Summary Import all matches from TheSports (one-time full sync)
// @Description Performs a full import of all matches from TheSports match/list API using pagination. This is intended for one-time initial sync to get all historical matches. The API returns 1000 matches per page and stops when total is 0. Use startPage to resume from a specific page if the import was interrupted.
// @Tags Import
// @Param startPage query int false "Starting page number (default: 1, use to resume interrupted import)"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /import/matches/list [post]
func (h *ImportHandler) ImportMatchesList(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
startPageStr := c.DefaultQuery("startPage", "1")
startPage, err := strconv.Atoi(startPageStr)
if err != nil || startPage < 1 {
startPage = 1
}
totalMatches := 0
page := startPage
const pageSize = 1000 // TheSports API default
fmt.Printf("ImportMatchesList: Starting from page %d\n", startPage)
for {
url := fmt.Sprintf("https://api.thesports.com/v1/football/match/list?user=%s&secret=%s&page=%d", user, secret, page)
resp, err := h.Client.Get(url)
if err != nil {
fmt.Printf("ImportMatchesList: ERROR - failed to call TheSports API page %d: %v\n", page, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to call TheSports API: %v", err)})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Printf("ImportMatchesList: ERROR - provider returned status %d for page %d\n", resp.StatusCode, page)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("provider returned status %d", resp.StatusCode)})
return
}
var payload struct {
Code int `json:"code"`
Query struct {
Total int `json:"total"`
Type string `json:"type"`
Page int `json:"page"`
} `json:"query"`
Results []struct {
ID string `json:"id"`
SeasonID string `json:"season_id"`
CompetitionID string `json:"competition_id"`
HomeTeamID string `json:"home_team_id"`
AwayTeamID string `json:"away_team_id"`
StatusID int `json:"status_id"`
MatchTime int64 `json:"match_time"`
VenueID string `json:"venue_id"`
RefereeID string `json:"referee_id"`
Neutral int `json:"neutral"`
Note string `json:"note"`
HomeScores []int `json:"home_scores"`
AwayScores []int `json:"away_scores"`
HomePosition string `json:"home_position"`
AwayPosition string `json:"away_position"`
Coverage struct {
Mlive int `json:"mlive"`
Lineup int `json:"lineup"`
} `json:"coverage"`
Round struct {
StageID string `json:"stage_id"`
GroupNum int `json:"group_num"`
RoundNum int `json:"round_num"`
} `json:"round"`
Environment struct {
Weather int `json:"weather"`
Pressure string `json:"pressure"`
Temperature string `json:"temperature"`
Wind string `json:"wind"`
Humidity string `json:"humidity"`
} `json:"environment"`
TBD int `json:"tbd"`
HasOT int `json:"has_ot"`
Ended int `json:"ended"`
TeamReverse int `json:"team_reverse"`
Loss int `json:"loss"`
UpdatedAt int64 `json:"updated_at"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
fmt.Printf("ImportMatchesList: ERROR - failed to decode response for page %d: %v\n", page, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to decode response: %v", err)})
return
}
if payload.Code != 0 {
fmt.Printf("ImportMatchesList: ERROR - provider returned code=%d for page %d\n", payload.Code, page)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("provider returned code %d", payload.Code)})
return
}
if len(payload.Results) == 0 {
fmt.Printf("ImportMatchesList: No more results, stopping at page %d\n", page-1)
break
}
// Process matches
for _, result := range payload.Results {
// Convert timestamp to time
matchDate := time.Unix(result.MatchTime, 0)
// Map status_id to string
status := "scheduled"
switch result.StatusID {
case 0:
status = "scheduled"
case 1:
status = "in_progress" // TheSports API uses 1 for live, map to in_progress
case 2:
status = "completed"
case 3:
status = "postponed"
case 4:
status = "cancelled"
}
// Extract scores
homeScore := 0
awayScore := 0
if len(result.HomeScores) > 0 {
homeScore = result.HomeScores[0]
}
if len(result.AwayScores) > 0 {
awayScore = result.AwayScores[0]
}
// Parse temperature if available
var temperature *float64
if result.Environment.Temperature != "" {
if temp, err := strconv.ParseFloat(result.Environment.Temperature, 64); err == nil {
temperature = &temp
}
}
// Create/update match
match := models.Match{
TsID: result.ID,
HomeTeamTsID: &result.HomeTeamID,
AwayTeamTsID: &result.AwayTeamID,
MatchDate: matchDate,
Status: status,
HomeScore: homeScore,
AwayScore: awayScore,
Temperature: temperature,
Notes: &result.Note,
APILastSyncedAt: &time.Time{},
APISyncStatus: "synced",
}
// Map weather ID to string if available
if result.Environment.Weather != 0 {
weatherStr := fmt.Sprintf("%d", result.Environment.Weather)
match.Weather = &weatherStr
}
// Set nullable scores for penalties if available
if len(result.HomeScores) > 6 {
match.HomeScorePenalties = result.HomeScores[6]
}
if len(result.AwayScores) > 6 {
match.AwayScorePenalties = result.AwayScores[6]
}
// Upsert match
if err := h.DB.Where("ts_id = ?", match.TsID).Assign(&match).FirstOrCreate(&match).Error; err != nil {
fmt.Printf("ImportMatchesList: ERROR - failed to upsert match %s: %v\n", match.TsID, err)
continue
}
totalMatches++
}
fmt.Printf("ImportMatchesList: Processed page %d, got %d matches (total: %d)\n", page, len(payload.Results), totalMatches)
// Check if we should continue pagination
if payload.Query.Total == 0 || len(payload.Results) < pageSize {
fmt.Printf("ImportMatchesList: Reached end of pagination at page %d\n", page)
break
}
page++
}
fmt.Printf("ImportMatchesList: Completed full sync. Total matches imported: %d\n", totalMatches)
c.JSON(http.StatusOK, gin.H{
"message": "Full match import completed",
"totalMatches": totalMatches,
"pagesProcessed": page - 1,
})
}
// ImportMatchesRecent imports recent matches from TheSports API (match/recent/list).
// @Summary Import recent matches from TheSports
// @Description Performs a matches import using TheSports match/recent/list API. If `since` is provided (unix seconds), only matches updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination (last 30 days).
// @Tags Import
// @Param pageSize query int false "Page size per request (default 100, only used for full imports)"
// @Param since query int false "Unix timestamp (seconds) to import only matches updated since this time"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /import/matches/recent [post]
func (h *ImportHandler) ImportMatchesRecent(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
pageSizeStr := c.DefaultQuery("pageSize", "100")
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil || pageSize <= 0 {
pageSize = 100
}
sinceStr := c.Query("since")
var since *int64
if sinceStr != "" {
if v, err := strconv.ParseInt(sinceStr, 10, 64); err == nil && v > 0 {
since = &v
}
}
imported, updated, errorsCount := 0, 0, 0
fetchAndUpsert := func(url string) error {
resp, err := h.Client.Get(url)
if err != nil {
log.Printf("failed to call TheSports matches API: %v", err)
return err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return fmt.Errorf("provider returned status %d", resp.StatusCode)
}
var payload struct {
Query struct {
Total int `json:"total"`
} `json:"query"`
Results []struct {
ID string `json:"id"`
SeasonID string `json:"season_id"`
CompetitionID string `json:"competition_id"`
HomeTeamID string `json:"home_team_id"`
AwayTeamID string `json:"away_team_id"`
StatusID int `json:"status_id"`
MatchTime int64 `json:"match_time"`
VenueID string `json:"venue_id"`
RefereeID string `json:"referee_id"`
Neutral int `json:"neutral"`
Note string `json:"note"`
HomeScores []int `json:"home_scores"`
AwayScores []int `json:"away_scores"`
HomePosition string `json:"home_position"`
AwayPosition string `json:"away_position"`
Coverage struct {
MLive int `json:"mlive"`
Lineup int `json:"lineup"`
} `json:"coverage"`
Round struct {
StageID string `json:"stage_id"`
GroupNum int `json:"group_num"`
RoundNum int `json:"round_num"`
} `json:"round"`
RelatedID string `json:"related_id"`
AggScore []int `json:"agg_score"`
Environment struct {
Weather int `json:"weather"`
Pressure string `json:"pressure"`
Temperature string `json:"temperature"`
Wind string `json:"wind"`
Humidity string `json:"humidity"`
} `json:"environment"`
TBD int `json:"tbd"`
HasOT int `json:"has_ot"`
Ended int `json:"ended"`
TeamReverse int `json:"team_reverse"`
Loss int `json:"loss"`
UpdatedAt int64 `json:"updated_at"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
resp.Body.Close()
return err
}
resp.Body.Close()
if payload.Query.Total == 0 || len(payload.Results) == 0 {
return fmt.Errorf("no more results")
}
for _, r := range payload.Results {
var match models.Match
if err := h.DB.Where("ts_id = ?", r.ID).First(&match).Error; err != nil {
if err == gorm.ErrRecordNotFound {
match = models.Match{
TsID: r.ID,
}
// basic mapping
if r.MatchTime > 0 {
mt := time.Unix(r.MatchTime, 0).UTC()
match.MatchDate = mt
}
if r.HomeTeamID != "" {
match.HomeTeamTsID = &r.HomeTeamID
var team models.Team
if err := h.DB.Where("ts_id = ?", r.HomeTeamID).First(&team).Error; err == nil {
match.HomeTeamWyID = team.WyID
}
}
if r.AwayTeamID != "" {
match.AwayTeamTsID = &r.AwayTeamID
var team models.Team
if err := h.DB.Where("ts_id = ?", r.AwayTeamID).First(&team).Error; err == nil {
match.AwayTeamWyID = team.WyID
}
}
// scores: index 0 is regular time goals
if len(r.HomeScores) > 0 {
match.HomeScore = r.HomeScores[0]
}
if len(r.AwayScores) > 0 {
match.AwayScore = r.AwayScores[0]
}
// penalties (index 6)
if len(r.HomeScores) > 6 {
match.HomeScorePenalties = r.HomeScores[6]
}
if len(r.AwayScores) > 6 {
match.AwayScorePenalties = r.AwayScores[6]
}
// environment temperature if present
if r.Environment.Temperature != "" {
if temp, err := strconv.ParseFloat(r.Environment.Temperature, 64); err == nil {
match.Temperature = &temp
}
}
if r.Environment.Weather != 0 {
w := fmt.Sprintf("%d", r.Environment.Weather)
match.Weather = &w
}
// updated_at metadata
if r.UpdatedAt > 0 {
ut := time.Unix(r.UpdatedAt, 0).UTC()
match.APILastSyncedAt = &ut
match.APISyncStatus = "synced"
}
if err := h.DB.Create(&match).Error; err != nil {
errorsCount++
continue
}
imported++
continue
}
errorsCount++
continue
}
// update existing match
if r.MatchTime > 0 {
mt := time.Unix(r.MatchTime, 0).UTC()
match.MatchDate = mt
}
if r.HomeTeamID != "" {
match.HomeTeamTsID = &r.HomeTeamID
var team models.Team
if err := h.DB.Where("ts_id = ?", r.HomeTeamID).First(&team).Error; err == nil {
match.HomeTeamWyID = team.WyID
}
}
if r.AwayTeamID != "" {
match.AwayTeamTsID = &r.AwayTeamID
var team models.Team
if err := h.DB.Where("ts_id = ?", r.AwayTeamID).First(&team).Error; err == nil {
match.AwayTeamWyID = team.WyID
}
}
if len(r.HomeScores) > 0 {
match.HomeScore = r.HomeScores[0]
}
if len(r.AwayScores) > 0 {
match.AwayScore = r.AwayScores[0]
}
if len(r.HomeScores) > 6 {
match.HomeScorePenalties = r.HomeScores[6]
}
if len(r.AwayScores) > 6 {
match.AwayScorePenalties = r.AwayScores[6]
}
if r.Environment.Temperature != "" {
if temp, err := strconv.ParseFloat(r.Environment.Temperature, 64); err == nil {
match.Temperature = &temp
}
}
if r.Environment.Weather != 0 {
w := fmt.Sprintf("%d", r.Environment.Weather)
match.Weather = &w
}
if r.UpdatedAt > 0 {
ut := time.Unix(r.UpdatedAt, 0).UTC()
match.APILastSyncedAt = &ut
match.APISyncStatus = "synced"
}
if err := h.DB.Save(&match).Error; err != nil {
errorsCount++
continue
}
updated++
}
return nil
}
if since != nil {
// incremental, time-based import
url := fmt.Sprintf("https://api.thesports.com/v1/football/match/recent/list?user=%s&secret=%s&type=time&time=%d", user, secret, *since)
_ = fetchAndUpsert(url)
} else {
// full page-based import
for page := 1; ; page++ {
url := fmt.Sprintf("https://api.thesports.com/v1/football/match/recent/list?user=%s&secret=%s&type=page&page=%d&page_size=%d", user, secret, page, pageSize)
if err := fetchAndUpsert(url); err != nil {
break
}
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Matches (recent) import completed",
"data": gin.H{
"imported": imported,
"updated": updated,
"errors": errorsCount,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// ImportCompetitions imports competitions from TheSports API.
// @Summary Import competitions from TheSports
// @Description Performs a competition import using TheSports competition additional list API. If `since` is provided (unix seconds), only competitions updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.
// @Tags Import
// @Param pageSize query int false "Page size per request (default 100, only used for full imports)"
// @Param since query int false "Unix timestamp (seconds) to import only competitions updated since this time"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /import/competitions [post]
func (h *ImportHandler) ImportCompetitions(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
pageSizeStr := c.DefaultQuery("pageSize", "100")
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil || pageSize <= 0 {
pageSize = 100
}
sinceStr := c.Query("since")
var since *int64
if sinceStr != "" {
if v, err := strconv.ParseInt(sinceStr, 10, 64); err == nil && v > 0 {
since = &v
}
}
imported, updated, errorsCount := 0, 0, 0
fetchAndUpsert := func(url string) error {
resp, err := h.Client.Get(url)
if err != nil {
log.Printf("failed to call TheSports competitions API: %v", err)
return err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return fmt.Errorf("provider returned status %d", resp.StatusCode)
}
var payload struct {
Query struct {
Total int `json:"total"`
} `json:"query"`
Results []struct {
ID string `json:"id"`
CategoryID string `json:"category_id"`
CountryID string `json:"country_id"`
Name string `json:"name"`
ShortName string `json:"short_name"`
Logo *string `json:"logo"`
Type int `json:"type"`
CurSeasonID string `json:"cur_season_id"`
Gender *int `json:"gender"`
UID *string `json:"uid"`
UpdatedAt *int64 `json:"updated_at"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
resp.Body.Close()
return err
}
resp.Body.Close()
if payload.Query.Total == 0 || len(payload.Results) == 0 {
return fmt.Errorf("no more results")
}
for _, r := range payload.Results {
var comp models.Competition
if err := h.DB.Where("ts_id = ?", r.ID).First(&comp).Error; err != nil {
if err == gorm.ErrRecordNotFound {
comp = models.Competition{
TsID: r.ID,
Name: r.Name,
}
// gender mapping
if r.Gender != nil {
gender := ""
switch *r.Gender {
case 1:
gender = "male"
case 2:
gender = "female"
}
if gender != "" {
comp.Gender = &gender
}
}
// area mapping via country_id
if r.CountryID != "" {
var area models.Area
if err := h.DB.Where("ts_id = ?", r.CountryID).First(&area).Error; err == nil {
if wy := parsePtrIntFromString(area.WyID); wy != nil {
comp.AreaWyID = wy
}
}
}
if err := h.DB.Create(&comp).Error; err != nil {
errorsCount++
continue
}
imported++
continue
}
errorsCount++
continue
}
// update existing competition
comp.Name = r.Name
if r.Gender != nil {
gender := ""
switch *r.Gender {
case 1:
gender = "male"
case 2:
gender = "female"
}
if gender != "" {
comp.Gender = &gender
}
}
if r.CountryID != "" {
var area models.Area
if err := h.DB.Where("ts_id = ?", r.CountryID).First(&area).Error; err == nil {
if wy := parsePtrIntFromString(area.WyID); wy != nil {
comp.AreaWyID = wy
}
}
}
if err := h.DB.Save(&comp).Error; err != nil {
errorsCount++
continue
}
updated++
}
return nil
}
if since != nil {
// incremental, time-based import
url := fmt.Sprintf("https://api.thesports.com/v1/football/competition/additional/list?user=%s&secret=%s&type=time&time=%d", user, secret, *since)
_ = fetchAndUpsert(url)
} else {
// full page-based import
for page := 1; ; page++ {
url := fmt.Sprintf("https://api.thesports.com/v1/football/competition/additional/list?user=%s&secret=%s&type=page&page=%d&page_size=%d", user, secret, page, pageSize)
if err := fetchAndUpsert(url); err != nil {
break
}
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Competitions import completed",
"data": gin.H{
"imported": imported,
"updated": updated,
"errors": errorsCount,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// ImportAreas imports countries/regions from TheSports API into the Area table.
// @Summary Import areas from TheSports
// @Description Imports all countries/regions from TheSports football country list API.
// @Tags Import
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /import/areas [post]
func (h *ImportHandler) ImportAreas(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
url := fmt.Sprintf("https://api.thesports.com/v1/football/country/list?user=%s&secret=%s", user, secret)
resp, err := h.Client.Get(url)
if err != nil {
log.Printf("failed to call TheSports areas API: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to call provider API"})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("provider returned status %d", resp.StatusCode)})
return
}
var payload struct {
Code int `json:"code"`
Results []struct {
ID string `json:"id"`
Name string `json:"name"`
Logo *string `json:"logo"`
Category *string `json:"category_id"`
UpdatedAt *int64 `json:"updated_at"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decode provider response"})
return
}
log.Printf("ImportAreas: provider code=%d, results=%d", payload.Code, len(payload.Results))
if payload.Code != 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("provider returned code %d", payload.Code)})
return
}
var imported, updated, errors int
for _, r := range payload.Results {
var area models.Area
if err := h.DB.Where("ts_id = ?", r.ID).First(&area).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// new area: store TheSports id in TsID, let DB/gorm generate primary key
area = models.Area{
TsID: r.ID,
Name: r.Name,
CategoryID: r.Category,
LogoURL: r.Logo,
}
if err := h.DB.Create(&area).Error; err != nil {
errors++
continue
}
imported++
continue
}
errors++
continue
}
// update existing
area.TsID = r.ID
area.Name = r.Name
area.CategoryID = r.Category
area.LogoURL = r.Logo
if r.UpdatedAt != nil {
ts := time.Unix(*r.UpdatedAt, 0).UTC()
area.TheSportsUpdatedAt = &ts
}
if err := h.DB.Save(&area).Error; err != nil {
errors++
continue
}
updated++
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Areas import completed",
"data": gin.H{
"imported": imported,
"updated": updated,
"errors": errors,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// ImportReferees imports referees from TheSports API.
// @Summary Import referees from TheSports
// @Description Performs a referee import using TheSports referee list API. If `since` is provided (unix seconds), only referees updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.
// @Tags Import
// @Param pageSize query int false "Page size per request (default 100, only used for full imports)"
// @Param since query int false "Unix timestamp (seconds) to import only referees updated since this time"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /import/referees [post]
func (h *ImportHandler) ImportReferees(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
pageSizeStr := c.DefaultQuery("pageSize", "100")
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil || pageSize <= 0 {
pageSize = 100
}
sinceStr := c.Query("since")
var since *int64
if sinceStr != "" {
if v, err := strconv.ParseInt(sinceStr, 10, 64); err == nil && v > 0 {
since = &v
}
}
imported, updated, errorsCount := 0, 0, 0
fetchAndUpsert := func(url string) error {
resp, err := h.Client.Get(url)
if err != nil {
log.Printf("failed to call TheSports referees API: %v", err)
return err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return fmt.Errorf("provider returned status %d", resp.StatusCode)
}
var payload struct {
Query struct {
Total int `json:"total"`
} `json:"query"`
Results []struct {
ID string `json:"id"`
Name string `json:"name"`
ShortName string `json:"short_name"`
Logo *string `json:"logo"`
Birthday *int64 `json:"birthday"`
CountryID *string `json:"country_id"`
UID *string `json:"uid"`
UpdatedAt *int64 `json:"updated_at"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
resp.Body.Close()
return err
}
resp.Body.Close()
if payload.Query.Total == 0 || len(payload.Results) == 0 {
return fmt.Errorf("no more results")
}
for _, r := range payload.Results {
var referee models.Referee
if err := h.DB.Where("ts_id = ?", r.ID).First(&referee).Error; err != nil {
if err == gorm.ErrRecordNotFound {
referee = models.Referee{
TsID: r.ID,
FirstName: r.Name,
LastName: r.Name,
ShortName: strPtrOrNil(r.ShortName),
}
if r.Birthday != nil {
bt := time.Unix(*r.Birthday, 0).UTC()
referee.DateOfBirth = &bt
}
if r.CountryID != nil && *r.CountryID != "" {
var area models.Area
if err := h.DB.Where("ts_id = ?", *r.CountryID).First(&area).Error; err == nil {
if wy := parsePtrIntFromString(area.WyID); wy != nil {
referee.NationalityWyID = wy
}
}
}
referee.UID = r.UID
referee.ImageDataURL = r.Logo
if r.UpdatedAt != nil {
ut := time.Unix(*r.UpdatedAt, 0).UTC()
referee.APILastSyncedAt = &ut
referee.APISyncStatus = "synced"
}
if err := h.DB.Create(&referee).Error; err != nil {
errorsCount++
continue
}
imported++
continue
}
errorsCount++
continue
}
// update existing
referee.ShortName = strPtrOrNil(r.ShortName)
if r.Birthday != nil {
bt := time.Unix(*r.Birthday, 0).UTC()
referee.DateOfBirth = &bt
}
if r.CountryID != nil && *r.CountryID != "" {
var area models.Area
if err := h.DB.Where("ts_id = ?", *r.CountryID).First(&area).Error; err == nil {
if wy := parsePtrIntFromString(area.WyID); wy != nil {
referee.NationalityWyID = wy
}
}
}
referee.UID = r.UID
referee.ImageDataURL = r.Logo
if r.UpdatedAt != nil {
ut := time.Unix(*r.UpdatedAt, 0).UTC()
referee.APILastSyncedAt = &ut
referee.APISyncStatus = "synced"
}
if err := h.DB.Save(&referee).Error; err != nil {
errorsCount++
continue
}
updated++
}
return nil
}
if since != nil {
// incremental, time-based import
url := fmt.Sprintf("https://api.thesports.com/v1/football/referee/list?user=%s&secret=%s&type=time&time=%d", user, secret, *since)
_ = fetchAndUpsert(url)
} else {
// full page-based import
for page := 1; ; page++ {
url := fmt.Sprintf("https://api.thesports.com/v1/football/referee/list?user=%s&secret=%s&type=page&page=%d&page_size=%d", user, secret, page, pageSize)
if err := fetchAndUpsert(url); err != nil {
break
}
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Referees import completed",
"data": gin.H{
"imported": imported,
"updated": updated,
"errors": errorsCount,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// ImportCoaches imports coaches from TheSports API.
// @Summary Import coaches from TheSports
// @Description Performs a coach import using TheSports coach list API. If `since` is provided (unix seconds), only coaches updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.
// @Tags Import
// @Param pageSize query int false "Page size per request (default 100, only used for full imports)"
// @Param since query int false "Unix timestamp (seconds) to import only coaches updated since this time"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /import/coaches [post]
func (h *ImportHandler) ImportCoaches(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
pageSizeStr := c.DefaultQuery("pageSize", "100")
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil || pageSize <= 0 {
pageSize = 100
}
sinceStr := c.Query("since")
var since *int64
if sinceStr != "" {
if v, err := strconv.ParseInt(sinceStr, 10, 64); err == nil && v > 0 {
since = &v
}
}
imported, updated, errorsCount := 0, 0, 0
fetchAndUpsert := func(url string) error {
resp, err := h.Client.Get(url)
if err != nil {
log.Printf("failed to call TheSports coaches API: %v", err)
return err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return fmt.Errorf("provider returned status %d", resp.StatusCode)
}
var payload struct {
Query struct {
Total int `json:"total"`
} `json:"query"`
Results []struct {
ID string `json:"id"`
TeamID string `json:"team_id"`
Name string `json:"name"`
ShortName string `json:"short_name"`
Logo *string `json:"logo"`
Type *int `json:"type"`
Birthday *int64 `json:"birthday"`
Age *int `json:"age"`
PreferredFormation *string `json:"preferred_formation"`
CountryID *string `json:"country_id"`
Nationality *string `json:"nationality"`
Joined *int64 `json:"joined"`
ContractUntil *int64 `json:"contract_until"`
UID *string `json:"uid"`
Deathday *int64 `json:"deathday"`
UpdatedAt *int64 `json:"updated_at"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
resp.Body.Close()
return err
}
resp.Body.Close()
if payload.Query.Total == 0 || len(payload.Results) == 0 {
return nil
}
for _, r := range payload.Results {
var coach models.Coach
if err := h.DB.Where("ts_id = ?", r.ID).First(&coach).Error; err != nil {
if err == gorm.ErrRecordNotFound {
coach = models.Coach{
TsID: r.ID,
FirstName: r.Name,
LastName: r.Name,
ShortName: strPtrOrNil(r.ShortName),
}
// map personal/date fields
if r.Birthday != nil {
ts := time.Unix(*r.Birthday, 0).UTC()
coach.DateOfBirth = &ts
}
// map country -> NationalityWyID via areas.ts_id when possible
if r.CountryID != nil && *r.CountryID != "" {
var area models.Area
if err := h.DB.Where("ts_id = ?", *r.CountryID).First(&area).Error; err == nil {
if wy := parsePtrIntFromString(area.WyID); wy != nil {
coach.NationalityWyID = wy
}
}
}
// map team -> CurrentTeamWyID via teams.ts_id when possible
if r.TeamID != "" {
var team models.Team
if err := h.DB.Where("ts_id = ?", r.TeamID).First(&team).Error; err == nil {
coach.CurrentTeamWyID = team.WyID
}
}
// preferred formation
coach.PreferredFormation = r.PreferredFormation
// joined and contract_until
if r.Joined != nil && *r.Joined > 0 {
jt := time.Unix(*r.Joined, 0).UTC()
coach.JoinedAt = &jt
}
if r.ContractUntil != nil && *r.ContractUntil > 0 {
ct := time.Unix(*r.ContractUntil, 0).UTC()
coach.ContractUntil = &ct
}
// uid and deathday
coach.UID = r.UID
if r.Deathday != nil && *r.Deathday > 0 {
dt := time.Unix(*r.Deathday, 0).UTC()
coach.Deathday = &dt
}
// provider sync metadata
coach.ImageDataURL = r.Logo
if r.UpdatedAt != nil {
ts := time.Unix(*r.UpdatedAt, 0).UTC()
coach.APILastSyncedAt = &ts
coach.APISyncStatus = "synced"
}
if err := h.DB.Create(&coach).Error; err != nil {
errorsCount++
continue
}
imported++
continue
}
errorsCount++
continue
}
// update existing coach with latest data
coach.ShortName = strPtrOrNil(r.ShortName)
if r.Birthday != nil {
ts := time.Unix(*r.Birthday, 0).UTC()
coach.DateOfBirth = &ts
}
if r.CountryID != nil && *r.CountryID != "" {
var area models.Area
if err := h.DB.Where("ts_id = ?", *r.CountryID).First(&area).Error; err == nil {
if wy := parsePtrIntFromString(area.WyID); wy != nil {
coach.NationalityWyID = wy
}
}
}
if r.TeamID != "" {
var team models.Team
if err := h.DB.Where("ts_id = ?", r.TeamID).First(&team).Error; err == nil {
coach.CurrentTeamWyID = team.WyID
}
}
coach.PreferredFormation = r.PreferredFormation
if r.Joined != nil && *r.Joined > 0 {
jt := time.Unix(*r.Joined, 0).UTC()
coach.JoinedAt = &jt
}
if r.ContractUntil != nil && *r.ContractUntil > 0 {
ct := time.Unix(*r.ContractUntil, 0).UTC()
coach.ContractUntil = &ct
}
coach.UID = r.UID
if r.Deathday != nil && *r.Deathday > 0 {
dt := time.Unix(*r.Deathday, 0).UTC()
coach.Deathday = &dt
}
coach.ImageDataURL = r.Logo
if r.UpdatedAt != nil {
ts := time.Unix(*r.UpdatedAt, 0).UTC()
coach.APILastSyncedAt = &ts
coach.APISyncStatus = "synced"
}
if err := h.DB.Save(&coach).Error; err != nil {
errorsCount++
continue
}
updated++
}
return nil
}
if since != nil {
// incremental, time-based import
url := fmt.Sprintf("https://api.thesports.com/v1/football/coach/list?user=%s&secret=%s&type=time&time=%d", user, secret, *since)
_ = fetchAndUpsert(url)
} else {
// full page-based import
for page := 1; ; page++ {
url := fmt.Sprintf("https://api.thesports.com/v1/football/coach/list?user=%s&secret=%s&type=page&page=%d&page_size=%d", user, secret, page, pageSize)
if err := fetchAndUpsert(url); err != nil {
break
}
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Coaches import completed",
"data": gin.H{
"imported": imported,
"updated": updated,
"errors": errorsCount,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// ImportTeams imports teams from TheSports API.
// @Summary Import teams from TheSports
// @Description Performs a team import using TheSports team additional list API. If `since` is provided (unix seconds), only teams updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.
// @Tags Import
// @Param pageSize query int false "Page size per request (default 100, only used for full imports)"
// @Param since query int false "Unix timestamp (seconds) to import only teams updated since this time"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /import/teams [post]
func (h *ImportHandler) ImportTeams(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
pageSizeStr := c.DefaultQuery("pageSize", "100")
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil || pageSize <= 0 {
pageSize = 100
}
sinceStr := c.Query("since")
var since *int64
if sinceStr != "" {
if v, err := strconv.ParseInt(sinceStr, 10, 64); err == nil && v > 0 {
since = &v
}
}
imported, updated, errors := 0, 0, 0
fetchAndUpsert := func(url string) error {
resp, err := h.Client.Get(url)
if err != nil {
log.Printf("failed to call TheSports teams API: %v", err)
return err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return fmt.Errorf("provider returned status %d", resp.StatusCode)
}
var payload struct {
Query struct {
Total int `json:"total"`
} `json:"query"`
Results []struct {
ID string `json:"id"`
CompetitionID string `json:"competition_id"`
CountryID string `json:"country_id"`
Name string `json:"name"`
ShortName string `json:"short_name"`
Logo *string `json:"logo"`
National int `json:"national"`
CountryLogo *string `json:"country_logo"`
FoundationTime *int `json:"foundation_time"`
Website *string `json:"website"`
CoachID string `json:"coach_id"`
VenueID string `json:"venue_id"`
MarketValue *int `json:"market_value"`
MarketValueCurrency *string `json:"market_value_currency"`
TotalPlayers *int `json:"total_players"`
ForeignPlayers *int `json:"foreign_players"`
NationalPlayers *int `json:"national_players"`
UID *string `json:"uid"`
Virtual *int `json:"virtual"`
Gender *int `json:"gender"`
UpdatedAt *int64 `json:"updated_at"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
resp.Body.Close()
return err
}
resp.Body.Close()
if payload.Query.Total == 0 || len(payload.Results) == 0 {
return nil
}
for _, r := range payload.Results {
var team models.Team
if err := h.DB.Where("ts_id = ?", r.ID).First(&team).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// new team
team = models.Team{
TsID: r.ID,
Name: r.Name,
ShortName: strPtrOrNil(r.ShortName),
ImageDataURL: r.Logo,
}
// map type from national flag
if r.National == 1 {
team.Type = "national"
} else {
team.Type = "club"
}
// map gender
if r.Gender != nil {
gender := ""
switch *r.Gender {
case 1:
gender = "male"
case 2:
gender = "female"
}
if gender != "" {
team.Gender = &gender
}
}
if r.UpdatedAt != nil {
ts := time.Unix(*r.UpdatedAt, 0).UTC()
team.APILastSyncedAt = &ts
team.APISyncStatus = "synced"
}
if err := h.DB.Create(&team).Error; err != nil {
errors++
continue
}
imported++
continue
}
errors++
continue
}
// update existing
team.Name = r.Name
team.ShortName = strPtrOrNil(r.ShortName)
team.ImageDataURL = r.Logo
if r.National == 1 {
team.Type = "national"
} else {
team.Type = "club"
}
if r.Gender != nil {
gender := ""
switch *r.Gender {
case 1:
gender = "male"
case 2:
gender = "female"
}
if gender != "" {
team.Gender = &gender
}
}
if r.UpdatedAt != nil {
ts := time.Unix(*r.UpdatedAt, 0).UTC()
team.APILastSyncedAt = &ts
team.APISyncStatus = "synced"
}
if err := h.DB.Save(&team).Error; err != nil {
errors++
continue
}
updated++
}
return nil
}
if since != nil {
// incremental, time-based import
url := fmt.Sprintf("https://api.thesports.com/v1/football/team/additional/list?user=%s&secret=%s&type=time&time=%d", user, secret, *since)
_ = fetchAndUpsert(url)
} else {
// full page-based import
for page := 1; ; page++ {
url := fmt.Sprintf("https://api.thesports.com/v1/football/team/additional/list?user=%s&secret=%s&type=page&page=%d&page_size=%d", user, secret, page, pageSize)
if err := fetchAndUpsert(url); err != nil {
break
}
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Teams import completed",
"data": gin.H{
"imported": imported,
"updated": updated,
"errors": errors,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// ImportPlayers imports players from TheSports API.
// @Summary Import players from TheSports
// @Description Performs a player import using TheSports player with_stat list API. If `since` is provided (unix seconds), only players updated since that time are fetched using the time-based endpoint. Otherwise, a full import is performed using page-based pagination.
// @Tags Import
// @Param pageSize query int false "Page size per request (default 100, only used for full imports)"
// @Param since query int false "Unix timestamp (seconds) to import only players updated since this time"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /import/players [post]
func (h *ImportHandler) ImportPlayers(c *gin.Context) {
user := h.Cfg.ProviderUser
secret := h.Cfg.ProviderSecret
if user == "" || secret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ProviderUser/ProviderSecret not configured"})
return
}
pageSizeStr := c.DefaultQuery("pageSize", "100")
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil || pageSize <= 0 {
pageSize = 100
}
sinceStr := c.Query("since")
var since *int64
if sinceStr != "" {
if v, err := strconv.ParseInt(sinceStr, 10, 64); err == nil && v > 0 {
since = &v
}
}
imported, updated, errors := 0, 0, 0
fetchAndUpsert := func(url string) error {
resp, err := h.Client.Get(url)
if err != nil {
log.Printf("failed to call TheSports players API: %v", err)
return err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return fmt.Errorf("provider returned status %d", resp.StatusCode)
}
var payload struct {
Query struct {
Total int `json:"total"`
} `json:"query"`
Results []struct {
ID string `json:"id"`
TeamID string `json:"team_id"`
Name string `json:"name"`
ShortName string `json:"short_name"`
Logo *string `json:"logo"`
Age *int `json:"age"`
Birthday *int64 `json:"birthday"`
Weight *int `json:"weight"`
Height *int `json:"height"`
CountryID *string `json:"country_id"`
MarketValue *int `json:"market_value"`
MarketCurrency *string `json:"market_value_currency"`
ContractUntil *int64 `json:"contract_until"`
PreferredFoot *int `json:"preferred_foot"`
Position *string `json:"position"`
Positions any `json:"positions"`
Ability any `json:"ability"`
Characteristics any `json:"characteristics"`
UID *string `json:"uid"`
Deathday *int64 `json:"deathday"`
RetireTime *int64 `json:"retire_time"`
UpdatedAt *int64 `json:"updated_at"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
resp.Body.Close()
return err
}
resp.Body.Close()
if payload.Query.Total == 0 || len(payload.Results) == 0 {
return nil
}
for _, r := range payload.Results {
var player models.Player
if err := h.DB.Where("ts_id = ?", r.ID).First(&player).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// new player
player = models.Player{
TsID: r.ID,
TeamTsID: strPtrOrNil(r.TeamID),
ShortName: strPtrOrNil(r.ShortName),
FirstName: r.Name,
}
if r.Birthday != nil {
ts := time.Unix(*r.Birthday, 0).UTC()
player.DateOfBirth = &ts
}
if r.Height != nil {
player.HeightCM = r.Height
}
if r.Weight != nil {
w := float64(*r.Weight)
player.WeightKG = &w
}
// map basic profile fields
if r.Position != nil {
player.Position = r.Position
}
if r.PreferredFoot != nil {
foot := ""
switch *r.PreferredFoot {
case 1:
foot = "right"
case 2:
foot = "left"
case 3:
foot = "both"
}
if foot != "" {
player.Foot = &foot
}
}
// map image/logo
player.ImageDataURL = r.Logo
player.MarketValue = r.MarketValue
player.MarketValueCurrency = r.MarketCurrency
if r.ContractUntil != nil {
ts := time.Unix(*r.ContractUntil, 0).UTC()
player.ContractUntil = &ts
}
// ability/characteristics/positions as JSON strings
if b, err := json.Marshal(r.Ability); err == nil {
jsonStr := string(b)
player.AbilityJSON = &jsonStr
}
if b, err := json.Marshal(r.Characteristics); err == nil {
jsonStr := string(b)
player.CharacteristicsJSON = &jsonStr
}
player.UID = r.UID
if r.Deathday != nil {
ts := time.Unix(*r.Deathday, 0).UTC()
player.Deathday = &ts
}
if r.RetireTime != nil {
ts := time.Unix(*r.RetireTime, 0).UTC()
player.RetireTime = &ts
}
if err := h.DB.Create(&player).Error; err != nil {
errors++
continue
}
imported++
continue
}
errors++
continue
}
// update existing
player.ShortName = strPtrOrNil(r.ShortName)
if r.Birthday != nil {
ts := time.Unix(*r.Birthday, 0).UTC()
player.DateOfBirth = &ts
}
if r.Height != nil {
player.HeightCM = r.Height
}
if r.Weight != nil {
w := float64(*r.Weight)
player.WeightKG = &w
}
if r.Position != nil {
player.Position = r.Position
}
if r.PreferredFoot != nil {
foot := ""
switch *r.PreferredFoot {
case 1:
foot = "right"
case 2:
foot = "left"
case 3:
foot = "both"
}
if foot != "" {
player.Foot = &foot
}
}
player.ImageDataURL = r.Logo
player.MarketValue = r.MarketValue
player.MarketValueCurrency = r.MarketCurrency
if r.ContractUntil != nil {
ts := time.Unix(*r.ContractUntil, 0).UTC()
player.ContractUntil = &ts
}
if b, err := json.Marshal(r.Ability); err == nil {
jsonStr := string(b)
player.AbilityJSON = &jsonStr
}
if b, err := json.Marshal(r.Characteristics); err == nil {
jsonStr := string(b)
player.CharacteristicsJSON = &jsonStr
}
player.UID = r.UID
if r.Deathday != nil {
ts := time.Unix(*r.Deathday, 0).UTC()
player.Deathday = &ts
}
if r.RetireTime != nil {
ts := time.Unix(*r.RetireTime, 0).UTC()
player.RetireTime = &ts
}
if err := h.DB.Save(&player).Error; err != nil {
errors++
continue
}
updated++
}
return nil
}
if since != nil {
// incremental, time-based import
url := fmt.Sprintf("https://api.thesports.com/v1/football/player/with_stat/list?user=%s&secret=%s&type=time&time=%d", user, secret, *since)
_ = fetchAndUpsert(url)
} else {
// full page-based import
for page := 1; ; page++ {
url := fmt.Sprintf("https://api.thesports.com/v1/football/player/with_stat/list?user=%s&secret=%s&type=page&page=%d&page_size=%d", user, secret, page, pageSize)
if err := fetchAndUpsert(url); err != nil {
break
}
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Players import completed",
"data": gin.H{
"imported": imported,
"updated": updated,
"errors": errors,
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
func parseIntOrZero(s string) int {
v, err := strconv.Atoi(s)
if err != nil {
return 0
}
return v
}
func parsePtrIntFromString(s string) *int {
if s == "" {
return nil
}
v, err := strconv.Atoi(s)
if err != nil {
return nil
}
return &v
}
func strPtrOrNil(s string) *string {
if s == "" {
return nil
}
return &s
}
package handlers
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"ScoutingSystemScoreData/internal/models"
"ScoutingSystemScoreData/internal/services"
)
type MatchHandler struct {
Service services.MatchService
}
func RegisterMatchRoutes(rg *gin.RouterGroup, db *gorm.DB) {
service := services.NewMatchService(db)
h := &MatchHandler{Service: service}
matches := rg.Group("/matches")
matches.GET("", h.List)
matches.GET("/head-to-head", h.HeadToHeadMostRecent)
matches.GET("/id/:id/lineup", h.GetLineupByMatchID)
matches.GET("/:matchTsId/lineup", h.GetLineupByMatchTsID)
}
// GetLineupByMatchID
// @Summary Get match lineup by internal match id
// @Description Returns the stored lineup data for a match (teams/formations/coaches + players) by internal match id.
// @Tags Matches
// @Param id path string true "Internal match id"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /matches/id/{id}/lineup [get]
func (h *MatchHandler) GetLineupByMatchID(c *gin.Context) {
matchID := c.Param("id")
if matchID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
return
}
match, teams, lineup, err := h.Service.GetLineupByID(c.Request.Context(), matchID)
if err != nil {
respondError(c, err)
return
}
teamTsIDToSide := map[string]string{}
for _, t := range teams {
if t.TeamTsID != nil && *t.TeamTsID != "" {
teamTsIDToSide[*t.TeamTsID] = t.Side
}
}
type lineupPlayerOut struct {
models.MatchLineupPlayer
Side string `json:"side"`
}
playersBySide := map[string][]lineupPlayerOut{
"home": {},
"away": {},
"unknown": {},
}
for _, p := range lineup {
side := "unknown"
if p.TeamTsID != nil {
if s, ok := teamTsIDToSide[*p.TeamTsID]; ok && s != "" {
side = s
}
}
playersBySide[side] = append(playersBySide[side], lineupPlayerOut{MatchLineupPlayer: p, Side: side})
}
teamsBySide := map[string]*models.MatchTeam{
"home": nil,
"away": nil,
}
for _, t := range teams {
teamCopy := t
if teamCopy.Side == "home" {
teamsBySide["home"] = &teamCopy
} else if teamCopy.Side == "away" {
teamsBySide["away"] = &teamCopy
}
}
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/matches/id/%s/lineup", matchID)
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"match": match,
"teams": gin.H{
"home": teamsBySide["home"],
"away": teamsBySide["away"],
},
"players": gin.H{
"home": playersBySide["home"],
"away": playersBySide["away"],
"unknown": playersBySide["unknown"],
},
},
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
// List matches
// @Summary List matches
// @Description Returns a paginated list of matches, optionally filtered by competitionWyId, seasonWyId, teamTsId and date range.
// @Tags Matches
// @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 competitionWyId query int false "Filter by competition wy_id"
// @Param seasonWyId query int false "Filter by season wy_id"
// @Param teamTsId query string false "Filter by team ts_id (matches where team is home or away)"
// @Param from query string false "Filter matches on/after date (RFC3339 or YYYY-MM-DD)"
// @Param to query string false "Filter matches on/before date (RFC3339 or YYYY-MM-DD)"
// @Param status query string false "Filter by match status"
// @Param order query string false "Sort order by match_date: asc|desc (default desc)"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /matches [get]
func (h *MatchHandler) 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
}
var competitionWyID *int
if v := c.Query("competitionWyId"); v != "" {
iv, err := strconv.Atoi(v)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid competitionWyId"})
return
}
competitionWyID = &iv
}
var seasonWyID *int
if v := c.Query("seasonWyId"); v != "" {
iv, err := strconv.Atoi(v)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid seasonWyId"})
return
}
seasonWyID = &iv
}
var teamTsID *string
if v := c.Query("teamTsId"); v != "" {
teamTsID = &v
}
parseDate := func(raw string) (*time.Time, error) {
if raw == "" {
return nil, nil
}
if t, err := time.Parse(time.RFC3339, raw); err == nil {
ut := t.UTC()
return &ut, nil
}
if t, err := time.Parse("2006-01-02", raw); err == nil {
ut := t.UTC()
return &ut, nil
}
return nil, fmt.Errorf("invalid date")
}
from, err := parseDate(c.Query("from"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid from (use RFC3339 or YYYY-MM-DD)"})
return
}
to, err := parseDate(c.Query("to"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid to (use RFC3339 or YYYY-MM-DD)"})
return
}
var status *string
if v := c.Query("status"); v != "" {
status = &v
}
order := c.DefaultQuery("order", "desc")
if order != "asc" && order != "desc" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid order (use asc or desc)"})
return
}
opts := services.ListMatchesOptions{
Limit: limit,
Offset: offset,
CompetitionWyID: competitionWyID,
SeasonWyID: seasonWyID,
TeamTsID: teamTsID,
FromDate: from,
ToDate: to,
Status: status,
Order: order,
}
matches, total, err := h.Service.ListMatches(c.Request.Context(), opts)
if err != nil {
respondError(c, err)
return
}
page := offset/limit + 1
hasMore := int64(offset+limit) < total
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := "/matches"
c.JSON(http.StatusOK, gin.H{
"data": matches,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
"totalItems": total,
"page": page,
"limit": limit,
"hasMore": hasMore,
},
})
}
// HeadToHeadMostRecent
// @Summary Most recent match between two teams
// @Description Returns the most recent match between teamA and teamB (by ts_id), regardless of home/away.
// @Tags Matches
// @Param teamA query string true "Team A ts_id"
// @Param teamB query string true "Team B ts_id"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /matches/head-to-head [get]
func (h *MatchHandler) HeadToHeadMostRecent(c *gin.Context) {
teamA := c.Query("teamA")
teamB := c.Query("teamB")
if teamA == "" || teamB == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "teamA and teamB are required"})
return
}
match, err := h.Service.GetHeadToHeadMostRecent(c.Request.Context(), teamA, teamB)
if err != nil {
respondError(c, err)
return
}
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/matches/head-to-head?teamA=%s&teamB=%s", teamA, teamB)
c.JSON(http.StatusOK, gin.H{
"data": match,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
},
})
}
// GetLineupByMatchTsID
// @Summary Get match lineup by match id
// @Description Returns the stored lineup data for a match (teams/formations/coaches + players) by match ts_id.
// @Tags Matches
// @Param matchTsId path string true "TheSports match id (tsId)"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /matches/{matchTsId}/lineup [get]
func (h *MatchHandler) GetLineupByMatchTsID(c *gin.Context) {
matchTsID := c.Param("matchTsId")
if matchTsID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "matchTsId is required"})
return
}
match, teams, lineup, err := h.Service.GetLineup(c.Request.Context(), matchTsID)
if err != nil {
respondError(c, err)
return
}
teamTsIDToSide := map[string]string{}
for _, t := range teams {
if t.TeamTsID != nil && *t.TeamTsID != "" {
teamTsIDToSide[*t.TeamTsID] = t.Side
}
}
type lineupPlayerOut struct {
models.MatchLineupPlayer
Side string `json:"side"`
}
playersBySide := map[string][]lineupPlayerOut{
"home": {},
"away": {},
"unknown": {},
}
for _, p := range lineup {
side := "unknown"
if p.TeamTsID != nil {
if s, ok := teamTsIDToSide[*p.TeamTsID]; ok && s != "" {
side = s
}
}
playersBySide[side] = append(playersBySide[side], lineupPlayerOut{MatchLineupPlayer: p, Side: side})
}
teamsBySide := map[string]*models.MatchTeam{
"home": nil,
"away": nil,
}
for _, t := range teams {
teamCopy := t
if teamCopy.Side == "home" {
teamsBySide["home"] = &teamCopy
} else if teamCopy.Side == "away" {
teamsBySide["away"] = &teamCopy
}
}
timestamp := time.Now().UTC().Format(time.RFC3339)
endpoint := fmt.Sprintf("/matches/%s/lineup", matchTsID)
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"match": match,
"teams": gin.H{
"home": teamsBySide["home"],
"away": teamsBySide["away"],
},
"players": gin.H{
"home": playersBySide["home"],
"away": playersBySide["away"],
"unknown": playersBySide["unknown"],
},
},
"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 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"
)
// Area mirrors the Nest/Drizzle `areas` table.
type Area struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
WyID string `gorm:"column:wy_id" json:"wyId"`
TsID string `gorm:"column:ts_id" json:"tsId"`
Name string `gorm:"column:name" json:"name"`
Alpha2Code *string `gorm:"column:alpha2code" json:"alpha2code"`
Alpha3Code *string `gorm:"column:alpha3code" json:"alpha3code"`
CategoryID *string `gorm:"column:ts_category_id" json:"categoryId"`
LogoURL *string `gorm:"column:logo" json:"logo"`
TheSportsUpdatedAt *time.Time `gorm:"column:ts_updated_at" json:"theSportsUpdatedAt"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deletedAt"`
}
func (a *Area) BeforeCreate(tx *gorm.DB) (err error) {
if a.ID != "" {
return nil
}
id, err := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 15)
if err != nil {
return err
}
a.ID = id
return nil
}
// Competition mirrors the Nest/Drizzle `competitions` table.
type Competition struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
WyID *int `gorm:"column:wy_id" json:"wyId"`
TsID string `gorm:"column:ts_id;size:64;uniqueIndex" json:"tsId"`
Name string `gorm:"column:name" json:"name"`
OfficialName *string `gorm:"column:official_name" json:"officialName"`
AreaWyID *int `gorm:"column:area_wy_id" json:"areaWyId"`
Gender *string `gorm:"column:gender;type:gender" json:"gender"`
Type string `gorm:"column:type;type:competition_type;default:league" json:"type"`
Category string `gorm:"column:category;default:default" json:"category"`
IsActive bool `gorm:"column:is_active;default:true" json:"isActive"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deletedAt"`
}
func (c *Competition) BeforeCreate(tx *gorm.DB) (err error) {
if c.ID != "" {
return nil
}
id, err := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 15)
if err != nil {
return err
}
c.ID = id
return nil
}
// Season mirrors the Nest/Drizzle `seasons` table.
type Season struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
WyID *int `gorm:"column:wy_id" json:"wyId"`
TsID string `gorm:"column:ts_id;size:64;uniqueIndex" json:"tsId"`
CompetitionWyID *int `gorm:"column:competition_wy_id" json:"competitionWyId"`
Name string `gorm:"column:name" json:"name"`
StartDate *time.Time `gorm:"column:start_date" json:"startDate"`
EndDate *time.Time `gorm:"column:end_date" json:"endDate"`
IsActive bool `gorm:"column:is_active;default:true" json:"isActive"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deletedAt"`
}
func (s *Season) 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
}
type Team struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
WyID *int `gorm:"column:wy_id;uniqueIndex" json:"wyId"`
TsID string `gorm:"column:ts_id;size:64;uniqueIndex" json:"tsId"`
GSMID *int `gorm:"column:gsm_id" json:"gsmId"`
Name string `gorm:"column:name" json:"name"`
OfficialName *string `gorm:"column:official_name" json:"officialName"`
ShortName *string `gorm:"column:short_name" json:"shortName"`
Description *string `gorm:"column:description" json:"description"`
Type string `gorm:"column:type;type:team_type;default:club" json:"type"`
Category string `gorm:"column:category;default:default" json:"category"`
Gender *string `gorm:"column:gender;type:gender" json:"gender"`
AreaWyID *int `gorm:"column:area_wy_id" json:"areaWyId"`
City *string `gorm:"column:city" json:"city"`
CoachWyID *int `gorm:"column:coach_wy_id" json:"coachWyId"`
CompetitionWyID *int `gorm:"column:competition_wy_id" json:"competitionWyId"`
SeasonWyID *int `gorm:"column:season_wy_id" json:"seasonWyId"`
League *string `gorm:"column:league" json:"league"`
Season *string `gorm:"column:season" json:"season"`
ImageDataURL *string `gorm:"column:image_data_url" json:"imageDataUrl"`
Status string `gorm:"column:status;type:status;default:active" json:"status"`
IsActive bool `gorm:"column:is_active;default:true" json:"isActive"`
APILastSyncedAt *time.Time `gorm:"column:api_last_synced_at" json:"apiLastSyncedAt"`
APISyncStatus string `gorm:"column:api_sync_status;type:api_sync_status;default:pending" json:"apiSyncStatus"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deletedAt"`
}
func (r *Referee) BeforeCreate(tx *gorm.DB) (err error) {
if r.ID != "" {
return nil
}
id, err := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 15)
if err != nil {
return err
}
r.ID = id
return nil
}
func (t *Team) BeforeCreate(tx *gorm.DB) (err error) {
if t.ID != "" {
return nil
}
id, err := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 15)
if err != nil {
return err
}
t.ID = id
return nil
}
type Coach struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
WyID *int `gorm:"column:wy_id;uniqueIndex" json:"wyId"`
TsID string `gorm:"column:ts_id;size:64" json:"tsId"`
FirstName string `gorm:"column:first_name" json:"firstName"`
LastName string `gorm:"column:last_name" json:"lastName"`
MiddleName *string `gorm:"column:middle_name" json:"middleName"`
ShortName *string `gorm:"column:short_name" json:"shortName"`
DateOfBirth *time.Time `gorm:"column:date_of_birth" json:"dateOfBirth"`
NationalityWyID *int `gorm:"column:nationality_wy_id" json:"nationalityWyId"`
CurrentTeamWyID *int `gorm:"column:current_team_wy_id" json:"currentTeamWyId"`
Position string `gorm:"column:position;type:coach_position;default:head_coach" json:"position"`
CoachingLicense *string `gorm:"column:coaching_license" json:"coachingLicense"`
YearsExperience *int `gorm:"column:years_experience" json:"yearsExperience"`
PreferredFormation *string `gorm:"column:preferred_formation" json:"preferredFormation"`
JoinedAt *time.Time `gorm:"column:joined_at" json:"joinedAt"`
ContractUntil *time.Time `gorm:"column:contract_until" json:"contractUntil"`
UID *string `gorm:"column:uid" json:"uid"`
Deathday *time.Time `gorm:"column:deathday" json:"deathday"`
Status string `gorm:"column:status;type:status;default:active" json:"status"`
ImageDataURL *string `gorm:"column:image_data_url" json:"imageDataUrl"`
APILastSyncedAt *time.Time `gorm:"column:api_last_synced_at" json:"apiLastSyncedAt"`
APISyncStatus string `gorm:"column:api_sync_status;type:api_sync_status;default:pending" json:"apiSyncStatus"`
IsActive bool `gorm:"column:is_active;default:true" json:"isActive"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deletedAt"`
}
func (c *Coach) BeforeCreate(tx *gorm.DB) (err error) {
if c.ID != "" {
return nil
}
id, err := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 15)
if err != nil {
return err
}
c.ID = id
return nil
}
type Referee struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
WyID *int `gorm:"column:wy_id;uniqueIndex" json:"wyId"`
TsID string `gorm:"column:ts_id;size:64" json:"tsId"`
FirstName string `gorm:"column:first_name" json:"firstName"`
LastName string `gorm:"column:last_name" json:"lastName"`
MiddleName *string `gorm:"column:middle_name" json:"middleName"`
ShortName *string `gorm:"column:short_name" json:"shortName"`
DateOfBirth *time.Time `gorm:"column:date_of_birth" json:"dateOfBirth"`
NationalityWyID *int `gorm:"column:nationality_wy_id" json:"nationalityWyId"`
RefereeType string `gorm:"column:referee_type;type:referee_type;default:main" json:"refereeType"`
FIFACategory *string `gorm:"column:fifa_category;type:fifa_category" json:"fifaCategory"`
ExperienceYears *int `gorm:"column:experience_years" json:"experienceYears"`
UID *string `gorm:"column:uid" json:"uid"`
Status string `gorm:"column:status;type:status;default:active" json:"status"`
ImageDataURL *string `gorm:"column:image_data_url" json:"imageDataUrl"`
APILastSyncedAt *time.Time `gorm:"column:api_last_synced_at" json:"apiLastSyncedAt"`
APISyncStatus string `gorm:"column:api_sync_status;type:api_sync_status;default:pending" json:"apiSyncStatus"`
IsActive bool `gorm:"column:is_active;default:true" json:"isActive"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deletedAt"`
}
type Player struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
WyID *int `gorm:"column:wy_id;uniqueIndex" json:"wyId"`
TsID string `gorm:"column:ts_id;size:64" json:"tsId"`
TeamWyID *int `gorm:"column:team_wy_id" json:"teamWyId"`
TeamTsID *string `gorm:"column:team_ts_id;size:64" json:"teamTsId"`
FirstName string `gorm:"column:first_name" json:"firstName"`
LastName string `gorm:"column:last_name" json:"lastName"`
MiddleName *string `gorm:"column:middle_name" json:"middleName"`
ShortName *string `gorm:"column:short_name" json:"shortName"`
GSMID *int `gorm:"column:gsm_id" json:"gsmId"`
CurrentTeamID *int `gorm:"column:current_team_id" json:"currentTeamId"`
CurrentNationalTeamID *int `gorm:"column:current_national_team_id" json:"currentNationalTeamId"`
DateOfBirth *time.Time `gorm:"column:date_of_birth" json:"dateOfBirth"`
HeightCM *int `gorm:"column:height_cm" json:"heightCm"`
WeightKG *float64 `gorm:"column:weight_kg" json:"weightKg"`
Foot *string `gorm:"column:foot;type:foot" json:"foot"`
Gender *string `gorm:"column:gender;type:gender" json:"gender"`
Position *string `gorm:"column:position" json:"position"`
RoleCode2 *string `gorm:"column:role_code2" json:"roleCode2"`
RoleCode3 *string `gorm:"column:role_code3" json:"roleCode3"`
RoleName *string `gorm:"column:role_name" json:"roleName"`
BirthAreaWyID *int `gorm:"column:birth_area_wy_id" json:"birthAreaWyId"`
PassportAreaWyID *int `gorm:"column:passport_area_wy_id" json:"passportAreaWyId"`
MarketValue *int `gorm:"column:market_value" json:"marketValue"`
MarketValueCurrency *string `gorm:"column:market_value_currency" json:"marketValueCurrency"`
ContractUntil *time.Time `gorm:"column:contract_until" json:"contractUntil"`
AbilityJSON *string `gorm:"column:ability_json;type:jsonb" json:"abilityJson"`
CharacteristicsJSON *string `gorm:"column:characteristics_json;type:jsonb" json:"characteristicsJson"`
UID *string `gorm:"column:uid" json:"uid"`
Deathday *time.Time `gorm:"column:deathday" json:"deathday"`
RetireTime *time.Time `gorm:"column:retire_time" json:"retireTime"`
Status string `gorm:"column:status;type:status;default:active" json:"status"`
ImageDataURL *string `gorm:"column:image_data_url" json:"imageDataUrl"`
JerseyNumber *int `gorm:"column:jersey_number" json:"jerseyNumber"`
APILastSyncedAt *time.Time `gorm:"column:api_last_synced_at" json:"apiLastSyncedAt"`
APISyncStatus string `gorm:"column:api_sync_status;type:api_sync_status;default:pending" json:"apiSyncStatus"`
IsActive bool `gorm:"column:is_active;default:true" json:"isActive"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deletedAt"`
}
func (p *Player) BeforeCreate(tx *gorm.DB) (err error) {
if p.ID != "" {
return nil
}
id, err := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 15)
if err != nil {
return err
}
p.ID = id
return nil
}
type Match struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
WyID *int `gorm:"column:wy_id;uniqueIndex" json:"wyId"`
TsID string `gorm:"column:ts_id;size:64;uniqueIndex" json:"tsId"`
HomeTeamWyID *int `gorm:"column:home_team_wy_id" json:"homeTeamWyId"`
HomeTeamTsID *string `gorm:"column:home_team_ts_id;size:64" json:"homeTeamTsId"`
AwayTeamWyID *int `gorm:"column:away_team_wy_id" json:"awayTeamWyId"`
AwayTeamTsID *string `gorm:"column:away_team_ts_id;size:64" json:"awayTeamTsId"`
CompetitionWyID *int `gorm:"column:competition_wy_id" json:"competitionWyId"`
SeasonWyID *int `gorm:"column:season_wy_id" json:"seasonWyId"`
RoundWyID *int `gorm:"column:round_wy_id" json:"roundWyId"`
MatchDate time.Time `gorm:"column:match_date" json:"matchDate"`
Venue *string `gorm:"column:venue" json:"venue"`
VenueCity *string `gorm:"column:venue_city" json:"venueCity"`
VenueCountry *string `gorm:"column:venue_country" json:"venueCountry"`
MatchType string `gorm:"column:match_type;type:match_type;default:regular" json:"matchType"`
Status string `gorm:"column:status;type:match_status;default:scheduled" json:"status"`
HomeScore int `gorm:"column:home_score;default:0" json:"homeScore"`
AwayScore int `gorm:"column:away_score;default:0" json:"awayScore"`
HomeScorePenalties int `gorm:"column:home_score_penalties;default:0" json:"homeScorePenalties"`
AwayScorePenalties int `gorm:"column:away_score_penalties;default:0" json:"awayScorePenalties"`
Attendance *int `gorm:"column:attendance" json:"attendance"`
MainRefereeWyID *int `gorm:"column:main_referee_wy_id" json:"mainRefereeWyId"`
AssistantReferee1WyID *int `gorm:"column:assistant_referee_1_wy_id" json:"assistantReferee1WyId"`
AssistantReferee2WyID *int `gorm:"column:assistant_referee_2_wy_id" json:"assistantReferee2WyId"`
FourthRefereeWyID *int `gorm:"column:fourth_referee_wy_id" json:"fourthRefereeWyId"`
VarRefereeWyID *int `gorm:"column:var_referee_wy_id" json:"varRefereeWyId"`
Weather *string `gorm:"column:weather" json:"weather"`
Temperature *float64 `gorm:"column:temperature" json:"temperature"`
APILastSyncedAt *time.Time `gorm:"column:api_last_synced_at" json:"apiLastSyncedAt"`
APISyncStatus string `gorm:"column:api_sync_status;type:api_sync_status;default:pending" json:"apiSyncStatus"`
Notes *string `gorm:"column:notes" json:"notes"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deletedAt"`
}
func (m *Match) BeforeCreate(tx *gorm.DB) (err error) {
if m.ID != "" {
return nil
}
id, err := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 15)
if err != nil {
return err
}
m.ID = id
return nil
}
type MatchTeam struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
MatchTsID string `gorm:"column:match_ts_id;size:64;index" json:"matchTsId"`
TeamWyID *int `gorm:"column:team_wy_id" json:"teamWyId"`
TeamTsID *string `gorm:"column:team_ts_id;size:64" json:"teamTsId"`
Side string `gorm:"column:side" json:"side"`
Formation *string `gorm:"column:formation" json:"formation"`
CoachWyID *int `gorm:"column:coach_wy_id" json:"coachWyId"`
CoachTsID *string `gorm:"column:coach_ts_id;size:64" json:"coachTsId"`
CoverageLineup *bool `gorm:"column:coverage_lineup" json:"coverageLineup"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (mt *MatchTeam) BeforeCreate(tx *gorm.DB) (err error) {
if mt.ID != "" {
return nil
}
id, err := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 15)
if err != nil {
return err
}
mt.ID = id
return nil
}
type MatchLineupPlayer struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
MatchTsID string `gorm:"column:match_ts_id;size:64;index" json:"matchTsId"`
TeamWyID *int `gorm:"column:team_wy_id" json:"teamWyId"`
TeamTsID *string `gorm:"column:team_ts_id;size:64" json:"teamTsId"`
PlayerWyID *int `gorm:"column:player_wy_id" json:"playerWyId"`
PlayerTsID *string `gorm:"column:player_ts_id;size:64" json:"playerTsId"`
ShirtNumber *int `gorm:"column:shirt_number" json:"shirtNumber"`
IsStarter bool `gorm:"column:is_starter;default:false" json:"isStarter"`
Position *string `gorm:"column:position" json:"position"`
PosX *float64 `gorm:"column:pos_x" json:"posX"`
PosY *float64 `gorm:"column:pos_y" json:"posY"`
MinuteIn *int `gorm:"column:minute_in" json:"minuteIn"`
MinuteOut *int `gorm:"column:minute_out" json:"minuteOut"`
SubOrder *int `gorm:"column:sub_order" json:"subOrder"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (ml *MatchLineupPlayer) BeforeCreate(tx *gorm.DB) (err error) {
if ml.ID != "" {
return nil
}
id, err := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 15)
if err != nil {
return err
}
ml.ID = id
return nil
}
type PlayerTransfer struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
WyID int `gorm:"column:wy_id;uniqueIndex" json:"wyId"`
TsID int `gorm:"column:ts_id;uniqueIndex" json:"tsId"`
PlayerWyID *int `gorm:"column:player_wy_id" json:"playerWyId"`
PlayerTsID *int `gorm:"column:player_ts_id" json:"playerTsId"`
FromTeamWyID *int `gorm:"column:from_team_wy_id" json:"fromTeamWyId"`
FromTeamTsID *int `gorm:"column:from_team_ts_id" json:"fromTeamTsId"`
ToTeamWyID *int `gorm:"column:to_team_wy_id" json:"toTeamWyId"`
ToTeamTsID *int `gorm:"column:to_team_ts_id" json:"toTeamTsId"`
FromTeamName *string `gorm:"column:from_team_name" json:"fromTeamName"`
ToTeamName *string `gorm:"column:to_team_name" json:"toTeamName"`
TransferDate *time.Time `gorm:"column:transfer_date" json:"transferDate"`
EndDate *time.Time `gorm:"column:end_date" json:"endDate"`
TransferType *string `gorm:"column:transfer_type" json:"transferType"`
TransferFee *float64 `gorm:"column:transfer_fee" json:"transferFee"`
Currency string `gorm:"column:currency;default:EUR" json:"currency"`
ContractLength *int `gorm:"column:contract_length" json:"contractLength"`
Season *string `gorm:"column:season" json:"season"`
IsActive bool `gorm:"column:is_active;default:false" json:"isActive"`
IsLoan bool `gorm:"column:is_loan;default:false" json:"isLoan"`
LoanDuration *int `gorm:"column:loan_duration" json:"loanDuration"`
HasOptionToBuy bool `gorm:"column:has_option_to_buy;default:false" json:"hasOptionToBuy"`
OptionToBuyFee *float64 `gorm:"column:option_to_buy_fee" json:"optionToBuyFee"`
AnnouncementDate *time.Time `gorm:"column:announcement_date" json:"announcementDate"`
SourceURL *string `gorm:"column:source_url" json:"sourceUrl"`
APILastSyncedAt *time.Time `gorm:"column:api_last_synced_at" json:"apiLastSyncedAt"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
type TeamSquad struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
TeamWyID *int `gorm:"column:team_wy_id" json:"teamWyId"`
TeamTsID *int `gorm:"column:team_ts_id" json:"teamTsId"`
PlayerWyID *int `gorm:"column:player_wy_id" json:"playerWyId"`
PlayerTsID *int `gorm:"column:player_ts_id" json:"playerTsId"`
PlayerWyIDExternal int `gorm:"column:player_wy_id_external" json:"playerWyIdExternal"`
IsActive bool `gorm:"column:is_active;default:true" json:"isActive"`
APILastSyncedAt *time.Time `gorm:"column:api_last_synced_at" json:"apiLastSyncedAt"`
APISyncStatus string `gorm:"column:api_sync_status;type:api_sync_status;default:pending" json:"apiSyncStatus"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
type Standing struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
CompetitionWyID *int `gorm:"column:competition_wy_id" json:"competitionWyId"`
CompetitionTsID *int `gorm:"column:competition_ts_id" json:"competitionTsId"`
SeasonWyID *int `gorm:"column:season_wy_id" json:"seasonWyId"`
SeasonTsID *int `gorm:"column:season_ts_id" json:"seasonTsId"`
TeamWyID *int `gorm:"column:team_wy_id" json:"teamWyId"`
TeamTsID *int `gorm:"column:team_ts_id" json:"teamTsId"`
GroupName string `gorm:"column:group_name;default:Regular Season" json:"groupName"`
Position *int `gorm:"column:position" json:"position"`
TotalPoints int `gorm:"column:total_points;default:0" json:"totalPoints"`
TotalPlayed int `gorm:"column:total_played;default:0" json:"totalPlayed"`
TotalWins int `gorm:"column:total_wins;default:0" json:"totalWins"`
TotalDraws int `gorm:"column:total_draws;default:0" json:"totalDraws"`
TotalLosses int `gorm:"column:total_losses;default:0" json:"totalLosses"`
TotalGoalsFor int `gorm:"column:total_goals_for;default:0" json:"totalGoalsFor"`
TotalGoalsAgainst int `gorm:"column:total_goals_against;default:0" json:"totalGoalsAgainst"`
GoalDifference int `gorm:"column:goal_difference;default:0" json:"goalDifference"`
PointsPerGame float64 `gorm:"column:points_per_game" json:"pointsPerGame"`
APILastSyncedAt *time.Time `gorm:"column:api_last_synced_at" json:"apiLastSyncedAt"`
APISyncStatus string `gorm:"column:api_sync_status;type:api_sync_status;default:pending" json:"apiSyncStatus"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deletedAt"`
}
// Round mirrors the Nest/Drizzle `rounds` table.
type Round struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
WyID int `gorm:"column:wy_id;uniqueIndex" json:"wyId"`
TsID *int `gorm:"column:ts_id" json:"tsId"`
CompetitionWyID *int `gorm:"column:competition_wy_id" json:"competitionWyId"`
CompetitionTsID *int `gorm:"column:competition_ts_id" json:"competitionTsId"`
SeasonWyID *int `gorm:"column:season_wy_id" json:"seasonWyId"`
SeasonTsID *int `gorm:"column:season_ts_id" json:"seasonTsId"`
Name string `gorm:"column:name" json:"name"`
RoundType string `gorm:"column:round_type;type:round_type;default:regular" json:"roundType"`
RoundNumber *int `gorm:"column:round_number" json:"roundNumber"`
StartDate *time.Time `gorm:"column:start_date" json:"startDate"`
EndDate *time.Time `gorm:"column:end_date" json:"endDate"`
IsActive bool `gorm:"column:is_active;default:true" json:"isActive"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deletedAt"`
}
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