- Go module with Gin, GORM, JWT, excelize dependencies - Configuration loading from YAML with all settings - GORM models for users, categories, components, configurations, alerts - Repository layer for all entities - Services: auth (JWT), pricing (median/average/weighted), components, quotes, configurations, export (CSV/XLSX), alerts - Middleware: JWT auth, role-based access, CORS - HTTP handlers for all API endpoints - Main server with dependency injection and graceful shutdown Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
290 lines
7.9 KiB
Go
290 lines
7.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/mchus/quoteforge/internal/config"
|
|
"github.com/mchus/quoteforge/internal/handlers"
|
|
"github.com/mchus/quoteforge/internal/middleware"
|
|
"github.com/mchus/quoteforge/internal/models"
|
|
"github.com/mchus/quoteforge/internal/repository"
|
|
"github.com/mchus/quoteforge/internal/services"
|
|
"github.com/mchus/quoteforge/internal/services/alerts"
|
|
"github.com/mchus/quoteforge/internal/services/pricing"
|
|
"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)
|
|
}
|
|
slog.Info("migrations completed")
|
|
}
|
|
|
|
gin.SetMode(cfg.Server.Mode)
|
|
router := setupRouter(db, cfg)
|
|
|
|
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 {
|
|
// 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(pricingService, alertService, componentRepo, statsRepo)
|
|
|
|
// Router
|
|
router := gin.New()
|
|
router.Use(gin.Recovery())
|
|
router.Use(requestLogger())
|
|
router.Use(middleware.CORS())
|
|
|
|
// Health check
|
|
router.GET("/health", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "ok",
|
|
"time": time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
})
|
|
|
|
// 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)
|
|
api.GET("/vendors", componentHandler.GetVendors)
|
|
|
|
// 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)
|
|
export.POST("/xlsx", exportHandler.ExportXLSX)
|
|
}
|
|
|
|
// 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.GET("/:uuid/xlsx", exportHandler.ExportConfigXLSX)
|
|
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("/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
|
|
}
|
|
|
|
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(),
|
|
)
|
|
}
|
|
}
|