681 lines
18 KiB
Go
681 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"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/localdb"
|
|
"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/pricelist"
|
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
|
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
|
"gorm.io/driver/mysql"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
const (
|
|
localDBPath = "./data/settings.db"
|
|
)
|
|
|
|
func main() {
|
|
configPath := flag.String("config", "config.yaml", "path to config file (optional, for server settings)")
|
|
migrate := flag.Bool("migrate", false, "run database migrations")
|
|
flag.Parse()
|
|
|
|
// Initialize local SQLite database (always used)
|
|
local, err := localdb.New(localDBPath)
|
|
if err != nil {
|
|
slog.Error("failed to initialize local database", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Check if running in setup mode (no connection settings)
|
|
if !local.HasSettings() {
|
|
slog.Info("no database settings found, starting setup mode")
|
|
runSetupMode(local)
|
|
return
|
|
}
|
|
|
|
// Load config for server settings (optional)
|
|
cfg, err := config.Load(*configPath)
|
|
if err != nil {
|
|
// Use defaults if config file doesn't exist
|
|
slog.Info("config file not found, using defaults", "path", *configPath)
|
|
cfg = &config.Config{}
|
|
}
|
|
setConfigDefaults(cfg)
|
|
|
|
setupLogger(cfg.Logging)
|
|
|
|
// Get DSN from local SQLite
|
|
dsn, err := local.GetDSN()
|
|
if err != nil {
|
|
slog.Error("failed to get database settings", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Connect to MariaDB
|
|
db, err := setupDatabaseFromDSN(dsn)
|
|
if err != nil {
|
|
slog.Error("failed to connect to database", "error", err)
|
|
slog.Info("you may need to reconfigure connection at /setup")
|
|
os.Exit(1)
|
|
}
|
|
|
|
dbUser := local.GetDBUser()
|
|
|
|
// Ensure DB user exists in qt_users table (for foreign key constraint)
|
|
dbUserID, err := models.EnsureDBUser(db, dbUser)
|
|
if err != nil {
|
|
slog.Error("failed to ensure DB user exists", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
slog.Info("starting QuoteForge server",
|
|
"host", cfg.Server.Host,
|
|
"port", cfg.Server.Port,
|
|
"db_user", dbUser,
|
|
"db_user_id", dbUserID,
|
|
)
|
|
|
|
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, syncService, err := setupRouter(db, cfg, local, dbUserID)
|
|
if err != nil {
|
|
slog.Error("failed to setup router", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Start background sync worker
|
|
workerCtx, workerCancel := context.WithCancel(context.Background())
|
|
defer workerCancel()
|
|
|
|
syncWorker := sync.NewWorker(syncService, db, 5*time.Minute)
|
|
go syncWorker.Start(workerCtx)
|
|
|
|
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...")
|
|
|
|
// Stop background sync worker first
|
|
syncWorker.Stop()
|
|
workerCancel()
|
|
|
|
// Then shutdown HTTP 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 setConfigDefaults(cfg *config.Config) {
|
|
if cfg.Server.Host == "" {
|
|
cfg.Server.Host = "0.0.0.0"
|
|
}
|
|
if cfg.Server.Port == 0 {
|
|
cfg.Server.Port = 8080
|
|
}
|
|
if cfg.Server.Mode == "" {
|
|
cfg.Server.Mode = "release"
|
|
}
|
|
if cfg.Server.ReadTimeout == 0 {
|
|
cfg.Server.ReadTimeout = 30 * time.Second
|
|
}
|
|
if cfg.Server.WriteTimeout == 0 {
|
|
cfg.Server.WriteTimeout = 30 * time.Second
|
|
}
|
|
if cfg.Pricing.DefaultMethod == "" {
|
|
cfg.Pricing.DefaultMethod = "weighted_median"
|
|
}
|
|
if cfg.Pricing.DefaultPeriodDays == 0 {
|
|
cfg.Pricing.DefaultPeriodDays = 90
|
|
}
|
|
if cfg.Pricing.FreshnessGreenDays == 0 {
|
|
cfg.Pricing.FreshnessGreenDays = 30
|
|
}
|
|
if cfg.Pricing.FreshnessYellowDays == 0 {
|
|
cfg.Pricing.FreshnessYellowDays = 60
|
|
}
|
|
if cfg.Pricing.FreshnessRedDays == 0 {
|
|
cfg.Pricing.FreshnessRedDays = 90
|
|
}
|
|
if cfg.Pricing.MinQuotesForMedian == 0 {
|
|
cfg.Pricing.MinQuotesForMedian = 3
|
|
}
|
|
}
|
|
|
|
// runSetupMode starts a minimal server that only serves the setup page
|
|
func runSetupMode(local *localdb.LocalDB) {
|
|
restartSig := make(chan struct{}, 1)
|
|
|
|
setupHandler, err := handlers.NewSetupHandler(local, "web/templates", restartSig)
|
|
if err != nil {
|
|
slog.Error("failed to create setup handler", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
gin.SetMode(gin.ReleaseMode)
|
|
router := gin.New()
|
|
router.Use(gin.Recovery())
|
|
|
|
router.Static("/static", "web/static")
|
|
|
|
// Setup routes only
|
|
router.GET("/", func(c *gin.Context) {
|
|
c.Redirect(http.StatusFound, "/setup")
|
|
})
|
|
router.GET("/setup", setupHandler.ShowSetup)
|
|
router.POST("/setup", setupHandler.SaveConnection)
|
|
router.POST("/setup/test", setupHandler.TestConnection)
|
|
router.GET("/setup/status", setupHandler.GetStatus)
|
|
|
|
// Health check
|
|
router.GET("/health", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "setup_required",
|
|
"time": time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
})
|
|
|
|
addr := ":8080"
|
|
slog.Info("starting setup mode server", "address", addr)
|
|
slog.Info("open http://localhost:8080/setup to configure database connection")
|
|
|
|
srv := &http.Server{
|
|
Addr: addr,
|
|
Handler: router,
|
|
}
|
|
|
|
go func() {
|
|
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)
|
|
|
|
select {
|
|
case <-quit:
|
|
slog.Info("setup mode server stopped")
|
|
case <-restartSig:
|
|
slog.Info("restarting application with saved settings...")
|
|
|
|
// Graceful shutdown
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
srv.Shutdown(ctx)
|
|
|
|
// Restart process with same arguments
|
|
restartProcess()
|
|
}
|
|
}
|
|
|
|
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 setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
|
|
gormLogger := logger.Default.LogMode(logger.Silent)
|
|
|
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
|
Logger: gormLogger,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sqlDB.SetMaxOpenConns(25)
|
|
sqlDB.SetMaxIdleConns(5)
|
|
sqlDB.SetConnMaxLifetime(5 * time.Minute)
|
|
|
|
return db, nil
|
|
}
|
|
|
|
func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUserID uint) (*gin.Engine, *sync.Service, error) {
|
|
// Repositories
|
|
componentRepo := repository.NewComponentRepository(db)
|
|
categoryRepo := repository.NewCategoryRepository(db)
|
|
priceRepo := repository.NewPriceRepository(db)
|
|
alertRepo := repository.NewAlertRepository(db)
|
|
statsRepo := repository.NewStatsRepository(db)
|
|
pricelistRepo := repository.NewPricelistRepository(db)
|
|
configRepo := repository.NewConfigurationRepository(db)
|
|
|
|
// Services
|
|
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
|
componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
|
quoteService := services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
|
exportService := services.NewExportService(cfg.Export, categoryRepo)
|
|
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
|
pricelistService := pricelist.NewService(db, pricelistRepo, componentRepo)
|
|
syncService := sync.NewService(pricelistRepo, configRepo, local)
|
|
|
|
// isOnline function for local-first architecture
|
|
isOnline := func() bool {
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return sqlDB.Ping() == nil
|
|
}
|
|
|
|
// Local-first configuration service (replaces old ConfigurationService)
|
|
configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline)
|
|
|
|
// Handlers
|
|
componentHandler := handlers.NewComponentHandler(componentService)
|
|
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
|
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
|
pricingHandler := handlers.NewPricingHandler(db, pricingService, alertService, componentRepo, priceRepo, statsRepo)
|
|
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
|
|
syncHandler, err := handlers.NewSyncHandler(local, syncService, db, "web/templates")
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
|
}
|
|
|
|
// Setup handler (for reconfiguration) - no restart signal in normal mode
|
|
setupHandler, err := handlers.NewSetupHandler(local, "web/templates", nil)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("creating setup handler: %w", err)
|
|
}
|
|
|
|
// Web handler (templates)
|
|
webHandler, err := handlers.NewWebHandler("web/templates", componentService)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Router
|
|
router := gin.New()
|
|
router.Use(gin.Recovery())
|
|
router.Use(requestLogger())
|
|
router.Use(middleware.CORS())
|
|
router.Use(middleware.OfflineDetector(db, local))
|
|
|
|
// 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,
|
|
"db_user": local.GetDBUser(),
|
|
})
|
|
})
|
|
|
|
// Current user info (DB user, not app user)
|
|
router.GET("/api/current-user", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"username": local.GetDBUser(),
|
|
"role": "db_user",
|
|
})
|
|
})
|
|
|
|
// Setup routes (for reconfiguration)
|
|
router.GET("/setup", setupHandler.ShowSetup)
|
|
router.POST("/setup", setupHandler.SaveConnection)
|
|
router.POST("/setup/test", setupHandler.TestConnection)
|
|
router.GET("/setup/status", setupHandler.GetStatus)
|
|
|
|
// Web pages
|
|
router.GET("/", webHandler.Index)
|
|
router.GET("/configs", webHandler.Configs)
|
|
router.GET("/configurator", webHandler.Configurator)
|
|
router.GET("/pricelists", func(c *gin.Context) {
|
|
// Redirect to admin/pricing with pricelists tab
|
|
c.Redirect(http.StatusFound, "/admin/pricing?tab=pricelists")
|
|
})
|
|
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
|
router.GET("/admin/pricing", webHandler.AdminPricing)
|
|
|
|
// htmx partials
|
|
partials := router.Group("/partials")
|
|
{
|
|
partials.GET("/components", webHandler.ComponentsPartial)
|
|
partials.GET("/sync-status", syncHandler.SyncStatusPartial)
|
|
}
|
|
|
|
// API routes
|
|
api := router.Group("/api")
|
|
{
|
|
api.GET("/ping", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"message": "pong"})
|
|
})
|
|
|
|
// Components (public read)
|
|
components := api.Group("/components")
|
|
{
|
|
components.GET("", componentHandler.List)
|
|
components.GET("/:lot_name", componentHandler.Get)
|
|
}
|
|
|
|
// Categories (public)
|
|
api.GET("/categories", componentHandler.GetCategories)
|
|
|
|
// Quote (public)
|
|
quote := api.Group("/quote")
|
|
{
|
|
quote.POST("/validate", quoteHandler.Validate)
|
|
quote.POST("/calculate", quoteHandler.Calculate)
|
|
}
|
|
|
|
// Export (public)
|
|
export := api.Group("/export")
|
|
{
|
|
export.POST("/csv", exportHandler.ExportCSV)
|
|
}
|
|
|
|
// Pricelists (public - RBAC disabled in Phase 1-3)
|
|
pricelists := api.Group("/pricelists")
|
|
{
|
|
pricelists.GET("", pricelistHandler.List)
|
|
pricelists.GET("/can-write", pricelistHandler.CanWrite)
|
|
pricelists.GET("/latest", pricelistHandler.GetLatest)
|
|
pricelists.GET("/:id", pricelistHandler.Get)
|
|
pricelists.GET("/:id/items", pricelistHandler.GetItems)
|
|
pricelists.POST("", pricelistHandler.Create)
|
|
pricelists.DELETE("/:id", pricelistHandler.Delete)
|
|
}
|
|
|
|
// Configurations (public - RBAC disabled)
|
|
configs := api.Group("/configs")
|
|
{
|
|
configs.GET("", func(c *gin.Context) {
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
|
|
|
cfgs, total, err := configService.ListAll(page, perPage)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"configurations": cfgs,
|
|
"total": total,
|
|
"page": page,
|
|
"per_page": perPage,
|
|
})
|
|
})
|
|
|
|
configs.POST("", func(c *gin.Context) {
|
|
var req services.CreateConfigRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
config, err := configService.Create(dbUserID, &req) // use DB user ID
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, config)
|
|
})
|
|
|
|
configs.GET("/:uuid", func(c *gin.Context) {
|
|
uuid := c.Param("uuid")
|
|
config, err := configService.GetByUUIDNoAuth(uuid)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, config)
|
|
})
|
|
|
|
configs.PUT("/:uuid", func(c *gin.Context) {
|
|
uuid := c.Param("uuid")
|
|
var req services.CreateConfigRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
config, err := configService.UpdateNoAuth(uuid, &req)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, config)
|
|
})
|
|
|
|
configs.DELETE("/:uuid", func(c *gin.Context) {
|
|
uuid := c.Param("uuid")
|
|
if err := configService.DeleteNoAuth(uuid); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
|
})
|
|
|
|
configs.PATCH("/:uuid/rename", func(c *gin.Context) {
|
|
uuid := c.Param("uuid")
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
config, err := configService.RenameNoAuth(uuid, req.Name)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, config)
|
|
})
|
|
|
|
configs.POST("/:uuid/clone", func(c *gin.Context) {
|
|
uuid := c.Param("uuid")
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
config, err := configService.CloneNoAuth(uuid, req.Name, dbUserID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, config)
|
|
})
|
|
|
|
configs.POST("/:uuid/refresh-prices", func(c *gin.Context) {
|
|
uuid := c.Param("uuid")
|
|
config, err := configService.RefreshPricesNoAuth(uuid)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, config)
|
|
})
|
|
}
|
|
|
|
// Pricing admin (public - RBAC disabled)
|
|
pricingAdmin := api.Group("/admin/pricing")
|
|
{
|
|
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)
|
|
}
|
|
|
|
// Sync API (for offline mode)
|
|
syncAPI := api.Group("/sync")
|
|
{
|
|
syncAPI.GET("/status", syncHandler.GetStatus)
|
|
syncAPI.POST("/components", syncHandler.SyncComponents)
|
|
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
|
|
syncAPI.POST("/all", syncHandler.SyncAll)
|
|
syncAPI.POST("/push", syncHandler.PushPendingChanges)
|
|
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
|
|
syncAPI.GET("/pending", syncHandler.GetPendingChanges)
|
|
}
|
|
}
|
|
|
|
return router, syncService, nil
|
|
}
|
|
|
|
// restartProcess restarts the current process with the same arguments
|
|
func restartProcess() {
|
|
executable, err := os.Executable()
|
|
if err != nil {
|
|
slog.Error("failed to get executable path", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
args := os.Args
|
|
env := os.Environ()
|
|
|
|
slog.Info("executing restart", "executable", executable, "args", args)
|
|
|
|
err = syscall.Exec(executable, args, env)
|
|
if err != nil {
|
|
slog.Error("failed to restart process", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
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(),
|
|
)
|
|
}
|
|
}
|