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 { if err := database.MigrateDB(dbInstance); err != nil { log.Fatalf("[eshop] Failed to run database migrations: %v", err) } } // 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) } } }