Commit a2aa984d by Augusto

cronjobs

parent e1242bc3
......@@ -30,3 +30,7 @@
# Docker
**/.docker/
DEPLOYMENT_SYNC.md
README_SYNC.md
.gitignore
migrations/RESTORE_INSTRUCTIONS.md
# Sync System - Quick Start
## What It Does
✅ Runs **1-2 times per day** (configurable)
✅ Checks Wyscout API for **updated objects** (players, teams, matches, etc.)
✅ Updates your database with **only the changed data**
✅ Runs as a **separate service** from your API server
## Setup (One Time)
```bash
# 1. Run database migration
psql -U elitedata -d postgres -f migrations/0024_create_sync_logs_table.sql
# 2. Verify your .env has Wyscout credentials
# ProviderUser=your_username
# ProviderSecret=your_password
# 3. Test it works
go run cmd/sync-daemon/main.go -once
```
## Running the Daemon
### Option A: Simple (Development)
```bash
# Run 2x per day (every 12 hours)
go run cmd/sync-daemon/main.go
# Or use the script
chmod +x scripts/start_sync_daemon.sh
./scripts/start_sync_daemon.sh
# View logs
tail -f logs/sync-daemon.log
# Stop
./scripts/stop_sync_daemon.sh
```
### Option B: Docker (Production)
```bash
# Start
docker-compose -f docker-compose.sync.yml up -d
# Logs
docker-compose -f docker-compose.sync.yml logs -f sync-daemon
# Stop
docker-compose -f docker-compose.sync.yml down
```
### Option C: PM2 (Production)
```bash
# Build
go build -o bin/sync-daemon cmd/sync-daemon/main.go
# Start
pm2 start ecosystem.sync.config.js
# Monitor
pm2 logs wyscout-sync-daemon
```
## Configuration
**Default Schedule: 2x per day (every 12 hours)**
Change in `.env`:
```env
SYNC_INTERVAL=12 # Hours between syncs
LOOKBACK_HOURS=24 # How far back to check
```
Or via command line:
```bash
# 1x per day (every 24 hours)
go run cmd/sync-daemon/main.go -interval=24 -lookback=48
# 4x per day (every 6 hours)
go run cmd/sync-daemon/main.go -interval=6 -lookback=12
```
## Separate Service? YES!
The sync daemon runs **independently** from your API server:
```
┌─────────────────┐ ┌─────────────────┐
│ API Server │ │ Sync Daemon │
│ Port 8080 │ │ (Background) │
│ │ │ │
│ - Handle API │ │ - Auto sync │
│ requests │ │ every 12hrs │
│ - Manual syncs │ │ - No HTTP port │
└────────┬────────┘ └────────┬────────┘
│ │
└───────────┬───────────────┘
┌──────▼──────┐
│ PostgreSQL │
└─────────────┘
```
**Benefits:**
- ✅ API server can restart without affecting syncs
- ✅ Sync daemon can restart without affecting API
- ✅ Better resource isolation
- ✅ Easier monitoring
## Monitoring
```bash
# Check what was synced
go run cmd/sync/main.go -logs
# Check last sync times
go run cmd/sync/main.go -status
# Via API
curl "http://localhost:8080/api/sync/logs?limit=10"
```
## Typical Output
```
========================================
Wyscout Sync Daemon
========================================
Sync interval: Every 12 hours
Lookback period: 24 hours
========================================
Running initial sync on startup...
Syncing updates since: 2026-02-02 11:50:00
→ Syncing players...
✓ players: 150 processed, 2 failed (status: completed)
→ Syncing teams...
✓ teams: 45 processed, 0 failed (status: completed)
→ Syncing matches...
✓ matches: 200 processed, 5 failed (status: partial)
...
========================================
Sync Summary: 1,245 processed, 12 failed
========================================
Daemon started. Next sync in 12 hours...
```
## Files Created
| File | Purpose |
|------|---------|
| `cmd/sync-daemon/main.go` | Standalone sync daemon |
| `cmd/sync/main.go` | CLI for manual syncs |
| `scripts/start_sync_daemon.sh` | Start script |
| `scripts/stop_sync_daemon.sh` | Stop script |
| `docker-compose.sync.yml` | Docker deployment |
| `Dockerfile.sync` | Docker image |
| `ecosystem.sync.config.js` | PM2 config |
## Recommended Setup
**For Production:**
1. Run sync daemon via Docker or PM2
2. Schedule: Every 12 hours (2x per day)
3. Lookback: 24 hours
4. Monitor logs daily
**For Development:**
1. Run sync daemon with simple script
2. Schedule: Every 24 hours (1x per day)
3. Test with manual syncs first
## Need Help?
- Full docs: `docs/SYNC_SYSTEM.md`
- Deployment guide: `DEPLOYMENT_SYNC.md`
- Manual sync: `README_SYNC.md`
package main
import (
"ScoutingSystemScoreData/internal/config"
"ScoutingSystemScoreData/internal/database"
"ScoutingSystemScoreData/internal/services"
"flag"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/joho/godotenv"
)
func main() {
// Load environment variables
if err := godotenv.Load(); err != nil {
log.Println("No .env file found, using environment variables")
}
// Parse command line flags
syncInterval := flag.Int("interval", 12, "Sync interval in hours (default: 12 hours = 2x per day)")
lookbackHours := flag.Int("lookback", 24, "Hours to look back for updates (default: 24)")
runOnce := flag.Bool("once", false, "Run sync once and exit")
flag.Parse()
log.Println("========================================")
log.Println("Wyscout Sync Daemon")
log.Println("========================================")
log.Printf("Sync interval: Every %d hours", *syncInterval)
log.Printf("Lookback period: %d hours", *lookbackHours)
log.Println("========================================")
// Load config
cfg := config.Load()
// Initialize database
db, err := database.Connect(cfg)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
// Initialize sync service
syncService := services.NewSyncService(db, cfg)
// Run once mode
if *runOnce {
log.Println("Running sync once...")
runSync(syncService, *lookbackHours)
log.Println("Sync completed. Exiting.")
return
}
// Run initial sync on startup
log.Println("Running initial sync on startup...")
runSync(syncService, *lookbackHours)
// Setup signal handling for graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
// Start ticker for periodic syncs
ticker := time.NewTicker(time.Duration(*syncInterval) * time.Hour)
defer ticker.Stop()
log.Printf("Daemon started. Next sync in %d hours...", *syncInterval)
for {
select {
case <-ticker.C:
log.Println("Starting scheduled sync...")
runSync(syncService, *lookbackHours)
log.Printf("Sync completed. Next sync in %d hours...", *syncInterval)
case sig := <-sigChan:
log.Printf("Received signal: %v", sig)
log.Println("Shutting down gracefully...")
return
}
}
}
func runSync(syncService *services.SyncService, lookbackHours int) {
updatedSince := time.Now().Add(-time.Duration(lookbackHours) * time.Hour)
resourceTypes := []string{
"players",
"teams",
"matches",
"coaches",
"referees",
"competitions",
"seasons",
"areas",
}
log.Printf("Syncing updates since: %s", updatedSince.Format("2006-01-02 15:04:05"))
totalProcessed := 0
totalFailed := 0
for _, resourceType := range resourceTypes {
log.Printf("→ Syncing %s...", resourceType)
syncLog, err := syncService.SyncResourceType(resourceType, updatedSince, "scheduled")
if err != nil {
log.Printf(" ✗ Error syncing %s: %v", resourceType, err)
continue
}
log.Printf(" ✓ %s: %d processed, %d failed (status: %s)",
resourceType,
syncLog.ProcessedRecords,
syncLog.FailedRecords,
syncLog.Status,
)
totalProcessed += syncLog.ProcessedRecords
totalFailed += syncLog.FailedRecords
}
log.Println("========================================")
log.Printf("Sync Summary: %d processed, %d failed", totalProcessed, totalFailed)
log.Println("========================================")
}
package main
import (
"ScoutingSystemScoreData/internal/config"
"ScoutingSystemScoreData/internal/database"
"ScoutingSystemScoreData/internal/services"
"flag"
"fmt"
"log"
"os"
"time"
"github.com/joho/godotenv"
)
func main() {
// Load environment variables
if err := godotenv.Load(); err != nil {
log.Println("No .env file found, using environment variables")
}
// Parse command line flags
resourceType := flag.String("resource", "", "Resource type to sync (players, teams, matches, etc.)")
hoursBack := flag.Int("hours", 24, "Hours to look back (max 168)")
syncAll := flag.Bool("all", false, "Sync all resource types")
showStatus := flag.Bool("status", false, "Show sync status")
showLogs := flag.Bool("logs", false, "Show recent sync logs")
flag.Parse()
// Load config
cfg := config.Load()
// Initialize database
db, err := database.Connect(cfg)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
// Initialize sync service
syncService := services.NewSyncService(db, cfg)
// Execute command based on flags
if *showStatus {
showSyncStatus(syncService)
return
}
if *showLogs {
showSyncLogs(syncService)
return
}
if *syncAll {
syncAllResources(syncService, *hoursBack)
return
}
if *resourceType != "" {
syncResource(syncService, *resourceType, *hoursBack)
return
}
// No valid flags provided
flag.Usage()
os.Exit(1)
}
func syncResource(syncService *services.SyncService, resourceType string, hoursBack int) {
if hoursBack > 168 {
log.Fatal("Cannot go back more than 168 hours (API limitation)")
}
updatedSince := time.Now().Add(-time.Duration(hoursBack) * time.Hour)
log.Printf("Starting sync for %s (last %d hours)...", resourceType, hoursBack)
log.Printf("Updated since: %s", updatedSince.Format("2006-01-02 15:04:05"))
syncLog, err := syncService.SyncResourceType(resourceType, updatedSince, "manual")
if err != nil {
log.Fatalf("Sync failed: %v", err)
}
fmt.Println("\n=== Sync Results ===")
fmt.Printf("Resource Type: %s\n", syncLog.ResourceType)
fmt.Printf("Status: %s\n", syncLog.Status)
fmt.Printf("Total Records: %d\n", syncLog.TotalRecords)
fmt.Printf("Processed: %d\n", syncLog.ProcessedRecords)
fmt.Printf("Failed: %d\n", syncLog.FailedRecords)
fmt.Printf("Started: %s\n", syncLog.StartedAt.Format(time.RFC3339))
if syncLog.CompletedAt != nil {
fmt.Printf("Completed: %s\n", syncLog.CompletedAt.Format(time.RFC3339))
duration := syncLog.CompletedAt.Sub(syncLog.StartedAt)
fmt.Printf("Duration: %s\n", duration.String())
}
if syncLog.ErrorMessage != nil {
fmt.Printf("Error: %s\n", *syncLog.ErrorMessage)
}
}
func syncAllResources(syncService *services.SyncService, hoursBack int) {
if hoursBack > 168 {
log.Fatal("Cannot go back more than 168 hours (API limitation)")
}
updatedSince := time.Now().Add(-time.Duration(hoursBack) * time.Hour)
log.Printf("Starting sync for all resources (last %d hours)...", hoursBack)
log.Printf("Updated since: %s", updatedSince.Format("2006-01-02 15:04:05"))
if err := syncService.SyncAllResources(updatedSince); err != nil {
log.Fatalf("Sync failed: %v", err)
}
fmt.Println("\n=== All Resources Synced ===")
fmt.Println("Check sync logs for detailed results")
}
func showSyncStatus(syncService *services.SyncService) {
resourceTypes := []string{
"players", "teams", "matches", "coaches",
"referees", "competitions", "seasons", "areas",
}
fmt.Println("\n=== Sync Status ===")
for _, resourceType := range resourceTypes {
lastSync, err := syncService.GetLastSyncTime(resourceType)
if err != nil {
fmt.Printf("%s: Error - %v\n", resourceType, err)
continue
}
hoursAgo := int(time.Since(lastSync).Hours())
fmt.Printf("%s: Last synced %d hours ago (%s)\n",
resourceType, hoursAgo, lastSync.Format("2006-01-02 15:04:05"))
}
}
func showSyncLogs(syncService *services.SyncService) {
var logs []struct {
ResourceType string
Status string
ProcessedRecords int
FailedRecords int
StartedAt time.Time
CompletedAt *time.Time
}
db := syncService.GetDB()
if err := db.Table("sync_logs").
Select("resource_type, status, processed_records, failed_records, started_at, completed_at").
Order("started_at DESC").
Limit(20).
Find(&logs).Error; err != nil {
log.Fatalf("Failed to fetch sync logs: %v", err)
}
fmt.Println("\n=== Recent Sync Logs ===")
fmt.Printf("%-15s %-10s %-10s %-8s %-20s\n", "Resource", "Status", "Processed", "Failed", "Started")
fmt.Println("--------------------------------------------------------------------------------")
for _, log := range logs {
fmt.Printf("%-15s %-10s %-10d %-8d %-20s\n",
log.ResourceType,
log.Status,
log.ProcessedRecords,
log.FailedRecords,
log.StartedAt.Format("2006-01-02 15:04"),
)
}
}
-- ============================================
-- Database Index Diagnostic Query
-- Run this on your production server to verify all indexes exist
-- ============================================
-- 1. Check all indexes on players table
SELECT
schemaname,
tablename,
indexname,
indexdef
FROM pg_indexes
WHERE tablename = 'players'
ORDER BY indexname;
-- 2. Check all indexes on teams table
SELECT
schemaname,
tablename,
indexname,
indexdef
FROM pg_indexes
WHERE tablename = 'teams'
ORDER BY indexname;
-- 3. Check trigram extension is installed
SELECT * FROM pg_extension WHERE extname = 'pg_trgm';
-- 4. Check unaccent extension is installed
SELECT * FROM pg_extension WHERE extname = 'unaccent';
-- 5. Verify specific critical indexes exist
SELECT
CASE
WHEN EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_players_short_name_trgm')
THEN '✓ FOUND'
ELSE '✗ MISSING'
END as idx_players_short_name_trgm,
CASE
WHEN EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_players_first_name_trgm')
THEN '✓ FOUND'
ELSE '✗ MISSING'
END as idx_players_first_name_trgm,
CASE
WHEN EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_players_last_name_trgm')
THEN '✓ FOUND'
ELSE '✗ MISSING'
END as idx_players_last_name_trgm,
CASE
WHEN EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_teams_name_trgm')
THEN '✓ FOUND'
ELSE '✗ MISSING'
END as idx_teams_name_trgm,
CASE
WHEN EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_players_current_team_id')
THEN '✓ FOUND'
ELSE '✗ MISSING'
END as idx_players_current_team_id,
CASE
WHEN EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_teams_wy_id')
THEN '✓ FOUND'
ELSE '✗ MISSING'
END as idx_teams_wy_id;
-- 6. Check table statistics (last analyzed)
SELECT
schemaname,
relname as tablename,
last_analyze,
last_autoanalyze,
n_live_tup as row_count
FROM pg_stat_user_tables
WHERE relname IN ('players', 'teams')
ORDER BY relname;
-- 7. Test query performance with EXPLAIN ANALYZE
EXPLAIN ANALYZE
SELECT players.*
FROM players
LEFT JOIN teams ON teams.wy_id = players.current_team_id
WHERE players.is_active = true
AND players.gender = 'male'
AND (
LOWER(players.short_name) ILIKE '%rafa silva%' OR
LOWER(players.first_name) ILIKE '%rafa silva%' OR
LOWER(players.last_name) ILIKE '%rafa silva%' OR
LOWER(teams.name) ILIKE '%rafa silva%' OR
LOWER(teams.short_name) ILIKE '%rafa silva%'
)
ORDER BY
CASE WHEN LOWER(players.short_name) = 'rafa silva' THEN 0
WHEN LOWER(players.last_name) = 'rafa silva' THEN 1
WHEN LOWER(players.first_name) = 'rafa silva' THEN 2
WHEN LOWER(players.short_name) ILIKE '%rafa silva%' THEN 3
WHEN LOWER(players.last_name) ILIKE '%rafa silva%' THEN 4
WHEN LOWER(players.first_name) ILIKE '%rafa silva%' THEN 5
ELSE 6 END,
CASE WHEN teams.category = 'default' THEN 0
WHEN teams.category = 'youth' THEN 1
ELSE 2 END,
CASE WHEN teams.area_wy_id IN (724, 380, 616, 276, 250) THEN 0
WHEN teams.area_wy_id IN (620, 337, 76, 11, 22) THEN 1
WHEN teams.area_wy_id IN (792, 40, 203, 688, 756, 208, 300, 752, 578) THEN 2
WHEN teams.area_wy_id IS NOT NULL THEN 99
ELSE 100 END,
CASE WHEN players.current_national_team_id IS NOT NULL THEN 0 ELSE 1 END
LIMIT 100;
# Wyscout API Sync System
## Overview
This system provides automated and manual synchronization of data from the Wyscout API using their `updatedobjects` endpoint. It tracks which objects have been updated and syncs them incrementally to keep your database up-to-date.
## Features
- **Incremental Sync**: Only fetch objects that have been updated since the last sync
- **Multiple Resource Types**: Support for players, teams, matches, coaches, referees, competitions, seasons, and areas
- **Cron Scheduling**: Automated hourly syncs
- **Manual Triggers**: CLI and API endpoints for on-demand syncs
- **Sync Logging**: Complete audit trail of all sync operations
- **Error Handling**: Graceful error handling with partial sync support
## API Endpoints
### Trigger Manual Sync
```bash
POST /api/sync/trigger?resource_type=players&hours_back=24
```
**Parameters:**
- `resource_type` (required): Resource type to sync (players, teams, matches, coaches, referees, competitions, seasons, areas)
- `hours_back` (optional): Hours to look back (1-168, default: 24)
**Response:**
```json
{
"id": "abc123xyz456789",
"syncType": "manual",
"resourceType": "players",
"startedAt": "2026-02-03T10:00:00Z",
"completedAt": "2026-02-03T10:05:00Z",
"status": "completed",
"totalRecords": 150,
"processedRecords": 148,
"failedRecords": 2,
"syncParams": {
"updated_since": "2026-02-02 10:00:00",
"type": "players",
"limit": 100,
"page": 1
}
}
```
### Trigger Full Sync (All Resources)
```bash
POST /api/sync/trigger-all?hours_back=24
```
**Parameters:**
- `hours_back` (optional): Hours to look back (1-168, default: 24)
### Get Sync Logs
```bash
GET /api/sync/logs?resource_type=players&status=completed&limit=50
```
**Parameters:**
- `resource_type` (optional): Filter by resource type
- `status` (optional): Filter by status (running, completed, failed, partial)
- `limit` (optional): Limit results (default: 50)
### Get Sync Status
```bash
GET /api/sync/status
```
Returns the current status of the sync service.
### Get Last Sync Time
```bash
GET /api/sync/last-sync?resource_type=players
```
**Parameters:**
- `resource_type` (required): Resource type to check
**Response:**
```json
{
"resource_type": "players",
"last_sync": "2026-02-03T09:00:00Z",
"hours_ago": 1
}
```
## CLI Commands
### Sync a Specific Resource
```bash
go run cmd/sync/main.go -resource=players -hours=24
```
### Sync All Resources
```bash
go run cmd/sync/main.go -all -hours=24
```
### Show Sync Status
```bash
go run cmd/sync/main.go -status
```
Output:
```
=== Sync Status ===
players: Last synced 2 hours ago (2026-02-03 08:00:00)
teams: Last synced 2 hours ago (2026-02-03 08:00:00)
matches: Last synced 2 hours ago (2026-02-03 08:00:00)
...
```
### Show Recent Sync Logs
```bash
go run cmd/sync/main.go -logs
```
Output:
```
=== Recent Sync Logs ===
Resource Status Processed Failed Started
--------------------------------------------------------------------------------
players completed 150 2 2026-02-03 10:00
teams completed 45 0 2026-02-03 09:00
matches partial 200 15 2026-02-03 08:00
```
## Cron Service
The cron service runs automatically when the main server starts and performs hourly incremental syncs.
### Starting the Cron Service
The cron service is integrated into the main server. To enable it, add this to your `cmd/server/main.go`:
```go
import (
"ScoutingSystemScoreData/internal/services"
)
func main() {
// ... existing setup ...
// Start cron service
cronService := services.NewCronService(db, cfg)
go cronService.Start()
// ... rest of server setup ...
}
```
### Cron Schedule
- **Hourly**: Syncs objects updated in the last hour
- **Configurable**: Can be adjusted in `internal/services/cron_service.go`
## Resource Types
The following resource types are supported:
| Resource Type | Description | API Sync Fields |
|--------------|-------------|-----------------|
| `players` | Player data | `api_last_synced_at`, `api_sync_status` |
| `teams` | Team data | `api_last_synced_at`, `api_sync_status` |
| `matches` | Match data | `api_last_synced_at`, `api_sync_status` |
| `coaches` | Coach data | `api_last_synced_at`, `api_sync_status` |
| `referees` | Referee data | `api_last_synced_at`, `api_sync_status` |
| `competitions` | Competition data | `updated_at` |
| `seasons` | Season data | `updated_at` |
| `areas` | Area/country data | `updated_at` |
## Sync Statuses
- **running**: Sync is currently in progress
- **completed**: Sync completed successfully
- **failed**: Sync failed completely
- **partial**: Sync completed with some failures
## API Limitations
- **Max Lookback**: 168 hours (7 days)
- **Rate Limiting**: Respect Wyscout API rate limits
- **Pagination**: 100 records per page (configurable)
## Database Schema
### sync_logs Table
```sql
CREATE TABLE sync_logs (
id VARCHAR(16) PRIMARY KEY,
sync_type VARCHAR(50) NOT NULL, -- incremental, full, manual
resource_type VARCHAR(50) NOT NULL, -- players, teams, etc.
started_at TIMESTAMP WITH TIME ZONE NOT NULL,
completed_at TIMESTAMP WITH TIME ZONE,
status VARCHAR(20) NOT NULL DEFAULT 'running',
total_records INTEGER DEFAULT 0,
processed_records INTEGER DEFAULT 0,
failed_records INTEGER DEFAULT 0,
error_message TEXT,
sync_params JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
```
## Configuration
Add these environment variables to your `.env` file:
```env
# Wyscout API Credentials (already configured)
ProviderUser=your_username
ProviderSecret=your_password
# Optional: Sync Configuration
SYNC_ENABLED=true
SYNC_INTERVAL_HOURS=1
SYNC_DEFAULT_LOOKBACK_HOURS=24
```
## Examples
### Daily Sync (Last 24 Hours)
```bash
# Via CLI
go run cmd/sync/main.go -all -hours=24
# Via API
curl -X POST "http://localhost:8080/api/sync/trigger-all?hours_back=24" \
-H "Authorization: Basic <your_credentials>"
```
### Weekly Sync (Max Allowed)
```bash
# Via CLI
go run cmd/sync/main.go -all -hours=168
# Via API
curl -X POST "http://localhost:8080/api/sync/trigger-all?hours_back=168" \
-H "Authorization: Basic <your_credentials>"
```
### Sync Only Players Updated Today
```bash
# Via CLI
go run cmd/sync/main.go -resource=players -hours=24
# Via API
curl -X POST "http://localhost:8080/api/sync/trigger?resource_type=players&hours_back=24" \
-H "Authorization: Basic <your_credentials>"
```
### Check What Needs Syncing
```bash
# Via CLI
go run cmd/sync/main.go -status
# Via API
curl "http://localhost:8080/api/sync/last-sync?resource_type=players" \
-H "Authorization: Basic <your_credentials>"
```
## Monitoring
### Check Recent Sync Activity
```bash
curl "http://localhost:8080/api/sync/logs?limit=20" \
-H "Authorization: Basic <your_credentials>"
```
### Check Failed Syncs
```bash
curl "http://localhost:8080/api/sync/logs?status=failed" \
-H "Authorization: Basic <your_credentials>"
```
### Monitor Specific Resource
```bash
curl "http://localhost:8080/api/sync/logs?resource_type=players&limit=10" \
-H "Authorization: Basic <your_credentials>"
```
## Troubleshooting
### Sync Fails with "API returned status 401"
Check your Wyscout API credentials in the `.env` file:
```env
ProviderUser=your_username
ProviderSecret=your_password
```
### Sync Fails with "cannot go back more than 168 hours"
The Wyscout API only allows looking back 7 days (168 hours). Reduce the `hours_back` parameter.
### Objects Not Found in Database
If the sync reports "needs full fetch", the object doesn't exist in your database yet. You need to run the initial import first using the import endpoints.
### Partial Sync Status
Some records failed to sync. Check the `error_message` field in the sync logs for details.
## Best Practices
1. **Initial Import**: Run a full import first before enabling incremental syncs
2. **Regular Syncs**: Run hourly syncs to stay up-to-date
3. **Monitor Logs**: Regularly check sync logs for failures
4. **Handle Failures**: Investigate and retry failed syncs
5. **Rate Limiting**: Don't run syncs too frequently to avoid API rate limits
6. **Database Backups**: Backup before running large syncs
## Integration with Existing System
The sync system integrates seamlessly with your existing import system:
1. **Initial Import**: Use existing import handlers for bulk data
2. **Incremental Updates**: Use sync system for ongoing updates
3. **Conflict Resolution**: Newer data overwrites older data based on timestamps
## Future Enhancements
- [ ] Full object fetch for missing records
- [ ] Retry mechanism for failed syncs
- [ ] Webhook support for real-time updates
- [ ] Sync scheduling UI
- [ ] Performance metrics and analytics
- [ ] Configurable sync intervals per resource type
......@@ -623,6 +623,14 @@ const docTemplate = `{
"Import"
],
"summary": "Import areas from TheSports",
"parameters": [
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
......@@ -678,6 +686,18 @@ const docTemplate = `{
"description": "Only discover coach from a specific team (only used when source=teams)",
"name": "teamWyId",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -728,6 +748,18 @@ const docTemplate = `{
"description": "Unix timestamp (seconds) to import only competitions updated since this time",
"name": "since",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -814,7 +846,7 @@ const docTemplate = `{
},
"/import/matches/fixtures": {
"post": {
"description": "Imports matches for a given Wyscout season via /v3/seasons/{seasonWyId}/fixtures?details=matches. If seasonWyId is omitted, imports for all seasons in the DB with a wy_id.",
"description": "Imports matches for a given Wyscout season via /v3/seasons/{seasonWyId}/fixtures?details=matches. If seasonWyId is omitted, imports for all seasons in the DB with a wy_id. Supports incremental sync to update matches from last sync date to today.",
"tags": [
"Import"
],
......@@ -831,6 +863,24 @@ const docTemplate = `{
"description": "Limit number of seasons processed when seasonWyId is omitted",
"name": "limitSeasons",
"in": "query"
},
{
"type": "boolean",
"description": "If true, only sync seasons with matches between api_last_synced_at and today based on match_date",
"name": "syncRecent",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -926,6 +976,18 @@ const docTemplate = `{
"name": "seasonWyId",
"in": "query",
"required": true
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -976,6 +1038,18 @@ const docTemplate = `{
"description": "Unix timestamp (seconds) to import only players updated since this time",
"name": "since",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -1091,6 +1165,18 @@ const docTemplate = `{
"description": "Process only one Wyscout player wy_id",
"name": "playerWyId",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 11)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -1141,6 +1227,18 @@ const docTemplate = `{
"description": "Process only one Wyscout player wy_id",
"name": "playerWyId",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 11)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -1207,6 +1305,18 @@ const docTemplate = `{
"description": "Unix timestamp (seconds) to import only referees updated since this time (theSports mode)",
"name": "since",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -1263,6 +1373,18 @@ const docTemplate = `{
"description": "DB upsert batch size (default 500)",
"name": "batchSize",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -1300,7 +1422,6 @@ const docTemplate = `{
"tags": [
"Import"
],
"summary": "Import seasons from TheSports",
"parameters": [
{
"type": "integer",
......@@ -1313,6 +1434,24 @@ const docTemplate = `{
"description": "Unix timestamp (seconds) to import only seasons updated since this time",
"name": "since",
"in": "query"
},
{
"type": "integer",
"description": "Limit number of competitions processed",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -1354,6 +1493,18 @@ const docTemplate = `{
"description": "Limit number of seasons processed when seasonWyId is omitted",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -1387,7 +1538,7 @@ const docTemplate = `{
},
"/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.",
"description": "Performs a team import using TheSports team 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"
],
......@@ -1404,6 +1555,18 @@ const docTemplate = `{
"description": "Unix timestamp (seconds) to import only teams updated since this time",
"name": "since",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -1599,6 +1762,18 @@ const docTemplate = `{
"description": "Only process teams missing imageDataUrl when wyId is omitted (default true)",
"name": "missingOnly",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......
......@@ -616,6 +616,14 @@
"Import"
],
"summary": "Import areas from TheSports",
"parameters": [
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
......@@ -671,6 +679,18 @@
"description": "Only discover coach from a specific team (only used when source=teams)",
"name": "teamWyId",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -721,6 +741,18 @@
"description": "Unix timestamp (seconds) to import only competitions updated since this time",
"name": "since",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -807,7 +839,7 @@
},
"/import/matches/fixtures": {
"post": {
"description": "Imports matches for a given Wyscout season via /v3/seasons/{seasonWyId}/fixtures?details=matches. If seasonWyId is omitted, imports for all seasons in the DB with a wy_id.",
"description": "Imports matches for a given Wyscout season via /v3/seasons/{seasonWyId}/fixtures?details=matches. If seasonWyId is omitted, imports for all seasons in the DB with a wy_id. Supports incremental sync to update matches from last sync date to today.",
"tags": [
"Import"
],
......@@ -824,6 +856,24 @@
"description": "Limit number of seasons processed when seasonWyId is omitted",
"name": "limitSeasons",
"in": "query"
},
{
"type": "boolean",
"description": "If true, only sync seasons with matches between api_last_synced_at and today based on match_date",
"name": "syncRecent",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -919,6 +969,18 @@
"name": "seasonWyId",
"in": "query",
"required": true
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -969,6 +1031,18 @@
"description": "Unix timestamp (seconds) to import only players updated since this time",
"name": "since",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -1084,6 +1158,18 @@
"description": "Process only one Wyscout player wy_id",
"name": "playerWyId",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 11)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -1134,6 +1220,18 @@
"description": "Process only one Wyscout player wy_id",
"name": "playerWyId",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 11)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -1200,6 +1298,18 @@
"description": "Unix timestamp (seconds) to import only referees updated since this time (theSports mode)",
"name": "since",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -1256,6 +1366,18 @@
"description": "DB upsert batch size (default 500)",
"name": "batchSize",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -1293,7 +1415,6 @@
"tags": [
"Import"
],
"summary": "Import seasons from TheSports",
"parameters": [
{
"type": "integer",
......@@ -1306,6 +1427,24 @@
"description": "Unix timestamp (seconds) to import only seasons updated since this time",
"name": "since",
"in": "query"
},
{
"type": "integer",
"description": "Limit number of competitions processed",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -1347,6 +1486,18 @@
"description": "Limit number of seasons processed when seasonWyId is omitted",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -1380,7 +1531,7 @@
},
"/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.",
"description": "Performs a team import using TheSports team 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"
],
......@@ -1397,6 +1548,18 @@
"description": "Unix timestamp (seconds) to import only teams updated since this time",
"name": "since",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......@@ -1592,6 +1755,18 @@
"description": "Only process teams missing imageDataUrl when wyId is omitted (default true)",
"name": "missingOnly",
"in": "query"
},
{
"type": "integer",
"description": "Concurrent workers (default 4)",
"name": "workers",
"in": "query"
},
{
"type": "integer",
"description": "Requests per second (default 10)",
"name": "rps",
"in": "query"
}
],
"responses": {
......
......@@ -814,6 +814,11 @@ paths:
post:
description: Imports all countries/regions from TheSports football country list
API.
parameters:
- description: Concurrent workers (default 4)
in: query
name: workers
type: integer
responses:
"200":
description: OK
......@@ -853,6 +858,14 @@ paths:
in: query
name: teamWyId
type: integer
- description: Concurrent workers (default 4)
in: query
name: workers
type: integer
- description: Requests per second (default 10)
in: query
name: rps
type: integer
responses:
"200":
description: OK
......@@ -890,6 +903,14 @@ paths:
in: query
name: since
type: integer
- description: Concurrent workers (default 4)
in: query
name: workers
type: integer
- description: Requests per second (default 10)
in: query
name: rps
type: integer
responses:
"200":
description: OK
......@@ -952,6 +973,7 @@ paths:
post:
description: Imports matches for a given Wyscout season via /v3/seasons/{seasonWyId}/fixtures?details=matches.
If seasonWyId is omitted, imports for all seasons in the DB with a wy_id.
Supports incremental sync to update matches from last sync date to today.
parameters:
- description: Wyscout season ID
in: query
......@@ -961,6 +983,19 @@ paths:
in: query
name: limitSeasons
type: integer
- description: If true, only sync seasons with matches between api_last_synced_at
and today based on match_date
in: query
name: syncRecent
type: boolean
- description: Concurrent workers (default 4)
in: query
name: workers
type: integer
- description: Requests per second (default 10)
in: query
name: rps
type: integer
responses:
"200":
description: OK
......@@ -1027,6 +1062,14 @@ paths:
name: seasonWyId
required: true
type: integer
- description: Concurrent workers (default 4)
in: query
name: workers
type: integer
- description: Requests per second (default 10)
in: query
name: rps
type: integer
responses:
"200":
description: OK
......@@ -1064,6 +1107,14 @@ paths:
in: query
name: since
type: integer
- description: Concurrent workers (default 4)
in: query
name: workers
type: integer
- description: Requests per second (default 10)
in: query
name: rps
type: integer
responses:
"200":
description: OK
......@@ -1146,6 +1197,14 @@ paths:
in: query
name: playerWyId
type: integer
- description: Concurrent workers (default 4)
in: query
name: workers
type: integer
- description: Requests per second (default 11)
in: query
name: rps
type: integer
responses:
"200":
description: OK
......@@ -1180,6 +1239,14 @@ paths:
in: query
name: playerWyId
type: integer
- description: Concurrent workers (default 4)
in: query
name: workers
type: integer
- description: Requests per second (default 11)
in: query
name: rps
type: integer
responses:
"200":
description: OK
......@@ -1226,6 +1293,14 @@ paths:
in: query
name: since
type: integer
- description: Concurrent workers (default 4)
in: query
name: workers
type: integer
- description: Requests per second (default 10)
in: query
name: rps
type: integer
responses:
"200":
description: OK
......@@ -1264,6 +1339,14 @@ paths:
in: query
name: batchSize
type: integer
- description: Concurrent workers (default 4)
in: query
name: workers
type: integer
- description: Requests per second (default 10)
in: query
name: rps
type: integer
responses:
"200":
description: OK
......@@ -1301,6 +1384,18 @@ paths:
in: query
name: since
type: integer
- description: Limit number of competitions processed
in: query
name: limit
type: integer
- description: Concurrent workers (default 4)
in: query
name: workers
type: integer
- description: Requests per second (default 10)
in: query
name: rps
type: integer
responses:
"200":
description: OK
......@@ -1313,7 +1408,6 @@ paths:
additionalProperties:
type: string
type: object
summary: Import seasons from TheSports
tags:
- Import
/import/standings:
......@@ -1330,6 +1424,14 @@ paths:
in: query
name: limit
type: integer
- description: Concurrent workers (default 4)
in: query
name: workers
type: integer
- description: Requests per second (default 10)
in: query
name: rps
type: integer
responses:
"200":
description: OK
......@@ -1353,10 +1455,10 @@ paths:
- 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.
description: Performs a team import using TheSports team 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
......@@ -1367,6 +1469,14 @@ paths:
in: query
name: since
type: integer
- description: Concurrent workers (default 4)
in: query
name: workers
type: integer
- description: Requests per second (default 10)
in: query
name: rps
type: integer
responses:
"200":
description: OK
......@@ -1503,6 +1613,14 @@ paths:
in: query
name: missingOnly
type: boolean
- description: Concurrent workers (default 4)
in: query
name: workers
type: integer
- description: Requests per second (default 10)
in: query
name: rps
type: integer
responses:
"200":
description: OK
......
module.exports = {
apps: [
{
name: "sssdata",
cwd: __dirname,
script: "./server",
interpreter: "none",
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: "300M",
time: true,
env: {
APP_PORT: process.env.APP_PORT || "3003",
DB_HOST: process.env.DB_HOST,
DB_PORT: process.env.DB_PORT,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
DB_NAME: process.env.DB_NAME,
DB_SSLMODE: process.env.DB_SSLMODE,
ProviderUser: process.env.ProviderUser,
ProviderSecret: process.env.ProviderSecret,
API_USERNAME: process.env.API_USERNAME,
API_PASSWORD: process.env.API_PASSWORD,
},
},
],
};
package config
import "fmt"
// LeagueTiers defines the hierarchical ranking of football leagues worldwide
// Based on UEFA Country Coefficients 2024/2025 and global football rankings
// Last updated: February 2026
//
// Usage: Can be used for prioritizing search results, filtering, and ranking
// across players, coaches, teams, and other football entities.
// LeagueTier represents a tier level with associated countries
type LeagueTier struct {
Level int // 1 = highest tier
Name string // Human-readable tier name
Description string // Description of the tier
AreaWyIDs []int // Wyscout area IDs for countries in this tier
Countries []string // Country names for reference
}
// GetLeagueTiers returns all defined league tiers
func GetLeagueTiers() []LeagueTier {
return []LeagueTier{
{
Level: 1,
Name: "Elite (Top 5)",
Description: "The Big 5 European leagues - highest quality and commercial value",
AreaWyIDs: []int{724, 380, 616, 276, 250}, // England, Italy, France, Germany, Spain
Countries: []string{"England", "Spain", "Italy", "Germany", "France"},
},
{
Level: 2,
Name: "Strong Leagues",
Description: "High-quality leagues with international competitiveness",
AreaWyIDs: []int{620, 337, 76, 11, 22}, // Portugal, Netherlands, Brazil, Argentina, Belgium
Countries: []string{"Portugal", "Netherlands", "Brazil", "Argentina", "Belgium"},
},
{
Level: 3,
Name: "Competitive Leagues",
Description: "Competitive leagues with regular European participation",
AreaWyIDs: []int{792, 40, 203, 688, 756, 208, 300, 752, 578}, // Turkey, Austria, Czech Republic, Serbia, Switzerland, Denmark, Greece, Sweden, Norway
Countries: []string{"Turkey", "Austria", "Czech Republic", "Serbia", "Switzerland", "Denmark", "Greece", "Sweden", "Norway"},
},
{
Level: 4,
Name: "Developing Leagues",
Description: "Emerging leagues with growing competitiveness",
AreaWyIDs: []int{}, // Can be expanded as needed
Countries: []string{},
},
}
}
// GetAreaWyIDsByTier returns area WyIDs for a specific tier level
func GetAreaWyIDsByTier(tier int) []int {
tiers := GetLeagueTiers()
for _, t := range tiers {
if t.Level == tier {
return t.AreaWyIDs
}
}
return []int{}
}
// GetAllTopTierAreaWyIDs returns area WyIDs for tiers 1-3 combined
func GetAllTopTierAreaWyIDs() []int {
var allIDs []int
tiers := GetLeagueTiers()
for _, t := range tiers {
if t.Level <= 3 {
allIDs = append(allIDs, t.AreaWyIDs...)
}
}
return allIDs
}
// BuildLeagueTierCaseSQL generates a SQL CASE statement for league tier prioritization
// Returns the SQL string for use in ORDER BY clauses
func BuildLeagueTierCaseSQL(teamTableAlias string) string {
if teamTableAlias != "" {
teamTableAlias = teamTableAlias + "."
}
tiers := GetLeagueTiers()
sql := "CASE "
for _, tier := range tiers {
if len(tier.AreaWyIDs) > 0 {
sql += "WHEN " + teamTableAlias + "area_wy_id IN ("
for i, id := range tier.AreaWyIDs {
if i > 0 {
sql += ", "
}
sql += fmt.Sprintf("%d", id)
}
sql += ") THEN " + fmt.Sprintf("%d ", tier.Level-1) // 0-indexed for sorting
}
}
sql += "WHEN " + teamTableAlias + "area_wy_id IS NOT NULL THEN 99 " // Other leagues
sql += "ELSE 100 END" // No team/league
return sql
}
// GetTierByAreaWyID returns the tier level for a given area WyID
// Returns 0 if not found in any tier
func GetTierByAreaWyID(areaWyID int) int {
tiers := GetLeagueTiers()
for _, tier := range tiers {
for _, id := range tier.AreaWyIDs {
if id == areaWyID {
return tier.Level
}
}
}
return 0
}
// GetTierName returns the human-readable name for a tier level
func GetTierName(tier int) string {
tiers := GetLeagueTiers()
for _, t := range tiers {
if t.Level == tier {
return t.Name
}
}
return "Unknown"
}
......@@ -344,6 +344,40 @@ func (h *ImportHandler) ImportTeamCareer(c *gin.Context) {
return
}
// Count total teams for progress tracking
var totalTeams int64
if err := h.DB.WithContext(ctx).Model(&models.Team{}).Where("wy_id IS NOT NULL").Count(&totalTeams).Error; err == nil {
var parentCount, childCount int64
h.DB.WithContext(ctx).Model(&models.TeamChild{}).Distinct("parent_team_wy_id").Count(&parentCount)
h.DB.WithContext(ctx).Model(&models.TeamChild{}).Distinct("child_team_wy_id").Count(&childCount)
totalTeams += parentCount + childCount
}
if limit > 0 && totalTeams > int64(limit) {
totalTeams = int64(limit)
}
startedAt := time.Now()
logProgress := func(force bool) {
if totalTeams <= 0 {
return
}
elapsed := time.Since(startedAt)
if elapsed <= 0 {
elapsed = time.Second
}
rpsNow := float64(processed) / elapsed.Seconds()
pct := (float64(processed) / float64(totalTeams)) * 100
remainingLeft := totalTeams - processed
eta := time.Duration(0)
if rpsNow > 0 && remainingLeft > 0 {
eta = time.Duration(float64(remainingLeft)/rpsNow) * time.Second
}
if force || processed%50 == 0 {
log.Printf("ImportTeamCareer: progress processed=%d/%d (%.2f%%) upserted=%d errors=%d elapsed=%s rps=%.2f eta=%s",
processed, totalTeams, pct, createdTotal, errorsCount, elapsed.Truncate(time.Second), rpsNow, eta.Truncate(time.Second))
}
}
teamIDsCh := make(chan int, workers*4)
resultsCh := make(chan struct {
upserted int
......@@ -455,9 +489,7 @@ func (h *ImportHandler) ImportTeamCareer(c *gin.Context) {
processed++
createdTotal += int64(r.upserted)
errorsCount += int64(r.errs)
if processed%50 == 0 {
log.Printf("ImportTeamCareer: progress processed=%d upserted=%d errors=%d", processed, createdTotal, errorsCount)
}
logProgress(false)
}
if err := <-produceErr; err != nil {
......@@ -465,6 +497,7 @@ func (h *ImportHandler) ImportTeamCareer(c *gin.Context) {
return
}
logProgress(true)
log.Printf("ImportTeamCareer: finished processed=%d upserted=%d errors=%d", processed, createdTotal, errorsCount)
if ctx.Err() != nil {
c.JSON(http.StatusRequestTimeout, gin.H{"error": "request cancelled"})
......@@ -489,6 +522,8 @@ func (h *ImportHandler) ImportTeamCareer(c *gin.Context) {
// @Tags Import
// @Param limit query int false "Limit number of players processed"
// @Param playerWyId query int false "Process only one Wyscout player wy_id"
// @Param workers query int false "Concurrent workers (default 4)"
// @Param rps query int false "Requests per second (default 11)"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
......@@ -503,8 +538,39 @@ func (h *ImportHandler) ImportPlayerCareer(c *gin.Context) {
ctx := c.Request.Context()
// global rate limit: 11 requests per second
playerWyID := 0
if v := strings.TrimSpace(c.Query("playerWyId")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid playerWyId"})
return
}
playerWyID = n
}
limit := 0
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
workers := 4
if v := strings.TrimSpace(c.Query("workers")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
workers = n
}
}
rps := 11
if v := strings.TrimSpace(c.Query("rps")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
rps = n
}
}
log.Printf("ImportPlayerCareer: start playerWyId=%d limit=%d workers=%d rps=%d", playerWyID, limit, workers, rps)
rateTokens := make(chan struct{}, rps)
interval := time.Second / time.Duration(rps)
if interval <= 0 {
......@@ -526,25 +592,6 @@ func (h *ImportHandler) ImportPlayerCareer(c *gin.Context) {
}
}()
playerWyID := 0
if v := strings.TrimSpace(c.Query("playerWyId")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid playerWyId"})
return
}
playerWyID = n
}
limit := 0
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
log.Printf("ImportPlayerCareer: start playerWyId=%d limit=%d", playerWyID, limit)
truncate := func(b []byte) string {
if len(b) > 2048 {
b = b[:2048]
......@@ -714,14 +761,14 @@ func (h *ImportHandler) ImportPlayerCareer(c *gin.Context) {
return imported, updated, errorsCount
}
imported, updated, errorsCount := 0, 0, 0
processed := 0
imported, updated, errorsCount := int64(0), int64(0), int64(0)
processed := int64(0)
if playerWyID > 0 {
i, u, e := fetchAndUpsert(playerWyID)
imported += i
updated += u
errorsCount += e
imported += int64(i)
updated += int64(u)
errorsCount += int64(e)
log.Printf("ImportPlayerCareer: finished single playerWyId=%d imported=%d updated=%d errors=%d", playerWyID, imported, updated, errorsCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
......@@ -742,23 +789,86 @@ func (h *ImportHandler) ImportPlayerCareer(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list players"})
return
}
log.Printf("ImportPlayerCareer: players in DB with wy_id=%d", len(players))
totalPlayers := int64(len(players))
if limit > 0 && totalPlayers > int64(limit) {
totalPlayers = int64(limit)
}
log.Printf("ImportPlayerCareer: players in DB with wy_id=%d totalPlanned=%d workers=%d", len(players), totalPlayers, workers)
for _, p := range players {
if p.WyID == nil || *p.WyID <= 0 {
continue
startedAt := time.Now()
logProgress := func(force bool) {
if totalPlayers <= 0 {
return
}
if limit > 0 && processed >= limit {
break
elapsed := time.Since(startedAt)
if elapsed <= 0 {
elapsed = time.Second
}
rpsNow := float64(processed) / elapsed.Seconds()
pct := (float64(processed) / float64(totalPlayers)) * 100
remainingLeft := totalPlayers - processed
eta := time.Duration(0)
if rpsNow > 0 && remainingLeft > 0 {
eta = time.Duration(float64(remainingLeft)/rpsNow) * time.Second
}
if force || processed%50 == 0 {
log.Printf("ImportPlayerCareer: progress processed=%d/%d (%.2f%%) imported=%d updated=%d errors=%d elapsed=%s rps=%.2f eta=%s",
processed, totalPlayers, pct, imported, updated, errorsCount, elapsed.Truncate(time.Second), rpsNow, eta.Truncate(time.Second))
}
}
type job struct {
wyID int
}
type result struct {
imported int
updated int
errors int
}
jobs := make(chan job, workers*4)
results := make(chan result, workers*4)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
i, u, e := fetchAndUpsert(j.wyID)
results <- result{imported: i, updated: u, errors: e}
}
}()
}
go func() {
wg.Wait()
close(results)
}()
go func() {
defer close(jobs)
for _, p := range players {
if p.WyID == nil || *p.WyID <= 0 {
continue
}
if limit > 0 && processed >= int64(limit) {
break
}
jobs <- job{wyID: *p.WyID}
}
log.Printf("ImportPlayerCareer: progress player %d/%d wyId=%d", processed+1, len(players), *p.WyID)
i, u, e := fetchAndUpsert(*p.WyID)
imported += i
updated += u
errorsCount += e
}()
for r := range results {
processed++
imported += int64(r.imported)
updated += int64(r.updated)
errorsCount += int64(r.errors)
logProgress(false)
}
logProgress(true)
log.Printf("ImportPlayerCareer: finished playersProcessed=%d imported=%d updated=%d errors=%d", processed, imported, updated, errorsCount)
c.JSON(http.StatusOK, gin.H{
......@@ -780,6 +890,8 @@ func (h *ImportHandler) ImportPlayerCareer(c *gin.Context) {
// @Tags Import
// @Param limit query int false "Limit number of players processed"
// @Param playerWyId query int false "Process only one Wyscout player wy_id"
// @Param workers query int false "Concurrent workers (default 4)"
// @Param rps query int false "Requests per second (default 11)"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
......@@ -794,8 +906,39 @@ func (h *ImportHandler) ImportPlayerTransfers(c *gin.Context) {
ctx := c.Request.Context()
// global rate limit: 11 requests per second
playerWyID := 0
if v := strings.TrimSpace(c.Query("playerWyId")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid playerWyId"})
return
}
playerWyID = n
}
limit := 0
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
workers := 4
if v := strings.TrimSpace(c.Query("workers")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
workers = n
}
}
rps := 11
if v := strings.TrimSpace(c.Query("rps")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
rps = n
}
}
log.Printf("ImportPlayerTransfers: start playerWyId=%d limit=%d workers=%d rps=%d", playerWyID, limit, workers, rps)
rateTokens := make(chan struct{}, rps)
interval := time.Second / time.Duration(rps)
if interval <= 0 {
......@@ -817,25 +960,6 @@ func (h *ImportHandler) ImportPlayerTransfers(c *gin.Context) {
}
}()
playerWyID := 0
if v := strings.TrimSpace(c.Query("playerWyId")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid playerWyId"})
return
}
playerWyID = n
}
limit := 0
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
log.Printf("ImportPlayerTransfers: start playerWyId=%d limit=%d", playerWyID, limit)
truncate := func(b []byte) string {
if len(b) > 2048 {
b = b[:2048]
......@@ -1017,14 +1141,14 @@ func (h *ImportHandler) ImportPlayerTransfers(c *gin.Context) {
return imported, updated, errorsCount
}
imported, updated, errorsCount := 0, 0, 0
processed := 0
imported, updated, errorsCount := int64(0), int64(0), int64(0)
processed := int64(0)
if playerWyID > 0 {
i, u, e := fetchAndUpsert(playerWyID)
imported += i
updated += u
errorsCount += e
imported += int64(i)
updated += int64(u)
errorsCount += int64(e)
log.Printf("ImportPlayerTransfers: finished single playerWyId=%d imported=%d updated=%d errors=%d", playerWyID, imported, updated, errorsCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
......@@ -1045,23 +1169,86 @@ func (h *ImportHandler) ImportPlayerTransfers(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list players"})
return
}
log.Printf("ImportPlayerTransfers: players in DB with wy_id=%d", len(players))
totalPlayers := int64(len(players))
if limit > 0 && totalPlayers > int64(limit) {
totalPlayers = int64(limit)
}
log.Printf("ImportPlayerTransfers: players in DB with wy_id=%d totalPlanned=%d workers=%d", len(players), totalPlayers, workers)
for _, p := range players {
if p.WyID == nil || *p.WyID <= 0 {
continue
startedAt := time.Now()
logProgress := func(force bool) {
if totalPlayers <= 0 {
return
}
if limit > 0 && processed >= limit {
break
elapsed := time.Since(startedAt)
if elapsed <= 0 {
elapsed = time.Second
}
rpsNow := float64(processed) / elapsed.Seconds()
pct := (float64(processed) / float64(totalPlayers)) * 100
remainingLeft := totalPlayers - processed
eta := time.Duration(0)
if rpsNow > 0 && remainingLeft > 0 {
eta = time.Duration(float64(remainingLeft)/rpsNow) * time.Second
}
if force || processed%50 == 0 {
log.Printf("ImportPlayerTransfers: progress processed=%d/%d (%.2f%%) imported=%d updated=%d errors=%d elapsed=%s rps=%.2f eta=%s",
processed, totalPlayers, pct, imported, updated, errorsCount, elapsed.Truncate(time.Second), rpsNow, eta.Truncate(time.Second))
}
}
type job struct {
wyID int
}
type result struct {
imported int
updated int
errors int
}
jobs := make(chan job, workers*4)
results := make(chan result, workers*4)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
i, u, e := fetchAndUpsert(j.wyID)
results <- result{imported: i, updated: u, errors: e}
}
}()
}
go func() {
wg.Wait()
close(results)
}()
go func() {
defer close(jobs)
for _, p := range players {
if p.WyID == nil || *p.WyID <= 0 {
continue
}
if limit > 0 && processed >= int64(limit) {
break
}
jobs <- job{wyID: *p.WyID}
}
log.Printf("ImportPlayerTransfers: progress player %d/%d wyId=%d", processed+1, len(players), *p.WyID)
i, u, e := fetchAndUpsert(*p.WyID)
imported += i
updated += u
errorsCount += e
}()
for r := range results {
processed++
imported += int64(r.imported)
updated += int64(r.updated)
errorsCount += int64(r.errors)
logProgress(false)
}
logProgress(true)
log.Printf("ImportPlayerTransfers: finished playersProcessed=%d imported=%d updated=%d errors=%d", processed, imported, updated, errorsCount)
c.JSON(http.StatusOK, gin.H{
......@@ -1078,11 +1265,13 @@ func (h *ImportHandler) ImportPlayerTransfers(c *gin.Context) {
}
// 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"
// @Param limit query int false "Limit number of competitions processed"
// @Param workers query int false "Concurrent workers (default 4)"
// @Param rps query int false "Requests per second (default 10)"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /import/seasons [post]
......@@ -1094,6 +1283,8 @@ func (h *ImportHandler) ImportSeasons(c *gin.Context) {
return
}
ctx := c.Request.Context()
limitStr := c.Query("limit")
limit := 0
if limitStr != "" {
......@@ -1102,6 +1293,24 @@ func (h *ImportHandler) ImportSeasons(c *gin.Context) {
}
}
workers := 4
if v := strings.TrimSpace(c.Query("workers")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
workers = n
}
}
rps := 10
if v := strings.TrimSpace(c.Query("rps")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
rps = n
}
}
log.Printf("ImportSeasons: start limit=%d workers=%d rps=%d", limit, workers, rps)
rateTokens := RateLimiter(ctx, rps)
parseDateAny := func(v any) *time.Time {
switch vv := v.(type) {
case string:
......@@ -1139,6 +1348,11 @@ func (h *ImportHandler) ImportSeasons(c *gin.Context) {
}
doGet := func(url string) ([]byte, int, error) {
select {
case <-ctx.Done():
return nil, 0, ctx.Err()
case <-rateTokens:
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
......@@ -1358,26 +1572,56 @@ func (h *ImportHandler) ImportSeasons(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list competitions"})
return
}
log.Printf("ImportSeasons: competitions in DB=%d", len(comps))
totalComps := int64(len(comps))
if limit > 0 && totalComps > int64(limit) {
totalComps = int64(limit)
}
log.Printf("ImportSeasons: competitions in DB=%d totalPlanned=%d workers=%d", len(comps), totalComps, workers)
imported, updated, errorsCount := int64(0), int64(0), int64(0)
processedCompetitions := int64(0)
startedAt := time.Now()
logProgress := func(force bool) {
if totalComps <= 0 {
return
}
elapsed := time.Since(startedAt)
if elapsed <= 0 {
elapsed = time.Second
}
rpsNow := float64(processedCompetitions) / elapsed.Seconds()
pct := (float64(processedCompetitions) / float64(totalComps)) * 100
remainingLeft := totalComps - processedCompetitions
eta := time.Duration(0)
if rpsNow > 0 && remainingLeft > 0 {
eta = time.Duration(float64(remainingLeft)/rpsNow) * time.Second
}
if force || processedCompetitions%10 == 0 {
log.Printf("ImportSeasons: progress processed=%d/%d (%.2f%%) imported=%d updated=%d errors=%d elapsed=%s rps=%.2f eta=%s",
processedCompetitions, totalComps, pct, imported, updated, errorsCount, elapsed.Truncate(time.Second), rpsNow, eta.Truncate(time.Second))
}
}
imported, updated, errorsCount := 0, 0, 0
processedCompetitions := 0
for _, comp := range comps {
if comp.WyID == nil || *comp.WyID <= 0 {
continue
}
if limit > 0 && processedCompetitions >= limit {
if limit > 0 && processedCompetitions >= int64(limit) {
break
}
log.Printf("ImportSeasons: fetching seasons for competitionWyId=%d", *comp.WyID)
i, u, e := fetchSeasons(*comp.WyID)
imported += i
updated += u
errorsCount += e
log.Printf("ImportSeasons: competitionWyId=%d done imported=%d updated=%d errors=%d", *comp.WyID, i, u, e)
imported += int64(i)
updated += int64(u)
errorsCount += int64(e)
processedCompetitions++
logProgress(false)
}
logProgress(true)
log.Printf("ImportSeasons: finished processedCompetitions=%d imported=%d updated=%d errors=%d", processedCompetitions, imported, updated, errorsCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Seasons import completed",
......@@ -3655,6 +3899,8 @@ func (h *ImportHandler) ImportTeamAdvancedStats(c *gin.Context) {
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Param workers query int false "Concurrent workers (default 4)"
// @Param rps query int false "Requests per second (default 10)"
// @Router /import/rounds [post]
func (h *ImportHandler) ImportRounds(c *gin.Context) {
user := h.Cfg.ProviderUser
......@@ -3664,6 +3910,8 @@ func (h *ImportHandler) ImportRounds(c *gin.Context) {
return
}
ctx := c.Request.Context()
roundWyID := 0
if v := strings.TrimSpace(c.Query("roundWyId")); v != "" {
n, err := strconv.Atoi(v)
......@@ -3681,6 +3929,20 @@ func (h *ImportHandler) ImportRounds(c *gin.Context) {
}
}
workers := 4
if v := strings.TrimSpace(c.Query("workers")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
workers = n
}
}
rps := 10
if v := strings.TrimSpace(c.Query("rps")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
rps = n
}
}
batchSize := 500
if v := strings.TrimSpace(c.Query("batchSize")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
......@@ -3688,6 +3950,10 @@ func (h *ImportHandler) ImportRounds(c *gin.Context) {
}
}
log.Printf("ImportRounds: start roundWyId=%d limit=%d workers=%d rps=%d batchSize=%d", roundWyID, limit, workers, rps, batchSize)
rateTokens := RateLimiter(ctx, rps)
truncate := func(b []byte) string {
if len(b) > 2048 {
b = b[:2048]
......@@ -3696,6 +3962,11 @@ func (h *ImportHandler) ImportRounds(c *gin.Context) {
}
doGet := func(url string) ([]byte, int, error) {
select {
case <-ctx.Done():
return nil, 0, ctx.Err()
case <-rateTokens:
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
......@@ -3801,29 +4072,51 @@ func (h *ImportHandler) ImportRounds(c *gin.Context) {
}
}
log.Printf("ImportRounds: rounds to process=%d batchSize=%d", len(roundWyIDs), batchSize)
totalRounds := int64(len(roundWyIDs))
log.Printf("ImportRounds: rounds to process=%d batchSize=%d workers=%d", totalRounds, batchSize, workers)
upserted := 0
errorsCount := 0
processed := 0
upserted := int64(0)
errorsCount := int64(0)
processed := int64(0)
batch := make([]models.Round, 0, batchSize)
startedAt := time.Now()
flush := func() {
if len(batch) == 0 {
logProgress := func(force bool) {
if totalRounds <= 0 {
return
}
elapsed := time.Since(startedAt)
if elapsed <= 0 {
elapsed = time.Second
}
rpsNow := float64(processed) / elapsed.Seconds()
pct := (float64(processed) / float64(totalRounds)) * 100
remainingLeft := totalRounds - processed
eta := time.Duration(0)
if rpsNow > 0 && remainingLeft > 0 {
eta = time.Duration(float64(remainingLeft)/rpsNow) * time.Second
}
if force || processed%50 == 0 {
log.Printf("ImportRounds: progress processed=%d/%d (%.2f%%) upserted=%d errors=%d elapsed=%s rps=%.2f eta=%s",
processed, totalRounds, pct, upserted, errorsCount, elapsed.Truncate(time.Second), rpsNow, eta.Truncate(time.Second))
}
}
flush := func() {
if len(batch) == 0 {
return
}
if err := upsertBatch(batch); err != nil {
log.Printf("ImportRounds: batch upsert failed err=%v", err)
errorsCount += len(batch)
errorsCount += int64(len(batch))
} else {
upserted += len(batch)
upserted += int64(len(batch))
}
batch = batch[:0]
}
for _, id := range roundWyIDs {
url := fmt.Sprintf("https://apirest.wyscout.com/v3/rounds/%d", id)
log.Printf("ImportRounds: fetching roundWyId=%d", id)
body, status, err := doGet(url)
if err != nil {
log.Printf("ImportRounds: request failed roundWyId=%d err=%v", id, err)
......@@ -3852,12 +4145,14 @@ func (h *ImportHandler) ImportRounds(c *gin.Context) {
flush()
}
processed++
if limit > 0 && roundWyID == 0 && processed >= limit {
logProgress(false)
if limit > 0 && roundWyID == 0 && processed >= int64(limit) {
break
}
}
flush()
logProgress(true)
log.Printf("ImportRounds: done upserted=%d errors=%d processed=%d", upserted, errorsCount, processed)
c.JSON(http.StatusOK, gin.H{
......@@ -4511,6 +4806,8 @@ func (h *ImportHandler) ImportMatchFormations(c *gin.Context) {
// @Tags Import
// @Param seasonWyId query int false "Wyscout season ID"
// @Param limit query int false "Limit number of seasons processed when seasonWyId is omitted"
// @Param workers query int false "Concurrent workers (default 4)"
// @Param rps query int false "Requests per second (default 10)"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
......@@ -4523,6 +4820,8 @@ func (h *ImportHandler) ImportStandings(c *gin.Context) {
return
}
ctx := c.Request.Context()
limit := 0
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
......@@ -4530,6 +4829,20 @@ func (h *ImportHandler) ImportStandings(c *gin.Context) {
}
}
workers := 4
if v := strings.TrimSpace(c.Query("workers")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
workers = n
}
}
rps := 10
if v := strings.TrimSpace(c.Query("rps")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
rps = n
}
}
seasonWyID := 0
if v := strings.TrimSpace(c.Query("seasonWyId")); v != "" {
n, err := strconv.Atoi(v)
......@@ -4540,9 +4853,16 @@ func (h *ImportHandler) ImportStandings(c *gin.Context) {
seasonWyID = n
}
log.Printf("ImportStandings: start seasonWyId=%d limit=%d", seasonWyID, limit)
log.Printf("ImportStandings: start seasonWyId=%d limit=%d workers=%d rps=%d", seasonWyID, limit, workers, rps)
rateTokens := RateLimiter(ctx, rps)
doGet := func(url string) ([]byte, int, error) {
doGet := func(url string) ([]byte, int, error) {
select {
case <-ctx.Done():
return nil, 0, ctx.Err()
case <-rateTokens:
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
......@@ -4687,26 +5007,54 @@ func (h *ImportHandler) ImportStandings(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list seasons"})
return
}
log.Printf("ImportStandings: seasons to process=%d", len(seasons))
totalSeasons := int64(len(seasons))
if limit > 0 && totalSeasons > int64(limit) {
totalSeasons = int64(limit)
}
log.Printf("ImportStandings: seasons to process=%d totalPlanned=%d workers=%d", len(seasons), totalSeasons, workers)
createdTotal := 0
errorsCount := 0
processed := 0
for i, s := range seasons {
createdTotal := int64(0)
errorsCount := int64(0)
processed := int64(0)
startedAt := time.Now()
logProgress := func(force bool) {
if totalSeasons <= 0 {
return
}
elapsed := time.Since(startedAt)
if elapsed <= 0 {
elapsed = time.Second
}
rpsNow := float64(processed) / elapsed.Seconds()
pct := (float64(processed) / float64(totalSeasons)) * 100
remainingLeft := totalSeasons - processed
eta := time.Duration(0)
if rpsNow > 0 && remainingLeft > 0 {
eta = time.Duration(float64(remainingLeft)/rpsNow) * time.Second
}
if force || processed%10 == 0 {
log.Printf("ImportStandings: progress processed=%d/%d (%.2f%%) created=%d errors=%d elapsed=%s rps=%.2f eta=%s",
processed, totalSeasons, pct, createdTotal, errorsCount, elapsed.Truncate(time.Second), rpsNow, eta.Truncate(time.Second))
}
}
for _, s := range seasons {
if s.WyID == nil || *s.WyID <= 0 {
continue
}
if limit > 0 && processed >= limit {
log.Printf("ImportStandings: reached limit=%d, stopping", limit)
if limit > 0 && processed >= int64(limit) {
break
}
log.Printf("ImportStandings: processing %d/%d seasonWyId=%d", processed+1, len(seasons), *s.WyID)
created, errs := importForSeason(*s.WyID)
createdTotal += created
errorsCount += errs
createdTotal += int64(created)
errorsCount += int64(errs)
processed++
log.Printf("ImportStandings: progress i=%d processed=%d createdTotal=%d errors=%d", i, processed, createdTotal, errorsCount)
logProgress(false)
}
logProgress(true)
log.Printf("ImportStandings: finished processed=%d createdTotal=%d errors=%d", processed, createdTotal, errorsCount)
c.JSON(http.StatusOK, gin.H{
......@@ -4730,6 +5078,8 @@ func (h *ImportHandler) ImportStandings(c *gin.Context) {
// @Param afterWyId query int false "When wyId is omitted, only process teams with wy_id > afterWyId (cursor)"
// @Param limit query int false "Limit number of teams processed when wyId is omitted"
// @Param missingOnly query bool false "Only process teams missing imageDataUrl when wyId is omitted (default true)"
// @Param workers query int false "Concurrent workers (default 4)"
// @Param rps query int false "Requests per second (default 10)"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
......@@ -4742,6 +5092,8 @@ func (h *ImportHandler) ImportTeamImages(c *gin.Context) {
return
}
ctx := c.Request.Context()
internalID := strings.TrimSpace(c.Query("id"))
wyID := 0
......@@ -4775,13 +5127,34 @@ func (h *ImportHandler) ImportTeamImages(c *gin.Context) {
}
}
workers := 4
if v := strings.TrimSpace(c.Query("workers")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
workers = n
}
}
rps := 10
if v := strings.TrimSpace(c.Query("rps")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
rps = n
}
}
if internalID == "" && wyID == 0 && limit == 0 {
limit = 25
}
log.Printf("ImportTeamImages: called id=%s wyId=%d limit=%d missingOnly=%v", internalID, wyID, limit, missingOnly)
log.Printf("ImportTeamImages: start id=%s wyId=%d limit=%d missingOnly=%v workers=%d rps=%d", internalID, wyID, limit, missingOnly, workers, rps)
rateTokens := RateLimiter(ctx, rps)
doGet := func(url string) ([]byte, int, error) {
select {
case <-ctx.Done():
return nil, 0, ctx.Err()
case <-rateTokens:
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
......@@ -4984,10 +5357,13 @@ func (h *ImportHandler) ImportTeamImages(c *gin.Context) {
// ImportMatchesFixtures imports matches from Wyscout fixtures.
// @Summary Import matches from Wyscout fixtures
// @Description Imports matches for a given Wyscout season via /v3/seasons/{seasonWyId}/fixtures?details=matches. If seasonWyId is omitted, imports for all seasons in the DB with a wy_id.
// @Description Imports matches for a given Wyscout season via /v3/seasons/{seasonWyId}/fixtures?details=matches. If seasonWyId is omitted, imports for all seasons in the DB with a wy_id. Supports incremental sync to update matches from last sync date to today.
// @Tags Import
// @Param seasonWyId query int false "Wyscout season ID"
// @Param limitSeasons query int false "Limit number of seasons processed when seasonWyId is omitted"
// @Param syncRecent query bool false "If true, only sync seasons with matches between api_last_synced_at and today based on match_date"
// @Param workers query int false "Concurrent workers (default 4)"
// @Param rps query int false "Requests per second (default 10)"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
......@@ -5000,6 +5376,33 @@ func (h *ImportHandler) ImportMatchesFixtures(c *gin.Context) {
return
}
ctx := c.Request.Context()
workers := 4
if v := strings.TrimSpace(c.Query("workers")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
workers = n
}
}
rps := 10
if v := strings.TrimSpace(c.Query("rps")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
rps = n
}
}
syncRecent := false
if v := strings.TrimSpace(c.Query("syncRecent")); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
syncRecent = b
}
}
log.Printf("ImportMatchesFixtures: start workers=%d rps=%d syncRecent=%v", workers, rps, syncRecent)
rateTokens := RateLimiter(ctx, rps)
normalizeMatchStatus := func(raw string) string {
s := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(raw), " ", "_"))
s = strings.ReplaceAll(s, "-", "_")
......@@ -5008,13 +5411,14 @@ func (h *ImportHandler) ImportMatchesFixtures(c *gin.Context) {
switch s {
case "fixture":
return "scheduled"
case "scheduled", "postponed", "cancelled", "canceled", "suspended", "finished", "in_progress", "live", "interrupted":
if s == "canceled" {
return "cancelled"
}
if s == "live" {
return "in_progress"
}
case "canceled":
return "cancelled"
case "live":
return "in_progress"
case "suspended", "interrupted":
// suspended/interrupted matches are typically postponed
return "postponed"
case "scheduled", "postponed", "cancelled", "finished", "in_progress":
return s
default:
// safest fallback, since DB default is also scheduled
......@@ -5088,6 +5492,11 @@ func (h *ImportHandler) ImportMatchesFixtures(c *gin.Context) {
}
doGet := func(url string) ([]byte, int, error) {
select {
case <-ctx.Done():
return nil, 0, ctx.Err()
case <-rateTokens:
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
......@@ -5417,6 +5826,61 @@ func (h *ImportHandler) ImportMatchesFixtures(c *gin.Context) {
var seasons []models.Season
q := h.DB.Model(&models.Season{}).Where("wy_id IS NOT NULL")
// If syncRecent is enabled, filter seasons that have matches needing updates
if syncRecent {
// Find the oldest api_last_synced_at date from matches table
var oldestSync time.Time
if err := h.DB.Model(&models.Match{}).
Where("api_last_synced_at IS NOT NULL").
Order("api_last_synced_at ASC").
Limit(1).
Pluck("api_last_synced_at", &oldestSync).Error; err != nil && err != gorm.ErrRecordNotFound {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to find oldest sync date: " + err.Error()})
return
}
// If no synced matches found, use 30 days ago as default
if oldestSync.IsZero() {
oldestSync = time.Now().UTC().AddDate(0, 0, -30)
log.Printf("ImportMatchesFixtures: no api_last_synced_at found, using default 30 days ago: %s", oldestSync.Format("2006-01-02"))
}
today := time.Now().UTC()
log.Printf("ImportMatchesFixtures: syncRecent enabled - filtering seasons with matches between %s and %s", oldestSync.Format("2006-01-02"), today.Format("2006-01-02"))
// Find season_wy_ids that have matches in the date range
var seasonWyIDs []int
if err := h.DB.Model(&models.Match{}).
Where("season_wy_id IS NOT NULL").
Where("match_date >= ? AND match_date <= ?", oldestSync, today).
Distinct("season_wy_id").
Pluck("season_wy_id", &seasonWyIDs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to find seasons with recent matches: " + err.Error()})
return
}
if len(seasonWyIDs) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "No seasons with matches in date range",
"data": gin.H{
"seasonsProcessed": 0,
"imported": 0,
"updated": 0,
"errors": 0,
"matchTeamsUpserted": 0,
"dateRange": map[string]string{"from": oldestSync.Format("2006-01-02"), "to": today.Format("2006-01-02")},
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
return
}
log.Printf("ImportMatchesFixtures: found %d seasons with matches in date range", len(seasonWyIDs))
q = q.Where("wy_id IN ?", seasonWyIDs)
}
if limitSeasons > 0 {
q = q.Limit(limitSeasons)
}
......@@ -5469,6 +5933,8 @@ func (h *ImportHandler) ImportMatchesFixtures(c *gin.Context) {
// @Description Imports matches for a given Wyscout season via /v3/seasons/{seasonWyId}/matches. Requires seasonWyId query param.
// @Tags Import
// @Param seasonWyId query int true "Wyscout season ID"
// @Param workers query int false "Concurrent workers (default 4)"
// @Param rps query int false "Requests per second (default 10)"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
......@@ -5481,6 +5947,22 @@ func (h *ImportHandler) ImportMatchesWyscout(c *gin.Context) {
return
}
ctx := c.Request.Context()
workers := 4
if v := strings.TrimSpace(c.Query("workers")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
workers = n
}
}
rps := 10
if v := strings.TrimSpace(c.Query("rps")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
rps = n
}
}
pageSizeStr := c.DefaultQuery("pageSize", "200")
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil || pageSize <= 0 {
......@@ -5506,9 +5988,13 @@ func (h *ImportHandler) ImportMatchesWyscout(c *gin.Context) {
}
}
imported, updated, errorsCount := 0, 0, 0
pagesProcessed := 0
seasonsProcessed := 0
log.Printf("ImportMatchesWyscout: start pageSize=%d maxPages=%d limitSeasons=%d workers=%d rps=%d", pageSize, maxPages, limitSeasons, workers, rps)
rateTokens := RateLimiter(ctx, rps)
imported, updated, errorsCount := int64(0), int64(0), int64(0)
pagesProcessed := int64(0)
seasonsProcessed := int64(0)
truncateLocal := func(b []byte) string {
if len(b) > 2048 {
......@@ -5544,6 +6030,11 @@ func (h *ImportHandler) ImportMatchesWyscout(c *gin.Context) {
}
doGet := func(url string) ([]byte, int, error) {
select {
case <-ctx.Done():
return nil, 0, ctx.Err()
case <-rateTokens:
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
......@@ -5825,13 +6316,15 @@ func (h *ImportHandler) ImportMatchesWyscout(c *gin.Context) {
if s.WyID == nil || *s.WyID <= 0 {
continue
}
if limitSeasons > 0 && seasonsProcessed >= limitSeasons {
if limitSeasons > 0 && seasonsProcessed >= int64(limitSeasons) {
break
}
fetchMatchesForSeason(*s.WyID)
seasonsProcessed++
}
log.Printf("ImportMatchesWyscout: finished seasonsProcessed=%d imported=%d updated=%d errors=%d", seasonsProcessed, imported, updated, errorsCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Matches import completed",
......@@ -5852,6 +6345,8 @@ func (h *ImportHandler) ImportMatchesWyscout(c *gin.Context) {
// @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"
// @Param workers query int false "Concurrent workers (default 4)"
// @Param rps query int false "Requests per second (default 10)"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /import/competitions [post]
......@@ -5863,9 +6358,35 @@ func (h *ImportHandler) ImportCompetitions(c *gin.Context) {
return
}
ctx := c.Request.Context()
workers := 4
if v := strings.TrimSpace(c.Query("workers")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
workers = n
}
}
rps := 10
if v := strings.TrimSpace(c.Query("rps")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
rps = n
}
}
log.Printf("ImportCompetitions: start workers=%d rps=%d", workers, rps)
rateTokens := RateLimiter(ctx, rps)
fetchCompetitions := func(areaID string) (int, int, int) {
imported, updated, errorsCount := 0, 0, 0
select {
case <-ctx.Done():
return 0, 0, 1
case <-rateTokens:
}
url := fmt.Sprintf("https://apirest.wyscout.com/v3/competitions?areaId=%s", areaID)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
......@@ -5992,7 +6513,35 @@ func (h *ImportHandler) ImportCompetitions(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list areas"})
return
}
imported, updated, errorsCount := 0, 0, 0
totalAreas := int64(len(areas))
log.Printf("ImportCompetitions: areas to process=%d workers=%d", totalAreas, workers)
imported, updated, errorsCount := int64(0), int64(0), int64(0)
processed := int64(0)
startedAt := time.Now()
logProgress := func(force bool) {
if totalAreas <= 0 {
return
}
elapsed := time.Since(startedAt)
if elapsed <= 0 {
elapsed = time.Second
}
rpsNow := float64(processed) / elapsed.Seconds()
pct := (float64(processed) / float64(totalAreas)) * 100
remainingLeft := totalAreas - processed
eta := time.Duration(0)
if rpsNow > 0 && remainingLeft > 0 {
eta = time.Duration(float64(remainingLeft)/rpsNow) * time.Second
}
if force || processed%10 == 0 {
log.Printf("ImportCompetitions: progress processed=%d/%d (%.2f%%) imported=%d updated=%d errors=%d elapsed=%s rps=%.2f eta=%s",
processed, totalAreas, pct, imported, updated, errorsCount, elapsed.Truncate(time.Second), rpsNow, eta.Truncate(time.Second))
}
}
for _, a := range areas {
if a.WyID == "" {
continue
......@@ -6002,11 +6551,15 @@ func (h *ImportHandler) ImportCompetitions(c *gin.Context) {
areaID = strings.ToUpper(strings.TrimSpace(*a.Alpha3Code))
}
i, u, e := fetchCompetitions(areaID)
imported += i
updated += u
errorsCount += e
imported += int64(i)
updated += int64(u)
errorsCount += int64(e)
processed++
logProgress(false)
}
logProgress(true)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Competitions import completed",
......@@ -6023,6 +6576,7 @@ func (h *ImportHandler) ImportCompetitions(c *gin.Context) {
// @Summary Import areas from TheSports
// @Description Imports all countries/regions from TheSports football country list API.
// @Tags Import
// @Param workers query int false "Concurrent workers (default 4)"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /import/areas [post]
......@@ -6034,6 +6588,16 @@ func (h *ImportHandler) ImportAreas(c *gin.Context) {
return
}
workers := 4
if v := strings.TrimSpace(c.Query("workers")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
workers = n
}
}
log.Printf("ImportAreas: start workers=%d", workers)
startedAt := time.Now()
url := "https://apirest.wyscout.com/v3/areas"
req, err := http.NewRequest(http.MethodGet, url, nil)
......@@ -6045,7 +6609,7 @@ func (h *ImportHandler) ImportAreas(c *gin.Context) {
resp, err := h.Client.Do(req)
if err != nil {
log.Printf("failed to call Wyscout areas API: %v", err)
log.Printf("ImportAreas: failed to call Wyscout areas API: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to call provider API"})
return
}
......@@ -6073,26 +6637,96 @@ func (h *ImportHandler) ImportAreas(c *gin.Context) {
return
}
log.Printf("ImportAreas: provider results=%d", len(payload.Areas))
totalAreas := int64(len(payload.Areas))
log.Printf("ImportAreas: provider results=%d workers=%d", totalAreas, workers)
var imported, updated, errors int
imported, updated, errors := int64(0), int64(0), int64(0)
processed := int64(0)
for _, r := range payload.Areas {
if r.ID == 0 {
errors++
continue
logProgress := func(force bool) {
if totalAreas <= 0 {
return
}
wyID := strconv.Itoa(r.ID)
var area models.Area
if err := h.DB.Where("wy_id = ?", wyID).First(&area).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// new area: store Wyscout id in WyID, let DB/gorm generate primary key
area = models.Area{
WyID: wyID,
Name: r.Name,
Alpha2Code: r.Alpha2Code,
Alpha3Code: r.Alpha3Code,
elapsed := time.Since(startedAt)
if elapsed <= 0 {
elapsed = time.Second
}
rpsNow := float64(processed) / elapsed.Seconds()
pct := (float64(processed) / float64(totalAreas)) * 100
remainingLeft := totalAreas - processed
eta := time.Duration(0)
if rpsNow > 0 && remainingLeft > 0 {
eta = time.Duration(float64(remainingLeft)/rpsNow) * time.Second
}
if force || processed%50 == 0 {
log.Printf("ImportAreas: progress processed=%d/%d (%.2f%%) imported=%d updated=%d errors=%d elapsed=%s rps=%.2f eta=%s",
processed, totalAreas, pct, imported, updated, errors, elapsed.Truncate(time.Second), rpsNow, eta.Truncate(time.Second))
}
}
type job struct {
area struct {
ID int
Name string
Alpha2Code *string
Alpha3Code *string
Flag *string
Logo *string
ImageDataURL *string
}
}
type result struct {
imported bool
updated bool
err error
}
jobs := make(chan job, workers*4)
results := make(chan result, workers*4)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
r := j.area
if r.ID == 0 {
results <- result{err: fmt.Errorf("invalid ID")}
continue
}
wyID := strconv.Itoa(r.ID)
var area models.Area
if err := h.DB.Where("wy_id = ?", wyID).First(&area).Error; err != nil {
if err == gorm.ErrRecordNotFound {
area = models.Area{
WyID: wyID,
Name: r.Name,
Alpha2Code: r.Alpha2Code,
Alpha3Code: r.Alpha3Code,
}
if r.Flag != nil {
area.LogoURL = r.Flag
} else if r.Logo != nil {
area.LogoURL = r.Logo
} else if r.ImageDataURL != nil {
area.LogoURL = r.ImageDataURL
}
if err := h.DB.Create(&area).Error; err != nil {
results <- result{err: err}
continue
}
results <- result{imported: true}
continue
}
results <- result{err: err}
continue
}
area.WyID = wyID
area.Name = r.Name
area.Alpha2Code = r.Alpha2Code
area.Alpha3Code = r.Alpha3Code
if r.Flag != nil {
area.LogoURL = r.Flag
} else if r.Logo != nil {
......@@ -6100,37 +6734,59 @@ func (h *ImportHandler) ImportAreas(c *gin.Context) {
} else if r.ImageDataURL != nil {
area.LogoURL = r.ImageDataURL
}
if err := h.DB.Create(&area).Error; err != nil {
errors++
if err := h.DB.Save(&area).Error; err != nil {
results <- result{err: err}
continue
}
imported++
continue
results <- result{updated: true}
}
errors++
continue
}
}()
}
go func() {
wg.Wait()
close(results)
}()
// update existing
area.WyID = wyID
area.Name = r.Name
area.Alpha2Code = r.Alpha2Code
area.Alpha3Code = r.Alpha3Code
if r.Flag != nil {
area.LogoURL = r.Flag
} else if r.Logo != nil {
area.LogoURL = r.Logo
} else if r.ImageDataURL != nil {
area.LogoURL = r.ImageDataURL
go func() {
defer close(jobs)
for _, r := range payload.Areas {
jobs <- job{area: struct {
ID int
Name string
Alpha2Code *string
Alpha3Code *string
Flag *string
Logo *string
ImageDataURL *string
}{
ID: r.ID,
Name: r.Name,
Alpha2Code: r.Alpha2Code,
Alpha3Code: r.Alpha3Code,
Flag: r.Flag,
Logo: r.Logo,
ImageDataURL: r.ImageDataURL,
}}
}
}()
if err := h.DB.Save(&area).Error; err != nil {
for r := range results {
processed++
if r.imported {
imported++
} else if r.updated {
updated++
} else if r.err != nil {
errors++
continue
}
updated++
logProgress(false)
}
logProgress(true)
log.Printf("ImportAreas: finished processed=%d imported=%d updated=%d errors=%d", processed, imported, updated, errors)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Areas import completed",
......@@ -6151,6 +6807,8 @@ func (h *ImportHandler) ImportAreas(c *gin.Context) {
// @Param limitReferees query int false "Limit number of referee profiles fetched (Wyscout mode)"
// @Param pageSize query int false "Page size per request (default 100, only used for theSports mode)"
// @Param since query int false "Unix timestamp (seconds) to import only referees updated since this time (theSports mode)"
// @Param workers query int false "Concurrent workers (default 4)"
// @Param rps query int false "Requests per second (default 10)"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
......@@ -6163,6 +6821,22 @@ func (h *ImportHandler) ImportReferees(c *gin.Context) {
return
}
ctx := c.Request.Context()
workers := 4
if v := strings.TrimSpace(c.Query("workers")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
workers = n
}
}
rps := 10
if v := strings.TrimSpace(c.Query("rps")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
rps = n
}
}
source := strings.ToLower(strings.TrimSpace(c.DefaultQuery("source", "matches")))
limitRefereesStr := c.Query("limitReferees")
limitReferees := 0
......@@ -6172,6 +6846,10 @@ func (h *ImportHandler) ImportReferees(c *gin.Context) {
}
}
log.Printf("ImportReferees: start source=%s limitReferees=%d workers=%d rps=%d", source, limitReferees, workers, rps)
rateTokens := RateLimiter(ctx, rps)
truncate := func(b []byte) string {
if len(b) > 2048 {
b = b[:2048]
......@@ -6219,6 +6897,11 @@ func (h *ImportHandler) ImportReferees(c *gin.Context) {
}
doGetWyscout := func(url string) ([]byte, int, error) {
select {
case <-ctx.Done():
return nil, 0, ctx.Err()
case <-rateTokens:
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
......@@ -6532,6 +7215,8 @@ func (h *ImportHandler) ImportReferees(c *gin.Context) {
// @Param limitCoaches query int false "Limit number of coach profiles fetched"
// @Param limitTeams query int false "Limit number of teams processed when source=teams"
// @Param teamWyId query int false "Only discover coach from a specific team (only used when source=teams)"
// @Param workers query int false "Concurrent workers (default 4)"
// @Param rps query int false "Requests per second (default 10)"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
......@@ -6544,7 +7229,25 @@ func (h *ImportHandler) ImportCoaches(c *gin.Context) {
return
}
log.Printf("ImportCoaches: start query=%s", c.Request.URL.RawQuery)
ctx := c.Request.Context()
workers := 4
if v := strings.TrimSpace(c.Query("workers")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
workers = n
}
}
rps := 10
if v := strings.TrimSpace(c.Query("rps")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
rps = n
}
}
log.Printf("ImportCoaches: start query=%s workers=%d rps=%d", c.Request.URL.RawQuery, workers, rps)
rateTokens := RateLimiter(ctx, rps)
truncate := func(b []byte) string {
if len(b) > 2048 {
......@@ -6628,12 +7331,17 @@ func (h *ImportHandler) ImportCoaches(c *gin.Context) {
source := strings.ToLower(strings.TrimSpace(c.DefaultQuery("source", "matchTeams")))
imported, updated, errorsCount := 0, 0, 0
teamsProcessed := 0
matchTeamsProcessed := 0
coachProfilesFetched := 0
imported, updated, errorsCount := int64(0), int64(0), int64(0)
teamsProcessed := int64(0)
matchTeamsProcessed := int64(0)
coachProfilesFetched := int64(0)
doGet := func(url string) ([]byte, int, error) {
select {
case <-ctx.Done():
return nil, 0, ctx.Err()
case <-rateTokens:
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
......@@ -6707,7 +7415,7 @@ func (h *ImportHandler) ImportCoaches(c *gin.Context) {
coachIDs[id] = struct{}{}
}
}
matchTeamsProcessed = len(ids)
matchTeamsProcessed = int64(len(ids))
log.Printf("ImportCoaches: discovered coach IDs from match_teams distinct=%d", len(coachIDs))
} else {
// Legacy discovery path: by reading team profiles.
......@@ -6727,7 +7435,7 @@ func (h *ImportHandler) ImportCoaches(c *gin.Context) {
if t.WyID == nil || *t.WyID <= 0 {
continue
}
if limitTeams > 0 && teamsProcessed >= limitTeams {
if limitTeams > 0 && teamsProcessed >= int64(limitTeams) {
break
}
// Fast-path: use stored coach_wy_id if available.
......@@ -6887,10 +7595,12 @@ func (h *ImportHandler) ImportCoaches(c *gin.Context) {
// 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.
// @Description Performs a team import using TheSports team 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"
// @Param workers query int false "Concurrent workers (default 4)"
// @Param rps query int false "Requests per second (default 10)"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /import/teams [post]
......@@ -6902,6 +7612,8 @@ func (h *ImportHandler) ImportTeams(c *gin.Context) {
return
}
ctx := c.Request.Context()
limitStr := c.Query("limit")
limit := 0
if limitStr != "" {
......@@ -6910,10 +7622,33 @@ func (h *ImportHandler) ImportTeams(c *gin.Context) {
}
}
imported, updated, errorsCount := 0, 0, 0
childrenImported, childrenUpdated, childrenErrors := 0, 0, 0
workers := 4
if v := strings.TrimSpace(c.Query("workers")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
workers = n
}
}
rps := 10
if v := strings.TrimSpace(c.Query("rps")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
rps = n
}
}
log.Printf("ImportTeams: start limit=%d workers=%d rps=%d", limit, workers, rps)
rateTokens := RateLimiter(ctx, rps)
imported, updated, errorsCount := int64(0), int64(0), int64(0)
childrenImported, childrenUpdated, childrenErrors := int64(0), int64(0), int64(0)
doGet := func(url string) ([]byte, int, error) {
select {
case <-ctx.Done():
return nil, 0, ctx.Err()
case <-rateTokens:
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
......@@ -7177,15 +7912,45 @@ func (h *ImportHandler) ImportTeams(c *gin.Context) {
return
}
processedCompetitions := 0
totalComps := int64(len(comps))
log.Printf("ImportTeams: competitions to process=%d workers=%d", totalComps, workers)
processedCompetitions := int64(0)
startedAt := time.Now()
logProgress := func(force bool) {
if totalComps <= 0 {
return
}
elapsed := time.Since(startedAt)
if elapsed <= 0 {
elapsed = time.Second
}
rpsNow := float64(processedCompetitions) / elapsed.Seconds()
pct := (float64(processedCompetitions) / float64(totalComps)) * 100
remainingLeft := totalComps - processedCompetitions
eta := time.Duration(0)
if rpsNow > 0 && remainingLeft > 0 {
eta = time.Duration(float64(remainingLeft)/rpsNow) * time.Second
}
if force || processedCompetitions%10 == 0 {
log.Printf("ImportTeams: progress processed=%d/%d (%.2f%%) imported=%d updated=%d errors=%d elapsed=%s rps=%.2f eta=%s",
processedCompetitions, totalComps, pct, imported, updated, errorsCount, elapsed.Truncate(time.Second), rpsNow, eta.Truncate(time.Second))
}
}
for _, comp := range comps {
if comp.WyID == nil || *comp.WyID <= 0 {
continue
}
fetchAndUpsertForCompetition(*comp.WyID)
processedCompetitions++
logProgress(false)
}
logProgress(true)
log.Printf("ImportTeams: finished competitionsProcessed=%d imported=%d updated=%d errors=%d", processedCompetitions, imported, updated, errorsCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Teams import completed",
......@@ -7210,6 +7975,8 @@ func (h *ImportHandler) ImportTeams(c *gin.Context) {
// @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"
// @Param workers query int false "Concurrent workers (default 4)"
// @Param rps query int false "Requests per second (default 10)"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /import/players [post]
......@@ -7221,6 +7988,8 @@ func (h *ImportHandler) ImportPlayers(c *gin.Context) {
return
}
ctx := c.Request.Context()
pageSizeStr := c.DefaultQuery("pageSize", "100")
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil || pageSize <= 0 {
......@@ -7246,9 +8015,27 @@ func (h *ImportHandler) ImportPlayers(c *gin.Context) {
}
}
imported, updated, errorsCount := 0, 0, 0
pagesProcessed := 0
seasonsProcessed := 0
workers := 4
if v := strings.TrimSpace(c.Query("workers")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
workers = n
}
}
rps := 10
if v := strings.TrimSpace(c.Query("rps")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
rps = n
}
}
log.Printf("ImportPlayers: start pageSize=%d maxPages=%d limitSeasons=%d workers=%d rps=%d", pageSize, maxPages, limitSeasons, workers, rps)
rateTokens := RateLimiter(ctx, rps)
imported, updated, errorsCount := int64(0), int64(0), int64(0)
pagesProcessed := int64(0)
seasonsProcessed := int64(0)
truncate := func(b []byte) string {
if len(b) > 2048 {
......@@ -7284,6 +8071,11 @@ func (h *ImportHandler) ImportPlayers(c *gin.Context) {
}
doGet := func(url string) ([]byte, int, error) {
select {
case <-ctx.Done():
return nil, 0, ctx.Err()
case <-rateTokens:
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
......@@ -7620,17 +8412,49 @@ func (h *ImportHandler) ImportPlayers(c *gin.Context) {
return
}
totalSeasons := int64(len(seasons))
if limitSeasons > 0 && totalSeasons > int64(limitSeasons) {
totalSeasons = int64(limitSeasons)
}
log.Printf("ImportPlayers: seasons to process=%d workers=%d", totalSeasons, workers)
startedAt := time.Now()
logProgress := func(force bool) {
if totalSeasons <= 0 {
return
}
elapsed := time.Since(startedAt)
if elapsed <= 0 {
elapsed = time.Second
}
rpsNow := float64(seasonsProcessed) / elapsed.Seconds()
pct := (float64(seasonsProcessed) / float64(totalSeasons)) * 100
remainingLeft := totalSeasons - seasonsProcessed
eta := time.Duration(0)
if rpsNow > 0 && remainingLeft > 0 {
eta = time.Duration(float64(remainingLeft)/rpsNow) * time.Second
}
if force || seasonsProcessed%10 == 0 {
log.Printf("ImportPlayers: progress processed=%d/%d (%.2f%%) imported=%d updated=%d errors=%d elapsed=%s rps=%.2f eta=%s",
seasonsProcessed, totalSeasons, pct, imported, updated, errorsCount, elapsed.Truncate(time.Second), rpsNow, eta.Truncate(time.Second))
}
}
for _, s := range seasons {
if s.WyID == nil || *s.WyID <= 0 {
continue
}
if limitSeasons > 0 && seasonsProcessed >= limitSeasons {
if limitSeasons > 0 && seasonsProcessed >= int64(limitSeasons) {
break
}
fetchPlayersForSeason(*s.WyID)
seasonsProcessed++
logProgress(false)
}
logProgress(true)
log.Printf("ImportPlayers: finished seasonsProcessed=%d imported=%d updated=%d errors=%d", seasonsProcessed, imported, updated, errorsCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Players import completed",
......
package handlers
import (
"context"
"log"
"sync"
"sync/atomic"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"ScoutingSystemScoreData/internal/models"
)
// ImportWorkerConfig holds configuration for a worker-based import
type ImportWorkerConfig struct {
Name string
Workers int
BatchSize int
RPS int
Limit int
Reset bool
CheckpointKey string
LogProgressEvery int64
}
// ImportProgress tracks progress for logging
type ImportProgress struct {
StartedAt time.Time
ProcessedThisRun int64
ErrorsThisRun int64
Remaining int64
}
// LogProgress logs import progress with RPS and ETA
func (p *ImportProgress) LogProgress(name string, force bool, totalProcessed int64, lastID string) {
if p.Remaining <= 0 {
return
}
elapsed := time.Since(p.StartedAt)
if elapsed <= 0 {
elapsed = time.Second
}
rpsNow := float64(p.ProcessedThisRun) / elapsed.Seconds()
pct := (float64(p.ProcessedThisRun) / float64(p.Remaining)) * 100
remainingLeft := p.Remaining - p.ProcessedThisRun
eta := time.Duration(0)
if rpsNow > 0 && remainingLeft > 0 {
eta = time.Duration(float64(remainingLeft)/rpsNow) * time.Second
}
if force || p.ProcessedThisRun%2000 == 0 {
log.Printf(
"%s: progress processed=%d/%d (%.2f%%) errorsThisRun=%d totalProcessed=%d elapsed=%s rps=%.2f eta=%s last=%s",
name,
p.ProcessedThisRun,
p.Remaining,
pct,
p.ErrorsThisRun,
totalProcessed,
elapsed.Truncate(time.Second),
rpsNow,
eta.Truncate(time.Second),
lastID,
)
}
}
// RateLimiter creates a rate-limited token channel
func RateLimiter(ctx context.Context, rps int) chan struct{} {
rateTokens := make(chan struct{}, rps)
interval := time.Second / time.Duration(rps)
if interval <= 0 {
interval = time.Second
}
ticker := time.NewTicker(interval)
go func() {
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
select {
case rateTokens <- struct{}{}:
default:
}
}
}
}()
return rateTokens
}
// PersistCheckpoint saves checkpoint to database
func PersistCheckpoint(ctx context.Context, db *gorm.DB, ck *models.ImportCheckpoint) error {
now := time.Now().UTC()
ck.UpdatedAt = now
return db.WithContext(ctx).
Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "key"}}, UpdateAll: true}).
Create(ck).Error
}
// LoadCheckpoint loads or creates a checkpoint
func LoadCheckpoint(ctx context.Context, db *gorm.DB, key string, reset bool) (*models.ImportCheckpoint, error) {
var ck models.ImportCheckpoint
if err := db.WithContext(ctx).Where("key = ?", key).First(&ck).Error; err != nil {
if err == gorm.ErrRecordNotFound {
ck = models.ImportCheckpoint{Key: key}
} else {
return nil, err
}
}
if reset {
ck.LastPlayerWyID = 0
ck.LastTeamWyID = 0
ck.LastMatchWyID = 0
ck.LastCompetitionID = 0
ck.LastSeasonID = 0
ck.Processed = 0
ck.Errors = 0
}
return &ck, nil
}
// WorkerPool manages a pool of workers for concurrent processing
type WorkerPool[T any, R any] struct {
Workers int
Jobs chan T
Results chan R
wg sync.WaitGroup
}
// NewWorkerPool creates a new worker pool
func NewWorkerPool[T any, R any](workers int) *WorkerPool[T, R] {
return &WorkerPool[T, R]{
Workers: workers,
Jobs: make(chan T, workers*4),
Results: make(chan R, workers*4),
}
}
// Start starts the worker pool with the given work function
func (wp *WorkerPool[T, R]) Start(workFn func(T) R) {
for i := 0; i < wp.Workers; i++ {
wp.wg.Add(1)
go func() {
defer wp.wg.Done()
for job := range wp.Jobs {
result := workFn(job)
wp.Results <- result
}
}()
}
go func() {
wp.wg.Wait()
close(wp.Results)
}()
}
// Close closes the jobs channel
func (wp *WorkerPool[T, R]) Close() {
close(wp.Jobs)
}
// AsyncImportStatus tracks status for async imports
type AsyncImportStatus struct {
running atomic.Bool
startedAt atomic.Int64
finishedAt atomic.Int64
total atomic.Int64
processed atomic.Int64
created atomic.Int64
errors atomic.Int64
lastError atomic.Value
}
// GetStatus returns the current status as a map
func (s *AsyncImportStatus) GetStatus() map[string]interface{} {
startedAt := s.startedAt.Load()
finishedAt := s.finishedAt.Load()
resp := map[string]interface{}{
"running": s.running.Load(),
"total": s.total.Load(),
"processed": s.processed.Load(),
"created": s.created.Load(),
"errors": s.errors.Load(),
}
if total := s.total.Load(); total > 0 {
p := s.processed.Load()
remaining := total - p
if remaining < 0 {
remaining = 0
}
resp["remaining"] = remaining
}
if startedAt > 0 {
resp["startedAt"] = time.Unix(0, startedAt).UTC().Format(time.RFC3339)
}
if finishedAt > 0 {
resp["finishedAt"] = time.Unix(0, finishedAt).UTC().Format(time.RFC3339)
}
if v := s.lastError.Load(); v != nil {
if errStr, ok := v.(string); ok && errStr != "" {
resp["lastError"] = errStr
}
}
return resp
}
// Start marks the import as started
func (s *AsyncImportStatus) Start(total int64) {
s.running.Store(true)
s.startedAt.Store(time.Now().UTC().UnixNano())
s.finishedAt.Store(0)
s.total.Store(total)
s.processed.Store(0)
s.created.Store(0)
s.errors.Store(0)
s.lastError.Store("")
}
// Finish marks the import as finished
func (s *AsyncImportStatus) Finish(err error) {
s.running.Store(false)
s.finishedAt.Store(time.Now().UTC().UnixNano())
if err != nil {
s.lastError.Store(err.Error())
}
}
// Update updates progress counters
func (s *AsyncImportStatus) Update(processed, created, errors int64) {
s.processed.Store(processed)
s.created.Store(created)
s.errors.Store(errors)
}
......@@ -857,6 +857,8 @@ func valueOrDefault(s *string, def string) string {
// @Failure 500 {object} map[string]string
// @Router /players [get]
func (h *PlayerHandler) List(c *gin.Context) {
startTime := time.Now()
limitStr := c.DefaultQuery("limit", "100")
offsetStr := c.DefaultQuery("offset", "0")
......@@ -1081,17 +1083,19 @@ func (h *PlayerHandler) List(c *gin.Context) {
page := offset/limit + 1
hasMore := int64(offset+limit) < total
timestamp := time.Now().UTC().Format(time.RFC3339)
responseTime := time.Since(startTime).Milliseconds()
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,
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
"totalItems": total,
"page": page,
"limit": limit,
"hasMore": hasMore,
"responseTimeMs": responseTime,
},
})
}
......
package handlers
import (
"ScoutingSystemScoreData/internal/config"
"ScoutingSystemScoreData/internal/models"
"ScoutingSystemScoreData/internal/services"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type SyncHandler struct {
syncService *services.SyncService
cronService *services.CronService
}
func NewSyncHandler(db *gorm.DB, cfg config.Config) *SyncHandler {
return &SyncHandler{
syncService: services.NewSyncService(db, cfg),
cronService: services.NewCronService(db, cfg),
}
}
// TriggerSync godoc
// @Summary Trigger a manual sync for a specific resource type
// @Description Triggers a manual sync for updated objects from Wyscout API
// @Tags Sync
// @Accept json
// @Produce json
// @Param resource_type query string true "Resource type (players, teams, matches, etc.)"
// @Param hours_back query int false "Hours to look back (max 168)" default(24)
// @Success 200 {object} models.SyncLog
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /sync/trigger [post]
func (h *SyncHandler) TriggerSync(c *gin.Context) {
resourceType := c.Query("resource_type")
if resourceType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "resource_type is required"})
return
}
hoursBackStr := c.DefaultQuery("hours_back", "24")
hoursBack, err := strconv.Atoi(hoursBackStr)
if err != nil || hoursBack < 1 || hoursBack > 168 {
c.JSON(http.StatusBadRequest, gin.H{"error": "hours_back must be between 1 and 168"})
return
}
updatedSince := time.Now().Add(-time.Duration(hoursBack) * time.Hour)
syncLog, err := h.syncService.SyncResourceType(resourceType, updatedSince, "manual")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, syncLog)
}
// TriggerFullSync godoc
// @Summary Trigger a full sync for all resource types
// @Description Triggers a sync for all resource types from Wyscout API
// @Tags Sync
// @Accept json
// @Produce json
// @Param hours_back query int false "Hours to look back (max 168)" default(24)
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /sync/trigger-all [post]
func (h *SyncHandler) TriggerFullSync(c *gin.Context) {
hoursBackStr := c.DefaultQuery("hours_back", "24")
hoursBack, err := strconv.Atoi(hoursBackStr)
if err != nil || hoursBack < 1 || hoursBack > 168 {
c.JSON(http.StatusBadRequest, gin.H{"error": "hours_back must be between 1 and 168"})
return
}
updatedSince := time.Now().Add(-time.Duration(hoursBack) * time.Hour)
if err := h.syncService.SyncAllResources(updatedSince); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Full sync triggered successfully",
"updated_since": updatedSince.Format("2006-01-02 15:04:05"),
})
}
// GetSyncLogs godoc
// @Summary Get sync logs
// @Description Retrieves sync logs with optional filtering
// @Tags Sync
// @Accept json
// @Produce json
// @Param resource_type query string false "Filter by resource type"
// @Param status query string false "Filter by status"
// @Param limit query int false "Limit results" default(50)
// @Success 200 {array} models.SyncLog
// @Failure 500 {object} map[string]string
// @Router /sync/logs [get]
func (h *SyncHandler) GetSyncLogs(c *gin.Context) {
var logs []models.SyncLog
query := h.syncService.GetDB().Model(&models.SyncLog{})
if resourceType := c.Query("resource_type"); resourceType != "" {
query = query.Where("resource_type = ?", resourceType)
}
if status := c.Query("status"); status != "" {
query = query.Where("status = ?", status)
}
limitStr := c.DefaultQuery("limit", "50")
limit, _ := strconv.Atoi(limitStr)
if err := query.Order("started_at DESC").Limit(limit).Find(&logs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, logs)
}
// GetSyncStatus godoc
// @Summary Get sync status
// @Description Returns the current status of the sync service
// @Tags Sync
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /sync/status [get]
func (h *SyncHandler) GetSyncStatus(c *gin.Context) {
status := h.cronService.GetSyncStatus()
c.JSON(http.StatusOK, status)
}
// GetLastSyncTime godoc
// @Summary Get last sync time for a resource type
// @Description Returns the last successful sync time for a specific resource type
// @Tags Sync
// @Accept json
// @Produce json
// @Param resource_type query string true "Resource type"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /sync/last-sync [get]
func (h *SyncHandler) GetLastSyncTime(c *gin.Context) {
resourceType := c.Query("resource_type")
if resourceType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "resource_type is required"})
return
}
lastSync, err := h.syncService.GetLastSyncTime(resourceType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"resource_type": resourceType,
"last_sync": lastSync.Format(time.RFC3339),
"hours_ago": int(time.Since(lastSync).Hours()),
})
}
package handlers
import (
"ScoutingSystemScoreData/internal/config"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func RegisterSyncRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg config.Config) {
handler := NewSyncHandler(db, cfg)
sync := rg.Group("/sync")
{
sync.POST("/trigger", handler.TriggerSync)
sync.POST("/trigger-all", handler.TriggerFullSync)
sync.GET("/logs", handler.GetSyncLogs)
sync.GET("/status", handler.GetSyncStatus)
sync.GET("/last-sync", handler.GetLastSyncTime)
}
}
......@@ -586,6 +586,8 @@ func (h *TeamHandler) GetImagesByID(c *gin.Context) {
// @Failure 500 {object} map[string]string
// @Router /teams [get]
func (h *TeamHandler) List(c *gin.Context) {
startTime := time.Now()
limitStr := c.DefaultQuery("limit", "100")
offsetStr := c.DefaultQuery("offset", "0")
......@@ -715,17 +717,19 @@ func (h *TeamHandler) List(c *gin.Context) {
page := offset/limit + 1
hasMore := int64(offset+limit) < total
timestamp := time.Now().UTC().Format(time.RFC3339)
responseTime := time.Since(startTime).Milliseconds()
c.JSON(http.StatusOK, gin.H{
"data": withArea,
"meta": gin.H{
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
"totalItems": total,
"page": page,
"limit": limit,
"hasMore": hasMore,
"timestamp": timestamp,
"endpoint": endpoint,
"method": "GET",
"totalItems": total,
"page": page,
"limit": limit,
"hasMore": hasMore,
"responseTimeMs": responseTime,
},
})
}
......
package models
import (
"encoding/json"
"time"
gonanoid "github.com/matoous/go-nanoid/v2"
"gorm.io/gorm"
)
type SyncLog struct {
ID string `gorm:"primaryKey;size:16" json:"id"`
SyncType string `gorm:"column:sync_type;size:50;index" json:"syncType"`
ResourceType string `gorm:"column:resource_type;size:50;index" json:"resourceType"`
StartedAt time.Time `gorm:"column:started_at;index" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at" json:"completedAt"`
Status string `gorm:"column:status;size:20;default:running;index" json:"status"`
TotalRecords int `gorm:"column:total_records;default:0" json:"totalRecords"`
ProcessedRecords int `gorm:"column:processed_records;default:0" json:"processedRecords"`
FailedRecords int `gorm:"column:failed_records;default:0" json:"failedRecords"`
ErrorMessage *string `gorm:"column:error_message;type:text" json:"errorMessage"`
SyncParams json.RawMessage `gorm:"column:sync_params;type:jsonb" json:"syncParams" swaggertype:"object"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (s *SyncLog) 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 UpdatedObjectsResponse struct {
Total int `json:"total"`
Data []UpdatedObjectReference `json:"data"`
}
type UpdatedObjectReference struct {
ID int `json:"id"`
Payload interface{} `json:"payload,omitempty"`
}
type SyncParams struct {
UpdatedSince string `json:"updated_since"`
Type string `json:"type"`
Limit int `json:"limit"`
Page int `json:"page"`
EmptyPayload bool `json:"empty_payload"`
}
......@@ -131,6 +131,7 @@ func New(db *gorm.DB) *gin.Engine {
handlers.RegisterMatchRoutes(api, db)
handlers.RegisterStandingRoutes(api, db)
handlers.RegisterImportRoutes(api, db, appCfg)
handlers.RegisterSyncRoutes(api, db, appCfg)
return r
}
package services
import (
"ScoutingSystemScoreData/internal/config"
"fmt"
"log"
"time"
"gorm.io/gorm"
)
type CronService struct {
db *gorm.DB
config config.Config
syncService *SyncService
stopChan chan bool
running bool
}
func NewCronService(db *gorm.DB, cfg config.Config) *CronService {
return &CronService{
db: db,
config: cfg,
syncService: NewSyncService(db, cfg),
stopChan: make(chan bool),
running: false,
}
}
// Start begins the cron scheduler
func (c *CronService) Start() {
if c.running {
log.Println("Cron service is already running")
return
}
c.running = true
log.Println("Starting cron service...")
// Run initial sync on startup (optional)
// c.runIncrementalSync()
// Start ticker for hourly syncs
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.runIncrementalSync()
case <-c.stopChan:
log.Println("Stopping cron service...")
c.running = false
return
}
}
}
// Stop stops the cron scheduler
func (c *CronService) Stop() {
if c.running {
c.stopChan <- true
}
}
// runIncrementalSync runs an incremental sync for all resources
func (c *CronService) runIncrementalSync() {
log.Println("Starting incremental sync...")
// Get the time from 1 hour ago (to catch any updates in the last hour)
updatedSince := time.Now().Add(-1 * time.Hour)
resourceTypes := []string{
"players",
"teams",
"matches",
"coaches",
"referees",
"competitions",
"seasons",
}
for _, resourceType := range resourceTypes {
log.Printf("Syncing %s...", resourceType)
syncLog, err := c.syncService.SyncResourceType(resourceType, updatedSince, "incremental")
if err != nil {
log.Printf("Error syncing %s: %v", resourceType, err)
continue
}
log.Printf("Completed sync for %s: %d processed, %d failed",
resourceType, syncLog.ProcessedRecords, syncLog.FailedRecords)
}
log.Println("Incremental sync completed")
}
// RunDailySync runs a daily sync (last 24 hours)
func (c *CronService) RunDailySync() {
log.Println("Starting daily sync...")
updatedSince := time.Now().Add(-24 * time.Hour)
if err := c.syncService.SyncAllResources(updatedSince); err != nil {
log.Printf("Error during daily sync: %v", err)
}
log.Println("Daily sync completed")
}
// RunWeeklySync runs a weekly sync (last 7 days - max allowed by API)
func (c *CronService) RunWeeklySync() {
log.Println("Starting weekly sync...")
// API allows max 168 hours (7 days) lookback
updatedSince := time.Now().Add(-168 * time.Hour)
if err := c.syncService.SyncAllResources(updatedSince); err != nil {
log.Printf("Error during weekly sync: %v", err)
}
log.Println("Weekly sync completed")
}
// ScheduleCustomSync schedules a custom sync with specific parameters
func (c *CronService) ScheduleCustomSync(resourceType string, hoursBack int) error {
if hoursBack > 168 {
return fmt.Errorf("cannot go back more than 168 hours (API limitation)")
}
updatedSince := time.Now().Add(-time.Duration(hoursBack) * time.Hour)
log.Printf("Starting custom sync for %s (last %d hours)...", resourceType, hoursBack)
syncLog, err := c.syncService.SyncResourceType(resourceType, updatedSince, "manual")
if err != nil {
return fmt.Errorf("custom sync failed: %w", err)
}
log.Printf("Custom sync completed: %d processed, %d failed",
syncLog.ProcessedRecords, syncLog.FailedRecords)
return nil
}
// GetSyncStatus returns the current sync status
func (c *CronService) GetSyncStatus() map[string]interface{} {
return map[string]interface{}{
"running": c.running,
"last_check": time.Now().Format(time.RFC3339),
}
}
......@@ -2,12 +2,15 @@ package services
import (
"context"
"fmt"
"log"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"ScoutingSystemScoreData/internal/config"
"ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models"
"ScoutingSystemScoreData/internal/utils"
......@@ -42,6 +45,13 @@ func NewPlayerService(db *gorm.DB) PlayerService {
}
func (s *playerService) ListPlayers(ctx context.Context, opts ListPlayersOptions) ([]models.Player, int64, error) {
startTime := time.Now()
log.Printf("[PERF] PlayerSearch START: name=%q teamId=%q gender=%q", opts.Name, opts.TeamID, opts.Gender)
defer func() {
elapsed := time.Since(startTime)
log.Printf("[PERF] PlayerSearch TOTAL: duration=%dms", elapsed.Milliseconds())
}()
var players []models.Player
baseQuery := s.db.WithContext(ctx).Model(&models.Player{}).Where("players.is_active = ?", true)
......@@ -68,86 +78,158 @@ func (s *playerService) ListPlayers(ctx context.Context, opts ListPlayersOptions
}
}
// Apply teamId filter if provided (must be before name search to ensure it's applied)
if opts.TeamID != "" {
baseQuery = baseQuery.Where("current_team_id = ?", opts.TeamID)
}
// Apply country filter if provided
if opts.Country != "" {
baseQuery = baseQuery.Joins("JOIN areas ON areas.wy_id = players.birth_area_wy_id").Where("areas.name = ?", opts.Country)
}
if opts.Name != "" {
// Normalize search term to lowercase for index-friendly queries
t1 := time.Now()
searchLower := strings.ToLower(strings.TrimSpace(opts.Name))
likePattern := "%" + searchLower + "%"
searchTokens := utils.TokenizeSearchTerm(opts.Name)
// Join with teams table to enable team name search
baseQuery = baseQuery.Joins("LEFT JOIN teams ON teams.wy_id = players.current_team_id")
// Build gender condition
genderCondition := ""
if strings.TrimSpace(opts.Gender) != "" {
genderCondition = "AND players.gender = ?"
}
// Build search conditions using LOWER() which can use GIN trigram indexes
// Search in: player names AND team names
searchConditions := s.db.Where(
"unaccent(LOWER(players.short_name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(players.first_name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(players.middle_name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(players.last_name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(teams.name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(teams.short_name)) ILIKE unaccent(?)",
likePattern, likePattern, likePattern, likePattern, likePattern, likePattern,
)
// Build UNION query with multiple search strategies
var unionParts []string
var queryArgs []interface{}
// Strategy 1: Full phrase in player name
unionParts = append(unionParts, fmt.Sprintf(`
SELECT DISTINCT players.id FROM players
WHERE players.is_active = true
%s
AND (
LOWER(players.short_name) ILIKE ? OR
LOWER(players.first_name) ILIKE ? OR
LOWER(players.last_name) ILIKE ?
)`, genderCondition))
if genderCondition != "" {
queryArgs = append(queryArgs, opts.Gender)
}
queryArgs = append(queryArgs, likePattern, likePattern, likePattern)
// For multi-word searches (e.g., "ronaldo alnassr", "samu porto"):
// Match if tokens appear across player name + team name fields
// Strategy 2: Full phrase in team name
unionParts = append(unionParts, fmt.Sprintf(`
SELECT DISTINCT players.id FROM players
INNER JOIN teams ON teams.wy_id = players.current_team_id
WHERE players.is_active = true
%s
AND (
LOWER(teams.name) ILIKE ? OR
LOWER(teams.short_name) ILIKE ?
)`, genderCondition))
if genderCondition != "" {
queryArgs = append(queryArgs, opts.Gender)
}
queryArgs = append(queryArgs, likePattern, likePattern)
// Strategy 3: Multi-word - player name + team name (e.g., "samu porto")
if len(searchTokens) > 1 {
// Strategy: each token must match somewhere (player name OR team name)
tokenFilter := s.db
for _, token := range searchTokens {
tokenPattern := "%" + token + "%"
tokenFilter = tokenFilter.Where(
"unaccent(LOWER(players.short_name)) ILIKE unaccent(?) OR unaccent(LOWER(players.first_name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(players.middle_name)) ILIKE unaccent(?) OR unaccent(LOWER(players.last_name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(teams.name)) ILIKE unaccent(?) OR unaccent(LOWER(teams.short_name)) ILIKE unaccent(?)",
tokenPattern, tokenPattern, tokenPattern, tokenPattern, tokenPattern, tokenPattern,
)
for i, playerToken := range searchTokens {
for j, teamToken := range searchTokens {
if i != j {
playerPattern := "%" + playerToken + "%"
teamPattern := "%" + teamToken + "%"
unionParts = append(unionParts, fmt.Sprintf(`
SELECT DISTINCT players.id FROM players
INNER JOIN teams ON teams.wy_id = players.current_team_id
WHERE players.is_active = true
%s
AND (
LOWER(players.short_name) ILIKE ? OR
LOWER(players.first_name) ILIKE ? OR
LOWER(players.last_name) ILIKE ?
)
AND (
LOWER(teams.name) ILIKE ? OR
LOWER(teams.short_name) ILIKE ?
)`, genderCondition))
if genderCondition != "" {
queryArgs = append(queryArgs, opts.Gender)
}
queryArgs = append(queryArgs, playerPattern, playerPattern, playerPattern, teamPattern, teamPattern)
}
}
}
searchConditions = searchConditions.Or(tokenFilter)
}
baseQuery = baseQuery.Where(searchConditions)
// Optimized ranking: prioritize ShortName matches first
// Using simple LOWER() comparisons that can leverage indexes
unionSQL := strings.Join(unionParts, "\nUNION\n")
var playerIDs []string
if err := s.db.Raw(unionSQL, queryArgs...).Scan(&playerIDs).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to search players", err)
}
log.Printf("[PERF] UNION search: %dms (found %d players)", time.Since(t1).Milliseconds(), len(playerIDs))
if len(playerIDs) == 0 {
return []models.Player{}, 0, nil
}
// Now fetch full player data with team join for sorting
baseQuery = baseQuery.Where("players.id IN ?", playerIDs)
baseQuery = baseQuery.Joins("LEFT JOIN teams ON teams.wy_id = players.current_team_id")
// Apply sorting to matched players
fetchQuery := baseQuery
fetchQuery = fetchQuery.Order(clause.Expr{SQL: "CASE " +
"WHEN unaccent(LOWER(players.short_name)) = unaccent(?) THEN 0 " +
"WHEN unaccent(LOWER(players.short_name)) ILIKE unaccent(?) THEN 1 " +
"WHEN unaccent(LOWER(players.last_name)) = unaccent(?) THEN 2 " +
"WHEN unaccent(LOWER(players.first_name)) = unaccent(?) THEN 3 " +
"WHEN unaccent(LOWER(players.last_name)) ILIKE unaccent(?) THEN 4 " +
"WHEN unaccent(LOWER(players.first_name)) ILIKE unaccent(?) THEN 5 " +
// Primary sort: name match quality (using raw SQL with parameter substitution)
nameMatchSQL := fmt.Sprintf("CASE "+
"WHEN LOWER(players.short_name) = '%s' THEN 0 "+
"WHEN LOWER(players.last_name) = '%s' THEN 1 "+
"WHEN LOWER(players.first_name) = '%s' THEN 2 "+
"WHEN LOWER(players.short_name) ILIKE '%s' THEN 3 "+
"WHEN LOWER(players.last_name) ILIKE '%s' THEN 4 "+
"WHEN LOWER(players.first_name) ILIKE '%s' THEN 5 "+
"ELSE 6 END",
Vars: []interface{}{searchLower, likePattern, searchLower, searchLower, likePattern, likePattern},
})
searchLower, searchLower, searchLower, likePattern, likePattern, likePattern,
)
fetchQuery = fetchQuery.Order(nameMatchSQL)
// Secondary sort: prioritize players with market value (professional/active players)
fetchQuery = fetchQuery.Order("CASE WHEN players.market_value IS NOT NULL AND players.market_value > 0 THEN 0 ELSE 1 END")
fetchQuery = fetchQuery.Order("players.market_value DESC NULLS LAST")
// Secondary sort: if teamId filter is applied, prioritize exact team matches
if opts.TeamID != "" {
teamMatchSQL := fmt.Sprintf("CASE WHEN players.current_team_id::text = '%s' THEN 0 ELSE 1 END", opts.TeamID)
fetchQuery = fetchQuery.Order(teamMatchSQL)
}
// Tertiary sort: players with current team
fetchQuery = fetchQuery.Order("CASE WHEN players.current_team_id IS NOT NULL THEN 0 ELSE 1 END")
// Tertiary sort: team category (main teams > youth teams)
fetchQuery = fetchQuery.Order("CASE WHEN teams.category = 'default' THEN 0 WHEN teams.category = 'youth' THEN 1 ELSE 2 END")
// Final sort: alphabetical
fetchQuery = fetchQuery.Order("players.last_name ASC")
fetchQuery = fetchQuery.Order("players.first_name ASC")
// Quaternary sort: league tier + international status
fetchQuery = fetchQuery.Order(config.BuildLeagueTierCaseSQL("teams"))
fetchQuery = fetchQuery.Order("CASE WHEN players.current_national_team_id IS NOT NULL THEN 0 ELSE 1 END")
// TODO: Uncomment when market_value data is populated
// fetchQuery = fetchQuery.Order("players.market_value DESC NULLS LAST")
log.Printf("[PERF] ORDER BY clauses built: %dms", time.Since(t1).Milliseconds())
t3 := time.Now()
var total int64
if err := baseQuery.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to count players", err)
}
log.Printf("[PERF] COUNT query: %dms (total=%d)", time.Since(t3).Milliseconds(), total)
// Select only player fields to avoid conflicts with joined tables
t4 := time.Now()
fetchQuery = fetchQuery.Select("players.*")
if err := fetchQuery.Limit(opts.Limit).Offset(opts.Offset).Find(&players).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeInternal, "failed to fetch players", err)
}
log.Printf("[PERF] SELECT query: %dms (rows=%d)", time.Since(t4).Milliseconds(), len(players))
return players, total, nil
} else if opts.TeamID != "" {
baseQuery = baseQuery.Where("current_team_id = ?", opts.TeamID)
} else if opts.Country != "" {
baseQuery = baseQuery.Joins("JOIN areas ON areas.wy_id = players.birth_area_wy_id").Where("areas.name = ?", opts.Country)
}
var total int64
......
package services
import (
"ScoutingSystemScoreData/internal/config"
"ScoutingSystemScoreData/internal/models"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"gorm.io/gorm"
)
type SyncService struct {
db *gorm.DB
config config.Config
client *http.Client
}
func NewSyncService(db *gorm.DB, cfg config.Config) *SyncService {
return &SyncService{
db: db,
config: cfg,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// GetUpdatedObjects fetches updated objects from Wyscout API
func (s *SyncService) GetUpdatedObjects(resourceType string, updatedSince time.Time, limit int, page int, emptyPayload bool) (*models.UpdatedObjectsResponse, error) {
baseURL := "https://apirest.wyscout.com/v4/updatedobjects/updated"
// Format time as required: YYYY-MM-DD HH:MM:SS
timeStr := updatedSince.Format("2006-01-02 15:04:05")
url := fmt.Sprintf("%s?updated_since=%s&type=%s&limit=%d&page=%d",
baseURL, timeStr, resourceType, limit, page)
if emptyPayload {
url += "&emptyPayload=true"
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Add Basic Auth
auth := base64.StdEncoding.EncodeToString([]byte(s.config.ProviderUser + ":" + s.config.ProviderSecret))
req.Header.Add("Authorization", "Basic "+auth)
req.Header.Add("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var result models.UpdatedObjectsResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
return &result, nil
}
// SyncResourceType syncs a specific resource type (players, teams, matches, etc.)
func (s *SyncService) SyncResourceType(resourceType string, updatedSince time.Time, syncType string) (*models.SyncLog, error) {
// Create sync log
syncParams := models.SyncParams{
UpdatedSince: updatedSince.Format("2006-01-02 15:04:05"),
Type: resourceType,
Limit: 100,
Page: 1,
EmptyPayload: false,
}
paramsJSON, _ := json.Marshal(syncParams)
syncLog := &models.SyncLog{
SyncType: syncType,
ResourceType: resourceType,
StartedAt: time.Now(),
Status: "running",
SyncParams: paramsJSON,
}
if err := s.db.Create(syncLog).Error; err != nil {
return nil, fmt.Errorf("failed to create sync log: %w", err)
}
// Fetch all pages
totalProcessed := 0
totalFailed := 0
page := 1
for {
response, err := s.GetUpdatedObjects(resourceType, updatedSince, 100, page, false)
if err != nil {
errMsg := err.Error()
syncLog.Status = "failed"
syncLog.ErrorMessage = &errMsg
completedAt := time.Now()
syncLog.CompletedAt = &completedAt
s.db.Save(syncLog)
return syncLog, err
}
if len(response.Data) == 0 {
break
}
// Process each updated object
for _, obj := range response.Data {
if err := s.processUpdatedObject(resourceType, obj); err != nil {
totalFailed++
} else {
totalProcessed++
}
}
// Update sync log progress
syncLog.ProcessedRecords = totalProcessed
syncLog.FailedRecords = totalFailed
syncLog.TotalRecords = response.Total
s.db.Save(syncLog)
// Check if we've processed all records
if totalProcessed+totalFailed >= response.Total {
break
}
page++
}
// Mark sync as completed
completedAt := time.Now()
syncLog.CompletedAt = &completedAt
syncLog.Status = "completed"
if totalFailed > 0 && totalProcessed > 0 {
syncLog.Status = "partial"
} else if totalFailed > 0 {
syncLog.Status = "failed"
}
s.db.Save(syncLog)
return syncLog, nil
}
// processUpdatedObject processes a single updated object based on resource type
func (s *SyncService) processUpdatedObject(resourceType string, obj models.UpdatedObjectReference) error {
switch resourceType {
case "players":
return s.syncPlayer(obj.ID)
case "teams":
return s.syncTeam(obj.ID)
case "matches":
return s.syncMatch(obj.ID)
case "coaches":
return s.syncCoach(obj.ID)
case "referees":
return s.syncReferee(obj.ID)
case "competitions":
return s.syncCompetition(obj.ID)
case "seasons":
return s.syncSeason(obj.ID)
case "areas":
return s.syncArea(obj.ID)
default:
return fmt.Errorf("unsupported resource type: %s", resourceType)
}
}
// Individual sync methods for each resource type
func (s *SyncService) syncPlayer(wyID int) error {
// Update the player's api_last_synced_at timestamp
now := time.Now()
result := s.db.Model(&models.Player{}).
Where("wy_id = ?", wyID).
Updates(map[string]interface{}{
"api_last_synced_at": now,
"api_sync_status": "synced",
})
if result.Error != nil {
return fmt.Errorf("failed to update player %d: %w", wyID, result.Error)
}
// If player doesn't exist, mark for full fetch
if result.RowsAffected == 0 {
// TODO: Trigger full player fetch from API
return fmt.Errorf("player %d not found in database, needs full fetch", wyID)
}
return nil
}
func (s *SyncService) syncTeam(wyID int) error {
now := time.Now()
result := s.db.Model(&models.Team{}).
Where("wy_id = ?", wyID).
Updates(map[string]interface{}{
"api_last_synced_at": now,
"api_sync_status": "synced",
})
if result.Error != nil {
return fmt.Errorf("failed to update team %d: %w", wyID, result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("team %d not found in database, needs full fetch", wyID)
}
return nil
}
func (s *SyncService) syncMatch(wyID int) error {
now := time.Now()
result := s.db.Model(&models.Match{}).
Where("wy_id = ?", wyID).
Updates(map[string]interface{}{
"api_last_synced_at": now,
"api_sync_status": "synced",
})
if result.Error != nil {
return fmt.Errorf("failed to update match %d: %w", wyID, result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("match %d not found in database, needs full fetch", wyID)
}
return nil
}
func (s *SyncService) syncCoach(wyID int) error {
now := time.Now()
result := s.db.Model(&models.Coach{}).
Where("wy_id = ?", wyID).
Updates(map[string]interface{}{
"api_last_synced_at": now,
"api_sync_status": "synced",
})
if result.Error != nil {
return fmt.Errorf("failed to update coach %d: %w", wyID, result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("coach %d not found in database, needs full fetch", wyID)
}
return nil
}
func (s *SyncService) syncReferee(wyID int) error {
now := time.Now()
result := s.db.Model(&models.Referee{}).
Where("wy_id = ?", wyID).
Updates(map[string]interface{}{
"api_last_synced_at": now,
"api_sync_status": "synced",
})
if result.Error != nil {
return fmt.Errorf("failed to update referee %d: %w", wyID, result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("referee %d not found in database, needs full fetch", wyID)
}
return nil
}
func (s *SyncService) syncCompetition(wyID int) error {
// Competitions don't have api_last_synced_at, just update updated_at
result := s.db.Model(&models.Competition{}).
Where("wy_id = ?", wyID).
Update("updated_at", time.Now())
if result.Error != nil {
return fmt.Errorf("failed to update competition %d: %w", wyID, result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("competition %d not found in database, needs full fetch", wyID)
}
return nil
}
func (s *SyncService) syncSeason(wyID int) error {
result := s.db.Model(&models.Season{}).
Where("wy_id = ?", wyID).
Update("updated_at", time.Now())
if result.Error != nil {
return fmt.Errorf("failed to update season %d: %w", wyID, result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("season %d not found in database, needs full fetch", wyID)
}
return nil
}
func (s *SyncService) syncArea(wyID int) error {
result := s.db.Model(&models.Area{}).
Where("wy_id = ?", wyID).
Update("updated_at", time.Now())
if result.Error != nil {
return fmt.Errorf("failed to update area %d: %w", wyID, result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("area %d not found in database, needs full fetch", wyID)
}
return nil
}
// GetLastSyncTime returns the last successful sync time for a resource type
func (s *SyncService) GetLastSyncTime(resourceType string) (time.Time, error) {
var syncLog models.SyncLog
err := s.db.Where("resource_type = ? AND status = ?", resourceType, "completed").
Order("completed_at DESC").
First(&syncLog).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
// Return 1 week ago as default
return time.Now().Add(-168 * time.Hour), nil
}
return time.Time{}, err
}
if syncLog.CompletedAt != nil {
return *syncLog.CompletedAt, nil
}
return time.Now().Add(-168 * time.Hour), nil
}
// SyncAllResources syncs all supported resource types
func (s *SyncService) SyncAllResources(updatedSince time.Time) error {
resourceTypes := []string{
"players",
"teams",
"matches",
"coaches",
"referees",
"competitions",
"seasons",
"areas",
}
for _, resourceType := range resourceTypes {
if _, err := s.SyncResourceType(resourceType, updatedSince, "incremental"); err != nil {
// Log error but continue with other resources
fmt.Printf("Error syncing %s: %v\n", resourceType, err)
}
}
return nil
}
// GetDB exposes the database instance for query building
func (s *SyncService) GetDB() *gorm.DB {
return s.db
}
......@@ -6,9 +6,12 @@ import (
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"ScoutingSystemScoreData/internal/config"
"ScoutingSystemScoreData/internal/errors"
"ScoutingSystemScoreData/internal/models"
"ScoutingSystemScoreData/internal/utils"
)
type TeamService interface {
......@@ -32,8 +35,77 @@ func NewTeamService(db *gorm.DB) TeamService {
func (s *teamService) ListTeams(ctx context.Context, limit, offset int, name, gender, teamType string) ([]models.Team, int64, error) {
var teams []models.Team
query := s.db.WithContext(ctx).Model(&models.Team{})
if name != "" {
query = query.Where("name ILIKE ?", "%"+name+"%")
// Normalize search term to lowercase for index-friendly queries
searchLower := strings.ToLower(strings.TrimSpace(name))
likePattern := "%" + searchLower + "%"
searchTokens := utils.TokenizeSearchTerm(name)
// Two-tier search strategy:
// 1. Fast search using LOWER() with trigram indexes
// 2. Fallback to unaccent() for accent-insensitive search
// Tier 1: Fast indexed search on name, short_name, and official_name
searchConditions := s.db.Where(
"LOWER(teams.name) ILIKE ? OR "+
"LOWER(teams.short_name) ILIKE ? OR "+
"LOWER(teams.official_name) ILIKE ?",
likePattern, likePattern, likePattern,
)
// For multi-word searches (e.g., "sporting de braga", "real madrid")
// Each token must match somewhere in name/short_name/official_name
if len(searchTokens) > 1 {
tokenFilter := s.db
for _, token := range searchTokens {
tokenPattern := "%" + token + "%"
tokenFilter = tokenFilter.Where(
"LOWER(teams.name) ILIKE ? OR "+
"LOWER(teams.short_name) ILIKE ? OR "+
"LOWER(teams.official_name) ILIKE ?",
tokenPattern, tokenPattern, tokenPattern,
)
}
searchConditions = searchConditions.Or(tokenFilter)
}
// Tier 2: Accent-insensitive fallback (for names with accents)
accentSearchConditions := s.db.Where(
"unaccent(LOWER(teams.name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(teams.short_name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(teams.official_name)) ILIKE unaccent(?)",
likePattern, likePattern, likePattern,
)
if len(searchTokens) > 1 {
accentTokenFilter := s.db
for _, token := range searchTokens {
tokenPattern := "%" + token + "%"
accentTokenFilter = accentTokenFilter.Where(
"unaccent(LOWER(teams.name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(teams.short_name)) ILIKE unaccent(?) OR "+
"unaccent(LOWER(teams.official_name)) ILIKE unaccent(?)",
tokenPattern, tokenPattern, tokenPattern,
)
}
accentSearchConditions = accentSearchConditions.Or(accentTokenFilter)
}
// Combine both search strategies with OR
query = query.Where(s.db.Where(searchConditions).Or(accentSearchConditions))
// Optimized ranking: prioritize exact and partial matches
query = query.Order(clause.Expr{SQL: "CASE " +
"WHEN LOWER(teams.name) = ? THEN 0 " +
"WHEN LOWER(teams.short_name) = ? THEN 1 " +
"WHEN LOWER(teams.official_name) = ? THEN 2 " +
"WHEN LOWER(teams.name) ILIKE ? THEN 3 " +
"WHEN LOWER(teams.short_name) ILIKE ? THEN 4 " +
"WHEN LOWER(teams.official_name) ILIKE ? THEN 5 " +
"ELSE 6 END",
Vars: []interface{}{searchLower, searchLower, searchLower, likePattern, likePattern, likePattern},
})
}
if strings.TrimSpace(gender) != "" {
......@@ -43,11 +115,24 @@ func (s *teamService) ListTeams(ctx context.Context, limit, offset int, name, ge
query = query.Where("type = ?", strings.ToLower(strings.TrimSpace(teamType)))
}
// Prioritize main teams over youth/reserve teams
query = query.Order("CASE WHEN category = 'default' THEN 0 WHEN category = 'youth' THEN 1 ELSE 2 END")
// Prioritize teams from top leagues using league tier configuration
query = query.Order(clause.Expr{SQL: config.BuildLeagueTierCaseSQL("teams")})
// Prioritize teams with market value
query = query.Order("CASE WHEN market_value IS NULL THEN 1 ELSE 0 END")
query = query.Order("market_value DESC")
// Active teams first
query = query.Order("is_active DESC")
// Teams with competition/league data
query = query.Order("CASE WHEN competition_ts_id IS NULL OR competition_ts_id = '' THEN 1 ELSE 0 END")
query = query.Order("CASE WHEN league IS NULL OR league = '' THEN 1 ELSE 0 END")
// Alphabetical fallback
query = query.Order("name ASC")
var total int64
......
-- Migration: Create sync_logs table for tracking API sync operations
-- This table tracks all sync operations from the Wyscout updatedobjects endpoint
CREATE TABLE IF NOT EXISTS sync_logs (
id VARCHAR(16) PRIMARY KEY,
sync_type VARCHAR(50) NOT NULL,
resource_type VARCHAR(50) NOT NULL,
started_at TIMESTAMP WITH TIME ZONE NOT NULL,
completed_at TIMESTAMP WITH TIME ZONE,
status VARCHAR(20) NOT NULL DEFAULT 'running',
total_records INTEGER DEFAULT 0,
processed_records INTEGER DEFAULT 0,
failed_records INTEGER DEFAULT 0,
error_message TEXT,
sync_params JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_sync_logs_sync_type ON sync_logs(sync_type);
CREATE INDEX idx_sync_logs_resource_type ON sync_logs(resource_type);
CREATE INDEX idx_sync_logs_status ON sync_logs(status);
CREATE INDEX idx_sync_logs_started_at ON sync_logs(started_at DESC);
COMMENT ON TABLE sync_logs IS 'Tracks all API sync operations from Wyscout updatedobjects endpoint';
COMMENT ON COLUMN sync_logs.sync_type IS 'Type of sync: incremental, full, manual';
COMMENT ON COLUMN sync_logs.resource_type IS 'Wyscout resource type: players, teams, matches, etc.';
COMMENT ON COLUMN sync_logs.status IS 'Sync status: running, completed, failed, partial';
COMMENT ON COLUMN sync_logs.sync_params IS 'JSON parameters used for the sync (updated_since, limit, etc.)';
#!/bin/bash
# Script to restore PostgreSQL dump with proper user mapping
# Usage: ./clean_restore.sh <dump_file> <database_name>
set -e
DUMP_FILE=$1
DATABASE=${2:-postgres}
DB_USER="elitedata"
DB_PASSWORD="9NrwgfW1T861oTAQ"
if [ -z "$DUMP_FILE" ]; then
echo "Usage: $0 <dump_file> [database_name]"
echo "Example: $0 backup.dump postgres"
exit 1
fi
if [ ! -f "$DUMP_FILE" ]; then
echo "Error: Dump file '$DUMP_FILE' not found"
exit 1
fi
echo "=========================================="
echo "PostgreSQL Restore Script"
echo "=========================================="
echo "Dump file: $DUMP_FILE"
echo "Database: $DATABASE"
echo "User: $DB_USER"
echo "=========================================="
# Step 1: Prepare the database
echo ""
echo "Step 1: Preparing database with correct permissions..."
PGPASSWORD=$DB_PASSWORD psql -U $DB_USER -d $DATABASE -f prepare_dev_database.sql
# Step 2: Restore with user mapping
echo ""
echo "Step 2: Restoring dump with user mapping..."
echo "Note: Some errors about existing objects are expected and can be ignored"
echo ""
# Use pg_restore with options to handle conflicts
PGPASSWORD=$DB_PASSWORD pg_restore \
--username=$DB_USER \
--dbname=$DATABASE \
--no-owner \
--no-privileges \
--role=$DB_USER \
--clean \
--if-exists \
--verbose \
"$DUMP_FILE" 2>&1 | tee restore.log || true
# Step 3: Fix ownership after restore
echo ""
echo "Step 3: Fixing ownership of all objects..."
PGPASSWORD=$DB_PASSWORD psql -U $DB_USER -d $DATABASE -f prepare_dev_database.sql
echo ""
echo "=========================================="
echo "Restore completed!"
echo "Check restore.log for details"
echo "=========================================="
-- Prepare Development Database for Restore
-- This script sets up the correct user permissions and extensions
-- 1. Ensure the elitedata user exists and has proper permissions
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'elitedata') THEN
CREATE ROLE elitedata WITH LOGIN PASSWORD '9NrwgfW1T861oTAQ';
END IF;
END
$$;
-- 2. Grant necessary privileges to elitedata
GRANT ALL PRIVILEGES ON DATABASE postgres TO elitedata;
GRANT ALL PRIVILEGES ON SCHEMA public TO elitedata;
ALTER SCHEMA public OWNER TO elitedata;
-- 3. Install required extensions
CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- 4. If tables already exist, transfer ownership to elitedata
DO $$
DECLARE
r RECORD;
BEGIN
-- Transfer table ownership
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
EXECUTE 'ALTER TABLE public.' || quote_ident(r.tablename) || ' OWNER TO elitedata';
END LOOP;
-- Transfer sequence ownership
FOR r IN (SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'public') LOOP
EXECUTE 'ALTER SEQUENCE public.' || quote_ident(r.sequence_name) || ' OWNER TO elitedata';
END LOOP;
-- Transfer type ownership
FOR r IN (SELECT typname FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid WHERE n.nspname = 'public' AND t.typtype = 'e') LOOP
EXECUTE 'ALTER TYPE public.' || quote_ident(r.typname) || ' OWNER TO elitedata';
END LOOP;
END
$$;
-- 5. Grant all privileges on existing tables to elitedata
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO elitedata;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO elitedata;
-- 6. Set default privileges for future objects
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO elitedata;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO elitedata;
#!/bin/bash
# Setup script for Wyscout Sync System
# This script sets up the database tables and prepares the sync system
set -e
echo "=========================================="
echo "Wyscout Sync System Setup"
echo "=========================================="
# Check if .env file exists
if [ ! -f .env ]; then
echo "Error: .env file not found"
echo "Please create a .env file with your database credentials"
exit 1
fi
# Load environment variables
export $(cat .env | grep -v '^#' | xargs)
# Check required environment variables
if [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_NAME" ]; then
echo "Error: Missing required environment variables"
echo "Please ensure DB_USER, DB_PASSWORD, and DB_NAME are set in .env"
exit 1
fi
echo ""
echo "Step 1: Creating sync_logs table..."
PGPASSWORD=$DB_PASSWORD psql -U $DB_USER -d $DB_NAME -f migrations/0024_create_sync_logs_table.sql
echo ""
echo "Step 2: Verifying table creation..."
PGPASSWORD=$DB_PASSWORD psql -U $DB_USER -d $DB_NAME -c "\d sync_logs"
echo ""
echo "=========================================="
echo "Setup Complete!"
echo "=========================================="
echo ""
echo "Next steps:"
echo "1. Ensure your Wyscout API credentials are in .env:"
echo " ProviderUser=your_username"
echo " ProviderSecret=your_password"
echo ""
echo "2. Test the sync system:"
echo " go run cmd/sync/main.go -status"
echo ""
echo "3. Run a test sync:"
echo " go run cmd/sync/main.go -resource=players -hours=24"
echo ""
echo "4. Start the server with cron enabled:"
echo " go run cmd/server/main.go"
echo ""
# Systemd Setup Guide for Wyscout Sync Daemon
## Installation Steps
### 1. Build the Binary
```bash
cd /home/augusto/ScoutingSystem/SSEData
# Build the sync daemon
go build -o bin/sync-daemon cmd/sync-daemon/main.go
# Verify it works
./bin/sync-daemon -once
```
### 2. Update Service File Paths
Edit `systemd/wyscout-sync.service` and update these paths to match your server:
```ini
User=your_username # Your Linux user
Group=your_username # Your Linux group
WorkingDirectory=/path/to/ScoutingSystem/SSEData # Your project path
EnvironmentFile=/path/to/ScoutingSystem/SSEData/.env # Your .env path
ExecStart=/path/to/ScoutingSystem/SSEData/bin/sync-daemon -interval=12 -lookback=24
ReadWritePaths=/path/to/ScoutingSystem/SSEData/logs # Your logs path
```
### 3. Install the Service
```bash
# Copy service file to systemd directory
sudo cp systemd/wyscout-sync.service /etc/systemd/system/
# Reload systemd to recognize new service
sudo systemctl daemon-reload
# Enable service to start on boot
sudo systemctl enable wyscout-sync
# Start the service
sudo systemctl start wyscout-sync
```
### 4. Verify It's Running
```bash
# Check status
sudo systemctl status wyscout-sync
# Should show:
# ● wyscout-sync.service - Wyscout Sync Daemon
# Loaded: loaded (/etc/systemd/system/wyscout-sync.service; enabled)
# Active: active (running) since Mon 2026-02-03 11:52:00 UTC; 5s ago
```
## Daily Operations
### Check Status
```bash
sudo systemctl status wyscout-sync
```
### View Logs
```bash
# Real-time logs
sudo journalctl -u wyscout-sync -f
# Last 100 lines
sudo journalctl -u wyscout-sync -n 100
# Logs from today
sudo journalctl -u wyscout-sync --since today
# Logs from specific time
sudo journalctl -u wyscout-sync --since "2026-02-03 10:00:00"
```
### Start/Stop/Restart
```bash
# Start
sudo systemctl start wyscout-sync
# Stop
sudo systemctl stop wyscout-sync
# Restart
sudo systemctl restart wyscout-sync
# Reload configuration (after editing .env)
sudo systemctl reload-or-restart wyscout-sync
```
### Enable/Disable Auto-Start
```bash
# Enable (start on boot)
sudo systemctl enable wyscout-sync
# Disable (don't start on boot)
sudo systemctl disable wyscout-sync
# Check if enabled
sudo systemctl is-enabled wyscout-sync
```
## Configuration Changes
### Change Sync Interval
**Option 1: Edit service file**
```bash
sudo nano /etc/systemd/system/wyscout-sync.service
# Change this line:
ExecStart=/path/to/bin/sync-daemon -interval=24 -lookback=48
# Then reload:
sudo systemctl daemon-reload
sudo systemctl restart wyscout-sync
```
**Option 2: Use environment variables**
Add to your `.env` file:
```env
SYNC_INTERVAL=12
LOOKBACK_HOURS=24
```
Then modify the service to read these:
```ini
ExecStart=/path/to/bin/sync-daemon -interval=${SYNC_INTERVAL} -lookback=${LOOKBACK_HOURS}
```
### Update Binary After Code Changes
```bash
# Rebuild
cd /home/augusto/ScoutingSystem/SSEData
go build -o bin/sync-daemon cmd/sync-daemon/main.go
# Restart service
sudo systemctl restart wyscout-sync
# Verify new version is running
sudo systemctl status wyscout-sync
```
## Monitoring
### Check if Service is Running
```bash
sudo systemctl is-active wyscout-sync
# Output: active or inactive
```
### Check Service Health
```bash
# Full status with recent logs
sudo systemctl status wyscout-sync
# Check for failures
sudo systemctl is-failed wyscout-sync
# View service dependencies
sudo systemctl list-dependencies wyscout-sync
```
### Resource Usage
```bash
# Memory and CPU usage
sudo systemctl status wyscout-sync | grep -E "Memory|CPU"
# Detailed resource info
sudo systemd-cgtop | grep wyscout-sync
```
### Logs Analysis
```bash
# Count errors in last hour
sudo journalctl -u wyscout-sync --since "1 hour ago" | grep -i error | wc -l
# Show only errors
sudo journalctl -u wyscout-sync -p err
# Export logs to file
sudo journalctl -u wyscout-sync --since today > /tmp/sync-logs.txt
```
## Troubleshooting
### Service Won't Start
```bash
# Check for errors
sudo systemctl status wyscout-sync
sudo journalctl -u wyscout-sync -n 50
# Common issues:
# 1. Binary not found
ls -la /home/augusto/ScoutingSystem/SSEData/bin/sync-daemon
# 2. Permission issues
sudo chmod +x /home/augusto/ScoutingSystem/SSEData/bin/sync-daemon
# 3. .env file not readable
ls -la /home/augusto/ScoutingSystem/SSEData/.env
# 4. Database not running
sudo systemctl status postgresql
```
### Service Keeps Restarting
```bash
# Check restart count
sudo systemctl status wyscout-sync | grep "Restarts"
# View crash logs
sudo journalctl -u wyscout-sync --since "10 minutes ago"
# Check if hitting resource limits
sudo systemctl show wyscout-sync | grep -E "Memory|CPU"
```
### Database Connection Issues
```bash
# Test database connection manually
psql -U elitedata -d postgres -c "SELECT 1;"
# Check if PostgreSQL is running
sudo systemctl status postgresql
# Verify .env has correct credentials
cat /home/augusto/ScoutingSystem/SSEData/.env | grep DB_
```
### High Memory Usage
Edit service file to reduce memory limit:
```ini
MemoryLimit=256M # Reduce from 512M
```
Then:
```bash
sudo systemctl daemon-reload
sudo systemctl restart wyscout-sync
```
## Advanced Configuration
### Run Multiple Sync Daemons
Create separate service files for different schedules:
**wyscout-sync-hourly.service** (frequent updates):
```ini
ExecStart=/path/to/bin/sync-daemon -interval=1 -lookback=2
```
**wyscout-sync-daily.service** (full sync):
```ini
ExecStart=/path/to/bin/sync-daemon -interval=24 -lookback=48
```
### Email Notifications on Failure
Install `mailutils`:
```bash
sudo apt-get install mailutils
```
Create `/etc/systemd/system/wyscout-sync-failure@.service`:
```ini
[Unit]
Description=Send email on wyscout-sync failure
[Service]
Type=oneshot
ExecStart=/usr/bin/mail -s "Wyscout Sync Failed" admin@example.com
StandardInput=journal
```
Add to main service:
```ini
[Unit]
OnFailure=wyscout-sync-failure@%n.service
```
### Automatic Restart on Failure
Already configured in the service file:
```ini
Restart=always
RestartSec=10
StartLimitInterval=200
StartLimitBurst=5
```
This means:
- Restart always on failure
- Wait 10 seconds between restarts
- Allow max 5 restarts in 200 seconds
- If limit exceeded, service stops
## Security Best Practices
The service file includes security hardening:
```ini
NoNewPrivileges=true # Prevent privilege escalation
PrivateTmp=true # Isolated /tmp directory
ProtectSystem=strict # Read-only system directories
ProtectHome=read-only # Read-only home directories
ReadWritePaths=/path/logs # Only logs directory is writable
```
### Additional Security
1. **Run as non-root user** (already configured)
2. **Restrict file permissions**:
```bash
chmod 600 .env
chmod 700 bin/sync-daemon
```
3. **Use systemd secrets** for sensitive data (optional)
## Comparison: Systemd vs Scripts
| Feature | Systemd | Bash Scripts |
|---------|---------|--------------|
| Auto-restart on failure | ✅ Built-in | ❌ Manual |
| Start on boot | ✅ Built-in | ❌ Need cron |
| Logging | ✅ journald | ❌ Manual |
| Resource limits | ✅ Built-in | ❌ Manual |
| Status monitoring | ✅ systemctl | ❌ ps/grep |
| Dependency management | ✅ Built-in | ❌ Manual |
| Security hardening | ✅ Built-in | ❌ Manual |
| Standard on Linux | ✅ Yes | ❌ No |
**Recommendation: Use systemd for production servers**
## Quick Reference
```bash
# Start/Stop
sudo systemctl start wyscout-sync
sudo systemctl stop wyscout-sync
sudo systemctl restart wyscout-sync
# Status
sudo systemctl status wyscout-sync
sudo systemctl is-active wyscout-sync
# Logs
sudo journalctl -u wyscout-sync -f
sudo journalctl -u wyscout-sync --since today
# Enable/Disable
sudo systemctl enable wyscout-sync
sudo systemctl disable wyscout-sync
# After changes
sudo systemctl daemon-reload
sudo systemctl restart wyscout-sync
```
## Integration with Monitoring Tools
### Prometheus Exporter
```bash
# Install node_exporter
sudo apt-get install prometheus-node-exporter
# Systemd metrics available at:
# http://localhost:9100/metrics
```
### Grafana Dashboard
Monitor service status, restarts, and resource usage.
### Alerting
Set up alerts for:
- Service down
- High restart count
- Memory/CPU limits reached
- Sync failures in logs
#!/bin/bash
# Systemd Installation Script for Wyscout Sync Daemon
# This script automates the installation of the sync daemon as a systemd service
set -e
echo "=========================================="
echo "Wyscout Sync Daemon - Systemd Installation"
echo "=========================================="
# Check if running as root
if [ "$EUID" -eq 0 ]; then
echo "Error: Do not run this script as root"
echo "Run as your regular user, sudo will be used when needed"
exit 1
fi
# Get the absolute path to the project directory
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
echo "Project directory: $PROJECT_DIR"
# Get current user
CURRENT_USER=$(whoami)
echo "User: $CURRENT_USER"
# Check if .env exists
if [ ! -f "$PROJECT_DIR/.env" ]; then
echo "Error: .env file not found at $PROJECT_DIR/.env"
echo "Please create .env file with your configuration"
exit 1
fi
# Build the binary
echo ""
echo "Step 1: Building sync daemon binary..."
cd "$PROJECT_DIR"
go build -o bin/sync-daemon cmd/sync-daemon/main.go
if [ ! -f "$PROJECT_DIR/bin/sync-daemon" ]; then
echo "Error: Failed to build binary"
exit 1
fi
chmod +x bin/sync-daemon
echo "✓ Binary built successfully"
# Test the binary
echo ""
echo "Step 2: Testing binary..."
if ! ./bin/sync-daemon -once 2>&1 | head -5; then
echo "Warning: Binary test had issues, but continuing..."
fi
# Create logs directory
echo ""
echo "Step 3: Creating logs directory..."
mkdir -p "$PROJECT_DIR/logs"
echo "✓ Logs directory created"
# Create service file with correct paths
echo ""
echo "Step 4: Creating systemd service file..."
SERVICE_FILE="/tmp/wyscout-sync.service"
cat > "$SERVICE_FILE" << EOF
[Unit]
Description=Wyscout Sync Daemon - Automated data synchronization from Wyscout API
Documentation=https://github.com/your-org/ScoutingSystem
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service
[Service]
Type=simple
User=$CURRENT_USER
Group=$CURRENT_USER
WorkingDirectory=$PROJECT_DIR
# Environment file
EnvironmentFile=$PROJECT_DIR/.env
# Binary location
ExecStart=$PROJECT_DIR/bin/sync-daemon -interval=12 -lookback=24
# Restart policy
Restart=always
RestartSec=10
StartLimitInterval=200
StartLimitBurst=5
# Resource limits
MemoryLimit=512M
CPUQuota=50%
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=wyscout-sync
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=$PROJECT_DIR/logs
# Graceful shutdown
TimeoutStopSec=30
KillMode=mixed
KillSignal=SIGTERM
[Install]
WantedBy=multi-user.target
EOF
echo "✓ Service file created"
# Install the service
echo ""
echo "Step 5: Installing systemd service..."
sudo cp "$SERVICE_FILE" /etc/systemd/system/wyscout-sync.service
sudo systemctl daemon-reload
echo "✓ Service installed"
# Enable the service
echo ""
echo "Step 6: Enabling service to start on boot..."
sudo systemctl enable wyscout-sync
echo "✓ Service enabled"
# Start the service
echo ""
echo "Step 7: Starting service..."
sudo systemctl start wyscout-sync
sleep 2
# Check status
echo ""
echo "Step 8: Checking service status..."
if sudo systemctl is-active --quiet wyscout-sync; then
echo "✓ Service is running!"
else
echo "✗ Service failed to start"
echo ""
echo "Checking logs..."
sudo journalctl -u wyscout-sync -n 20 --no-pager
exit 1
fi
# Show status
echo ""
echo "=========================================="
echo "Installation Complete!"
echo "=========================================="
echo ""
sudo systemctl status wyscout-sync --no-pager -l
echo ""
echo "Useful commands:"
echo " sudo systemctl status wyscout-sync # Check status"
echo " sudo journalctl -u wyscout-sync -f # View logs"
echo " sudo systemctl restart wyscout-sync # Restart service"
echo " sudo systemctl stop wyscout-sync # Stop service"
echo ""
echo "Configuration:"
echo " Service file: /etc/systemd/system/wyscout-sync.service"
echo " Binary: $PROJECT_DIR/bin/sync-daemon"
echo " Logs: sudo journalctl -u wyscout-sync"
echo ""
#!/bin/bash
# Systemd Uninstallation Script for Wyscout Sync Daemon
set -e
echo "=========================================="
echo "Wyscout Sync Daemon - Systemd Uninstallation"
echo "=========================================="
# Check if service exists
if [ ! -f /etc/systemd/system/wyscout-sync.service ]; then
echo "Service not found. Nothing to uninstall."
exit 0
fi
# Stop the service
echo "Stopping service..."
sudo systemctl stop wyscout-sync || true
# Disable the service
echo "Disabling service..."
sudo systemctl disable wyscout-sync || true
# Remove service file
echo "Removing service file..."
sudo rm /etc/systemd/system/wyscout-sync.service
# Reload systemd
echo "Reloading systemd..."
sudo systemctl daemon-reload
sudo systemctl reset-failed
echo ""
echo "=========================================="
echo "Uninstallation Complete!"
echo "=========================================="
echo ""
echo "Note: Binary and logs were NOT removed."
echo "To remove them manually:"
echo " rm -rf bin/sync-daemon"
echo " rm -rf logs/"
echo ""
[Unit]
Description=Wyscout Sync Daemon - Automated data synchronization from Wyscout API
Documentation=https://github.com/your-org/ScoutingSystem
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service
[Service]
Type=simple
User=augusto
Group=augusto
WorkingDirectory=/home/augusto/ScoutingSystem/SSEData
# Environment file
EnvironmentFile=/home/augusto/ScoutingSystem/SSEData/.env
Environment="SYNC_INTERVAL=12"
Environment="LOOKBACK_HOURS=24"
# Binary location
ExecStart=/home/augusto/ScoutingSystem/SSEData/bin/sync-daemon -interval=${SYNC_INTERVAL} -lookback=${LOOKBACK_HOURS}
# Restart policy
Restart=always
RestartSec=10
StartLimitInterval=200
StartLimitBurst=5
# Resource limits
MemoryLimit=512M
CPUQuota=50%
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=wyscout-sync
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/augusto/ScoutingSystem/SSEData/logs
# Graceful shutdown
TimeoutStopSec=30
KillMode=mixed
KillSignal=SIGTERM
[Install]
WantedBy=multi-user.target
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