Add initial backend implementation
- 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>
This commit is contained in:
289
cmd/server/main.go
Normal file
289
cmd/server/main.go
Normal file
@@ -0,0 +1,289 @@
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user