mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
hot fix #1
This commit is contained in:
@@ -89,6 +89,40 @@ type Config struct {
|
||||
|
||||
// Feature flags
|
||||
RembgEnabled bool
|
||||
// Club data mode: "auto" (FACR integration) or "manual" (no FACR/http scraping)
|
||||
ClubDataMode string
|
||||
|
||||
// E-shop settings
|
||||
EshopEnabled bool
|
||||
EshopFrontendURL string
|
||||
EshopAPIURL string
|
||||
|
||||
// Stripe (E-shop payments)
|
||||
StripeEnabled bool
|
||||
StripeSecretKey string
|
||||
StripePublishableKey string
|
||||
StripeWebhookSecret string
|
||||
StripeCurrency string
|
||||
StripeReturnURL string
|
||||
|
||||
// Revolut (E-shop payments)
|
||||
RevolutEnabled bool
|
||||
RevolutEnvironment string
|
||||
RevolutAPIKey string
|
||||
RevolutPublicKey string
|
||||
RevolutWebhookSecret string
|
||||
RevolutReturnURL string
|
||||
RevolutWebhookURL string
|
||||
|
||||
// Packeta / Zasilkovna
|
||||
PacketaAPIPassword string
|
||||
PacketaWidgetAPIKey string
|
||||
PacketaEshopName string
|
||||
PacketaEnv string
|
||||
|
||||
// DeepSeek AI Support
|
||||
DeepSeekAPIKey string
|
||||
DeepSeekBaseURL string
|
||||
}
|
||||
|
||||
var AppConfig *Config
|
||||
@@ -200,6 +234,40 @@ func LoadConfig() {
|
||||
|
||||
// Feature flags
|
||||
RembgEnabled: getEnvAsBool("REMBG_ENABLED", true),
|
||||
// Club data mode: auto (FACR integration) or manual (no FACR/http scraping)
|
||||
ClubDataMode: strings.ToLower(strings.TrimSpace(getEnv("CLUB_DATA_MODE", "auto"))),
|
||||
|
||||
// E-shop settings
|
||||
EshopEnabled: getEnvAsBool("ESHOP_ENABLED", false),
|
||||
EshopFrontendURL: getEnv("ESHOP_FRONTEND_URL", ""),
|
||||
EshopAPIURL: getEnv("ESHOP_API_URL", ""),
|
||||
|
||||
// Stripe (E-shop payments)
|
||||
StripeEnabled: getEnvAsBool("STRIPE_ENABLED", false),
|
||||
StripeSecretKey: getEnv("STRIPE_SECRET_KEY", ""),
|
||||
StripePublishableKey: getEnv("STRIPE_PUBLISHABLE_KEY", ""),
|
||||
StripeWebhookSecret: getEnv("STRIPE_WEBHOOK_SECRET", ""),
|
||||
StripeCurrency: getEnv("STRIPE_CURRENCY", "CZK"),
|
||||
StripeReturnURL: getEnv("STRIPE_RETURN_URL", ""),
|
||||
|
||||
// Revolut (E-shop payments)
|
||||
RevolutEnabled: getEnvAsBool("REVOLUT_ENABLED", false),
|
||||
RevolutEnvironment: getEnv("REVOLUT_ENVIRONMENT", "sandbox"),
|
||||
RevolutAPIKey: getEnv("REVOLUT_API_KEY", ""),
|
||||
RevolutPublicKey: getEnv("REVOLUT_PUBLIC_KEY", ""),
|
||||
RevolutWebhookSecret: getEnv("REVOLUT_WEBHOOK_SECRET", ""),
|
||||
RevolutReturnURL: getEnv("REVOLUT_RETURN_URL", ""),
|
||||
RevolutWebhookURL: getEnv("REVOLUT_WEBHOOK_URL", ""),
|
||||
|
||||
// Packeta / Zasilkovna
|
||||
PacketaAPIPassword: getEnv("PACKETA_API_PASSWORD", ""),
|
||||
PacketaWidgetAPIKey: getEnv("PACKETA_WIDGET_API_KEY", ""),
|
||||
PacketaEshopName: getEnv("PACKETA_ESHP_NAME", "MyClubEshop"),
|
||||
PacketaEnv: getEnv("PACKETA_ENV", "test"),
|
||||
|
||||
// DeepSeek AI Support (used by e-shop support chat)
|
||||
DeepSeekAPIKey: getEnv("DEEPSEEK_API_KEY", ""),
|
||||
DeepSeekBaseURL: getEnv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
|
||||
}
|
||||
|
||||
// Override allowed origins if specified in environment (comma-separated)
|
||||
|
||||
+2426
-213
File diff suppressed because it is too large
Load Diff
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(¤tSettings, 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(¤tSettings, 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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -157,7 +157,13 @@ func RoleAuth(requiredRole string) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has the required role
|
||||
// Editors are allowed to access routes that require either "editor" or "admin"
|
||||
if userRole == "editor" && (requiredRole == "editor" || requiredRole == "admin") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has the required role exactly
|
||||
if userRole != requiredRole {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
|
||||
c.Abort()
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/pkg/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// I18nMiddleware handles internationalization
|
||||
type I18nMiddleware struct {
|
||||
db *gorm.DB
|
||||
defaultLang *models.Language
|
||||
}
|
||||
|
||||
// NewI18nMiddleware creates a new i18n middleware
|
||||
func NewI18nMiddleware() (*I18nMiddleware, error) {
|
||||
db := database.GetDB()
|
||||
|
||||
// Get default language
|
||||
defaultLang, err := models.GetDefaultLanguage(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &I18nMiddleware{
|
||||
db: db,
|
||||
defaultLang: defaultLang,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Middleware returns the Gin middleware function
|
||||
func (m *I18nMiddleware) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Try to get language from various sources in priority order
|
||||
|
||||
// 1. User preference (if authenticated)
|
||||
if userID, exists := c.Get("user_id"); exists {
|
||||
if userPref, err := m.getUserLanguage(userID.(uint)); err == nil {
|
||||
c.Set("language", userPref.LanguageCode)
|
||||
c.Set("language_name", userPref.Language.Name)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. URL parameter (?lang=en)
|
||||
if lang := c.Query("lang"); lang != "" && m.isValidLanguage(lang) {
|
||||
c.Set("language", lang)
|
||||
c.Set("language_name", m.getLanguageName(lang))
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Cookie
|
||||
if cookie, err := c.Cookie("lang"); err == nil && m.isValidLanguage(cookie) {
|
||||
c.Set("language", cookie)
|
||||
c.Set("language_name", m.getLanguageName(cookie))
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Accept-Language header
|
||||
if header := c.GetHeader("Accept-Language"); header != "" {
|
||||
if lang := m.parseAcceptLanguage(header); lang != "" {
|
||||
c.Set("language", lang)
|
||||
c.Set("language_name", m.getLanguageName(lang))
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Default language
|
||||
c.Set("language", m.defaultLang.Code)
|
||||
c.Set("language_name", m.defaultLang.Name)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// getUserLanguage gets user's preferred language from database
|
||||
func (m *I18nMiddleware) getUserLanguage(userID uint) (*models.UserLanguagePreference, error) {
|
||||
var pref models.UserLanguagePreference
|
||||
err := m.db.Where("user_id = ?", userID).Preload("Language").First(&pref).Error
|
||||
return &pref, err
|
||||
}
|
||||
|
||||
// isValidLanguage checks if language code is valid and active
|
||||
func (m *I18nMiddleware) isValidLanguage(code string) bool {
|
||||
var count int64
|
||||
m.db.Model(&models.Language{}).Where("code = ? AND is_active = ?", code, true).Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// getLanguageName returns the name of a language
|
||||
func (m *I18nMiddleware) getLanguageName(code string) string {
|
||||
var lang models.Language
|
||||
err := m.db.Where("code = ?", code).First(&lang).Error
|
||||
if err != nil {
|
||||
return m.defaultLang.Name
|
||||
}
|
||||
return lang.Name
|
||||
}
|
||||
|
||||
// parseAcceptLanguage parses Accept-Language header and returns best match
|
||||
func (m *I18nMiddleware) parseAcceptLanguage(header string) string {
|
||||
// Simple implementation - split by comma and take first
|
||||
// In production, you'd want to parse q-values
|
||||
langs := strings.Split(header, ",")
|
||||
for _, lang := range langs {
|
||||
lang = strings.TrimSpace(strings.Split(lang, ";")[0])
|
||||
// Convert en-US to en, de-DE to de, etc.
|
||||
if len(lang) > 2 {
|
||||
lang = lang[:2]
|
||||
}
|
||||
if m.isValidLanguage(lang) {
|
||||
return lang
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetLanguage returns the current language code from context
|
||||
func GetLanguage(c *gin.Context) string {
|
||||
if lang, exists := c.Get("language"); exists {
|
||||
return lang.(string)
|
||||
}
|
||||
return "cs" // fallback to Czech
|
||||
}
|
||||
|
||||
// GetLanguageName returns the current language name from context
|
||||
func GetLanguageName(c *gin.Context) string {
|
||||
if name, exists := c.Get("language_name"); exists {
|
||||
return name.(string)
|
||||
}
|
||||
return "Čeština"
|
||||
}
|
||||
|
||||
// SetLanguageCookie sets the language preference in a cookie
|
||||
func SetLanguageCookie(c *gin.Context, languageCode string) {
|
||||
c.SetCookie("lang", languageCode, 365*24*60*60, "/", "", false, true)
|
||||
}
|
||||
|
||||
// TranslationHelper provides translation functions
|
||||
type TranslationHelper struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewTranslationHelper creates a new translation helper
|
||||
func NewTranslationHelper() *TranslationHelper {
|
||||
return &TranslationHelper{
|
||||
db: database.GetDB(),
|
||||
}
|
||||
}
|
||||
|
||||
// T translates a key using the current language context
|
||||
func (th *TranslationHelper) T(c *gin.Context, key string) string {
|
||||
lang := GetLanguage(c)
|
||||
|
||||
translation, err := models.GetTranslationWithFallback(th.db, key, lang)
|
||||
if err != nil {
|
||||
// Return key if translation not found
|
||||
return key
|
||||
}
|
||||
|
||||
return translation.Value
|
||||
}
|
||||
|
||||
// TWithParams translates a key with parameters
|
||||
func (th *TranslationHelper) TWithParams(c *gin.Context, key string, params map[string]interface{}) string {
|
||||
result := th.T(c, key)
|
||||
|
||||
// Simple parameter replacement
|
||||
for k, v := range params {
|
||||
result = strings.ReplaceAll(result, "{{"+k+"}}", v.(string))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"fotbal-club/pkg/logger"
|
||||
"fotbal-club/internal/services"
|
||||
"fotbal-club/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -18,27 +18,28 @@ func CustomRecovery() gin.HandlerFunc {
|
||||
if err := recover(); err != nil {
|
||||
// Get stack trace
|
||||
stack := string(debug.Stack())
|
||||
|
||||
|
||||
// Log the panic
|
||||
requestID := GetRequestID(c)
|
||||
logger.Error("Panic recovered",
|
||||
"request_id", requestID,
|
||||
"error", fmt.Sprintf("%v", err),
|
||||
"stack", stack,
|
||||
"path", c.Request.URL.Path,
|
||||
"method", c.Request.Method,
|
||||
)
|
||||
|
||||
logger.Error(fmt.Sprintf(
|
||||
"Panic recovered: request_id=%s error=%v path=%s method=%s stack=%s",
|
||||
requestID,
|
||||
err,
|
||||
c.Request.URL.Path,
|
||||
c.Request.Method,
|
||||
stack,
|
||||
))
|
||||
|
||||
// Return error response
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Internal server error",
|
||||
"request_id": requestID,
|
||||
})
|
||||
|
||||
|
||||
c.Abort()
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -52,13 +53,14 @@ func CustomRecoveryWithReporter(reporter *services.ErrorReporter) gin.HandlerFun
|
||||
if err := recover(); err != nil {
|
||||
stack := string(debug.Stack())
|
||||
requestID := GetRequestID(c)
|
||||
logger.Error("Panic recovered",
|
||||
"request_id", requestID,
|
||||
"error", fmt.Sprintf("%v", err),
|
||||
"stack", stack,
|
||||
"path", c.Request.URL.Path,
|
||||
"method", c.Request.Method,
|
||||
)
|
||||
logger.Error(fmt.Sprintf(
|
||||
"Panic recovered: request_id=%s error=%v path=%s method=%s stack=%s",
|
||||
requestID,
|
||||
err,
|
||||
c.Request.URL.Path,
|
||||
c.Request.Method,
|
||||
stack,
|
||||
))
|
||||
reporter.Report(c.Request.Context(), &services.ErrorEvent{
|
||||
Origin: "backend",
|
||||
Language: "go",
|
||||
|
||||
@@ -45,6 +45,10 @@ func ValidateContentType() gin.HandlerFunc {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if strings.Contains(path, "/admin/manual/matches/import") || strings.Contains(path, "/admin/manual/tables/import") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Allow scoreboard timer control endpoints without requiring JSON body
|
||||
// These actions do not read request body and are triggered via simple POSTs from remote UI
|
||||
@@ -57,6 +61,11 @@ func ValidateContentType() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(path, "/admin/prefetch/trigger") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(path, "/rembg/start") {
|
||||
c.Next()
|
||||
return
|
||||
|
||||
@@ -2,8 +2,8 @@ package models
|
||||
|
||||
type CommentReaction struct {
|
||||
BaseModel
|
||||
CommentID uint `json:"comment_id" gorm:"index;not null"`
|
||||
UserID uint `json:"user_id" gorm:"index;not null"`
|
||||
CommentID uint `json:"comment_id" gorm:"not null"`
|
||||
UserID uint `json:"user_id" gorm:"not null"`
|
||||
Type string `json:"type" gorm:"size:24;not null;index"` // like|heart|smile|laugh|thumbs_up|thumbs_down|sad|angry
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// EshopProductCategory represents a product category in the e-shop (e.g. dresy, čepice)
|
||||
type EshopProductCategory struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Slug string `gorm:"size:190;uniqueIndex;not null" json:"slug"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
|
||||
DisplayOrder int `gorm:"default:0" json:"display_order"`
|
||||
Active bool `gorm:"default:true" json:"active"`
|
||||
}
|
||||
|
||||
func (EshopProductCategory) TableName() string { return "eshop_product_categories" }
|
||||
|
||||
// EshopProduct represents a sellable product in the e-shop
|
||||
// Prices are stored in cents for currency-safe arithmetic.
|
||||
type EshopProduct struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Slug string `gorm:"size:190;uniqueIndex;not null" json:"slug"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
ShortDescription string `gorm:"type:text" json:"short_description"`
|
||||
DescriptionHTML string `gorm:"type:text" json:"description_html"`
|
||||
PriceCents int64 `json:"price_cents"`
|
||||
Currency string `gorm:"size:10;default:'CZK'" json:"currency"`
|
||||
VATRate float64 `json:"vat_rate"`
|
||||
Active bool `gorm:"default:true;index" json:"active"`
|
||||
StockMode string `gorm:"size:20;default:'finite'" json:"stock_mode"` // finite | unlimited
|
||||
DefaultImageURL string `gorm:"size:500" json:"default_image_url"`
|
||||
GalleryJSON string `gorm:"type:text" json:"gallery_json"` // JSON array of image URLs
|
||||
Tags string `gorm:"type:text" json:"tags"` // comma-separated or JSON
|
||||
MetadataJSON string `gorm:"type:text" json:"metadata_json"` // flexible metadata
|
||||
CategoryID *uint `gorm:"index" json:"category_id,omitempty"`
|
||||
Category *EshopProductCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||
Variants []EshopProductVariant `gorm:"foreignKey:ProductID" json:"variants,omitempty"`
|
||||
}
|
||||
|
||||
func (EshopProduct) TableName() string { return "eshop_products" }
|
||||
|
||||
// EshopProductVariant represents a concrete variant of a product (size/color)
|
||||
type EshopProductVariant struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
ProductID uint `gorm:"index;not null" json:"product_id"`
|
||||
Product EshopProduct `gorm:"foreignKey:ProductID" json:"-"`
|
||||
SKU string `gorm:"size:64;index" json:"sku"`
|
||||
Name string `gorm:"size:255" json:"name"`
|
||||
AttributesJSON string `gorm:"type:text" json:"attributes_json"` // e.g. {"size":"M","color":"Modrá"}
|
||||
StockQty int `json:"stock_qty"`
|
||||
Barcode string `gorm:"size:128" json:"barcode"`
|
||||
ImageURL string `gorm:"size:500" json:"image_url"`
|
||||
}
|
||||
|
||||
func (EshopProductVariant) TableName() string { return "eshop_product_variants" }
|
||||
|
||||
// EshopCart represents an open shopping cart (either user-based or session-based)
|
||||
type EshopCart struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
UserID *uint `gorm:"index" json:"user_id,omitempty"`
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
SessionToken string `gorm:"size:64;index" json:"session_token,omitempty"`
|
||||
Currency string `gorm:"size:10" json:"currency"`
|
||||
Completed bool `gorm:"default:false;index" json:"completed"`
|
||||
|
||||
Items []EshopCartItem `gorm:"foreignKey:CartID" json:"items,omitempty"`
|
||||
}
|
||||
|
||||
func (EshopCart) TableName() string { return "eshop_carts" }
|
||||
|
||||
// EshopCartItem represents a single item in the shopping cart
|
||||
type EshopCartItem struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
CartID uint `gorm:"index;not null" json:"cart_id"`
|
||||
Cart EshopCart `gorm:"foreignKey:CartID" json:"-"`
|
||||
ProductID uint `gorm:"index;not null" json:"product_id"`
|
||||
Product EshopProduct `gorm:"foreignKey:ProductID" json:"product"`
|
||||
VariantID *uint `gorm:"index" json:"variant_id,omitempty"`
|
||||
Variant *EshopProductVariant `gorm:"foreignKey:VariantID" json:"variant,omitempty"`
|
||||
Quantity int `gorm:"not null;default:1" json:"quantity"`
|
||||
|
||||
UnitPriceCents int64 `json:"unit_price_cents"`
|
||||
Currency string `gorm:"size:10" json:"currency"`
|
||||
}
|
||||
|
||||
func (EshopCartItem) TableName() string { return "eshop_cart_items" }
|
||||
|
||||
// EshopOrder represents a placed order
|
||||
// It is intentionally denormalized a bit so changes to user profile do not affect historical orders.
|
||||
type EshopOrder struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
OrderNumber string `gorm:"size:32;uniqueIndex" json:"order_number"`
|
||||
UserID *uint `gorm:"index" json:"user_id,omitempty"`
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
SessionToken string `gorm:"size:64;index" json:"session_token,omitempty"`
|
||||
|
||||
Email string `gorm:"size:255" json:"email"`
|
||||
FirstName string `gorm:"size:100" json:"first_name"`
|
||||
LastName string `gorm:"size:100" json:"last_name"`
|
||||
|
||||
BillingAddressJSON string `gorm:"type:text" json:"billing_address_json"`
|
||||
ShippingAddressJSON string `gorm:"type:text" json:"shipping_address_json"`
|
||||
|
||||
Status string `gorm:"size:32;index" json:"status"` // new, awaiting_payment, paid, cancelled, refunded, ready_to_ship, shipped, delivered
|
||||
TotalAmountCents int64 `json:"total_amount_cents"`
|
||||
Currency string `gorm:"size:10" json:"currency"`
|
||||
ShippingMethod string `gorm:"size:32" json:"shipping_method"`
|
||||
ShippingPriceCents int64 `json:"shipping_price_cents"`
|
||||
ShippingDataJSON string `gorm:"type:text" json:"shipping_data_json"`
|
||||
|
||||
TicketOrder *uint `gorm:"index" json:"ticket_order,omitempty"` // Flag for ticket orders
|
||||
TicketCampaignID *uint `gorm:"index" json:"ticket_campaign_id,omitempty"` // Link to ticket campaign
|
||||
|
||||
MetadataJSON string `gorm:"type:text" json:"metadata_json"`
|
||||
Items []EshopOrderItem `gorm:"foreignKey:OrderID" json:"items,omitempty"`
|
||||
Payments []EshopPayment `gorm:"foreignKey:OrderID" json:"payments,omitempty"`
|
||||
Labels []EshopShippingLabel `gorm:"foreignKey:OrderID" json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
func (EshopOrder) TableName() string { return "eshop_orders" }
|
||||
|
||||
// EshopOrderItem represents a line item within an order
|
||||
type EshopOrderItem struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
OrderID uint `gorm:"index;not null" json:"order_id"`
|
||||
Order EshopOrder `gorm:"foreignKey:OrderID" json:"-"`
|
||||
ProductID uint `gorm:"index" json:"product_id"`
|
||||
VariantID *uint `gorm:"index" json:"variant_id,omitempty"`
|
||||
|
||||
Name string `gorm:"size:255" json:"name"`
|
||||
SKU string `gorm:"size:64" json:"sku"`
|
||||
Quantity int `gorm:"not null;default:1" json:"quantity"`
|
||||
|
||||
UnitPriceCents int64 `json:"unit_price_cents"`
|
||||
Currency string `gorm:"size:10" json:"currency"`
|
||||
VATRate float64 `json:"vat_rate"`
|
||||
|
||||
TicketID *uint `gorm:"index" json:"ticket_id,omitempty"` // Link to ticket if this is a ticket item
|
||||
}
|
||||
|
||||
func (EshopOrderItem) TableName() string { return "eshop_order_items" }
|
||||
|
||||
// EshopPayment tracks payments for orders (e.g. Stripe)
|
||||
type EshopPayment struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
OrderID uint `gorm:"index;not null" json:"order_id"`
|
||||
Order EshopOrder `gorm:"foreignKey:OrderID" json:"-"`
|
||||
|
||||
Provider string `gorm:"size:32" json:"provider"` // stripe, bank_transfer
|
||||
ProviderPaymentID string `gorm:"size:128;index" json:"provider_payment_id"`
|
||||
Status string `gorm:"size:32;index" json:"status"` // pending, succeeded, failed, refunded
|
||||
AmountCents int64 `json:"amount_cents"`
|
||||
Currency string `gorm:"size:10" json:"currency"`
|
||||
RawPayloadJSON string `gorm:"type:text" json:"raw_payload_json"`
|
||||
}
|
||||
|
||||
func (EshopPayment) TableName() string { return "eshop_payments" }
|
||||
|
||||
// EshopShippingLabel tracks carrier labels / Packeta packets for orders
|
||||
type EshopShippingLabel struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
OrderID uint `gorm:"index;not null" json:"order_id"`
|
||||
Order EshopOrder `gorm:"foreignKey:OrderID" json:"-"`
|
||||
|
||||
Carrier string `gorm:"size:32" json:"carrier"` // packeta, courier, pickup
|
||||
PacketaPacketID string `gorm:"size:64;index" json:"packeta_packet_id"`
|
||||
TrackingNumber string `gorm:"size:64" json:"tracking_number"`
|
||||
LabelURL string `gorm:"size:500" json:"label_url"`
|
||||
Status string `gorm:"size:64" json:"status"`
|
||||
HistoryJSON string `gorm:"type:text" json:"history_json"`
|
||||
}
|
||||
|
||||
func (EshopShippingLabel) TableName() string { return "eshop_shipping_labels" }
|
||||
|
||||
// EshopSettings stores configuration specific to the e-shop instance
|
||||
type EshopSettings struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
DefaultCurrency string `gorm:"size:10;default:'CZK'" json:"default_currency"`
|
||||
SupportedCurrencies string `gorm:"type:text" json:"supported_currencies"`
|
||||
DefaultCountry string `gorm:"size:2;default:'CZ'" json:"default_country"`
|
||||
ShippingOptionsJSON string `gorm:"type:text" json:"shipping_options_json"`
|
||||
TermsURL string `gorm:"size:500" json:"terms_url"`
|
||||
ReturnsPolicyURL string `gorm:"size:500" json:"returns_policy_url"`
|
||||
SupportEmail string `gorm:"size:255" json:"support_email"`
|
||||
SupportPhone string `gorm:"size:64" json:"support_phone"`
|
||||
}
|
||||
|
||||
func (EshopSettings) TableName() string { return "eshop_settings" }
|
||||
@@ -0,0 +1,259 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// FacilityType represents different types of facilities
|
||||
type FacilityType string
|
||||
|
||||
const (
|
||||
FacilityTypeField FacilityType = "field" // Football field, training pitch
|
||||
FacilityTypeGym FacilityType = "gym" // Indoor gym, fitness area
|
||||
FacilityTypeLocker FacilityType = "locker" // Locker rooms
|
||||
FacilityTypeClassroom FacilityType = "classroom" // Meeting rooms, classrooms
|
||||
FacilityTypeStorage FacilityType = "storage" // Equipment storage
|
||||
FacilityTypeOther FacilityType = "other" // Other facilities
|
||||
)
|
||||
|
||||
// FacilityStatus represents the current status of a facility
|
||||
type FacilityStatus string
|
||||
|
||||
const (
|
||||
FacilityStatusActive FacilityStatus = "active" // Available for booking
|
||||
FacilityStatusInactive FacilityStatus = "inactive" // Temporarily unavailable
|
||||
FacilityStatusMaintenance FacilityStatus = "maintenance" // Under maintenance
|
||||
FacilityStatusClosed FacilityStatus = "closed" // Permanently closed
|
||||
)
|
||||
|
||||
// Facility represents a physical facility that can be booked
|
||||
type Facility struct {
|
||||
BaseModel
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
Type FacilityType `json:"type" gorm:"type:varchar(20);not null"`
|
||||
Status FacilityStatus `json:"status" gorm:"type:varchar(20);not null;default:'active'"`
|
||||
Capacity int `json:"capacity"` // Maximum capacity (people)
|
||||
Area float64 `json:"area"` // Area in square meters
|
||||
Location string `json:"location"` // Building/room location
|
||||
IsIndoor bool `json:"is_indoor" gorm:"default:true"`
|
||||
IsOutdoor bool `json:"is_outdoor" gorm:"default:false"`
|
||||
ImageURL string `json:"image_url"`
|
||||
|
||||
// Booking settings
|
||||
RequiresApproval bool `json:"requires_approval" gorm:"default:false"`
|
||||
MinBookingDuration int `json:"min_booking_duration"` // Minimum booking duration in minutes
|
||||
MaxBookingDuration int `json:"max_booking_duration"` // Maximum booking duration in minutes
|
||||
BookingAdvanceDays int `json:"booking_advance_days"` // How many days in advance users can book
|
||||
|
||||
// Pricing
|
||||
PricePerHour float64 `json:"price_per_hour"` // Price per hour for bookings
|
||||
|
||||
// Availability
|
||||
AvailabilityRules []FacilityAvailabilityRule `json:"availability_rules" gorm:"constraint:OnDelete:CASCADE"`
|
||||
|
||||
// Relationships
|
||||
Bookings []FacilityBooking `json:"bookings,omitempty" gorm:"constraint:OnDelete:CASCADE"`
|
||||
Equipment []FacilityEquipment `json:"equipment,omitempty" gorm:"constraint:OnDelete:CASCADE"`
|
||||
Maintenance []FacilityMaintenance `json:"maintenance,omitempty" gorm:"constraint:OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
// FacilityAvailabilityRule defines when a facility is available for booking
|
||||
type FacilityAvailabilityRule struct {
|
||||
BaseModel
|
||||
FacilityID uint `json:"facility_id" gorm:"index;not null"`
|
||||
Facility Facility `json:"facility" gorm:"foreignKey:FacilityID"`
|
||||
|
||||
DayOfWeek int `json:"day_of_week"` // 0=Sunday, 1=Monday, ..., 6=Saturday
|
||||
StartTime string `json:"start_time"` // HH:MM format
|
||||
EndTime string `json:"end_time"` // HH:MM format
|
||||
IsAvailable bool `json:"is_available" gorm:"default:true"`
|
||||
|
||||
// Recurring exceptions
|
||||
StartDate *time.Time `json:"start_date"`
|
||||
EndDate *time.Time `json:"end_date"`
|
||||
}
|
||||
|
||||
// BookingStatus represents the status of a facility booking
|
||||
type BookingStatus string
|
||||
|
||||
const (
|
||||
BookingStatusPending BookingStatus = "pending" // Awaiting approval
|
||||
BookingStatusConfirmed BookingStatus = "confirmed" // Approved and confirmed
|
||||
BookingStatusCancelled BookingStatus = "cancelled" // Cancelled
|
||||
BookingStatusCompleted BookingStatus = "completed" // Completed
|
||||
BookingStatusNoShow BookingStatus = "noshow" // No show
|
||||
)
|
||||
|
||||
// FacilityBooking represents a booking for a facility
|
||||
type FacilityBooking struct {
|
||||
BaseModel
|
||||
FacilityID uint `json:"facility_id" gorm:"index;not null"`
|
||||
Facility Facility `json:"facility" gorm:"foreignKey:FacilityID"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"index;not null"`
|
||||
User User `json:"user" gorm:"foreignKey:UserID"`
|
||||
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
StartTime time.Time `json:"start_time" gorm:"not null"`
|
||||
EndTime time.Time `json:"end_time" gorm:"not null"`
|
||||
Status BookingStatus `json:"status" gorm:"type:varchar(20);not null;default:'pending'"`
|
||||
|
||||
// Pricing
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
PaymentStatus string `json:"payment_status" gorm:"default:'pending'"`
|
||||
|
||||
// Attendance
|
||||
ActualStartTime *time.Time `json:"actual_start_time"`
|
||||
ActualEndTime *time.Time `json:"actual_end_time"`
|
||||
AttendeesCount int `json:"attendees_count"`
|
||||
|
||||
// Notes
|
||||
InternalNotes string `json:"internal_notes"` // Admin-only notes
|
||||
PublicNotes string `json:"public_notes"` // Visible to booker
|
||||
|
||||
// Cancellation
|
||||
CancelledAt *time.Time `json:"cancelled_at"`
|
||||
CancelledBy *uint `json:"cancelled_by"`
|
||||
CancelReason string `json:"cancel_reason"`
|
||||
}
|
||||
|
||||
// EquipmentStatus represents the status of equipment
|
||||
type EquipmentStatus string
|
||||
|
||||
const (
|
||||
EquipmentStatusAvailable EquipmentStatus = "available"
|
||||
EquipmentStatusInUse EquipmentStatus = "in_use"
|
||||
EquipmentStatusMaintenance EquipmentStatus = "maintenance"
|
||||
EquipmentStatusDamaged EquipmentStatus = "damaged"
|
||||
EquipmentStatusLost EquipmentStatus = "lost"
|
||||
EquipmentStatusRetired EquipmentStatus = "retired"
|
||||
)
|
||||
|
||||
// FacilityEquipment represents equipment associated with a facility
|
||||
type FacilityEquipment struct {
|
||||
BaseModel
|
||||
FacilityID uint `json:"facility_id" gorm:"index;not null"`
|
||||
Facility Facility `json:"facility" gorm:"foreignKey:FacilityID"`
|
||||
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"` // e.g., "balls", "cones", "goals", "training aids"
|
||||
Status EquipmentStatus `json:"status" gorm:"type:varchar(20);not null;default:'available'"`
|
||||
Quantity int `json:"quantity"` // Total quantity
|
||||
Available int `json:"available"` // Currently available quantity
|
||||
|
||||
// Purchase info
|
||||
PurchaseDate *time.Time `json:"purchase_date"`
|
||||
PurchasePrice float64 `json:"purchase_price"`
|
||||
Supplier string `json:"supplier"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
WarrantyExpiry *time.Time `json:"warranty_expiry"`
|
||||
|
||||
// Maintenance
|
||||
LastMaintenanceDate *time.Time `json:"last_maintenance_date"`
|
||||
NextMaintenanceDate *time.Time `json:"next_maintenance_date"`
|
||||
|
||||
// Location tracking
|
||||
CurrentLocation string `json:"current_location"`
|
||||
|
||||
ImageURL string `json:"image_url"`
|
||||
|
||||
// Usage tracking
|
||||
UsageCount int `json:"usage_count"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
}
|
||||
|
||||
// MaintenanceType represents the type of maintenance
|
||||
type MaintenanceType string
|
||||
|
||||
const (
|
||||
MaintenanceTypeRoutine MaintenanceType = "routine" // Regular maintenance
|
||||
MaintenanceTypeRepair MaintenanceType = "repair" // Repair work
|
||||
MaintenanceTypeInspection MaintenanceType = "inspection" // Safety inspection
|
||||
MaintenanceTypeUpgrade MaintenanceType = "upgrade" // Upgrades/improvements
|
||||
)
|
||||
|
||||
// FacilityMaintenance represents maintenance work on facilities
|
||||
type FacilityMaintenance struct {
|
||||
BaseModel
|
||||
FacilityID uint `json:"facility_id" gorm:"index;not null"`
|
||||
Facility Facility `json:"facility" gorm:"foreignKey:FacilityID"`
|
||||
|
||||
Type MaintenanceType `json:"type" gorm:"type:varchar(20);not null"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
|
||||
// Scheduling
|
||||
ScheduledDate *time.Time `json:"scheduled_date"`
|
||||
EstimatedDuration int `json:"estimated_duration"` // Duration in minutes
|
||||
ActualDuration int `json:"actual_duration"`
|
||||
|
||||
// Status
|
||||
Status string `json:"status" gorm:"default:'scheduled'"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
|
||||
// Cost
|
||||
EstimatedCost float64 `json:"estimated_cost"`
|
||||
ActualCost float64 `json:"actual_cost"`
|
||||
|
||||
// Personnel
|
||||
AssignedTo string `json:"assigned_to"`
|
||||
PerformedBy string `json:"performed_by"`
|
||||
|
||||
// Impact on availability
|
||||
IsFacilityUnavailable bool `json:"is_facility_unavailable" gorm:"default:true"`
|
||||
|
||||
// Notes
|
||||
InternalNotes string `json:"internal_notes"`
|
||||
PublicNotes string `json:"public_notes"`
|
||||
|
||||
// Related equipment
|
||||
EquipmentAffected []string `json:"equipment_affected" gorm:"type:text"` // JSON array of equipment names
|
||||
}
|
||||
|
||||
// WeatherCondition represents weather conditions for outdoor activities
|
||||
type WeatherCondition struct {
|
||||
BaseModel
|
||||
FacilityID uint `json:"facility_id" gorm:"index;not null"`
|
||||
Facility Facility `json:"facility" gorm:"foreignKey:FacilityID"`
|
||||
|
||||
DateTime time.Time `json:"date_time" gorm:"not null"`
|
||||
Temperature float64 `json:"temperature"` // Celsius
|
||||
Humidity float64 `json:"humidity"` // Percentage
|
||||
Precipitation float64 `json:"precipitation"` // mm
|
||||
WindSpeed float64 `json:"wind_speed"` // km/h
|
||||
WindDirection int `json:"wind_direction"` // Degrees
|
||||
WeatherCode string `json:"weather_code"` // OpenWeatherMap condition code
|
||||
Description string `json:"description"` // Weather description
|
||||
IsSuitable bool `json:"is_suitable"` // Suitable for outdoor activities
|
||||
Recommendations string `json:"recommendations"` // Activity recommendations
|
||||
}
|
||||
|
||||
// FacilityBookingTemplate represents reusable booking templates
|
||||
type FacilityBookingTemplate struct {
|
||||
BaseModel
|
||||
FacilityID uint `json:"facility_id" gorm:"index;not null"`
|
||||
Facility Facility `json:"facility" gorm:"foreignKey:FacilityID"`
|
||||
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
|
||||
// Default booking settings
|
||||
Duration int `json:"duration"` // Duration in minutes
|
||||
PricePerHour float64 `json:"price_per_hour"` // Override facility price if set
|
||||
RequiresApproval bool `json:"requires_approval"`
|
||||
|
||||
// Recurrence pattern for regular bookings
|
||||
IsRecurring bool `json:"is_recurring"`
|
||||
RecurrencePattern string `json:"recurrence_pattern"` // JSON: daily, weekly, monthly
|
||||
|
||||
// Default settings
|
||||
DefaultTitle string `json:"default_title"`
|
||||
DefaultDescription string `json:"default_description"`
|
||||
DefaultAttendees int `json:"default_attendees"`
|
||||
|
||||
IsActive bool `json:"is_active" gorm:"default:true"`
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Budget represents a budget category with limits and tracking
|
||||
type Budget struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"not null;size:255"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Category string `json:"category" gorm:"size:100;index"` // např. "Týmové provoz", "Stadion", "Marketing", "Cestování"
|
||||
|
||||
// Budget limits and tracking
|
||||
YearlyLimit float64 `json:"yearly_limit" gorm:"type:decimal(12,2)"`
|
||||
MonthlyLimit float64 `json:"monthly_limit" gorm:"type:decimal(12,2)"`
|
||||
CurrentSpend float64 `json:"current_spend" gorm:"type:decimal(12,2);default:0"`
|
||||
|
||||
// Budget period
|
||||
FiscalYear int `json:"fiscal_year" gorm:"index"`
|
||||
StartDate time.Time `json:"start_date"`
|
||||
EndDate time.Time `json:"end_date"`
|
||||
|
||||
// Status and management
|
||||
Active bool `json:"active" gorm:"default:true"`
|
||||
AlertThreshold float64 `json:"alert_threshold" gorm:"type:decimal(5,2);default:80"` // % pro varování
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy uint `json:"created_by"`
|
||||
UpdatedBy uint `json:"updated_by"`
|
||||
|
||||
// Relations
|
||||
Expenses []Expense `json:"expenses,omitempty" gorm:"foreignKey:BudgetID"`
|
||||
}
|
||||
|
||||
// Sponsorship represents sponsorship contracts and tracking
|
||||
type Sponsorship struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
SponsorName string `json:"sponsor_name" gorm:"not null;size:255"`
|
||||
SponsorLogo string `json:"sponsor_logo" gorm:"size:500"`
|
||||
ContactPerson string `json:"contact_person" gorm:"size:255"`
|
||||
ContactEmail string `json:"contact_email" gorm:"size:255"`
|
||||
ContactPhone string `json:"contact_phone" gorm:"size:50"`
|
||||
|
||||
// Contract details
|
||||
ContractNumber string `json:"contract_number" gorm:"size:100;uniqueIndex"`
|
||||
ContractType string `json:"contract_type" gorm:"size:100"` // "Hlavní", "Technický", "Mediální", "Akce"
|
||||
|
||||
// Financial terms
|
||||
TotalValue float64 `json:"total_value" gorm:"type:decimal(12,2)"`
|
||||
PaymentSchedule string `json:"payment_schedule" gorm:"size:100"` // "Měsíčně", "Čtvrtletně", "Ročně", "Jednorázově"
|
||||
Currency string `json:"currency" gorm:"size:3;default:'CZK'"`
|
||||
|
||||
// Contract period
|
||||
StartDate time.Time `json:"start_date"`
|
||||
EndDate time.Time `json:"end_date"`
|
||||
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"`
|
||||
RenewalNotice int `json:"renewal_notice" gorm:"default:90"` // dní předem
|
||||
|
||||
// Benefits and obligations
|
||||
Benefits string `json:"benefits" gorm:"type:text"` // JSON s detaily benefitů
|
||||
Obligations string `json:"obligations" gorm:"type:text"`
|
||||
|
||||
// Status tracking
|
||||
Status string `json:"status" gorm:"size:50;default:'active'"` // "active", "expired", "terminated", "pending"
|
||||
LastPaymentDate time.Time `json:"last_payment_date"`
|
||||
NextPaymentDate time.Time `json:"next_payment_date"`
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy uint `json:"created_by"`
|
||||
UpdatedBy uint `json:"updated_by"`
|
||||
|
||||
// Relations
|
||||
Payments []SponsorshipPayment `json:"payments,omitempty" gorm:"foreignKey:SponsorshipID"`
|
||||
Documents []SponsorshipDocument `json:"documents,omitempty" gorm:"foreignKey:SponsorshipID"`
|
||||
}
|
||||
|
||||
// SponsorshipPayment tracks individual payments from sponsors
|
||||
type SponsorshipPayment struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
SponsorshipID uint `json:"sponsorship_id" gorm:"not null;index"`
|
||||
|
||||
// Payment details
|
||||
Amount float64 `json:"amount" gorm:"type:decimal(12,2)"`
|
||||
Currency string `json:"currency" gorm:"size:3;default:'CZK'"`
|
||||
PaymentDate time.Time `json:"payment_date"`
|
||||
PaymentMethod string `json:"payment_method" gorm:"size:100"` // "Bankovní převod", "Hotovost", "Karta"
|
||||
|
||||
// Reference and status
|
||||
ReferenceNumber string `json:"reference_number" gorm:"size:255"`
|
||||
Status string `json:"status" gorm:"size:50;default:'received'"` // "expected", "received", "overdue", "cancelled"
|
||||
Notes string `json:"notes" gorm:"type:text"`
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy uint `json:"created_by"`
|
||||
UpdatedBy uint `json:"updated_by"`
|
||||
|
||||
// Relations
|
||||
Sponsorship Sponsorship `json:"sponsorship,omitempty" gorm:"foreignKey:SponsorshipID"`
|
||||
}
|
||||
|
||||
// SponsorshipDocument stores contract documents and related files
|
||||
type SponsorshipDocument struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
SponsorshipID uint `json:"sponsorship_id" gorm:"not null;index"`
|
||||
|
||||
// Document details
|
||||
Name string `json:"name" gorm:"not null;size:255"`
|
||||
Type string `json:"type" gorm:"size:100"` // "Smlouva", "Faktura", "Dodatek", "Jiný"
|
||||
FileName string `json:"file_name" gorm:"size:500"`
|
||||
FilePath string `json:"file_path" gorm:"size:500"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
MimeType string `json:"mime_type" gorm:"size:100"`
|
||||
|
||||
// Document metadata
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Version string `json:"version" gorm:"size:20;default:'1.0'"`
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy uint `json:"created_by"`
|
||||
UpdatedBy uint `json:"updated_by"`
|
||||
|
||||
// Relations
|
||||
Sponsorship Sponsorship `json:"sponsorship,omitempty" gorm:"foreignKey:SponsorshipID"`
|
||||
}
|
||||
|
||||
// Expense represents individual expenses with receipt tracking
|
||||
type Expense struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
|
||||
// Basic expense info
|
||||
Title string `json:"title" gorm:"not null;size:255"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Category string `json:"category" gorm:"size:100;index"` // "Cestování", "Materiál", "Služby", "Strava", "Ubytování"
|
||||
Subcategory string `json:"subcategory" gorm:"size:100"`
|
||||
|
||||
// Financial details
|
||||
Amount float64 `json:"amount" gorm:"type:decimal(12,2)"`
|
||||
Currency string `json:"currency" gorm:"size:3;default:'CZK'"`
|
||||
VATRate float64 `json:"vat_rate" gorm:"type:decimal(5,2);default:21"` // DPH sazba v %
|
||||
VATAmount float64 `json:"vat_amount" gorm:"type:decimal(12,2)"`
|
||||
TotalAmount float64 `json:"total_amount" gorm:"type:decimal(12,2)"`
|
||||
|
||||
// Expense details
|
||||
ExpenseDate time.Time `json:"expense_date"`
|
||||
PaymentMethod string `json:"payment_method" gorm:"size:100"` // "Hotovost", "Karta", "Faktura", "Proforma"
|
||||
|
||||
// Receipt and documentation
|
||||
HasReceipt bool `json:"has_receipt" gorm:"default:false"`
|
||||
ReceiptData string `json:"receipt_data" gorm:"type:text"` // OCR data z paragonu
|
||||
ReceiptImage string `json:"receipt_image" gorm:"size:500"` // cesta k obrázku paragonu
|
||||
|
||||
// Approval workflow
|
||||
Status string `json:"status" gorm:"size:50;default:'pending'"` // "pending", "approved", "rejected", "reimbursed"
|
||||
ApprovedBy uint `json:"approved_by"`
|
||||
ApprovedAt *time.Time `json:"approved_at"`
|
||||
RejectionReason string `json:"rejection_reason" gorm:"type:text"`
|
||||
|
||||
// Budget tracking
|
||||
BudgetID *uint `json:"budget_id" gorm:"index"`
|
||||
TeamID *uint `json:"team_id" gorm:"index"` // přiřazení k týmu
|
||||
ProjectID *uint `json:"project_id" gorm:"index"` // přiřazení k projektu
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy uint `json:"created_by"`
|
||||
UpdatedBy uint `json:"updated_by"`
|
||||
|
||||
// Relations
|
||||
Budget *Budget `json:"budget,omitempty" gorm:"foreignKey:BudgetID"`
|
||||
Documents []ExpenseDocument `json:"documents,omitempty" gorm:"foreignKey:ExpenseID"`
|
||||
}
|
||||
|
||||
// ExpenseDocument stores expense-related documents
|
||||
type ExpenseDocument struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
ExpenseID uint `json:"expense_id" gorm:"not null;index"`
|
||||
|
||||
// Document details
|
||||
Name string `json:"name" gorm:"not null;size:255"`
|
||||
Type string `json:"type" gorm:"size:100"` // "Paragon", "Faktura", "Smlouva", "Jiný"
|
||||
FileName string `json:"file_name" gorm:"size:500"`
|
||||
FilePath string `json:"file_path" gorm:"size:500"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
MimeType string `json:"mime_type" gorm:"size:100"`
|
||||
|
||||
// OCR data for receipts
|
||||
OCRData string `json:"ocr_data" gorm:"type:text"`
|
||||
OCRAccuracy float64 `json:"ocr_accuracy" gorm:"type:decimal(5,2)"` // spolehlivost OCR v %
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy uint `json:"created_by"`
|
||||
UpdatedBy uint `json:"updated_by"`
|
||||
|
||||
// Relations
|
||||
Expense Expense `json:"expense,omitempty" gorm:"foreignKey:ExpenseID"`
|
||||
}
|
||||
|
||||
// FinancialReport represents generated financial reports
|
||||
type FinancialReport struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
|
||||
// Report details
|
||||
Name string `json:"name" gorm:"not null;size:255"`
|
||||
Type string `json:"type" gorm:"size:100"` // "monthly", "quarterly", "yearly", "custom"
|
||||
Period string `json:"period" gorm:"size:50"` // "2024-01", "Q1-2024", "2024", "custom"
|
||||
|
||||
// Report data
|
||||
ReportData string `json:"report_data" gorm:"type:text"` // JSON s daty reportu
|
||||
Summary string `json:"summary" gorm:"type:text"`
|
||||
|
||||
// File generation
|
||||
FilePath string `json:"file_path" gorm:"size:500"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy uint `json:"created_by"`
|
||||
UpdatedBy uint `json:"updated_by"`
|
||||
}
|
||||
|
||||
// FinancialSettings stores club-wide financial configuration
|
||||
type FinancialSettings struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
|
||||
// Budget settings
|
||||
DefaultCurrency string `json:"default_currency" gorm:"size:3;default:'CZK'"`
|
||||
DefaultVATRate float64 `json:"default_vat_rate" gorm:"type:decimal(5,2);default:21"`
|
||||
FiscalYearStart string `json:"fiscal_year_start" gorm:"size:10;default:'01-01'"` // MM-DD format
|
||||
|
||||
// Approval settings
|
||||
ExpenseApprovalRequired bool `json:"expense_approval_required" gorm:"default:true"`
|
||||
MaxExpenseAutoApprove float64 `json:"max_expense_auto_approve" gorm:"type:decimal(12,2);default:1000"`
|
||||
|
||||
// Notification settings
|
||||
BudgetAlertEnabled bool `json:"budget_alert_enabled" gorm:"default:true"`
|
||||
BudgetAlertThreshold float64 `json:"budget_alert_threshold" gorm:"type:decimal(5,2);default:80"`
|
||||
SponsorshipAlertEnabled bool `json:"sponsorship_alert_enabled" gorm:"default:true"`
|
||||
|
||||
// OCR settings
|
||||
OCRServiceEnabled bool `json:"ocr_service_enabled" gorm:"default:true"`
|
||||
OCRProvider string `json:"ocr_provider" gorm:"size:50;default:'tesseract'"` // "tesseract", "google", "azure"
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UpdatedBy uint `json:"updated_by"`
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Language represents a supported language
|
||||
type Language struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(5)" json:"id"`
|
||||
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
||||
NativeName string `gorm:"type:varchar(100);not null" json:"native_name"`
|
||||
Code string `gorm:"type:varchar(10);not null;uniqueIndex" json:"code"`
|
||||
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Translation represents a translatable string
|
||||
type Translation struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Key string `gorm:"type:varchar(200);not null;index" json:"key"`
|
||||
LanguageCode string `gorm:"type:varchar(10);not null;index" json:"language_code"`
|
||||
Value string `gorm:"type:text;not null" json:"value"`
|
||||
Context string `gorm:"type:varchar(100)" json:"context"` // e.g., "navbar", "admin", "public"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Relationships
|
||||
Language Language `gorm:"foreignKey:LanguageCode;references:Code" json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// ContentTranslation represents translations for content like articles, activities, etc.
|
||||
type ContentTranslation struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ContentType string `gorm:"type:varchar(50);not null;index" json:"content_type"` // "article", "activity", "page", etc.
|
||||
ContentID uint `gorm:"not null;index" json:"content_id"`
|
||||
LanguageCode string `gorm:"type:varchar(10);not null;index" json:"language_code"`
|
||||
Title string `gorm:"type:varchar(500)" json:"title"`
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
Excerpt string `gorm:"type:text" json:"excerpt"`
|
||||
MetaTitle string `gorm:"type:varchar(200)" json:"meta_title"`
|
||||
MetaDescription string `gorm:"type:varchar(500)" json:"meta_description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Relationships
|
||||
Language Language `gorm:"foreignKey:LanguageCode;references:Code" json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// UserLanguagePreference tracks user's preferred language
|
||||
type UserLanguagePreference struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;uniqueIndex" json:"user_id"`
|
||||
LanguageCode string `gorm:"type:varchar(10);not null" json:"language_code"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Relationships
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
Language Language `gorm:"foreignKey:LanguageCode;references:Code" json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for Language model
|
||||
func (Language) TableName() string {
|
||||
return "languages"
|
||||
}
|
||||
|
||||
// TableName returns the table name for Translation model
|
||||
func (Translation) TableName() string {
|
||||
return "translations"
|
||||
}
|
||||
|
||||
// TableName returns the table name for ContentTranslation model
|
||||
func (ContentTranslation) TableName() string {
|
||||
return "content_translations"
|
||||
}
|
||||
|
||||
// TableName returns the table name for UserLanguagePreference model
|
||||
func (UserLanguagePreference) TableName() string {
|
||||
return "user_language_preferences"
|
||||
}
|
||||
|
||||
// BeforeCreate sets default values
|
||||
func (l *Language) BeforeCreate(tx *gorm.DB) error {
|
||||
if l.SortOrder == 0 {
|
||||
l.SortOrder = 100
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDefaultLanguage returns the default language
|
||||
func GetDefaultLanguage(db *gorm.DB) (*Language, error) {
|
||||
var lang Language
|
||||
err := db.Where("is_default = ? AND is_active = ?", true, true).First(&lang).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &lang, nil
|
||||
}
|
||||
|
||||
// GetActiveLanguages returns all active languages ordered by sort order
|
||||
func GetActiveLanguages(db *gorm.DB) ([]Language, error) {
|
||||
var languages []Language
|
||||
err := db.Where("is_active = ?", true).Order("sort_order ASC, name ASC").Find(&languages).Error
|
||||
return languages, err
|
||||
}
|
||||
|
||||
// GetTranslation returns a translation for a specific key and language
|
||||
func GetTranslation(db *gorm.DB, key, languageCode string) (*Translation, error) {
|
||||
var translation Translation
|
||||
err := db.Where("key = ? AND language_code = ?", key, languageCode).First(&translation).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &translation, nil
|
||||
}
|
||||
|
||||
// GetTranslationWithFallback returns a translation, falling back to default language if needed
|
||||
func GetTranslationWithFallback(db *gorm.DB, key, languageCode string) (*Translation, error) {
|
||||
// Try requested language first
|
||||
translation, err := GetTranslation(db, key, languageCode)
|
||||
if err == nil {
|
||||
return translation, nil
|
||||
}
|
||||
|
||||
// Fall back to default language
|
||||
defaultLang, err := GetDefaultLanguage(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return GetTranslation(db, key, defaultLang.Code)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Invoice represents a complete invoice with auto-fill capabilities
|
||||
type Invoice struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
|
||||
// Invoice identification
|
||||
InvoiceNumber string `json:"invoice_number" gorm:"size:50;uniqueIndex;not null"`
|
||||
InvoiceType string `json:"invoice_type" gorm:"size:20;default:'faktura'"` // "faktura", "zalohova_faktura", "proforma_faktura", "dobropis"
|
||||
VariableSymbol string `json:"variable_symbol" gorm:"size:20"`
|
||||
ConstantSymbol string `json:"constant_symbol" gorm:"size:20"`
|
||||
SpecificSymbol string `json:"specific_symbol" gorm:"size:20"`
|
||||
|
||||
// Invoice dates
|
||||
IssueDate time.Time `json:"issue_date" gorm:"not null"`
|
||||
DueDate time.Time `json:"due_date" gorm:"not null"`
|
||||
TaxableSupplyDate time.Time `json:"taxable_supply_date"`
|
||||
|
||||
// Supplier information (auto-filled from club settings)
|
||||
SupplierName string `json:"supplier_name" gorm:"size:255;not null"`
|
||||
SupplierICO string `json:"supplier_ico" gorm:"size:20"`
|
||||
SupplierDIC string `json:"supplier_dic" gorm:"size:20"`
|
||||
SupplierAddress string `json:"supplier_address" gorm:"type:text"`
|
||||
SupplierCity string `json:"supplier_city" gorm:"size:100"`
|
||||
SupplierZIP string `json:"supplier_zip" gorm:"size:10"`
|
||||
SupplierCountry string `json:"supplier_country" gorm:"size:100;default:'Česká republika'"`
|
||||
|
||||
// Supplier bank information (auto-filled)
|
||||
BankName string `json:"bank_name" gorm:"size:255"`
|
||||
BankAccount string `json:"bank_account" gorm:"size:50"`
|
||||
BankIBAN string `json:"bank_iban" gorm:"size:50"`
|
||||
BankSWIFT string `json:"bank_swift" gorm:"size:20"`
|
||||
|
||||
// Customer information (manual or auto-fill from database)
|
||||
CustomerID *uint `json:"customer_id" gorm:"index"`
|
||||
CustomerName string `json:"customer_name" gorm:"size:255;not null"`
|
||||
CustomerICO string `json:"customer_ico" gorm:"size:20"`
|
||||
CustomerDIC string `json:"customer_dic" gorm:"size:20"`
|
||||
CustomerAddress string `json:"customer_address" gorm:"type:text"`
|
||||
CustomerCity string `json:"customer_city" gorm:"size:100"`
|
||||
CustomerZIP string `json:"customer_zip" gorm:"size:10"`
|
||||
CustomerCountry string `json:"customer_country" gorm:"size:100;default:'Česká republika'"`
|
||||
CustomerEmail string `json:"customer_email" gorm:"size:255"`
|
||||
CustomerPhone string `json:"customer_phone" gorm:"size:50"`
|
||||
|
||||
// Financial summary
|
||||
TotalAmount float64 `json:"total_amount" gorm:"type:decimal(15,2);not null"`
|
||||
TotalVAT float64 `json:"total_vat" gorm:"type:decimal(15,2);not null"`
|
||||
TotalAmountVAT float64 `json:"total_amount_vat" gorm:"type:decimal(15,2);not null"`
|
||||
TotalAmountWithoutVAT float64 `json:"total_amount_without_vat" gorm:"type:decimal(15,2);not null"`
|
||||
Currency string `json:"currency" gorm:"size:3;default:'CZK'"`
|
||||
|
||||
// Invoice status and workflow
|
||||
Status string `json:"status" gorm:"size:20;default:'draft'"` // "draft", "sent", "paid", "overdue", "cancelled"
|
||||
PaymentStatus string `json:"payment_status" gorm:"size:20;default:'unpaid'"` // "unpaid", "partially_paid", "paid", "overdue"
|
||||
PaymentDate *time.Time `json:"payment_date"`
|
||||
PaidAmount float64 `json:"paid_amount" gorm:"type:decimal(15,2);default:0"`
|
||||
|
||||
// Additional information
|
||||
Note string `json:"note" gorm:"type:text"`
|
||||
PaymentNote string `json:"payment_note" gorm:"type:text"`
|
||||
InternalNote string `json:"internal_note" gorm:"type:text"`
|
||||
|
||||
// PDF and sending
|
||||
PDFPath string `json:"pdf_path" gorm:"size:500"`
|
||||
PDFGeneratedAt *time.Time `json:"pdf_generated_at"`
|
||||
SentAt *time.Time `json:"sent_at"`
|
||||
SentTo string `json:"sent_to" gorm:"type:text"` // JSON array of emails
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy uint `json:"created_by"`
|
||||
UpdatedBy uint `json:"updated_by"`
|
||||
|
||||
// Relations
|
||||
Items []InvoiceItem `json:"items,omitempty" gorm:"foreignKey:InvoiceID;constraint:OnDelete:CASCADE"`
|
||||
Payments []InvoicePayment `json:"payments,omitempty" gorm:"foreignKey:InvoiceID;constraint:OnDelete:CASCADE"`
|
||||
Customer *InvoiceCustomer `json:"customer,omitempty" gorm:"foreignKey:CustomerID"`
|
||||
}
|
||||
|
||||
// InvoiceItem represents individual line items in an invoice
|
||||
type InvoiceItem struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
InvoiceID uint `json:"invoice_id" gorm:"not null;index"`
|
||||
|
||||
// Item details
|
||||
Description string `json:"description" gorm:"type:text;not null"`
|
||||
Quantity float64 `json:"quantity" gorm:"type:decimal(12,3);not null"`
|
||||
Unit string `json:"unit" gorm:"size:20;default:'ks'"` // "ks", "hod", "m", "kg", etc.
|
||||
UnitPrice float64 `json:"unit_price" gorm:"type:decimal(15,2);not null"`
|
||||
TotalPrice float64 `json:"total_price" gorm:"type:decimal(15,2);not null"`
|
||||
|
||||
// VAT information
|
||||
VATRate float64 `json:"vat_rate" gorm:"type:decimal(5,2);not null"` // 0, 10, 21
|
||||
VATAmount float64 `json:"vat_amount" gorm:"type:decimal(15,2);not null"`
|
||||
TotalWithVAT float64 `json:"total_with_vat" gorm:"type:decimal(15,2);not null"`
|
||||
|
||||
// Additional fields
|
||||
Code string `json:"code" gorm:"size:100"` // Product code or service code
|
||||
Note string `json:"note" gorm:"type:text"`
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Relations
|
||||
Invoice Invoice `json:"invoice,omitempty" gorm:"foreignKey:InvoiceID"`
|
||||
}
|
||||
|
||||
// InvoicePayment represents payments received for invoices
|
||||
type InvoicePayment struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
InvoiceID uint `json:"invoice_id" gorm:"not null;index"`
|
||||
|
||||
// Payment details
|
||||
Amount float64 `json:"amount" gorm:"type:decimal(15,2);not null"`
|
||||
Currency string `json:"currency" gorm:"size:3;default:'CZK'"`
|
||||
PaymentDate time.Time `json:"payment_date" gorm:"not null"`
|
||||
PaymentMethod string `json:"payment_method" gorm:"size:50"` // "bank_transfer", "cash", "card", "other"
|
||||
|
||||
// Bank transfer details
|
||||
VariableSymbol string `json:"variable_symbol" gorm:"size:20"`
|
||||
ConstantSymbol string `json:"constant_symbol" gorm:"size:20"`
|
||||
SpecificSymbol string `json:"specific_symbol" gorm:"size:20"`
|
||||
BankAccount string `json:"bank_account" gorm:"size:50"`
|
||||
|
||||
// Additional information
|
||||
Note string `json:"note" gorm:"type:text"`
|
||||
ReferenceNumber string `json:"reference_number" gorm:"size:255"`
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy uint `json:"created_by"`
|
||||
|
||||
// Relations
|
||||
Invoice Invoice `json:"invoice,omitempty" gorm:"foreignKey:InvoiceID"`
|
||||
}
|
||||
|
||||
// InvoiceCustomer represents customer database for auto-fill
|
||||
type InvoiceCustomer struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
|
||||
// Basic information
|
||||
Name string `json:"name" gorm:"size:255;not null"`
|
||||
ICO string `json:"ico" gorm:"size:20;uniqueIndex"`
|
||||
DIC string `json:"dic" gorm:"size:20"`
|
||||
|
||||
// Address
|
||||
Address string `json:"address" gorm:"type:text"`
|
||||
City string `json:"city" gorm:"size:100"`
|
||||
ZIP string `json:"zip" gorm:"column:zip;size:10"`
|
||||
Country string `json:"country" gorm:"size:100;default:'Česká republika'"`
|
||||
|
||||
// Contact information
|
||||
Email string `json:"email" gorm:"size:255"`
|
||||
Phone string `json:"phone" gorm:"size:50"`
|
||||
Website string `json:"website" gorm:"size:255"`
|
||||
|
||||
// Business information
|
||||
BusinessType string `json:"business_type" gorm:"size:100"` // "individual", "company", "non_profit"
|
||||
VATPayer bool `json:"vat_payer" gorm:"default:true"`
|
||||
|
||||
// Notes and metadata
|
||||
Notes string `json:"notes" gorm:"type:text"`
|
||||
Active bool `json:"active" gorm:"default:true"`
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy uint `json:"created_by"`
|
||||
UpdatedBy uint `json:"updated_by"`
|
||||
|
||||
// Relations
|
||||
Invoices []Invoice `json:"invoices,omitempty" gorm:"foreignKey:CustomerID"`
|
||||
}
|
||||
|
||||
// InvoiceTemplate represents invoice templates for different types
|
||||
type InvoiceTemplate struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
|
||||
// Template identification
|
||||
Name string `json:"name" gorm:"size:255;not null"`
|
||||
Type string `json:"type" gorm:"size:50;not null"` // "standard", "proforma", "credit_note"
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
|
||||
// Template content (HTML with placeholders)
|
||||
HeaderHTML string `json:"header_html" gorm:"type:text"`
|
||||
BodyHTML string `json:"body_html" gorm:"type:text"`
|
||||
FooterHTML string `json:"footer_html" gorm:"type:text"`
|
||||
|
||||
// CSS styling
|
||||
CSS string `json:"css" gorm:"type:text"`
|
||||
|
||||
// Default settings
|
||||
DefaultVATRate float64 `json:"default_vat_rate" gorm:"type:decimal(5,2);default:21"`
|
||||
DefaultPaymentTerm int `json:"default_payment_term" gorm:"default:14"` // days
|
||||
|
||||
// Status
|
||||
Active bool `json:"active" gorm:"default:true"`
|
||||
Default bool `json:"default" gorm:"default:false"`
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy uint `json:"created_by"`
|
||||
UpdatedBy uint `json:"updated_by"`
|
||||
}
|
||||
|
||||
// InvoiceSettings represents global invoice settings
|
||||
type InvoiceSettings struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
|
||||
// Company information (auto-fill source)
|
||||
CompanyName string `json:"company_name" gorm:"size:255;not null"`
|
||||
CompanyICO string `json:"company_ico" gorm:"size:20;not null"`
|
||||
CompanyDIC string `json:"company_dic" gorm:"size:20"`
|
||||
CompanyAddress string `json:"company_address" gorm:"type:text"`
|
||||
CompanyCity string `json:"company_city" gorm:"size:100"`
|
||||
CompanyZIP string `json:"company_zip" gorm:"size:10"`
|
||||
CompanyCountry string `json:"company_country" gorm:"size:100;default:'Česká republika'"`
|
||||
|
||||
// Bank information
|
||||
BankName string `json:"bank_name" gorm:"size:255"`
|
||||
BankAccount string `json:"bank_account" gorm:"size:50"`
|
||||
BankIBAN string `json:"bank_iban" gorm:"size:50"`
|
||||
BankSWIFT string `json:"bank_swift" gorm:"size:20"`
|
||||
|
||||
// Invoice numbering
|
||||
InvoiceNumberFormat string `json:"invoice_number_format" gorm:"size:100;default:'F{year}{seq:6}'"` // Format with placeholders
|
||||
NextInvoiceNumber int `json:"next_invoice_number" gorm:"default:1"`
|
||||
CurrentYear int `json:"current_year"`
|
||||
|
||||
// Default settings
|
||||
DefaultPaymentTerm int `json:"default_payment_term" gorm:"default:14"` // days
|
||||
DefaultVATRate float64 `json:"default_vat_rate" gorm:"type:decimal(5,2);default:21"`
|
||||
DefaultCurrency string `json:"default_currency" gorm:"size:3;default:'CZK'"`
|
||||
|
||||
// Email settings
|
||||
EmailFrom string `json:"email_from" gorm:"size:255"`
|
||||
EmailSubject string `json:"email_subject" gorm:"size:255;default:'Faktura č. {invoice_number}'"`
|
||||
EmailBody string `json:"email_body" gorm:"type:text"`
|
||||
|
||||
// PDF settings
|
||||
PDFLogoPath string `json:"pdf_logo_path" gorm:"size:500"`
|
||||
PDFFooter string `json:"pdf_footer" gorm:"type:text"`
|
||||
|
||||
// Legal information
|
||||
RegistrationNumber string `json:"registration_number" gorm:"size:50"`
|
||||
TaxRegistrationNumber string `json:"tax_registration_number" gorm:"size:50"`
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UpdatedBy uint `json:"updated_by"`
|
||||
}
|
||||
|
||||
// InvoiceSequence represents invoice number sequences
|
||||
type InvoiceSequence struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
|
||||
// Sequence identification
|
||||
Type string `json:"type" gorm:"size:50;not null"` // "faktura", "zalohova_faktura", "proforma_faktura", "dobropis"
|
||||
Year int `json:"year" gorm:"not null"`
|
||||
|
||||
// Sequence numbers
|
||||
CurrentNumber int `json:"current_number" gorm:"default:1"`
|
||||
Prefix string `json:"prefix" gorm:"size:20"`
|
||||
Suffix string `json:"suffix" gorm:"size:20"`
|
||||
Padding int `json:"padding" gorm:"default:6"`
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ManualCompetition stores manually maintained competition metadata for manual club data mode.
|
||||
// It is scoped to the primary club (club_id/club_type from Settings) and mirrors key FACR fields.
|
||||
type ManualCompetition struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
|
||||
// Linkage to main club
|
||||
ClubID string `gorm:"index;not null" json:"club_id"` // UUID from fotbal.cz club link
|
||||
ClubType string `gorm:"index;not null" json:"club_type"` // football|futsal
|
||||
|
||||
// FACR-like identifiers and links entered manually
|
||||
Code string `gorm:"index;not null" json:"code"` // Competition code, e.g. A1A
|
||||
Name string `gorm:"not null" json:"name"` // Display name, e.g. SATUM 5. liga mužů
|
||||
ExternalID string `gorm:"index;not null" json:"external_id"` // UUID from soutez/table link
|
||||
|
||||
MatchesLink string `json:"matches_link"` // e.g. https://www.fotbal.cz/souteze/turnaje/hlavni/<uuid>
|
||||
TableLink string `json:"table_link"` // e.g. https://www.fotbal.cz/souteze/turnaje/table/<uuid>
|
||||
|
||||
TeamCount string `json:"team_count"` // Optional; free-form number as string
|
||||
}
|
||||
|
||||
func (ManualCompetition) TableName() string { return "manual_competitions" }
|
||||
|
||||
// ManualMatch stores manually entered matches for a competition in manual mode.
|
||||
// It is designed so the backend can reconstruct the FACR Match JSON shape.
|
||||
type ManualMatch struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
|
||||
CompetitionID uint `gorm:"index;not null" json:"competition_id"`
|
||||
Competition *ManualCompetition `gorm:"foreignKey:CompetitionID" json:"-"`
|
||||
|
||||
// Stable identifier parsed from the match link (UUID from fotbal.cz)
|
||||
ExternalMatchID string `gorm:"uniqueIndex;not null" json:"external_match_id"`
|
||||
|
||||
// Round label, e.g. "2. kolo"
|
||||
Round string `json:"round"`
|
||||
|
||||
// Whether the primary club plays at home. If false, the primary club is away.
|
||||
IsHome bool `json:"is_home"`
|
||||
|
||||
// Opponent information (name + fotbal.cz link & ID)
|
||||
OpponentName string `json:"opponent_name"`
|
||||
OpponentExternalID string `gorm:"index" json:"opponent_external_id"` // UUID from opponent club link
|
||||
OpponentURL string `json:"opponent_url"`
|
||||
|
||||
// Kickoff datetime in local time
|
||||
Kickoff time.Time `json:"kickoff"`
|
||||
|
||||
// Scores as free-form strings (e.g. "2:1", "2:1 (1:0)")
|
||||
Score string `json:"score"`
|
||||
HalftimeScore string `json:"halftime_score"`
|
||||
|
||||
// Match link (report URL) and location
|
||||
MatchURL string `json:"match_url"`
|
||||
Venue string `json:"venue"`
|
||||
|
||||
// Optional notes
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
func (ManualMatch) TableName() string { return "manual_matches" }
|
||||
|
||||
// ManualTableRow stores a single row in a competition table (standings) for manual mode.
|
||||
type ManualTableRow struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
|
||||
CompetitionID uint `gorm:"index;not null" json:"competition_id"`
|
||||
Competition *ManualCompetition `gorm:"foreignKey:CompetitionID" json:"-"`
|
||||
|
||||
// Position, e.g. "1.", "2."
|
||||
Rank string `json:"rank"`
|
||||
|
||||
// Club identification: name and fotbal.cz UUID for logo matching
|
||||
TeamName string `json:"team_name"`
|
||||
ExternalTeamID string `gorm:"index" json:"external_team_id"`
|
||||
|
||||
// Basic stats; kept as strings to match FACR JSON and allow flexible input
|
||||
Played string `json:"played"` // Z
|
||||
Wins string `json:"wins"` // V
|
||||
Draws string `json:"draws"` // R
|
||||
Losses string `json:"losses"` // P
|
||||
Score string `json:"score"` // Skóre, e.g. "45:17"
|
||||
Points string `json:"points"` // B
|
||||
}
|
||||
|
||||
func (ManualTableRow) TableName() string { return "manual_table_rows" }
|
||||
@@ -274,6 +274,16 @@ type Settings struct {
|
||||
VideosSource string `json:"videos_source"` // auto | manual
|
||||
VideosLimit int `json:"videos_limit"` // number of items on homepage
|
||||
|
||||
// Transient decoded forms for admin/public JSON responses (not persisted)
|
||||
Videos []string `gorm:"-" json:"videos,omitempty"`
|
||||
VideosItems []struct {
|
||||
URL string `json:"url"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Length *string `json:"length,omitempty"`
|
||||
UploadedAt *string `json:"uploaded_at,omitempty"`
|
||||
ThumbnailURL *string `json:"thumbnail_url,omitempty"`
|
||||
} `gorm:"-" json:"videos_items,omitempty"`
|
||||
|
||||
// Manual videos storage (JSON strings)
|
||||
VideosJSON string `gorm:"type:text" json:"-"`
|
||||
VideosItemsJSON string `gorm:"type:text" json:"-"`
|
||||
@@ -340,6 +350,9 @@ type Settings struct {
|
||||
ErrorReviewAdminURL string `json:"error_review_admin_url"`
|
||||
ErrorReviewAdminToken string `json:"error_review_admin_token"`
|
||||
ErrorReviewUIURL string `json:"error_review_ui_url"`
|
||||
|
||||
// E-shop payment configuration
|
||||
RevolutEnabled bool `json:"revolut_enabled"`
|
||||
}
|
||||
|
||||
// TableName specifies table name for Settings model
|
||||
|
||||
@@ -104,6 +104,19 @@ func (n *NavigationItem) GetURL() string {
|
||||
"files": "/admin/soubory",
|
||||
"docs": "/admin/docs",
|
||||
"engagement": "/admin/engagement",
|
||||
"i18n": "/admin/jazyky",
|
||||
"financial_dashboard": "/admin/financial-dashboard",
|
||||
"expenses": "/admin/expenses",
|
||||
"invoices": "/admin/invoices",
|
||||
"invoice_settings": "/admin/invoice-settings",
|
||||
"customers": "/admin/customers",
|
||||
"eshop_products": "/admin/eshop-products",
|
||||
"tickets": "/admin/tickets",
|
||||
"facilities": "/admin/facilities",
|
||||
"equipment": "/admin/equipment",
|
||||
"maintenance": "/admin/maintenance",
|
||||
"manual_facr": "/admin/manual-facr",
|
||||
"qr_codes": "/admin/qr-codes",
|
||||
}
|
||||
if url, ok := adminURLMap[n.PageType]; ok {
|
||||
return url
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type QRCode struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"not null;size:255"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
TargetURL string `json:"target_url" gorm:"not null;size:500"`
|
||||
QRCodeURL string `json:"qr_code_url" gorm:"size:500"`
|
||||
ScanCount int `json:"scan_count" gorm:"default:0"`
|
||||
IsActive bool `json:"is_active" gorm:"default:true"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (QRCode) TableName() string {
|
||||
return "qr_codes"
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TicketType represents a type of ticket with pricing and rules
|
||||
type TicketType struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
PriceCents int64 `gorm:"not null" json:"price_cents"`
|
||||
Currency string `gorm:"size:10;default:'CZK'" json:"currency"`
|
||||
Color string `gorm:"size:20;default:'primary'" json:"color"` // primary, secondary, success, warning, danger
|
||||
DisplayOrder int `gorm:"default:0" json:"display_order"`
|
||||
Active bool `gorm:"default:true;index" json:"active"`
|
||||
MaxTicketsPerOrder int `gorm:"default:10" json:"max_tickets_per_order"`
|
||||
SaleStartTime *time.Time `json:"sale_start_time"`
|
||||
SaleEndTime *time.Time `json:"sale_end_time"`
|
||||
RequiresMembership bool `gorm:"default:false" json:"requires_membership"`
|
||||
MinAge *int `json:"min_age"`
|
||||
}
|
||||
|
||||
func (TicketType) TableName() string { return "ticket_types" }
|
||||
|
||||
// TicketCampaign represents a ticket sales campaign for a specific match or event
|
||||
type TicketCampaign struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Title string `gorm:"not null" json:"title"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
|
||||
// Match information
|
||||
ExternalMatchID *string `gorm:"index" json:"external_match_id"` // FACR 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"`
|
||||
|
||||
// Campaign settings
|
||||
Active bool `gorm:"default:true;index" json:"active"`
|
||||
SaleStartTime time.Time `gorm:"not null" json:"sale_start_time"`
|
||||
SaleEndTime time.Time `gorm:"not null" json:"sale_end_time"`
|
||||
MaxTotalTickets *int `json:"max_total_tickets"`
|
||||
|
||||
// Relationships
|
||||
TicketTypes []TicketType `gorm:"many2many:campaign_ticket_types;" json:"ticket_types,omitempty"`
|
||||
CampaignTicketTypes []CampaignTicketType `gorm:"foreignKey:CampaignID" json:"campaign_ticket_types,omitempty"`
|
||||
Tickets []Ticket `gorm:"foreignKey:CampaignID" json:"tickets,omitempty"`
|
||||
}
|
||||
|
||||
func (TicketCampaign) TableName() string { return "ticket_campaigns" }
|
||||
|
||||
// CampaignTicketType links ticket types to campaigns with campaign-specific overrides
|
||||
type CampaignTicketType struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
CampaignID uint `gorm:"not null;index" json:"campaign_id"`
|
||||
Campaign TicketCampaign `gorm:"foreignKey:CampaignID" json:"-"`
|
||||
TicketTypeID uint `gorm:"not null;index" json:"ticket_type_id"`
|
||||
TicketType TicketType `gorm:"foreignKey:TicketTypeID" json:"-"`
|
||||
|
||||
// Campaign-specific overrides
|
||||
PriceCents *int64 `json:"price_cents"` // Override default price if set
|
||||
MaxQuantity *int `json:"max_quantity"` // Campaign-specific quantity limit
|
||||
}
|
||||
|
||||
func (CampaignTicketType) TableName() string { return "campaign_ticket_types" }
|
||||
|
||||
// Ticket represents an actual sold/reserved ticket
|
||||
type Ticket struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
OrderID *uint `gorm:"index" json:"order_id,omitempty"`
|
||||
Order *EshopOrder `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||
CampaignID uint `gorm:"not null;index" json:"campaign_id"`
|
||||
Campaign TicketCampaign `gorm:"foreignKey:CampaignID" json:"campaign,omitempty"`
|
||||
TicketTypeID uint `gorm:"not null;index" json:"ticket_type_id"`
|
||||
TicketType TicketType `gorm:"foreignKey:TicketTypeID" json:"ticket_type,omitempty"`
|
||||
|
||||
// Ticket holder information
|
||||
HolderName string `gorm:"not null" json:"holder_name"`
|
||||
HolderEmail string `gorm:"not null;index" json:"holder_email"`
|
||||
HolderPhone string `gorm:"size:50" json:"holder_phone"`
|
||||
|
||||
// Ticket details
|
||||
Quantity int `gorm:"not null;default:1" json:"quantity"`
|
||||
UnitPriceCents int64 `gorm:"not null" json:"unit_price_cents"`
|
||||
TotalPriceCents int64 `gorm:"not null" json:"total_price_cents"`
|
||||
Currency string `gorm:"size:10;default:'CZK'" json:"currency"`
|
||||
|
||||
// Status tracking
|
||||
Status string `gorm:"size:20;default:'reserved';index" json:"status"` // reserved, paid, cancelled, used
|
||||
Barcode string `gorm:"unique" json:"barcode"`
|
||||
QRCodeData string `gorm:"type:text" json:"qr_code_data"`
|
||||
|
||||
// Usage tracking
|
||||
UsedAt *time.Time `json:"used_at"`
|
||||
UsedBy *string `json:"used_by"`
|
||||
}
|
||||
|
||||
func (Ticket) TableName() string { return "tickets" }
|
||||
|
||||
// TicketAvailability tracks available tickets per campaign/type
|
||||
type TicketAvailability struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
CampaignID uint `gorm:"not null;uniqueIndex:idx_campaign_type" json:"campaign_id"`
|
||||
Campaign TicketCampaign `gorm:"foreignKey:CampaignID" json:"-"`
|
||||
TicketTypeID uint `gorm:"not null;uniqueIndex:idx_campaign_type" json:"ticket_type_id"`
|
||||
TicketType TicketType `gorm:"foreignKey:TicketTypeID" json:"-"`
|
||||
|
||||
TotalCapacity int `gorm:"not null;default:0" json:"total_capacity"`
|
||||
SoldQuantity int `gorm:"not null;default:0" json:"sold_quantity"`
|
||||
ReservedQuantity int `gorm:"not null;default:0" json:"reserved_quantity"`
|
||||
AvailableQuantity int `gorm:"-" json:"available_quantity"` // Computed field
|
||||
}
|
||||
|
||||
func (TicketAvailability) TableName() string { return "ticket_availability" }
|
||||
|
||||
// AvailableTicketView represents the database view for available tickets
|
||||
type AvailableTicketView struct {
|
||||
CampaignID uint `json:"campaign_id"`
|
||||
CampaignTitle string `json:"campaign_title"`
|
||||
CampaignDescription string `json:"campaign_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"`
|
||||
SaleEndTime time.Time `json:"sale_end_time"`
|
||||
|
||||
TicketTypeID uint `json:"ticket_type_id"`
|
||||
TicketTypeName string `json:"ticket_type_name"`
|
||||
TicketTypeDesc string `json:"ticket_type_description"`
|
||||
PriceCents int64 `json:"price_cents"`
|
||||
MaxPerOrder int `json:"max_per_order"`
|
||||
Color string `json:"color"`
|
||||
AvailableQuantity int `json:"available_quantity"`
|
||||
TotalCapacity int `json:"total_capacity"`
|
||||
SaleStatus string `json:"sale_status"` // upcoming, available, sold_out, ended
|
||||
}
|
||||
|
||||
func (AvailableTicketView) TableName() string { return "available_tickets_view" }
|
||||
|
||||
// Helper methods
|
||||
|
||||
// IsAvailable checks if tickets are currently available for purchase
|
||||
func (atv AvailableTicketView) IsAvailable() bool {
|
||||
return atv.SaleStatus == "available" && atv.AvailableQuantity > 0
|
||||
}
|
||||
|
||||
// GetPriceInCZK returns price formatted for display
|
||||
func (atv AvailableTicketView) GetPriceInCZK() float64 {
|
||||
return float64(atv.PriceCents) / 100.0
|
||||
}
|
||||
|
||||
// GetFormattedPrice returns price as string with CZK
|
||||
func (atv AvailableTicketView) GetFormattedPrice() string {
|
||||
return fmt.Sprintf("%.0f Kč", atv.GetPriceInCZK())
|
||||
}
|
||||
|
||||
// BeforeCreate hook for Ticket to generate barcode
|
||||
func (t *Ticket) BeforeCreate(tx *gorm.DB) error {
|
||||
if t.Barcode == "" {
|
||||
// Generate unique barcode
|
||||
t.Barcode = generateTicketBarcode()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateTicketBarcode creates a unique barcode for tickets
|
||||
func generateTicketBarcode() string {
|
||||
// Generate random number for uniqueness
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(10000))
|
||||
// Simple implementation - in production, use proper barcode generation
|
||||
return fmt.Sprintf("TKT-%d-%d", time.Now().Unix(), n.Int64())
|
||||
}
|
||||
+498
-117
@@ -29,7 +29,11 @@ func GetNewsletterAutomation() *services.NewsletterAutomation {
|
||||
func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
emailService := email.NewEmailService(config.AppConfig, db)
|
||||
|
||||
// Initialize services
|
||||
weatherService := services.NewWeatherService(db)
|
||||
|
||||
// Initialize controllers
|
||||
healthController := &controllers.HealthController{DB: db}
|
||||
baseController := &controllers.BaseController{DB: db}
|
||||
authController := controllers.NewAuthController(db)
|
||||
contactController := controllers.NewContactController(db, emailService)
|
||||
@@ -47,24 +51,47 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
navigationController := controllers.NewNavigationController(db)
|
||||
pollController := controllers.NewPollController(db)
|
||||
sweepstakesController := controllers.NewSweepstakesController(db, emailService)
|
||||
i18nController := controllers.NewI18nController()
|
||||
clothingController := controllers.NewClothingController(db)
|
||||
pageElementConfigController := controllers.NewPageElementConfigController(db)
|
||||
articleController := controllers.NewArticleController(db)
|
||||
var eshopAdminController *controllers.EshopAdminController
|
||||
if config.AppConfig.EshopEnabled {
|
||||
eshopAdminController = controllers.NewEshopAdminController(db)
|
||||
}
|
||||
myuibrixController := &controllers.MyUIbrixController{DB: db}
|
||||
editorPreviewController := controllers.NewEditorPreviewController(db)
|
||||
shortLinkController := controllers.NewShortLinkController(db)
|
||||
commentController := controllers.NewCommentController(db)
|
||||
engagementController := controllers.NewEngagementController(db, emailService)
|
||||
facrController := controllers.NewFACRController(db)
|
||||
manualFACRAdminController := controllers.NewManualFACRAdminController(db)
|
||||
youtubeController := controllers.NewYouTubeController(db)
|
||||
umamiController := controllers.NewUmamiController()
|
||||
imageProcessingController := &controllers.ImageProcessingController{}
|
||||
errorController := controllers.NewErrorController(db)
|
||||
directoryController := controllers.NewDirectoryController(db)
|
||||
financialController := controllers.NewFinancialController(db)
|
||||
invoiceController := controllers.NewInvoiceController(db)
|
||||
ticketController := controllers.NewTicketController(db)
|
||||
ticketCheckoutController := controllers.NewTicketCheckoutController(db)
|
||||
qrCodeController := controllers.NewQRCodeController(db)
|
||||
|
||||
// Facility management controllers
|
||||
facilityController := controllers.NewFacilityController()
|
||||
equipmentController := controllers.NewEquipmentController()
|
||||
maintenanceController := controllers.NewMaintenanceController()
|
||||
facilityWeatherController := controllers.NewWeatherController("") // For facilities
|
||||
adminWeatherController := controllers.NewAdminWeatherController(weatherService) // For admin dashboard
|
||||
bookingCalendarController := controllers.NewBookingCalendarController()
|
||||
|
||||
// API v1 group
|
||||
{
|
||||
// Health check
|
||||
api.GET("/health", baseController.HealthCheck)
|
||||
api.GET("/health/live", healthController.Liveness)
|
||||
api.GET("/health/ready", healthController.Readiness)
|
||||
api.GET("/health/full", healthController.Health)
|
||||
|
||||
// CSRF token for cookie-based clients
|
||||
api.GET("/csrf-token", middleware.GetCSRFToken)
|
||||
@@ -85,6 +112,10 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
// Public shortlink creation for visitors (same-site only)
|
||||
api.POST("/shortlinks/public", middleware.RateLimit(30, time.Minute), shortLinkController.PublicCreateShortLink)
|
||||
|
||||
// Public i18n endpoints
|
||||
api.GET("/i18n/languages", i18nController.GetLanguages)
|
||||
api.GET("/i18n/translations/:language", i18nController.GetTranslations)
|
||||
|
||||
// Email tracking (public)
|
||||
api.GET("/email/open.gif", emailController.OpenPixel)
|
||||
api.GET("/email/click", emailController.ClickRedirect)
|
||||
@@ -103,6 +134,11 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
|
||||
api.POST("/errors", middleware.RateLimit(120, time.Minute), errorController.Ingest)
|
||||
|
||||
// Weather endpoints (public)
|
||||
api.GET("/weather", adminWeatherController.GetWeather)
|
||||
api.GET("/weather/club", adminWeatherController.GetWeatherForClub)
|
||||
api.GET("/weather/match", adminWeatherController.GetWeatherForMatch)
|
||||
|
||||
// Auth routes
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
@@ -149,6 +185,9 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
// CSRF protect state-changing requests when relying on cookies (Bearer tokens are auto-exempt)
|
||||
protected.Use(middleware.CSRFProtection())
|
||||
{
|
||||
// User language preference
|
||||
protected.POST("/i18n/user-language", i18nController.SetUserLanguage)
|
||||
|
||||
// Sweepstakes (protected)
|
||||
protected.POST("/sweepstakes/:id/enter", middleware.RateLimit(30, time.Minute), sweepstakesController.Enter)
|
||||
protected.POST("/sweepstakes/:id/played", sweepstakesController.MarkVisualPlayed)
|
||||
@@ -205,6 +244,13 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
ai.POST("/about/generate", aiController.GenerateAboutPage)
|
||||
ai.POST("/css/generate", aiController.GenerateCSS)
|
||||
ai.POST("/instagram/generate", aiController.GenerateInstagram)
|
||||
ai.POST("/instagram/images", aiController.GenerateInstagramImages)
|
||||
ai.POST("/main-image/generate", aiController.GenerateMainImage)
|
||||
ai.POST("/ocr/process", aiController.ProcessOCR)
|
||||
ai.POST("/voice/transcribe", aiController.TranscribeAudio)
|
||||
ai.POST("/test-parse", aiController.TestAIParse)
|
||||
ai.POST("/translate", aiController.TranslateText)
|
||||
ai.GET("/usage/status", aiController.GetAIUsageStatus)
|
||||
}
|
||||
|
||||
// User profile
|
||||
@@ -245,7 +291,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
|
||||
// Teams (protected)
|
||||
teams := protected.Group("/teams")
|
||||
teams.Use(middleware.RoleAuth("admin"))
|
||||
teams.Use(middleware.RoleAuth("editor"))
|
||||
{
|
||||
teams.POST("", baseController.CreateTeam)
|
||||
teams.PUT("/:id", baseController.UpdateTeam)
|
||||
@@ -254,7 +300,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
|
||||
// Players (protected)
|
||||
players := protected.Group("/players")
|
||||
players.Use(middleware.RoleAuth("admin"))
|
||||
players.Use(middleware.RoleAuth("editor"))
|
||||
{
|
||||
players.POST("", baseController.CreatePlayer)
|
||||
players.PUT("/:id", baseController.UpdatePlayer)
|
||||
@@ -283,6 +329,19 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
admin := protected.Group("/admin")
|
||||
admin.Use(middleware.RoleAuth("admin"))
|
||||
{
|
||||
// i18n management
|
||||
i18nAdmin := admin.Group("/i18n")
|
||||
{
|
||||
i18nAdmin.GET("/languages", i18nController.AdminGetAllLanguages)
|
||||
i18nAdmin.POST("/languages", i18nController.AdminCreateLanguage)
|
||||
i18nAdmin.PUT("/languages/:id", i18nController.AdminUpdateLanguage)
|
||||
i18nAdmin.DELETE("/languages/:id", i18nController.AdminDeleteLanguage)
|
||||
i18nAdmin.GET("/translations", i18nController.AdminGetTranslations)
|
||||
i18nAdmin.POST("/translations", i18nController.AdminCreateTranslation)
|
||||
i18nAdmin.PUT("/translations/:id", i18nController.AdminUpdateTranslation)
|
||||
i18nAdmin.DELETE("/translations/:id", i18nController.AdminDeleteTranslation)
|
||||
}
|
||||
|
||||
// Errors
|
||||
admin.GET("/errors", errorController.AdminList)
|
||||
admin.GET("/errors/:id", errorController.AdminGet)
|
||||
@@ -290,6 +349,11 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
admin.GET("/errors/external", errorController.AdminListExternal)
|
||||
admin.GET("/errors/external/:id", errorController.AdminGetExternal)
|
||||
|
||||
// Directory Service
|
||||
admin.POST("/directory/register", directoryController.RegisterInstance)
|
||||
admin.POST("/directory/heartbeat", directoryController.Heartbeat)
|
||||
admin.GET("/directory/info", directoryController.GetInstanceInfo)
|
||||
|
||||
// Comments
|
||||
commentsAdmin := admin.Group("/comments")
|
||||
{
|
||||
@@ -301,8 +365,41 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
commentsAdmin.GET("/unban-requests", commentController.AdminListUnban)
|
||||
commentsAdmin.POST("/unban-requests/:id/resolve", commentController.AdminResolveUnban)
|
||||
}
|
||||
|
||||
// E-shop product management (admin)
|
||||
if config.AppConfig.EshopEnabled {
|
||||
eshop := admin.Group("/eshop")
|
||||
{
|
||||
// Products
|
||||
eshop.GET("/products", eshopAdminController.AdminListProducts)
|
||||
eshop.GET("/products/:id", eshopAdminController.AdminGetProduct)
|
||||
eshop.POST("/products", eshopAdminController.AdminCreateProduct)
|
||||
eshop.PUT("/products/:id", eshopAdminController.AdminUpdateProduct)
|
||||
eshop.DELETE("/products/:id", eshopAdminController.AdminDeleteProduct)
|
||||
// Variants
|
||||
eshop.GET("/products/:id/variants", eshopAdminController.AdminListVariants)
|
||||
eshop.POST("/products/:id/variants", eshopAdminController.AdminCreateVariant)
|
||||
eshop.PUT("/variants/:id", eshopAdminController.AdminUpdateVariant)
|
||||
eshop.DELETE("/variants/:id", eshopAdminController.AdminDeleteVariant)
|
||||
}
|
||||
}
|
||||
// Admin-only endpoints for managing sponsors, etc. (user CRUD removed; no handlers defined)
|
||||
|
||||
// Manual FACR (manual club data mode) CSV templates & imports
|
||||
manual := admin.Group("/manual")
|
||||
{
|
||||
manual.GET("/competitions", manualFACRAdminController.ListManualCompetitions)
|
||||
manual.POST("/competitions", manualFACRAdminController.CreateManualCompetition)
|
||||
manual.PUT("/competitions/:id", manualFACRAdminController.UpdateManualCompetition)
|
||||
manual.DELETE("/competitions/:id", manualFACRAdminController.DeleteManualCompetition)
|
||||
manual.GET("/matches/template", manualFACRAdminController.GetMatchesTemplateCSV)
|
||||
manual.GET("/tables/template", manualFACRAdminController.GetTablesTemplateCSV)
|
||||
manual.POST("/matches/import", manualFACRAdminController.ImportMatchesCSV)
|
||||
manual.POST("/tables/import", manualFACRAdminController.ImportTablesCSV)
|
||||
manual.POST("/matches/import-json", manualFACRAdminController.ImportMatchesJSON)
|
||||
manual.POST("/tables/import-json", manualFACRAdminController.ImportTablesJSON)
|
||||
}
|
||||
|
||||
// Competition aliases (admin)
|
||||
admin.GET("/competition-aliases", baseController.GetCompetitionAliases)
|
||||
admin.PUT("/competition-aliases/:code", baseController.PutCompetitionAlias)
|
||||
@@ -385,6 +482,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
|
||||
// Newsletter management
|
||||
admin.GET("/newsletter/subscribers", contactController.GetNewsletterSubscribers)
|
||||
admin.POST("/newsletter/subscribers", contactController.CreateNewsletterSubscriber)
|
||||
admin.POST("/newsletter/send", contactController.SendNewsletter)
|
||||
admin.POST("/newsletter/preview", contactController.PreviewNewsletter)
|
||||
admin.POST("/newsletter/test", contactController.SendNewsletterTest)
|
||||
@@ -484,6 +582,44 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
clothing.POST("/reorder", clothingController.UpdateClothingOrder)
|
||||
}
|
||||
|
||||
// Admin QR codes management
|
||||
qr := admin.Group("/qr-codes")
|
||||
{
|
||||
qr.GET("", qrCodeController.GetQRCodes)
|
||||
qr.POST("", qrCodeController.CreateQRCode)
|
||||
qr.GET("/:id", qrCodeController.GetQRCode)
|
||||
qr.PUT("/:id", qrCodeController.UpdateQRCode)
|
||||
qr.DELETE("/:id", qrCodeController.DeleteQRCode)
|
||||
}
|
||||
|
||||
// Facility management (admin)
|
||||
facilities := admin.Group("/facilities")
|
||||
{
|
||||
facilities.GET("", facilityController.GetFacilities)
|
||||
facilities.GET("/:id", facilityController.GetFacility)
|
||||
facilities.POST("", facilityController.CreateFacility)
|
||||
facilities.PUT("/:id", facilityController.UpdateFacility)
|
||||
facilities.DELETE("/:id", facilityController.DeleteFacility)
|
||||
facilities.GET("/:id/bookings", facilityController.GetFacilityBookings)
|
||||
}
|
||||
|
||||
// Equipment management (admin)
|
||||
equipment := admin.Group("/equipment")
|
||||
{
|
||||
equipment.GET("", equipmentController.GetEquipment)
|
||||
equipment.POST("", equipmentController.CreateEquipment)
|
||||
equipment.PUT("/:id", equipmentController.UpdateEquipment)
|
||||
equipment.DELETE("/:id", equipmentController.DeleteEquipment)
|
||||
}
|
||||
|
||||
// Maintenance management (admin)
|
||||
maintenance := admin.Group("/maintenance")
|
||||
{
|
||||
maintenance.GET("", maintenanceController.GetMaintenance)
|
||||
maintenance.POST("", maintenanceController.CreateMaintenance)
|
||||
maintenance.PUT("/:id", maintenanceController.UpdateMaintenance)
|
||||
}
|
||||
|
||||
// Polls management (admin)
|
||||
polls := admin.Group("/polls")
|
||||
{
|
||||
@@ -560,125 +696,370 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
sw.POST("/:id/finalize", sweepstakesController.AdminFinalize)
|
||||
}
|
||||
|
||||
// Financial management (admin)
|
||||
financial := admin.Group("/financial")
|
||||
{
|
||||
// Dashboard and overview
|
||||
financial.GET("/dashboard", financialController.GetFinancialDashboard)
|
||||
|
||||
// Budget management
|
||||
budgets := financial.Group("/budgets")
|
||||
{
|
||||
budgets.GET("", financialController.GetBudgets)
|
||||
budgets.GET("/:id", financialController.GetBudget)
|
||||
budgets.POST("", financialController.CreateBudget)
|
||||
budgets.PUT("/:id", financialController.UpdateBudget)
|
||||
budgets.DELETE("/:id", financialController.DeleteBudget)
|
||||
budgets.GET("/categories", financialController.GetBudgetCategories)
|
||||
budgets.GET("/overview", financialController.GetBudgetOverview)
|
||||
}
|
||||
|
||||
// Sponsorship management
|
||||
sponsorships := financial.Group("/sponsorships")
|
||||
{
|
||||
sponsorships.GET("", financialController.GetSponsorships)
|
||||
sponsorships.GET("/:id", financialController.GetSponsorship)
|
||||
sponsorships.POST("", financialController.CreateSponsorship)
|
||||
sponsorships.PUT("/:id", financialController.UpdateSponsorship)
|
||||
sponsorships.DELETE("/:id", financialController.DeleteSponsorship)
|
||||
sponsorships.GET("/overview", financialController.GetSponsorshipOverview)
|
||||
}
|
||||
|
||||
// Expense management
|
||||
expenses := financial.Group("/expenses")
|
||||
{
|
||||
expenses.GET("", financialController.GetExpenses)
|
||||
expenses.GET("/:id", financialController.GetExpense)
|
||||
expenses.POST("", financialController.CreateExpense)
|
||||
expenses.PUT("/:id", financialController.UpdateExpense)
|
||||
expenses.DELETE("/:id", financialController.DeleteExpense)
|
||||
expenses.PATCH("/:id/approve", financialController.ApproveExpense)
|
||||
expenses.PATCH("/:id/reject", financialController.RejectExpense)
|
||||
expenses.POST("/upload-receipt", financialController.UploadReceipt)
|
||||
expenses.GET("/categories", financialController.GetExpenseCategories)
|
||||
expenses.GET("/overview", financialController.GetExpenseOverview)
|
||||
}
|
||||
|
||||
// Reports and analytics
|
||||
reports := financial.Group("/reports")
|
||||
{
|
||||
reports.GET("", financialController.GetFinancialReports)
|
||||
reports.POST("/generate", financialController.GenerateFinancialReport)
|
||||
reports.GET("/:id", financialController.GetFinancialReport)
|
||||
reports.DELETE("/:id", financialController.DeleteFinancialReport)
|
||||
}
|
||||
|
||||
// Settings
|
||||
financial.GET("/settings", financialController.GetFinancialSettings)
|
||||
financial.PUT("/settings", financialController.UpdateFinancialSettings)
|
||||
}
|
||||
|
||||
// Invoice management (admin)
|
||||
invoices := admin.Group("/invoices")
|
||||
{
|
||||
// Invoice CRUD
|
||||
invoices.GET("", invoiceController.GetInvoices)
|
||||
invoices.GET("/:id", invoiceController.GetInvoice)
|
||||
invoices.POST("", invoiceController.CreateInvoice)
|
||||
invoices.PUT("/:id", invoiceController.UpdateInvoice)
|
||||
invoices.DELETE("/:id", invoiceController.DeleteInvoice)
|
||||
|
||||
// Invoice operations
|
||||
invoices.POST("/:id/generate-pdf", invoiceController.GenerateInvoicePDF)
|
||||
invoices.POST("/:id/send", invoiceController.SendInvoice)
|
||||
|
||||
// Customer management
|
||||
customers := invoices.Group("/customers")
|
||||
{
|
||||
customers.GET("", invoiceController.GetCustomers)
|
||||
customers.GET("/:id", invoiceController.GetCustomer)
|
||||
customers.POST("", invoiceController.CreateCustomer)
|
||||
customers.PUT("/:id", invoiceController.UpdateCustomer)
|
||||
customers.DELETE("/:id", invoiceController.DeleteCustomer)
|
||||
customers.GET("/autofill", invoiceController.AutofillCustomerByICO)
|
||||
}
|
||||
|
||||
// Settings
|
||||
invoices.GET("/settings", invoiceController.GetInvoiceSettings)
|
||||
invoices.PUT("/settings", invoiceController.UpdateInvoiceSettings)
|
||||
invoices.GET("/ares-search/:ico", invoiceController.SearchSupplierByICO)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Protected routes end
|
||||
RegisterAnalyticsRoutes(api, db)
|
||||
RegisterContactInfoRoutes(api, db)
|
||||
|
||||
api.POST("/upload", middleware.RateLimit(30, time.Minute), baseController.UploadImage)
|
||||
|
||||
imageProcessing := api.Group("/image-processing")
|
||||
imageProcessing.Use(middleware.JWTAuth(db))
|
||||
imageProcessing.Use(middleware.CSRFProtection())
|
||||
imageProcessing.Use(middleware.RoleAuth("editor"))
|
||||
{
|
||||
imageProcessing.POST("/process", imageProcessingController.ProcessImage)
|
||||
imageProcessing.POST("/crop-upload", imageProcessingController.CropAndUpload)
|
||||
imageProcessing.POST("/quick-edit", imageProcessingController.QuickEdit)
|
||||
}
|
||||
|
||||
api.GET("/scoreboard", scoreboardController.GetPublic)
|
||||
api.GET("/scoreboard/colors/derive", scoreboardController.DeriveColors)
|
||||
api.GET("/scoreboard/sponsors", scoreboardController.ListSponsors)
|
||||
api.GET("/scoreboard/qr", scoreboardController.GetQR)
|
||||
|
||||
api.GET("/settings", baseController.GetPublicSettings)
|
||||
api.GET("/competition-aliases", baseController.GetPublicCompetitionAliases)
|
||||
api.GET("/public/team-logo-overrides", baseController.GetPublicTeamLogoOverrides)
|
||||
|
||||
// Articles (public; use optional auth so admin/editor can see drafts in list when requesting published=false)
|
||||
articlesPub := api.Group("/articles")
|
||||
articlesPub.Use(middleware.JWTOptional(db))
|
||||
{
|
||||
articlesPub.GET("/featured", baseController.GetFeaturedArticles)
|
||||
articlesPub.GET("", baseController.GetArticles)
|
||||
articlesPub.GET("/slug/:slug", baseController.GetArticleBySlug)
|
||||
articlesPub.GET("/:id", baseController.GetArticle)
|
||||
articlesPub.POST("/:id/read", baseController.IncrementArticleRead)
|
||||
articlesPub.POST("/:id/track-view", baseController.TrackArticleView)
|
||||
articlesPub.GET("/:id/match-link", baseController.GetArticleMatchLink)
|
||||
}
|
||||
api.GET("/categories", baseController.GetCategories)
|
||||
api.GET("/youtube/videos", youtubeController.GetYouTubeVideos)
|
||||
api.GET("/about", aboutController.GetPublicAboutPage)
|
||||
api.GET("/teams", baseController.GetTeams)
|
||||
api.GET("/teams/:id", baseController.GetTeam)
|
||||
api.GET("/players", baseController.GetPlayers)
|
||||
api.GET("/players/:id", baseController.GetPlayer)
|
||||
api.GET("/sponsors", baseController.GetSponsors)
|
||||
api.GET("/banners", baseController.GetBanners)
|
||||
api.GET("/matches", baseController.GetMatches)
|
||||
api.GET("/matches/history", baseController.GetMatchesHistory)
|
||||
api.GET("/standings", baseController.GetStandings)
|
||||
|
||||
api.GET("/gallery/albums", galleryController.GetGalleryAlbums)
|
||||
api.GET("/gallery/albums/:id", galleryController.GetGalleryAlbum)
|
||||
api.GET("/gallery/proxy-image", galleryController.ProxyImage)
|
||||
|
||||
api.GET("/zonerama/album", baseController.GetZoneramaAlbum)
|
||||
api.GET("/zonerama-album", baseController.GetZoneramaAlbum)
|
||||
api.GET("/zonerama/picks", baseController.GetZoneramaPicks)
|
||||
|
||||
api.GET("/clothing", clothingController.GetClothing)
|
||||
api.GET("/sweepstakes/current", sweepstakesController.GetCurrent)
|
||||
api.GET("/sweepstakes/:id/visual", sweepstakesController.PublicVisualData)
|
||||
|
||||
pollsPub := api.Group("/polls")
|
||||
pollsPub.Use(middleware.JWTOptional(db))
|
||||
{
|
||||
pollsPub.GET("", pollController.GetPolls)
|
||||
pollsPub.GET("/:id", pollController.GetPoll)
|
||||
pollsPub.POST("/:id/vote", middleware.RateLimit(10, time.Minute), pollController.Vote)
|
||||
pollsPub.GET("/:id/results", pollController.GetPollResults)
|
||||
}
|
||||
|
||||
api.POST("/contact", middleware.RateLimit(10, time.Minute), contactController.SubmitContactForm)
|
||||
api.POST("/newsletter/subscribe", middleware.RateLimit(30, time.Minute), contactController.SubscribeToNewsletter)
|
||||
api.POST("/newsletter/unsubscribe/:email", middleware.RateLimit(30, time.Minute), contactController.UnsubscribeFromNewsletter)
|
||||
api.POST("/newsletter/setup", middleware.RateLimit(30, time.Minute), contactController.SetupNewsletterPreferences)
|
||||
api.GET("/newsletter/preferences", contactController.GetNewsletterPreferencesByToken)
|
||||
api.POST("/newsletter/preferences", contactController.SaveNewsletterPreferencesByToken)
|
||||
api.POST("/newsletter/unsubscribe-token", contactController.UnsubscribeByToken)
|
||||
|
||||
facr := api.Group("/facr")
|
||||
{
|
||||
facr.GET("/club/search", facrController.SearchClubs)
|
||||
facr.GET("/club/:type/:id", facrController.GetClubInfo)
|
||||
facr.GET("/club/:type/:id/table", facrController.GetClubTables)
|
||||
}
|
||||
|
||||
// Public facility management routes
|
||||
api.GET("/facilities", facilityController.GetPublicFacilities)
|
||||
api.GET("/facilities/calendar", bookingCalendarController.GetCalendarEvents)
|
||||
api.POST("/facilities/bookings", middleware.JWTAuth(db), facilityController.CreateBooking)
|
||||
api.GET("/facilities/:id", facilityController.GetFacility)
|
||||
api.GET("/facilities/:id/availability", facilityController.GetFacilityAvailability)
|
||||
api.GET("/facilities/:id/weather", facilityWeatherController.GetWeatherForecast)
|
||||
|
||||
// Public ticket endpoints
|
||||
tickets := api.Group("/tickets")
|
||||
{
|
||||
tickets.GET("/available", ticketController.GetAvailableTickets)
|
||||
tickets.GET("/campaigns", ticketController.GetCampaigns)
|
||||
tickets.GET("/campaigns/:id", ticketController.GetCampaign)
|
||||
// Ticket reservation and validation require auth
|
||||
tickets.Use(middleware.JWTAuth(db))
|
||||
{
|
||||
tickets.POST("/reserve", ticketController.ReserveTickets)
|
||||
tickets.POST("/:id/confirm", ticketController.ConfirmTicket)
|
||||
tickets.POST("/:id/validate", ticketController.ValidateTicket)
|
||||
}
|
||||
}
|
||||
|
||||
// Ticket checkout (requires auth)
|
||||
ticketCheckout := api.Group("/ticket-checkout")
|
||||
ticketCheckout.Use(middleware.JWTAuth(db))
|
||||
{
|
||||
ticketCheckout.POST("/order", ticketCheckoutController.CreateTicketOrder)
|
||||
ticketCheckout.POST("/orders/:order_id/complete", ticketCheckoutController.CompleteTicketOrder)
|
||||
ticketCheckout.GET("/orders", ticketCheckoutController.GetTicketOrders)
|
||||
}
|
||||
|
||||
// User tickets and QR codes (require auth)
|
||||
userTickets := api.Group("/tickets")
|
||||
userTickets.Use(middleware.JWTAuth(db))
|
||||
{
|
||||
userTickets.GET("/my-tickets", ticketController.GetMyTickets)
|
||||
userTickets.GET("/:id/qr", qrCodeController.GenerateTicketQR)
|
||||
userTickets.GET("/:id/qr-download", qrCodeController.DownloadTicketQR)
|
||||
userTickets.POST("/validate-qr", qrCodeController.ValidateTicketFromQR)
|
||||
}
|
||||
|
||||
api.GET("/umami/config", umamiController.GetUmamiConfig)
|
||||
api.POST("/umami/initialize-setup", umamiController.InitializeUmamiSetup)
|
||||
// Adblock-safe public alias for config (avoids 'umami' keyword)
|
||||
api.GET("/insights/config", umamiController.GetUmamiConfig)
|
||||
|
||||
// ... (rest of the code remains the same)
|
||||
umami := api.Group("/admin/umami")
|
||||
umami.Use(middleware.JWTAuth(db))
|
||||
umami.Use(middleware.RoleAuth("admin"))
|
||||
{
|
||||
umami.POST("/initialize", umamiController.InitializeUmami)
|
||||
umami.GET("/stats", umamiController.GetStats)
|
||||
umami.GET("/metrics/:type", umamiController.GetMetrics)
|
||||
umami.GET("/pageviews", umamiController.GetPageviews)
|
||||
}
|
||||
|
||||
// Adblock-safe admin aliases (avoid 'umami'/'metrics' in path)
|
||||
insights := api.Group("/admin/insights")
|
||||
insights.Use(middleware.JWTAuth(db))
|
||||
insights.Use(middleware.RoleAuth("admin"))
|
||||
{
|
||||
insights.POST("/initialize", umamiController.InitializeUmami)
|
||||
insights.GET("/summary", umamiController.GetStats)
|
||||
insights.GET("/breakdown/:type", umamiController.GetMetrics)
|
||||
insights.GET("/pageviews", umamiController.GetPageviews)
|
||||
}
|
||||
|
||||
// Accountant routes (role: accountant)
|
||||
// Accountants have access to financial management, budgets, expenses, sponsorships, and invoices
|
||||
accountant := api.Group("/accountant")
|
||||
accountant.Use(middleware.JWTAuth(db))
|
||||
accountant.Use(middleware.RoleAuth("accountant"))
|
||||
{
|
||||
// Financial dashboard
|
||||
accountant.GET("/dashboard", financialController.GetFinancialDashboard)
|
||||
|
||||
// Budget management
|
||||
budgets := accountant.Group("/budgets")
|
||||
{
|
||||
budgets.GET("", financialController.GetBudgets)
|
||||
budgets.GET("/:id", financialController.GetBudget)
|
||||
budgets.POST("", financialController.CreateBudget)
|
||||
budgets.PUT("/:id", financialController.UpdateBudget)
|
||||
budgets.DELETE("/:id", financialController.DeleteBudget)
|
||||
budgets.GET("/categories", financialController.GetBudgetCategories)
|
||||
budgets.GET("/overview", financialController.GetBudgetOverview)
|
||||
}
|
||||
|
||||
// Sponsorship management
|
||||
sponsorships := accountant.Group("/sponsorships")
|
||||
{
|
||||
sponsorships.GET("", financialController.GetSponsorships)
|
||||
sponsorships.GET("/:id", financialController.GetSponsorship)
|
||||
sponsorships.POST("", financialController.CreateSponsorship)
|
||||
sponsorships.PUT("/:id", financialController.UpdateSponsorship)
|
||||
sponsorships.DELETE("/:id", financialController.DeleteSponsorship)
|
||||
sponsorships.GET("/overview", financialController.GetSponsorshipOverview)
|
||||
}
|
||||
|
||||
// Expense management
|
||||
expenses := accountant.Group("/expenses")
|
||||
{
|
||||
expenses.GET("", financialController.GetExpenses)
|
||||
expenses.GET("/:id", financialController.GetExpense)
|
||||
expenses.POST("", financialController.CreateExpense)
|
||||
expenses.PUT("/:id", financialController.UpdateExpense)
|
||||
expenses.DELETE("/:id", financialController.DeleteExpense)
|
||||
expenses.PATCH("/:id/approve", financialController.ApproveExpense)
|
||||
expenses.PATCH("/:id/reject", financialController.RejectExpense)
|
||||
expenses.POST("/upload-receipt", financialController.UploadReceipt)
|
||||
expenses.GET("/categories", financialController.GetExpenseCategories)
|
||||
expenses.GET("/overview", financialController.GetExpenseOverview)
|
||||
}
|
||||
|
||||
// Financial reports
|
||||
reports := accountant.Group("/reports")
|
||||
{
|
||||
reports.GET("", financialController.GetFinancialReports)
|
||||
reports.POST("/generate", financialController.GenerateFinancialReport)
|
||||
reports.GET("/:id", financialController.GetFinancialReport)
|
||||
reports.DELETE("/:id", financialController.DeleteFinancialReport)
|
||||
}
|
||||
|
||||
// Financial settings
|
||||
accountant.GET("/settings", financialController.GetFinancialSettings)
|
||||
accountant.PUT("/settings", financialController.UpdateFinancialSettings)
|
||||
|
||||
// Invoice management
|
||||
invoices := accountant.Group("/invoices")
|
||||
{
|
||||
// Invoice CRUD
|
||||
invoices.GET("", invoiceController.GetInvoices)
|
||||
invoices.GET("/:id", invoiceController.GetInvoice)
|
||||
invoices.POST("", invoiceController.CreateInvoice)
|
||||
invoices.PUT("/:id", invoiceController.UpdateInvoice)
|
||||
invoices.DELETE("/:id", invoiceController.DeleteInvoice)
|
||||
|
||||
// Invoice operations
|
||||
invoices.POST("/:id/generate-pdf", invoiceController.GenerateInvoicePDF)
|
||||
invoices.POST("/:id/send", invoiceController.SendInvoice)
|
||||
|
||||
// Customer management
|
||||
customers := invoices.Group("/customers")
|
||||
{
|
||||
customers.GET("", invoiceController.GetCustomers)
|
||||
customers.GET("/:id", invoiceController.GetCustomer)
|
||||
customers.POST("", invoiceController.CreateCustomer)
|
||||
customers.PUT("/:id", invoiceController.UpdateCustomer)
|
||||
customers.DELETE("/:id", invoiceController.DeleteCustomer)
|
||||
customers.GET("/autofill", invoiceController.AutofillCustomerByICO)
|
||||
}
|
||||
|
||||
// Invoice settings
|
||||
invoices.GET("/settings", invoiceController.GetInvoiceSettings)
|
||||
invoices.PUT("/settings", invoiceController.UpdateInvoiceSettings)
|
||||
invoices.GET("/ares-search/:ico", invoiceController.SearchSupplierByICO)
|
||||
}
|
||||
|
||||
// Ticket management (admin)
|
||||
ticketsAdmin := admin.Group("/tickets")
|
||||
{
|
||||
// Campaigns
|
||||
ticketsAdmin.GET("/campaigns", ticketController.AdminGetCampaigns)
|
||||
ticketsAdmin.POST("/campaigns", ticketController.AdminCreateCampaign)
|
||||
ticketsAdmin.GET("/campaigns/:id", ticketController.GetCampaign)
|
||||
ticketsAdmin.PUT("/campaigns/:id", ticketController.AdminUpdateCampaign)
|
||||
ticketsAdmin.DELETE("/campaigns/:id", ticketController.AdminDeleteCampaign)
|
||||
|
||||
// Ticket types
|
||||
ticketsAdmin.GET("/types", ticketController.AdminGetTicketTypes)
|
||||
ticketsAdmin.POST("/types", ticketController.AdminCreateTicketType)
|
||||
ticketsAdmin.GET("/types/:id", ticketController.AdminGetTicketType)
|
||||
ticketsAdmin.PUT("/types/:id", ticketController.AdminUpdateTicketType)
|
||||
ticketsAdmin.DELETE("/types/:id", ticketController.AdminDeleteTicketType)
|
||||
|
||||
// Ticket management
|
||||
ticketsAdmin.GET("", ticketController.AdminGetTickets)
|
||||
ticketsAdmin.GET("/:id", ticketController.AdminGetTicket)
|
||||
ticketsAdmin.PATCH("/:id/status", ticketController.AdminUpdateTicketStatus)
|
||||
ticketsAdmin.POST("/:id/validate", ticketController.AdminValidateTicket)
|
||||
ticketsAdmin.GET("/sales/overview", ticketController.AdminGetSalesOverview)
|
||||
ticketsAdmin.GET("/sales/export", ticketController.AdminExportSales)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RegisterAnalyticsRoutes(api, db)
|
||||
|
||||
api.GET("/umami/config", umamiController.GetUmamiConfig)
|
||||
api.POST("/umami/initialize-setup", umamiController.InitializeUmamiSetup)
|
||||
// Adblock-safe public alias for config (avoids 'umami' keyword)
|
||||
api.GET("/insights/config", umamiController.GetUmamiConfig)
|
||||
|
||||
umami := api.Group("/admin/umami")
|
||||
umami.Use(middleware.JWTAuth(db))
|
||||
umami.Use(middleware.RoleAuth("admin"))
|
||||
{
|
||||
umami.POST("/initialize", umamiController.InitializeUmami)
|
||||
umami.GET("/stats", umamiController.GetStats)
|
||||
umami.GET("/metrics/:type", umamiController.GetMetrics)
|
||||
umami.GET("/pageviews", umamiController.GetPageviews)
|
||||
}
|
||||
|
||||
// Adblock-safe admin aliases (avoid 'umami'/'metrics' in path)
|
||||
insights := api.Group("/admin/insights")
|
||||
insights.Use(middleware.JWTAuth(db))
|
||||
insights.Use(middleware.RoleAuth("admin"))
|
||||
{
|
||||
insights.POST("/initialize", umamiController.InitializeUmami)
|
||||
insights.GET("/summary", umamiController.GetStats)
|
||||
insights.GET("/breakdown/:type", umamiController.GetMetrics)
|
||||
insights.GET("/pageviews", umamiController.GetPageviews)
|
||||
}
|
||||
|
||||
RegisterContactInfoRoutes(api, db)
|
||||
|
||||
api.POST("/upload", middleware.RateLimit(30, time.Minute), baseController.UploadImage)
|
||||
|
||||
imageProcessing := api.Group("/image-processing")
|
||||
imageProcessing.Use(middleware.JWTAuth(db))
|
||||
imageProcessing.Use(middleware.CSRFProtection())
|
||||
imageProcessing.Use(middleware.RoleAuth("editor"))
|
||||
{
|
||||
imageProcessing.POST("/process", imageProcessingController.ProcessImage)
|
||||
imageProcessing.POST("/crop-upload", imageProcessingController.CropAndUpload)
|
||||
imageProcessing.POST("/quick-edit", imageProcessingController.QuickEdit)
|
||||
}
|
||||
|
||||
api.GET("/scoreboard", scoreboardController.GetPublic)
|
||||
api.GET("/scoreboard/colors/derive", scoreboardController.DeriveColors)
|
||||
api.GET("/scoreboard/sponsors", scoreboardController.ListSponsors)
|
||||
api.GET("/scoreboard/qr", scoreboardController.GetQR)
|
||||
|
||||
api.GET("/settings", baseController.GetPublicSettings)
|
||||
api.GET("/competition-aliases", baseController.GetPublicCompetitionAliases)
|
||||
api.GET("/public/team-logo-overrides", baseController.GetPublicTeamLogoOverrides)
|
||||
|
||||
// Articles (public; use optional auth so admin/editor can see drafts in list when requesting published=false)
|
||||
articlesPub := api.Group("/articles")
|
||||
articlesPub.Use(middleware.JWTOptional(db))
|
||||
{
|
||||
articlesPub.GET("/featured", baseController.GetFeaturedArticles)
|
||||
articlesPub.GET("", baseController.GetArticles)
|
||||
articlesPub.GET("/slug/:slug", baseController.GetArticleBySlug)
|
||||
articlesPub.GET("/:id", baseController.GetArticle)
|
||||
articlesPub.POST("/:id/read", baseController.IncrementArticleRead)
|
||||
articlesPub.POST("/:id/track-view", baseController.TrackArticleView)
|
||||
articlesPub.GET("/:id/match-link", baseController.GetArticleMatchLink)
|
||||
}
|
||||
api.GET("/categories", baseController.GetCategories)
|
||||
api.GET("/youtube/videos", youtubeController.GetYouTubeVideos)
|
||||
api.GET("/about", aboutController.GetPublicAboutPage)
|
||||
api.GET("/teams", baseController.GetTeams)
|
||||
api.GET("/teams/:id", baseController.GetTeam)
|
||||
api.GET("/players", baseController.GetPlayers)
|
||||
api.GET("/players/:id", baseController.GetPlayer)
|
||||
api.GET("/sponsors", baseController.GetSponsors)
|
||||
api.GET("/banners", baseController.GetBanners)
|
||||
api.GET("/matches", baseController.GetMatches)
|
||||
api.GET("/matches/history", baseController.GetMatchesHistory)
|
||||
api.GET("/standings", baseController.GetStandings)
|
||||
|
||||
api.GET("/gallery/albums", galleryController.GetGalleryAlbums)
|
||||
api.GET("/gallery/albums/:id", galleryController.GetGalleryAlbum)
|
||||
api.GET("/gallery/proxy-image", galleryController.ProxyImage)
|
||||
|
||||
api.GET("/zonerama/album", baseController.GetZoneramaAlbum)
|
||||
api.GET("/zonerama-album", baseController.GetZoneramaAlbum)
|
||||
api.GET("/zonerama/picks", baseController.GetZoneramaPicks)
|
||||
|
||||
api.GET("/clothing", clothingController.GetClothing)
|
||||
api.GET("/sweepstakes/current", sweepstakesController.GetCurrent)
|
||||
api.GET("/sweepstakes/:id/visual", sweepstakesController.PublicVisualData)
|
||||
|
||||
pollsPub := api.Group("/polls")
|
||||
pollsPub.Use(middleware.JWTOptional(db))
|
||||
{
|
||||
pollsPub.GET("", pollController.GetPolls)
|
||||
pollsPub.GET("/:id", pollController.GetPoll)
|
||||
pollsPub.POST("/:id/vote", middleware.RateLimit(10, time.Minute), pollController.Vote)
|
||||
pollsPub.GET("/:id/results", pollController.GetPollResults)
|
||||
}
|
||||
|
||||
api.POST("/contact", middleware.RateLimit(10, time.Minute), contactController.SubmitContactForm)
|
||||
api.POST("/newsletter/subscribe", middleware.RateLimit(30, time.Minute), contactController.SubscribeToNewsletter)
|
||||
api.POST("/newsletter/unsubscribe/:email", middleware.RateLimit(30, time.Minute), contactController.UnsubscribeFromNewsletter)
|
||||
api.POST("/newsletter/setup", middleware.RateLimit(30, time.Minute), contactController.SetupNewsletterPreferences)
|
||||
api.GET("/newsletter/preferences", contactController.GetNewsletterPreferencesByToken)
|
||||
api.POST("/newsletter/preferences", contactController.SaveNewsletterPreferencesByToken)
|
||||
api.POST("/newsletter/unsubscribe-token", contactController.UnsubscribeByToken)
|
||||
|
||||
facr := api.Group("/facr")
|
||||
{
|
||||
facr.GET("/club/search", facrController.SearchClubs)
|
||||
facr.GET("/club/:type/:id", facrController.GetClubInfo)
|
||||
facr.GET("/club/:type/:id/table", facrController.GetClubTables)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func SetupRootRoutes(r *gin.Engine, db *gorm.DB) {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package eshop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
)
|
||||
|
||||
type PacketaService struct {
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
func NewPacketaService(cfg *config.Config) *PacketaService {
|
||||
return &PacketaService{Config: cfg}
|
||||
}
|
||||
|
||||
const packetaAPIURL = "https://www.zasilkovna.cz/api/rest"
|
||||
|
||||
// CreatePacket creates a new packet in Packeta system
|
||||
func (s *PacketaService) CreatePacket(order *models.EshopOrder) (string, error) {
|
||||
type PacketAttributes struct {
|
||||
Number string `xml:"number"`
|
||||
Name string `xml:"name"`
|
||||
Surname string `xml:"surname"`
|
||||
Email string `xml:"email"`
|
||||
Phone string `xml:"phone"`
|
||||
AddressID string `xml:"addressId"`
|
||||
Value string `xml:"value"`
|
||||
Currency string `xml:"currency"`
|
||||
Weight string `xml:"weight"`
|
||||
Eshop string `xml:"eshop"`
|
||||
}
|
||||
|
||||
type CreatePacketRequest struct {
|
||||
XMLName xml.Name `xml:"createPacket"`
|
||||
ApiPassword string `xml:"apiPassword"`
|
||||
PacketAttributes PacketAttributes `xml:"packetAttributes"`
|
||||
}
|
||||
|
||||
// Extract shipping data
|
||||
// order.ShippingDataJSON should contain the selected point ID (addressId)
|
||||
// For now assuming we parse it or it is passed differently.
|
||||
// In a real scenario, we would parse ShippingDataJSON to get the point ID.
|
||||
addressID := "1483" // Fallback/Test ID if parsing fails. TODO: Implement proper parsing.
|
||||
|
||||
reqBody := CreatePacketRequest{
|
||||
ApiPassword: s.Config.PacketaAPIPassword,
|
||||
PacketAttributes: PacketAttributes{
|
||||
Number: order.OrderNumber,
|
||||
Name: order.FirstName,
|
||||
Surname: order.LastName,
|
||||
Email: order.Email,
|
||||
Phone: "", // Phone is optional in our model but required for some carriers. TODO: Add phone to checkout.
|
||||
AddressID: addressID,
|
||||
Value: fmt.Sprintf("%.2f", float64(order.TotalAmountCents)/100.0),
|
||||
Currency: order.Currency,
|
||||
Weight: "1.0", // Default weight 1kg. TODO: Calculate from items.
|
||||
Eshop: s.Config.PacketaEshopName,
|
||||
},
|
||||
}
|
||||
|
||||
respBytes, err := s.sendRequest(reqBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
type CreatePacketResponse struct {
|
||||
PacketId string `xml:"packetId"`
|
||||
Status string `xml:"status"`
|
||||
Fault string `xml:"fault"`
|
||||
}
|
||||
|
||||
var resp CreatePacketResponse
|
||||
if err := xml.Unmarshal(respBytes, &resp); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if resp.Status != "ok" && resp.PacketId == "" {
|
||||
return "", fmt.Errorf("packeta error: %s", resp.Fault)
|
||||
}
|
||||
|
||||
return resp.PacketId, nil
|
||||
}
|
||||
|
||||
// GetPacketLabel downloads the label PDF
|
||||
func (s *PacketaService) GetPacketLabel(packetID string) ([]byte, error) {
|
||||
type PacketLabelPdfRequest struct {
|
||||
XMLName xml.Name `xml:"packetLabelPdf"`
|
||||
ApiPassword string `xml:"apiPassword"`
|
||||
PacketId string `xml:"packetId"`
|
||||
Format string `xml:"format"`
|
||||
Offset int `xml:"offset"`
|
||||
}
|
||||
|
||||
reqBody := PacketLabelPdfRequest{
|
||||
ApiPassword: s.Config.PacketaAPIPassword,
|
||||
PacketId: packetID,
|
||||
Format: "A6 on A4",
|
||||
Offset: 0,
|
||||
}
|
||||
|
||||
return s.sendRequest(reqBody)
|
||||
}
|
||||
|
||||
// GetPacketStatus checks the status of a packet
|
||||
func (s *PacketaService) GetPacketStatus(packetID string) (string, error) {
|
||||
type PacketStatusRequest struct {
|
||||
XMLName xml.Name `xml:"packetStatus"`
|
||||
ApiPassword string `xml:"apiPassword"`
|
||||
PacketId string `xml:"packetId"`
|
||||
}
|
||||
|
||||
reqBody := PacketStatusRequest{
|
||||
ApiPassword: s.Config.PacketaAPIPassword,
|
||||
PacketId: packetID,
|
||||
}
|
||||
|
||||
respBytes, err := s.sendRequest(reqBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Simplistic parsing for status text/code
|
||||
// XML response structure varies slightly but usually contains <status>...</status>
|
||||
// We might need a more robust struct.
|
||||
return string(respBytes), nil
|
||||
}
|
||||
|
||||
func (s *PacketaService) sendRequest(payload interface{}) ([]byte, error) {
|
||||
xmlBytes, err := xml.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Post(packetaAPIURL, "text/xml", bytes.NewReader(xmlBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package eshop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/pkg/httpclient"
|
||||
)
|
||||
|
||||
// RevolutOAuthService handles OAuth2 authentication for both Revolut Pro and Business accounts
|
||||
type RevolutOAuthService struct {
|
||||
cfg *config.Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// RevolutAccountType represents the type of Revolut account
|
||||
type RevolutAccountType string
|
||||
|
||||
const (
|
||||
RevolutAccountTypePro RevolutAccountType = "revolut_pro"
|
||||
RevolutAccountTypeBusiness RevolutAccountType = "business"
|
||||
)
|
||||
|
||||
// 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: httpclient.DefaultClient(),
|
||||
}
|
||||
}
|
||||
|
||||
// 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", // Get from Revolut for sandbox
|
||||
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", // Production client ID
|
||||
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", // Production business client ID
|
||||
AuthBaseURL: "https://business.revolut.com",
|
||||
TokenURL: "https://b2b.revolut.com/api/1.0/auth/token",
|
||||
APIBaseURL: "https://merchant.revolut.com/api/1.0",
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Default to Revolut Pro
|
||||
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)
|
||||
|
||||
// For Revolut Pro, use the select-user-type endpoint
|
||||
if accountType == RevolutAccountTypePro {
|
||||
params := url.Values{}
|
||||
params.Set("client_id", config.ClientID)
|
||||
params.Set("redirect_uri", s.cfg.RevolutWebhookURL)
|
||||
params.Set("response_type", "code")
|
||||
params.Set("scope", "checkout_extension")
|
||||
params.Set("code_challenge_method", "S256")
|
||||
params.Set("code_challenge", codeChallenge)
|
||||
params.Set("response_mode", "query")
|
||||
params.Set("state", state)
|
||||
params.Set("integration_type", "CUSTOM_PLUGIN")
|
||||
params.Set("rwa_auth_type", "auth")
|
||||
|
||||
authURL := fmt.Sprintf("%s/s/select-user-type?%s", config.AuthBaseURL, params.Encode())
|
||||
return authURL, nil
|
||||
}
|
||||
|
||||
// For Revolut Business, use direct SSO
|
||||
params := url.Values{}
|
||||
params.Set("client_id", config.ClientID)
|
||||
params.Set("redirect_uri", s.cfg.RevolutWebhookURL)
|
||||
params.Set("response_type", "code")
|
||||
params.Set("code_challenge_method", "S256")
|
||||
params.Set("code_challenge", codeChallenge)
|
||||
params.Set("response_mode", "query")
|
||||
params.Set("prompt", "select_account")
|
||||
params.Set("state", state)
|
||||
|
||||
authURL := fmt.Sprintf("%s/signin?%s", config.AuthBaseURL, params.Encode())
|
||||
return authURL, nil
|
||||
}
|
||||
|
||||
// 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 := url.Values{}
|
||||
data.Set("client_id", config.ClientID)
|
||||
data.Set("code", code)
|
||||
data.Set("code_verifier", codeVerifier)
|
||||
data.Set("grant_type", "authorization_code")
|
||||
data.Set("redirect_uri", s.cfg.RevolutWebhookURL)
|
||||
|
||||
req, err := http.NewRequest("POST", config.TokenURL, strings.NewReader(data.Encode()))
|
||||
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()
|
||||
|
||||
body, 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(body))
|
||||
}
|
||||
|
||||
var token RevolutOAuthToken
|
||||
if err := json.Unmarshal(body, &token); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
// Store account type with token for later use
|
||||
token.Scope = fmt.Sprintf("%s account_type:%s", token.Scope, accountType)
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// RefreshAccessToken refreshes the access token using refresh token
|
||||
func (s *RevolutOAuthService) RefreshAccessToken(accountType RevolutAccountType, refreshToken string) (*RevolutOAuthToken, error) {
|
||||
config := s.GetOAuthConfig(accountType)
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("client_id", config.ClientID)
|
||||
data.Set("refresh_token", refreshToken)
|
||||
data.Set("grant_type", "refresh_token")
|
||||
|
||||
req, err := http.NewRequest("POST", config.TokenURL, strings.NewReader(data.Encode()))
|
||||
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()
|
||||
|
||||
body, 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(body))
|
||||
}
|
||||
|
||||
var token RevolutOAuthToken
|
||||
if err := json.Unmarshal(body, &token); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse refresh response: %w", err)
|
||||
}
|
||||
|
||||
// Store account type with token
|
||||
token.Scope = fmt.Sprintf("%s account_type:%s", token.Scope, accountType)
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// StoreOAuthToken stores the OAuth token with account type information
|
||||
func (s *RevolutOAuthService) StoreOAuthToken(accountType RevolutAccountType, token *RevolutOAuthToken) error {
|
||||
// Store token in database or secure storage
|
||||
// You would typically store:
|
||||
// - access_token
|
||||
// - refresh_token
|
||||
// - expires_at (calculated from expires_in)
|
||||
// - token_type
|
||||
// - scope (includes account_type)
|
||||
// - account_type
|
||||
|
||||
expiresAt := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
|
||||
|
||||
// TODO: Implement database storage with account type
|
||||
fmt.Printf("Storing OAuth token for %s: expires at %v\n", accountType, expiresAt)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStoredOAuthToken retrieves stored OAuth token and account type
|
||||
func (s *RevolutOAuthService) GetStoredOAuthToken() (*RevolutOAuthToken, RevolutAccountType, error) {
|
||||
// TODO: Implement database retrieval
|
||||
return nil, "", fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
// CreatePaymentWithOAuth creates a payment using OAuth token instead of API key
|
||||
func (s *RevolutOAuthService) CreatePaymentWithOAuth(order *models.EshopOrder) (*PaymentResult, error) {
|
||||
// Get stored OAuth token and account type
|
||||
token, accountType, err := s.GetStoredOAuthToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OAuth token: %w", err)
|
||||
}
|
||||
|
||||
// Check if token needs refresh
|
||||
if token.ExpiresIn > 0 && token.RefreshToken != "" {
|
||||
// Token might be expired, refresh it
|
||||
newToken, err := s.RefreshAccessToken(accountType, token.RefreshToken)
|
||||
if err == nil {
|
||||
token = newToken
|
||||
s.StoreOAuthToken(accountType, token)
|
||||
}
|
||||
}
|
||||
|
||||
// Create order using OAuth token
|
||||
return s.createOrder(order, token.AccessToken, accountType)
|
||||
}
|
||||
|
||||
// createOrder creates a Revolut order using OAuth access token
|
||||
func (s *RevolutOAuthService) createOrder(order *models.EshopOrder, accessToken string, accountType RevolutAccountType) (*PaymentResult, error) {
|
||||
config := s.GetOAuthConfig(accountType)
|
||||
|
||||
// Create order request
|
||||
orderReq := map[string]interface{}{
|
||||
"amount": order.TotalAmountCents,
|
||||
"currency": order.Currency,
|
||||
"description": fmt.Sprintf("Order %s", order.OrderNumber),
|
||||
"merchant_order_id": fmt.Sprintf("%d", order.ID),
|
||||
}
|
||||
|
||||
orderJSON, err := json.Marshal(orderReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal order request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", config.APIBaseURL+"/orders", bytes.NewBuffer(orderJSON))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create order request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute order request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read order response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return nil, fmt.Errorf("order API error: status %d, response: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var orderResp map[string]interface{}
|
||||
if err := json.Unmarshal(body, &orderResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
||||
}
|
||||
|
||||
orderID, ok := orderResp["id"].(string)
|
||||
if !ok || orderID == "" {
|
||||
return nil, fmt.Errorf("order response missing id")
|
||||
}
|
||||
|
||||
return &PaymentResult{
|
||||
RedirectURL: fmt.Sprintf("%s/checkout/%s", config.APIBaseURL, orderID),
|
||||
ProviderPaymentID: orderID,
|
||||
RawPayloadJSON: string(body),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package eshop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/pkg/httpclient"
|
||||
)
|
||||
|
||||
// RevolutService handles Revolut payment integration
|
||||
type RevolutService struct {
|
||||
cfg *config.Config
|
||||
client *http.Client
|
||||
apiBase string
|
||||
}
|
||||
|
||||
// Revolut API structures
|
||||
type RevolutOrderRequest struct {
|
||||
Amount int64 `json:"amount"` // Amount in minor currency units (cents)
|
||||
Currency string `json:"currency"` // 3-letter currency code (EUR, CZK, etc.)
|
||||
Description string `json:"description"` // Order description
|
||||
MerchantOrderID string `json:"merchant_order_id,omitempty"` // Your internal order ID
|
||||
Customer *RevolutCustomer `json:"customer,omitempty"`
|
||||
}
|
||||
|
||||
type RevolutCustomer struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
FirstName string `json:"first_name,omitempty"`
|
||||
LastName string `json:"last_name,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
}
|
||||
|
||||
type RevolutOrderResponse struct {
|
||||
ID string `json:"id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Status string `json:"status"` // PENDING, COMPLETED, FAILED
|
||||
CheckoutURL string `json:"checkout_url,omitempty"`
|
||||
MerchantOrderID string `json:"merchant_order_id,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type RevolutWebhookPayload struct {
|
||||
Type string `json:"type"` // ORDER_COMPLETED, ORDER_CANCELLED, etc.
|
||||
OrderID string `json:"order_id"`
|
||||
Order RevolutOrderResponse `json:"order"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
func NewRevolutService(cfg *config.Config) *RevolutService {
|
||||
base := "https://sandbox-merchant.revolut.com/api"
|
||||
if strings.ToLower(strings.TrimSpace(cfg.RevolutEnvironment)) == "production" {
|
||||
base = "https://merchant.revolut.com/api"
|
||||
}
|
||||
|
||||
return &RevolutService{
|
||||
cfg: cfg,
|
||||
client: httpclient.DefaultClient(),
|
||||
apiBase: base,
|
||||
}
|
||||
}
|
||||
|
||||
// CreatePayment initializes a Revolut payment for the given order
|
||||
func (s *RevolutService) CreatePayment(order *models.EshopOrder) (*PaymentResult, error) {
|
||||
if !s.cfg.RevolutEnabled {
|
||||
return nil, fmt.Errorf("Revolut payment provider is disabled")
|
||||
}
|
||||
|
||||
apiKey := strings.TrimSpace(s.cfg.RevolutAPIKey)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("Revolut API key not configured")
|
||||
}
|
||||
|
||||
// Parse billing address to get customer information
|
||||
var billingAddress map[string]interface{}
|
||||
if order.BillingAddressJSON != "" {
|
||||
if err := json.Unmarshal([]byte(order.BillingAddressJSON), &billingAddress); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse billing address: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create order request
|
||||
orderReq := RevolutOrderRequest{
|
||||
Amount: order.TotalAmountCents,
|
||||
Currency: order.Currency,
|
||||
Description: fmt.Sprintf("Objednávka %s", order.OrderNumber),
|
||||
MerchantOrderID: order.OrderNumber,
|
||||
Customer: &RevolutCustomer{
|
||||
Email: order.Email,
|
||||
FirstName: getStringFromMap(billingAddress, "first_name"),
|
||||
LastName: getStringFromMap(billingAddress, "last_name"),
|
||||
Phone: getStringFromMap(billingAddress, "phone"),
|
||||
},
|
||||
}
|
||||
|
||||
// Convert to JSON
|
||||
jsonData, err := json.Marshal(orderReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal Revolut order request: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
url := fmt.Sprintf("%s/1.0/orders", s.apiBase)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Revolut order request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
// Execute request
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute Revolut order request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read Revolut order response: %w", err)
|
||||
}
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return nil, fmt.Errorf("Revolut API error: status %d, response: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var orderResp RevolutOrderResponse
|
||||
if err := json.Unmarshal(body, &orderResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Revolut order response: %w", err)
|
||||
}
|
||||
|
||||
// Store raw payload for debugging
|
||||
rawPayload := string(body)
|
||||
|
||||
// Return payment result
|
||||
return &PaymentResult{
|
||||
RedirectURL: orderResp.CheckoutURL,
|
||||
ProviderPaymentID: orderResp.ID,
|
||||
RawPayloadJSON: rawPayload,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyWebhook verifies and parses incoming Revolut webhooks
|
||||
func (s *RevolutService) VerifyWebhook(payload []byte, signature string) (bool, error) {
|
||||
webhookSecret := strings.TrimSpace(s.cfg.RevolutWebhookSecret)
|
||||
if webhookSecret == "" {
|
||||
return false, fmt.Errorf("Revolut webhook secret not configured")
|
||||
}
|
||||
|
||||
// TODO: Implement webhook signature verification
|
||||
// Revolut uses HMAC-SHA256 for webhook signatures
|
||||
// For now, we'll accept all webhooks in sandbox mode
|
||||
if strings.ToLower(strings.TrimSpace(s.cfg.RevolutEnvironment)) == "sandbox" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// In production, verify the signature here
|
||||
// This would involve computing HMAC-SHA256 of the payload with the webhook secret
|
||||
// and comparing it with the provided signature
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ParseWebhook parses a Revolut webhook payload
|
||||
func (s *RevolutService) ParseWebhook(payload []byte) (*RevolutWebhookPayload, error) {
|
||||
var webhook RevolutWebhookPayload
|
||||
if err := json.Unmarshal(payload, &webhook); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Revolut webhook: %w", err)
|
||||
}
|
||||
return &webhook, nil
|
||||
}
|
||||
|
||||
// GetOrderStatus retrieves the current status of a Revolut order
|
||||
func (s *RevolutService) GetOrderStatus(revolutOrderID string) (*RevolutOrderResponse, error) {
|
||||
if !s.cfg.RevolutEnabled {
|
||||
return nil, fmt.Errorf("Revolut payment provider is disabled")
|
||||
}
|
||||
|
||||
apiKey := strings.TrimSpace(s.cfg.RevolutAPIKey)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("Revolut API key not configured")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/1.0/orders/%s", s.apiBase, revolutOrderID)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Revolut status request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute Revolut status request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read Revolut status response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Revolut API error: status %d, response: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var orderResp RevolutOrderResponse
|
||||
if err := json.Unmarshal(body, &orderResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Revolut status response: %w", err)
|
||||
}
|
||||
|
||||
return &orderResp, nil
|
||||
}
|
||||
|
||||
// RefundOrder processes a refund for a Revolut order
|
||||
func (s *RevolutService) RefundOrder(revolutOrderID string, amount int64, reason string) error {
|
||||
if !s.cfg.RevolutEnabled {
|
||||
return fmt.Errorf("Revolut payment provider is disabled")
|
||||
}
|
||||
|
||||
apiKey := strings.TrimSpace(s.cfg.RevolutAPIKey)
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("Revolut API key not configured")
|
||||
}
|
||||
|
||||
// Refund request structure
|
||||
refundReq := map[string]interface{}{
|
||||
"amount": amount,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(refundReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal Revolut refund request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/1.0/orders/%s/refund", s.apiBase, revolutOrderID)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create Revolut refund request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute Revolut refund request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("Revolut refund API error: status %d, response: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getStringFromMap safely extracts a string value from a map, returning empty string if not found or not a string
|
||||
func getStringFromMap(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if val, ok := m[key]; ok {
|
||||
if str, ok := val.(string); ok {
|
||||
return str
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package eshop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/pkg/httpclient"
|
||||
)
|
||||
|
||||
// PaymentResult represents a generic result from a payment provider
|
||||
type PaymentResult struct {
|
||||
RedirectURL string
|
||||
ProviderPaymentID string
|
||||
RawPayloadJSON string
|
||||
}
|
||||
|
||||
// StripeService handles Stripe payment integration
|
||||
type StripeService struct {
|
||||
cfg *config.Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewStripeService creates a new Stripe service instance
|
||||
func NewStripeService(cfg *config.Config) *StripeService {
|
||||
return &StripeService{
|
||||
cfg: cfg,
|
||||
client: httpclient.DefaultClient(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreatePayment creates a Stripe PaymentIntent for the given order
|
||||
func (s *StripeService) CreatePayment(order *models.EshopOrder) (*PaymentResult, error) {
|
||||
if !s.cfg.StripeEnabled {
|
||||
return nil, fmt.Errorf("Stripe payment provider is disabled")
|
||||
}
|
||||
|
||||
secretKey := strings.TrimSpace(s.cfg.StripeSecretKey)
|
||||
if secretKey == "" {
|
||||
return nil, fmt.Errorf("Stripe secret key not configured")
|
||||
}
|
||||
|
||||
// Create PaymentIntent request
|
||||
paymentIntentReq := map[string]interface{}{
|
||||
"amount": order.TotalAmountCents,
|
||||
"currency": strings.ToLower(order.Currency),
|
||||
"metadata": map[string]string{
|
||||
"order_id": fmt.Sprintf("%d", order.ID),
|
||||
"order_number": order.OrderNumber,
|
||||
},
|
||||
"automatic_payment_methods": map[string]interface{}{
|
||||
"enabled": true,
|
||||
},
|
||||
}
|
||||
|
||||
paymentJSON, err := json.Marshal(paymentIntentReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal Stripe PaymentIntent request: %w", err)
|
||||
}
|
||||
|
||||
// Create PaymentIntent via Stripe API
|
||||
req, err := http.NewRequest("POST", "https://api.stripe.com/v1/payment_intents", bytes.NewBuffer(paymentJSON))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Stripe PaymentIntent request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Bearer "+secretKey)
|
||||
|
||||
// Convert JSON to form-encoded for Stripe API
|
||||
formData := fmt.Sprintf("amount=%d¤cy=%s&automatic_payment_methods[enabled]=true",
|
||||
order.TotalAmountCents, strings.ToLower(order.Currency))
|
||||
req.Body = io.NopCloser(strings.NewReader(formData))
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute Stripe PaymentIntent request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read Stripe PaymentIntent response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Stripe API error: status %d, response: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var paymentIntent map[string]interface{}
|
||||
if err := json.Unmarshal(body, &paymentIntent); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Stripe PaymentIntent response: %w", err)
|
||||
}
|
||||
|
||||
clientSecret, ok := paymentIntent["client_secret"].(string)
|
||||
if !ok || clientSecret == "" {
|
||||
return nil, fmt.Errorf("Stripe PaymentIntent response missing client_secret")
|
||||
}
|
||||
|
||||
paymentID, ok := paymentIntent["id"].(string)
|
||||
if !ok || paymentID == "" {
|
||||
return nil, fmt.Errorf("Stripe PaymentIntent response missing id")
|
||||
}
|
||||
|
||||
return &PaymentResult{
|
||||
RedirectURL: "", // Stripe uses client_secret, not redirect
|
||||
ProviderPaymentID: paymentID,
|
||||
RawPayloadJSON: string(body),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetClientSecret returns the client secret for a PaymentIntent (for frontend use)
|
||||
func (s *StripeService) GetClientSecret(paymentIntentID string) (string, error) {
|
||||
if s.cfg.StripeSecretKey == "" {
|
||||
return "", fmt.Errorf("Stripe secret key not configured")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.stripe.com/v1/payment_intents/%s", paymentIntentID)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(s.cfg.StripeSecretKey, "")
|
||||
req.Header.Set("Stripe-Version", "2023-10-16")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to make request to Stripe: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("Stripe API error: %d - %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
clientSecret, ok := result["client_secret"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no client secret in response")
|
||||
}
|
||||
|
||||
return clientSecret, nil
|
||||
}
|
||||
@@ -65,6 +65,15 @@ func ProcessFACRLogo(src string) (string, error) {
|
||||
if u == "" {
|
||||
return "", fmt.Errorf("empty url")
|
||||
}
|
||||
// In manual club data mode we avoid any remote FACR/fotbal.cz processing and
|
||||
// return the original URL unchanged so that LogoAPI/manual overrides can be
|
||||
// applied on the frontend without additional HTTP calls.
|
||||
if config.AppConfig != nil {
|
||||
mode := strings.ToLower(strings.TrimSpace(config.AppConfig.ClubDataMode))
|
||||
if mode == "manual" {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
// Feature flag: allow disabling background removal entirely via .env
|
||||
if config.AppConfig != nil && !config.AppConfig.RembgEnabled {
|
||||
// Simply return the original URL (no processing)
|
||||
|
||||
@@ -20,6 +20,7 @@ type logoAPIResponse struct {
|
||||
LogoURLSVG string `json:"logo_url_svg"`
|
||||
LogoURLPNG string `json:"logo_url_png"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
ClubName string `json:"club_name"`
|
||||
}
|
||||
|
||||
func CacheClubLogo(db *gorm.DB, clubID string) (string, error) {
|
||||
@@ -137,11 +138,11 @@ func CacheClubLogo(db *gorm.DB, clubID string) (string, error) {
|
||||
if db != nil {
|
||||
if err := db.Where("file_path = ?", dest).First(&existing).Error; err != nil {
|
||||
uf := models.UploadedFile{
|
||||
Filename: "club-logo" + ext,
|
||||
FilePath: dest,
|
||||
FileURL: publicURL,
|
||||
MimeType: mime,
|
||||
FileSize: 0,
|
||||
Filename: "club-logo" + ext,
|
||||
FilePath: dest,
|
||||
FileURL: publicURL,
|
||||
MimeType: mime,
|
||||
FileSize: 0,
|
||||
UploadedByID: nil,
|
||||
}
|
||||
if fi != nil {
|
||||
@@ -153,3 +154,47 @@ func CacheClubLogo(db *gorm.DB, clubID string) (string, error) {
|
||||
|
||||
return publicURL, nil
|
||||
}
|
||||
|
||||
type ClubLogoAndName struct {
|
||||
LogoURL string
|
||||
ClubName string
|
||||
}
|
||||
|
||||
func CacheClubLogoAndName(db *gorm.DB, clubID string) (*ClubLogoAndName, error) {
|
||||
cid := strings.TrimSpace(clubID)
|
||||
if cid == "" {
|
||||
return nil, fmt.Errorf("empty club id")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 12 * time.Second}
|
||||
req, err := http.NewRequest("GET", "https://logoapi.sportcreative.eu/logos/"+cid+"/json", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "fotbal-club/logo-cache")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("logoapi status %d", resp.StatusCode)
|
||||
}
|
||||
var api logoAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&api); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the logo using existing function
|
||||
logoURL, err := CacheClubLogo(db, clubID)
|
||||
if err != nil {
|
||||
// Even if logo caching fails, we still return the club name if available
|
||||
logoURL = ""
|
||||
}
|
||||
|
||||
return &ClubLogoAndName{
|
||||
LogoURL: logoURL,
|
||||
ClubName: strings.TrimSpace(api.ClubName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -148,12 +148,22 @@ func (na *NewsletterAutomation) checkWeeklyDigest() {
|
||||
currentDay := strings.ToLower(now.Weekday().String()[:3])
|
||||
currentHour := now.Hour()
|
||||
|
||||
// Check if it's the right day and hour
|
||||
if currentDay != targetDay || currentHour != targetHour {
|
||||
// Check if it's the right day and hour (with minute precision to prevent multiple sends)
|
||||
currentMinute := now.Minute()
|
||||
if currentDay != targetDay || currentHour != targetHour || currentMinute > 5 {
|
||||
// Only run in the first 5 minutes of the target hour to avoid repeats
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already sent today
|
||||
// Check if already sent today (using database for persistence)
|
||||
var todaySent models.NewsletterSentLog
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
if err := na.db.Where("newsletter_type = ? AND sent_at >= ?", "weekly", todayStart).First(&todaySent).Error; err == nil {
|
||||
log.Printf("[newsletter-automation] Weekly digest already sent today at %s", todaySent.SentAt.Format("15:04:05"))
|
||||
return
|
||||
}
|
||||
|
||||
// Also check in-memory as backup
|
||||
if na.lastWeekly.Year() == now.Year() && na.lastWeekly.YearDay() == now.YearDay() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
)
|
||||
|
||||
type PacketaService struct {
|
||||
ApiPassword string
|
||||
ApiUrl string
|
||||
EshopName string
|
||||
}
|
||||
|
||||
func NewPacketaService(cfg *config.Config) *PacketaService {
|
||||
// Defaults
|
||||
apiUrl := "https://www.zasilkovna.cz/api/rest"
|
||||
eshopName := "MyClubEshop"
|
||||
|
||||
if cfg.PacketaEshopName != "" {
|
||||
eshopName = cfg.PacketaEshopName
|
||||
}
|
||||
|
||||
return &PacketaService{
|
||||
ApiPassword: cfg.PacketaAPIPassword,
|
||||
ApiUrl: apiUrl,
|
||||
EshopName: eshopName,
|
||||
}
|
||||
}
|
||||
|
||||
// XML Structures
|
||||
type CreatePacketRequest struct {
|
||||
XMLName xml.Name `xml:"createPacket"`
|
||||
ApiPassword string `xml:"apiPassword"`
|
||||
PacketAttributes PacketAttributes `xml:"packetAttributes"`
|
||||
}
|
||||
|
||||
type PacketAttributes struct {
|
||||
Number string `xml:"number"`
|
||||
Name string `xml:"name"`
|
||||
Surname string `xml:"surname"`
|
||||
Email string `xml:"email"`
|
||||
Phone string `xml:"phone"`
|
||||
AddressId string `xml:"addressId"`
|
||||
Value string `xml:"value"`
|
||||
Currency string `xml:"currency"`
|
||||
Weight string `xml:"weight"`
|
||||
Eshop string `xml:"eshop"`
|
||||
}
|
||||
|
||||
type CreatePacketResponse struct {
|
||||
XMLName xml.Name `xml:"response"`
|
||||
Status string `xml:"status"`
|
||||
Result PacketResult `xml:"result"`
|
||||
Fault *PacketFault `xml:"fault"`
|
||||
}
|
||||
|
||||
type PacketResult struct {
|
||||
PacketId string `xml:"packetId"`
|
||||
LabelUrl string `xml:"labelUrl"` // Not standard, usually requires separate call
|
||||
Barcode string `xml:"barcode"`
|
||||
}
|
||||
|
||||
type PacketFault struct {
|
||||
String string `xml:"string"`
|
||||
Detail string `xml:"detail"`
|
||||
}
|
||||
|
||||
// CreatePacket calls Packeta API to register the shipment
|
||||
func (s *PacketaService) CreatePacket(order *models.EshopOrder, addressId string) (string, error) {
|
||||
if s.ApiPassword == "" {
|
||||
return "", fmt.Errorf("Packeta API password not configured")
|
||||
}
|
||||
|
||||
req := CreatePacketRequest{
|
||||
ApiPassword: s.ApiPassword,
|
||||
PacketAttributes: PacketAttributes{
|
||||
Number: order.OrderNumber,
|
||||
Name: order.FirstName,
|
||||
Surname: order.LastName,
|
||||
Email: order.Email,
|
||||
Phone: "", // Add phone to order model if needed
|
||||
AddressId: addressId,
|
||||
Value: fmt.Sprintf("%.2f", float64(order.TotalAmountCents)/100.0),
|
||||
Currency: order.Currency,
|
||||
Weight: "1.0", // Default weight, should come from product sum
|
||||
Eshop: s.EshopName,
|
||||
},
|
||||
}
|
||||
|
||||
xmlData, err := xml.Marshal(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := http.Post(s.ApiUrl, "text/xml", bytes.NewBuffer(xmlData))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var xmlResp CreatePacketResponse
|
||||
if err := xml.Unmarshal(body, &xmlResp); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if xmlResp.Status != "ok" {
|
||||
msg := "unknown error"
|
||||
if xmlResp.Fault != nil {
|
||||
msg = xmlResp.Fault.String + ": " + xmlResp.Fault.Detail
|
||||
}
|
||||
return "", fmt.Errorf("packeta error: %s", msg)
|
||||
}
|
||||
|
||||
return xmlResp.Result.PacketId, nil
|
||||
}
|
||||
|
||||
// Helper struct for packet status
|
||||
type PacketStatusRequest struct {
|
||||
XMLName xml.Name `xml:"packetStatus"`
|
||||
ApiPassword string `xml:"apiPassword"`
|
||||
PacketId string `xml:"packetId"`
|
||||
}
|
||||
|
||||
type PacketStatusResponse struct {
|
||||
XMLName xml.Name `xml:"response"`
|
||||
Status string `xml:"status"`
|
||||
Result PacketStatusResult `xml:"result"`
|
||||
}
|
||||
|
||||
type PacketStatusResult struct {
|
||||
StatusCode string `xml:"statusCode"`
|
||||
StatusText string `xml:"statusText"`
|
||||
}
|
||||
|
||||
func (s *PacketaService) GetPacketLabel(packetId string) ([]byte, error) {
|
||||
if s.ApiPassword == "" {
|
||||
return nil, fmt.Errorf("Packeta API password not configured")
|
||||
}
|
||||
|
||||
type PacketLabelPdfRequest struct {
|
||||
XMLName xml.Name `xml:"packetLabelPdf"`
|
||||
ApiPassword string `xml:"apiPassword"`
|
||||
PacketId string `xml:"packetId"`
|
||||
Format string `xml:"format"`
|
||||
Offset int `xml:"offset"`
|
||||
}
|
||||
|
||||
req := PacketLabelPdfRequest{
|
||||
ApiPassword: s.ApiPassword,
|
||||
PacketId: packetId,
|
||||
Format: "A6 on A4",
|
||||
Offset: 0,
|
||||
}
|
||||
|
||||
xmlData, err := xml.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := http.Post(s.ApiUrl, "text/xml", bytes.NewBuffer(xmlData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("label request failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (s *PacketaService) GetPacketStatus(packetId string) (string, error) {
|
||||
req := PacketStatusRequest{
|
||||
ApiPassword: s.ApiPassword,
|
||||
PacketId: packetId,
|
||||
}
|
||||
|
||||
xmlData, err := xml.Marshal(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := http.Post(s.ApiUrl, "text/xml", bytes.NewBuffer(xmlData))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var xmlResp PacketStatusResponse
|
||||
if err := xml.Unmarshal(body, &xmlResp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if xmlResp.Status != "ok" {
|
||||
return "", fmt.Errorf("status check failed")
|
||||
}
|
||||
|
||||
return xmlResp.Result.StatusText, nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPacketaService_GetPacketStatus_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST request, got %s", r.Method)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read request body: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Contains(body, []byte("<packetStatus>")) {
|
||||
t.Fatalf("expected packetStatus XML request, got: %s", string(body))
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
_, _ = w.Write([]byte(`
|
||||
<response>
|
||||
<status>ok</status>
|
||||
<result>
|
||||
<statusCode>DELIVERED</statusCode>
|
||||
<statusText>DELIVERED</statusText>
|
||||
</result>
|
||||
</response>
|
||||
`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := &PacketaService{
|
||||
ApiPassword: "test-password",
|
||||
ApiUrl: server.URL,
|
||||
EshopName: "TestEshop",
|
||||
}
|
||||
|
||||
status, err := service.GetPacketStatus("12345")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPacketStatus returned error: %v", err)
|
||||
}
|
||||
|
||||
if status != "DELIVERED" {
|
||||
t.Fatalf("expected status 'DELIVERED', got %q", status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WeatherService struct {
|
||||
db *gorm.DB
|
||||
apiKey string
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type WeatherResponse struct {
|
||||
Location struct {
|
||||
Name string `json:"name"`
|
||||
Region string `json:"region"`
|
||||
Country string `json:"country"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
TzID string `json:"tz_id"`
|
||||
LocalTime string `json:"localtime"`
|
||||
} `json:"location"`
|
||||
Current struct {
|
||||
LastUpdated string `json:"last_updated"`
|
||||
TempC float64 `json:"temp_c"`
|
||||
TempF float64 `json:"temp_f"`
|
||||
IsDay int `json:"is_day"`
|
||||
Condition struct {
|
||||
Text string `json:"text"`
|
||||
Icon string `json:"icon"`
|
||||
Code int `json:"code"`
|
||||
} `json:"condition"`
|
||||
WindMph float64 `json:"wind_mph"`
|
||||
WindKph float64 `json:"wind_kph"`
|
||||
WindDegree int `json:"wind_degree"`
|
||||
WindDir string `json:"wind_dir"`
|
||||
PressureMb float64 `json:"pressure_mb"`
|
||||
PressureIn float64 `json:"pressure_in"`
|
||||
PrecipMm float64 `json:"precip_mm"`
|
||||
PrecipIn float64 `json:"precip_in"`
|
||||
Humidity int `json:"humidity"`
|
||||
Cloud int `json:"cloud"`
|
||||
FeelsLikeC float64 `json:"feelslike_c"`
|
||||
FeelsLikeF float64 `json:"feelslike_f"`
|
||||
VisKm float64 `json:"vis_km"`
|
||||
VisMiles float64 `json:"vis_miles"`
|
||||
UV float64 `json:"uv"`
|
||||
GustMph float64 `json:"gust_mph"`
|
||||
GustKph float64 `json:"gust_kph"`
|
||||
} `json:"current"`
|
||||
Forecast struct {
|
||||
ForecastDay []struct {
|
||||
Date string `json:"date"`
|
||||
DateEpoch int64 `json:"date_epoch"`
|
||||
Day struct {
|
||||
MaxTempC float64 `json:"maxtemp_c"`
|
||||
MaxTempF float64 `json:"maxtemp_f"`
|
||||
MinTempC float64 `json:"mintemp_c"`
|
||||
MinTempF float64 `json:"mintemp_f"`
|
||||
AvgTempC float64 `json:"avgtemp_c"`
|
||||
AvgTempF float64 `json:"avgtemp_f"`
|
||||
MaxWindMph float64 `json:"maxwind_mph"`
|
||||
MaxWindKph float64 `json:"maxwind_kph"`
|
||||
TotalPrecipMm float64 `json:"totalprecip_mm"`
|
||||
TotalPrecipIn float64 `json:"totalprecip_in"`
|
||||
TotalSnowCm float64 `json:"totalsnow_cm"`
|
||||
AvgVisKm float64 `json:"avgvis_km"`
|
||||
AvgVisMiles float64 `json:"avgvis_miles"`
|
||||
AvgHumidity float64 `json:"avghumidity"`
|
||||
DailyWillItRain int `json:"daily_will_it_rain"`
|
||||
DailyChanceOfRain int `json:"daily_chance_of_rain"`
|
||||
DailyWillItSnow int `json:"daily_will_it_snow"`
|
||||
DailyChanceOfSnow int `json:"daily_chance_of_snow"`
|
||||
Condition struct {
|
||||
Text string `json:"text"`
|
||||
Icon string `json:"icon"`
|
||||
Code int `json:"code"`
|
||||
} `json:"condition"`
|
||||
UV float64 `json:"uv"`
|
||||
} `json:"day"`
|
||||
Astro struct {
|
||||
Sunrise string `json:"sunrise"`
|
||||
Sunset string `json:"sunset"`
|
||||
Moonrise string `json:"moonrise"`
|
||||
Moonset string `json:"moonset"`
|
||||
MoonPhase string `json:"moon_phase"`
|
||||
MoonIllumination float64 `json:"moon_illumination"`
|
||||
IsMoonUp int `json:"is_moon_up"`
|
||||
IsSunUp int `json:"is_sun_up"`
|
||||
} `json:"astro"`
|
||||
Hour []struct {
|
||||
TimeEpoch int64 `json:"time_epoch"`
|
||||
Time string `json:"time"`
|
||||
TempC float64 `json:"temp_c"`
|
||||
TempF float64 `json:"temp_f"`
|
||||
IsDay int `json:"is_day"`
|
||||
Condition struct {
|
||||
Text string `json:"text"`
|
||||
Icon string `json:"icon"`
|
||||
Code int `json:"code"`
|
||||
} `json:"condition"`
|
||||
WindMph float64 `json:"wind_mph"`
|
||||
WindKph float64 `json:"wind_kph"`
|
||||
WindDegree int `json:"wind_degree"`
|
||||
WindDir string `json:"wind_dir"`
|
||||
PressureMb float64 `json:"pressure_mb"`
|
||||
PressureIn float64 `json:"pressure_in"`
|
||||
PrecipMm float64 `json:"precip_mm"`
|
||||
PrecipIn float64 `json:"precip_in"`
|
||||
Humidity int `json:"humidity"`
|
||||
Cloud int `json:"cloud"`
|
||||
FeelsLikeC float64 `json:"feelslike_c"`
|
||||
FeelsLikeF float64 `json:"feelslike_f"`
|
||||
WindChillC float64 `json:"windchill_c"`
|
||||
WindChillF float64 `json:"windchill_f"`
|
||||
HeatIndexC float64 `json:"heatindex_c"`
|
||||
HeatIndexF float64 `json:"heatindex_f"`
|
||||
DewPointC float64 `json:"dewpoint_c"`
|
||||
DewPointF float64 `json:"dewpoint_f"`
|
||||
WillItRain int `json:"will_it_rain"`
|
||||
ChanceOfRain int `json:"chance_of_rain"`
|
||||
WillItSnow int `json:"will_it_snow"`
|
||||
ChanceOfSnow int `json:"chance_of_snow"`
|
||||
VisKm float64 `json:"vis_km"`
|
||||
VisMiles float64 `json:"vis_miles"`
|
||||
GustMph float64 `json:"gust_mph"`
|
||||
GustKph float64 `json:"gust_kph"`
|
||||
UV float64 `json:"uv"`
|
||||
} `json:"hour"`
|
||||
} `json:"forecastday"`
|
||||
} `json:"forecast"`
|
||||
Alerts []struct {
|
||||
Headline string `json:"headline"`
|
||||
MsgType string `json:"msgtype"`
|
||||
Severity string `json:"severity"`
|
||||
Urgency string `json:"urgency"`
|
||||
Areas string `json:"areas"`
|
||||
Category string `json:"category"`
|
||||
Certainty string `json:"certainty"`
|
||||
Event string `json:"event"`
|
||||
Note string `json:"note"`
|
||||
Effective string `json:"effective"`
|
||||
Expires string `json:"expires"`
|
||||
Desc string `json:"desc"`
|
||||
Instruction string `json:"instruction"`
|
||||
} `json:"alerts"`
|
||||
}
|
||||
|
||||
func NewWeatherService(db *gorm.DB) *WeatherService {
|
||||
apiKey := os.Getenv("WEATHER_API_KEY")
|
||||
baseURL := os.Getenv("WEATHER_API_BASE_URL")
|
||||
|
||||
if apiKey == "" {
|
||||
apiKey = "20dfd9a556ec43888dc103523250904" // fallback
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.weatherapi.com/v1"
|
||||
}
|
||||
|
||||
return &WeatherService{
|
||||
db: db,
|
||||
apiKey: apiKey,
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *WeatherService) GetWeatherByLocation(location string) (*WeatherResponse, error) {
|
||||
if location == "" {
|
||||
// Try to get club location from settings
|
||||
location = ws.getClubLocation()
|
||||
if location == "" {
|
||||
return nil, fmt.Errorf("no location specified and no club location found")
|
||||
}
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/forecast.json?key=%s&q=%s&days=3&aqi=no&alerts=no",
|
||||
ws.baseURL, ws.apiKey, location)
|
||||
|
||||
resp, err := ws.client.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch weather data: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("weather API error: status %d, response: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
var weatherResp WeatherResponse
|
||||
if err := json.Unmarshal(body, &weatherResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse weather response: %w", err)
|
||||
}
|
||||
|
||||
return &weatherResp, nil
|
||||
}
|
||||
|
||||
func (ws *WeatherService) getClubLocation() string {
|
||||
var settings models.Settings
|
||||
if err := ws.db.First(&settings).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// If coordinates are available, use them for most accurate weather
|
||||
if settings.LocationLatitude != 0 && settings.LocationLongitude != 0 {
|
||||
return fmt.Sprintf("%.6f,%.6f", settings.LocationLatitude, settings.LocationLongitude)
|
||||
}
|
||||
|
||||
// Try different location fields in order of preference
|
||||
if settings.ContactCity != "" {
|
||||
location := settings.ContactCity
|
||||
if settings.ContactCountry != "" &&
|
||||
settings.ContactCountry != "Czech Republic" &&
|
||||
settings.ContactCountry != "Česká republika" &&
|
||||
settings.ContactCountry != "Česko" {
|
||||
location += "," + settings.ContactCountry
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
// Fallback to a default Czech city if club is in Czech Republic
|
||||
if settings.ContactCountry == "Czech Republic" ||
|
||||
settings.ContactCountry == "Česká republika" ||
|
||||
settings.ContactCountry == "Česko" {
|
||||
return "Prague"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (ws *WeatherService) GetWeatherForClub() (*WeatherResponse, error) {
|
||||
return ws.GetWeatherByLocation("")
|
||||
}
|
||||
|
||||
func (ws *WeatherService) GetWeatherForMatch(matchDateTime string, location string) (*WeatherResponse, error) {
|
||||
if location == "" {
|
||||
location = ws.getClubLocation()
|
||||
if location == "" {
|
||||
return nil, fmt.Errorf("no location specified and no club location found")
|
||||
}
|
||||
}
|
||||
|
||||
// Parse match date to determine if we need hourly forecast
|
||||
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 {
|
||||
return nil, fmt.Errorf("invalid match date time format: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// If match is more than 7 days away, regular forecast won't be available
|
||||
now := time.Now()
|
||||
if matchTime.Sub(now) > 7*24*time.Hour {
|
||||
return nil, fmt.Errorf("match is too far in the future for weather forecast")
|
||||
}
|
||||
|
||||
// If match is in the past, no forecast needed
|
||||
if matchTime.Before(now) {
|
||||
return nil, fmt.Errorf("match is in the past")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/forecast.json?key=%s&q=%s&days=7&aqi=no&alerts=no",
|
||||
ws.baseURL, ws.apiKey, location)
|
||||
|
||||
resp, err := ws.client.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch weather data: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("weather API error: status %d, response: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
var weatherResp WeatherResponse
|
||||
if err := json.Unmarshal(body, &weatherResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse weather response: %w", err)
|
||||
}
|
||||
|
||||
return &weatherResp, nil
|
||||
}
|
||||
|
||||
func (ws *WeatherService) FindClosestHourlyForecast(weather *WeatherResponse, matchTime time.Time) *struct {
|
||||
TimeEpoch int64 `json:"time_epoch"`
|
||||
Time string `json:"time"`
|
||||
TempC float64 `json:"temp_c"`
|
||||
TempF float64 `json:"temp_f"`
|
||||
IsDay int `json:"is_day"`
|
||||
Condition struct {
|
||||
Text string `json:"text"`
|
||||
Icon string `json:"icon"`
|
||||
Code int `json:"code"`
|
||||
} `json:"condition"`
|
||||
WindMph float64 `json:"wind_mph"`
|
||||
WindKph float64 `json:"wind_kph"`
|
||||
WindDegree int `json:"wind_degree"`
|
||||
WindDir string `json:"wind_dir"`
|
||||
PressureMb float64 `json:"pressure_mb"`
|
||||
PressureIn float64 `json:"pressure_in"`
|
||||
PrecipMm float64 `json:"precip_mm"`
|
||||
PrecipIn float64 `json:"precip_in"`
|
||||
Humidity int `json:"humidity"`
|
||||
Cloud int `json:"cloud"`
|
||||
FeelsLikeC float64 `json:"feelslike_c"`
|
||||
FeelsLikeF float64 `json:"feelslike_f"`
|
||||
WindChillC float64 `json:"windchill_c"`
|
||||
WindChillF float64 `json:"windchill_f"`
|
||||
HeatIndexC float64 `json:"heatindex_c"`
|
||||
HeatIndexF float64 `json:"heatindex_f"`
|
||||
DewPointC float64 `json:"dewpoint_c"`
|
||||
DewPointF float64 `json:"dewpoint_f"`
|
||||
WillItRain int `json:"will_it_rain"`
|
||||
ChanceOfRain int `json:"chance_of_rain"`
|
||||
WillItSnow int `json:"will_it_snow"`
|
||||
ChanceOfSnow int `json:"chance_of_snow"`
|
||||
VisKm float64 `json:"vis_km"`
|
||||
VisMiles float64 `json:"vis_miles"`
|
||||
GustMph float64 `json:"gust_mph"`
|
||||
GustKph float64 `json:"gust_kph"`
|
||||
UV float64 `json:"uv"`
|
||||
} {
|
||||
var closestHour *struct {
|
||||
TimeEpoch int64 `json:"time_epoch"`
|
||||
Time string `json:"time"`
|
||||
TempC float64 `json:"temp_c"`
|
||||
TempF float64 `json:"temp_f"`
|
||||
IsDay int `json:"is_day"`
|
||||
Condition struct {
|
||||
Text string `json:"text"`
|
||||
Icon string `json:"icon"`
|
||||
Code int `json:"code"`
|
||||
} `json:"condition"`
|
||||
WindMph float64 `json:"wind_mph"`
|
||||
WindKph float64 `json:"wind_kph"`
|
||||
WindDegree int `json:"wind_degree"`
|
||||
WindDir string `json:"wind_dir"`
|
||||
PressureMb float64 `json:"pressure_mb"`
|
||||
PressureIn float64 `json:"pressure_in"`
|
||||
PrecipMm float64 `json:"precip_mm"`
|
||||
PrecipIn float64 `json:"precip_in"`
|
||||
Humidity int `json:"humidity"`
|
||||
Cloud int `json:"cloud"`
|
||||
FeelsLikeC float64 `json:"feelslike_c"`
|
||||
FeelsLikeF float64 `json:"feelslike_f"`
|
||||
WindChillC float64 `json:"windchill_c"`
|
||||
WindChillF float64 `json:"windchill_f"`
|
||||
HeatIndexC float64 `json:"heatindex_c"`
|
||||
HeatIndexF float64 `json:"heatindex_f"`
|
||||
DewPointC float64 `json:"dewpoint_c"`
|
||||
DewPointF float64 `json:"dewpoint_f"`
|
||||
WillItRain int `json:"will_it_rain"`
|
||||
ChanceOfRain int `json:"chance_of_rain"`
|
||||
WillItSnow int `json:"will_it_snow"`
|
||||
ChanceOfSnow int `json:"chance_of_snow"`
|
||||
VisKm float64 `json:"vis_km"`
|
||||
VisMiles float64 `json:"vis_miles"`
|
||||
GustMph float64 `json:"gust_mph"`
|
||||
GustKph float64 `json:"gust_kph"`
|
||||
UV float64 `json:"uv"`
|
||||
}
|
||||
|
||||
minDiff := 24 * time.Hour // Start with a large difference
|
||||
|
||||
for _, day := range weather.Forecast.ForecastDay {
|
||||
for _, hour := range day.Hour {
|
||||
hourTime := time.Unix(hour.TimeEpoch, 0)
|
||||
diff := hourTime.Sub(matchTime)
|
||||
if diff >= 0 && diff < minDiff {
|
||||
minDiff = diff
|
||||
closestHour = &hour
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return closestHour
|
||||
}
|
||||
|
||||
func (ws *WeatherService) GetWeatherIconURL(icon string) string {
|
||||
if icon == "" {
|
||||
return ""
|
||||
}
|
||||
// WeatherAPI provides relative URLs, make them absolute
|
||||
return "https:" + icon
|
||||
}
|
||||
Reference in New Issue
Block a user