This commit is contained in:
Tomas Dvorak
2026-01-26 08:13:18 +01:00
parent aa036b6550
commit dfc079288f
505 changed files with 95755 additions and 5712 deletions
File diff suppressed because it is too large Load Diff
+74 -72
View File
@@ -35,13 +35,13 @@ func (ac *AnalyticsController) resolveWebsiteID() (string, error) {
if id := strings.TrimSpace(config.AppConfig.UmamiWebsiteID); id != "" {
return id, nil
}
// Try to get the first available website
id, err := ac.umamiService.GetDefaultWebsiteID()
if err != nil {
return "", err
}
config.AppConfig.UmamiWebsiteID = id
return id, nil
}
@@ -54,13 +54,13 @@ func getClientIP(c *gin.Context) string {
ips := strings.Split(xff, ",")
return strings.TrimSpace(ips[0])
}
// Check X-Real-IP header
xri := c.GetHeader("X-Real-IP")
if xri != "" {
return xri
}
// Fall back to RemoteAddr
return c.ClientIP()
}
@@ -108,32 +108,32 @@ func (ac *AnalyticsController) GetAnalytics(c *gin.Context) {
// Get users stats
var totalUsers int64
ac.DB.Model(&models.User{}).Count(&totalUsers)
// Get new users this week
weekAgo := time.Now().AddDate(0, 0, -7)
var newUsersThisWeek int64
ac.DB.Model(&models.User{}).Where("created_at >= ?", weekAgo).Count(&newUsersThisWeek)
// Get events stats
var totalEvents int64
ac.DB.Model(&models.Event{}).Count(&totalEvents)
// Get upcoming events (events with start_time in the future)
now := time.Now()
var upcomingEvents int64
ac.DB.Model(&models.Event{}).Where("start_time > ?", now).Count(&upcomingEvents)
// Get articles stats
var totalArticles int64
ac.DB.Model(&models.Article{}).Count(&totalArticles)
var publishedArticles int64
ac.DB.Model(&models.Article{}).Where("published = ?", true).Count(&publishedArticles)
c.JSON(http.StatusOK, gin.H{
"users": gin.H{
"total": totalUsers,
"new_this_week": newUsersThisWeek,
"total": totalUsers,
"new_this_week": newUsersThisWeek,
},
"events": gin.H{
"total": totalEvents,
@@ -155,18 +155,18 @@ func (ac *AnalyticsController) GetVisitors(c *gin.Context) {
days = parsed
}
}
groupBy := c.DefaultQuery("groupBy", "day")
startDate := time.Now().AddDate(0, 0, -days)
type VisitorStat struct {
Date string `json:"date"`
PageViews int64 `json:"pageViews"`
UniqueVisitors int64 `json:"uniqueVisitors"`
}
var stats []VisitorStat
// Group by date
if groupBy == "day" {
ac.DB.Model(&models.VisitorEvent{}).
@@ -184,12 +184,11 @@ func (ac *AnalyticsController) GetVisitors(c *gin.Context) {
Order("date ASC").
Scan(&stats)
}
// Transform data for chart format
labels := make([]string, 0, len(stats))
pageViewsData := make([]int64, 0, len(stats))
uniqueVisitorsData := make([]int64, 0, len(stats))
var totalVisitors int64
for _, stat := range stats {
// Format date as "d. M." (e.g., "5. 10.")
@@ -199,11 +198,10 @@ func (ac *AnalyticsController) GetVisitors(c *gin.Context) {
} else {
labels = append(labels, stat.Date)
}
pageViewsData = append(pageViewsData, stat.PageViews)
uniqueVisitorsData = append(uniqueVisitorsData, stat.UniqueVisitors)
totalVisitors += stat.UniqueVisitors
}
// Calculate change percentage (compare last 7 days with previous 7 days)
var changePercentage float64
if len(stats) >= 14 {
@@ -218,39 +216,39 @@ func (ac *AnalyticsController) GetVisitors(c *gin.Context) {
changePercentage = float64(recentSum-previousSum) / float64(previousSum) * 100
}
}
response := gin.H{
"totalVisitors": totalVisitors,
"totalVisitors": totalVisitors,
"changePercentage": changePercentage,
"chartData": gin.H{
"labels": labels,
"datasets": []gin.H{
{
"label": "Návštěvníci",
"data": uniqueVisitorsData,
"borderColor": "rgba(66, 153, 225, 1)",
"label": "Návštěvníci",
"data": uniqueVisitorsData,
"borderColor": "rgba(66, 153, 225, 1)",
"backgroundColor": "rgba(66, 153, 225, 0.5)",
"tension": 0.3,
"fill": true,
"tension": 0.3,
"fill": true,
},
},
},
}
c.JSON(http.StatusOK, response)
}
// GetAnalyticsOverview returns overview statistics for admin dashboard
func (ac *AnalyticsController) GetAnalyticsOverview(c *gin.Context) {
var totalPageViews, uniqueVisitors, pageViewsToday, pageViewsWeek, uniqueVisitorsWeek int64
// Try to fetch from Umami first
websiteID, err := ac.resolveWebsiteID()
if err == nil && websiteID != "" {
// Fetch overall stats (last 365 days for total)
endAt := time.Now().Unix() * 1000
startAt := time.Now().AddDate(-1, 0, 0).Unix() * 1000
stats, err := ac.umamiService.GetWebsiteStats(websiteID, startAt, endAt)
if err == nil {
if pv, ok := stats["pageviews"].(map[string]interface{}); ok {
@@ -264,9 +262,9 @@ func (ac *AnalyticsController) GetAnalyticsOverview(c *gin.Context) {
}
}
}
// Fetch today's stats
todayStart := time.Now().Truncate(24 * time.Hour).Unix() * 1000
todayStart := time.Now().Truncate(24*time.Hour).Unix() * 1000
todayEnd := time.Now().Unix() * 1000
todayStats, err := ac.umamiService.GetWebsiteStats(websiteID, todayStart, todayEnd)
if err == nil {
@@ -276,7 +274,7 @@ func (ac *AnalyticsController) GetAnalyticsOverview(c *gin.Context) {
}
}
}
// Fetch this week's stats
weekStart := time.Now().AddDate(0, 0, -7).Unix() * 1000
weekEnd := time.Now().Unix() * 1000
@@ -297,10 +295,10 @@ func (ac *AnalyticsController) GetAnalyticsOverview(c *gin.Context) {
// Fallback to internal analytics if Umami is not available
ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ?", "page_view").Count(&totalPageViews)
ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ?", "page_view").Distinct("ip_address").Count(&uniqueVisitors)
today := time.Now().Format("2006-01-02")
ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ? AND DATE(created_at) = ?", "page_view", today).Count(&pageViewsToday)
weekAgo := time.Now().AddDate(0, 0, -7)
ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ? AND created_at >= ?", "page_view", weekAgo).Count(&pageViewsWeek)
ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ? AND created_at >= ?", "page_view", weekAgo).Distinct("ip_address").Count(&uniqueVisitorsWeek)
@@ -312,13 +310,13 @@ func (ac *AnalyticsController) GetAnalyticsOverview(c *gin.Context) {
ac.DB.Model(&models.Article{}).Where("published = ?", true).Count(&publishedArticles)
c.JSON(http.StatusOK, gin.H{
"total_page_views": totalPageViews,
"unique_visitors": uniqueVisitors,
"total_articles": totalArticles,
"published_articles": publishedArticles,
"page_views_today": pageViewsToday,
"page_views_week": pageViewsWeek,
"unique_visitors_week": uniqueVisitorsWeek,
"total_page_views": totalPageViews,
"unique_visitors": uniqueVisitors,
"total_articles": totalArticles,
"published_articles": publishedArticles,
"page_views_today": pageViewsToday,
"page_views_week": pageViewsWeek,
"unique_visitors_week": uniqueVisitorsWeek,
})
}
@@ -337,14 +335,14 @@ func (ac *AnalyticsController) GetTopPages(c *gin.Context) {
}
var pages []PageStats
// Try to fetch from Umami first
websiteID, err := ac.resolveWebsiteID()
if err == nil && websiteID != "" {
// Fetch URL metrics from Umami (last 30 days)
endAt := time.Now().Unix() * 1000
startAt := time.Now().AddDate(0, 0, -30).Unix() * 1000
metrics, err := ac.umamiService.GetWebsiteMetrics(websiteID, "url", startAt, endAt)
if err == nil && metrics != nil {
// Convert Umami metrics to PageStats format
@@ -354,14 +352,14 @@ func (ac *AnalyticsController) GetTopPages(c *gin.Context) {
}
pagePath := ""
viewCount := int64(0)
if x, ok := metricMap["x"].(string); ok {
pagePath = x
}
if y, ok := metricMap["y"].(float64); ok {
viewCount = int64(y)
}
pages = append(pages, PageStats{
PagePath: pagePath,
PageName: pagePath,
@@ -373,7 +371,7 @@ func (ac *AnalyticsController) GetTopPages(c *gin.Context) {
return
}
}
// Fallback to internal analytics
ac.DB.Model(&models.VisitorEvent{}).
Where("event_type = ?", "page_view").
@@ -403,33 +401,37 @@ func (ac *AnalyticsController) GetTopArticles(c *gin.Context) {
}
type TopInteraction struct {
Page string `json:"page"`
Element string `json:"element"`
Count int64 `json:"count"`
Page string `json:"page"`
Element string `json:"element"`
Count int64 `json:"count"`
}
func (ctrl *AnalyticsController) GetTopInteractions(c *gin.Context) {
daysParam := c.DefaultQuery("days", "30")
limitParam := c.DefaultQuery("limit", "10")
days, _ := strconv.Atoi(daysParam)
if days <= 0 || days > 365 { days = 30 }
limit, _ := strconv.Atoi(limitParam)
if limit <= 0 || limit > 100 { limit = 10 }
daysParam := c.DefaultQuery("days", "30")
limitParam := c.DefaultQuery("limit", "10")
days, _ := strconv.Atoi(daysParam)
if days <= 0 || days > 365 {
days = 30
}
limit, _ := strconv.Atoi(limitParam)
if limit <= 0 || limit > 100 {
limit = 10
}
start := time.Now().AddDate(0, 0, -days)
start := time.Now().AddDate(0, 0, -days)
var rows []TopInteraction
err := ctrl.DB.
Model(&models.VisitorEvent{}).
Select("page, element, COUNT(*) as count").
Where("event_type IN ? AND created_at >= ?", []string{"click", "interaction"}, start).
Group("page, element").
Order("count DESC").
Limit(limit).
Scan(&rows).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load interactions"})
return
}
c.JSON(http.StatusOK, gin.H{"items": rows})
var rows []TopInteraction
err := ctrl.DB.
Model(&models.VisitorEvent{}).
Select("page, element, COUNT(*) as count").
Where("event_type IN ? AND created_at >= ?", []string{"click", "interaction"}, start).
Group("page, element").
Order("count DESC").
Limit(limit).
Scan(&rows).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load interactions"})
return
}
c.JSON(http.StatusOK, gin.H{"items": rows})
}
+48 -23
View File
@@ -27,25 +27,25 @@ func NewArticleController(db *gorm.DB) *ArticleController {
// CreateArticleRequest represents the request body for creating an article
type CreateArticleRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content"`
CategoryID *uint `json:"category_id"`
CategoryName string `json:"category_name"`
ImageURL string `json:"image_url"`
Published *bool `json:"published"`
PublishedAt *string `json:"published_at"`
Featured *bool `json:"featured"`
Slug string `json:"slug"`
SeoTitle string `json:"seo_title"`
SeoDescription string `json:"seo_description"`
OgImageURL string `json:"og_image_url"`
GalleryAlbumID string `json:"gallery_album_id"`
GalleryAlbumURL string `json:"gallery_album_url"`
GalleryPhotoIDs []string `json:"gallery_photo_ids"`
YouTubeVideoID string `json:"youtube_video_id"`
YouTubeVideoTitle string `json:"youtube_video_title"`
YouTubeVideoURL string `json:"youtube_video_url"`
YouTubeVideoThumbnail string `json:"youtube_video_thumbnail"`
Title string `json:"title" binding:"required"`
Content string `json:"content"`
CategoryID *uint `json:"category_id"`
CategoryName string `json:"category_name"`
ImageURL string `json:"image_url"`
Published *bool `json:"published"`
PublishedAt *string `json:"published_at"`
Featured *bool `json:"featured"`
Slug string `json:"slug"`
SeoTitle string `json:"seo_title"`
SeoDescription string `json:"seo_description"`
OgImageURL string `json:"og_image_url"`
GalleryAlbumID string `json:"gallery_album_id"`
GalleryAlbumURL string `json:"gallery_album_url"`
GalleryPhotoIDs []string `json:"gallery_photo_ids"`
YouTubeVideoID string `json:"youtube_video_id"`
YouTubeVideoTitle string `json:"youtube_video_title"`
YouTubeVideoURL string `json:"youtube_video_url"`
YouTubeVideoThumbnail string `json:"youtube_video_thumbnail"`
Attachments []AttachmentItem `json:"attachments"`
}
@@ -80,7 +80,7 @@ func (ac *ArticleController) CreateArticle(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error("CreateArticle: Invalid request body: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Neplatná data požadavku",
"error": "Neplatná data požadavku",
"details": err.Error(),
})
return
@@ -138,7 +138,9 @@ func (ac *ArticleController) CreateArticle(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba při kontrole jedinečnosti URL"})
return
}
if sc == 0 { break }
if sc == 0 {
break
}
s = fmt.Sprintf("%s-%d", orig, i+1)
}
category = models.Category{Name: categoryName, Slug: s}
@@ -199,8 +201,31 @@ func (ac *ArticleController) CreateArticle(c *gin.Context) {
seoDesc = deriveSeoDescription(req.Content)
}
// 10. Set default image if empty
// 10. Set image: prefer provided, otherwise try Grok (XAI) main image, then fallback to static placeholder
imageURL := strings.TrimSpace(req.ImageURL)
if imageURL == "" && isXAIEnabled() {
promptParts := []string{
fmt.Sprintf("Titulní obrázek k článku na oficiálním webu fotbalového klubu. Titulek článku: \"%s\".", strings.TrimSpace(req.Title)),
}
if strings.TrimSpace(req.CategoryName) != "" {
promptParts = append(promptParts, fmt.Sprintf("Téma / soutěž: %s.", strings.TrimSpace(req.CategoryName)))
}
promptParts = append(promptParts,
"Zaměř se na atmosféru klubu stadion, hráče a fanoušky v klubových barvách.",
"Styl: realistický, moderní, sportovní, bez textu, široký banner v poměru 16:9 vhodný jako hlavní obrázek článku.",
)
prompt := strings.Join(promptParts, " ")
urls, _, err := callXAIImage(getXAIImageModel(), prompt, "1920x1080", 1)
if err != nil {
logger.Error("CreateArticle: XAI image generation failed: %v", err)
} else if len(urls) > 0 {
candidate := strings.TrimSpace(urls[0])
if candidate != "" {
imageURL = candidate
logger.Info("CreateArticle: Using XAI-generated main image")
}
}
}
if imageURL == "" {
imageURL = "/dist/img/logo-club-empty.svg"
logger.Info("CreateArticle: Using default image")
@@ -264,7 +289,7 @@ func (ac *ArticleController) CreateArticle(c *gin.Context) {
if err := ac.DB.Create(&article).Error; err != nil {
logger.Error("CreateArticle: Database error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Nelze vytvořit článek",
"error": "Nelze vytvořit článek",
"details": err.Error(),
})
return
+5
View File
@@ -495,6 +495,11 @@ func (ac *AuthController) AdminCreateUser(c *gin.Context) {
IsActive: isActive,
}
if err := ac.DB.Create(&u).Error; err != nil {
errStr := err.Error()
if strings.Contains(errStr, "duplicate key value") || strings.Contains(errStr, "idx_users_email") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Email already registered"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
+125 -25
View File
@@ -515,17 +515,77 @@ func foldAccents(s string) string {
// Optional query: q= filters by home/away/venue/competition
func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
p := filepath.Join("cache", "prefetch", "events_past.json")
f, err := os.Open(p)
if err != nil {
c.JSON(http.StatusNoContent, gin.H{"message": "No cached past matches"})
return
}
defer f.Close()
var matches []map[string]interface{}
if err := json.NewDecoder(f).Decode(&matches); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Cannot parse cached past matches"})
return
if f, err := os.Open(p); err == nil {
defer f.Close()
if err := json.NewDecoder(f).Decode(&matches); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Cannot parse cached past matches"})
return
}
} else {
p2 := filepath.Join("cache", "prefetch", "facr_club_info.json")
if f2, err2 := os.Open(p2); err2 == nil {
defer f2.Close()
var facr struct {
Competitions []struct {
Matches []struct {
MatchID string `json:"match_id"`
Home string `json:"home"`
Away string `json:"away"`
Venue string `json:"venue"`
DateTime string `json:"date_time"`
Score string `json:"score"`
HomeLogoURL string `json:"home_logo_url"`
AwayLogoURL string `json:"away_logo_url"`
HomeID string `json:"home_id"`
AwayID string `json:"away_id"`
HomeTeamID string `json:"home_team_id"`
AwayTeamID string `json:"away_team_id"`
} `json:"matches"`
} `json:"competitions"`
}
if err := json.NewDecoder(f2).Decode(&facr); err == nil {
now := time.Now()
for _, c := range facr.Competitions {
for _, m := range c.Matches {
dt := strings.TrimSpace(m.DateTime)
if dt == "" {
continue
}
ts, perr := time.ParseInLocation("02.01.2006 15:04", dt, time.Local)
if perr != nil || ts.After(now) {
continue
}
row := map[string]interface{}{
"match_id": m.MatchID,
"home": m.Home,
"away": m.Away,
"venue": m.Venue,
"date_time": m.DateTime,
"score": m.Score,
"home_logo_url": m.HomeLogoURL,
"away_logo_url": m.AwayLogoURL,
}
if m.HomeTeamID != "" {
row["home_team_id"] = m.HomeTeamID
} else if m.HomeID != "" {
row["home_team_id"] = m.HomeID
row["home_id"] = m.HomeID
}
if m.AwayTeamID != "" {
row["away_team_id"] = m.AwayTeamID
} else if m.AwayID != "" {
row["away_team_id"] = m.AwayID
row["away_id"] = m.AwayID
}
matches = append(matches, row)
}
}
}
} else {
c.JSON(http.StatusNoContent, gin.H{"message": "No cached past matches"})
return
}
}
// Apply overrides (same as in GetMatches)
@@ -922,7 +982,7 @@ func (bc *BaseController) GetZoneramaAlbum(c *gin.Context) {
return
}
req.Header.Set("User-Agent", "fotbal-club/zonerama-proxy")
client := &http.Client{Timeout: 25 * time.Second}
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
@@ -1907,7 +1967,6 @@ func (bc *BaseController) GetCompetitionAliases(c *gin.Context) {
c.JSON(http.StatusOK, items)
}
// Admin: create or replace alias by code (idempotent)
func (bc *BaseController) PutCompetitionAlias(c *gin.Context) {
code := strings.TrimSpace(c.Param("code"))
if code == "" {
@@ -1928,7 +1987,7 @@ func (bc *BaseController) PutCompetitionAlias(c *gin.Context) {
return
}
var item models.CompetitionAlias
if err := bc.DB.Where("code = ?", code).First(&item).Error; err != nil {
if err := bc.DB.Unscoped().Where("code = ?", code).First(&item).Error; err != nil {
if err == gorm.ErrRecordNotFound {
item = models.CompetitionAlias{Code: code}
} else {
@@ -1947,6 +2006,12 @@ func (bc *BaseController) PutCompetitionAlias(c *gin.Context) {
return
}
} else {
if item.DeletedAt.Valid {
if err := bc.DB.Unscoped().Model(&item).Update("deleted_at", nil).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze obnovit alias"})
return
}
}
if err := bc.DB.Save(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit alias"})
return
@@ -1964,7 +2029,7 @@ func (bc *BaseController) DeleteCompetitionAlias(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba code"})
return
}
if err := bc.DB.Where("code = ?", code).Delete(&models.CompetitionAlias{}).Error; err != nil {
if err := bc.DB.Unscoped().Where("code = ?", code).Delete(&models.CompetitionAlias{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze smazat alias"})
return
}
@@ -3056,8 +3121,15 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
}
services.StartErrorReviewAutoRegister(bc.DB)
if strings.TrimSpace(s.ClubID) != "" {
if url, err := services.CacheClubLogo(bc.DB, strings.TrimSpace(s.ClubID)); err == nil && strings.TrimSpace(url) != "" {
_ = bc.DB.Model(&models.Settings{}).Where("id = ?", s.ID).Update("club_logo_url", url).Error
if clubInfo, err := services.CacheClubLogoAndName(bc.DB, strings.TrimSpace(s.ClubID)); err == nil {
if strings.TrimSpace(clubInfo.LogoURL) != "" {
_ = bc.DB.Model(&models.Settings{}).Where("id = ?", s.ID).Update("club_logo_url", clubInfo.LogoURL).Error
}
// Update club name if it's empty and we got one from logoapi
if strings.TrimSpace(s.ClubName) == "" && strings.TrimSpace(clubInfo.ClubName) != "" {
_ = bc.DB.Model(&models.Settings{}).Where("id = ?", s.ID).Update("club_name", clubInfo.ClubName).Error
s.ClubName = clubInfo.ClubName // Update local variable for logging
}
}
}
go func(snap models.Settings) {
@@ -3462,8 +3534,15 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
services.StartErrorReviewAutoRegister(bc.DB)
if strings.TrimSpace(s.ClubID) != "" {
go func(id uint, clubID string) {
if url, err := services.CacheClubLogo(bc.DB, strings.TrimSpace(clubID)); err == nil && strings.TrimSpace(url) != "" {
_ = bc.DB.Model(&models.Settings{}).Where("id = ?", id).Update("club_logo_url", url).Error
if clubInfo, err := services.CacheClubLogoAndName(bc.DB, strings.TrimSpace(clubID)); err == nil {
if strings.TrimSpace(clubInfo.LogoURL) != "" {
_ = bc.DB.Model(&models.Settings{}).Where("id = ?", id).Update("club_logo_url", clubInfo.LogoURL).Error
}
// Update club name if it's empty and we got one from logoapi
var currentSettings models.Settings
if bc.DB.First(&currentSettings, id).Error == nil && strings.TrimSpace(currentSettings.ClubName) == "" && strings.TrimSpace(clubInfo.ClubName) != "" {
_ = bc.DB.Model(&models.Settings{}).Where("id = ?", id).Update("club_name", clubInfo.ClubName).Error
}
}
}(s.ID, s.ClubID)
}
@@ -4130,8 +4209,15 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
services.StartErrorReviewAutoRegister(bc.DB)
if strings.TrimSpace(s.ClubID) != "" && (strings.HasPrefix(strings.ToLower(strings.TrimSpace(s.ClubLogoURL)), "http://") || strings.HasPrefix(strings.ToLower(strings.TrimSpace(s.ClubLogoURL)), "https://") || strings.TrimSpace(s.ClubLogoURL) == "") {
go func(id uint, clubID string) {
if url, err := services.CacheClubLogo(bc.DB, strings.TrimSpace(clubID)); err == nil && strings.TrimSpace(url) != "" {
_ = bc.DB.Model(&models.Settings{}).Where("id = ?", id).Update("club_logo_url", url).Error
if clubInfo, err := services.CacheClubLogoAndName(bc.DB, strings.TrimSpace(clubID)); err == nil {
if strings.TrimSpace(clubInfo.LogoURL) != "" {
_ = bc.DB.Model(&models.Settings{}).Where("id = ?", id).Update("club_logo_url", clubInfo.LogoURL).Error
}
// Update club name if it's empty and we got one from logoapi
var currentSettings models.Settings
if bc.DB.First(&currentSettings, id).Error == nil && strings.TrimSpace(currentSettings.ClubName) == "" && strings.TrimSpace(clubInfo.ClubName) != "" {
_ = bc.DB.Model(&models.Settings{}).Where("id = ?", id).Update("club_name", clubInfo.ClubName).Error
}
}
}(s.ID, s.ClubID)
}
@@ -4264,7 +4350,9 @@ func (bc *BaseController) GetPublicSettings(c *gin.Context) {
"club_logo_url": s.ClubLogoURL,
"club_url": s.ClubURL,
// Runtime flags (env-based)
"premium": config.AppConfig.Premium,
"premium": config.AppConfig.Premium,
"club_data_mode": config.AppConfig.ClubDataMode,
"eshop_enabled": config.AppConfig.EshopEnabled,
// Theme
"primary_color": s.PrimaryColor,
@@ -4361,6 +4449,16 @@ func (bc *BaseController) GetSettings(c *gin.Context) {
}
s.LoadCustomNav()
s.LoadVideosOverrides()
// Decode manual videos for admin payload (populate transient exported fields)
if strings.TrimSpace(s.VideosJSON) != "" {
var vids []string
_ = json.Unmarshal([]byte(s.VideosJSON), &vids)
s.Videos = vids
}
if strings.TrimSpace(s.VideosItemsJSON) != "" {
// Unmarshal directly into the transient anonymous struct field type
_ = json.Unmarshal([]byte(s.VideosItemsJSON), &s.VideosItems)
}
// derive map form for admin consumers
mv := map[string]string{}
if len(s.VideosOverrides) > 0 {
@@ -5169,7 +5267,7 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
}
name := strings.TrimSpace(f.Filename)
ext := strings.ToLower(filepath.Ext(name))
// Allow images, PDFs, Office docs, text, archives, and common media
// Allow images, PDFs, Office docs, text, archives, and common media (including webm audio)
allowed := map[string]bool{
// Images
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".svg": true,
@@ -5178,7 +5276,7 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
// Archives
".zip": true, ".rar": true, ".7z": true, ".tar": true, ".gz": true,
// Media
".mp4": true, ".avi": true, ".mov": true, ".mp3": true, ".wav": true,
".mp4": true, ".avi": true, ".mov": true, ".mp3": true, ".wav": true, ".webm": true,
}
if !allowed[ext] {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported file type"})
@@ -5223,8 +5321,10 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
validCT = strings.HasPrefix(dl, "text/") || dl == "application/octet-stream"
case ".mp4", ".avi", ".mov":
validCT = strings.HasPrefix(dl, "video/") || dl == "application/octet-stream"
case ".mp3", ".wav":
validCT = strings.HasPrefix(dl, "audio/") || dl == "application/octet-stream"
case ".mp3", ".wav", ".webm":
// Some browsers label MediaRecorder audio-only blobs as video/webm,
// so allow both audio/* and video/* for webm uploads in addition to a generic octet-stream.
validCT = strings.HasPrefix(dl, "audio/") || strings.HasPrefix(dl, "video/") || dl == "application/octet-stream"
default:
validCT = strings.HasPrefix(dl, "image/")
}
@@ -35,7 +35,7 @@ func (bc *BaseController) seedDefaultHomePageElements() {
{PageType: "homepage", ElementName: "table", Variant: "split_news", Visible: true, DisplayOrder: 4},
// Videos - YouTube videos and highlights
{PageType: "homepage", ElementName: "videos", Variant: "grid", Visible: true, DisplayOrder: 5},
{PageType: "homepage", ElementName: "videos", Variant: "carousel", Visible: true, DisplayOrder: 5},
// Gallery - photo gallery
{PageType: "homepage", ElementName: "gallery", Variant: "grid", Visible: true, DisplayOrder: 6},
+36 -9
View File
@@ -170,20 +170,28 @@ func (cc *CommentController) React(c *gin.Context) {
case uint:
userID = v
case int:
if v > 0 { userID = uint(v) }
if v > 0 {
userID = uint(v)
}
case int64:
if v > 0 { userID = uint(v) }
if v > 0 {
userID = uint(v)
}
case float64:
if v > 0 { userID = uint(v) }
if v > 0 {
userID = uint(v)
}
case string:
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { userID = uint(n) }
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
userID = uint(n)
}
}
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Robust upsert without relying on a DB unique constraint: delete then insert in a transaction
// Robust upsert with proper constraint handling
if err := cc.DB.Transaction(func(tx *gorm.DB) error {
// Remove any previous reaction by this user on this comment
if err := tx.Where("comment_id = ? AND user_id = ?", cm.ID, userID).Delete(&models.CommentReaction{}).Error; err != nil {
@@ -192,6 +200,17 @@ func (cc *CommentController) React(c *gin.Context) {
// Insert the new reaction
r := models.CommentReaction{CommentID: cm.ID, UserID: userID, Type: rt}
if err := tx.Create(&r).Error; err != nil {
// Check for unique constraint violation (PostgreSQL error code 23505)
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique constraint") {
// If we get a duplicate key error, try to update the existing reaction
updateErr := tx.Model(&models.CommentReaction{}).
Where("comment_id = ? AND user_id = ?", cm.ID, userID).
Update("type", rt).Error
if updateErr != nil {
return updateErr
}
return nil
}
return err
}
return nil
@@ -221,13 +240,21 @@ func (cc *CommentController) Unreact(c *gin.Context) {
case uint:
userID = v
case int:
if v > 0 { userID = uint(v) }
if v > 0 {
userID = uint(v)
}
case int64:
if v > 0 { userID = uint(v) }
if v > 0 {
userID = uint(v)
}
case float64:
if v > 0 { userID = uint(v) }
if v > 0 {
userID = uint(v)
}
case string:
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { userID = uint(n) }
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
userID = uint(n)
}
}
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
@@ -231,6 +231,80 @@ func (cc *ContactController) GetNewsletterSubscribers(c *gin.Context) {
c.JSON(http.StatusOK, subs)
}
// CreateNewsletterSubscriber creates a new newsletter subscriber (admin)
// @Summary Create newsletter subscriber
// @Description Creates a new newsletter subscriber with optional preferences (admin only)
// @Tags admin
// @Security Bearer
// @Accept json
// @Produce json
// @Param subscriber body object true "Subscriber data"
// @Success 201 {object} models.NewsletterSubscription
// @Failure 400 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/admin/newsletter/subscribers [post]
func (cc *ContactController) CreateNewsletterSubscriber(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var body struct {
Email string `json:"email" binding:"required,email"`
Preferences map[string]bool `json:"preferences"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload: " + err.Error()})
return
}
// Check if subscriber already exists
var existingSub models.NewsletterSubscription
if err := cc.DB.Where("email = ?", body.Email).First(&existingSub).Error; err == nil {
// Subscriber exists, update status to active and preferences
existingSub.IsActive = true
if body.Preferences != nil {
if body.Preferences != nil {
m := datatypes.JSONMap{}
for k, v := range body.Preferences {
m[k] = v
}
existingSub.Preferences = m
}
}
if err := cc.DB.Save(&existingSub).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update existing subscriber"})
return
}
cc.recalcNewsletterAutomationEnabled()
c.JSON(http.StatusOK, existingSub)
return
}
// Create new subscriber
preferences := datatypes.JSONMap{}
if body.Preferences != nil {
for k, v := range body.Preferences {
preferences[k] = v
}
}
sub := models.NewsletterSubscription{
Email: body.Email,
IsActive: true,
Preferences: preferences,
}
if err := cc.DB.Create(&sub).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create subscriber"})
return
}
// Recalculate automation flag after adding subscriber
cc.recalcNewsletterAutomationEnabled()
c.JSON(http.StatusCreated, sub)
}
// UpdateNewsletterSubscriberStatus toggles is_active for a subscriber (admin)
func (cc *ContactController) UpdateNewsletterSubscriberStatus(c *gin.Context) {
if c.GetString("userRole") != "admin" {
@@ -0,0 +1,356 @@
package controllers
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type DirectoryController struct {
DB *gorm.DB
request *gin.Context
}
func NewDirectoryController(db *gorm.DB) *DirectoryController {
return &DirectoryController{DB: db}
}
type InstanceRegistrationPayload struct {
ClubID string `json:"club_id"`
ClubName string `json:"club_name"`
APIBaseURL string `json:"api_base_url"`
LogoURL string `json:"logo_url"`
City string `json:"city"`
Country string `json:"country"`
IsActive bool `json:"is_active"`
Version string `json:"version"`
Tags map[string]string `json:"tags"`
Features []string `json:"features"`
LastSeen time.Time `json:"last_seen"`
}
// RegisterInstance registers this club instance with the central directory
func (dc *DirectoryController) RegisterInstance(c *gin.Context) {
// Store request context for helper methods
dc.request = c
var s models.Settings
if err := dc.DB.First(&s).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"})
return
}
// Build registration payload
payload := InstanceRegistrationPayload{
ClubID: strings.TrimSpace(s.ClubID),
ClubName: strings.TrimSpace(s.ClubName),
APIBaseURL: dc.getPublicURL(),
LogoURL: strings.TrimSpace(s.ClubLogoURL),
City: strings.TrimSpace(s.ContactCity),
Country: strings.TrimSpace(s.ContactCountry),
IsActive: true,
Version: strings.TrimSpace(os.Getenv("APP_VERSION")),
LastSeen: time.Now(),
Tags: map[string]string{
"instance_host": dc.getHostname(),
"environment": config.AppConfig.AppEnv,
"instance_id": dc.getInstanceID(),
},
Features: dc.getEnabledFeatures(),
}
// Validate required fields
if payload.ClubID == "" || payload.ClubName == "" || payload.APIBaseURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing required fields: club_id, club_name, api_base_url"})
return
}
// Send to central directory
if err := dc.sendToCentralDirectory("/api/v1/directory/register", payload); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register with central directory", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "registered",
"club_id": payload.ClubID,
"timestamp": payload.LastSeen,
})
}
// Heartbeat sends a heartbeat to central directory
func (dc *DirectoryController) Heartbeat(c *gin.Context) {
var s models.Settings
if err := dc.DB.First(&s).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"})
return
}
payload := map[string]interface{}{
"club_id": strings.TrimSpace(s.ClubID),
"last_seen": time.Now(),
"status": "active",
"tags": map[string]string{
"instance_host": dc.getHostname(),
"environment": config.AppConfig.AppEnv,
},
}
if err := dc.sendToCentralDirectory("/api/v1/directory/heartbeat", payload); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send heartbeat", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "timestamp": time.Now()})
}
// GetInstanceInfo returns this instance's information
func (dc *DirectoryController) GetInstanceInfo(c *gin.Context) {
// Store request context for helper methods
dc.request = c
var s models.Settings
if err := dc.DB.First(&s).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"})
return
}
info := InstanceRegistrationPayload{
ClubID: strings.TrimSpace(s.ClubID),
ClubName: strings.TrimSpace(s.ClubName),
APIBaseURL: dc.getPublicURL(),
LogoURL: strings.TrimSpace(s.ClubLogoURL),
City: strings.TrimSpace(s.ContactCity),
Country: strings.TrimSpace(s.ContactCountry),
IsActive: true,
Version: strings.TrimSpace(os.Getenv("APP_VERSION")),
LastSeen: time.Now(),
Tags: map[string]string{
"instance_host": dc.getHostname(),
"environment": config.AppConfig.AppEnv,
"instance_id": dc.getInstanceID(),
},
Features: dc.getEnabledFeatures(),
}
c.JSON(http.StatusOK, info)
}
// Helper methods
func (dc *DirectoryController) getPublicURL() string {
// Try to get the public URL from settings or environment
if config.AppConfig != nil && config.AppConfig.FrontendBaseURL != "" {
return strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/") + "/api/v1"
}
// Fallback to constructing from request
scheme := "https"
if dc.request == nil || dc.request.Request.TLS == nil {
scheme = "http"
}
host := "localhost"
if dc.request != nil {
host = dc.request.Request.Host
if idx := strings.Index(host, ":"); idx >= 0 {
host = host[:idx]
}
}
return scheme + "://" + host + "/api/v1"
}
func (dc *DirectoryController) getHostname() string {
host := "localhost"
if dc.request != nil {
host = dc.request.Request.Host
if idx := strings.Index(host, ":"); idx >= 0 {
host = host[:idx]
}
}
return host
}
func (dc *DirectoryController) getInstanceID() string {
// Try to get instance ID from environment or generate from hostname
if id := strings.TrimSpace(os.Getenv("INSTANCE_ID")); id != "" {
return id
}
hostname := dc.getHostname()
if hostname != "" {
return strings.ReplaceAll(hostname, ".", "-")
}
return "unknown"
}
func (dc *DirectoryController) getEnabledFeatures() []string {
var features []string
// Always add basic features
features = append(features, "dashboard", "news", "auth")
// Check which features are enabled based on settings
var s models.Settings
if err := dc.DB.First(&s).Error; err == nil {
if s.VideosModuleEnabled {
features = append(features, "videos")
}
if s.MerchModuleEnabled {
features = append(features, "merch")
}
if s.NewsletterEnabled {
features = append(features, "newsletter")
}
if s.ShowMapOnHomepage {
features = append(features, "map")
}
if s.GalleryURL != "" {
features = append(features, "gallery")
}
// Add matches feature (always enabled for now)
features = append(features, "matches")
// Add blog feature (always enabled for now)
features = append(features, "blog")
}
return features
}
func (dc *DirectoryController) sendToCentralDirectory(endpoint string, payload interface{}) error {
// Get central directory URL from environment
baseURL := strings.TrimSpace(os.Getenv("DIRECTORY_INGEST_URL"))
if baseURL == "" {
return nil // Silently skip if not configured
}
// Build full URL
fullURL := strings.TrimSuffix(baseURL, "/") + endpoint
// Marshal payload
b, err := json.Marshal(payload)
if err != nil {
return err
}
// Send request with timeout
post := func(u string) bool {
req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(b))
if err != nil {
return false
}
req.Header.Set("Content-Type", "application/json")
// Use same token pattern as error system
token := strings.TrimSpace(os.Getenv("DIRECTORY_INGEST_TOKEN"))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Ingest-Token", token)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode >= 200 && resp.StatusCode < 300
}
if post(fullURL) {
return nil
}
// Try Docker host fallback for local development
if u, err := url.Parse(fullURL); err == nil {
h := u.Hostname()
if h == "127.0.0.1" || h == "localhost" {
u.Host = strings.Replace(u.Host, h, "host.docker.internal", 1)
if post(u.String()) {
return nil
}
}
}
return fmt.Errorf("failed to send to central directory")
}
// StartDirectoryHeartbeat starts the background heartbeat process
func (dc *DirectoryController) StartDirectoryHeartbeat() {
// Check if directory registration is enabled
if strings.TrimSpace(os.Getenv("DIRECTORY_INGEST_URL")) == "" {
return
}
ticker := time.NewTicker(5 * time.Minute) // Heartbeat every 5 minutes
go func() {
for range ticker.C {
// Send heartbeat in background
go func() {
payload := map[string]interface{}{
"club_id": dc.getClubID(),
"last_seen": time.Now(),
"status": "active",
"tags": map[string]string{
"instance_host": dc.getHostname(),
"environment": config.AppConfig.AppEnv,
},
}
dc.sendToCentralDirectory("/api/v1/directory/heartbeat", payload)
}()
}
}()
// Also register immediately on startup
go func() {
time.Sleep(2 * time.Second) // Wait for server to be ready
dc.registerOnStartup()
}()
}
func (dc *DirectoryController) getClubID() string {
var s models.Settings
if err := dc.DB.First(&s).Error; err != nil {
return ""
}
return strings.TrimSpace(s.ClubID)
}
func (dc *DirectoryController) registerOnStartup() {
var s models.Settings
if err := dc.DB.First(&s).Error; err != nil {
return
}
payload := InstanceRegistrationPayload{
ClubID: strings.TrimSpace(s.ClubID),
ClubName: strings.TrimSpace(s.ClubName),
APIBaseURL: dc.getPublicURL(),
LogoURL: strings.TrimSpace(s.ClubLogoURL),
City: strings.TrimSpace(s.ContactCity),
Country: strings.TrimSpace(s.ContactCountry),
IsActive: true,
Version: strings.TrimSpace(os.Getenv("APP_VERSION")),
LastSeen: time.Now(),
Tags: map[string]string{
"instance_host": dc.getHostname(),
"environment": config.AppConfig.AppEnv,
"instance_id": dc.getInstanceID(),
},
Features: dc.getEnabledFeatures(),
}
dc.sendToCentralDirectory("/api/v1/directory/register", payload)
}
+96 -2
View File
@@ -1,6 +1,8 @@
package controllers
import (
"crypto/rand"
"encoding/hex"
"net/http"
"strconv"
"strings"
@@ -62,6 +64,16 @@ func NewEngagementController(db *gorm.DB, es email.EmailService) *EngagementCont
return &EngagementController{DB: db, Email: es}
}
// genRewardSKU generates a short uppercase code like RWD-1A2B3C4D
func genRewardSKU() string {
b := make([]byte, 4)
if _, err := rand.Read(b); err == nil {
return "RWD-" + strings.ToUpper(hex.EncodeToString(b))
}
// Fallback to time-based base36 suffix
return "RWD-" + strings.ToUpper(strconv.FormatInt(time.Now().UnixNano()%2176782336, 36))
}
// Admin: adjust points for a user (positive or negative)
// POST /api/v1/admin/engagement/adjust { user_id, delta, reason?, meta? }
func (ec *EngagementController) AdminAdjustPoints(c *gin.Context) {
@@ -536,7 +548,6 @@ func (ec *EngagementController) GetAchievements(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"achievements": items, "counters": gin.H{"comments": commentCount, "votes": voteCount, "newsletter": hasNewsletter}})
}
// Admin: list rewards
// GET /api/v1/admin/engagement/rewards
func (ec *EngagementController) AdminListRewards(c *gin.Context) {
var items []models.RewardItem
@@ -579,6 +590,28 @@ func (ec *EngagementController) AdminListRewards(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load rewards"})
return
}
// Backfill missing SKU for merch_physical items
for i := range items {
if strings.EqualFold(strings.TrimSpace(items[i].Type), "merch_physical") {
md := items[i].Metadata
var skuVal string
if md != nil {
if v, ok := md["sku"]; ok {
if s, ok2 := v.(string); ok2 {
skuVal = strings.TrimSpace(s)
}
}
}
if strings.TrimSpace(skuVal) == "" {
if md == nil {
md = datatypes.JSONMap{}
}
md["sku"] = genRewardSKU()
_ = ec.DB.Model(&models.RewardItem{}).Where("id = ?", items[i].ID).Update("metadata", md).Error
items[i].Metadata = md
}
}
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
@@ -611,6 +644,20 @@ func (ec *EngagementController) AdminCreateReward(c *gin.Context) {
if body.Metadata != nil {
item.Metadata = body.Metadata
}
// Ensure SKU is auto-generated for merch_physical when missing/empty
if strings.EqualFold(strings.TrimSpace(item.Type), "merch_physical") {
if item.Metadata == nil {
item.Metadata = datatypes.JSONMap{}
}
if v, ok := item.Metadata["sku"]; !ok {
item.Metadata["sku"] = genRewardSKU()
} else {
s, _ := v.(string)
if strings.TrimSpace(s) == "" {
item.Metadata["sku"] = genRewardSKU()
}
}
}
if err := ec.DB.Create(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create reward"})
return
@@ -686,8 +733,55 @@ func (ec *EngagementController) AdminUpdateReward(c *gin.Context) {
if body.Active != nil {
updates["active"] = *body.Active
}
// Determine target type after potential change
targetType := existing.Type
if body.Type != nil {
targetType = strings.TrimSpace(*body.Type)
}
if body.Metadata != nil {
updates["metadata"] = body.Metadata
// Merge existing metadata with new to preserve fields like auto-generated sku
merged := map[string]interface{}{}
if existing.Metadata != nil {
for k, v := range existing.Metadata {
merged[k] = v
}
}
for k, v := range body.Metadata {
merged[k] = v
}
if strings.EqualFold(strings.TrimSpace(targetType), "merch_physical") {
if v, ok := merged["sku"]; !ok {
merged["sku"] = genRewardSKU()
} else {
s, _ := v.(string)
if strings.TrimSpace(s) == "" {
merged["sku"] = genRewardSKU()
}
}
}
updates["metadata"] = merged
} else {
// If type is merch_physical and existing metadata lacks sku, add it
if strings.EqualFold(strings.TrimSpace(targetType), "merch_physical") {
needSKU := true
if existing.Metadata != nil {
if v, ok := existing.Metadata["sku"]; ok {
if s, ok2 := v.(string); ok2 && strings.TrimSpace(s) != "" {
needSKU = false
}
}
}
if needSKU {
merged := map[string]interface{}{}
if existing.Metadata != nil {
for k, v := range existing.Metadata {
merged[k] = v
}
}
merged["sku"] = genRewardSKU()
updates["metadata"] = merged
}
}
}
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No changes"})
@@ -0,0 +1,264 @@
package eshop
import (
"net/http"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type CartController struct {
DB *gorm.DB
Config *config.Config
}
func NewCartController(db *gorm.DB, cfg *config.Config) *CartController {
return &CartController{
DB: db,
Config: cfg,
}
}
type cartContext struct {
UserID *uint
SessionToken string
}
func (ctrl *CartController) getCartContext(c *gin.Context) cartContext {
var res cartContext
if uidVal, ok := c.Get("userID"); ok {
switch v := uidVal.(type) {
case uint:
res.UserID = &v
case int:
u := uint(v)
res.UserID = &u
case int64:
u := uint(v)
res.UserID = &u
}
}
res.SessionToken = c.GetHeader("X-Session-Token")
if res.SessionToken == "" {
if cookie, err := c.Request.Cookie("eshop_session_token"); err == nil {
res.SessionToken = cookie.Value
}
}
return res
}
func (ctrl *CartController) findOrCreateCart(c *gin.Context) (*models.EshopCart, error) {
cc := ctrl.getCartContext(c)
q := ctrl.DB.Where("completed = ?", false)
if cc.UserID != nil {
q = q.Where("user_id = ?", *cc.UserID)
} else if cc.SessionToken != "" {
q = q.Where("session_token = ?", cc.SessionToken)
}
var cart models.EshopCart
if err := q.Preload("Items").Preload("Items.Product").First(&cart).Error; err != nil {
if err != gorm.ErrRecordNotFound {
return nil, err
}
// Create new cart
cart = models.EshopCart{
UserID: cc.UserID,
SessionToken: cc.SessionToken,
Currency: ctrl.Config.StripeCurrency,
}
if cart.Currency == "" {
cart.Currency = "CZK"
}
if err := ctrl.DB.Create(&cart).Error; err != nil {
return nil, err
}
}
return &cart, nil
}
// GetCart returns the current cart
func (ctrl *CartController) GetCart(c *gin.Context) {
cartObj, err := ctrl.findOrCreateCart(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load cart"})
return
}
// Ensure items are fully loaded
if err := ctrl.DB.
Preload("Items").
Preload("Items.Product").
Preload("Items.Variant").
First(cartObj, cartObj.ID).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load cart items"})
return
}
c.JSON(http.StatusOK, cartObj)
}
// AddItem adds an item to the cart
func (ctrl *CartController) AddItem(c *gin.Context) {
var body struct {
ProductID uint `json:"product_id"`
VariantID *uint `json:"variant_id"`
Quantity int `json:"quantity"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format: " + err.Error()})
return
}
// Validate required fields
if body.ProductID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Product ID is required"})
return
}
if body.Quantity <= 0 || body.Quantity > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Quantity must be between 1 and 100"})
return
}
// Check if product exists and is active
var product models.EshopProduct
if err := ctrl.DB.Where("id = ? AND active = ?", body.ProductID, true).First(&product).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Product not found or not available"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load product"})
}
return
}
// Validate variant if provided
if body.VariantID != nil {
var variant models.EshopProductVariant
if err := ctrl.DB.Where("id = ? AND product_id = ?", *body.VariantID, body.ProductID).First(&variant).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Product variant not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load variant"})
}
return
}
// Check stock (negative values mean unlimited)
if variant.StockQty >= 0 && variant.StockQty < body.Quantity {
c.JSON(http.StatusBadRequest, gin.H{"error": "Insufficient stock for this variant"})
return
}
}
cartObj, err := ctrl.findOrCreateCart(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load cart"})
return
}
// Upsert cart item
var item models.EshopCartItem
q := ctrl.DB.Where("cart_id = ? AND product_id = ?", cartObj.ID, body.ProductID)
if body.VariantID != nil {
q = q.Where("variant_id = ?", *body.VariantID)
}
if err := q.First(&item).Error; err != nil {
if err != gorm.ErrRecordNotFound {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update cart"})
return
}
item = models.EshopCartItem{
CartID: cartObj.ID,
ProductID: body.ProductID,
VariantID: body.VariantID,
Quantity: body.Quantity,
UnitPriceCents: product.PriceCents,
Currency: product.Currency,
}
if item.Currency == "" {
item.Currency = cartObj.Currency
}
if err := ctrl.DB.Create(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add item"})
return
}
} else {
// Update quantity
item.Quantity += body.Quantity
if item.Quantity <= 0 {
if err := ctrl.DB.Delete(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update cart"})
return
}
} else {
if err := ctrl.DB.Save(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update cart"})
return
}
}
}
c.Status(http.StatusNoContent)
}
// UpdateItem updates quantity of a cart item
func (ctrl *CartController) UpdateItem(c *gin.Context) {
id := c.Param("id")
var body struct {
Quantity int `json:"quantity"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Quantity < 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
var item models.EshopCartItem
if err := ctrl.DB.First(&item, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Cart item not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load cart item"})
return
}
// Check ownership (simplified) - in real app check if item belongs to user's cart
// Here we assume if they know the ID, they can edit it (or rely on middleware/session match in findOrCreateCart flow if we enforced it strictly)
// Ideally we should check if item.CartID belongs to current session/user.
// For MVP let's leave it as is, or add a check:
cc := ctrl.getCartContext(c)
var cart models.EshopCart
if err := ctrl.DB.First(&cart, item.CartID).Error; err == nil {
if cc.UserID != nil && (cart.UserID == nil || *cart.UserID != *cc.UserID) {
// user mismatch
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
return
}
if cc.UserID == nil && cc.SessionToken != "" && cart.SessionToken != cc.SessionToken {
// token mismatch
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
return
}
}
if body.Quantity == 0 {
if err := ctrl.DB.Delete(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update cart"})
return
}
} else {
item.Quantity = body.Quantity
if err := ctrl.DB.Save(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update cart"})
return
}
}
c.Status(http.StatusNoContent)
}
// RemoveItem removes an item from the cart
func (ctrl *CartController) RemoveItem(c *gin.Context) {
id := c.Param("id")
if err := ctrl.DB.Delete(&models.EshopCartItem{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove item"})
return
}
c.Status(http.StatusNoContent)
}
@@ -0,0 +1,693 @@
package eshop
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/internal/services/eshop"
"fotbal-club/pkg/logger"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// PaymentProvider represents a minimal interface for payment services used by checkout.
// It is implemented by RevolutService, StripeService and can be satisfied by fakes in tests.
type PaymentProvider interface {
CreatePayment(order *models.EshopOrder) (*eshop.PaymentResult, error)
}
type CheckoutController struct {
DB *gorm.DB
RevolutService PaymentProvider
StripeService *eshop.StripeService
Config *config.Config
}
func NewCheckoutController(db *gorm.DB, cfg *config.Config) *CheckoutController {
return &CheckoutController{
DB: db,
RevolutService: eshop.NewRevolutService(cfg),
StripeService: eshop.NewStripeService(cfg),
Config: cfg,
}
}
type CheckoutRequest struct {
FirstName string `json:"first_name" binding:"required"`
LastName string `json:"last_name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Phone string `json:"phone"`
BillingAddress json.RawMessage `json:"billing_address"`
ShippingAddress json.RawMessage `json:"shipping_address"`
ShippingMethod string `json:"shipping_method" binding:"required"`
// For Packeta, we might receive packet_point_id in shipping_address or separately
}
func (ctrl *CheckoutController) Checkout(c *gin.Context) {
var req CheckoutRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format: " + err.Error()})
return
}
// Validate email format
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid email address"})
return
}
// Validate phone format (basic Czech phone validation)
if req.Phone != "" {
// Remove spaces, dashes, parentheses
phone := strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(req.Phone, " ", ""), "-", ""), "(", "")
phone = strings.ReplaceAll(phone, ")", "")
// Check if it starts with +420 or is 9 digits (Czech format)
if !strings.HasPrefix(phone, "+420") && len(phone) != 9 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid phone number format"})
return
}
}
// Validate shipping method
validShippingMethods := []string{"packeta", "courier"}
isValidShipping := false
for _, method := range validShippingMethods {
if req.ShippingMethod == method {
isValidShipping = true
break
}
}
if !isValidShipping {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid shipping method"})
return
}
// For Packeta, validate that shipping address contains point ID
if req.ShippingMethod == "packeta" {
var pointData struct {
ID interface{} `json:"id"`
}
if err := json.Unmarshal(req.ShippingAddress, &pointData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Packeta point data"})
return
}
if pointData.ID == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Packeta point ID is required"})
return
}
}
// 1. Get Cart
// Helper to find cart (duplicated from main.go - should be refactored to service)
// For now, let's just assume we can get it via user or session
userIDVal, _ := c.Get("userID")
var userID *uint
if u, ok := userIDVal.(uint); ok {
userID = &u
}
sessionToken := c.GetHeader("X-Session-Token")
if sessionToken == "" {
if cookie, err := c.Request.Cookie("eshop_session_token"); err == nil {
sessionToken = cookie.Value
}
}
var cart models.EshopCart
q := ctrl.DB.Preload("Items").Preload("Items.Product").Preload("Items.Variant").Where("completed = ?", false)
if userID != nil {
q = q.Where("user_id = ?", *userID)
} else if sessionToken != "" {
q = q.Where("session_token = ?", sessionToken)
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "No session identified"})
return
}
if err := q.First(&cart).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Cart not found or expired"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve cart"})
}
return
}
if len(cart.Items) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cart is empty"})
return
}
// Validate cart items and check inventory
for _, item := range cart.Items {
if item.Quantity <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid item quantity"})
return
}
// Check if product still exists and is active
var product models.EshopProduct
if err := ctrl.DB.Where("id = ? AND active = ?", item.ProductID, true).First(&product).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Product no longer available"})
return
}
// Check variant if exists
if item.VariantID != nil {
var variant models.EshopProductVariant
if err := ctrl.DB.Where("id = ? AND product_id = ?", item.VariantID, item.ProductID).First(&variant).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Product variant no longer available"})
return
}
// Check stock for variant (negative values mean unlimited)
if variant.StockQty >= 0 && variant.StockQty < item.Quantity {
c.JSON(http.StatusBadRequest, gin.H{"error": "Insufficient stock for product variant"})
return
}
}
// Verify price hasn't changed
if item.UnitPriceCents != product.PriceCents {
// For MVP, we'll allow it but log it
logger.Warn("Price mismatch for product %d: cart %d vs current %d", item.ProductID, item.UnitPriceCents, product.PriceCents)
}
}
// 2. Calculate totals
var itemsTotal int64
for _, item := range cart.Items {
itemsTotal += item.UnitPriceCents * int64(item.Quantity)
}
// Shipping price - simplistic logic for MVP
var shippingPrice int64 = 0
if req.ShippingMethod == "packeta" {
shippingPrice = 7900 // 79 CZK
} else if req.ShippingMethod == "courier" {
shippingPrice = 9900 // 99 CZK
}
totalAmount := itemsTotal + shippingPrice
// 3. Create Order
// Generate order number (e.g. 202510001)
orderNumber := fmt.Sprintf("%s%d", time.Now().Format("200601"), time.Now().Unix()%100000) // simplified
order := models.EshopOrder{
OrderNumber: orderNumber,
UserID: userID,
SessionToken: sessionToken,
Email: req.Email,
FirstName: req.FirstName,
LastName: req.LastName,
BillingAddressJSON: string(req.BillingAddress),
ShippingAddressJSON: string(req.ShippingAddress),
Status: "awaiting_payment",
TotalAmountCents: totalAmount,
Currency: cart.Currency,
ShippingMethod: req.ShippingMethod,
ShippingPriceCents: shippingPrice,
}
tx := ctrl.DB.Begin()
if err := tx.Create(&order).Error; err != nil {
tx.Rollback()
logger.Error("Failed to create order: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create order"})
return
}
// Create items
for _, cartItem := range cart.Items {
orderItem := models.EshopOrderItem{
OrderID: order.ID,
ProductID: cartItem.ProductID,
VariantID: cartItem.VariantID,
Name: cartItem.Product.Name,
Quantity: cartItem.Quantity,
UnitPriceCents: cartItem.UnitPriceCents,
Currency: cartItem.Currency,
VATRate: cartItem.Product.VATRate,
}
// Add variant name if exists
if cartItem.Variant != nil {
orderItem.Name += " (" + cartItem.Variant.Name + ")"
orderItem.SKU = cartItem.Variant.SKU
}
if err := tx.Create(&orderItem).Error; err != nil {
tx.Rollback()
logger.Error("Failed to create order item: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create order items"})
return
}
}
// 4. Choose payment provider (Stripe preferred, then Revolut)
provider := ""
if ctrl.Config.StripeEnabled {
provider = "stripe"
} else if ctrl.Config.RevolutEnabled {
provider = "revolut"
}
var result *eshop.PaymentResult
var providerErr error
switch provider {
case "stripe":
result, providerErr = ctrl.StripeService.CreatePayment(&order)
case "revolut":
result, providerErr = ctrl.RevolutService.CreatePayment(&order)
}
// Handle different payment provider responses
if provider != "" && providerErr == nil && result != nil {
payment := models.EshopPayment{
OrderID: order.ID,
Provider: provider,
ProviderPaymentID: result.ProviderPaymentID,
Status: "pending",
AmountCents: order.TotalAmountCents,
Currency: order.Currency,
RawPayloadJSON: result.RawPayloadJSON,
}
if err := tx.Create(&payment).Error; err != nil {
tx.Rollback()
logger.Error("Failed to create payment record: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save payment info"})
return
}
tx.Commit()
// Different response format based on provider
response := gin.H{
"order_id": order.ID,
"order_number": order.OrderNumber,
"payment_provider": provider,
}
if provider == "stripe" {
// For Stripe, return the client secret for frontend confirmation
var paymentData map[string]interface{}
if err := json.Unmarshal([]byte(result.RawPayloadJSON), &paymentData); err == nil {
if clientSecret, ok := paymentData["client_secret"].(string); ok {
response["client_secret"] = clientSecret
}
}
} else if strings.TrimSpace(result.RedirectURL) != "" {
// For redirect-based providers (Revolut)
response["payment_redirect_url"] = result.RedirectURL
}
c.JSON(http.StatusOK, response)
return
}
// If provider was selected but failed or is not implemented yet, log the error
if provider != "" && providerErr != nil {
logger.Error("Payment provider error (%s): %v", provider, providerErr)
}
// 5. No online payment provider available fall back to manual email instructions
supportEmail := ctrl.getSupportEmail()
manualMeta := map[string]string{
"reason": "no_online_provider",
"contact_email": supportEmail,
}
if providerErr != nil {
manualMeta["provider_error"] = providerErr.Error()
}
metaJSON, _ := json.Marshal(manualMeta)
manualPayment := models.EshopPayment{
OrderID: order.ID,
Provider: "manual_email",
Status: "pending",
AmountCents: order.TotalAmountCents,
Currency: order.Currency,
RawPayloadJSON: string(metaJSON),
}
if err := tx.Create(&manualPayment).Error; err != nil {
tx.Rollback()
logger.Error("Failed to create manual payment record: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save payment info"})
return
}
tx.Commit()
c.JSON(http.StatusOK, gin.H{
"order_id": order.ID,
"order_number": order.OrderNumber,
"manual_payment": true,
"contact_email": supportEmail,
})
}
// getSupportEmail returns the best support email for manual orders
func (ctrl *CheckoutController) getSupportEmail() string {
email := strings.TrimSpace(ctrl.Config.ContactEmail)
if email == "" {
email = strings.TrimSpace(ctrl.Config.AdminEmail)
}
var settings models.EshopSettings
if err := ctrl.DB.First(&settings).Error; err == nil {
if se := strings.TrimSpace(settings.SupportEmail); se != "" {
email = se
}
}
if email == "" {
// Safe default to avoid returning empty email
email = "info@example.com"
}
return email
}
// GetOrder returns a single order by ID, ensuring the user/session owns it
func (ctrl *CheckoutController) GetOrder(c *gin.Context) {
id := c.Param("id")
userIDVal, _ := c.Get("userID")
var userID *uint
if u, ok := userIDVal.(uint); ok {
userID = &u
}
sessionToken := c.GetHeader("X-Session-Token")
if sessionToken == "" {
if cookie, err := c.Request.Cookie("eshop_session_token"); err == nil {
sessionToken = cookie.Value
}
}
var order models.EshopOrder
q := ctrl.DB.Preload("Items").Preload("Payments").Preload("Labels").Where("id = ?", id)
// Security check: only allow owner to view
if userID != nil {
q = q.Where("user_id = ?", *userID)
} else if sessionToken != "" {
q = q.Where("session_token = ?", sessionToken)
} else {
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
return
}
if err := q.First(&order).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Order not found"})
return
}
c.JSON(http.StatusOK, order)
}
// RevolutWebhook handles asynchronous notifications from Revolut about payment state changes.
// It expects a JSON payload containing order status updates.
func (ctrl *CheckoutController) RevolutWebhook(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
logger.Error("[revolut-webhook] failed to read body: %v", err)
c.Status(http.StatusOK)
return
}
// Verify webhook signature
signature := strings.TrimSpace(c.GetHeader("Revolut-Pay-Payload-Signature"))
revolutService := eshop.NewRevolutService(ctrl.Config)
valid, err := revolutService.VerifyWebhook(body, signature)
if err != nil {
logger.Error("[revolut-webhook] signature verification failed: %v", err)
c.Status(http.StatusBadRequest)
return
}
if !valid {
logger.Error("[revolut-webhook] invalid signature")
c.Status(http.StatusUnauthorized)
return
}
// Parse webhook payload
webhook, err := revolutService.ParseWebhook(body)
if err != nil {
logger.Error("[revolut-webhook] failed to parse JSON: %v", err)
c.Status(http.StatusOK)
return
}
// Find payment and related order
tx := ctrl.DB.Begin()
var payment models.EshopPayment
if err := tx.Preload("Order").Where("provider = ? AND provider_payment_id = ?", "revolut", webhook.Order.ID).First(&payment).Error; err != nil {
logger.Error("[revolut-webhook] payment not found for id=%s: %v", webhook.Order.ID, err)
tx.Rollback()
c.Status(http.StatusOK)
return
}
order := payment.Order
if order.ID == 0 {
logger.Error("[revolut-webhook] loaded payment without order for id=%s", webhook.Order.ID)
tx.Rollback()
c.Status(http.StatusOK)
return
}
// Decide new statuses based on Revolut order status
newPaymentStatus := ""
newOrderStatus := ""
switch webhook.Order.Status {
case "COMPLETED":
newPaymentStatus = "paid"
newOrderStatus = "paid"
case "CANCELLED":
newPaymentStatus = "cancelled"
newOrderStatus = "cancelled"
case "FAILED":
newPaymentStatus = "failed"
newOrderStatus = "cancelled"
default:
// For other states we just store the raw payload and return OK
logger.Info("[revolut-webhook] unhandled status %s for payment %s", webhook.Order.Status, webhook.Order.ID)
if err := tx.Model(&payment).Updates(map[string]interface{}{
"raw_payload_json": string(body),
"updated_at": time.Now(),
}).Error; err != nil {
logger.Error("[revolut-webhook] failed to store raw payload for payment %s: %v", webhook.Order.ID, err)
tx.Rollback()
c.Status(http.StatusOK)
return
}
tx.Commit()
c.Status(http.StatusOK)
return
}
// Update payment status
updates := map[string]interface{}{
"status": newPaymentStatus,
"raw_payload_json": string(body),
"updated_at": time.Now(),
}
if err := tx.Model(&payment).Updates(updates).Error; err != nil {
logger.Error("[revolut-webhook] failed to update payment %s: %v", webhook.Order.ID, err)
tx.Rollback()
c.Status(http.StatusOK)
return
}
// Update order status if needed
if order.Status != newOrderStatus {
if err := tx.Model(&models.EshopOrder{}).
Where("id = ?", order.ID).
Update("status", newOrderStatus).Error; err != nil {
logger.Error("[revolut-webhook] failed to update order %d status: %v", order.ID, err)
tx.Rollback()
c.Status(http.StatusOK)
return
}
// Mark cart as completed if order is paid
if newOrderStatus == "paid" {
cartQ := tx.Model(&models.EshopCart{}).Where("completed = ?", false)
if order.UserID != nil {
cartQ = cartQ.Where("user_id = ?", order.UserID)
} else {
cartQ = cartQ.Where("session_token = ?", order.SessionToken)
}
if err := cartQ.Update("completed", true).Error; err != nil {
logger.Error("[revolut-webhook] failed to mark cart as completed for order %d: %v", order.ID, err)
// Non-fatal, continue
}
}
}
tx.Commit()
logger.Info("[revolut-webhook] processed payment %s with status %s for order %d", webhook.Order.ID, webhook.Order.Status, order.ID)
c.Status(http.StatusOK)
}
// StripeWebhook handles Stripe webhook events
func (ctrl *CheckoutController) StripeWebhook(c *gin.Context) {
if ctrl.Config.StripeWebhookSecret == "" {
logger.Error("[stripe-webhook] webhook secret not configured")
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Webhook not configured"})
return
}
body, err := io.ReadAll(c.Request.Body)
if err != nil {
logger.Error("[stripe-webhook] failed to read webhook body: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
signature := c.GetHeader("Stripe-Signature")
if signature == "" {
logger.Error("[stripe-webhook] no signature provided")
c.JSON(http.StatusBadRequest, gin.H{"error": "No signature"})
return
}
// Parse the event
var event map[string]interface{}
if err := json.Unmarshal(body, &event); err != nil {
logger.Error("[stripe-webhook] failed to parse event: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})
return
}
eventType, ok := event["type"].(string)
if !ok {
logger.Error("[stripe-webhook] no event type")
c.JSON(http.StatusBadRequest, gin.H{"error": "No event type"})
return
}
logger.Info("[stripe-webhook] received event: %s", eventType)
switch eventType {
case "payment_intent.succeeded":
ctrl.handleStripePaymentSucceeded(event, c)
case "payment_intent.payment_failed":
ctrl.handleStripePaymentFailed(event, c)
default:
logger.Info("[stripe-webhook] unhandled event type: %s", eventType)
}
c.Status(http.StatusOK)
}
func (ctrl *CheckoutController) handleStripePaymentSucceeded(event map[string]interface{}, c *gin.Context) {
paymentIntent, ok := event["data"].(map[string]interface{})["object"].(map[string]interface{})
if !ok {
logger.Error("[stripe-webhook] invalid payment intent object")
return
}
paymentIntentID, ok := paymentIntent["id"].(string)
if !ok {
logger.Error("[stripe-webhook] no payment intent ID")
return
}
metadata, _ := paymentIntent["metadata"].(map[string]interface{})
orderIDStr, _ := metadata["order_id"].(string)
if orderIDStr == "" {
logger.Error("[stripe-webhook] no order ID in payment intent metadata")
return
}
tx := ctrl.DB.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// Find the payment record
var payment models.EshopPayment
if err := tx.Where("provider_payment_id = ? AND provider = ?", paymentIntentID, "stripe").First(&payment).Error; err != nil {
logger.Error("[stripe-webhook] payment record not found: %v", err)
tx.Rollback()
return
}
// Update payment status
payment.Status = "succeeded"
if err := tx.Save(&payment).Error; err != nil {
logger.Error("[stripe-webhook] failed to update payment status: %v", err)
tx.Rollback()
return
}
// Update order status
if err := tx.Model(&models.EshopOrder{}).Where("id = ?", payment.OrderID).Update("status", "paid").Error; err != nil {
logger.Error("[stripe-webhook] failed to update order status: %v", err)
tx.Rollback()
return
}
// Get order for cart completion
var order models.EshopOrder
if err := tx.First(&order, payment.OrderID).Error; err != nil {
logger.Error("[stripe-webhook] failed to get order: %v", err)
tx.Rollback()
return
}
// Mark cart as completed
cartQ := tx.Model(&models.EshopCart{}).Where("completed = ?", false)
if order.UserID != nil {
cartQ = cartQ.Where("user_id = ?", *order.UserID)
} else if strings.TrimSpace(order.SessionToken) != "" {
cartQ = cartQ.Where("session_token = ?", order.SessionToken)
}
if err := cartQ.Update("completed", true).Error; err != nil {
logger.Error("[stripe-webhook] failed to mark cart as completed: %v", err)
// Non-fatal
}
tx.Commit()
logger.Info("[stripe-webhook] payment succeeded for order %d, payment intent %s", payment.OrderID, paymentIntentID)
}
func (ctrl *CheckoutController) handleStripePaymentFailed(event map[string]interface{}, c *gin.Context) {
paymentIntent, ok := event["data"].(map[string]interface{})["object"].(map[string]interface{})
if !ok {
logger.Error("[stripe-webhook] invalid payment intent object")
return
}
paymentIntentID, ok := paymentIntent["id"].(string)
if !ok {
logger.Error("[stripe-webhook] no payment intent ID")
return
}
// Find and update payment record
var payment models.EshopPayment
if err := ctrl.DB.Where("provider_payment_id = ? AND provider = ?", paymentIntentID, "stripe").First(&payment).Error; err != nil {
logger.Error("[stripe-webhook] payment record not found: %v", err)
return
}
payment.Status = "failed"
if err := ctrl.DB.Save(&payment).Error; err != nil {
logger.Error("[stripe-webhook] failed to update payment status: %v", err)
return
}
logger.Info("[stripe-webhook] payment failed for order %d, payment intent %s", payment.OrderID, paymentIntentID)
}
@@ -0,0 +1,574 @@
package eshop
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
eshoppkg "fotbal-club/internal/services/eshop"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// fakePaymentProvider is a simple test double for PaymentProvider used to
// simulate payment providers without performing real HTTP calls.
type fakePaymentProvider struct {
called bool
result *eshoppkg.PaymentResult
err error
}
func (f *fakePaymentProvider) CreatePayment(order *models.EshopOrder) (*eshoppkg.PaymentResult, error) {
f.called = true
return f.result, f.err
}
// TestCheckoutController_ManualPaymentFallback_CreatesOrderAndPayment verifies
// that when no online payment providers are enabled, Checkout creates an order
// and a manual_email payment and returns the expected JSON response.
func TestCheckoutController_ManualPaymentFallback_CreatesOrderAndPayment(t *testing.T) {
gin.SetMode(gin.TestMode)
// In-memory SQLite DB for isolated test
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open in-memory db: %v", err)
}
// Migrate the minimal set of tables needed for checkout
if err := db.AutoMigrate(
&models.EshopProduct{},
&models.EshopCart{},
&models.EshopCartItem{},
&models.EshopOrder{},
&models.EshopOrderItem{},
&models.EshopPayment{},
); err != nil {
t.Fatalf("failed to migrate schemas: %v", err)
}
// Seed one active product
product := models.EshopProduct{
Slug: "test-product",
Name: "Test Product",
PriceCents: 10000,
Currency: "CZK",
VATRate: 21,
Active: true,
}
if err := db.Create(&product).Error; err != nil {
t.Fatalf("failed to seed product: %v", err)
}
// Seed cart with one item for a specific session token
const sessionToken = "test-session-token"
cart := models.EshopCart{
SessionToken: sessionToken,
Currency: "CZK",
Completed: false,
}
if err := db.Create(&cart).Error; err != nil {
t.Fatalf("failed to seed cart: %v", err)
}
cartItem := models.EshopCartItem{
CartID: cart.ID,
ProductID: product.ID,
Quantity: 2,
UnitPriceCents: product.PriceCents,
Currency: product.Currency,
}
if err := db.Create(&cartItem).Error; err != nil {
t.Fatalf("failed to seed cart item: %v", err)
}
// Config with no online payment providers -> forces manual_email fallback
cfg := &config.Config{
ContactEmail: "eshop-support@example.com",
RevolutEnabled: false,
}
ctrl := &CheckoutController{
DB: db,
Config: cfg,
}
// Build checkout request payload
body := map[string]interface{}{
"first_name": "Jan",
"last_name": "Novák",
"email": "jan.novak@example.com",
"phone": "+420123456789",
"billing_address": map[string]string{
"street": "Testovací 123",
"city": "Praha",
"zip": "11000",
"country": "CZ",
},
"shipping_address": map[string]string{
"packet_point_id": "PACKETA123",
},
"shipping_method": "packeta",
}
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("failed to marshal request body: %v", err)
}
req, err := http.NewRequest(http.MethodPost, "/api/v1/eshop/checkout", bytes.NewReader(payload))
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Session-Token", sessionToken)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
// Execute handler
ctrl.Checkout(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body: %s", w.Code, w.Body.String())
}
// Parse JSON response
var resp struct {
OrderID uint `json:"order_id"`
OrderNumber string `json:"order_number"`
ManualPayment bool `json:"manual_payment"`
ContactEmail string `json:"contact_email"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v (body: %s)", err, w.Body.String())
}
if resp.OrderID == 0 {
t.Fatalf("expected non-zero order_id in response")
}
if !resp.ManualPayment {
t.Errorf("expected manual_payment=true in response")
}
if resp.ContactEmail != cfg.ContactEmail {
t.Errorf("expected contact_email %q, got %q", cfg.ContactEmail, resp.ContactEmail)
}
// Load order with related items and payments
var order models.EshopOrder
if err := db.Preload("Items").Preload("Payments").First(&order, resp.OrderID).Error; err != nil {
t.Fatalf("failed to load created order: %v", err)
}
// itemsTotal = 2 * 10000, shippingPrice = 7900 for Packeta -> 27900
var expectedItemsTotal int64 = int64(2 * 10000)
var expectedShipping int64 = 7900
var expectedTotal int64 = expectedItemsTotal + expectedShipping
if order.TotalAmountCents != expectedTotal {
t.Errorf("unexpected order total: got %d, want %d", order.TotalAmountCents, expectedTotal)
}
if order.ShippingPriceCents != expectedShipping {
t.Errorf("unexpected shipping price: got %d, want %d", order.ShippingPriceCents, expectedShipping)
}
if order.Status != "awaiting_payment" {
t.Errorf("unexpected order status: got %q, want %q", order.Status, "awaiting_payment")
}
if len(order.Items) != 1 {
t.Fatalf("expected 1 order item, got %d", len(order.Items))
}
if len(order.Payments) != 1 {
t.Fatalf("expected 1 payment, got %d", len(order.Payments))
}
payment := order.Payments[0]
if payment.Provider != "manual_email" {
t.Errorf("expected payment provider manual_email, got %q", payment.Provider)
}
if payment.AmountCents != order.TotalAmountCents {
t.Errorf("unexpected payment amount: got %d, want %d", payment.AmountCents, order.TotalAmountCents)
}
if payment.Status != "pending" {
t.Errorf("unexpected payment status: got %q, want %q", payment.Status, "pending")
}
}
// TestCheckoutController_RevolutWebhook_UpdatesPaymentAndOrderStatuses
// verifies that a Revolut webhook with COMPLETED status correctly updates
// payment status to "paid", order status to "paid", and marks the cart as completed.
func TestCheckoutController_RevolutWebhook_Paid_UpdatesOrderAndCart(t *testing.T) {
gin.SetMode(gin.TestMode)
// In-memory SQLite DB for isolated test
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open in-memory db: %v", err)
}
// Migrate minimal tables needed for webhook logic
if err := db.AutoMigrate(
&models.EshopCart{},
&models.EshopCartItem{},
&models.EshopOrder{},
&models.EshopPayment{},
); err != nil {
t.Fatalf("failed to migrate schemas: %v", err)
}
const (
paymentID = "123456"
sessionToken = "webhook-session"
)
// Seed cart for the given session token
cart := models.EshopCart{
SessionToken: sessionToken,
Currency: "CZK",
Completed: false,
}
if err := db.Create(&cart).Error; err != nil {
t.Fatalf("failed to seed cart: %v", err)
}
// Seed order linked to the same session token
order := models.EshopOrder{
SessionToken: sessionToken,
Status: "awaiting_payment",
Currency: "CZK",
TotalAmountCents: 15000,
ShippingMethod: "packeta",
ShippingPriceCents: 7900,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("failed to seed order: %v", err)
}
// Seed pending Revolut payment for the order
payment := models.EshopPayment{
OrderID: order.ID,
Provider: "revolut",
ProviderPaymentID: "123456", // Must match webhook order ID
Status: "pending",
AmountCents: order.TotalAmountCents,
Currency: order.Currency,
}
if err := db.Create(&payment).Error; err != nil {
t.Fatalf("failed to seed payment: %v", err)
}
ctrl := &CheckoutController{
DB: db,
Config: &config.Config{},
}
// Build Revolut webhook payload with COMPLETED status
body := map[string]interface{}{
"type": "ORDER_COMPLETED",
"order_id": "123456",
"order": map[string]interface{}{
"id": "123456",
"amount": 10000,
"currency": "CZK",
"status": "COMPLETED",
"merchant_order_id": "TEST-001",
"created_at": "2024-01-01T12:00:00Z",
},
"timestamp": "2024-01-01T12:00:00Z",
}
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("failed to marshal webhook body: %v", err)
}
req, err := http.NewRequest(http.MethodPost, "/api/v1/eshop/payments/revolut/webhook", bytes.NewReader(payload))
if err != nil {
t.Fatalf("failed to create webhook request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
// Execute webhook handler
ctrl.RevolutWebhook(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body: %s", w.Code, w.Body.String())
}
// Reload payment, order and cart
var updatedPayment models.EshopPayment
if err := db.First(&updatedPayment, payment.ID).Error; err != nil {
t.Fatalf("failed to load updated payment: %v", err)
}
if updatedPayment.Status != "paid" {
t.Errorf("unexpected payment status: got %q, want %q", updatedPayment.Status, "paid")
}
var updatedOrder models.EshopOrder
if err := db.First(&updatedOrder, order.ID).Error; err != nil {
t.Fatalf("failed to load updated order: %v", err)
}
if updatedOrder.Status != "paid" {
t.Errorf("unexpected order status: got %q, want %q", updatedOrder.Status, "paid")
}
var updatedCart models.EshopCart
if err := db.First(&updatedCart, cart.ID).Error; err != nil {
t.Fatalf("failed to load updated cart: %v", err)
}
if !updatedCart.Completed {
t.Errorf("expected cart to be marked completed, but it was not")
}
}
// TestCheckoutController_FullPaidFlow_Revolut_E2E covers the full flow
// checkout → Revolut payment creation (via fake provider) → Revolut webhook
// updating payment/order status and marking the cart as completed.
func TestCheckoutController_FullPaidFlow_Revolut_E2E(t *testing.T) {
gin.SetMode(gin.TestMode)
// In-memory SQLite DB for isolated test
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open in-memory db: %v", err)
}
// Migrate tables needed for checkout + payments
if err := db.AutoMigrate(
&models.EshopProduct{},
&models.EshopCart{},
&models.EshopCartItem{},
&models.EshopOrder{},
&models.EshopOrderItem{},
&models.EshopPayment{},
); err != nil {
t.Fatalf("failed to migrate schemas: %v", err)
}
// Seed one active product
product := models.EshopProduct{
Slug: "e2e-product",
Name: "E2E Product",
PriceCents: 15000,
Currency: "CZK",
VATRate: 21,
Active: true,
}
if err := db.Create(&product).Error; err != nil {
t.Fatalf("failed to seed product: %v", err)
}
// Seed cart with one item for a specific session token
const sessionToken = "e2e-session-token"
cart := models.EshopCart{
SessionToken: sessionToken,
Currency: "CZK",
Completed: false,
}
if err := db.Create(&cart).Error; err != nil {
t.Fatalf("failed to seed cart: %v", err)
}
cartItem := models.EshopCartItem{
CartID: cart.ID,
ProductID: product.ID,
Quantity: 1,
UnitPriceCents: product.PriceCents,
Currency: product.Currency,
}
if err := db.Create(&cartItem).Error; err != nil {
t.Fatalf("failed to seed cart item: %v", err)
}
// Config with Revolut enabled so that Checkout chooses Revolut provider
cfg := &config.Config{
ContactEmail: "eshop-support@example.com",
RevolutEnabled: true,
}
const (
providerPaymentID = "555666"
redirectURL = "https://gw.gopay.test/pay/555666"
)
fakeProv := &fakePaymentProvider{
result: &eshoppkg.PaymentResult{
RedirectURL: redirectURL,
ProviderPaymentID: providerPaymentID,
RawPayloadJSON: `{"id":555666,"gw_url":"` + redirectURL + `"}`,
},
}
ctrl := &CheckoutController{
DB: db,
Config: cfg,
}
body := map[string]interface{}{
"first_name": "Petr",
"last_name": "Svoboda",
"email": "petr.svoboda@example.com",
"phone": "+420777000111",
"billing_address": map[string]string{
"street": "E2E 1",
"city": "Praha",
"zip": "11000",
"country": "CZ",
},
"shipping_address": map[string]string{
"packet_point_id": "PACKETA-E2E",
},
"shipping_method": "packeta",
}
checkoutPayload, err := json.Marshal(body)
if err != nil {
t.Fatalf("failed to marshal checkout body: %v", err)
}
req, err := http.NewRequest(http.MethodPost, "/api/v1/eshop/checkout", bytes.NewReader(checkoutPayload))
if err != nil {
t.Fatalf("failed to create checkout request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Session-Token", sessionToken)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
ctrl.Checkout(c)
if w.Code != http.StatusOK {
t.Fatalf("checkout expected status 200, got %d, body: %s", w.Code, w.Body.String())
}
if !fakeProv.called {
t.Fatalf("expected fake payment provider to be called during checkout")
}
var checkoutResp struct {
OrderID uint `json:"order_id"`
OrderNumber string `json:"order_number"`
PaymentProvider string `json:"payment_provider"`
PaymentRedirectURL string `json:"payment_redirect_url"`
ManualPayment bool `json:"manual_payment"`
}
if err := json.Unmarshal(w.Body.Bytes(), &checkoutResp); err != nil {
t.Fatalf("failed to unmarshal checkout response: %v (body: %s)", err, w.Body.String())
}
if checkoutResp.OrderID == 0 {
t.Fatalf("expected non-zero order_id in checkout response")
}
if checkoutResp.PaymentProvider != "revolut" {
t.Errorf("expected payment_provider=revolut, got %q", checkoutResp.PaymentProvider)
}
if checkoutResp.PaymentRedirectURL != redirectURL {
t.Errorf("unexpected payment_redirect_url: got %q, want %q", checkoutResp.PaymentRedirectURL, redirectURL)
}
if checkoutResp.ManualPayment {
t.Errorf("did not expect manual_payment=true for Revolut flow")
}
// Verify DB state after checkout but before webhook
var order models.EshopOrder
if err := db.Preload("Payments").First(&order, checkoutResp.OrderID).Error; err != nil {
t.Fatalf("failed to load order after checkout: %v", err)
}
if order.Status != "awaiting_payment" {
t.Errorf("unexpected order status after checkout: got %q, want %q", order.Status, "awaiting_payment")
}
if len(order.Payments) != 1 {
t.Fatalf("expected 1 payment after checkout, got %d", len(order.Payments))
}
p := order.Payments[0]
if p.Provider != "revolut" {
t.Errorf("expected payment provider revolut, got %q", p.Provider)
}
if p.ProviderPaymentID != providerPaymentID {
t.Errorf("unexpected provider payment id: got %q, want %q", p.ProviderPaymentID, providerPaymentID)
}
if p.Status != "pending" {
t.Errorf("unexpected payment status after checkout: got %q, want %q", p.Status, "pending")
}
var cartBefore models.EshopCart
if err := db.First(&cartBefore, cart.ID).Error; err != nil {
t.Fatalf("failed to load cart after checkout: %v", err)
}
if cartBefore.Completed {
t.Errorf("expected cart to be not completed before webhook")
}
// ---- Phase 2: Revolut webhook marks payment/order as paid and cart as completed ----
webhookBody := map[string]interface{}{
"type": "ORDER_COMPLETED",
"order_id": "555666",
"order": map[string]interface{}{
"id": "555666",
"amount": 10000,
"currency": "CZK",
"status": "COMPLETED",
"merchant_order_id": "TEST-002",
"created_at": "2024-01-01T12:00:00Z",
},
"timestamp": "2024-01-01T12:00:00Z",
}
webhookPayload, err := json.Marshal(webhookBody)
if err != nil {
t.Fatalf("failed to marshal webhook body: %v", err)
}
webhookReq, err := http.NewRequest(http.MethodPost, "/api/v1/eshop/payments/revolut/webhook", bytes.NewReader(webhookPayload))
if err != nil {
t.Fatalf("failed to create webhook request: %v", err)
}
webhookReq.Header.Set("Content-Type", "application/json")
webhookW := httptest.NewRecorder()
webhookCtx, _ := gin.CreateTestContext(webhookW)
webhookCtx.Request = webhookReq
ctrl.RevolutWebhook(webhookCtx)
if webhookW.Code != http.StatusOK {
t.Fatalf("webhook expected status 200, got %d, body: %s", webhookW.Code, webhookW.Body.String())
}
// Reload payment, order and cart after webhook
var updatedPayment models.EshopPayment
if err := db.First(&updatedPayment, p.ID).Error; err != nil {
t.Fatalf("failed to load updated payment after webhook: %v", err)
}
if updatedPayment.Status != "paid" {
t.Errorf("unexpected payment status after webhook: got %q, want %q", updatedPayment.Status, "paid")
}
var updatedOrder models.EshopOrder
if err := db.First(&updatedOrder, order.ID).Error; err != nil {
t.Fatalf("failed to load updated order after webhook: %v", err)
}
if updatedOrder.Status != "paid" {
t.Errorf("unexpected order status after webhook: got %q, want %q", updatedOrder.Status, "paid")
}
var updatedCart models.EshopCart
if err := db.First(&updatedCart, cart.ID).Error; err != nil {
t.Fatalf("failed to load updated cart after webhook: %v", err)
}
if !updatedCart.Completed {
t.Errorf("expected cart to be completed after webhook, but it was not")
}
}
@@ -0,0 +1,226 @@
package eshop
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
eshoppkg "fotbal-club/internal/services/eshop"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// TestEshopE2E_FullPurchaseFlow tests the complete flow:
// 1. Add item to cart (POST /cart/items)
// TestCheckoutController_FullPaidFlow_Revolut_E2E covers the full flow
// checkout → Revolut payment creation (via fake provider) → Revolut webhook
// updating payment/order status and marking the cart as completed.
func TestEshopE2E_FullPurchaseFlow(t *testing.T) {
gin.SetMode(gin.TestMode)
// 1. Setup DB
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open in-memory db: %v", err)
}
// Migrate all necessary tables
if err := db.AutoMigrate(
&models.EshopProduct{},
&models.EshopProductVariant{},
&models.EshopCart{},
&models.EshopCartItem{},
&models.EshopOrder{},
&models.EshopOrderItem{},
&models.EshopPayment{},
&models.EshopShippingLabel{}, // if needed for checkout
); err != nil {
t.Fatalf("failed to migrate schemas: %v", err)
}
// 2. Seed Product
product := models.EshopProduct{
Slug: "jersey-home",
Name: "Home Jersey",
PriceCents: 150000, // 1500.00 CZK
Currency: "CZK",
VATRate: 21,
Active: true,
}
if err := db.Create(&product).Error; err != nil {
t.Fatalf("failed to seed product: %v", err)
}
// 3. Setup Controllers & Router
cfg := &config.Config{
RevolutEnabled: true, // Enable Revolut for this test
}
fakeProv := &fakePaymentProvider{
result: &eshoppkg.PaymentResult{
RedirectURL: "https://revolut.test/pay/123",
ProviderPaymentID: "123456789",
RawPayloadJSON: `{"id":123456789}`,
},
}
checkoutCtrl := &CheckoutController{
DB: db,
Config: cfg,
RevolutService: fakeProv,
}
cartCtrl := &CartController{
DB: db,
Config: cfg,
}
r := gin.New()
// Middleware to simulate session token
r.Use(func(c *gin.Context) {
// In real app, this is done by JWTOptional/Session middleware
// We'll just read header and set context
token := c.GetHeader("X-Session-Token")
if token != "" {
c.Set("session_token", token)
}
c.Next()
})
eshopGroup := r.Group("/api/v1/eshop")
{
eshopGroup.POST("/cart/items", cartCtrl.AddItem)
eshopGroup.POST("/checkout", checkoutCtrl.Checkout)
eshopGroup.POST("/payments/revolut/webhook", checkoutCtrl.RevolutWebhook)
}
const sessionToken = "user-session-123"
// -------------------------------------------------------------------------
// Step 1: Add Item to Cart
// -------------------------------------------------------------------------
addItemBody := map[string]interface{}{
"product_id": product.ID,
"quantity": 1,
}
payload, _ := json.Marshal(addItemBody)
req1, _ := http.NewRequest("POST", "/api/v1/eshop/cart/items", bytes.NewReader(payload))
req1.Header.Set("Content-Type", "application/json")
req1.Header.Set("X-Session-Token", sessionToken)
w1 := httptest.NewRecorder()
r.ServeHTTP(w1, req1)
if w1.Code != http.StatusNoContent {
t.Fatalf("AddItem failed: %d %s", w1.Code, w1.Body.String())
}
// Verify cart exists in DB
var cart models.EshopCart
if err := db.Where("session_token = ?", sessionToken).First(&cart).Error; err != nil {
t.Fatalf("failed to find cart: %v", err)
}
if cart.Completed {
t.Fatal("new cart should not be completed")
}
// -------------------------------------------------------------------------
// Step 2: Checkout
// -------------------------------------------------------------------------
checkoutBody := map[string]interface{}{
"first_name": "Test",
"last_name": "User",
"email": "test@example.com",
"phone": "+420777123456",
"billing_address": map[string]string{
"street": "Test St 1",
"city": "Prague",
"zip": "10000",
},
"shipping_method": "personal_pickup", // simple method
}
payload2, _ := json.Marshal(checkoutBody)
req2, _ := http.NewRequest("POST", "/api/v1/eshop/checkout", bytes.NewReader(payload2))
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("X-Session-Token", sessionToken)
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("Checkout failed: %d %s", w2.Code, w2.Body.String())
}
var checkoutResp struct {
OrderID uint `json:"order_id"`
Provider string `json:"payment_provider"`
}
json.Unmarshal(w2.Body.Bytes(), &checkoutResp)
if checkoutResp.OrderID == 0 {
t.Fatal("expected order_id")
}
if checkoutResp.Provider != "revolut" {
t.Fatalf("expected revolut provider, got %s", checkoutResp.Provider)
}
// DEBUG: Print all payments
var payments []models.EshopPayment
db.Find(&payments)
for _, p := range payments {
t.Logf("Payment in DB: ID=%d, OrderID=%d, Provider=%s, ProviderPaymentID='%s'", p.ID, p.OrderID, p.Provider, p.ProviderPaymentID)
}
// -------------------------------------------------------------------------
// Step 3: Payment Webhook (Success)
// -------------------------------------------------------------------------
webhookBody := map[string]interface{}{
"type": "ORDER_COMPLETED",
"order_id": "123456789", // matches the fake provider ID
"order": map[string]interface{}{
"id": "123456789",
"amount": 15000,
"currency": "CZK",
"status": "COMPLETED",
"merchant_order_id": "TEST-001",
"created_at": "2024-01-01T12:00:00Z",
},
"timestamp": "2024-01-01T12:00:00Z",
}
payload3, _ := json.Marshal(webhookBody)
req3, _ := http.NewRequest("POST", "/api/v1/eshop/payments/revolut/webhook", bytes.NewReader(payload3))
req3.Header.Set("Content-Type", "application/json")
w3 := httptest.NewRecorder()
r.ServeHTTP(w3, req3)
if w3.Code != http.StatusOK {
t.Fatalf("Webhook failed: %d %s", w3.Code, w3.Body.String())
}
// -------------------------------------------------------------------------
// Step 4: Verification
// -------------------------------------------------------------------------
// Check Order Status
var order models.EshopOrder
if err := db.First(&order, checkoutResp.OrderID).Error; err != nil {
t.Fatalf("failed to load order: %v", err)
}
if order.Status != "paid" {
t.Errorf("expected order status 'paid', got '%s'", order.Status)
}
// Check Cart Status
if err := db.First(&cart, cart.ID).Error; err != nil {
t.Fatalf("failed to reload cart: %v", err)
}
if !cart.Completed {
t.Error("expected cart to be marked as completed after payment")
}
}
@@ -0,0 +1,522 @@
package eshop
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/pkg/logger"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// RevolutAccountType represents the type of Revolut account
type RevolutAccountType string
const (
RevolutAccountTypePro RevolutAccountType = "revolut_pro"
RevolutAccountTypeBusiness RevolutAccountType = "business"
)
// RevolutOAuthToken represents the OAuth token response
type RevolutOAuthToken struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token,omitempty"`
Scope string `json:"scope"`
}
// RevolutOAuthService handles OAuth2 authentication for both Revolut Pro and Business accounts
type RevolutOAuthService struct {
cfg *config.Config
client *http.Client
}
// RevolutOAuthConfig holds OAuth configuration for different account types
type RevolutOAuthConfig struct {
AccountType RevolutAccountType `json:"account_type"`
ClientID string `json:"client_id"`
AuthBaseURL string `json:"auth_base_url"`
TokenURL string `json:"token_url"`
APIBaseURL string `json:"api_base_url"`
}
// NewRevolutOAuthService creates a new OAuth service instance
func NewRevolutOAuthService(cfg *config.Config) *RevolutOAuthService {
return &RevolutOAuthService{
cfg: cfg,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// GetOAuthConfig returns configuration for the specified account type and environment
func (s *RevolutOAuthService) GetOAuthConfig(accountType RevolutAccountType) RevolutOAuthConfig {
isSandbox := s.cfg.RevolutEnvironment == "sandbox"
switch accountType {
case RevolutAccountTypePro:
if isSandbox {
return RevolutOAuthConfig{
AccountType: RevolutAccountTypePro,
ClientID: "sandbox_pro_client_id",
AuthBaseURL: "https://sandbox-checkout.revolut.com",
TokenURL: "https://sandbox-checkout.revolut.com/api/connect/oauth/token",
APIBaseURL: "https://sandbox-merchant.revolut.com/api/1.0",
}
} else {
return RevolutOAuthConfig{
AccountType: RevolutAccountTypePro,
ClientID: "9cda975e-016c-4b49-b5c6-37d1285ba046",
AuthBaseURL: "https://checkout.revolut.com",
TokenURL: "https://checkout.revolut.com/api/connect/oauth/token",
APIBaseURL: "https://merchant.revolut.com/api/1.0",
}
}
case RevolutAccountTypeBusiness:
if isSandbox {
return RevolutOAuthConfig{
AccountType: RevolutAccountTypeBusiness,
ClientID: "sandbox_business_client_id",
AuthBaseURL: "https://sandbox-business.revolut.com",
TokenURL: "https://sandbox-business.revolut.com/api/1.0/auth/token",
APIBaseURL: "https://sandbox-merchant.revolut.com/api/1.0",
}
} else {
return RevolutOAuthConfig{
AccountType: RevolutAccountTypeBusiness,
ClientID: "diiToLZlMJOPtWhdFTxQ",
AuthBaseURL: "https://business.revolut.com",
TokenURL: "https://b2b.revolut.com/api/1.0/auth/token",
APIBaseURL: "https://merchant.revolut.com/api/1.0",
}
}
default:
return s.GetOAuthConfig(RevolutAccountTypePro)
}
}
// GenerateAuthURL generates the OAuth2 authorization URL for both account types
func (s *RevolutOAuthService) GenerateAuthURL(accountType RevolutAccountType, state string, codeChallenge string) (string, error) {
config := s.GetOAuthConfig(accountType)
if accountType == RevolutAccountTypePro {
params := map[string]string{
"client_id": config.ClientID,
"redirect_uri": s.cfg.RevolutWebhookURL,
"response_type": "code",
"scope": "checkout_extension",
"code_challenge_method": "S256",
"code_challenge": codeChallenge,
"response_mode": "query",
"state": state,
"integration_type": "CUSTOM_PLUGIN",
"rwa_auth_type": "auth",
}
query := buildQueryString(params)
return fmt.Sprintf("%s/s/select-user-type?%s", config.AuthBaseURL, query), nil
}
params := map[string]string{
"client_id": config.ClientID,
"redirect_uri": s.cfg.RevolutWebhookURL,
"response_type": "code",
"code_challenge_method": "S256",
"code_challenge": codeChallenge,
"response_mode": "query",
"prompt": "select_account",
"state": state,
}
query := buildQueryString(params)
return fmt.Sprintf("%s/signin?%s", config.AuthBaseURL, query), nil
}
// buildQueryString builds a query string from a map
func buildQueryString(params map[string]string) string {
var parts []string
for k, v := range params {
parts = append(parts, fmt.Sprintf("%s=%s", k, v))
}
return strings.Join(parts, "&")
}
// GenerateCodeVerifier generates a PKCE code verifier
func GenerateCodeVerifier() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// GenerateCodeChallenge generates a PKCE code challenge from verifier
func GenerateCodeChallenge(verifier string) string {
h := sha256.New()
h.Write([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
// ExchangeCodeForToken exchanges authorization code for access token
func (s *RevolutOAuthService) ExchangeCodeForToken(accountType RevolutAccountType, code, codeVerifier string) (*RevolutOAuthToken, error) {
config := s.GetOAuthConfig(accountType)
data := map[string]string{
"client_id": config.ClientID,
"code": code,
"code_verifier": codeVerifier,
"grant_type": "authorization_code",
"redirect_uri": s.cfg.RevolutWebhookURL,
}
body := buildFormData(data)
req, err := http.NewRequest("POST", config.TokenURL, strings.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute token request: %w", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read token response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token API error: status %d, response: %s", resp.StatusCode, string(bodyBytes))
}
var token RevolutOAuthToken
if err := json.Unmarshal(bodyBytes, &token); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
token.Scope = fmt.Sprintf("%s account_type:%s", token.Scope, accountType)
return &token, nil
}
// buildFormData builds form data from a map
func buildFormData(data map[string]string) string {
var parts []string
for k, v := range data {
parts = append(parts, fmt.Sprintf("%s=%s", k, v))
}
return strings.Join(parts, "&")
}
// StoreOAuthToken stores the OAuth token with account type information
func (s *RevolutOAuthService) StoreOAuthToken(accountType RevolutAccountType, token *RevolutOAuthToken) error {
expiresAt := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
logger.Info("Storing OAuth token for %s: expires at %v", accountType, expiresAt)
return nil
}
// GetStoredOAuthToken retrieves stored OAuth token and account type
func (s *RevolutOAuthService) GetStoredOAuthToken() (*RevolutOAuthToken, RevolutAccountType, error) {
return nil, "", fmt.Errorf("not implemented")
}
// RefreshAccessToken refreshes the access token using refresh token
func (s *RevolutOAuthService) RefreshAccessToken(accountType RevolutAccountType, refreshToken string) (*RevolutOAuthToken, error) {
config := s.GetOAuthConfig(accountType)
data := map[string]string{
"client_id": config.ClientID,
"refresh_token": refreshToken,
"grant_type": "refresh_token",
}
body := buildFormData(data)
req, err := http.NewRequest("POST", config.TokenURL, strings.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create refresh request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute refresh request: %w", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read refresh response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("refresh API error: status %d, response: %s", resp.StatusCode, string(bodyBytes))
}
var token RevolutOAuthToken
if err := json.Unmarshal(bodyBytes, &token); err != nil {
return nil, fmt.Errorf("failed to parse refresh response: %w", err)
}
token.Scope = fmt.Sprintf("%s account_type:%s", token.Scope, accountType)
return &token, nil
}
// RevolutOAuthController handles OAuth authentication for Revolut Pro
type RevolutOAuthController struct {
DB *gorm.DB
Config *config.Config
OAuthService *RevolutOAuthService
}
// NewRevolutOAuthController creates a new OAuth controller
func NewRevolutOAuthController(db *gorm.DB, cfg *config.Config) *RevolutOAuthController {
return &RevolutOAuthController{
DB: db,
Config: cfg,
OAuthService: NewRevolutOAuthService(cfg),
}
}
// OAuthStart initiates the OAuth flow for both Revolut Pro and Business
func (ctrl *RevolutOAuthController) OAuthStart(c *gin.Context) {
// Get account type from request (pro or business)
var req struct {
AccountType string `json:"account_type" binding:"required,oneof=revolut_pro business"`
}
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error("Invalid OAuth start request: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid account type. Use 'revolut_pro' or 'business'"})
return
}
// Convert to RevolutAccountType
var accountType RevolutAccountType
if req.AccountType == "revolut_pro" {
accountType = RevolutAccountTypePro
} else {
accountType = RevolutAccountTypeBusiness
}
// Generate PKCE verifier and challenge
verifier, err := GenerateCodeVerifier()
if err != nil {
logger.Error("Failed to generate code verifier: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initiate OAuth"})
return
}
challenge := GenerateCodeChallenge(verifier)
// Generate state token for security
state := fmt.Sprintf("revolut_oauth_%s_%d", accountType, time.Now().UnixNano())
// Store verifier and state in session/temporary storage
// For now, we'll use a simple approach - in production, use Redis or database
// TODO: Store code_verifier, state, and account_type securely
logger.Info("OAuth session created: state=%s, account_type=%s", state, accountType)
// Generate authorization URL
authURL, err := ctrl.OAuthService.GenerateAuthURL(accountType, state, challenge)
if err != nil {
logger.Error("Failed to generate auth URL: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate authorization URL"})
return
}
// Return the authorization URL for frontend to redirect
c.JSON(http.StatusOK, gin.H{
"authorization_url": authURL,
"state": state,
"account_type": req.AccountType,
})
}
// OAuthCallback handles the OAuth callback from Revolut
func (ctrl *RevolutOAuthController) OAuthCallback(c *gin.Context) {
// Get query parameters
code := c.Query("code")
state := c.Query("state")
errorParam := c.Query("error")
if errorParam != "" {
logger.Error("OAuth error: %s", errorParam)
c.JSON(http.StatusBadRequest, gin.H{"error": "OAuth authorization failed"})
return
}
if code == "" || state == "" {
logger.Error("Missing OAuth callback parameters")
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing required parameters"})
return
}
// TODO: Retrieve stored session data and verify state + account type
// For now, we'll extract account type from state - in production, verify from secure storage
var accountType RevolutAccountType = RevolutAccountTypePro // Default
// Extract account type from state if available
if strings.Contains(state, "business") {
accountType = RevolutAccountTypeBusiness
}
// TODO: Retrieve code_verifier from session
codeVerifier := "stored_code_verifier" // This should come from secure storage
// Exchange code for access token
token, err := ctrl.OAuthService.ExchangeCodeForToken(accountType, code, codeVerifier)
if err != nil {
logger.Error("Failed to exchange code for token: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to obtain access token"})
return
}
// Store the OAuth token securely
if err := ctrl.OAuthService.StoreOAuthToken(accountType, token); err != nil {
logger.Error("Failed to store OAuth token: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store access token"})
return
}
// Mark Revolut as configured
if err := ctrl.updateRevolutConfig(true); err != nil {
logger.Error("Failed to update Revolut config: %v", err)
// Continue anyway - token is stored
}
logger.Info("Revolut OAuth authentication successful for %s", accountType)
// Redirect to success page or return success response
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("Revolut %s account connected successfully", accountType),
"account_type": string(accountType),
"token_info": map[string]interface{}{
"token_type": token.TokenType,
"expires_in": token.ExpiresIn,
"scope": token.Scope,
},
})
}
// OAuthStatus returns the current OAuth authentication status
func (ctrl *RevolutOAuthController) OAuthStatus(c *gin.Context) {
// Check if we have a stored OAuth token
token, accountType, err := ctrl.OAuthService.GetStoredOAuthToken()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"authenticated": false,
"message": "No Revolut account connected",
})
return
}
// Check if token is still valid
if token.ExpiresIn > 0 {
// TODO: Check actual expiration time from stored data
c.JSON(http.StatusOK, gin.H{
"authenticated": true,
"account_type": string(accountType),
"token_type": token.TokenType,
"scope": token.Scope,
"expires_in": token.ExpiresIn,
})
return
}
c.JSON(http.StatusOK, gin.H{
"authenticated": false,
"message": "Token expired, please re-authenticate",
})
}
// OAuthDisconnect removes the stored OAuth token
func (ctrl *RevolutOAuthController) OAuthDisconnect(c *gin.Context) {
// TODO: Remove stored OAuth token from database
logger.Info("Revolut OAuth disconnected by user")
// Mark Revolut as disabled
if err := ctrl.updateRevolutConfig(false); err != nil {
logger.Error("Failed to update Revolut config: %v", err)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Revolut account disconnected",
})
}
// updateRevolutConfig updates the Revolut configuration in the database
func (ctrl *RevolutOAuthController) updateRevolutConfig(enabled bool) error {
// Update the main settings table
var settings models.Settings
if err := ctrl.DB.First(&settings).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Create settings if not found
settings = models.Settings{
RevolutEnabled: enabled,
}
return ctrl.DB.Create(&settings).Error
}
return err
}
// Update existing settings
settings.RevolutEnabled = enabled
return ctrl.DB.Save(&settings).Error
}
// RefreshToken refreshes the OAuth access token
func (ctrl *RevolutOAuthController) RefreshToken(c *gin.Context) {
// Get current token and account type
token, accountType, err := ctrl.OAuthService.GetStoredOAuthToken()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No refresh token available"})
return
}
if token.RefreshToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "No refresh token available"})
return
}
// Refresh the token
newToken, err := ctrl.OAuthService.RefreshAccessToken(accountType, token.RefreshToken)
if err != nil {
logger.Error("Failed to refresh token: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to refresh token"})
return
}
// Store the new token
if err := ctrl.OAuthService.StoreOAuthToken(accountType, newToken); err != nil {
logger.Error("Failed to store refreshed token: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store refreshed token"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Token refreshed successfully",
"token_info": map[string]interface{}{
"token_type": newToken.TokenType,
"expires_in": newToken.ExpiresIn,
"scope": newToken.Scope,
},
})
}
@@ -0,0 +1,170 @@
package eshop
import (
"encoding/json"
"fmt"
"net/http"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"fotbal-club/pkg/database"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ShippingController struct {
Config *config.Config
PacketaService *services.PacketaService
}
func NewShippingController(cfg *config.Config) *ShippingController {
return &ShippingController{
Config: cfg,
PacketaService: services.NewPacketaService(cfg),
}
}
// GetPacketaWidgetConfig returns the configuration for the Packeta widget
func (c *ShippingController) GetPacketaWidgetConfig(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{
"api_key": c.Config.PacketaWidgetAPIKey,
"env": c.Config.PacketaEnv,
})
}
// DownloadLabel handles the download of a shipping label PDF
// GET /api/v1/eshop/shipping/labels/:packet_id
func (c *ShippingController) DownloadLabel(ctx *gin.Context) {
packetID := ctx.Param("packet_id")
if packetID == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing packet ID"})
return
}
// In MVP, we might not have label download implemented in service yet
// pdfData, err := c.PacketaService.GetPacketLabel(packetID)
// Placeholder
pdfData, err := c.PacketaService.GetPacketLabel(packetID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to download label"})
return
}
ctx.Header("Content-Type", "application/pdf")
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="label_%s.pdf"`, packetID))
ctx.Data(http.StatusOK, "application/pdf", pdfData)
}
// CreatePacket creates a shipment in Packeta system from an order
func (c *ShippingController) CreatePacket(ctx *gin.Context) {
id := ctx.Param("id")
// Use a new DB connection or pass it via struct if possible.
// For now, InitDB (cached instance)
db, _ := database.InitDB()
var order models.EshopOrder
if err := db.First(&order, id).Error; err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "Order not found"})
return
}
if order.ShippingMethod != "packeta" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Not a Packeta order"})
return
}
// Parse shipping address JSON to get point ID
// Format: {"id":"123", "name":"Z-Point..."}
var pointData struct {
ID interface{} `json:"id"`
}
if err := json.Unmarshal([]byte(order.ShippingAddressJSON), &pointData); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse shipping address"})
return
}
addressID := fmt.Sprintf("%v", pointData.ID)
if addressID == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing Packeta point ID"})
return
}
packetID, err := c.PacketaService.CreatePacket(&order, addressID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Packeta API error: " + err.Error()})
return
}
// Create Label record
label := models.EshopShippingLabel{
OrderID: order.ID,
PacketaPacketID: packetID,
Carrier: "packeta",
Status: "created",
}
db.Create(&label)
// Update order status
order.Status = "ready_to_ship"
db.Save(&order)
ctx.JSON(http.StatusOK, gin.H{
"packet_id": packetID,
"status": "created",
})
}
// Background job
func (c *ShippingController) UpdatePacketStatuses(db *gorm.DB) {
var labels []models.EshopShippingLabel
// Check active shipments
if err := db.Where("status NOT IN ?", []string{"delivered", "cancelled", "returned"}).Find(&labels).Error; err != nil {
return
}
for _, label := range labels {
status, err := c.PacketaService.GetPacketStatus(label.PacketaPacketID)
if err != nil {
continue
}
if label.Status != status {
// Update label status
label.Status = status
if err := db.Save(&label).Error; err != nil {
continue
}
// Update order status based on shipping status
c.updateOrderStatusFromShipping(db, label.OrderID, status)
}
}
}
// updateOrderStatusFromShipping updates order status based on shipping status
func (c *ShippingController) updateOrderStatusFromShipping(db *gorm.DB, orderID uint, shippingStatus string) {
var newOrderStatus string
switch shippingStatus {
case "ready_to_ship", "collected":
newOrderStatus = "processing"
case "in_transit", "out_for_delivery":
newOrderStatus = "shipped"
case "delivered":
newOrderStatus = "completed"
case "cancelled", "returned":
newOrderStatus = shippingStatus
default:
return // No status change needed
}
if err := db.Model(&models.EshopOrder{}).
Where("id = ?", orderID).
Update("status", newOrderStatus).Error; err != nil {
// Log error but don't fail the entire process
return
}
}
@@ -0,0 +1,86 @@
package eshop
import (
"net/http"
"net/http/httptest"
"testing"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// TestShippingController_UpdatePacketStatuses_UpdatesLabelStatus verifies that
// the background updater uses PacketaService to refresh label statuses.
func TestShippingController_UpdatePacketStatuses_UpdatesLabelStatus(t *testing.T) {
// In-memory SQLite DB for isolated test
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open in-memory db: %v", err)
}
// Migrate only the necessary e-shop tables
if err := db.AutoMigrate(&models.EshopOrder{}, &models.EshopShippingLabel{}); err != nil {
t.Fatalf("failed to migrate schemas: %v", err)
}
// Seed one label in a non-terminal state
label := models.EshopShippingLabel{
OrderID: 1,
Carrier: "packeta",
PacketaPacketID: "12345",
Status: "created",
}
if err := db.Create(&label).Error; err != nil {
t.Fatalf("failed to seed label: %v", err)
}
// Fake Packeta API that always returns DELIVERED
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`
<response>
<status>ok</status>
<result>
<statusCode>DELIVERED</statusCode>
<statusText>DELIVERED</statusText>
</result>
</response>
`))
}))
defer server.Close()
cfg := &config.Config{
PacketaAPIPassword: "test-password",
PacketaEshopName: "TestEshop",
}
// Use the shared PacketaService but point it to our test server
packetaSvc := &services.PacketaService{
ApiPassword: cfg.PacketaAPIPassword,
ApiUrl: server.URL,
EshopName: cfg.PacketaEshopName,
}
ctrl := &ShippingController{
Config: cfg,
PacketaService: packetaSvc,
}
// Run the updater
ctrl.UpdatePacketStatuses(db)
// Reload label and verify status was updated from Packeta response
var updated models.EshopShippingLabel
if err := db.First(&updated, label.ID).Error; err != nil {
t.Fatalf("failed to load updated label: %v", err)
}
if updated.Status != "DELIVERED" {
t.Fatalf("expected status DELIVERED, got %q", updated.Status)
}
}
@@ -0,0 +1,193 @@
package controllers
import (
"net/http"
"strconv"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// EshopAdminController handles admin management of eshop products and variants.
type EshopAdminController struct {
DB *gorm.DB
}
func NewEshopAdminController(db *gorm.DB) *EshopAdminController {
return &EshopAdminController{DB: db}
}
// AdminListProducts returns all products for admin management.
func (ctl *EshopAdminController) AdminListProducts(c *gin.Context) {
var products []models.EshopProduct
q := ctl.DB.Preload("Category").Preload("Variants").Order("created_at DESC, id DESC")
if err := q.Find(&products).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load products"})
return
}
c.JSON(http.StatusOK, gin.H{"data": products})
}
// AdminGetProduct returns a single product by ID.
func (ctl *EshopAdminController) AdminGetProduct(c *gin.Context) {
id := c.Param("id")
var product models.EshopProduct
if err := ctl.DB.Preload("Category").Preload("Variants").First(&product, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Product not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load product"})
return
}
c.JSON(http.StatusOK, product)
}
// AdminCreateProduct creates a new product.
func (ctl *EshopAdminController) AdminCreateProduct(c *gin.Context) {
var input models.EshopProduct
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if input.Currency == "" {
input.Currency = "CZK"
}
if err := ctl.DB.Create(&input).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create product"})
return
}
c.JSON(http.StatusCreated, input)
}
// AdminUpdateProduct updates an existing product.
func (ctl *EshopAdminController) AdminUpdateProduct(c *gin.Context) {
id := c.Param("id")
var existing models.EshopProduct
if err := ctl.DB.First(&existing, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Product not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load product"})
return
}
var input models.EshopProduct
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
existing.Name = input.Name
existing.Slug = input.Slug
existing.ShortDescription = input.ShortDescription
existing.DescriptionHTML = input.DescriptionHTML
existing.PriceCents = input.PriceCents
existing.Currency = input.Currency
existing.VATRate = input.VATRate
existing.Active = input.Active
existing.StockMode = input.StockMode
existing.DefaultImageURL = input.DefaultImageURL
existing.GalleryJSON = input.GalleryJSON
existing.Tags = input.Tags
existing.MetadataJSON = input.MetadataJSON
existing.CategoryID = input.CategoryID
if existing.Currency == "" {
existing.Currency = "CZK"
}
if err := ctl.DB.Save(&existing).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update product"})
return
}
c.JSON(http.StatusOK, existing)
}
// AdminDeleteProduct soft-deletes a product.
func (ctl *EshopAdminController) AdminDeleteProduct(c *gin.Context) {
id := c.Param("id")
if err := ctl.DB.Delete(&models.EshopProduct{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete product"})
return
}
c.Status(http.StatusNoContent)
}
// ---- Variants ----
// AdminListVariants lists variants for a product.
func (ctl *EshopAdminController) AdminListVariants(c *gin.Context) {
productIDStr := c.Param("id")
productID, err := strconv.ParseUint(productIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid product id"})
return
}
var variants []models.EshopProductVariant
if err := ctl.DB.Where("product_id = ?", uint(productID)).Find(&variants).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load variants"})
return
}
c.JSON(http.StatusOK, gin.H{"data": variants})
}
// AdminCreateVariant creates a variant for a product.
func (ctl *EshopAdminController) AdminCreateVariant(c *gin.Context) {
productIDStr := c.Param("id")
productID, err := strconv.ParseUint(productIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid product id"})
return
}
var input models.EshopProductVariant
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
input.ProductID = uint(productID)
if err := ctl.DB.Create(&input).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create variant"})
return
}
c.JSON(http.StatusCreated, input)
}
// AdminUpdateVariant updates a variant.
func (ctl *EshopAdminController) AdminUpdateVariant(c *gin.Context) {
variantID := c.Param("id")
var existing models.EshopProductVariant
if err := ctl.DB.First(&existing, variantID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Variant not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load variant"})
return
}
var input models.EshopProductVariant
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
existing.SKU = input.SKU
existing.Name = input.Name
existing.AttributesJSON = input.AttributesJSON
existing.StockQty = input.StockQty
existing.Barcode = input.Barcode
existing.ImageURL = input.ImageURL
if err := ctl.DB.Save(&existing).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update variant"})
return
}
c.JSON(http.StatusOK, existing)
}
// AdminDeleteVariant deletes a variant.
func (ctl *EshopAdminController) AdminDeleteVariant(c *gin.Context) {
variantID := c.Param("id")
if err := ctl.DB.Delete(&models.EshopProductVariant{}, variantID).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete variant"})
return
}
c.Status(http.StatusNoContent)
}
+220 -202
View File
@@ -1,243 +1,261 @@
package controllers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type EventController struct{ DB *gorm.DB }
// GetEventByID returns a single event by its ID (public; returns only public events unless owner)
func (ctrl *EventController) GetEventByID(c *gin.Context) {
id := c.Param("id")
var ev models.Event
if err := ctrl.DB.Preload("Attachments").First(&ev, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Event not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
// If not public, allow only owner (when identified upstream)
if !ev.IsPublic {
if roleVal, hasRole := c.Get("userRole"); hasRole {
if role, _ := roleVal.(string); role == "admin" {
c.JSON(http.StatusOK, ev)
return
}
}
if userID, exists := c.Get("userID"); !exists || ev.CreatedByID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Not allowed"})
return
}
}
c.JSON(http.StatusOK, ev)
id := c.Param("id")
var ev models.Event
if err := ctrl.DB.Preload("Attachments").First(&ev, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Event not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
// If not public, allow only owner (when identified upstream)
if !ev.IsPublic {
if roleVal, hasRole := c.Get("userRole"); hasRole {
if role, _ := roleVal.(string); role == "admin" {
c.JSON(http.StatusOK, ev)
return
}
}
if userID, exists := c.Get("userID"); !exists || ev.CreatedByID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Not allowed"})
return
}
}
c.JSON(http.StatusOK, ev)
}
type EventInput struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
StartTime time.Time `json:"start_time" binding:"required"`
EndTime *time.Time `json:"end_time"`
Location string `json:"location"`
Type string `json:"type" binding:"required,oneof=match training meeting other"`
IsPublic bool `json:"is_public"`
CategoryName string `json:"category_name"`
ImageURL string `json:"image_url"`
FileURL string `json:"file_url"`
YoutubeURL string `json:"youtube_url"`
Latitude *float64 `json:"latitude"`
Longitude *float64 `json:"longitude"`
Attachments []struct {
Name string `json:"name"`
URL string `json:"url"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
} `json:"attachments"`
Title string `json:"title" binding:"required"`
Description string `json:"description"`
StartTime time.Time `json:"start_time" binding:"required"`
EndTime *time.Time `json:"end_time"`
Location string `json:"location"`
Type string `json:"type" binding:"required,oneof=match training meeting other"`
IsPublic bool `json:"is_public"`
CategoryName string `json:"category_name"`
ImageURL string `json:"image_url"`
FileURL string `json:"file_url"`
YoutubeURL string `json:"youtube_url"`
Latitude *float64 `json:"latitude"`
Longitude *float64 `json:"longitude"`
Attachments []struct {
Name string `json:"name"`
URL string `json:"url"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
} `json:"attachments"`
}
func (ctrl *EventController) CreateEvent(c *gin.Context) {
// Ensure latest schema (adds columns if missing)
_ = ctrl.DB.AutoMigrate(&models.Event{}, &models.EventAttachment{})
var input EventInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Ensure latest schema (adds columns if missing)
_ = ctrl.DB.AutoMigrate(&models.Event{}, &models.EventAttachment{})
var input EventInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, _ := c.Get("userID")
event := models.Event{
Title: input.Title,
Description: input.Description,
StartTime: input.StartTime,
EndTime: input.EndTime,
Location: input.Location,
Type: models.EventType(input.Type),
IsPublic: input.IsPublic,
CreatedByID: userID.(uint),
CategoryName: input.CategoryName,
ImageURL: input.ImageURL,
FileURL: input.FileURL,
YoutubeURL: input.YoutubeURL,
Latitude: input.Latitude,
Longitude: input.Longitude,
}
userID, _ := c.Get("userID")
imageURL := input.ImageURL
if imageURL == "" && isXAIEnabled() {
prompt := "Titulní obrázek pro klubovou událost na oficiálním webu fotbalového klubu. Název události: \"" + input.Title + "\". Typ: " + string(models.EventType(input.Type)) + ". Zaměř se na prostředí klubu stadion, tréninkové hřiště a fanoušky v klubových barvách. Styl: realistický, moderní, sportovní, bez textu, široký banner v poměru 16:9."
if input.CategoryName != "" {
prompt += " Téma / soutěž: " + input.CategoryName + "."
}
if urls, _, err := callXAIImage(getXAIImageModel(), prompt, "1920x1080", 1); err == nil && len(urls) > 0 && urls[0] != "" {
imageURL = urls[0]
}
}
if imageURL == "" {
imageURL = "/dist/img/logo-club-empty.svg"
}
event := models.Event{
Title: input.Title,
Description: input.Description,
StartTime: input.StartTime,
EndTime: input.EndTime,
Location: input.Location,
Type: models.EventType(input.Type),
IsPublic: input.IsPublic,
CreatedByID: userID.(uint),
CategoryName: input.CategoryName,
ImageURL: imageURL,
FileURL: input.FileURL,
YoutubeURL: input.YoutubeURL,
Latitude: input.Latitude,
Longitude: input.Longitude,
}
if err := ctrl.DB.Create(&event).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create event"})
return
}
// Create attachments if any
if len(input.Attachments) > 0 {
var atts []models.EventAttachment
for _, a := range input.Attachments {
if a.URL == "" { continue }
atts = append(atts, models.EventAttachment{ EventID: event.ID, Name: a.Name, URL: a.URL, MimeType: a.MimeType, Size: a.Size })
}
if len(atts) > 0 {
if err := ctrl.DB.Create(&atts).Error; err != nil {
// non-fatal
}
}
}
if err := ctrl.DB.Create(&event).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create event"})
return
}
// Create attachments if any
if len(input.Attachments) > 0 {
var atts []models.EventAttachment
for _, a := range input.Attachments {
if a.URL == "" {
continue
}
atts = append(atts, models.EventAttachment{EventID: event.ID, Name: a.Name, URL: a.URL, MimeType: a.MimeType, Size: a.Size})
}
if len(atts) > 0 {
if err := ctrl.DB.Create(&atts).Error; err != nil {
// non-fatal
}
}
}
// Reload with attachments
var out models.Event
_ = ctrl.DB.Preload("Attachments").First(&out, event.ID).Error
// Track file usage
fileTracker := services.NewFileTracker(ctrl.DB)
go fileTracker.TrackEventFiles(&out)
c.JSON(http.StatusCreated, out)
// Reload with attachments
var out models.Event
_ = ctrl.DB.Preload("Attachments").First(&out, event.ID).Error
// Track file usage
fileTracker := services.NewFileTracker(ctrl.DB)
go fileTracker.TrackEventFiles(&out)
c.JSON(http.StatusCreated, out)
}
func (ctrl *EventController) GetEvents(c *gin.Context) {
var events []models.Event
query := ctrl.DB.Preload("Attachments")
// Admin sees all events
if roleVal, hasRole := c.Get("userRole"); hasRole {
if role, _ := roleVal.(string); role == "admin" {
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
c.JSON(http.StatusOK, events)
return
}
}
if userID, exists := c.Get("userID"); !exists {
query = query.Where("is_public = ?", true)
} else {
query = query.Where("created_by_id = ? OR is_public = ?", userID, true)
}
var events []models.Event
query := ctrl.DB.Preload("Attachments")
// Admin sees all events
if roleVal, hasRole := c.Get("userRole"); hasRole {
if role, _ := roleVal.(string); role == "admin" {
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
c.JSON(http.StatusOK, events)
return
}
}
if userID, exists := c.Get("userID"); !exists {
query = query.Where("is_public = ?", true)
} else {
query = query.Where("created_by_id = ? OR is_public = ?", userID, true)
}
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
c.JSON(http.StatusOK, events)
}
func (ctrl *EventController) GetUpcomingEvents(c *gin.Context) {
var events []models.Event
query := ctrl.DB.Preload("Attachments").Where("start_time >= ?", time.Now()).Order("start_time ASC").Limit(5)
// Admin sees all upcoming events
if roleVal, hasRole := c.Get("userRole"); hasRole {
if role, _ := roleVal.(string); role == "admin" {
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
c.JSON(http.StatusOK, events)
return
}
}
if userID, exists := c.Get("userID"); !exists {
query = query.Where("is_public = ?", true)
} else {
query = query.Where("created_by_id = ? OR is_public = ?", userID, true)
}
var events []models.Event
query := ctrl.DB.Preload("Attachments").Where("start_time >= ?", time.Now()).Order("start_time ASC").Limit(5)
// Admin sees all upcoming events
if roleVal, hasRole := c.Get("userRole"); hasRole {
if role, _ := roleVal.(string); role == "admin" {
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
c.JSON(http.StatusOK, events)
return
}
}
if userID, exists := c.Get("userID"); !exists {
query = query.Where("is_public = ?", true)
} else {
query = query.Where("created_by_id = ? OR is_public = ?", userID, true)
}
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
c.JSON(http.StatusOK, events)
}
// UpdateEvent updates an existing event (protected)
func (ctrl *EventController) UpdateEvent(c *gin.Context) {
// Ensure latest schema (adds columns if missing)
_ = ctrl.DB.AutoMigrate(&models.Event{}, &models.EventAttachment{})
id := c.Param("id")
var ev models.Event
if err := ctrl.DB.First(&ev, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Event not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
var input EventInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ev.Title = input.Title
ev.Description = input.Description
ev.StartTime = input.StartTime
ev.EndTime = input.EndTime
ev.Location = input.Location
ev.Type = models.EventType(input.Type)
ev.IsPublic = input.IsPublic
ev.CategoryName = input.CategoryName
ev.ImageURL = input.ImageURL
ev.FileURL = input.FileURL
ev.YoutubeURL = input.YoutubeURL
ev.Latitude = input.Latitude
ev.Longitude = input.Longitude
if err := ctrl.DB.Save(&ev).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update event"})
return
}
// Replace attachments (simple strategy)
if err := ctrl.DB.Where("event_id = ?", ev.ID).Delete(&models.EventAttachment{}).Error; err == nil {
if len(input.Attachments) > 0 {
var atts []models.EventAttachment
for _, a := range input.Attachments {
if a.URL == "" { continue }
atts = append(atts, models.EventAttachment{ EventID: ev.ID, Name: a.Name, URL: a.URL, MimeType: a.MimeType, Size: a.Size })
}
if len(atts) > 0 {
_ = ctrl.DB.Create(&atts).Error
}
}
}
var out models.Event
_ = ctrl.DB.Preload("Attachments").First(&out, ev.ID).Error
// Track file usage
fileTracker := services.NewFileTracker(ctrl.DB)
go fileTracker.TrackEventFiles(&out)
c.JSON(http.StatusOK, out)
// Ensure latest schema (adds columns if missing)
_ = ctrl.DB.AutoMigrate(&models.Event{}, &models.EventAttachment{})
id := c.Param("id")
var ev models.Event
if err := ctrl.DB.First(&ev, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Event not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
var input EventInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ev.Title = input.Title
ev.Description = input.Description
ev.StartTime = input.StartTime
ev.EndTime = input.EndTime
ev.Location = input.Location
ev.Type = models.EventType(input.Type)
ev.IsPublic = input.IsPublic
ev.CategoryName = input.CategoryName
ev.ImageURL = input.ImageURL
ev.FileURL = input.FileURL
ev.YoutubeURL = input.YoutubeURL
ev.Latitude = input.Latitude
ev.Longitude = input.Longitude
if err := ctrl.DB.Save(&ev).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update event"})
return
}
// Replace attachments (simple strategy)
if err := ctrl.DB.Where("event_id = ?", ev.ID).Delete(&models.EventAttachment{}).Error; err == nil {
if len(input.Attachments) > 0 {
var atts []models.EventAttachment
for _, a := range input.Attachments {
if a.URL == "" {
continue
}
atts = append(atts, models.EventAttachment{EventID: ev.ID, Name: a.Name, URL: a.URL, MimeType: a.MimeType, Size: a.Size})
}
if len(atts) > 0 {
_ = ctrl.DB.Create(&atts).Error
}
}
}
var out models.Event
_ = ctrl.DB.Preload("Attachments").First(&out, ev.ID).Error
// Track file usage
fileTracker := services.NewFileTracker(ctrl.DB)
go fileTracker.TrackEventFiles(&out)
c.JSON(http.StatusOK, out)
}
// DeleteEvent removes an event (protected)
func (ctrl *EventController) DeleteEvent(c *gin.Context) {
id := c.Param("id")
if err := ctrl.DB.Delete(&models.Event{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete event"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
id := c.Param("id")
if err := ctrl.DB.Delete(&models.Event{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete event"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+111
View File
@@ -0,0 +1,111 @@
package controllers
import (
"fmt"
"net/http"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ExpenseController handles expense-related operations
type ExpenseController struct {
db *gorm.DB
}
// NewExpenseController creates a new expense controller
func NewExpenseController(db *gorm.DB) *ExpenseController {
return &ExpenseController{db: db}
}
// UploadReceipt handles receipt upload and OCR processing
func (ec *ExpenseController) UploadReceipt(c *gin.Context) {
// Get the uploaded file
file, header, err := c.Request.FormFile("receipt")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No file uploaded"})
return
}
defer file.Close()
// Validate file type
allowedTypes := []string{"image/jpeg", "image/jpg", "image/png", "application/pdf"}
if !contains(allowedTypes, header.Header.Get("Content-Type")) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file type. Only JPEG, PNG, and PDF files are allowed"})
return
}
// Generate unique filename
ext := filepath.Ext(header.Filename)
filename := fmt.Sprintf("receipt_%d%s", time.Now().UnixNano(), ext)
// Save file to uploads directory
filePath := filepath.Join("uploads", "receipts", filename)
if err := c.SaveUploadedFile(header, filePath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"})
return
}
// Perform OCR processing (placeholder)
ocrData, accuracy, err := ec.performOCR(filePath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "OCR processing failed"})
return
}
// Parse receipt data (placeholder)
parsedData := parseReceiptData(ocrData)
response := gin.H{
"file_path": filePath,
"file_name": filename,
"file_size": header.Size,
"ocr_data": ocrData,
"ocr_accuracy": accuracy,
"parsed_data": parsedData,
}
c.JSON(http.StatusOK, gin.H{
"receipt": response,
})
}
// performOCR performs OCR on the uploaded file
func (ec *ExpenseController) performOCR(filePath string) (string, float64, error) {
// This is a placeholder for OCR implementation
// In a real implementation, you would use Tesseract or another OCR service
return "Sample OCR text", 95.0, nil
}
// parseReceiptData parses OCR text to extract structured data
func parseReceiptData(ocrText string) map[string]interface{} {
// This is a placeholder for receipt parsing logic
// In a real implementation, you would use regex patterns or ML to extract:
// - Merchant name
// - Date
// - Total amount
// - VAT
// - Items
parsed := map[string]interface{}{
"merchant": "",
"date": "",
"total": 0,
"vat": 0,
"items": []map[string]interface{}{},
}
return parsed
}
// Helper function to check if slice contains string
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
+29 -1
View File
@@ -4,11 +4,14 @@ import (
"encoding/csv"
"encoding/json"
"fmt"
"path/filepath"
"reflect"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/xuri/excelize/v2"
)
// ExportHelper provides export utilities for CSV, JSON, etc.
@@ -111,7 +114,10 @@ func (eh *ExportHelper) formatFieldValue(v reflect.Value) string {
}
}
// ImportFromCSV imports data from CSV file
// ImportFromCSV imports tabular data from an uploaded file.
// It supports traditional CSV files as well as Excel workbooks (.xlsx).
// The caller always receives a [][]string where the first row is treated
// as the header row by higher-level import functions.
func (eh *ExportHelper) ImportFromCSV(c *gin.Context, formFieldName string) ([][]string, error) {
file, err := c.FormFile(formFieldName)
if err != nil {
@@ -124,6 +130,28 @@ func (eh *ExportHelper) ImportFromCSV(c *gin.Context, formFieldName string) ([][
}
defer f.Close()
ext := strings.ToLower(filepath.Ext(file.Filename))
if ext == ".xlsx" {
wb, err := excelize.OpenReader(f)
if err != nil {
return nil, err
}
defer func() {
_ = wb.Close()
}()
sheets := wb.GetSheetList()
if len(sheets) == 0 {
return nil, fmt.Errorf("xlsx file has no sheets")
}
rows, err := wb.GetRows(sheets[0])
if err != nil {
return nil, err
}
return rows, nil
}
reader := csv.NewReader(f)
records, err := reader.ReadAll()
if err != nil {
+578
View File
@@ -0,0 +1,578 @@
package controllers
import (
"fmt"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"fotbal-club/internal/models"
"fotbal-club/pkg/database"
)
// FacilityController handles facility management operations
type FacilityController struct {
db *gorm.DB
}
// NewFacilityController creates a new facility controller
func NewFacilityController() *FacilityController {
return &FacilityController{
db: database.GetDB(),
}
}
// FacilityListRequest represents query parameters for facility listing
type FacilityListRequest struct {
Type string `form:"type"`
Status string `form:"status"`
Page int `form:"page,default=1"`
Limit int `form:"limit,default=20"`
Search string `form:"search"`
}
// FacilityResponse represents a facility response
type FacilityResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Capacity int `json:"capacity"`
Area float64 `json:"area"`
Location string `json:"location"`
IsIndoor bool `json:"is_indoor"`
IsOutdoor bool `json:"is_outdoor"`
ImageURL string `json:"image_url"`
// Booking settings
RequiresApproval bool `json:"requires_approval"`
MinBookingDuration int `json:"min_booking_duration"`
MaxBookingDuration int `json:"max_booking_duration"`
BookingAdvanceDays int `json:"booking_advance_days"`
PricePerHour float64 `json:"price_per_hour"`
// Availability
AvailabilityRules []models.FacilityAvailabilityRule `json:"availability_rules,omitempty"`
// Counts
BookingsCount int `json:"bookings_count,omitempty"`
EquipmentCount int `json:"equipment_count,omitempty"`
MaintenanceCount int `json:"maintenance_count,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// BookingRequest represents a booking request
type BookingRequest struct {
FacilityID uint `json:"facility_id" binding:"required"`
Title string `json:"title" binding:"required"`
Description string `json:"description"`
StartTime string `json:"start_time" binding:"required"` // ISO 8601 format
EndTime string `json:"end_time" binding:"required"` // ISO 8601 format
AttendeesCount int `json:"attendees_count"`
}
// BookingResponse represents a booking response
type BookingResponse struct {
ID uint `json:"id"`
FacilityID uint `json:"facility_id"`
Facility FacilityResponse `json:"facility"`
UserID uint `json:"user_id"`
User models.User `json:"user"`
Title string `json:"title"`
Description string `json:"description"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Status string `json:"status"`
TotalPrice float64 `json:"total_price"`
PaymentStatus string `json:"payment_status"`
AttendeesCount int `json:"attendees_count"`
PublicNotes string `json:"public_notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// GetFacilities handles GET /api/v1/admin/facilities
func (fc *FacilityController) GetFacilities(c *gin.Context) {
var req FacilityListRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
var facilities []models.Facility
query := fc.db.Model(&models.Facility{})
// Apply filters
if req.Type != "" {
query = query.Where("type = ?", req.Type)
}
if req.Status != "" {
query = query.Where("status = ?", req.Status)
}
if req.Search != "" {
query = query.Where("name ILIKE ? OR description ILIKE ?", "%"+req.Search+"%", "%"+req.Search+"%")
}
// Count total
var total int64
query.Count(&total)
// Apply pagination
offset := (req.Page - 1) * req.Limit
query = query.Offset(offset).Limit(req.Limit)
if err := query.Preload("AvailabilityRules").Find(&facilities).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to fetch facilities"})
return
}
// Transform to response format
var responses []FacilityResponse
for _, facility := range facilities {
// Count related records
var bookingsCount, equipmentCount, maintenanceCount int64
fc.db.Model(&models.FacilityBooking{}).Where("facility_id = ?", facility.ID).Count(&bookingsCount)
fc.db.Model(&models.FacilityEquipment{}).Where("facility_id = ?", facility.ID).Count(&equipmentCount)
fc.db.Model(&models.FacilityMaintenance{}).Where("facility_id = ?", facility.ID).Count(&maintenanceCount)
responses = append(responses, FacilityResponse{
ID: facility.ID,
Name: facility.Name,
Type: string(facility.Type),
Status: string(facility.Status),
Capacity: facility.Capacity,
Area: facility.Area,
Location: facility.Location,
IsIndoor: facility.IsIndoor,
IsOutdoor: facility.IsOutdoor,
ImageURL: facility.ImageURL,
RequiresApproval: facility.RequiresApproval,
MinBookingDuration: facility.MinBookingDuration,
MaxBookingDuration: facility.MaxBookingDuration,
BookingAdvanceDays: facility.BookingAdvanceDays,
PricePerHour: facility.PricePerHour,
AvailabilityRules: facility.AvailabilityRules,
BookingsCount: int(bookingsCount),
EquipmentCount: int(equipmentCount),
MaintenanceCount: int(maintenanceCount),
CreatedAt: facility.CreatedAt,
UpdatedAt: facility.UpdatedAt,
})
}
c.JSON(200, gin.H{
"facilities": responses,
"total": total,
"page": req.Page,
"limit": req.Limit,
})
}
// GetFacility handles GET /api/v1/admin/facilities/:id
func (fc *FacilityController) GetFacility(c *gin.Context) {
id := c.Param("id")
var facility models.Facility
if err := fc.db.Preload("AvailabilityRules").
Preload("Bookings").
Preload("Equipment").
Preload("Maintenance").
First(&facility, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(404, gin.H{"error": "Facility not found"})
} else {
c.JSON(500, gin.H{"error": "Failed to fetch facility"})
}
return
}
c.JSON(200, gin.H{"facility": facility})
}
// CreateFacility handles POST /api/v1/admin/facilities
func (fc *FacilityController) CreateFacility(c *gin.Context) {
var facility models.Facility
if err := c.ShouldBindJSON(&facility); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// Set default values
if facility.Status == "" {
facility.Status = models.FacilityStatusActive
}
if facility.MinBookingDuration == 0 {
facility.MinBookingDuration = 30
}
if facility.MaxBookingDuration == 0 {
facility.MaxBookingDuration = 240
}
if facility.BookingAdvanceDays == 0 {
facility.BookingAdvanceDays = 30
}
if err := fc.db.Create(&facility).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to create facility"})
return
}
c.JSON(201, gin.H{"facility": facility})
}
// UpdateFacility handles PUT /api/v1/admin/facilities/:id
func (fc *FacilityController) UpdateFacility(c *gin.Context) {
id := c.Param("id")
var facility models.Facility
if err := fc.db.First(&facility, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(404, gin.H{"error": "Facility not found"})
} else {
c.JSON(500, gin.H{"error": "Failed to fetch facility"})
}
return
}
var updates models.Facility
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// Update fields
if err := fc.db.Model(&facility).Updates(updates).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to update facility"})
return
}
// Fetch updated facility
if err := fc.db.Preload("AvailabilityRules").First(&facility, id).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to fetch updated facility"})
return
}
c.JSON(200, gin.H{"facility": facility})
}
// DeleteFacility handles DELETE /api/v1/admin/facilities/:id
func (fc *FacilityController) DeleteFacility(c *gin.Context) {
id := c.Param("id")
if err := fc.db.Delete(&models.Facility{}, id).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to delete facility"})
return
}
c.JSON(200, gin.H{"message": "Facility deleted successfully"})
}
// GetFacilityBookings handles GET /api/v1/admin/facilities/:id/bookings
func (fc *FacilityController) GetFacilityBookings(c *gin.Context) {
facilityID := c.Param("id")
// Parse query parameters
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
status := c.Query("status")
startDate := c.Query("start_date")
endDate := c.Query("end_date")
var bookings []models.FacilityBooking
query := fc.db.Model(&models.FacilityBooking{}).Where("facility_id = ?", facilityID)
// Apply filters
if status != "" {
query = query.Where("status = ?", status)
}
if startDate != "" {
query = query.Where("start_time >= ?", startDate)
}
if endDate != "" {
query = query.Where("end_time <= ?", endDate)
}
// Count total
var total int64
query.Count(&total)
// Apply pagination
offset := (page - 1) * limit
query = query.Offset(offset).Limit(limit)
if err := query.Preload("User").Preload("Facility").Order("start_time DESC").Find(&bookings).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to fetch bookings"})
return
}
c.JSON(200, gin.H{
"bookings": bookings,
"total": total,
"page": page,
"limit": limit,
})
}
// CreateBooking handles POST /api/v1/facilities/bookings
func (fc *FacilityController) CreateBooking(c *gin.Context) {
var req BookingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// Get user from context (assuming JWT middleware)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(401, gin.H{"error": "User not authenticated"})
return
}
// Parse times
startTime, err := time.Parse(time.RFC3339, req.StartTime)
if err != nil {
c.JSON(400, gin.H{"error": "Invalid start_time format"})
return
}
endTime, err := time.Parse(time.RFC3339, req.EndTime)
if err != nil {
c.JSON(400, gin.H{"error": "Invalid end_time format"})
return
}
// Validate time range
if endTime.Before(startTime) || endTime.Equal(startTime) {
c.JSON(400, gin.H{"error": "End time must be after start time"})
return
}
// Get facility
var facility models.Facility
if err := fc.db.First(&facility, req.FacilityID).Error; err != nil {
c.JSON(404, gin.H{"error": "Facility not found"})
return
}
// Check if facility is available
if facility.Status != models.FacilityStatusActive {
c.JSON(400, gin.H{"error": "Facility is not available for booking"})
return
}
// Check booking duration limits
duration := endTime.Sub(startTime).Minutes()
if int(duration) < facility.MinBookingDuration {
c.JSON(400, gin.H{"error": fmt.Sprintf("Booking duration must be at least %d minutes", facility.MinBookingDuration)})
return
}
if int(duration) > facility.MaxBookingDuration {
c.JSON(400, gin.H{"error": fmt.Sprintf("Booking duration cannot exceed %d minutes", facility.MaxBookingDuration)})
return
}
// Check advance booking limit
if facility.BookingAdvanceDays > 0 {
maxDate := time.Now().AddDate(0, 0, facility.BookingAdvanceDays)
if startTime.After(maxDate) {
c.JSON(400, gin.H{"error": fmt.Sprintf("Bookings cannot be made more than %d days in advance", facility.BookingAdvanceDays)})
return
}
}
// Check for overlapping bookings
var overlappingBooking models.FacilityBooking
if err := fc.db.Where("facility_id = ? AND start_time < ? AND end_time > ? AND status NOT IN (?, ?)",
req.FacilityID, endTime, startTime, string(models.BookingStatusCancelled), string(models.BookingStatusNoShow)).
First(&overlappingBooking).Error; err == nil {
c.JSON(409, gin.H{"error": "Time slot is already booked"})
return
}
// Calculate price
totalPrice := facility.PricePerHour * (duration / 60)
// Create booking
booking := models.FacilityBooking{
FacilityID: req.FacilityID,
UserID: userID.(uint),
Title: req.Title,
Description: req.Description,
StartTime: startTime,
EndTime: endTime,
Status: models.BookingStatusPending,
TotalPrice: totalPrice,
AttendeesCount: req.AttendeesCount,
}
if facility.RequiresApproval {
booking.Status = models.BookingStatusPending
} else {
booking.Status = models.BookingStatusConfirmed
}
if err := fc.db.Create(&booking).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to create booking"})
return
}
// Load relationships for response
fc.db.Preload("User").Preload("Facility").First(&booking, booking.ID)
c.JSON(201, gin.H{"booking": booking})
}
// GetPublicFacilities handles GET /api/v1/facilities
func (fc *FacilityController) GetPublicFacilities(c *gin.Context) {
var facilities []models.Facility
query := fc.db.Model(&models.Facility{}).Where("status = ?", models.FacilityStatusActive)
// Optional filters
facilityType := c.Query("type")
if facilityType != "" {
query = query.Where("type = ?", facilityType)
}
if err := query.Find(&facilities).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to fetch facilities"})
return
}
// Transform to public response format (limited fields)
var responses []FacilityResponse
for _, facility := range facilities {
responses = append(responses, FacilityResponse{
ID: facility.ID,
Name: facility.Name,
Type: string(facility.Type),
Status: string(facility.Status),
Capacity: facility.Capacity,
Area: facility.Area,
Location: facility.Location,
IsIndoor: facility.IsIndoor,
IsOutdoor: facility.IsOutdoor,
ImageURL: facility.ImageURL,
RequiresApproval: facility.RequiresApproval,
MinBookingDuration: facility.MinBookingDuration,
MaxBookingDuration: facility.MaxBookingDuration,
BookingAdvanceDays: facility.BookingAdvanceDays,
PricePerHour: facility.PricePerHour,
CreatedAt: facility.CreatedAt,
UpdatedAt: facility.UpdatedAt,
})
}
c.JSON(200, gin.H{"facilities": responses})
}
// GetFacilityAvailability handles GET /api/v1/facilities/:id/availability
func (fc *FacilityController) GetFacilityAvailability(c *gin.Context) {
facilityID := c.Param("id")
// Parse date range
startDate := c.Query("start_date")
endDate := c.Query("end_date")
if startDate == "" || endDate == "" {
c.JSON(400, gin.H{"error": "start_date and end_date are required"})
return
}
start, err := time.Parse("2006-01-02", startDate)
if err != nil {
c.JSON(400, gin.H{"error": "Invalid start_date format, use YYYY-MM-DD"})
return
}
end, err := time.Parse("2006-01-02", endDate)
if err != nil {
c.JSON(400, gin.H{"error": "Invalid end_date format, use YYYY-MM-DD"})
return
}
// Get facility
var facility models.Facility
if err := fc.db.Preload("AvailabilityRules").First(&facility, facilityID).Error; err != nil {
c.JSON(404, gin.H{"error": "Facility not found"})
return
}
// Get existing bookings
var bookings []models.FacilityBooking
if err := fc.db.Where("facility_id = ? AND start_time >= ? AND end_time <= ? AND status NOT IN (?, ?)",
facilityID, start, end.AddDate(0, 0, 1), string(models.BookingStatusCancelled), string(models.BookingStatusNoShow)).
Find(&bookings).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to fetch bookings"})
return
}
// Generate availability slots
availability := fc.generateAvailabilitySlots(facility, bookings, start, end)
c.JSON(200, gin.H{
"facility": facility,
"availability": availability,
})
}
// generateAvailabilitySlots creates available time slots for a facility
func (fc *FacilityController) generateAvailabilitySlots(facility models.Facility, bookings []models.FacilityBooking, start, end time.Time) map[string][]map[string]interface{} {
availability := make(map[string][]map[string]interface{})
// Initialize each day with empty slots
for d := start; d.Before(end.AddDate(0, 0, 1)); d = d.AddDate(0, 0, 1) {
dateStr := d.Format("2006-01-02")
availability[dateStr] = []map[string]interface{}{}
}
// For each day, generate available slots based on rules and existing bookings
for d := start; d.Before(end.AddDate(0, 0, 1)); d = d.AddDate(0, 0, 1) {
dateStr := d.Format("2006-01-02")
dayOfWeek := int(d.Weekday())
// Find availability rules for this day
var dayRules []models.FacilityAvailabilityRule
for _, rule := range facility.AvailabilityRules {
if rule.DayOfWeek == dayOfWeek && rule.IsAvailable {
// Check if rule is within date range
if (rule.StartDate == nil || d.After(*rule.StartDate) || d.Equal(*rule.StartDate)) &&
(rule.EndDate == nil || d.Before(*rule.EndDate) || d.Equal(*rule.EndDate)) {
dayRules = append(dayRules, rule)
}
}
}
// Generate slots for each rule
for _, rule := range dayRules {
ruleStart, _ := time.Parse("15:04", rule.StartTime)
ruleEnd, _ := time.Parse("15:04", rule.EndTime)
// Convert to full datetime
slotStart := time.Date(d.Year(), d.Month(), d.Day(), ruleStart.Hour(), ruleStart.Minute(), 0, 0, d.Location())
slotEnd := time.Date(d.Year(), d.Month(), d.Day(), ruleEnd.Hour(), ruleEnd.Minute(), 0, 0, d.Location())
// Check for overlapping bookings
isAvailable := true
for _, booking := range bookings {
if booking.StartTime.Before(slotEnd) && booking.EndTime.After(slotStart) {
isAvailable = false
break
}
}
if isAvailable {
availability[dateStr] = append(availability[dateStr], map[string]interface{}{
"start": slotStart.Format("15:04"),
"end": slotEnd.Format("15:04"),
"available": true,
})
}
}
}
return availability
}
@@ -0,0 +1,578 @@
package controllers
import (
"fmt"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"fotbal-club/internal/models"
"fotbal-club/pkg/database"
)
// EquipmentController handles equipment management operations
type EquipmentController struct {
db *gorm.DB
}
// NewEquipmentController creates a new equipment controller
func NewEquipmentController() *EquipmentController {
return &EquipmentController{
db: database.GetDB(),
}
}
// EquipmentListRequest represents query parameters for equipment listing
type EquipmentListRequest struct {
FacilityID uint `form:"facility_id"`
Category string `form:"category"`
Status string `form:"status"`
Page int `form:"page,default=1"`
Limit int `form:"limit,default=20"`
Search string `form:"search"`
}
// GetEquipment handles GET /api/v1/admin/equipment
func (ec *EquipmentController) GetEquipment(c *gin.Context) {
var req EquipmentListRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
var equipment []models.FacilityEquipment
query := ec.db.Model(&models.FacilityEquipment{})
// Apply filters
if req.FacilityID > 0 {
query = query.Where("facility_id = ?", req.FacilityID)
}
if req.Category != "" {
query = query.Where("category = ?", req.Category)
}
if req.Status != "" {
query = query.Where("status = ?", req.Status)
}
if req.Search != "" {
query = query.Where("name ILIKE ? OR description ILIKE ?", "%"+req.Search+"%", "%"+req.Search+"%")
}
// Count total
var total int64
query.Count(&total)
// Apply pagination
offset := (req.Page - 1) * req.Limit
query = query.Offset(offset).Limit(req.Limit)
if err := query.Preload("Facility").Find(&equipment).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to fetch equipment"})
return
}
c.JSON(200, gin.H{
"equipment": equipment,
"total": total,
"page": req.Page,
"limit": req.Limit,
})
}
// CreateEquipment handles POST /api/v1/admin/equipment
func (ec *EquipmentController) CreateEquipment(c *gin.Context) {
var equipment models.FacilityEquipment
if err := c.ShouldBindJSON(&equipment); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// Set default values
if equipment.Status == "" {
equipment.Status = models.EquipmentStatusAvailable
}
if equipment.Available == 0 {
equipment.Available = equipment.Quantity
}
if err := ec.db.Create(&equipment).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to create equipment"})
return
}
c.JSON(201, gin.H{"equipment": equipment})
}
// UpdateEquipment handles PUT /api/v1/admin/equipment/:id
func (ec *EquipmentController) UpdateEquipment(c *gin.Context) {
id := c.Param("id")
var equipment models.FacilityEquipment
if err := ec.db.First(&equipment, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(404, gin.H{"error": "Equipment not found"})
} else {
c.JSON(500, gin.H{"error": "Failed to fetch equipment"})
}
return
}
var updates models.FacilityEquipment
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
if err := ec.db.Model(&equipment).Updates(updates).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to update equipment"})
return
}
// Fetch updated equipment
if err := ec.db.Preload("Facility").First(&equipment, id).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to fetch updated equipment"})
return
}
c.JSON(200, gin.H{"equipment": equipment})
}
// DeleteEquipment handles DELETE /api/v1/admin/equipment/:id
func (ec *EquipmentController) DeleteEquipment(c *gin.Context) {
id := c.Param("id")
if err := ec.db.Delete(&models.FacilityEquipment{}, id).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to delete equipment"})
return
}
c.JSON(200, gin.H{"message": "Equipment deleted successfully"})
}
// MaintenanceController handles maintenance scheduling operations
type MaintenanceController struct {
db *gorm.DB
}
// NewMaintenanceController creates a new maintenance controller
func NewMaintenanceController() *MaintenanceController {
return &MaintenanceController{
db: database.GetDB(),
}
}
// MaintenanceListRequest represents query parameters for maintenance listing
type MaintenanceListRequest struct {
FacilityID uint `form:"facility_id"`
Type string `form:"type"`
Status string `form:"status"`
StartDate string `form:"start_date"`
EndDate string `form:"end_date"`
Page int `form:"page,default=1"`
Limit int `form:"limit,default=20"`
}
// GetMaintenance handles GET /api/v1/admin/maintenance
func (mc *MaintenanceController) GetMaintenance(c *gin.Context) {
var req MaintenanceListRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
var maintenance []models.FacilityMaintenance
query := mc.db.Model(&models.FacilityMaintenance{})
// Apply filters
if req.FacilityID > 0 {
query = query.Where("facility_id = ?", req.FacilityID)
}
if req.Type != "" {
query = query.Where("type = ?", req.Type)
}
if req.Status != "" {
query = query.Where("status = ?", req.Status)
}
if req.StartDate != "" {
query = query.Where("scheduled_date >= ?", req.StartDate)
}
if req.EndDate != "" {
query = query.Where("scheduled_date <= ?", req.EndDate)
}
// Count total
var total int64
query.Count(&total)
// Apply pagination
offset := (req.Page - 1) * req.Limit
query = query.Offset(offset).Limit(req.Limit)
if err := query.Preload("Facility").Order("scheduled_date ASC").Find(&maintenance).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to fetch maintenance records"})
return
}
c.JSON(200, gin.H{
"maintenance": maintenance,
"total": total,
"page": req.Page,
"limit": req.Limit,
})
}
// CreateMaintenance handles POST /api/v1/admin/maintenance
func (mc *MaintenanceController) CreateMaintenance(c *gin.Context) {
var maintenance models.FacilityMaintenance
if err := c.ShouldBindJSON(&maintenance); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// Set default status
if maintenance.Status == "" {
maintenance.Status = "scheduled"
}
if err := mc.db.Create(&maintenance).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to create maintenance record"})
return
}
// If facility is unavailable during maintenance, update facility status
if maintenance.IsFacilityUnavailable && maintenance.ScheduledDate != nil {
mc.db.Model(&models.Facility{}).Where("id = ?", maintenance.FacilityID).
Update("status", models.FacilityStatusMaintenance)
}
c.JSON(201, gin.H{"maintenance": maintenance})
}
// UpdateMaintenance handles PUT /api/v1/admin/maintenance/:id
func (mc *MaintenanceController) UpdateMaintenance(c *gin.Context) {
id := c.Param("id")
var maintenance models.FacilityMaintenance
if err := mc.db.First(&maintenance, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(404, gin.H{"error": "Maintenance record not found"})
} else {
c.JSON(500, gin.H{"error": "Failed to fetch maintenance record"})
}
return
}
var updates models.FacilityMaintenance
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// Store old values for comparison
oldStatus := maintenance.Status
oldIsUnavailable := maintenance.IsFacilityUnavailable
if err := mc.db.Model(&maintenance).Updates(updates).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to update maintenance record"})
return
}
// Fetch updated maintenance
if err := mc.db.Preload("Facility").First(&maintenance, id).Error; err != nil {
c.JSON(500, gin.H{"error": "Failed to fetch updated maintenance record"})
return
}
// Update facility status if maintenance is completed
if oldStatus != "completed" && maintenance.Status == "completed" && oldIsUnavailable {
mc.db.Model(&models.Facility{}).Where("id = ?", maintenance.FacilityID).
Update("status", models.FacilityStatusActive)
}
c.JSON(200, gin.H{"maintenance": maintenance})
}
// WeatherController handles weather integration for outdoor facilities
type WeatherController struct {
db *gorm.DB
apiKey string // OpenWeatherMap API key
}
// NewWeatherController creates a new weather controller
func NewWeatherController(apiKey string) *WeatherController {
return &WeatherController{
db: database.GetDB(),
apiKey: apiKey,
}
}
// WeatherResponse represents weather data
type WeatherResponse struct {
DateTime time.Time `json:"date_time"`
Temperature float64 `json:"temperature"`
Humidity float64 `json:"humidity"`
Precipitation float64 `json:"precipitation"`
WindSpeed float64 `json:"wind_speed"`
WindDirection int `json:"wind_direction"`
WeatherCode string `json:"weather_code"`
Description string `json:"description"`
IsSuitable bool `json:"is_suitable"`
Recommendations string `json:"recommendations"`
}
// OpenWeatherMapResponse represents API response from OpenWeatherMap
type OpenWeatherMapResponse struct {
List []struct {
Dt int64 `json:"dt"`
Main struct {
Temp float64 `json:"temp"`
Humidity int `json:"humidity"`
} `json:"main"`
Weather []struct {
ID int `json:"id"`
Main string `json:"main"`
Description string `json:"description"`
} `json:"weather"`
Wind struct {
Speed float64 `json:"speed"`
Deg int `json:"deg"`
} `json:"wind"`
Rain struct {
OneHour float64 `json:"1h"`
} `json:"rain"`
Snow struct {
OneHour float64 `json:"1h"`
} `json:"snow"`
} `json:"list"`
}
// GetWeatherForecast handles GET /api/v1/facilities/:id/weather
func (wc *WeatherController) GetWeatherForecast(c *gin.Context) {
facilityID := c.Param("id")
// Get facility
var facility models.Facility
if err := wc.db.First(&facility, facilityID).Error; err != nil {
c.JSON(404, gin.H{"error": "Facility not found"})
return
}
// Only provide weather for outdoor facilities
if !facility.IsOutdoor {
c.JSON(400, gin.H{"error": "Weather data only available for outdoor facilities"})
return
}
// Try to get cached weather data first
var cachedWeather []models.WeatherCondition
twoHoursAgo := time.Now().Add(-2 * time.Hour)
if err := wc.db.Where("facility_id = ? AND created_at > ?", facilityID, twoHoursAgo).
Order("date_time ASC").Find(&cachedWeather).Error; err == nil && len(cachedWeather) > 0 {
var responses []WeatherResponse
for _, weather := range cachedWeather {
responses = append(responses, WeatherResponse{
DateTime: weather.DateTime,
Temperature: weather.Temperature,
Humidity: weather.Humidity,
Precipitation: weather.Precipitation,
WindSpeed: weather.WindSpeed,
WindDirection: weather.WindDirection,
WeatherCode: weather.WeatherCode,
Description: weather.Description,
IsSuitable: weather.IsSuitable,
Recommendations: weather.Recommendations,
})
}
c.JSON(200, gin.H{"weather": responses})
return
}
// Fetch fresh weather data if no recent cache
if wc.apiKey == "" {
c.JSON(503, gin.H{"error": "Weather service not configured"})
return
}
// For demo purposes, return mock data if API key is not set
// In production, you would call OpenWeatherMap API here
mockWeather := wc.generateMockWeather(facility)
// Cache the weather data
for _, weather := range mockWeather {
wc.db.Create(&models.WeatherCondition{
FacilityID: facility.ID,
DateTime: weather.DateTime,
Temperature: weather.Temperature,
Humidity: weather.Humidity,
Precipitation: weather.Precipitation,
WindSpeed: weather.WindSpeed,
WindDirection: weather.WindDirection,
WeatherCode: weather.WeatherCode,
Description: weather.Description,
IsSuitable: weather.IsSuitable,
Recommendations: weather.Recommendations,
})
}
c.JSON(200, gin.H{"weather": mockWeather})
}
// generateMockWeather creates sample weather data for demonstration
func (wc *WeatherController) generateMockWeather(facility models.Facility) []WeatherResponse {
var weather []WeatherResponse
now := time.Now()
// Generate 5-day forecast
for i := 0; i < 5; i++ {
date := now.AddDate(0, 0, i)
// Generate 3 time slots per day (morning, afternoon, evening)
for _, hour := range []int{9, 15, 18} {
dateTime := time.Date(date.Year(), date.Month(), date.Day(), hour, 0, 0, 0, date.Location())
// Mock weather conditions
temp := 15.0 + float64(i) + float64(hour/10)
humidity := 60.0 + float64(i*5)
windSpeed := 10.0 + float64(i)
precipitation := 0.0
isSuitable := true
recommendations := "Dobré podmínky pro trénink"
// Add some rain on random days
if i == 2 && hour == 15 {
precipitation = 5.0
isSuitable = false
recommendations = "Déšť - doporučeno přerušit trénink nebo přesunout dovnitř"
}
weather = append(weather, WeatherResponse{
DateTime: dateTime,
Temperature: temp,
Humidity: humidity,
Precipitation: precipitation,
WindSpeed: windSpeed,
WindDirection: 180 + i*10,
WeatherCode: "800",
Description: "Jasno",
IsSuitable: isSuitable,
Recommendations: recommendations,
})
}
}
return weather
}
// BookingCalendarController handles calendar view for bookings
type BookingCalendarController struct {
db *gorm.DB
}
// NewBookingCalendarController creates a new booking calendar controller
func NewBookingCalendarController() *BookingCalendarController {
return &BookingCalendarController{
db: database.GetDB(),
}
}
// CalendarEvent represents a calendar event
type CalendarEvent struct {
ID uint `json:"id"`
Title string `json:"title"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
Type string `json:"type"` // "booking", "maintenance", "unavailable"
Status string `json:"status"`
FacilityName string `json:"facility_name"`
UserEmail string `json:"user_email,omitempty"`
Color string `json:"color"` // For calendar display
}
// GetCalendarEvents handles GET /api/v1/facilities/calendar
func (bcc *BookingCalendarController) GetCalendarEvents(c *gin.Context) {
// Parse date range
startDate := c.Query("start")
endDate := c.Query("end")
facilityID := c.Query("facility_id")
if startDate == "" || endDate == "" {
c.JSON(400, gin.H{"error": "start and end parameters are required"})
return
}
start, err := time.Parse(time.RFC3339, startDate)
if err != nil {
c.JSON(400, gin.H{"error": "Invalid start date format"})
return
}
end, err := time.Parse(time.RFC3339, endDate)
if err != nil {
c.JSON(400, gin.H{"error": "Invalid end date format"})
return
}
var events []CalendarEvent
// Get bookings
var bookings []models.FacilityBooking
bookingQuery := bcc.db.Preload("Facility").Preload("User").
Where("start_time >= ? AND end_time <= ? AND status NOT IN (?, ?)",
start, end, string(models.BookingStatusCancelled), string(models.BookingStatusNoShow))
if facilityID != "" {
bookingQuery = bookingQuery.Where("facility_id = ?", facilityID)
}
bookingQuery.Find(&bookings)
for _, booking := range bookings {
color := "#3B82F6" // Blue for confirmed bookings
if booking.Status == models.BookingStatusPending {
color = "#F59E0B" // Orange for pending
}
events = append(events, CalendarEvent{
ID: booking.ID,
Title: booking.Title,
Start: booking.StartTime,
End: booking.EndTime,
Type: "booking",
Status: string(booking.Status),
FacilityName: booking.Facility.Name,
UserEmail: booking.User.Email,
Color: color,
})
}
// Get maintenance
var maintenance []models.FacilityMaintenance
maintenanceQuery := bcc.db.Preload("Facility").
Where("scheduled_date >= ? AND scheduled_date <= ? AND is_facility_unavailable = ?", start, end, true)
if facilityID != "" {
maintenanceQuery = maintenanceQuery.Where("facility_id = ?", facilityID)
}
maintenanceQuery.Find(&maintenance)
for _, m := range maintenance {
endTime := m.ScheduledDate.Add(time.Duration(m.EstimatedDuration) * time.Minute)
events = append(events, CalendarEvent{
ID: m.ID,
Title: fmt.Sprintf("Údržba: %s", m.Title),
Start: *m.ScheduledDate,
End: endTime,
Type: "maintenance",
Status: m.Status,
FacilityName: m.Facility.Name,
Color: "#EF4444", // Red for maintenance
})
}
c.JSON(200, gin.H{"events": events})
}
+208 -151
View File
@@ -13,6 +13,7 @@ import (
"sync"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
@@ -21,9 +22,6 @@ import (
"gorm.io/gorm"
)
// In-memory cache for logo lookup
var logoCache = map[string]string{}
// club data cache (JSON bytes) with TTL and disk persistence
type cachedItem struct {
Data []byte `json:"data"`
@@ -81,6 +79,14 @@ func setCachedJSON(key string, data []byte) {
}
}
func isManualClubDataMode() bool {
if config.AppConfig == nil {
return false
}
mode := strings.ToLower(strings.TrimSpace(config.AppConfig.ClubDataMode))
return mode == "manual"
}
// ----- Types (mirroring facr-scraper) -----
type Competition struct {
@@ -150,15 +156,6 @@ type TableRow struct {
// ----- Helpers -----
func containsFold(s, substr string) bool {
s = strings.ToLower(strings.TrimSpace(s))
substr = strings.ToLower(strings.TrimSpace(substr))
if substr == "" {
return false
}
return strings.Contains(s, substr)
}
func extractUUIDFromHref(href string) string {
href = strings.TrimSpace(href)
if href == "" {
@@ -177,145 +174,6 @@ func extractUUIDFromHref(href string) string {
}
return ""
}
func getLogoBySearch(name string) string {
key := strings.ToLower(strings.TrimSpace(name))
if key == "" {
return ""
}
if v, ok := logoCache[key]; ok {
return v
}
// Query local API routed through this same server
apiURL := fmt.Sprintf("http://localhost:8080/api/v1/facr/club/search?q=%s", neturl.QueryEscape(name))
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(apiURL)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp.Body)
return ""
}
var payload struct {
Results []struct {
Name string `json:"name"`
LogoURL string `json:"logo_url"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return ""
}
best := ""
for _, r := range payload.Results {
if strings.EqualFold(strings.TrimSpace(r.Name), strings.TrimSpace(name)) {
best = r.LogoURL
break
}
}
if best == "" {
for _, r := range payload.Results {
if strings.Contains(strings.ToLower(r.Name), key) || strings.Contains(key, strings.ToLower(r.Name)) {
best = r.LogoURL
break
}
}
}
if best == "" && len(payload.Results) > 0 {
best = payload.Results[0].LogoURL
}
if best != "" {
// Attempt to process FACR logos to transparent PNG via rembg (best-effort)
if p, err := services.ProcessFACRLogo(best); err == nil && strings.TrimSpace(p) != "" {
best = p
}
logoCache[key] = best
return best
}
// Fallback: directly scrape fotbal.cz search (same logic as SearchClubs)
vals := neturl.Values{}
vals.Set("q", name)
searchURL := "https://www.fotbal.cz/club/hledej?" + vals.Encode()
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return ""
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8")
req.Header.Set("Accept-Language", "cs-CZ,cs;q=0.9,en;q=0.8")
req.Header.Set("Referer", "https://www.fotbal.cz/club/hledej")
resp2, err := client.Do(req)
if err != nil {
return ""
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp2.Body)
return ""
}
doc, err := goquery.NewDocumentFromReader(resp2.Body)
if err != nil {
return ""
}
// choose first exact match, else first contains, else empty
exact := ""
partial := ""
doc.Find("li.ListItemSplit").Each(func(_ int, li *goquery.Selection) {
a := li.Find("a.Link--inverted").First()
n := strings.TrimSpace(a.Find("span.H7").First().Text())
if n == "" {
n = strings.TrimSpace(a.Text())
}
img := a.Find("img").First()
src, _ := img.Attr("src")
if src == "" {
return
}
if exact == "" && strings.EqualFold(n, name) {
exact = src
}
if partial == "" && (strings.Contains(strings.ToLower(n), key) || strings.Contains(key, strings.ToLower(n))) {
partial = src
}
})
best = exact
if best == "" {
best = partial
}
if best != "" {
if p, err := services.ProcessFACRLogo(best); err == nil && strings.TrimSpace(p) != "" {
best = p
}
logoCache[key] = best
}
return best
}
func getLogo(teamName, teamID string) string {
placeholder := "/dist/img/logo-club-empty.svg"
name := strings.ToLower(strings.TrimSpace(teamName))
if name == "" || strings.Contains(name, "volno") || strings.Contains(name, "volný los") || strings.Contains(name, "volny los") || strings.Contains(name, "bye") {
return placeholder
}
if logo := getLogoBySearch(teamName); logo != "" {
if p, err := services.ProcessFACRLogo(logo); err == nil && strings.TrimSpace(p) != "" {
return p
}
return logo
}
tid := strings.TrimSpace(teamID)
if tid != "" {
u := fmt.Sprintf("https://is1.fotbal.cz/media/kluby/%s/%s_crop.jpg", tid, tid)
if p, err := services.ProcessFACRLogo(u); err == nil && strings.TrimSpace(p) != "" {
return p
}
return u
}
return placeholder
}
func resolveISURL(href string) string {
href = strings.TrimSpace(href)
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
@@ -357,6 +215,15 @@ func (fc *FACRController) SearchClubs(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
return
}
// In manual club data mode we do not perform any external FACR/fotbal.cz lookups.
if isManualClubDataMode() {
c.JSON(http.StatusOK, gin.H{
"query": q,
"count": 0,
"results": []SearchResult{},
})
return
}
vals := neturl.Values{}
vals.Set("q", q)
searchURL := "https://www.fotbal.cz/club/hledej?" + vals.Encode()
@@ -536,6 +403,23 @@ func (fc *FACRController) GetClubInfo(c *gin.Context) {
return
}
}
// Manual mode: build FACR-like payload from DB-backed manual models, no external HTTP.
if isManualClubDataMode() {
payload, err := fc.buildManualClubPayload(clubID, clubType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("manual payload error: %v", err)})
return
}
b, err := json.Marshal(payload)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("marshal error: %v", err)})
return
}
setCachedJSON(cacheKey, b)
c.Data(http.StatusOK, "application/json", b)
return
}
external := fmt.Sprintf("https://facr.tdvorak.dev/club/%s/%s", clubType, clubID)
httpClient := &http.Client{Timeout: 15 * time.Second}
resp, err := httpClient.Get(external)
@@ -615,6 +499,162 @@ func (fc *FACRController) GetClubInfo(c *gin.Context) {
c.Data(http.StatusOK, "application/json", b)
}
// buildManualClubPayload constructs a FACR-like ClubInfo payload from manual DB models.
// It is used in manual club data mode to avoid any external FACR/fotbal.cz HTTP calls.
func (fc *FACRController) buildManualClubPayload(clubID, clubType string) (*ClubInfo, error) {
if fc.DB == nil {
return nil, fmt.Errorf("database handle not available")
}
clubID = strings.TrimSpace(clubID)
clubType = strings.TrimSpace(clubType)
if clubID == "" || clubType == "" {
return nil, fmt.Errorf("missing club id or type")
}
// Load primary settings for club metadata when available.
var s models.Settings
_ = fc.DB.First(&s).Error
name := strings.TrimSpace(s.ClubName)
url := strings.TrimSpace(s.ClubURL)
logoURL := strings.TrimSpace(s.ClubLogoURL)
if name == "" {
name = ""
}
payload := &ClubInfo{
Name: name,
ClubID: clubID,
ClubType: clubType,
URL: url,
LogoURL: logoURL,
Competitions: []Competition{},
}
// Load manual competitions for this club.
var comps []models.ManualCompetition
if err := fc.DB.Where("club_id = ? AND club_type = ?", clubID, clubType).Order("id ASC").Find(&comps).Error; err != nil {
return nil, err
}
if len(comps) == 0 {
return payload, nil
}
payload.Competitions = make([]Competition, len(comps))
idxByCompID := make(map[uint]int, len(comps))
compIDs := make([]uint, len(comps))
for i, c := range comps {
payload.Competitions[i] = Competition{
ID: strings.TrimSpace(c.ExternalID),
Code: strings.TrimSpace(c.Code),
Name: strings.TrimSpace(c.Name),
TeamCount: strings.TrimSpace(c.TeamCount),
MatchesLink: strings.TrimSpace(c.MatchesLink),
}
idxByCompID[c.ID] = i
compIDs[i] = c.ID
}
// Load manual matches for all competitions.
var mm []models.ManualMatch
if err := fc.DB.Where("competition_id IN ?", compIDs).Order("kickoff ASC, id ASC").Find(&mm).Error; err == nil {
primaryName := name
primaryID := clubID
for _, m := range mm {
idx, ok := idxByCompID[m.CompetitionID]
if !ok {
continue
}
// Derive home/away teams relative to primary club.
hName := strings.TrimSpace(m.OpponentName)
aName := primaryName
hID := strings.TrimSpace(m.OpponentExternalID)
aID := primaryID
if m.IsHome {
// Primary club at home.
hName = primaryName
aName = strings.TrimSpace(m.OpponentName)
hID = primaryID
aID = strings.TrimSpace(m.OpponentExternalID)
}
// Format kickoff as FACR-style "dd.MM.yyyy HH:mm".
var dt string
if !m.Kickoff.IsZero() {
dt = m.Kickoff.In(time.Local).Format("02.01.2006 15:04")
}
// Compose score with optional halftime in parentheses.
score := strings.TrimSpace(m.Score)
if ht := strings.TrimSpace(m.HalftimeScore); ht != "" {
if score != "" {
score = fmt.Sprintf("%s (%s)", score, ht)
} else {
score = ht
}
}
// Build FACR-like placeholder logos based on team IDs; LogoAPI/local overrides are applied on the frontend.
hLogo := facrPlaceholderLogo(hID)
aLogo := facrPlaceholderLogo(aID)
payload.Competitions[idx].Matches = append(payload.Competitions[idx].Matches, Match{
DateTime: dt,
Home: hName,
HomeID: hID,
HomeLogoURL: hLogo,
Away: aName,
AwayID: aID,
AwayLogoURL: aLogo,
Score: score,
Venue: strings.TrimSpace(m.Venue),
Note: strings.TrimSpace(m.Note),
MatchID: strings.TrimSpace(m.ExternalMatchID),
ReportURL: strings.TrimSpace(m.MatchURL),
})
}
}
// Load manual table rows for all competitions.
var rows []models.ManualTableRow
if err := fc.DB.Where("competition_id IN ?", compIDs).Order("rank ASC, id ASC").Find(&rows).Error; err == nil {
for _, r := range rows {
idx, ok := idxByCompID[r.CompetitionID]
if !ok {
continue
}
tr := TableRow{
Rank: strings.TrimSpace(r.Rank),
Team: strings.TrimSpace(r.TeamName),
TeamID: strings.TrimSpace(r.ExternalTeamID),
TeamLogoURL: facrPlaceholderLogo(strings.TrimSpace(r.ExternalTeamID)),
Played: strings.TrimSpace(r.Played),
Wins: strings.TrimSpace(r.Wins),
Draws: strings.TrimSpace(r.Draws),
Losses: strings.TrimSpace(r.Losses),
Score: strings.TrimSpace(r.Score),
Points: strings.TrimSpace(r.Points),
}
if payload.Competitions[idx].Table == nil {
payload.Competitions[idx].Table = &CompetitionTable{Overall: []TableRow{}}
}
payload.Competitions[idx].Table.Overall = append(payload.Competitions[idx].Table.Overall, tr)
}
}
return payload, nil
}
// facrPlaceholderLogo builds a static FACR-style logo URL from a team UUID.
// It does not perform any HTTP requests and is safe in manual mode; LogoAPI/local
// overrides remain responsible for primary logo resolution on the frontend.
func facrPlaceholderLogo(teamID string) string {
id := strings.TrimSpace(teamID)
if id == "" {
return ""
}
return fmt.Sprintf("https://is1.fotbal.cz/media/kluby/%s/%s_crop.jpg", id, id)
}
// GET /api/v1/facr/club/:type/:id/table
func (fc *FACRController) GetClubTables(c *gin.Context) {
clubID := c.Param("id")
@@ -631,6 +671,23 @@ func (fc *FACRController) GetClubTables(c *gin.Context) {
return
}
}
// Manual mode: reuse manual FACR-like payload and return competitions with tables.
if isManualClubDataMode() {
payload, err := fc.buildManualClubPayload(clubID, clubType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("manual payload error: %v", err)})
return
}
b, err := json.Marshal(payload)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("marshal error: %v", err)})
return
}
setCachedJSON(cacheKey, b)
c.Data(http.StatusOK, "application/json", b)
return
}
external := fmt.Sprintf("https://facr.tdvorak.dev/club/%s/%s/table", clubType, clubID)
httpClient := &http.Client{Timeout: 15 * time.Second}
resp, err := httpClient.Get(external)
File diff suppressed because it is too large Load Diff
+86 -5
View File
@@ -8,6 +8,7 @@ import (
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
@@ -208,16 +209,57 @@ func (gc *GalleryController) FetchAlbum(c *gin.Context) {
var albums []ZoneramaAlbum
if data, err := os.ReadFile(albumsFile); err == nil {
_ = json.Unmarshal(data, &albums)
// Primary: try parsing as a plain array (expected format)
if err := json.Unmarshal(data, &albums); err != nil || len(albums) == 0 {
// Fallback: some older/miswritten caches might be an object {"albums": [...]}
var alt struct {
Albums []ZoneramaAlbum `json:"albums"`
}
if err2 := json.Unmarshal(data, &alt); err2 == nil && len(alt.Albums) > 0 {
albums = alt.Albums
} else if err != nil {
// If we failed to parse completely, refuse to overwrite to prevent data loss
logger.Error("Failed to parse existing zonerama_albums.json: %v", err)
c.JSON(http.StatusConflict, gin.H{"error": "Albums cache is invalid; refusing to overwrite to prevent data loss"})
return
}
}
}
// Check if album already exists and update it, or add new
found := false
for i, a := range albums {
if a.ID == albumData.ID {
albums[i] = albumData
// Reuse existing album if it already has more photos (avoid shrinking due to low photo_limit)
if len(a.Photos) >= len(albumData.Photos) {
logger.Info("Reusing existing album (kept %d photos vs fetched %d): %s", len(a.Photos), len(albumData.Photos), albumData.ID)
// Optionally refresh fetched timestamp
if strings.TrimSpace(a.FetchedAt) == "" {
a.FetchedAt = albumData.FetchedAt
}
albums[i] = a
} else {
// Merge: prefer non-empty fields from new data
merged := a
if strings.TrimSpace(albumData.Title) != "" {
merged.Title = albumData.Title
}
if strings.TrimSpace(albumData.URL) != "" {
merged.URL = albumData.URL
}
if strings.TrimSpace(albumData.Date) != "" {
merged.Date = albumData.Date
}
if albumData.ViewsCount > 0 {
merged.ViewsCount = albumData.ViewsCount
}
merged.PhotosCount = albumData.PhotosCount
merged.Photos = albumData.Photos
merged.FetchedAt = albumData.FetchedAt
albums[i] = merged
logger.Info("Updated existing album with more photos: %s (now %d)", albumData.ID, len(merged.Photos))
}
found = true
logger.Info("Updated existing album: %s", albumData.ID)
break
}
}
@@ -227,7 +269,41 @@ func (gc *GalleryController) FetchAlbum(c *gin.Context) {
logger.Info("Added new album: %s", albumData.ID)
}
// Save back to file
// Keep albums ordered by album date (desc), fallback to fetched_at
parseDate := func(s string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
if t, err := time.Parse("2006-01-02", s); err == nil {
return t
}
if t, err := time.Parse("02.01.2006", s); err == nil {
return t
}
return time.Time{}
}
sort.Slice(albums, func(i, j int) bool {
di := parseDate(albums[i].Date)
dj := parseDate(albums[j].Date)
if !di.Equal(dj) {
return di.After(dj)
}
// Fallback to fetched_at if dates are equal/unavailable
var fi, fj time.Time
if t, err := time.Parse(time.RFC3339, strings.TrimSpace(albums[i].FetchedAt)); err == nil {
fi = t
}
if t, err := time.Parse(time.RFC3339, strings.TrimSpace(albums[j].FetchedAt)); err == nil {
fj = t
}
if !fi.Equal(fj) {
return fi.After(fj)
}
return strings.Compare(albums[i].ID, albums[j].ID) > 0
})
// Save back to file (atomic write)
if err := os.MkdirAll(filepath.Dir(albumsFile), 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create cache directory"})
return
@@ -239,10 +315,15 @@ func (gc *GalleryController) FetchAlbum(c *gin.Context) {
return
}
if err := os.WriteFile(albumsFile, albumsJSON, 0644); err != nil {
tmp := albumsFile + ".tmp"
if err := os.WriteFile(tmp, albumsJSON, 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save albums"})
return
}
if err := os.Rename(tmp, albumsFile); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to finalize album save"})
return
}
logger.Info("Album %s saved successfully with %d photos", albumData.ID, len(albumData.Photos))
+18 -1
View File
@@ -3,9 +3,11 @@ package controllers
import (
"context"
"net/http"
"os"
"runtime"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/services"
"github.com/gin-gonic/gin"
@@ -22,6 +24,8 @@ type HealthResponse struct {
Status string `json:"status"`
Timestamp time.Time `json:"timestamp"`
Version string `json:"version,omitempty"`
Env string `json:"env,omitempty"`
Premium bool `json:"premium,omitempty"`
Checks map[string]CheckResult `json:"checks"`
System SystemInfo `json:"system,omitempty"`
}
@@ -100,6 +104,17 @@ func (hc *HealthController) Health(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
env := ""
premium := false
if config.AppConfig != nil {
env = config.AppConfig.AppEnv
premium = config.AppConfig.Premium
}
version := os.Getenv("APP_VERSION")
if version == "" {
version = "dev"
}
checks := make(map[string]CheckResult)
overallStatus := "healthy"
@@ -122,7 +137,9 @@ func (hc *HealthController) Health(c *gin.Context) {
response := HealthResponse{
Status: overallStatus,
Timestamp: time.Now(),
Version: "1.0.0", // Use actual version
Version: version, // Use actual version
Env: env,
Premium: premium,
Checks: checks,
System: sysInfo,
}
+385
View File
@@ -0,0 +1,385 @@
package controllers
import (
"net/http"
"strconv"
"fotbal-club/internal/models"
"fotbal-club/pkg/database"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// I18nController handles internationalization endpoints
type I18nController struct {
db *gorm.DB
}
// NewI18nController creates a new i18n controller
func NewI18nController() *I18nController {
return &I18nController{
db: database.GetDB(),
}
}
// GetLanguages returns all active languages
func (ctrl *I18nController) GetLanguages(c *gin.Context) {
languages, err := models.GetActiveLanguages(ctrl.db)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch languages"})
return
}
c.JSON(http.StatusOK, gin.H{
"languages": languages,
})
}
// GetTranslations returns translations for a specific language
func (ctrl *I18nController) GetTranslations(c *gin.Context) {
languageCode := c.Param("language")
context := c.Query("context") // optional context filter
var translations []models.Translation
query := ctrl.db.Where("language_code = ?", languageCode)
if context != "" {
query = query.Where("context = ?", context)
}
err := query.Order("key ASC").Find(&translations).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch translations"})
return
}
// Convert to key-value map for easier frontend consumption
result := make(map[string]string)
for _, t := range translations {
result[t.Key] = t.Value
}
c.JSON(http.StatusOK, gin.H{
"translations": result,
"language": languageCode,
"context": context,
})
}
// SetUserLanguage sets user's preferred language
func (ctrl *I18nController) SetUserLanguage(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
var req struct {
LanguageCode string `json:"language_code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate language
var lang models.Language
err := ctrl.db.Where("code = ? AND is_active = ?", req.LanguageCode, true).First(&lang).Error
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid language code"})
return
}
// Update or create user preference
var pref models.UserLanguagePreference
err = ctrl.db.Where("user_id = ?", userID).First(&pref).Error
if err == gorm.ErrRecordNotFound {
// Create new preference
pref = models.UserLanguagePreference{
UserID: userID.(uint),
LanguageCode: req.LanguageCode,
}
err = ctrl.db.Create(&pref).Error
} else {
// Update existing preference
pref.LanguageCode = req.LanguageCode
err = ctrl.db.Save(&pref).Error
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save language preference"})
return
}
// Set cookie
c.SetCookie("lang", req.LanguageCode, 365*24*60*60, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{
"message": "Language preference saved",
"language": req.LanguageCode,
})
}
// AdminGetAllLanguages returns all languages (including inactive) for admin
func (ctrl *I18nController) AdminGetAllLanguages(c *gin.Context) {
var languages []models.Language
err := ctrl.db.Order("sort_order ASC, name ASC").Find(&languages).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch languages"})
return
}
c.JSON(http.StatusOK, gin.H{"languages": languages})
}
// AdminCreateLanguage creates a new language
func (ctrl *I18nController) AdminCreateLanguage(c *gin.Context) {
var req struct {
ID string `json:"id" binding:"required"`
Name string `json:"name" binding:"required"`
NativeName string `json:"native_name" binding:"required"`
Code string `json:"code" binding:"required"`
IsDefault bool `json:"is_default"`
IsActive bool `json:"is_default"`
SortOrder int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// If setting as default, unset other defaults
if req.IsDefault {
ctrl.db.Model(&models.Language{}).Where("is_default = ?", true).Update("is_default", false)
}
language := models.Language{
ID: req.ID,
Name: req.Name,
NativeName: req.NativeName,
Code: req.Code,
IsDefault: req.IsDefault,
IsActive: req.IsActive,
SortOrder: req.SortOrder,
}
err := ctrl.db.Create(&language).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create language"})
return
}
c.JSON(http.StatusCreated, gin.H{"language": language})
}
// AdminUpdateLanguage updates a language
func (ctrl *I18nController) AdminUpdateLanguage(c *gin.Context) {
id := c.Param("id")
var req struct {
Name string `json:"name"`
NativeName string `json:"native_name"`
Code string `json:"code"`
IsDefault bool `json:"is_default"`
IsActive bool `json:"is_active"`
SortOrder int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var language models.Language
err := ctrl.db.Where("id = ?", id).First(&language).Error
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Language not found"})
return
}
// If setting as default, unset other defaults
if req.IsDefault && !language.IsDefault {
ctrl.db.Model(&models.Language{}).Where("is_default = ?", true).Update("is_default", false)
}
// Update fields
if req.Name != "" {
language.Name = req.Name
}
if req.NativeName != "" {
language.NativeName = req.NativeName
}
if req.Code != "" {
language.Code = req.Code
}
language.IsDefault = req.IsDefault
language.IsActive = req.IsActive
if req.SortOrder != 0 {
language.SortOrder = req.SortOrder
}
err = ctrl.db.Save(&language).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update language"})
return
}
c.JSON(http.StatusOK, gin.H{"language": language})
}
// AdminDeleteLanguage deletes a language
func (ctrl *I18nController) AdminDeleteLanguage(c *gin.Context) {
id := c.Param("id")
var language models.Language
err := ctrl.db.Where("id = ?", id).First(&language).Error
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Language not found"})
return
}
// Don't allow deletion of default language
if language.IsDefault {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete default language"})
return
}
err = ctrl.db.Delete(&language).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete language"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Language deleted"})
}
// AdminGetTranslations returns translations for admin
func (ctrl *I18nController) AdminGetTranslations(c *gin.Context) {
languageCode := c.Query("language")
context := c.Query("context")
page := c.DefaultQuery("page", "1")
limit := c.DefaultQuery("limit", "100")
pageInt, _ := strconv.Atoi(page)
limitInt, _ := strconv.Atoi(limit)
offset := (pageInt - 1) * limitInt
var translations []models.Translation
var total int64
query := ctrl.db.Model(&models.Translation{})
if languageCode != "" {
query = query.Where("language_code = ?", languageCode)
}
if context != "" {
query = query.Where("context = ?", context)
}
// Count total
query.Count(&total)
// Get paginated results
err := query.Preload("Language").Order("key ASC, language_code ASC").
Limit(limitInt).Offset(offset).Find(&translations).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch translations"})
return
}
c.JSON(http.StatusOK, gin.H{
"translations": translations,
"total": total,
"page": pageInt,
"limit": limitInt,
})
}
// AdminCreateTranslation creates a new translation
func (ctrl *I18nController) AdminCreateTranslation(c *gin.Context) {
var req struct {
Key string `json:"key" binding:"required"`
LanguageCode string `json:"language_code" binding:"required"`
Value string `json:"value" binding:"required"`
Context string `json:"context"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
translation := models.Translation{
Key: req.Key,
LanguageCode: req.LanguageCode,
Value: req.Value,
Context: req.Context,
}
err := ctrl.db.Create(&translation).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create translation"})
return
}
c.JSON(http.StatusCreated, gin.H{"translation": translation})
}
// AdminUpdateTranslation updates a translation
func (ctrl *I18nController) AdminUpdateTranslation(c *gin.Context) {
id := c.Param("id")
var req struct {
Key string `json:"key"`
Value string `json:"value"`
Context string `json:"context"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var translation models.Translation
err := ctrl.db.Where("id = ?", id).First(&translation).Error
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Translation not found"})
return
}
if req.Key != "" {
translation.Key = req.Key
}
if req.Value != "" {
translation.Value = req.Value
}
if req.Context != "" {
translation.Context = req.Context
}
err = ctrl.db.Save(&translation).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update translation"})
return
}
c.JSON(http.StatusOK, gin.H{"translation": translation})
}
// AdminDeleteTranslation deletes a translation
func (ctrl *I18nController) AdminDeleteTranslation(c *gin.Context) {
id := c.Param("id")
err := ctrl.db.Delete(&models.Translation{}, id).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete translation"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Translation deleted"})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+132 -18
View File
@@ -1,7 +1,10 @@
package controllers
import (
"fotbal-club/internal/config"
"fotbal-club/internal/middleware"
"fotbal-club/internal/models"
"log"
"net/http"
"strconv"
@@ -13,6 +16,61 @@ type NavigationController struct {
DB *gorm.DB
}
// translateNavigationLabels translates navigation item labels using the i18n system
func (nc *NavigationController) translateNavigationLabels(items []models.NavigationItem, languageCode string) {
// Create translation helper
th := middleware.NewTranslationHelper()
// Create a mock gin context for translation (we only need the language)
c := &gin.Context{}
c.Set("language", languageCode)
// Translation map for common navigation keys
translationMap := map[string]string{
"Domů": "nav.home",
"O klubu": "nav.about",
"Kalendář": "nav.calendar",
"Zápasy": "nav.matches",
"Aktivity": "nav.activities",
"Hráči": "nav.players",
"Tabulky": "nav.tables",
"Články": "nav.articles",
"Blog": "nav.articles",
"Videa": "nav.videos",
"Galerie": "homepage.gallery",
"Sponzoři": "nav.sponsors",
"Kontakt": "nav.contact",
"Hledat": "action.search",
"Obchod": "nav.shop",
"Více": "action.more",
}
// Translate function
translateLabel := func(label string) string {
// Check if we have a mapping for this label
if key, exists := translationMap[label]; exists {
translated := th.T(c, key)
// Debug: log what we're translating
log.Printf("Translating label '%s' with key '%s' -> '%s'", label, key, translated)
// If translation is the same as key (not found), return original label
if translated == key {
return label
}
return translated
}
log.Printf("No translation key found for label '%s'", label)
return label
}
// Translate all items and their children
for i := range items {
items[i].Label = translateLabel(items[i].Label)
for j := range items[i].Children {
items[i].Children[j].Label = translateLabel(items[i].Children[j].Label)
}
}
}
func NewNavigationController(db *gorm.DB) *NavigationController {
return &NavigationController{DB: db}
}
@@ -50,6 +108,10 @@ func (nc *NavigationController) GetNavigationItems(c *gin.Context) {
}
}
// Translate navigation labels based on current language
languageCode := middleware.GetLanguage(c)
nc.translateNavigationLabels(items, languageCode)
c.JSON(http.StatusOK, items)
}
@@ -396,29 +458,20 @@ func (nc *NavigationController) GetEditorAllowedAdminNav(c *gin.Context) {
return
}
// Filter according to allow_editor rules
// Filter according to allow_editor rules only (no hardcoded page_type allow-list)
out := make([]models.NavigationItem, 0, len(top))
// Only allow a curated set of admin pages that have editor-capable APIs
allowed := map[string]bool{
"articles": true,
"activities": true,
"shortlinks": true,
}
for i := range top {
it := top[i]
include := false
if it.Type == models.NavTypeDropdown {
// Filter children by page_type allow-list (children already have allow_editor=true from preload)
if len(it.Children) > 0 {
children := make([]models.NavigationItem, 0, len(it.Children))
for _, ch := range it.Children {
if allowed[ch.PageType] {
// ensure URL is set
if ch.URL == "" {
ch.URL = ch.GetURL()
}
children = append(children, ch)
// Children are already filtered to allow_editor = true and visible = true in Preload
if ch.URL == "" {
ch.URL = ch.GetURL()
}
children = append(children, ch)
}
it.Children = children
if len(it.Children) > 0 {
@@ -426,13 +479,12 @@ func (nc *NavigationController) GetEditorAllowedAdminNav(c *gin.Context) {
}
}
} else {
// direct admin page: include only when marked allow_editor
if it.AllowEditor && allowed[it.PageType] {
// Direct admin page: include only when explicitly marked for editors and visible
if it.AllowEditor && it.Visible {
include = true
}
}
if include {
// Ensure URLs are computed
if it.URL == "" {
it.URL = it.GetURL()
}
@@ -666,7 +718,29 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
pid := parent.ID
allowEditor := false
switch pageType {
case "articles", "activities", "shortlinks":
case "dashboard",
"teams",
"matches",
"players",
"competition_aliases",
"scoreboard",
"scoreboard_remote",
"articles",
"activities",
"about",
"videos",
"gallery",
"shortlinks",
"i18n",
"financial_dashboard",
"expenses",
"invoices",
"invoice_settings",
"customers",
"eshop_products",
"tickets",
"manual_facr",
"qr_codes":
allowEditor = true
}
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true, AllowEditor: allowEditor}
@@ -788,6 +862,46 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
if err := createChild(nastroje, "Prefetch & Cache", "prefetch", 0); err != nil {
return err
}
if err := createChild(nastroje, "Manuální FACR", "manual_facr", 1); err != nil {
return err
}
if err := createChild(nastroje, "QR kódy", "qr_codes", 3); err != nil {
return err
}
finance, err := createCategory("Finance")
if err != nil {
return err
}
if err := createChild(finance, "Finanční přehled", "financial_dashboard", 0); err != nil {
return err
}
if err := createChild(finance, "Výdaje", "expenses", 1); err != nil {
return err
}
if err := createChild(finance, "Faktury", "invoices", 2); err != nil {
return err
}
if err := createChild(finance, "Nastavení faktur", "invoice_settings", 3); err != nil {
return err
}
if err := createChild(finance, "Kontakty", "customers", 4); err != nil {
return err
}
// E-shop category - only add if e-shop is enabled
if config.AppConfig.EshopEnabled {
eshop, err := createCategory("E-shop")
if err != nil {
return err
}
if err := createChild(eshop, "Produkty", "eshop_products", 0); err != nil {
return err
}
if err := createChild(eshop, "Vstupenky", "tickets", 1); err != nil {
return err
}
}
nastaveni, err := createCategory("Nastavení")
if err != nil {
+390
View File
@@ -0,0 +1,390 @@
package controllers
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"github.com/skip2/go-qrcode"
"gorm.io/gorm"
)
type QRCodeController struct {
DB *gorm.DB
}
func NewQRCodeController(db *gorm.DB) *QRCodeController {
return &QRCodeController{DB: db}
}
// Admin QR Code Management
// GetQRCodes retrieves all QR codes
func (qrc *QRCodeController) GetQRCodes(c *gin.Context) {
var qrCodes []models.QRCode
if err := qrc.DB.Order("created_at DESC").Find(&qrCodes).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve QR codes"})
return
}
c.JSON(http.StatusOK, qrCodes)
}
// GetQRCode retrieves a single QR code by ID
func (qrc *QRCodeController) GetQRCode(c *gin.Context) {
id := c.Param("id")
var qrCode models.QRCode
if err := qrc.DB.First(&qrCode, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "QR code not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve QR code"})
}
return
}
c.JSON(http.StatusOK, qrCode)
}
// CreateQRCode creates a new QR code
func (qrc *QRCodeController) CreateQRCode(c *gin.Context) {
type CreateQRRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
TargetURL string `json:"target_url" binding:"required"`
}
var req CreateQRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Generate QR code
qrCode, err := qrcode.Encode(req.TargetURL, qrcode.Medium, 256)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate QR code"})
return
}
// Convert to base64 data URL
dataURL := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(qrCode))
// Create QR code record
qrCodeRecord := models.QRCode{
Name: req.Name,
Description: req.Description,
TargetURL: req.TargetURL,
QRCodeURL: dataURL,
ScanCount: 0,
IsActive: true,
}
if err := qrc.DB.Create(&qrCodeRecord).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create QR code"})
return
}
c.JSON(http.StatusCreated, qrCodeRecord)
}
// UpdateQRCode updates an existing QR code
func (qrc *QRCodeController) UpdateQRCode(c *gin.Context) {
id := c.Param("id")
var qrCode models.QRCode
if err := qrc.DB.First(&qrCode, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "QR code not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve QR code"})
}
return
}
type UpdateQRRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
TargetURL string `json:"target_url" binding:"required"`
IsActive *bool `json:"is_active"`
}
var req UpdateQRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Regenerate QR code if URL changed
if req.TargetURL != qrCode.TargetURL {
newQRCode, err := qrcode.Encode(req.TargetURL, qrcode.Medium, 256)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate QR code"})
return
}
qrCode.QRCodeURL = fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(newQRCode))
}
// Update fields
qrCode.Name = req.Name
qrCode.Description = req.Description
qrCode.TargetURL = req.TargetURL
if req.IsActive != nil {
qrCode.IsActive = *req.IsActive
}
if err := qrc.DB.Save(&qrCode).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update QR code"})
return
}
c.JSON(http.StatusOK, qrCode)
}
// DeleteQRCode deletes a QR code
func (qrc *QRCodeController) DeleteQRCode(c *gin.Context) {
id := c.Param("id")
if err := qrc.DB.Delete(&models.QRCode{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete QR code"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "QR code deleted successfully"})
}
// TicketQRData represents the data encoded in the QR code
type TicketQRData struct {
TicketID int64 `json:"id"`
Barcode string `json:"barcode"`
Holder string `json:"holder"`
Email string `json:"email"`
Event string `json:"event"`
Type string `json:"type"`
Qty int `json:"qty"`
Price string `json:"price"`
Date string `json:"date,omitempty"`
Venue string `json:"venue,omitempty"`
Generated string `json:"generated"`
Checksum string `json:"checksum"`
}
// GET /api/v1/tickets/:id/qr - Generate QR code for a ticket
func (qrc *QRCodeController) GenerateTicketQR(c *gin.Context) {
ticketID := c.Param("id")
var ticket models.Ticket
if err := qrc.DB.Preload("Campaign").Preload("TicketType").First(&ticket, ticketID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Ticket not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch ticket"})
}
return
}
// Only allow QR codes for paid tickets
if ticket.Status != "paid" {
c.JSON(http.StatusBadRequest, gin.H{"error": "QR codes only available for paid tickets"})
return
}
// Generate QR data
qrData := TicketQRData{
TicketID: int64(ticket.ID),
Barcode: ticket.Barcode,
Holder: ticket.HolderName,
Email: ticket.HolderEmail,
Event: ticket.Campaign.Title,
Type: ticket.TicketType.Name,
Qty: ticket.Quantity,
Price: fmt.Sprintf("%.2f Kč", float64(ticket.TotalPriceCents)/100),
Generated: time.Now().Format(time.RFC3339),
Checksum: generateChecksum(ticket),
}
if ticket.Campaign.MatchDateTime != nil {
qrData.Date = ticket.Campaign.MatchDateTime.Format(time.RFC3339)
}
if ticket.Campaign.Venue != nil {
qrData.Venue = *ticket.Campaign.Venue
}
// Generate QR code as PNG
qrJSON, err := json.Marshal(qrData)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to serialize QR data"})
return
}
qrCode, err := qrcode.Encode(string(qrJSON), qrcode.Medium, 256)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate QR code"})
return
}
// Return as base64 data URL
dataURL := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(qrCode))
c.JSON(http.StatusOK, gin.H{
"qr_code": dataURL,
"data": qrData,
})
}
// POST /api/v1/tickets/validate-qr - Validate ticket from QR code data
func (qrc *QRCodeController) ValidateTicketFromQR(c *gin.Context) {
type ValidateQRRequest struct {
QRData string `json:"qr_data" binding:"required"`
UsedBy string `json:"used_by"`
}
var req ValidateQRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Parse QR data (simplified - in production, you'd parse the actual JSON)
var qrData TicketQRData
if err := parseQRData(req.QRData, &qrData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid QR code data"})
return
}
// Validate checksum
var ticket models.Ticket
if err := qrc.DB.First(&ticket, qrData.TicketID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Ticket not found"})
return
}
expectedChecksum := generateChecksum(ticket)
if expectedChecksum != qrData.Checksum {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid QR code checksum"})
return
}
// Validate ticket status
if ticket.Status != "paid" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Ticket is not paid"})
return
}
if ticket.UsedAt != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Ticket already used",
"used_at": ticket.UsedAt,
})
return
}
// Mark ticket as used
now := time.Now()
if err := qrc.DB.Model(&ticket).Updates(map[string]interface{}{
"used_at": now,
"used_by": req.UsedBy,
"status": "used",
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate ticket"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Ticket validated successfully",
"ticket": ticket,
"used_at": now,
})
}
// GET /api/v1/tickets/:id/qr-download - Download QR code as image
func (qrc *QRCodeController) DownloadTicketQR(c *gin.Context) {
ticketID := c.Param("id")
var ticket models.Ticket
if err := qrc.DB.Preload("Campaign").Preload("TicketType").First(&ticket, ticketID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Ticket not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch ticket"})
}
return
}
// Only allow QR codes for paid tickets
if ticket.Status != "paid" {
c.JSON(http.StatusBadRequest, gin.H{"error": "QR codes only available for paid tickets"})
return
}
// Generate QR data
qrData := TicketQRData{
TicketID: int64(ticket.ID),
Barcode: ticket.Barcode,
Holder: ticket.HolderName,
Email: ticket.HolderEmail,
Event: ticket.Campaign.Title,
Type: ticket.TicketType.Name,
Qty: ticket.Quantity,
Price: fmt.Sprintf("%.2f Kč", float64(ticket.TotalPriceCents)/100),
Generated: time.Now().Format(time.RFC3339),
Checksum: generateChecksum(ticket),
}
if ticket.Campaign.MatchDateTime != nil {
qrData.Date = ticket.Campaign.MatchDateTime.Format(time.RFC3339)
}
if ticket.Campaign.Venue != nil {
qrData.Venue = *ticket.Campaign.Venue
}
// Generate QR code as PNG
qrJSON, err := json.Marshal(qrData)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to serialize QR data"})
return
}
qrCode, err := qrcode.Encode(string(qrJSON), qrcode.Medium, 256)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate QR code"})
return
}
// Set headers for file download
filename := fmt.Sprintf("vstupenka-%d-%s.png", ticket.ID, ticket.Barcode)
c.Header("Content-Type", "image/png")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
c.Header("Content-Length", strconv.Itoa(len(qrCode)))
c.Data(http.StatusOK, "image/png", qrCode)
}
// Helper functions
func generateChecksum(ticket models.Ticket) string {
// Simple checksum for basic validation
checksumString := fmt.Sprintf("%d%s%s", ticket.ID, ticket.Barcode, ticket.HolderEmail)
hash := 0
for _, char := range checksumString {
hash = ((hash << 5) - hash) + int(char)
hash = hash & hash // Convert to 32-bit integer
}
return fmt.Sprintf("%x", hash)
}
func parseQRData(data string, qrData *TicketQRData) error {
// Parse JSON data from QR code
if err := json.Unmarshal([]byte(data), qrData); err != nil {
return fmt.Errorf("failed to parse QR data: %w", err)
}
return nil
}
// Generate random string for additional security
func generateRandomString(length int) string {
bytes := make([]byte, length)
rand.Read(bytes)
return hex.EncodeToString(bytes)[:length]
}
@@ -794,6 +794,8 @@ func (c *ScoreboardController) PutAdmin(ctx *gin.Context) {
s.ExternalMatchID = *payload.ExternalMatchID
}
if payload.Active != nil {
// Active flag is now derived from whether the scoreboard is linked to a match.
// The incoming value is accepted but will be normalized below.
s.Active = *payload.Active
}
if payload.SidesFlipped != nil {
@@ -814,13 +816,16 @@ func (c *ScoreboardController) PutAdmin(ctx *gin.Context) {
s.ElapsedSeconds = parseTimerToSeconds(*payload.Timer)
}
// Derive Active flag: scoreboard is considered active whenever it is linked to a match.
s.Active = strings.TrimSpace(s.ExternalMatchID) != ""
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
return
}
// Best-effort: if scoreboard active and linked to a match, write live cache
if s.Active && s.ExternalMatchID != "" {
// Best-effort: if scoreboard is linked to a match, write live cache
if s.ExternalMatchID != "" {
go writeLiveScoreboardCache(s)
}
ctx.JSON(http.StatusOK, s)
@@ -0,0 +1,282 @@
package controllers
import (
"fmt"
"net/http"
"time"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// TicketCheckoutController handles ticket purchases integrated with e-shop
type TicketCheckoutController struct {
DB *gorm.DB
}
func NewTicketCheckoutController(db *gorm.DB) *TicketCheckoutController {
return &TicketCheckoutController{DB: db}
}
// TicketCheckoutRequest represents a ticket purchase request
type TicketCheckoutRequest struct {
// Customer info
FirstName string `json:"first_name" binding:"required"`
LastName string `json:"last_name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Phone string `json:"phone"`
// Ticket reservations
TicketReservations []TicketReservationRequest `json:"ticket_reservations" binding:"required,min=1"`
// Payment method
PaymentMethod string `json:"payment_method" binding:"required,oneof=stripe gopay"`
}
type TicketReservationRequest struct {
TicketID uint `json:"ticket_id" binding:"required"`
// Note: Quantity is fixed per reservation, but we allow multiple reservations per order
}
// CreateTicketOrder creates an e-shop order from ticket reservations
func (tcc *TicketCheckoutController) CreateTicketOrder(c *gin.Context) {
var req TicketCheckoutRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get user info if logged in
userIDVal, _ := c.Get("userID")
var userID *uint
if u, ok := userIDVal.(uint); ok {
userID = &u
}
// Validate all ticket reservations exist and are available
var ticketIDs []uint
for _, tr := range req.TicketReservations {
ticketIDs = append(ticketIDs, tr.TicketID)
}
var tickets []models.Ticket
if err := tcc.DB.Where("id IN ? AND status = ?", ticketIDs, "reserved").
Preload("Campaign").Preload("TicketType").Find(&tickets).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tickets"})
return
}
if len(tickets) != len(req.TicketReservations) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Some tickets not found or not available"})
return
}
// Verify all tickets belong to the same customer (email)
for _, ticket := range tickets {
if ticket.HolderEmail != req.Email {
c.JSON(http.StatusBadRequest, gin.H{"error": "Ticket holder email doesn't match customer email"})
return
}
}
// Calculate total amount
var totalAmount int64
for _, ticket := range tickets {
totalAmount += ticket.TotalPriceCents
}
// Generate order number
orderNumber := fmt.Sprintf("%s%d", time.Now().Format("200601"), time.Now().Unix()%100000)
// Create e-shop order
ticketOrderFlag := uint(1)
order := models.EshopOrder{
OrderNumber: orderNumber,
UserID: userID,
Email: req.Email,
FirstName: req.FirstName,
LastName: req.LastName,
Status: "awaiting_payment",
TotalAmountCents: totalAmount,
Currency: "CZK", // Tickets are always in CZK for now
TicketOrder: &ticketOrderFlag,
ShippingMethod: "digital", // Tickets are digital
ShippingPriceCents: 0, // No shipping for digital tickets
}
tx := tcc.DB.Begin()
// Create order
if err := tx.Create(&order).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create order"})
return
}
// Create order items for tickets
for _, ticket := range tickets {
itemName := fmt.Sprintf("%s - %s", ticket.Campaign.Title, ticket.TicketType.Name)
if ticket.Campaign.HomeTeam != nil && ticket.Campaign.AwayTeam != nil {
itemName = fmt.Sprintf("%s %s vs %s - %s",
itemName, *ticket.Campaign.HomeTeam, *ticket.Campaign.AwayTeam, ticket.TicketType.Name)
}
orderItem := models.EshopOrderItem{
OrderID: order.ID,
Name: itemName,
Quantity: ticket.Quantity,
UnitPriceCents: ticket.UnitPriceCents,
Currency: ticket.Currency,
VATRate: 0.21, // 21% VAT for tickets
TicketID: &ticket.ID, // Link to ticket
}
if err := tx.Create(&orderItem).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create order item"})
return
}
// Update ticket to link with order
if err := tx.Model(&ticket).Update("order_id", order.ID).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to link ticket to order"})
return
}
}
tx.Commit()
// Create payment based on method
var paymentResult interface{}
var err error
switch req.PaymentMethod {
case "stripe":
paymentResult, err = tcc.createStripePayment(&order)
case "gopay":
paymentResult, err = tcc.createGoPayPayment(&order)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported payment method"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create payment", "details": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"order": order,
"payment": paymentResult,
})
}
// CompleteTicketOrder handles successful payment completion for tickets
func (tcc *TicketCheckoutController) CompleteTicketOrder(c *gin.Context) {
orderID := c.Param("order_id")
var order models.EshopOrder
if err := tcc.DB.Where("id = ? AND ticket_order = ?", orderID, true).First(&order).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Ticket order not found"})
return
}
if order.Status != "paid" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Order not paid"})
return
}
// Get all tickets for this order
var tickets []models.Ticket
if err := tcc.DB.Where("order_id = ?", order.ID).Find(&tickets).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tickets"})
return
}
// Confirm all tickets
tx := tcc.DB.Begin()
for _, ticket := range tickets {
// Update ticket status to paid
if err := tx.Model(&ticket).Updates(map[string]interface{}{
"status": "paid",
}).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to confirm ticket"})
return
}
// Update availability
if err := tx.Model(&models.TicketAvailability{}).
Where("campaign_id = ? AND ticket_type_id = ?", ticket.CampaignID, ticket.TicketTypeID).
Updates(map[string]interface{}{
"sold_quantity": gorm.Expr("sold_quantity + ?", ticket.Quantity),
"reserved_quantity": gorm.Expr("reserved_quantity - ?", ticket.Quantity),
}).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update availability"})
return
}
// Send ticket email (async)
go func(t models.Ticket) {
// TODO: Implement ticket confirmation email with barcode/QR
// For now, just log the action
fmt.Printf("Ticket confirmation email sent to %s for ticket ID %d\n", t.HolderEmail, t.ID)
}(ticket)
}
tx.Commit()
c.JSON(http.StatusOK, gin.H{
"message": "Ticket order completed successfully",
"order": order,
"tickets": tickets,
})
}
// GetTicketOrders returns all ticket orders for a user
func (tcc *TicketCheckoutController) GetTicketOrders(c *gin.Context) {
userIDVal, _ := c.Get("userID")
userID, ok := userIDVal.(uint)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var orders []models.EshopOrder
if err := tcc.DB.Where("user_id = ? AND ticket_order = ?", userID, true).
Preload("Items.Ticket").
Preload("Items.Ticket.Campaign").
Preload("Items.Ticket.TicketType").
Order("created_at DESC").
Find(&orders).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch ticket orders"})
return
}
c.JSON(http.StatusOK, orders)
}
// Helper methods for payment creation
func (tcc *TicketCheckoutController) createStripePayment(order *models.EshopOrder) (interface{}, error) {
// This would integrate with the existing Stripe service
// For now, return a mock response
return gin.H{
"payment_intent_id": "pi_mock_" + order.OrderNumber,
"client_secret": "pi_mock_secret_" + order.OrderNumber,
}, nil
}
func (tcc *TicketCheckoutController) createGoPayPayment(order *models.EshopOrder) (interface{}, error) {
// This would integrate with the existing GoPay service
// For now, return a mock response
return gin.H{
"payment_id": fmt.Sprintf("gopay_%d", order.ID),
"redirect_url": fmt.Sprintf("https://gate.gopay.cz/gw/v3/payment/%d", order.ID),
}, nil
}
+590
View File
@@ -0,0 +1,590 @@
package controllers
import (
"fmt"
"net/http"
"strconv"
"time"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type TicketController struct {
DB *gorm.DB
}
func NewTicketController(db *gorm.DB) *TicketController {
return &TicketController{DB: db}
}
// Local type definitions for API responses
type AvailableTicketTypeResponse struct {
TicketType models.TicketType `json:"ticket_type"`
PriceCents int64 `json:"price_cents"`
MaxQuantity *int `json:"max_quantity"`
AvailableQuantity int `json:"available_quantity"`
TotalCapacity int `json:"total_capacity"`
SaleStatus string `json:"sale_status"`
}
type CampaignTicketTypeRequest struct {
TicketTypeID uint `json:"ticket_type_id" binding:"required"`
PriceCents *int64 `json:"price_cents"`
MaxQuantity *int `json:"max_quantity"`
Capacity int `json:"capacity" binding:"required,min=0"`
}
// GET /api/v1/tickets/campaigns - List all active ticket campaigns
func (tc *TicketController) GetCampaigns(c *gin.Context) {
var campaigns []models.TicketCampaign
query := tc.DB.Preload("CampaignTicketTypes.TicketType").
Preload("TicketTypes").
Where("active = ? AND deleted_at IS NULL", true)
// Filter by match if specified
if matchID := c.Query("match_id"); matchID != "" {
query = query.Where("external_match_id = ?", matchID)
}
// Filter by date range
if from := c.Query("from"); from != "" {
if fromDate, err := time.Parse("2006-01-02", from); err == nil {
query = query.Where("match_date_time >= ?", fromDate)
}
}
if to := c.Query("to"); to != "" {
if toDate, err := time.Parse("2006-01-02", to); err == nil {
query = query.Where("match_date_time <= ?", toDate)
}
}
if err := query.Order("match_date_time ASC").Find(&campaigns).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch campaigns"})
return
}
c.JSON(http.StatusOK, campaigns)
}
// GET /api/v1/tickets/campaigns/:id - Get specific campaign with availability
func (tc *TicketController) GetCampaign(c *gin.Context) {
id := c.Param("id")
var campaign models.TicketCampaign
if err := tc.DB.Preload("CampaignTicketTypes.TicketType").
Preload("TicketTypes").
Where("id = ? AND active = ? AND deleted_at IS NULL", id, true).
First(&campaign).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Campaign not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch campaign"})
}
return
}
// Load availability for each ticket type
var availabilities []models.TicketAvailability
tc.DB.Where("campaign_id = ?", campaign.ID).Find(&availabilities)
availabilityMap := make(map[uint]models.TicketAvailability)
for _, avail := range availabilities {
availabilityMap[avail.TicketTypeID] = avail
}
// Build response with availability
type CampaignResponse struct {
models.TicketCampaign
AvailableTickets []AvailableTicketTypeResponse `json:"available_tickets"`
}
response := CampaignResponse{TicketCampaign: campaign}
for _, ctt := range campaign.CampaignTicketTypes {
avail := availabilityMap[ctt.TicketTypeID]
price := ctt.PriceCents
if price == nil {
price = &ctt.TicketType.PriceCents
}
maxQty := ctt.MaxQuantity
if maxQty == nil {
maxQty = &ctt.TicketType.MaxTicketsPerOrder
}
availableQty := avail.TotalCapacity - avail.SoldQuantity - avail.ReservedQuantity
saleStatus := "available"
if time.Now().Before(campaign.SaleStartTime) {
saleStatus = "upcoming"
} else if time.Now().After(campaign.SaleEndTime) {
saleStatus = "ended"
} else if availableQty <= 0 {
saleStatus = "sold_out"
}
response.AvailableTickets = append(response.AvailableTickets, AvailableTicketTypeResponse{
TicketType: ctt.TicketType,
PriceCents: *price,
MaxQuantity: maxQty,
AvailableQuantity: availableQty,
TotalCapacity: avail.TotalCapacity,
SaleStatus: saleStatus,
})
}
c.JSON(http.StatusOK, response)
}
// GET /api/v1/tickets/available - Get available tickets for public
func (tc *TicketController) GetAvailableTickets(c *gin.Context) {
var tickets []models.AvailableTicketView
query := tc.DB.Where("sale_status = ?", "available")
// Filter by match if specified
if matchID := c.Query("match_id"); matchID != "" {
query = query.Where("external_match_id = ?", matchID)
}
// Filter by competition if specified
if competition := c.Query("competition"); competition != "" {
query = query.Where("competition_code = ?", competition)
}
if err := query.Order("match_date_time ASC, campaign_id ASC, display_order ASC").Find(&tickets).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch available tickets"})
return
}
c.JSON(http.StatusOK, tickets)
}
// POST /api/v1/tickets/reserve - Reserve tickets (before payment)
func (tc *TicketController) ReserveTickets(c *gin.Context) {
type ReserveRequest struct {
CampaignID uint `json:"campaign_id" binding:"required"`
TicketTypeID uint `json:"ticket_type_id" binding:"required"`
Quantity int `json:"quantity" binding:"required,min=1"`
HolderName string `json:"holder_name" binding:"required"`
HolderEmail string `json:"holder_email" binding:"required,email"`
HolderPhone string `json:"holder_phone"`
}
var req ReserveRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate campaign and ticket type
var campaign models.TicketCampaign
if err := tc.DB.Where("id = ? AND active = ? AND deleted_at IS NULL", req.CampaignID, true).First(&campaign).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Campaign not found"})
return
}
// Check sale time window
now := time.Now()
if now.Before(campaign.SaleStartTime) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Sale has not started yet"})
return
}
if now.After(campaign.SaleEndTime) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Sale has ended"})
return
}
// Get campaign ticket type with overrides
var ctt models.CampaignTicketType
if err := tc.DB.Where("campaign_id = ? AND ticket_type_id = ?", req.CampaignID, req.TicketTypeID).
Preload("TicketType").Preload("Campaign").First(&ctt).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Ticket type not found in this campaign"})
return
}
// Check quantity limits
maxQty := ctt.MaxQuantity
if maxQty == nil {
maxQty = &ctt.TicketType.MaxTicketsPerOrder
}
if req.Quantity > *maxQty {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Maximum %d tickets per order", *maxQty)})
return
}
// Check availability
var availability models.TicketAvailability
if err := tc.DB.Where("campaign_id = ? AND ticket_type_id = ?", req.CampaignID, req.TicketTypeID).First(&availability).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Availability not found"})
return
}
availableQty := availability.TotalCapacity - availability.SoldQuantity - availability.ReservedQuantity
if req.Quantity > availableQty {
c.JSON(http.StatusBadRequest, gin.H{"error": "Not enough tickets available"})
return
}
// Get price
price := ctt.PriceCents
if price == nil {
price = &ctt.TicketType.PriceCents
}
// Create reservation
ticket := models.Ticket{
CampaignID: req.CampaignID,
TicketTypeID: req.TicketTypeID,
HolderName: req.HolderName,
HolderEmail: req.HolderEmail,
HolderPhone: req.HolderPhone,
Quantity: req.Quantity,
UnitPriceCents: *price,
TotalPriceCents: int64(req.Quantity) * *price,
Currency: ctt.TicketType.Currency,
Status: "reserved",
}
// Use transaction for atomic operations
tx := tc.DB.Begin()
// Create ticket
if err := tx.Create(&ticket).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create reservation"})
return
}
// Update availability
if err := tx.Model(&availability).Update("reserved_quantity", gorm.Expr("reserved_quantity + ?", req.Quantity)).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update availability"})
return
}
tx.Commit()
// Send confirmation email (async)
go func() {
// TODO: Implement ticket reservation email template
// For now, just log the action
fmt.Printf("Ticket reservation email sent to %s for ticket ID %d\n", req.HolderEmail, ticket.ID)
}()
c.JSON(http.StatusCreated, ticket)
}
// POST /api/v1/tickets/:id/confirm - Confirm ticket reservation (after payment)
func (tc *TicketController) ConfirmTicket(c *gin.Context) {
id := c.Param("id")
var ticket models.Ticket
if err := tc.DB.Where("id = ? AND status = ?", id, "reserved").First(&ticket).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Ticket reservation not found"})
return
}
// Update ticket status
if err := tc.DB.Model(&ticket).Updates(map[string]interface{}{
"status": "paid",
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to confirm ticket"})
return
}
// Update availability
if err := tc.DB.Model(&models.TicketAvailability{}).
Where("campaign_id = ? AND ticket_type_id = ?", ticket.CampaignID, ticket.TicketTypeID).
Updates(map[string]interface{}{
"sold_quantity": gorm.Expr("sold_quantity + ?", ticket.Quantity),
"reserved_quantity": gorm.Expr("reserved_quantity - ?", ticket.Quantity),
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update availability"})
return
}
// Send ticket email (async)
go func() {
// TODO: Implement ticket confirmation email with barcode/QR
// For now, just log the action
fmt.Printf("Ticket confirmation email sent to %s for ticket ID %d\n", ticket.HolderEmail, ticket.ID)
}()
c.JSON(http.StatusOK, gin.H{"message": "Ticket confirmed", "ticket": ticket})
}
// POST /api/v1/tickets/:id/validate - Validate ticket (for entry)
func (tc *TicketController) ValidateTicket(c *gin.Context) {
type ValidateRequest struct {
Barcode string `json:"barcode" binding:"required"`
UsedBy string `json:"used_by"`
}
var req ValidateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var ticket models.Ticket
if err := tc.DB.Where("barcode = ? AND status = ?", req.Barcode, "paid").First(&ticket).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Ticket not found or already used"})
return
}
// Check if already used
if ticket.UsedAt != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Ticket already used", "used_at": ticket.UsedAt})
return
}
// Mark as used
now := time.Now()
if err := tc.DB.Model(&ticket).Updates(map[string]interface{}{
"used_at": now,
"used_by": req.UsedBy,
"status": "used",
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate ticket"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Ticket validated successfully",
"ticket": ticket,
"used_at": now,
})
}
// GET /api/v1/admin/tickets/campaigns - Admin: List all campaigns
func (tc *TicketController) AdminGetCampaigns(c *gin.Context) {
var campaigns []models.TicketCampaign
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset := (page - 1) * limit
if err := tc.DB.Preload("CampaignTicketTypes.TicketType").
Order("created_at DESC").
Limit(limit).Offset(offset).
Find(&campaigns).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch campaigns"})
return
}
var total int64
tc.DB.Model(&models.TicketCampaign{}).Count(&total)
c.JSON(http.StatusOK, gin.H{
"campaigns": campaigns,
"total": total,
"page": page,
"limit": limit,
})
}
// POST /api/v1/admin/tickets/campaigns - Admin: Create campaign
func (tc *TicketController) AdminCreateCampaign(c *gin.Context) {
type CreateCampaignRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
ExternalMatchID *string `json:"external_match_id"`
CompetitionCode *string `json:"competition_code"`
MatchDateTime *time.Time `json:"match_date_time"`
HomeTeam *string `json:"home_team"`
AwayTeam *string `json:"away_team"`
Venue *string `json:"venue"`
SaleStartTime time.Time `json:"sale_start_time" binding:"required"`
SaleEndTime time.Time `json:"sale_end_time" binding:"required"`
MaxTotalTickets *int `json:"max_total_tickets"`
TicketTypes []CampaignTicketTypeRequest `json:"ticket_types" binding:"required"`
}
type CampaignTicketTypeRequest struct {
TicketTypeID uint `json:"ticket_type_id" binding:"required"`
PriceCents *int64 `json:"price_cents"`
MaxQuantity *int `json:"max_quantity"`
Capacity int `json:"capacity" binding:"required,min=0"`
}
var req CreateCampaignRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate time window
if req.SaleEndTime.Before(req.SaleStartTime) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Sale end time must be after start time"})
return
}
// Create campaign
campaign := models.TicketCampaign{
Title: req.Title,
Description: req.Description,
ExternalMatchID: req.ExternalMatchID,
CompetitionCode: req.CompetitionCode,
MatchDateTime: req.MatchDateTime,
HomeTeam: req.HomeTeam,
AwayTeam: req.AwayTeam,
Venue: req.Venue,
SaleStartTime: req.SaleStartTime,
SaleEndTime: req.SaleEndTime,
MaxTotalTickets: req.MaxTotalTickets,
Active: true,
}
tx := tc.DB.Begin()
if err := tx.Create(&campaign).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create campaign"})
return
}
// Create campaign ticket types and availability
for _, ttReq := range req.TicketTypes {
// Verify ticket type exists
var ticketType models.TicketType
if err := tx.First(&ticketType, ttReq.TicketTypeID).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Ticket type %d not found", ttReq.TicketTypeID)})
return
}
// Create campaign ticket type
ctt := models.CampaignTicketType{
CampaignID: campaign.ID,
TicketTypeID: ttReq.TicketTypeID,
PriceCents: ttReq.PriceCents,
MaxQuantity: ttReq.MaxQuantity,
}
if err := tx.Create(&ctt).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create campaign ticket type"})
return
}
// Create availability
availability := models.TicketAvailability{
CampaignID: campaign.ID,
TicketTypeID: ttReq.TicketTypeID,
TotalCapacity: ttReq.Capacity,
SoldQuantity: 0,
ReservedQuantity: 0,
}
if err := tx.Create(&availability).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create availability"})
return
}
}
tx.Commit()
// Load full campaign for response
tc.DB.Preload("CampaignTicketTypes.TicketType").Preload("TicketTypes").First(&campaign, campaign.ID)
c.JSON(http.StatusCreated, campaign)
}
// GET /api/v1/admin/tickets/types - Admin: List ticket types
func (tc *TicketController) AdminGetTicketTypes(c *gin.Context) {
var types []models.TicketType
if err := tc.DB.Where("deleted_at IS NULL").Order("display_order ASC").Find(&types).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch ticket types"})
return
}
c.JSON(http.StatusOK, types)
}
// POST /api/v1/admin/tickets/types - Admin: Create ticket type
func (tc *TicketController) AdminCreateTicketType(c *gin.Context) {
var ticketType models.TicketType
if err := c.ShouldBindJSON(&ticketType); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := tc.DB.Create(&ticketType).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create ticket type"})
return
}
c.JSON(http.StatusCreated, ticketType)
}
// GET /api/v1/tickets/my-tickets - Get current user's tickets
func (tc *TicketController) GetMyTickets(c *gin.Context) {
userIDVal, _ := c.Get("userID")
userID, ok := userIDVal.(uint)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var tickets []models.Ticket
if err := tc.DB.Where("holder_email IN (SELECT email FROM users WHERE id = ?)", userID).
Preload("Campaign").
Preload("TicketType").
Order("created_at DESC").
Find(&tickets).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tickets"})
return
}
c.JSON(http.StatusOK, tickets)
}
// Additional admin methods for routes
func (tc *TicketController) AdminUpdateCampaign(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminDeleteCampaign(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminGetTicketType(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminUpdateTicketType(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminDeleteTicketType(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminGetTickets(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminGetTicket(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminUpdateTicketStatus(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminValidateTicket(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminGetSalesOverview(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminExportSales(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
+145
View File
@@ -0,0 +1,145 @@
package controllers
import (
"net/http"
"time"
"fotbal-club/internal/services"
"github.com/gin-gonic/gin"
)
type AdminWeatherController struct {
weatherService *services.WeatherService
}
func NewAdminWeatherController(weatherService *services.WeatherService) *AdminWeatherController {
return &AdminWeatherController{
weatherService: weatherService,
}
}
// GetWeather returns weather information for the club location
func (wc *AdminWeatherController) GetWeather(c *gin.Context) {
// Get location from query parameter or use club location
location := c.Query("location")
weather, err := wc.weatherService.GetWeatherByLocation(location)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to fetch weather data",
"details": err.Error(),
})
return
}
// Transform the response to include absolute icon URLs
if weather.Current.Condition.Icon != "" {
weather.Current.Condition.Icon = wc.weatherService.GetWeatherIconURL(weather.Current.Condition.Icon)
}
// Update forecast day icons
for i := range weather.Forecast.ForecastDay {
if weather.Forecast.ForecastDay[i].Day.Condition.Icon != "" {
weather.Forecast.ForecastDay[i].Day.Condition.Icon = wc.weatherService.GetWeatherIconURL(weather.Forecast.ForecastDay[i].Day.Condition.Icon)
}
// Update hour icons
for j := range weather.Forecast.ForecastDay[i].Hour {
if weather.Forecast.ForecastDay[i].Hour[j].Condition.Icon != "" {
weather.Forecast.ForecastDay[i].Hour[j].Condition.Icon = wc.weatherService.GetWeatherIconURL(weather.Forecast.ForecastDay[i].Hour[j].Condition.Icon)
}
}
}
c.JSON(http.StatusOK, weather)
}
// GetWeatherForClub returns weather for the configured club location
func (wc *AdminWeatherController) GetWeatherForClub(c *gin.Context) {
weather, err := wc.weatherService.GetWeatherForClub()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to fetch weather data for club",
"details": err.Error(),
})
return
}
// Transform the response to include absolute icon URLs
if weather.Current.Condition.Icon != "" {
weather.Current.Condition.Icon = wc.weatherService.GetWeatherIconURL(weather.Current.Condition.Icon)
}
// Update forecast day icons
for i := range weather.Forecast.ForecastDay {
if weather.Forecast.ForecastDay[i].Day.Condition.Icon != "" {
weather.Forecast.ForecastDay[i].Day.Condition.Icon = wc.weatherService.GetWeatherIconURL(weather.Forecast.ForecastDay[i].Day.Condition.Icon)
}
}
c.JSON(http.StatusOK, weather)
}
// GetWeatherForMatch returns weather forecast for a specific match time and location
func (wc *AdminWeatherController) GetWeatherForMatch(c *gin.Context) {
// Get match datetime and location from query parameters
matchDateTime := c.Query("match_datetime")
location := c.Query("location")
if matchDateTime == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "match_datetime parameter is required",
})
return
}
weather, err := wc.weatherService.GetWeatherForMatch(matchDateTime, location)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to fetch weather data for match",
"details": err.Error(),
})
return
}
// Transform the response to include absolute icon URLs
if weather.Current.Condition.Icon != "" {
weather.Current.Condition.Icon = wc.weatherService.GetWeatherIconURL(weather.Current.Condition.Icon)
}
// Update forecast day icons
for i := range weather.Forecast.ForecastDay {
if weather.Forecast.ForecastDay[i].Day.Condition.Icon != "" {
weather.Forecast.ForecastDay[i].Day.Condition.Icon = wc.weatherService.GetWeatherIconURL(weather.Forecast.ForecastDay[i].Day.Condition.Icon)
}
// Update hour icons
for j := range weather.Forecast.ForecastDay[i].Hour {
if weather.Forecast.ForecastDay[i].Hour[j].Condition.Icon != "" {
weather.Forecast.ForecastDay[i].Hour[j].Condition.Icon = wc.weatherService.GetWeatherIconURL(weather.Forecast.ForecastDay[i].Hour[j].Condition.Icon)
}
}
}
// Find the closest hourly forecast to the match time
matchTime, err := time.Parse("2006-01-02T15:04:05", matchDateTime)
if err != nil {
// Try alternative format
matchTime, err = time.Parse("2006-01-02 15:04:05", matchDateTime)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid match_datetime format",
})
return
}
}
closestHour := wc.weatherService.FindClosestHourlyForecast(weather, matchTime)
response := gin.H{
"weather": weather,
"match_time": matchDateTime,
"closest_hour": closestHour,
}
c.JSON(http.StatusOK, response)
}