Unified export filename format across both ExportCSV and ExportConfigCSV: - Format: YYYY-MM-DD (project_name) config_name BOM.csv - Use PriceUpdatedAt if available, otherwise CreatedAt - Extract project name from ProjectUUID for ExportCSV via projectService - Pass project_uuid from frontend to backend in export request - Add projectUUID and projectName state variables to track project context This ensures consistent naming whether exporting from form or project view, and uses most recent price update timestamp in filename. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1577 lines
47 KiB
Go
1577 lines
47 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
syncpkg "sync"
|
|
"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/sync"
|
|
"github.com/gin-gonic/gin"
|
|
"gopkg.in/yaml.v3"
|
|
"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
|
|
const onDemandPullCooldown = 30 * time.Second
|
|
|
|
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)
|
|
|
|
resolvedLocalDBPath, err := appstate.ResolveDBPath(*localDBPath)
|
|
if err != nil {
|
|
slog.Error("failed to resolve local database path", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
resolvedConfigPath, err := appstate.ResolveConfigPathNearDB(*configPath, resolvedLocalDBPath)
|
|
if err != nil {
|
|
slog.Error("failed to resolve config 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)
|
|
if err := ensureDefaultConfigFile(resolvedConfigPath); err != nil {
|
|
slog.Error("failed to ensure default config file", "path", resolvedConfigPath, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
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)
|
|
if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil {
|
|
slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
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)
|
|
}
|
|
|
|
if readiness, readinessErr := syncService.GetReadiness(); readinessErr != nil {
|
|
slog.Warn("sync readiness check failed on startup", "error", readinessErr)
|
|
} else if readiness != nil && readiness.Blocked {
|
|
slog.Warn("sync readiness blocked on startup",
|
|
"reason_code", readiness.ReasonCode,
|
|
"reason_text", readiness.ReasonText,
|
|
)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
func ensureDefaultConfigFile(configPath string) error {
|
|
if strings.TrimSpace(configPath) == "" {
|
|
return fmt.Errorf("config path is empty")
|
|
}
|
|
if _, err := os.Stat(configPath); err == nil {
|
|
return nil
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
const defaultConfigYAML = `server:
|
|
host: "127.0.0.1"
|
|
port: 8080
|
|
mode: "release"
|
|
read_timeout: 30s
|
|
write_timeout: 30s
|
|
|
|
logging:
|
|
level: "info"
|
|
format: "json"
|
|
output: "stdout"
|
|
`
|
|
if err := os.WriteFile(configPath, []byte(defaultConfigYAML), 0644); err != nil {
|
|
return err
|
|
}
|
|
slog.Info("created default config file", "path", configPath)
|
|
return nil
|
|
}
|
|
|
|
type runtimeServerConfig struct {
|
|
Host string `yaml:"host"`
|
|
Port int `yaml:"port"`
|
|
Mode string `yaml:"mode"`
|
|
ReadTimeout time.Duration `yaml:"read_timeout"`
|
|
WriteTimeout time.Duration `yaml:"write_timeout"`
|
|
}
|
|
|
|
type runtimeLoggingConfig struct {
|
|
Level string `yaml:"level"`
|
|
Format string `yaml:"format"`
|
|
Output string `yaml:"output"`
|
|
}
|
|
|
|
type runtimeConfigFile struct {
|
|
Server runtimeServerConfig `yaml:"server"`
|
|
Logging runtimeLoggingConfig `yaml:"logging"`
|
|
}
|
|
|
|
// migrateConfigFileToRuntimeShape rewrites config.yaml in a minimal runtime format.
|
|
// Deprecated sections from legacy configs are intentionally dropped.
|
|
func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) error {
|
|
if cfg == nil {
|
|
return fmt.Errorf("config is nil")
|
|
}
|
|
|
|
runtimeCfg := runtimeConfigFile{
|
|
Server: runtimeServerConfig{
|
|
Host: cfg.Server.Host,
|
|
Port: cfg.Server.Port,
|
|
Mode: cfg.Server.Mode,
|
|
ReadTimeout: cfg.Server.ReadTimeout,
|
|
WriteTimeout: cfg.Server.WriteTimeout,
|
|
},
|
|
Logging: runtimeLoggingConfig{
|
|
Level: cfg.Logging.Level,
|
|
Format: cfg.Logging.Format,
|
|
Output: cfg.Logging.Output,
|
|
},
|
|
}
|
|
|
|
rendered, err := yaml.Marshal(&runtimeCfg)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal runtime config: %w", err)
|
|
}
|
|
|
|
current, err := os.ReadFile(configPath)
|
|
if err == nil && bytes.Equal(bytes.TrimSpace(current), bytes.TrimSpace(rendered)) {
|
|
return nil
|
|
}
|
|
if err := os.WriteFile(configPath, rendered, 0644); err != nil {
|
|
return fmt.Errorf("write runtime config: %w", err)
|
|
}
|
|
slog.Info("migrated config.yaml to runtime format", "path", configPath)
|
|
return nil
|
|
}
|
|
|
|
// 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 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)
|
|
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 componentService *services.ComponentService
|
|
var quoteService *services.QuoteService
|
|
var exportService *services.ExportService
|
|
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 {
|
|
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
|
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, nil)
|
|
exportService = services.NewExportService(cfg.Export, categoryRepo)
|
|
} else {
|
|
// In offline mode, we still need to create services that don't require DB.
|
|
componentService = services.NewComponentService(nil, nil, nil)
|
|
quoteService = services.NewQuoteService(nil, nil, nil, local, nil)
|
|
exportService = services.NewExportService(cfg.Export, 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)
|
|
}
|
|
}
|
|
|
|
type pullState struct {
|
|
mu syncpkg.Mutex
|
|
running bool
|
|
lastStarted time.Time
|
|
}
|
|
triggerPull := func(label string, state *pullState, pullFn func() error) {
|
|
state.mu.Lock()
|
|
if state.running {
|
|
state.mu.Unlock()
|
|
return
|
|
}
|
|
if !state.lastStarted.IsZero() && time.Since(state.lastStarted) < onDemandPullCooldown {
|
|
state.mu.Unlock()
|
|
return
|
|
}
|
|
state.running = true
|
|
state.lastStarted = time.Now()
|
|
state.mu.Unlock()
|
|
|
|
go func() {
|
|
defer func() {
|
|
state.mu.Lock()
|
|
state.running = false
|
|
state.mu.Unlock()
|
|
}()
|
|
if err := pullFn(); err != nil {
|
|
slog.Warn("on-demand pull failed", "scope", label, "error", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
var projectsPullState pullState
|
|
var configsPullState pullState
|
|
|
|
syncProjectsFromServer := func() error {
|
|
if !connMgr.IsOnline() {
|
|
return nil
|
|
}
|
|
if readiness, err := syncService.EnsureReadinessForSync(); err != nil {
|
|
slog.Warn("skipping project pull: sync readiness blocked",
|
|
"error", err,
|
|
"reason_code", readiness.ReasonCode,
|
|
"reason_text", readiness.ReasonText,
|
|
)
|
|
return nil
|
|
}
|
|
if _, err := syncService.ImportProjectsToLocal(); err != nil && !errors.Is(err, sync.ErrOffline) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
syncConfigurationsFromServer := func() error {
|
|
if !connMgr.IsOnline() {
|
|
return nil
|
|
}
|
|
if readiness, err := syncService.EnsureReadinessForSync(); err != nil {
|
|
slog.Warn("skipping configuration pull: sync readiness blocked",
|
|
"error", err,
|
|
"reason_code", readiness.ReasonCode,
|
|
"reason_text", readiness.ReasonText,
|
|
)
|
|
return nil
|
|
}
|
|
_, err := configService.ImportFromServer()
|
|
if err != nil && !errors.Is(err, sync.ErrOffline) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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, projectService)
|
|
pricelistHandler := handlers.NewPricelistHandler(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
|
|
var dbError string
|
|
includeCounts := c.Query("include_counts") == "true"
|
|
|
|
// Fast status path: do not execute heavy COUNT queries unless requested.
|
|
status := connMgr.GetStatus()
|
|
dbOK = status.IsConnected
|
|
if !status.IsConnected {
|
|
dbError = "Database not connected (offline mode)"
|
|
if status.LastError != "" {
|
|
dbError = status.LastError
|
|
}
|
|
}
|
|
|
|
// Optional diagnostics mode with server table counts.
|
|
if includeCounts && status.IsConnected {
|
|
if db, err := connMgr.GetDB(); err == nil && db != nil {
|
|
_ = db.Table("lot").Count(&lotCount)
|
|
_ = db.Table("lot_log").Count(&lotLogCount)
|
|
_ = db.Table("qt_lot_metadata").Count(&metadataCount)
|
|
} else if err != nil {
|
|
dbOK = false
|
|
dbError = err.Error()
|
|
} else {
|
|
dbOK = false
|
|
dbError = "Database not connected (offline mode)"
|
|
}
|
|
} else {
|
|
lotCount = 0
|
|
lotLogCount = 0
|
|
metadataCount = 0
|
|
}
|
|
|
|
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", webHandler.Pricelists)
|
|
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
|
|
|
// 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)
|
|
quote.POST("/price-levels", quoteHandler.PriceLevels)
|
|
}
|
|
|
|
// 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("/latest", pricelistHandler.GetLatest)
|
|
pricelists.GET("/:id", pricelistHandler.Get)
|
|
pricelists.GET("/:id/items", pricelistHandler.GetItems)
|
|
pricelists.GET("/:id/lots", pricelistHandler.GetLotNames)
|
|
}
|
|
|
|
// Configurations (public - RBAC disabled)
|
|
configs := api.Group("/configs")
|
|
{
|
|
configs.GET("", func(c *gin.Context) {
|
|
triggerPull("configs", &configsPullState, 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,
|
|
})
|
|
})
|
|
|
|
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
|
|
}
|
|
|
|
projects := api.Group("/projects")
|
|
{
|
|
projects.GET("", func(c *gin.Context) {
|
|
triggerPull("projects", &projectsPullState, syncProjectsFromServer)
|
|
triggerPull("configs", &configsPullState, 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"))
|
|
// Return all projects by default (set high limit for configs to reference)
|
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "1000"))
|
|
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]
|
|
}
|
|
|
|
// Build per-project active config stats in one pass (avoid N+1 scans).
|
|
projectConfigCount := map[string]int{}
|
|
projectConfigTotal := map[string]float64{}
|
|
if localConfigs, cfgErr := local.GetConfigurations(); cfgErr == nil {
|
|
for i := range localConfigs {
|
|
cfg := localConfigs[i]
|
|
if !cfg.IsActive || cfg.ProjectUUID == nil || *cfg.ProjectUUID == "" {
|
|
continue
|
|
}
|
|
projectUUID := *cfg.ProjectUUID
|
|
projectConfigCount[projectUUID]++
|
|
if cfg.TotalPrice != nil {
|
|
projectConfigTotal[projectUUID] += *cfg.TotalPrice
|
|
}
|
|
}
|
|
}
|
|
|
|
projectRows := make([]gin.H, 0, len(paged))
|
|
for i := range paged {
|
|
p := paged[i]
|
|
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": projectConfigCount[p.UUID],
|
|
"total": projectConfigTotal[p.UUID],
|
|
})
|
|
}
|
|
|
|
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,
|
|
})
|
|
})
|
|
|
|
|
|
// GET /api/projects/all - Returns all projects without pagination for UI dropdowns
|
|
projects.GET("/all", func(c *gin.Context) {
|
|
allProjects, err := projectService.ListByUser(dbUsername, true)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Return simplified list of all projects (UUID + Name only)
|
|
type ProjectSimple struct {
|
|
UUID string `json:"uuid"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
simplified := make([]ProjectSimple, 0, len(allProjects))
|
|
for _, p := range allProjects {
|
|
simplified = append(simplified, ProjectSimple{
|
|
UUID: p.UUID,
|
|
Name: p.Name,
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, simplified)
|
|
})
|
|
|
|
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) {
|
|
triggerPull("configs", &configsPullState, 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)
|
|
})
|
|
}
|
|
|
|
// Sync API (for offline mode)
|
|
syncAPI := api.Group("/sync")
|
|
{
|
|
syncAPI.GET("/status", syncHandler.GetStatus)
|
|
syncAPI.GET("/readiness", syncHandler.GetReadiness)
|
|
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(),
|
|
)
|
|
}
|
|
}
|