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/ // https://www.fotbal.cz/souteze/turnaje/table/ 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, }) }