- Add tab-based configurator (Base, Storage, PCI, Power, Accessories, Other) - Base tab: single-select with autocomplete for MB, CPU, MEM - Other tabs: multi-select with autocomplete and quantity input - Table view with LOT, Description, Price, Quantity, Total columns - Add configuration list page with create modal (opportunity number) - Remove Excel export functionality and excelize dependency - Increase component list limit from 100 to 5000 - Add web templates (base, index, configs, login, admin_pricing) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
349 lines
9.4 KiB
Go
349 lines
9.4 KiB
Go
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(),
|
|
)
|
|
}
|
|
}
|