- Migration 029: local_partnumber_books, local_partnumber_book_items, vendor_spec TEXT column on local_configurations - Models: LocalPartnumberBook, LocalPartnumberBookItem, VendorSpec, VendorSpecItem with JSON Valuer/Scanner - Repository: PartnumberBookRepository (GetActiveBook, FindLotByPartnumber, SaveBook/Items, ListBooks, CountBookItems) - Service: VendorSpecResolver 3-step resolution (book → manual suggestion → unresolved) + AggregateLOTs with is_primary_pn qty logic - Sync: PullPartnumberBooks append-only pull from qt_partnumber_books - Handlers: VendorSpecHandler (GET/PUT/resolve/apply), PartnumberBooksHandler - Routes: /api/configs/:uuid/vendor-spec*, /api/partnumber-books, /api/sync/partnumber-books, /partnumber-books page - UI: 3 top-level tabs [Estimate][BOM вендора][Ценообразование]; Excel paste, PN resolution, inline LOT autocomplete, pricing table - Bible: 03-database.md updated, 09-vendor-spec.md added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1881 lines
56 KiB
Go
1881 lines
56 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
|
||
const startupConsoleWarning = "Не закрывайте консоль иначе приложение не будет работать"
|
||
|
||
func main() {
|
||
showStartupConsoleWarning()
|
||
|
||
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)")
|
||
resetLocalDB := flag.Bool("reset-localdb", false, "reset local SQLite data on startup (keeps connection settings)")
|
||
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)
|
||
}
|
||
}
|
||
|
||
if shouldResetLocalDB(*resetLocalDB) {
|
||
if err := localdb.ResetData(resolvedLocalDBPath); err != nil {
|
||
slog.Error("failed to reset local database", "error", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
|
||
backupCtx, backupCancel := context.WithCancel(context.Background())
|
||
defer backupCancel()
|
||
go startBackupScheduler(backupCtx, cfg, resolvedLocalDBPath, resolvedConfigPath)
|
||
|
||
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()
|
||
backupCancel()
|
||
|
||
// 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 showStartupConsoleWarning() {
|
||
// Visible in console output.
|
||
fmt.Println(startupConsoleWarning)
|
||
// Keep the warning always visible in the console window title when supported.
|
||
fmt.Printf("\033]0;%s\007", startupConsoleWarning)
|
||
}
|
||
|
||
func shouldResetLocalDB(flagValue bool) bool {
|
||
if flagValue {
|
||
return true
|
||
}
|
||
value := strings.TrimSpace(os.Getenv("QFS_RESET_LOCAL_DB"))
|
||
if value == "" {
|
||
return false
|
||
}
|
||
switch strings.ToLower(value) {
|
||
case "1", "true", "yes", "y":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func derefString(value *string) string {
|
||
if value == nil {
|
||
return ""
|
||
}
|
||
return *value
|
||
}
|
||
|
||
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
|
||
}
|
||
if cfg.Backup.Time == "" {
|
||
cfg.Backup.Time = "00:00"
|
||
}
|
||
}
|
||
|
||
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
|
||
|
||
backup:
|
||
time: "00:00"
|
||
|
||
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 runtimeBackupConfig struct {
|
||
Time string `yaml:"time"`
|
||
}
|
||
|
||
type runtimeConfigFile struct {
|
||
Server runtimeServerConfig `yaml:"server"`
|
||
Logging runtimeLoggingConfig `yaml:"logging"`
|
||
Backup runtimeBackupConfig `yaml:"backup"`
|
||
}
|
||
|
||
// 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,
|
||
},
|
||
Backup: runtimeBackupConfig{
|
||
Time: cfg.Backup.Time,
|
||
},
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
|
||
if cfg == nil {
|
||
return
|
||
}
|
||
|
||
hour, minute, err := parseBackupTime(cfg.Backup.Time)
|
||
if err != nil {
|
||
slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
|
||
hour = 0
|
||
minute = 0
|
||
}
|
||
|
||
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
|
||
slog.Error("local backup failed", "error", backupErr)
|
||
} else if len(created) > 0 {
|
||
for _, path := range created {
|
||
slog.Info("local backup completed", "archive", path)
|
||
}
|
||
}
|
||
|
||
for {
|
||
next := nextBackupTime(time.Now(), hour, minute)
|
||
timer := time.NewTimer(time.Until(next))
|
||
|
||
select {
|
||
case <-ctx.Done():
|
||
timer.Stop()
|
||
return
|
||
case <-timer.C:
|
||
start := time.Now()
|
||
created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
|
||
duration := time.Since(start)
|
||
if backupErr != nil {
|
||
slog.Error("local backup failed", "error", backupErr, "duration", duration)
|
||
} else {
|
||
for _, path := range created {
|
||
slog.Info("local backup completed", "archive", path, "duration", duration)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func parseBackupTime(value string) (int, int, error) {
|
||
if strings.TrimSpace(value) == "" {
|
||
return 0, 0, fmt.Errorf("empty backup time")
|
||
}
|
||
parsed, err := time.Parse("15:04", value)
|
||
if err != nil {
|
||
return 0, 0, err
|
||
}
|
||
return parsed.Hour(), parsed.Minute(), nil
|
||
}
|
||
|
||
func nextBackupTime(now time.Time, hour, minute int) time.Time {
|
||
location := now.Location()
|
||
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, location)
|
||
if !now.Before(target) {
|
||
target = target.Add(24 * time.Hour)
|
||
}
|
||
return target
|
||
}
|
||
|
||
// 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, local)
|
||
} 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, local)
|
||
}
|
||
|
||
// 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, projectService)
|
||
pricelistHandler := handlers.NewPricelistHandler(local)
|
||
vendorSpecHandler := handlers.NewVendorSpecHandler(local)
|
||
partnumberBooksHandler := handlers.NewPartnumberBooksHandler(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("/configs/:uuid/revisions", webHandler.ConfigRevisions)
|
||
router.GET("/pricelists", webHandler.Pricelists)
|
||
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
||
router.GET("/partnumber-books", webHandler.PartnumberBooks)
|
||
|
||
// 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)
|
||
}
|
||
|
||
// Partnumber books (read-only)
|
||
pnBooks := api.Group("/partnumber-books")
|
||
{
|
||
pnBooks.GET("", partnumberBooksHandler.List)
|
||
pnBooks.GET("/:id", partnumberBooksHandler.GetItems)
|
||
}
|
||
|
||
// 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.POST("/preview-article", func(c *gin.Context) {
|
||
var req services.ArticlePreviewRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
result, err := configService.BuildArticlePreview(&req)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"article": result.Article,
|
||
"warnings": result.Warnings,
|
||
})
|
||
})
|
||
|
||
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 {
|
||
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, 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"`
|
||
FromVersion int `json:"from_version"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
config, err := configService.CloneNoAuthToProjectFromVersion(uuid, req.Name, dbUsername, nil, req.FromVersion)
|
||
if err != nil {
|
||
if errors.Is(err, services.ErrConfigVersionNotFound) {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
||
return
|
||
}
|
||
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)
|
||
|
||
// Vendor spec (BOM) endpoints
|
||
configs.GET("/:uuid/vendor-spec", vendorSpecHandler.GetVendorSpec)
|
||
configs.PUT("/:uuid/vendor-spec", vendorSpecHandler.PutVendorSpec)
|
||
configs.POST("/:uuid/vendor-spec/resolve", vendorSpecHandler.ResolveVendorSpec)
|
||
configs.POST("/:uuid/vendor-spec/apply", vendorSpecHandler.ApplyVendorSpec)
|
||
|
||
configs.PATCH("/:uuid/server-count", func(c *gin.Context) {
|
||
uuid := c.Param("uuid")
|
||
var req struct {
|
||
ServerCount int `json:"server_count" binding:"required,min=1"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
config, err := configService.UpdateServerCount(uuid, req.ServerCount)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, config)
|
||
})
|
||
}
|
||
|
||
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(derefString(p.Name)), search) &&
|
||
!strings.Contains(strings.ToLower(p.Code), search) &&
|
||
!strings.Contains(strings.ToLower(p.Variant), 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(derefString(left.Name)))
|
||
rightName := strings.ToLower(strings.TrimSpace(derefString(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(derefString(left.Name)))
|
||
rightName := strings.ToLower(strings.TrimSpace(derefString(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,
|
||
"code": p.Code,
|
||
"variant": p.Variant,
|
||
"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"`
|
||
Code string `json:"code"`
|
||
Variant string `json:"variant"`
|
||
Name string `json:"name"`
|
||
IsActive bool `json:"is_active"`
|
||
}
|
||
|
||
simplified := make([]ProjectSimple, 0, len(allProjects))
|
||
for _, p := range allProjects {
|
||
simplified = append(simplified, ProjectSimple{
|
||
UUID: p.UUID,
|
||
Code: p.Code,
|
||
Variant: p.Variant,
|
||
Name: derefString(p.Name),
|
||
IsActive: p.IsActive,
|
||
})
|
||
}
|
||
|
||
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.Code) == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "project code is required"})
|
||
return
|
||
}
|
||
project, err := projectService.Create(dbUsername, &req)
|
||
if err != nil {
|
||
switch {
|
||
case errors.Is(err, services.ErrProjectCodeExists):
|
||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||
default:
|
||
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
|
||
}
|
||
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
||
if err != nil {
|
||
switch {
|
||
case errors.Is(err, services.ErrProjectCodeExists):
|
||
c.JSON(http.StatusConflict, 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, 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.DELETE("/:uuid", func(c *gin.Context) {
|
||
if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil {
|
||
switch {
|
||
case errors.Is(err, services.ErrCannotDeleteMainVariant):
|
||
c.JSON(http.StatusBadRequest, 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, gin.H{"message": "variant deleted"})
|
||
})
|
||
|
||
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.PATCH("/:uuid/configs/reorder", func(c *gin.Context) {
|
||
var req struct {
|
||
OrderedUUIDs []string `json:"ordered_uuids"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if len(req.OrderedUUIDs) == 0 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "ordered_uuids is required"})
|
||
return
|
||
}
|
||
|
||
configs, err := configService.ReorderProjectConfigurationsNoAuth(c.Param("uuid"), req.OrderedUUIDs)
|
||
if err != nil {
|
||
switch {
|
||
case errors.Is(err, services.ErrProjectNotFound):
|
||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||
default:
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
}
|
||
return
|
||
}
|
||
|
||
total := 0.0
|
||
for i := range configs {
|
||
if configs[i].TotalPrice != nil {
|
||
total += *configs[i].TotalPrice
|
||
}
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"project_uuid": c.Param("uuid"),
|
||
"configurations": configs,
|
||
"total": total,
|
||
})
|
||
})
|
||
|
||
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.GET("/:uuid/export", exportHandler.ExportProjectCSV)
|
||
|
||
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("/partnumber-books", syncHandler.SyncPartnumberBooks)
|
||
syncAPI.POST("/all", syncHandler.SyncAll)
|
||
syncAPI.POST("/push", syncHandler.PushPendingChanges)
|
||
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
|
||
syncAPI.GET("/pending", syncHandler.GetPendingChanges)
|
||
syncAPI.POST("/repair", syncHandler.RepairPendingChanges)
|
||
}
|
||
}
|
||
|
||
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
|
||
|
||
blw := &captureResponseWriter{
|
||
ResponseWriter: c.Writer,
|
||
body: bytes.NewBuffer(nil),
|
||
}
|
||
c.Writer = blw
|
||
|
||
c.Next()
|
||
|
||
latency := time.Since(start)
|
||
status := c.Writer.Status()
|
||
|
||
if status >= http.StatusBadRequest {
|
||
responseBody := strings.TrimSpace(blw.body.String())
|
||
if len(responseBody) > 2048 {
|
||
responseBody = responseBody[:2048] + "...(truncated)"
|
||
}
|
||
errText := strings.TrimSpace(c.Errors.String())
|
||
|
||
slog.Error("request failed",
|
||
"method", c.Request.Method,
|
||
"path", path,
|
||
"query", query,
|
||
"status", status,
|
||
"latency", latency,
|
||
"ip", c.ClientIP(),
|
||
"errors", errText,
|
||
"response", responseBody,
|
||
)
|
||
return
|
||
}
|
||
|
||
slog.Info("request",
|
||
"method", c.Request.Method,
|
||
"path", path,
|
||
"query", query,
|
||
"status", status,
|
||
"latency", latency,
|
||
"ip", c.ClientIP(),
|
||
)
|
||
}
|
||
}
|
||
|
||
type captureResponseWriter struct {
|
||
gin.ResponseWriter
|
||
body *bytes.Buffer
|
||
}
|
||
|
||
func (w *captureResponseWriter) Write(b []byte) (int, error) {
|
||
if len(b) > 0 {
|
||
_, _ = w.body.Write(b)
|
||
}
|
||
return w.ResponseWriter.Write(b)
|
||
}
|
||
|
||
func (w *captureResponseWriter) WriteString(s string) (int, error) {
|
||
if s != "" {
|
||
_, _ = w.body.WriteString(s)
|
||
}
|
||
return w.ResponseWriter.WriteString(s)
|
||
}
|