package main import ( "context" "flag" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "github.com/gin-gonic/gin" "git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/handlers" "git.mchus.pro/mchus/quoteforge/internal/middleware" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services/alerts" "git.mchus.pro/mchus/quoteforge/internal/services/pricing" "golang.org/x/crypto/bcrypt" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" ) func main() { configPath := flag.String("config", "config.yaml", "path to config file") migrate := flag.Bool("migrate", false, "run database migrations") flag.Parse() cfg, err := config.Load(*configPath) if err != nil { slog.Error("failed to load config", "error", err) os.Exit(1) } setupLogger(cfg.Logging) slog.Info("starting QuoteForge server", "host", cfg.Server.Host, "port", cfg.Server.Port, "mode", cfg.Server.Mode, ) db, err := setupDatabase(cfg.Database) if err != nil { slog.Error("failed to connect to database", "error", err) os.Exit(1) } if *migrate { slog.Info("running database migrations...") if err := models.Migrate(db); err != nil { slog.Error("migration failed", "error", err) os.Exit(1) } if err := models.SeedCategories(db); err != nil { slog.Error("seeding categories failed", "error", err) os.Exit(1) } // Create default admin user (admin / admin123) adminHash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) if err := models.SeedAdminUser(db, string(adminHash)); err != nil { slog.Error("seeding admin user failed", "error", err) os.Exit(1) } slog.Info("migrations completed") } gin.SetMode(cfg.Server.Mode) router, err := setupRouter(db, cfg) if err != nil { slog.Error("failed to setup router", "error", err) os.Exit(1) } srv := &http.Server{ Addr: cfg.Address(), Handler: router, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout, } go func() { slog.Info("server listening", "address", cfg.Address()) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("server error", "error", err) os.Exit(1) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit slog.Info("shutting down server...") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { slog.Error("server forced to shutdown", "error", err) } slog.Info("server stopped") } func setupLogger(cfg config.LoggingConfig) { var level slog.Level switch cfg.Level { case "debug": level = slog.LevelDebug case "warn": level = slog.LevelWarn case "error": level = slog.LevelError default: level = slog.LevelInfo } opts := &slog.HandlerOptions{Level: level} var handler slog.Handler if cfg.Format == "json" { handler = slog.NewJSONHandler(os.Stdout, opts) } else { handler = slog.NewTextHandler(os.Stdout, opts) } slog.SetDefault(slog.New(handler)) } func setupDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) { gormLogger := logger.Default.LogMode(logger.Silent) db, err := gorm.Open(mysql.Open(cfg.DSN()), &gorm.Config{ Logger: gormLogger, }) if err != nil { return nil, err } sqlDB, err := db.DB() if err != nil { return nil, err } sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime) return db, nil } func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) { // Repositories userRepo := repository.NewUserRepository(db) componentRepo := repository.NewComponentRepository(db) categoryRepo := repository.NewCategoryRepository(db) priceRepo := repository.NewPriceRepository(db) configRepo := repository.NewConfigurationRepository(db) alertRepo := repository.NewAlertRepository(db) statsRepo := repository.NewStatsRepository(db) // Services authService := services.NewAuthService(userRepo, cfg.Auth) pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing) componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo) quoteService := services.NewQuoteService(componentRepo, statsRepo, pricingService) configService := services.NewConfigurationService(configRepo, componentRepo, quoteService) exportService := services.NewExportService(cfg.Export) alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing) // Handlers authHandler := handlers.NewAuthHandler(authService, userRepo) componentHandler := handlers.NewComponentHandler(componentService) quoteHandler := handlers.NewQuoteHandler(quoteService) configHandler := handlers.NewConfigurationHandler(configService, exportService) exportHandler := handlers.NewExportHandler(exportService, configService, componentService) pricingHandler := handlers.NewPricingHandler(db, pricingService, alertService, componentRepo, priceRepo, statsRepo) // Web handler (templates) webHandler, err := handlers.NewWebHandler("web/templates", componentService) if err != nil { return nil, err } // Router router := gin.New() router.Use(gin.Recovery()) router.Use(requestLogger()) router.Use(middleware.CORS()) // Static files router.Static("/static", "web/static") // Health check router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", "time": time.Now().UTC().Format(time.RFC3339), }) }) // DB status endpoint router.GET("/api/db-status", func(c *gin.Context) { var lotCount, lotLogCount, metadataCount int64 var dbOK bool = true var dbError string sqlDB, err := db.DB() if err != nil { dbOK = false dbError = err.Error() } else if err := sqlDB.Ping(); err != nil { dbOK = false dbError = err.Error() } db.Table("lot").Count(&lotCount) db.Table("lot_log").Count(&lotLogCount) db.Table("qt_lot_metadata").Count(&metadataCount) c.JSON(http.StatusOK, gin.H{ "connected": dbOK, "error": dbError, "lot_count": lotCount, "lot_log_count": lotLogCount, "metadata_count": metadataCount, }) }) // Web pages router.GET("/", webHandler.Index) router.GET("/login", webHandler.Login) router.GET("/configs", webHandler.Configs) router.GET("/configurator", webHandler.Configurator) router.GET("/admin/pricing", webHandler.AdminPricing) // htmx partials partials := router.Group("/partials") { partials.GET("/components", webHandler.ComponentsPartial) } // API routes api := router.Group("/api") { api.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "pong"}) }) // Auth (public) auth := api.Group("/auth") { auth.POST("/login", authHandler.Login) auth.POST("/refresh", authHandler.Refresh) auth.POST("/logout", authHandler.Logout) auth.GET("/me", middleware.Auth(authService), authHandler.Me) } // Components (public read, for quote builder) components := api.Group("/components") { components.GET("", componentHandler.List) components.GET("/:lot_name", componentHandler.Get) } // Categories (public) api.GET("/categories", componentHandler.GetCategories) // Quote (public, for anonymous quote building) quote := api.Group("/quote") { quote.POST("/validate", quoteHandler.Validate) quote.POST("/calculate", quoteHandler.Calculate) } // Export (public, for anonymous exports) export := api.Group("/export") { export.POST("/csv", exportHandler.ExportCSV) } // Configurations (requires auth) configs := api.Group("/configs") configs.Use(middleware.Auth(authService)) configs.Use(middleware.RequireEditor()) { configs.GET("", configHandler.List) configs.POST("", configHandler.Create) configs.GET("/:uuid", configHandler.Get) configs.PUT("/:uuid", configHandler.Update) configs.DELETE("/:uuid", configHandler.Delete) configs.GET("/:uuid/export", configHandler.ExportJSON) configs.GET("/:uuid/csv", exportHandler.ExportConfigCSV) configs.POST("/import", configHandler.ImportJSON) } } // Admin routes admin := router.Group("/admin") admin.Use(middleware.Auth(authService)) { // Pricing admin pricingAdmin := admin.Group("/pricing") pricingAdmin.Use(middleware.RequirePricingAdmin()) { pricingAdmin.GET("/stats", pricingHandler.GetStats) pricingAdmin.GET("/components", pricingHandler.ListComponents) pricingAdmin.GET("/components/:lot_name", pricingHandler.GetComponentPricing) pricingAdmin.POST("/update", pricingHandler.UpdatePrice) pricingAdmin.POST("/preview", pricingHandler.PreviewPrice) pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll) pricingAdmin.GET("/alerts", pricingHandler.ListAlerts) pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert) pricingAdmin.POST("/alerts/:id/resolve", pricingHandler.ResolveAlert) pricingAdmin.POST("/alerts/:id/ignore", pricingHandler.IgnoreAlert) } } return router, nil } func requestLogger() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() path := c.Request.URL.Path query := c.Request.URL.RawQuery c.Next() latency := time.Since(start) status := c.Writer.Status() slog.Info("request", "method", c.Request.Method, "path", path, "query", query, "status", status, "latency", latency, "ip", c.ClientIP(), ) } }