Files
QuoteForge/cmd/qfs/main.go
2026-02-06 16:42:32 +03:00

1401 lines
43 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"io/fs"
"log/slog"
"math"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"syscall"
"time"
qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
"git.mchus.pro/mchus/quoteforge/internal/appstate"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/db"
"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"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// Version is set via ldflags during build
var Version = "dev"
const backgroundSyncInterval = 5 * time.Minute
func main() {
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
migrate := flag.Bool("migrate", false, "run database migrations")
version := flag.Bool("version", false, "show version information")
flag.Parse()
// Show version if requested
if *version {
fmt.Printf("qfs version %s\n", Version)
os.Exit(0)
}
exePath, _ := os.Executable()
slog.Info("starting qfs", "version", Version, "executable", exePath)
appmeta.SetVersion(Version)
resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath)
if err != nil {
slog.Error("failed to resolve config path", "error", err)
os.Exit(1)
}
resolvedLocalDBPath, err := appstate.ResolveDBPath(*localDBPath)
if err != nil {
slog.Error("failed to resolve local database path", "error", err)
os.Exit(1)
}
// Migrate legacy project-local config path to the user state directory when using defaults.
if *configPath == "" && os.Getenv("QFS_CONFIG_PATH") == "" {
migratedFrom, migrateErr := appstate.MigrateLegacyFile(resolvedConfigPath, []string{"config.yaml"})
if migrateErr != nil {
slog.Warn("failed to migrate legacy config file", "error", migrateErr)
} else if migratedFrom != "" {
slog.Info("migrated legacy config file", "from", migratedFrom, "to", resolvedConfigPath)
}
}
// Migrate legacy project-local DB path to the user state directory when using defaults.
if *localDBPath == "" && os.Getenv("QFS_DB_PATH") == "" {
legacyPaths := []string{
filepath.Join("data", "settings.db"),
filepath.Join("data", "qfs.db"),
}
migratedFrom, migrateErr := appstate.MigrateLegacyDB(resolvedLocalDBPath, legacyPaths)
if migrateErr != nil {
slog.Warn("failed to migrate legacy local database", "error", migrateErr)
} else if migratedFrom != "" {
slog.Info("migrated legacy local database", "from", migratedFrom, "to", resolvedLocalDBPath)
}
}
// Initialize local SQLite database (always used)
local, err := localdb.New(resolvedLocalDBPath)
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(resolvedConfigPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
// Use defaults if config file doesn't exist
slog.Info("config file not found, using defaults", "path", resolvedConfigPath)
cfg = &config.Config{}
} else {
slog.Error("failed to load config", "path", resolvedConfigPath, "error", err)
os.Exit(1)
}
}
setConfigDefaults(cfg)
slog.Info("resolved runtime files", "config_path", resolvedConfigPath, "localdb_path", resolvedLocalDBPath)
setupLogger(cfg.Logging)
// Create connection manager and try to connect immediately if settings exist
connMgr := db.NewConnectionManager(local)
dbUser := local.GetDBUser()
// Try to connect to MariaDB on startup
mariaDB, err := connMgr.GetDB()
if err != nil {
slog.Warn("failed to connect to MariaDB on startup, starting in offline mode", "error", err)
mariaDB = nil
} else {
slog.Info("successfully connected to MariaDB on startup")
}
slog.Info("starting QuoteForge server",
"version", Version,
"host", cfg.Server.Host,
"port", cfg.Server.Port,
"db_user", dbUser,
"online", mariaDB != nil,
)
if *migrate {
if mariaDB == nil {
slog.Error("cannot run migrations: database not available")
os.Exit(1)
}
slog.Info("running database migrations...")
if err := models.Migrate(mariaDB); err != nil {
slog.Error("migration failed", "error", err)
os.Exit(1)
}
if err := models.SeedCategories(mariaDB); err != nil {
slog.Error("seeding categories failed", "error", err)
os.Exit(1)
}
slog.Info("migrations completed")
}
// Always apply SQL migrations on startup when database is available.
// This keeps schema in sync for long-running installations without manual steps.
// If current DB user does not have enough privileges, continue startup in normal mode.
if mariaDB != nil {
sqlMigrationsPath := filepath.Join("migrations")
needsMigrations, err := models.NeedsSQLMigrations(mariaDB, sqlMigrationsPath)
if err != nil {
if models.IsMigrationPermissionError(err) {
slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err)
} else {
slog.Error("startup SQL migrations check failed", "path", sqlMigrationsPath, "error", err)
os.Exit(1)
}
} else if needsMigrations {
if err := models.RunSQLMigrations(mariaDB, sqlMigrationsPath); err != nil {
if models.IsMigrationPermissionError(err) {
slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err)
} else {
slog.Error("startup SQL migrations failed", "path", sqlMigrationsPath, "error", err)
os.Exit(1)
}
} else {
slog.Info("startup SQL migrations applied", "path", sqlMigrationsPath)
}
} else {
slog.Debug("startup SQL migrations not needed", "path", sqlMigrationsPath)
}
}
gin.SetMode(cfg.Server.Mode)
restartSig := make(chan struct{}, 1)
router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUser, restartSig)
if err != nil {
slog.Error("failed to setup router", "error", err)
os.Exit(1)
}
// Start background sync worker (will auto-skip when offline)
workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel()
syncWorker := sync.NewWorker(syncService, connMgr, backgroundSyncInterval)
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)
}
}()
// Automatically open browser after server starts (with a small delay)
go func() {
time.Sleep(1 * time.Second)
// Always use localhost for browser, even if server binds to 0.0.0.0
browserURL := fmt.Sprintf("http://127.0.0.1:%d", cfg.Server.Port)
slog.Info("Opening browser to", "url", browserURL)
err := openBrowser(browserURL)
if err != nil {
slog.Warn("Failed to open browser", "error", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
shouldRestart := false
select {
case <-quit:
slog.Info("shutting down server...")
case <-restartSig:
shouldRestart = true
slog.Info("restarting application after connection settings update...")
}
// 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")
if shouldRestart {
restartProcess()
}
}
func setConfigDefaults(cfg *config.Config) {
if cfg.Server.Host == "" {
cfg.Server.Host = "127.0.0.1"
}
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)
// In setup mode, we don't have a connection manager yet (will restart after setup)
templatesPath := filepath.Join("web", "templates")
setupHandler, err := handlers.NewSetupHandler(local, nil, templatesPath, 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())
staticPath := filepath.Join("web", "static")
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
router.Static("/static", staticPath)
} else if staticFS, err := qfassets.StaticFS(); err == nil {
router.StaticFS("/static", http.FS(staticFS))
}
// 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 := "127.0.0.1:8080"
slog.Info("starting setup mode server", "address", addr, "version", Version)
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)
}
}()
// Open browser to setup page
go func() {
time.Sleep(1 * time.Second)
browserURL := "http://127.0.0.1:8080/setup"
slog.Info("Opening browser to setup page", "url", browserURL)
err := openBrowser(browserURL)
if err != nil {
slog.Warn("Failed to open browser", "error", err)
}
}()
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(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUsername string, restartSig chan struct{}) (*gin.Engine, *sync.Service, error) {
// mariaDB may be nil if we're in offline mode
// Repositories
var componentRepo *repository.ComponentRepository
var categoryRepo *repository.CategoryRepository
var priceRepo *repository.PriceRepository
var alertRepo *repository.AlertRepository
var statsRepo *repository.StatsRepository
var pricelistRepo *repository.PricelistRepository
// Only initialize repositories if we have a database connection
if mariaDB != nil {
componentRepo = repository.NewComponentRepository(mariaDB)
categoryRepo = repository.NewCategoryRepository(mariaDB)
priceRepo = repository.NewPriceRepository(mariaDB)
alertRepo = repository.NewAlertRepository(mariaDB)
statsRepo = repository.NewStatsRepository(mariaDB)
pricelistRepo = repository.NewPricelistRepository(mariaDB)
} else {
// In offline mode, we'll use nil repositories or handle them differently
// This is handled in the sync service and other components
}
// Services
var pricingService *pricing.Service
var componentService *services.ComponentService
var quoteService *services.QuoteService
var exportService *services.ExportService
var alertService *alerts.Service
var pricelistService *pricelist.Service
var syncService *sync.Service
var projectService *services.ProjectService
// Sync service always uses ConnectionManager (works offline and online)
syncService = sync.NewService(connMgr, local)
if mariaDB != nil {
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(mariaDB, pricelistRepo, componentRepo, pricingService)
} else {
// In offline mode, we still need to create services that don't require DB
pricingService = pricing.NewService(nil, nil, cfg.Pricing)
componentService = services.NewComponentService(nil, nil, nil)
quoteService = services.NewQuoteService(nil, nil, pricingService)
exportService = services.NewExportService(cfg.Export, nil)
alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing)
pricelistService = pricelist.NewService(nil, nil, nil, nil)
}
// isOnline function for local-first architecture
isOnline := func() bool {
return connMgr.IsOnline()
}
// Local-first configuration service (replaces old ConfigurationService)
projectService = services.NewProjectService(local)
configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline)
// Data hygiene: remove empty nameless projects and ensure every configuration is attached to a project.
if removed, err := local.ConsolidateSystemProjects(); err == nil && removed > 0 {
slog.Info("consolidated duplicate local system projects", "removed", removed)
}
if removed, err := local.PurgeEmptyNamelessProjects(); err == nil && removed > 0 {
slog.Info("purged empty nameless local projects", "removed", removed)
}
if err := local.BackfillConfigurationProjects(dbUsername); err != nil {
slog.Warn("failed to backfill local configuration projects", "error", err)
}
if mariaDB != nil {
serverProjectRepo := repository.NewProjectRepository(mariaDB)
if removed, err := serverProjectRepo.PurgeEmptyNamelessProjects(); err == nil && removed > 0 {
slog.Info("purged empty nameless server projects", "removed", removed)
}
if err := serverProjectRepo.EnsureSystemProjectsAndBackfillConfigurations(); err != nil {
slog.Warn("failed to backfill server configuration projects", "error", err)
}
}
syncProjectsFromServer := func() {
if !connMgr.IsOnline() {
return
}
if _, err := syncService.ImportProjectsToLocal(); err != nil && !errors.Is(err, sync.ErrOffline) {
slog.Warn("failed to sync projects from server", "error", err)
}
}
syncConfigurationsFromServer := func() {
if !connMgr.IsOnline() {
return
}
_, _ = configService.ImportFromServer()
}
// Use filepath.Join for cross-platform path compatibility
templatesPath := filepath.Join("web", "templates")
// Handlers
componentHandler := handlers.NewComponentHandler(componentService, local)
quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo)
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
if err != nil {
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
}
// Setup handler (for reconfiguration)
setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, restartSig)
if err != nil {
return nil, nil, fmt.Errorf("creating setup handler: %w", err)
}
// Web handler (templates)
webHandler, err := handlers.NewWebHandler(templatesPath, 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(connMgr, local))
// Static files (use filepath.Join for Windows compatibility)
staticPath := filepath.Join("web", "static")
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
router.Static("/static", staticPath)
} else if staticFS, err := qfassets.StaticFS(); err == nil {
router.StaticFS("/static", http.FS(staticFS))
}
// Health check
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"time": time.Now().UTC().Format(time.RFC3339),
})
})
// Restart endpoint (for development purposes)
router.POST("/api/restart", func(c *gin.Context) {
// This will cause the server to restart by exiting
// The restartProcess function will be called to restart the process
slog.Info("Restart requested via API")
go func() {
time.Sleep(100 * time.Millisecond)
restartProcess()
}()
c.JSON(http.StatusOK, gin.H{"message": "restarting..."})
})
// DB status endpoint
router.GET("/api/db-status", func(c *gin.Context) {
var lotCount, lotLogCount, metadataCount int64
var dbOK bool = false
var dbError string
// Check if connection exists (fast check, no reconnect attempt)
status := connMgr.GetStatus()
if status.IsConnected {
// Already connected, safe to use
if db, err := connMgr.GetDB(); err == nil && db != nil {
dbOK = true
db.Table("lot").Count(&lotCount)
db.Table("lot_log").Count(&lotLogCount)
db.Table("qt_lot_metadata").Count(&metadataCount)
}
} else {
// Not connected - don't try to reconnect on status check
// This prevents 3s timeout on every request
dbError = "Database not connected (offline mode)"
if status.LastError != "" {
dbError = status.LastError
}
}
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("/projects", webHandler.Projects)
router.GET("/projects/:uuid", webHandler.ProjectDetail)
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.POST("/create-with-progress", pricelistHandler.CreateWithProgress)
pricelists.PATCH("/:id/active", pricelistHandler.SetActive)
pricelists.DELETE("/:id", pricelistHandler.Delete)
}
// Configurations (public - RBAC disabled)
configs := api.Group("/configs")
{
configs.GET("", func(c *gin.Context) {
syncConfigurationsFromServer()
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
status := c.DefaultQuery("status", "active")
search := c.Query("search")
if status != "active" && status != "archived" && status != "all" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
return
}
cfgs, total, err := configService.ListAllWithStatus(page, perPage, status, search)
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,
"status": status,
"search": search,
})
})
configs.POST("/import", func(c *gin.Context) {
result, err := configService.ImportFromServer()
if err != nil {
if errors.Is(err, sync.ErrOffline) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database is offline"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
})
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(dbUsername, &req)
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": "archived"})
})
configs.POST("/:uuid/reactivate", func(c *gin.Context) {
uuid := c.Param("uuid")
config, err := configService.ReactivateNoAuth(uuid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "reactivated",
"config": config,
})
})
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, dbUsername)
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)
})
configs.PATCH("/:uuid/project", func(c *gin.Context) {
uuid := c.Param("uuid")
var req struct {
ProjectUUID string `json:"project_uuid"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updated, err := configService.SetProjectNoAuth(uuid, req.ProjectUUID)
if err != nil {
switch {
case errors.Is(err, services.ErrConfigNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, updated)
})
configs.GET("/:uuid/versions", func(c *gin.Context) {
uuid := c.Param("uuid")
limit, err := strconv.Atoi(c.DefaultQuery("limit", "20"))
if err != nil || limit <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"})
return
}
offset, err := strconv.Atoi(c.DefaultQuery("offset", "0"))
if err != nil || offset < 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid offset"})
return
}
versions, err := configService.ListVersions(uuid, limit, offset)
if err != nil {
switch {
case errors.Is(err, services.ErrConfigNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
case errors.Is(err, services.ErrInvalidVersionNumber):
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid paging params"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, gin.H{
"versions": versions,
"limit": limit,
"offset": offset,
})
})
configs.GET("/:uuid/versions/:version", func(c *gin.Context) {
uuid := c.Param("uuid")
versionNo, err := strconv.Atoi(c.Param("version"))
if err != nil || versionNo <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version number"})
return
}
version, err := configService.GetVersion(uuid, versionNo)
if err != nil {
switch {
case errors.Is(err, services.ErrInvalidVersionNumber):
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version number"})
case errors.Is(err, services.ErrConfigVersionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, version)
})
configs.POST("/:uuid/rollback", func(c *gin.Context) {
uuid := c.Param("uuid")
var req struct {
TargetVersion int `json:"target_version"`
Note string `json:"note"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.TargetVersion <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_version"})
return
}
config, err := configService.RollbackToVersionWithNote(uuid, req.TargetVersion, dbUsername, req.Note)
if err != nil {
switch {
case errors.Is(err, services.ErrInvalidVersionNumber):
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_version"})
case errors.Is(err, services.ErrConfigNotFound), errors.Is(err, services.ErrConfigVersionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
case errors.Is(err, services.ErrVersionConflict):
c.JSON(http.StatusConflict, gin.H{"error": "version conflict"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
currentVersion, err := configService.GetCurrentVersion(uuid)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": "rollback applied",
"config": config,
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "rollback applied",
"config": config,
"current_version": currentVersion,
})
})
}
projects := api.Group("/projects")
{
projects.GET("", func(c *gin.Context) {
syncProjectsFromServer()
syncConfigurationsFromServer()
status := c.DefaultQuery("status", "active")
search := strings.ToLower(strings.TrimSpace(c.Query("search")))
author := strings.ToLower(strings.TrimSpace(c.Query("author")))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "10"))
sortField := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sort", "created_at")))
sortDir := strings.ToLower(strings.TrimSpace(c.DefaultQuery("dir", "desc")))
if status != "active" && status != "archived" && status != "all" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
return
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 10
}
if perPage > 100 {
perPage = 100
}
if sortField != "name" && sortField != "created_at" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sort field"})
return
}
if sortDir != "asc" && sortDir != "desc" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sort direction"})
return
}
allProjects, err := projectService.ListByUser(dbUsername, true)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
filtered := make([]models.Project, 0, len(allProjects))
for i := range allProjects {
p := allProjects[i]
if status == "active" && !p.IsActive {
continue
}
if status == "archived" && p.IsActive {
continue
}
if search != "" && !strings.Contains(strings.ToLower(p.Name), search) {
continue
}
if author != "" && !strings.Contains(strings.ToLower(strings.TrimSpace(p.OwnerUsername)), author) {
continue
}
filtered = append(filtered, p)
}
sort.Slice(filtered, func(i, j int) bool {
left := filtered[i]
right := filtered[j]
if sortField == "name" {
leftName := strings.ToLower(strings.TrimSpace(left.Name))
rightName := strings.ToLower(strings.TrimSpace(right.Name))
if leftName == rightName {
if sortDir == "asc" {
return left.CreatedAt.Before(right.CreatedAt)
}
return left.CreatedAt.After(right.CreatedAt)
}
if sortDir == "asc" {
return leftName < rightName
}
return leftName > rightName
}
if left.CreatedAt.Equal(right.CreatedAt) {
leftName := strings.ToLower(strings.TrimSpace(left.Name))
rightName := strings.ToLower(strings.TrimSpace(right.Name))
if sortDir == "asc" {
return leftName < rightName
}
return leftName > rightName
}
if sortDir == "asc" {
return left.CreatedAt.Before(right.CreatedAt)
}
return left.CreatedAt.After(right.CreatedAt)
})
total := len(filtered)
totalPages := 0
if total > 0 {
totalPages = int(math.Ceil(float64(total) / float64(perPage)))
}
if totalPages > 0 && page > totalPages {
page = totalPages
}
start := (page - 1) * perPage
if start < 0 {
start = 0
}
end := start + perPage
if end > total {
end = total
}
paged := []models.Project{}
if start < total {
paged = filtered[start:end]
}
projectRows := make([]gin.H, 0, len(paged))
for i := range paged {
p := paged[i]
configs, err := projectService.ListConfigurations(p.UUID, dbUsername, "active")
if err != nil {
configs = &services.ProjectConfigurationsResult{
ProjectUUID: p.UUID,
Configs: []models.Configuration{},
Total: 0,
}
}
projectRows = append(projectRows, gin.H{
"id": p.ID,
"uuid": p.UUID,
"owner_username": p.OwnerUsername,
"name": p.Name,
"tracker_url": p.TrackerURL,
"is_active": p.IsActive,
"is_system": p.IsSystem,
"created_at": p.CreatedAt,
"updated_at": p.UpdatedAt,
"config_count": len(configs.Configs),
"total": configs.Total,
})
}
c.JSON(http.StatusOK, gin.H{
"projects": projectRows,
"status": status,
"search": search,
"author": author,
"sort": sortField,
"dir": sortDir,
"page": page,
"per_page": perPage,
"total": total,
"total_pages": totalPages,
})
})
projects.POST("", func(c *gin.Context) {
var req services.CreateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if strings.TrimSpace(req.Name) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
return
}
project, err := projectService.Create(dbUsername, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, project)
})
projects.GET("/:uuid", func(c *gin.Context) {
project, err := projectService.GetByUUID(c.Param("uuid"), dbUsername)
if err != nil {
switch {
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, project)
})
projects.PUT("/:uuid", func(c *gin.Context) {
var req services.UpdateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if strings.TrimSpace(req.Name) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
return
}
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
if err != nil {
switch {
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, project)
})
projects.POST("/:uuid/archive", func(c *gin.Context) {
if err := projectService.Archive(c.Param("uuid"), dbUsername); err != nil {
switch {
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "project archived"})
})
projects.POST("/:uuid/reactivate", func(c *gin.Context) {
if err := projectService.Reactivate(c.Param("uuid"), dbUsername); err != nil {
switch {
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "project reactivated"})
})
projects.GET("/:uuid/configs", func(c *gin.Context) {
syncConfigurationsFromServer()
status := c.DefaultQuery("status", "active")
if status != "active" && status != "archived" && status != "all" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
return
}
result, err := projectService.ListConfigurations(c.Param("uuid"), dbUsername, status)
if err != nil {
switch {
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.Header("X-Config-Status", status)
c.JSON(http.StatusOK, result)
})
projects.POST("/:uuid/configs", 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
}
projectUUID := c.Param("uuid")
req.ProjectUUID = &projectUUID
config, err := configService.Create(dbUsername, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, config)
})
projects.POST("/:uuid/configs/:config_uuid/clone", func(c *gin.Context) {
var req struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
projectUUID := c.Param("uuid")
config, err := configService.CloneNoAuthToProject(c.Param("config_uuid"), req.Name, dbUsername, &projectUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, 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.GET("/info", syncHandler.GetInfo)
syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
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 openBrowser(url string) error {
var cmd string
var args []string
switch runtime.GOOS {
case "windows":
cmd = "cmd"
args = []string{"/c", "start", url}
case "darwin":
cmd = "open"
args = []string{url}
default: // "linux", "freebsd", "openbsd", "netbsd"
cmd = "xdg-open"
args = []string{url}
}
return exec.Command(cmd, args...).Start()
}
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(),
)
}
}