This commit is contained in:
Tomas Dvorak
2026-01-26 08:13:18 +01:00
parent aa036b6550
commit dfc079288f
505 changed files with 95755 additions and 5712 deletions
+375
View File
@@ -0,0 +1,375 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"fotbal-club/internal/config"
eshop_controllers "fotbal-club/internal/controllers/eshop"
"fotbal-club/internal/middleware"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"fotbal-club/pkg/database"
"fotbal-club/pkg/email"
"fotbal-club/pkg/logger"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func main() {
// Load shared configuration
config.LoadConfig()
// Logger level based on DEBUG flag
if config.AppConfig != nil && config.AppConfig.Debug {
logger.SetLevel(logger.LevelDebug)
log.Println("[eshop] Logger level set to DEBUG")
} else {
logger.SetLevel(logger.LevelInfo)
}
// Gin mode
if config.AppConfig != nil && config.AppConfig.AppEnv == "production" {
gin.SetMode(gin.ReleaseMode)
}
// Initialize shared database connection
dbInstance, err := database.InitDB()
if err != nil {
log.Fatalf("[eshop] Failed to connect to database: %v", err)
}
// Optional migrations for eshop-specific tables only (will be added later)
runMigrations, _ := strconv.ParseBool(os.Getenv("RUN_MIGRATIONS"))
if runMigrations {
log.Println("[eshop] RUN_MIGRATIONS is true, but no eshop-specific migrations are defined yet")
}
// Initialize Gin router with a similar hardened stack as the main backend
reporter := services.NewErrorReporter(config.AppConfig)
r := gin.New()
r.Use(middleware.CustomRecoveryWithReporter(reporter))
if err := r.SetTrustedProxies(nil); err != nil {
log.Printf("[eshop] Trusted proxies setup error: %v", err)
}
r.Use(middleware.RequestID())
r.Use(middleware.RequestLogger())
r.Use(middleware.ErrorStatusReporter(reporter))
r.Use(middleware.SanitizeHeaders())
r.Use(middleware.DBContext())
r.Use(middleware.RequestSizeLimit(2 * 1024 * 1024))
r.Use(middleware.ValidateContentType())
r.Use(gzip.Gzip(gzip.DefaultCompression))
r.Use(middleware.SecurityHeaders())
// E-shop CORS reuse AllowedOrigins from shared config
r.Use(func(c *gin.Context) {
origin := c.Request.Header.Get("Origin")
allowed := false
// Explicit exact-origin allow list
for _, ao := range config.AppConfig.AllowedOrigins {
if ao == origin {
allowed = true
break
}
}
// Wildcard support
if !allowed {
for _, ao := range config.AppConfig.AllowedOrigins {
if ao == "*" && origin != "" {
allowed = true
break
}
}
}
// Relaxed rule for non-production local dev
if !allowed && origin != "" && config.AppConfig.AppEnv != "production" {
if strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") ||
strings.HasPrefix(origin, "https://localhost:") || strings.HasPrefix(origin, "https://127.0.0.1:") {
allowed = true
}
}
if allowed && origin != "" {
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
c.Writer.Header().Set("Vary", "Origin")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Expose-Headers", "X-Request-ID")
}
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, Cache-Control, X-Requested-With, X-Session-Token, X-Admin-Token, X-Dev-Admin, X-CSRF-Token")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
// Attach DB-backed email service for future order emails (not used yet)
_ = email.NewEmailService(config.AppConfig, dbInstance)
// API group for E-shop
api := r.Group("/api/v1/eshop")
// Healthcheck
api.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"service": "myclub-eshop-backend",
"time": time.Now().UTC().Format(time.RFC3339),
})
})
api.HEAD("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"service": "myclub-eshop-backend",
"time": time.Now().UTC().Format(time.RFC3339),
})
})
// Admin Setup & Management Endpoints
admin := api.Group("/admin")
admin.Use(middleware.JWTAuth(dbInstance))
admin.Use(middleware.RoleAuth("admin"))
{
// GET /api/v1/eshop/admin/settings
admin.GET("/settings", func(c *gin.Context) {
var settings models.EshopSettings
if err := dbInstance.First(&settings).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Return defaults if not found
c.JSON(http.StatusOK, models.EshopSettings{
DefaultCurrency: "CZK",
DefaultCountry: "CZ",
})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load settings"})
return
}
c.JSON(http.StatusOK, settings)
})
// PUT /api/v1/eshop/admin/settings
admin.PUT("/settings", func(c *gin.Context) {
var body models.EshopSettings
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
var settings models.EshopSettings
if err := dbInstance.First(&settings).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Create new
settings = body
// Ensure ID is 0 or handled by GORM for creation
settings.ID = 0
if err := dbInstance.Create(&settings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create settings"})
return
}
c.JSON(http.StatusOK, settings)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing settings"})
return
}
// Update existing
settings.DefaultCurrency = body.DefaultCurrency
settings.SupportedCurrencies = body.SupportedCurrencies
settings.DefaultCountry = body.DefaultCountry
settings.ShippingOptionsJSON = body.ShippingOptionsJSON
settings.TermsURL = body.TermsURL
settings.ReturnsPolicyURL = body.ReturnsPolicyURL
settings.SupportEmail = body.SupportEmail
settings.SupportPhone = body.SupportPhone
if err := dbInstance.Save(&settings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
return
}
c.JSON(http.StatusOK, settings)
})
// GET /api/v1/eshop/admin/club-info
// Returns main Club Settings to pre-fill E-shop Setup
admin.GET("/club-info", func(c *gin.Context) {
var clubSettings models.Settings
if err := dbInstance.First(&clubSettings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load club settings"})
return
}
c.JSON(http.StatusOK, gin.H{
"club_name": clubSettings.ClubName,
"club_logo_url": clubSettings.ClubLogoURL,
"contact_email": clubSettings.ContactEmail,
"contact_phone": clubSettings.ContactPhone,
"contact_country": clubSettings.ContactCountry,
"primary_color": clubSettings.PrimaryColor,
})
})
}
// Shipping controller & endpoints
shippingCtrl := eshop_controllers.NewShippingController(config.AppConfig)
// Admin Shipping Actions
admin.POST("/orders/:id/create-packet", shippingCtrl.CreatePacket)
// Public Shipping endpoints
shipping := api.Group("/shipping")
{
shipping.GET("/packeta-widget-config", shippingCtrl.GetPacketaWidgetConfig)
shipping.GET("/labels/:packet_id", shippingCtrl.DownloadLabel)
}
// Support chat routes
support := api.Group("/support")
support.Use(middleware.JWTOptional(dbInstance), middleware.RateLimit(60, time.Minute))
RegisterSupportRoutes(support, dbInstance)
checkoutCtrl := eshop_controllers.NewCheckoutController(dbInstance, config.AppConfig)
api.POST("/checkout", middleware.JWTOptional(dbInstance), middleware.RateLimit(10, time.Minute), checkoutCtrl.Checkout)
// Payment webhooks (Revolut, Stripe)
payments := api.Group("/payments")
{
payments.POST("/revolut/webhook", middleware.RateLimit(60, time.Minute), checkoutCtrl.RevolutWebhook)
payments.POST("/stripe/webhook", middleware.RateLimit(60, time.Minute), checkoutCtrl.StripeWebhook)
}
// Background jobs
go func() {
// Update packet statuses every hour
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
if config.AppConfig.PacketaAPIPassword != "" {
shippingCtrl.UpdatePacketStatuses(dbInstance)
}
}
}()
// Public catalog endpoints (auth optional)
productsPub := api.Group("/products")
productsPub.Use(middleware.JWTOptional(dbInstance))
{
// GET /api/v1/eshop/products
productsPub.GET("", func(c *gin.Context) {
var items []models.EshopProduct
q := dbInstance.
Preload("Category").
Preload("Variants").
Where("active = ?", true).
Order("created_at DESC, id DESC")
if err := q.Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load products"})
return
}
c.JSON(http.StatusOK, gin.H{"data": items})
})
// GET /api/v1/eshop/products/:slug
productsPub.GET(":slug", func(c *gin.Context) {
slug := c.Param("slug")
if strings.TrimSpace(slug) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing product slug"})
return
}
var item models.EshopProduct
if err := dbInstance.
Preload("Category").
Preload("Variants").
Where("slug = ? AND active = ?", slug, true).
First(&item).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, item)
})
}
// Cart controller
cartCtrl := eshop_controllers.NewCartController(dbInstance, config.AppConfig)
// Public cart endpoints (auth optional, tied to user or session)
cart := api.Group("/cart")
cart.Use(middleware.JWTOptional(dbInstance))
{
cart.GET("", cartCtrl.GetCart)
cart.POST("/items", cartCtrl.AddItem)
cart.PATCH("/items/:id", cartCtrl.UpdateItem)
cart.DELETE("/items/:id", cartCtrl.RemoveItem)
}
// GET /api/v1/eshop/orders/:id
api.GET("/orders/:id", middleware.JWTOptional(dbInstance), checkoutCtrl.GetOrder)
port := os.Getenv("PORT")
if strings.TrimSpace(port) == "" {
port = "8080"
}
srv := &http.Server{
Addr: ":" + port,
Handler: r,
ReadTimeout: config.AppConfig.ReadTimeout,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: config.AppConfig.WriteTimeout,
IdleTimeout: config.AppConfig.IdleTimeout,
}
// Graceful shutdown and DB close
sqlDB, err := dbInstance.DB()
if err != nil {
log.Printf("[eshop] Error getting database connection: %v", err)
}
go func() {
log.Printf("[eshop] Server starting on port %s\n", port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("[eshop] Failed to start server: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("[eshop] Shutdown signal received, shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("[eshop] Server forced to shutdown: %v", err)
}
if sqlDB != nil {
if err := sqlDB.Close(); err != nil {
log.Printf("[eshop] Error closing database connection: %v", err)
}
}
}