Files
MyClub/internal/controllers/manual_facr_admin_controller.go
T
Tomas Dvorak dfc079288f hot fix #1
2026-01-26 08:13:18 +01:00

1302 lines
40 KiB
Go

package controllers
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"github.com/gin-gonic/gin"
"github.com/xuri/excelize/v2"
"gorm.io/gorm"
)
// ManualFACRAdminController manages manual competitions, matches and tables via CSV templates/imports.
type ManualFACRAdminController struct {
DB *gorm.DB
}
// NewManualFACRAdminController creates a new instance.
func NewManualFACRAdminController(db *gorm.DB) *ManualFACRAdminController {
return &ManualFACRAdminController{DB: db}
}
func (mc *ManualFACRAdminController) ensureManualMode(c *gin.Context) bool {
if !isManualClubDataMode() {
c.JSON(http.StatusBadRequest, gin.H{"error": "manual club data mode is required for this operation"})
return false
}
return true
}
func (mc *ManualFACRAdminController) getPrimaryClubSettings() (*models.Settings, error) {
var s models.Settings
if err := mc.DB.First(&s).Error; err != nil {
return nil, err
}
if strings.TrimSpace(s.ClubID) == "" || strings.TrimSpace(s.ClubType) == "" {
return nil, fmt.Errorf("primary club ID/type not configured in settings")
}
return &s, nil
}
// triggerManualPrefetchAsync triggers a background prefetch cycle so that
// /cache/prefetch/facr_club_info.json, facr_tables.json and matches.json
// are rebuilt immediately from the current data. This is especially useful
// in manual mode where we don't hit any external FACR API.
func (mc *ManualFACRAdminController) triggerManualPrefetchAsync() {
go func() {
base := getPrefetchBaseURL()
services.PrefetchOnce(strings.TrimRight(base, "/"))
}()
}
// buildCompetitionLinks constructs canonical fotbal.cz links for a competition
// based on its external UUID. The format is:
//
// https://www.fotbal.cz/souteze/turnaje/hlavni/<uuid>
// https://www.fotbal.cz/souteze/turnaje/table/<uuid>
func buildCompetitionLinks(externalID string) (string, string) {
externalID = strings.TrimSpace(externalID)
if externalID == "" {
return "", ""
}
base := "https://www.fotbal.cz/souteze/turnaje"
matches := fmt.Sprintf("%s/hlavni/%s", base, externalID)
table := fmt.Sprintf("%s/table/%s", base, externalID)
return matches, table
}
// ----- Manual competitions CRUD -----
// ListManualCompetitions returns all manual competitions for the primary club.
func (mc *ManualFACRAdminController) ListManualCompetitions(c *gin.Context) {
if !mc.ensureManualMode(c) {
return
}
settings, err := mc.getPrimaryClubSettings()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load settings: %v", err)})
return
}
clubID := strings.TrimSpace(settings.ClubID)
clubType := strings.TrimSpace(settings.ClubType)
var comps []models.ManualCompetition
if err := mc.DB.Where("club_id = ? AND club_type = ?", clubID, clubType).
Order("code ASC, name ASC, id ASC").Find(&comps).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to load competitions: %v", err)})
return
}
c.JSON(http.StatusOK, comps)
}
type manualCompetitionPayload struct {
Code string `json:"code"`
Name string `json:"name"`
ExternalID string `json:"external_id"`
MatchesLink string `json:"matches_link"`
TableLink string `json:"table_link"`
TeamCount string `json:"team_count"`
}
// CreateManualCompetition creates a new manual competition for the primary club.
func (mc *ManualFACRAdminController) CreateManualCompetition(c *gin.Context) {
if !mc.ensureManualMode(c) {
return
}
settings, err := mc.getPrimaryClubSettings()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load settings: %v", err)})
return
}
clubID := strings.TrimSpace(settings.ClubID)
clubType := strings.TrimSpace(settings.ClubType)
var body manualCompetitionPayload
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
body.Code = strings.TrimSpace(body.Code)
body.Name = strings.TrimSpace(body.Name)
body.ExternalID = strings.TrimSpace(body.ExternalID)
body.MatchesLink = strings.TrimSpace(body.MatchesLink)
body.TableLink = strings.TrimSpace(body.TableLink)
body.TeamCount = strings.TrimSpace(body.TeamCount)
if body.Code == "" || body.Name == "" || body.ExternalID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "code, name and external_id are required"})
return
}
// Allow admin to paste either plain UUID or full fotbal.cz link; always
// store canonical UUID and derive matches/table links from it.
uuid := extractUUIDFromHref(body.ExternalID)
if uuid == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "external_id must be a valid UUID or fotbal.cz competition URL containing UUID"})
return
}
body.ExternalID = uuid
body.MatchesLink, body.TableLink = buildCompetitionLinks(uuid)
var existing models.ManualCompetition
if err := mc.DB.Where("club_id = ? AND club_type = ? AND (code = ? OR external_id = ?)", clubID, clubType, body.Code, body.ExternalID).
First(&existing).Error; err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "competition with this code or external_id already exists"})
return
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to check existing competitions: %v", err)})
return
}
comp := models.ManualCompetition{
ClubID: clubID,
ClubType: clubType,
Code: body.Code,
Name: body.Name,
ExternalID: body.ExternalID,
MatchesLink: body.MatchesLink,
TableLink: body.TableLink,
TeamCount: body.TeamCount,
}
if err := mc.DB.Create(&comp).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create competition: %v", err)})
return
}
mc.triggerManualPrefetchAsync()
c.JSON(http.StatusCreated, comp)
}
// UpdateManualCompetition updates an existing manual competition.
func (mc *ManualFACRAdminController) UpdateManualCompetition(c *gin.Context) {
if !mc.ensureManualMode(c) {
return
}
settings, err := mc.getPrimaryClubSettings()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load settings: %v", err)})
return
}
clubID := strings.TrimSpace(settings.ClubID)
clubType := strings.TrimSpace(settings.ClubType)
idStr := strings.TrimSpace(c.Param("id"))
if idStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
return
}
idVal, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var comp models.ManualCompetition
if err := mc.DB.First(&comp, uint(idVal)).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "competition not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to load competition: %v", err)})
}
return
}
if strings.TrimSpace(comp.ClubID) != clubID || strings.TrimSpace(comp.ClubType) != clubType {
c.JSON(http.StatusNotFound, gin.H{"error": "competition not found for this club"})
return
}
var body manualCompetitionPayload
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
body.Code = strings.TrimSpace(body.Code)
body.Name = strings.TrimSpace(body.Name)
body.ExternalID = strings.TrimSpace(body.ExternalID)
body.MatchesLink = strings.TrimSpace(body.MatchesLink)
body.TableLink = strings.TrimSpace(body.TableLink)
body.TeamCount = strings.TrimSpace(body.TeamCount)
if body.Code == "" || body.Name == "" || body.ExternalID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "code, name and external_id are required"})
return
}
uuid := extractUUIDFromHref(body.ExternalID)
if uuid == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "external_id must be a valid UUID or fotbal.cz competition URL containing UUID"})
return
}
body.ExternalID = uuid
body.MatchesLink, body.TableLink = buildCompetitionLinks(uuid)
comp.Code = body.Code
comp.Name = body.Name
comp.ExternalID = body.ExternalID
comp.MatchesLink = body.MatchesLink
comp.TableLink = body.TableLink
comp.TeamCount = body.TeamCount
if err := mc.DB.Save(&comp).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update competition: %v", err)})
return
}
mc.triggerManualPrefetchAsync()
c.JSON(http.StatusOK, comp)
}
// DeleteManualCompetition deletes a manual competition and its matches/tables.
func (mc *ManualFACRAdminController) DeleteManualCompetition(c *gin.Context) {
if !mc.ensureManualMode(c) {
return
}
settings, err := mc.getPrimaryClubSettings()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load settings: %v", err)})
return
}
clubID := strings.TrimSpace(settings.ClubID)
clubType := strings.TrimSpace(settings.ClubType)
idStr := strings.TrimSpace(c.Param("id"))
if idStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
return
}
idVal, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var comp models.ManualCompetition
if err := mc.DB.First(&comp, uint(idVal)).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "competition not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to load competition: %v", err)})
}
return
}
if strings.TrimSpace(comp.ClubID) != clubID || strings.TrimSpace(comp.ClubType) != clubType {
c.JSON(http.StatusNotFound, gin.H{"error": "competition not found for this club"})
return
}
if err := mc.DB.Where("competition_id = ?", comp.ID).Delete(&models.ManualMatch{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete competition matches: %v", err)})
return
}
if err := mc.DB.Where("competition_id = ?", comp.ID).Delete(&models.ManualTableRow{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete competition table rows: %v", err)})
return
}
if err := mc.DB.Delete(&comp).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete competition: %v", err)})
return
}
mc.triggerManualPrefetchAsync()
c.Status(http.StatusNoContent)
}
// ----- CSV templates -----
func (mc *ManualFACRAdminController) buildCompetitionsSheetForTemplates(f *excelize.File, sheetName string) (string, error) {
settings, err := mc.getPrimaryClubSettings()
if err != nil {
return "", err
}
clubID := strings.TrimSpace(settings.ClubID)
clubType := strings.TrimSpace(settings.ClubType)
var comps []models.ManualCompetition
if err := mc.DB.Where("club_id = ? AND club_type = ?", clubID, clubType).
Order("code ASC, name ASC, id ASC").Find(&comps).Error; err != nil {
return "", err
}
if len(comps) == 0 {
return "", nil
}
f.NewSheet(sheetName)
f.SetCellValue(sheetName, "A1", "kod_souteze")
f.SetCellValue(sheetName, "B1", "nazev_souteze")
f.SetCellValue(sheetName, "C1", "id_souteze_uuid")
for i, comp := range comps {
row := i + 2
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), comp.Code)
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), comp.Name)
f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), comp.ExternalID)
}
return fmt.Sprintf("%s!$A$2:$A$%d", sheetName, len(comps)+1), nil
}
func (mc *ManualFACRAdminController) buildTeamsSheetForTemplates(f *excelize.File, sheetName string) (string, error) {
settings, err := mc.getPrimaryClubSettings()
if err != nil {
return "", err
}
clubID := strings.TrimSpace(settings.ClubID)
clubType := strings.TrimSpace(settings.ClubType)
var comps []models.ManualCompetition
if err := mc.DB.Where("club_id = ? AND club_type = ?", clubID, clubType).
Order("code ASC, name ASC, id ASC").Find(&comps).Error; err != nil {
return "", err
}
if len(comps) == 0 {
return "", nil
}
type teamRow struct {
Code string
Name string
}
rows := make([]teamRow, 0)
seen := make(map[string]bool)
for _, comp := range comps {
var tableRows []models.ManualTableRow
if err := mc.DB.Where("competition_id = ?", comp.ID).
Order("team_name ASC, id ASC").Find(&tableRows).Error; err != nil {
return "", err
}
for _, tr := range tableRows {
name := strings.TrimSpace(tr.TeamName)
if name == "" {
continue
}
key := strings.ToLower(strings.TrimSpace(comp.Code)) + "|" + strings.ToLower(name)
if seen[key] {
continue
}
seen[key] = true
rows = append(rows, teamRow{Code: comp.Code, Name: name})
}
}
if len(rows) == 0 {
return "", nil
}
f.NewSheet(sheetName)
f.SetCellValue(sheetName, "A1", "kod_souteze")
f.SetCellValue(sheetName, "B1", "nazev_tymu")
for i, r := range rows {
row := i + 2
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), r.Code)
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), r.Name)
}
return fmt.Sprintf("%s!$B$2:$B$%d", sheetName, len(rows)+1), nil
}
// GetMatchesTemplateCSV returns a CSV template for manual matches import.
func (mc *ManualFACRAdminController) GetMatchesTemplateCSV(c *gin.Context) {
if !mc.ensureManualMode(c) {
return
}
f := excelize.NewFile()
matchesSheet := "Zápasy"
if name := f.GetSheetName(f.GetActiveSheetIndex()); name != matchesSheet {
_ = f.SetSheetName(name, matchesSheet)
}
headers := []string{
"kod_souteze",
"id_souteze_uuid",
"kolo",
"doma_venku",
"nazev_soupere",
"odkaz_klub_soupere",
"id_zapasu",
"datum",
"cas",
"vysledek",
"vysledek_polocas",
"odkaz_na_zapas",
"hriste",
}
for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
f.SetCellValue(matchesSheet, cell, h)
}
competitionRange, _ := mc.buildCompetitionsSheetForTemplates(f, "Souteze")
if competitionRange != "" {
dv := &excelize.DataValidation{
Type: "list",
AllowBlank: true,
Formula1: competitionRange,
Sqref: "A2:A500",
}
if err := f.AddDataValidation(matchesSheet, dv); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add data validation: %v", err)})
return
}
}
teamsRange, _ := mc.buildTeamsSheetForTemplates(f, "Tymy")
if teamsRange != "" {
// Filter team names by competition code from column A in the same row.
// Tymy sheet is grouped by kod_souteze, so MATCH+COUNTIF provide a contiguous range.
dv := &excelize.DataValidation{
Type: "list",
AllowBlank: true,
Formula1: "=OFFSET(Tymy!$B$1, MATCH($A2, Tymy!$A:$A, 0)-1, 0, COUNTIF(Tymy!$A:$A, $A2), 1)",
Sqref: "E2:E500",
}
if err := f.AddDataValidation(matchesSheet, dv); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add data validation: %v", err)})
return
}
}
dvHomeAway := &excelize.DataValidation{
Type: "list",
AllowBlank: false,
Formula1: "\"doma,venku\"",
Sqref: "D2:D500",
}
if err := f.AddDataValidation(matchesSheet, dvHomeAway); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add data validation: %v", err)})
return
}
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Header("Content-Disposition", "attachment; filename=manual_matches_template.xlsx")
if err := f.Write(c.Writer); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write XLSX: %v", err)})
return
}
}
// GetTablesTemplateCSV returns a CSV template for manual tables import.
func (mc *ManualFACRAdminController) GetTablesTemplateCSV(c *gin.Context) {
if !mc.ensureManualMode(c) {
return
}
f := excelize.NewFile()
tablesSheet := "Tabulky"
if name := f.GetSheetName(f.GetActiveSheetIndex()); name != tablesSheet {
_ = f.SetSheetName(name, tablesSheet)
}
headers := []string{
"kod_souteze",
"id_souteze_uuid",
"poradi",
"nazev_tymu",
"odkaz_na_klub",
"zapasy",
"vyhry",
"remizy",
"prohry",
"skore",
"body",
}
for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
f.SetCellValue(tablesSheet, cell, h)
}
competitionRange, _ := mc.buildCompetitionsSheetForTemplates(f, "Souteze")
if competitionRange != "" {
dv := &excelize.DataValidation{
Type: "list",
AllowBlank: true,
Formula1: competitionRange,
Sqref: "A2:A500",
}
if err := f.AddDataValidation(tablesSheet, dv); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add data validation: %v", err)})
return
}
}
teamsRange, _ := mc.buildTeamsSheetForTemplates(f, "Tymy")
if teamsRange != "" {
// Filter team names by competition code from column A in the same row.
dv := &excelize.DataValidation{
Type: "list",
AllowBlank: true,
Formula1: "=OFFSET(Tymy!$B$1, MATCH($A2, Tymy!$A:$A, 0)-1, 0, COUNTIF(Tymy!$A:$A, $A2), 1)",
Sqref: "D2:D500",
}
if err := f.AddDataValidation(tablesSheet, dv); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add data validation: %v", err)})
return
}
}
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Header("Content-Disposition", "attachment; filename=manual_tables_template.xlsx")
if err := f.Write(c.Writer); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write XLSX: %v", err)})
return
}
}
// ----- CSV import helpers -----
func normalizeHeader(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func buildHeaderIndex(headers []string) map[string]int {
idx := make(map[string]int, len(headers)*2)
for i, h := range headers {
key := normalizeHeader(h)
idx[key] = i
// Czech CSV headers -> internal field keys
switch key {
case "kod_souteze":
idx["competition_code"] = i
case "id_souteze_uuid":
idx["competition_external_id"] = i
case "kolo":
idx["round"] = i
case "doma_venku":
idx["is_home"] = i
case "nazev_soupere":
idx["opponent_name"] = i
case "odkaz_klub_soupere":
idx["opponent_club_link"] = i
case "id_zapasu":
idx["external_match_id"] = i
case "datum":
idx["kickoff_date"] = i
case "cas":
idx["kickoff_time"] = i
case "vysledek":
idx["score_fulltime"] = i
case "vysledek_polocas":
idx["score_halftime"] = i
case "odkaz_na_zapas":
idx["match_link"] = i
case "hriste":
idx["venue"] = i
case "poznamka":
idx["note"] = i
case "poradi":
idx["rank"] = i
case "nazev_tymu":
idx["team_name"] = i
case "odkaz_na_klub":
idx["team_club_link"] = i
case "zapasy":
idx["played"] = i
case "vyhry":
idx["wins"] = i
case "remizy":
idx["draws"] = i
case "prohry":
idx["losses"] = i
case "skore":
idx["score"] = i
case "body":
idx["points"] = i
}
}
return idx
}
func getCSVField(row []string, idx map[string]int, name string) string {
pos, ok := idx[normalizeHeader(name)]
if !ok || pos >= len(row) {
return ""
}
return strings.TrimSpace(row[pos])
}
func parseIsHomeFlag(s string) (bool, error) {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return false, fmt.Errorf("is_home is required (home/away or 1/0)")
}
switch s {
case "home", "h", "1", "true", "yes", "y", "doma", "domaci", "domácí", "d":
return true, nil
case "away", "a", "0", "false", "no", "n", "venku", "v":
return false, nil
default:
return false, fmt.Errorf("invalid is_home value: %s", s)
}
}
// ImportMatchesCSV imports manual matches from a CSV file.
// Expected columns: competition_code, competition_external_id, round, is_home, opponent_name,
// opponent_club_link, external_match_id, kickoff_date (YYYY-MM-DD), kickoff_time (HH:MM),
// score_fulltime, score_halftime, match_link, venue, note.
func (mc *ManualFACRAdminController) ImportMatchesCSV(c *gin.Context) {
if !mc.ensureManualMode(c) {
return
}
records, err := Exporter.ImportFromCSV(c, "file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to read CSV: %v", err)})
return
}
if len(records) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "CSV file is empty"})
return
}
headers := records[0]
idx := buildHeaderIndex(headers)
settings, err := mc.getPrimaryClubSettings()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load settings: %v", err)})
return
}
clubID := strings.TrimSpace(settings.ClubID)
clubType := strings.TrimSpace(settings.ClubType)
var imported, updated int
var rowErrors []string
// Preload competitions cache by (code, external_id)
compByKey := map[string]*models.ManualCompetition{}
for rowIdx, row := range records[1:] {
lineNo := rowIdx + 2 // 1-based including header
// Skip completely empty rows
empty := true
for _, v := range row {
if strings.TrimSpace(v) != "" {
empty = false
break
}
}
if empty {
continue
}
compCode := getCSVField(row, idx, "competition_code")
compExtID := getCSVField(row, idx, "competition_external_id")
if compCode == "" && compExtID == "" {
rowErrors = append(rowErrors, fmt.Sprintf("row %d: competition_code or competition_external_id is required", lineNo))
continue
}
compKey := compCode + "|" + compExtID
comp := compByKey[compKey]
if comp == nil {
q := mc.DB.Where("club_id = ? AND club_type = ?", clubID, clubType)
if compExtID != "" {
q = q.Where("external_id = ?", compExtID)
} else {
q = q.Where("code = ?", compCode)
}
var found models.ManualCompetition
if err := q.First(&found).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
rowErrors = append(rowErrors, fmt.Sprintf("row %d: competition not found for code='%s' external_id='%s'", lineNo, compCode, compExtID))
continue
}
rowErrors = append(rowErrors, fmt.Sprintf("row %d: DB error loading competition: %v", lineNo, err))
continue
}
comp = &found
compByKey[compKey] = comp
}
isHome, err := parseIsHomeFlag(getCSVField(row, idx, "is_home"))
if err != nil {
rowErrors = append(rowErrors, fmt.Sprintf("row %d: %v", lineNo, err))
continue
}
opponentName := getCSVField(row, idx, "opponent_name")
oppLink := getCSVField(row, idx, "opponent_club_link")
oppExternalID := ""
if oppLink != "" {
oppExternalID = extractUUIDFromHref(oppLink)
}
matchID := getCSVField(row, idx, "external_match_id")
if matchID == "" {
if ml := getCSVField(row, idx, "match_link"); ml != "" {
matchID = extractUUIDFromHref(ml)
}
}
if matchID == "" {
rowErrors = append(rowErrors, fmt.Sprintf("row %d: external_match_id or match_link with UUID is required", lineNo))
continue
}
dateStr := getCSVField(row, idx, "kickoff_date")
timeStr := getCSVField(row, idx, "kickoff_time")
var kickoff time.Time
if dateStr != "" && timeStr != "" {
parsed, err := time.ParseInLocation("2006-01-02 15:04", dateStr+" "+timeStr, time.Local)
if err != nil {
rowErrors = append(rowErrors, fmt.Sprintf("row %d: invalid kickoff date/time: %v", lineNo, err))
continue
}
kickoff = parsed
}
fullScore := getCSVField(row, idx, "score_fulltime")
htScore := getCSVField(row, idx, "score_halftime")
matchLink := getCSVField(row, idx, "match_link")
venue := getCSVField(row, idx, "venue")
note := getCSVField(row, idx, "note")
round := getCSVField(row, idx, "round")
var existing models.ManualMatch
q := mc.DB.Where("competition_id = ? AND external_match_id = ?", comp.ID, matchID)
err = q.First(&existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
rowErrors = append(rowErrors, fmt.Sprintf("row %d: DB error loading match: %v", lineNo, err))
continue
}
if errors.Is(err, gorm.ErrRecordNotFound) {
// Create new
m := models.ManualMatch{
CompetitionID: comp.ID,
ExternalMatchID: matchID,
Round: round,
IsHome: isHome,
OpponentName: opponentName,
OpponentExternalID: oppExternalID,
OpponentURL: oppLink,
Kickoff: kickoff,
Score: fullScore,
HalftimeScore: htScore,
MatchURL: matchLink,
Venue: venue,
Note: note,
}
if err := mc.DB.Create(&m).Error; err != nil {
rowErrors = append(rowErrors, fmt.Sprintf("row %d: failed to create match: %v", lineNo, err))
continue
}
imported++
} else {
// Update existing
existing.Round = round
existing.IsHome = isHome
existing.OpponentName = opponentName
existing.OpponentExternalID = oppExternalID
existing.OpponentURL = oppLink
existing.Kickoff = kickoff
existing.Score = fullScore
existing.HalftimeScore = htScore
existing.MatchURL = matchLink
existing.Venue = venue
existing.Note = note
if err := mc.DB.Save(&existing).Error; err != nil {
rowErrors = append(rowErrors, fmt.Sprintf("row %d: failed to update match: %v", lineNo, err))
continue
}
updated++
}
}
if imported > 0 || updated > 0 {
mc.triggerManualPrefetchAsync()
}
c.JSON(http.StatusOK, gin.H{
"imported": imported,
"updated": updated,
"errors": rowErrors,
})
}
// ImportTablesCSV imports manual tables (standings) from a CSV file.
// Expected columns: competition_code, competition_external_id, rank, team_name,
// team_club_link, played, wins, draws, losses, score, points.
func (mc *ManualFACRAdminController) ImportTablesCSV(c *gin.Context) {
if !mc.ensureManualMode(c) {
return
}
records, err := Exporter.ImportFromCSV(c, "file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to read CSV: %v", err)})
return
}
if len(records) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "CSV file is empty"})
return
}
headers := records[0]
idx := buildHeaderIndex(headers)
settings, err := mc.getPrimaryClubSettings()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load settings: %v", err)})
return
}
clubID := strings.TrimSpace(settings.ClubID)
clubType := strings.TrimSpace(settings.ClubType)
var imported int
var rowErrors []string
// Cache competitions and track whether we've cleared existing rows per competition.
compByKey := map[string]*models.ManualCompetition{}
clearedTable := map[uint]bool{}
for rowIdx, row := range records[1:] {
lineNo := rowIdx + 2
// Skip completely empty rows
empty := true
for _, v := range row {
if strings.TrimSpace(v) != "" {
empty = false
break
}
}
if empty {
continue
}
compCode := getCSVField(row, idx, "competition_code")
compExtID := getCSVField(row, idx, "competition_external_id")
if compCode == "" && compExtID == "" {
rowErrors = append(rowErrors, fmt.Sprintf("row %d: competition_code or competition_external_id is required", lineNo))
continue
}
compKey := compCode + "|" + compExtID
comp := compByKey[compKey]
if comp == nil {
q := mc.DB.Where("club_id = ? AND club_type = ?", clubID, clubType)
if compExtID != "" {
q = q.Where("external_id = ?", compExtID)
} else {
q = q.Where("code = ?", compCode)
}
var found models.ManualCompetition
if err := q.First(&found).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
rowErrors = append(rowErrors, fmt.Sprintf("row %d: competition not found for code='%s' external_id='%s'", lineNo, compCode, compExtID))
continue
}
rowErrors = append(rowErrors, fmt.Sprintf("row %d: DB error loading competition: %v", lineNo, err))
continue
}
comp = &found
compByKey[compKey] = comp
}
// On first row for this competition, clear previous table rows to fully replace.
if !clearedTable[comp.ID] {
if err := mc.DB.Where("competition_id = ?", comp.ID).Delete(&models.ManualTableRow{}).Error; err != nil {
rowErrors = append(rowErrors, fmt.Sprintf("row %d: failed to clear existing table rows: %v", lineNo, err))
continue
}
clearedTable[comp.ID] = true
}
rank := getCSVField(row, idx, "rank")
teamName := getCSVField(row, idx, "team_name")
teamLink := getCSVField(row, idx, "team_club_link")
teamExtID := ""
if teamLink != "" {
teamExtID = extractUUIDFromHref(teamLink)
}
played := getCSVField(row, idx, "played")
wins := getCSVField(row, idx, "wins")
draws := getCSVField(row, idx, "draws")
losses := getCSVField(row, idx, "losses")
score := getCSVField(row, idx, "score")
points := getCSVField(row, idx, "points")
rowModel := models.ManualTableRow{
CompetitionID: comp.ID,
Rank: rank,
TeamName: teamName,
ExternalTeamID: teamExtID,
Played: played,
Wins: wins,
Draws: draws,
Losses: losses,
Score: score,
Points: points,
}
if err := mc.DB.Create(&rowModel).Error; err != nil {
rowErrors = append(rowErrors, fmt.Sprintf("row %d: failed to create table row: %v", lineNo, err))
continue
}
imported++
}
if imported > 0 {
mc.triggerManualPrefetchAsync()
}
c.JSON(http.StatusOK, gin.H{
"imported": imported,
"errors": rowErrors,
})
}
type manualMatchJSON struct {
CompetitionCode string `json:"competition_code"`
CompetitionExternalID string `json:"competition_external_id"`
Round string `json:"round"`
IsHome string `json:"is_home"`
OpponentName string `json:"opponent_name"`
OpponentClubLink string `json:"opponent_club_link"`
ExternalMatchID string `json:"external_match_id"`
KickoffDate string `json:"kickoff_date"`
KickoffTime string `json:"kickoff_time"`
KickoffISO string `json:"kickoff"`
ScoreFulltime string `json:"score_fulltime"`
ScoreHalftime string `json:"score_halftime"`
MatchLink string `json:"match_link"`
Venue string `json:"venue"`
Note string `json:"note"`
}
// ImportMatchesJSON imports manual matches from a JSON payload.
// The JSON shape mirrors the CSV columns used by ImportMatchesCSV, wrapped in an "items" array.
func (mc *ManualFACRAdminController) ImportMatchesJSON(c *gin.Context) {
if !mc.ensureManualMode(c) {
return
}
var body struct {
Items []manualMatchJSON `json:"items"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(body.Items) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "JSON body must contain non-empty 'items' array"})
return
}
settings, err := mc.getPrimaryClubSettings()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load settings: %v", err)})
return
}
clubID := strings.TrimSpace(settings.ClubID)
clubType := strings.TrimSpace(settings.ClubType)
var imported, updated int
var rowErrors []string
// Preload competitions cache by (code, external_id)
compByKey := map[string]*models.ManualCompetition{}
for idx, item := range body.Items {
lineNo := idx + 1
// Skip completely empty items
if strings.TrimSpace(item.CompetitionCode) == "" &&
strings.TrimSpace(item.CompetitionExternalID) == "" &&
strings.TrimSpace(item.Round) == "" &&
strings.TrimSpace(item.OpponentName) == "" &&
strings.TrimSpace(item.OpponentClubLink) == "" &&
strings.TrimSpace(item.ExternalMatchID) == "" &&
strings.TrimSpace(item.MatchLink) == "" {
continue
}
compCode := strings.TrimSpace(item.CompetitionCode)
compExtID := strings.TrimSpace(item.CompetitionExternalID)
if compCode == "" && compExtID == "" {
rowErrors = append(rowErrors, fmt.Sprintf("item %d: competition_code or competition_external_id is required", lineNo))
continue
}
compKey := compCode + "|" + compExtID
comp := compByKey[compKey]
if comp == nil {
q := mc.DB.Where("club_id = ? AND club_type = ?", clubID, clubType)
if compExtID != "" {
q = q.Where("external_id = ?", compExtID)
} else {
q = q.Where("code = ?", compCode)
}
var found models.ManualCompetition
if err := q.First(&found).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
rowErrors = append(rowErrors, fmt.Sprintf("item %d: competition not found for code='%s' external_id='%s'", lineNo, compCode, compExtID))
continue
}
rowErrors = append(rowErrors, fmt.Sprintf("item %d: DB error loading competition: %v", lineNo, err))
continue
}
comp = &found
compByKey[compKey] = comp
}
isHome, err := parseIsHomeFlag(item.IsHome)
if err != nil {
rowErrors = append(rowErrors, fmt.Sprintf("item %d: %v", lineNo, err))
continue
}
opponentName := strings.TrimSpace(item.OpponentName)
oppLink := strings.TrimSpace(item.OpponentClubLink)
oppExternalID := ""
if oppLink != "" {
oppExternalID = extractUUIDFromHref(oppLink)
}
matchID := strings.TrimSpace(item.ExternalMatchID)
if matchID == "" && strings.TrimSpace(item.MatchLink) != "" {
matchID = extractUUIDFromHref(item.MatchLink)
}
if matchID == "" {
rowErrors = append(rowErrors, fmt.Sprintf("item %d: external_match_id or match_link with UUID is required", lineNo))
continue
}
var kickoff time.Time
iso := strings.TrimSpace(item.KickoffISO)
dateStr := strings.TrimSpace(item.KickoffDate)
timeStr := strings.TrimSpace(item.KickoffTime)
if iso != "" {
parsed, err2 := time.Parse(time.RFC3339, iso)
if err2 != nil {
rowErrors = append(rowErrors, fmt.Sprintf("item %d: invalid kickoff (RFC3339): %v", lineNo, err2))
continue
}
kickoff = parsed
} else if dateStr != "" && timeStr != "" {
parsed, err2 := time.ParseInLocation("2006-01-02 15:04", dateStr+" "+timeStr, time.Local)
if err2 != nil {
rowErrors = append(rowErrors, fmt.Sprintf("item %d: invalid kickoff date/time: %v", lineNo, err2))
continue
}
kickoff = parsed
}
fullScore := strings.TrimSpace(item.ScoreFulltime)
htScore := strings.TrimSpace(item.ScoreHalftime)
matchLink := strings.TrimSpace(item.MatchLink)
venue := strings.TrimSpace(item.Venue)
note := strings.TrimSpace(item.Note)
round := strings.TrimSpace(item.Round)
var existing models.ManualMatch
q := mc.DB.Where("competition_id = ? AND external_match_id = ?", comp.ID, matchID)
err = q.First(&existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
rowErrors = append(rowErrors, fmt.Sprintf("item %d: DB error loading match: %v", lineNo, err))
continue
}
if errors.Is(err, gorm.ErrRecordNotFound) {
m := models.ManualMatch{
CompetitionID: comp.ID,
ExternalMatchID: matchID,
Round: round,
IsHome: isHome,
OpponentName: opponentName,
OpponentExternalID: oppExternalID,
OpponentURL: oppLink,
Kickoff: kickoff,
Score: fullScore,
HalftimeScore: htScore,
MatchURL: matchLink,
Venue: venue,
Note: note,
}
if err := mc.DB.Create(&m).Error; err != nil {
rowErrors = append(rowErrors, fmt.Sprintf("item %d: failed to create match: %v", lineNo, err))
continue
}
imported++
} else {
existing.Round = round
existing.IsHome = isHome
existing.OpponentName = opponentName
existing.OpponentExternalID = oppExternalID
existing.OpponentURL = oppLink
existing.Kickoff = kickoff
existing.Score = fullScore
existing.HalftimeScore = htScore
existing.MatchURL = matchLink
existing.Venue = venue
existing.Note = note
if err := mc.DB.Save(&existing).Error; err != nil {
rowErrors = append(rowErrors, fmt.Sprintf("item %d: failed to update match: %v", lineNo, err))
continue
}
updated++
}
}
if imported > 0 || updated > 0 {
mc.triggerManualPrefetchAsync()
}
c.JSON(http.StatusOK, gin.H{
"imported": imported,
"updated": updated,
"errors": rowErrors,
})
}
type manualTableRowJSON struct {
CompetitionCode string `json:"competition_code"`
CompetitionExternalID string `json:"competition_external_id"`
Rank string `json:"rank"`
TeamName string `json:"team_name"`
TeamClubLink string `json:"team_club_link"`
Played string `json:"played"`
Wins string `json:"wins"`
Draws string `json:"draws"`
Losses string `json:"losses"`
Score string `json:"score"`
Points string `json:"points"`
}
// ImportTablesJSON imports manual tables from a JSON payload.
// The JSON shape mirrors the CSV columns used by ImportTablesCSV, wrapped in an "items" array.
func (mc *ManualFACRAdminController) ImportTablesJSON(c *gin.Context) {
if !mc.ensureManualMode(c) {
return
}
var body struct {
Items []manualTableRowJSON `json:"items"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(body.Items) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "JSON body must contain non-empty 'items' array"})
return
}
settings, err := mc.getPrimaryClubSettings()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load settings: %v", err)})
return
}
clubID := strings.TrimSpace(settings.ClubID)
clubType := strings.TrimSpace(settings.ClubType)
var imported int
var rowErrors []string
// Cache competitions and track whether we've cleared existing rows per competition.
compByKey := map[string]*models.ManualCompetition{}
clearedTable := map[uint]bool{}
for idx, item := range body.Items {
lineNo := idx + 1
// Skip completely empty items
if strings.TrimSpace(item.CompetitionCode) == "" &&
strings.TrimSpace(item.CompetitionExternalID) == "" &&
strings.TrimSpace(item.Rank) == "" &&
strings.TrimSpace(item.TeamName) == "" {
continue
}
compCode := strings.TrimSpace(item.CompetitionCode)
compExtID := strings.TrimSpace(item.CompetitionExternalID)
if compCode == "" && compExtID == "" {
rowErrors = append(rowErrors, fmt.Sprintf("item %d: competition_code or competition_external_id is required", lineNo))
continue
}
compKey := compCode + "|" + compExtID
comp := compByKey[compKey]
if comp == nil {
q := mc.DB.Where("club_id = ? AND club_type = ?", clubID, clubType)
if compExtID != "" {
q = q.Where("external_id = ?", compExtID)
} else {
q = q.Where("code = ?", compCode)
}
var found models.ManualCompetition
if err := q.First(&found).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
rowErrors = append(rowErrors, fmt.Sprintf("item %d: competition not found for code='%s' external_id='%s'", lineNo, compCode, compExtID))
continue
}
rowErrors = append(rowErrors, fmt.Sprintf("item %d: DB error loading competition: %v", lineNo, err))
continue
}
comp = &found
compByKey[compKey] = comp
}
// On first row for this competition, clear previous table rows to fully replace.
if !clearedTable[comp.ID] {
if err := mc.DB.Where("competition_id = ?", comp.ID).Delete(&models.ManualTableRow{}).Error; err != nil {
rowErrors = append(rowErrors, fmt.Sprintf("item %d: failed to clear existing table rows: %v", lineNo, err))
continue
}
clearedTable[comp.ID] = true
}
teamName := strings.TrimSpace(item.TeamName)
teamLink := strings.TrimSpace(item.TeamClubLink)
teamExtID := ""
if teamLink != "" {
teamExtID = extractUUIDFromHref(teamLink)
}
played := strings.TrimSpace(item.Played)
wins := strings.TrimSpace(item.Wins)
draws := strings.TrimSpace(item.Draws)
losses := strings.TrimSpace(item.Losses)
score := strings.TrimSpace(item.Score)
points := strings.TrimSpace(item.Points)
rowModel := models.ManualTableRow{
CompetitionID: comp.ID,
Rank: strings.TrimSpace(item.Rank),
TeamName: teamName,
ExternalTeamID: teamExtID,
Played: played,
Wins: wins,
Draws: draws,
Losses: losses,
Score: score,
Points: points,
}
if err := mc.DB.Create(&rowModel).Error; err != nil {
rowErrors = append(rowErrors, fmt.Sprintf("item %d: failed to create table row: %v", lineNo, err))
continue
}
imported++
}
if imported > 0 {
mc.triggerManualPrefetchAsync()
}
c.JSON(http.StatusOK, gin.H{
"imported": imported,
"errors": rowErrors,
})
}