17 Commits

Author SHA1 Message Date
Mikhail Chusavitin
f4f92dea66 Store configuration owner by MariaDB username 2026-02-04 12:20:41 +03:00
Mikhail Chusavitin
f42b850734 Recover DB connection automatically after network returns 2026-02-04 11:43:31 +03:00
Mikhail Chusavitin
d094d39427 Add server-to-local configuration import in web UI 2026-02-04 11:31:23 +03:00
Mikhail Chusavitin
4509e93864 Store config in user state and clean old release notes 2026-02-04 11:21:48 +03:00
Mikhail Chusavitin
e2800b06f9 Log binary version and executable path on startup 2026-02-04 10:21:18 +03:00
Mikhail Chusavitin
7c606af2bb Fix missing config handling and auto-restart after setup 2026-02-04 10:19:35 +03:00
Mikhail Chusavitin
fabd30650d Store local DB in user state dir as qfs.db 2026-02-04 10:03:17 +03:00
Mikhail Chusavitin
40ade651b0 Ignore local Go cache directory 2026-02-04 09:55:36 +03:00
Mikhail Chusavitin
1b87c53609 Fix offline usage tracking and active pricelist sync 2026-02-04 09:54:13 +03:00
a3dc264efd Merge feature/phase2-sqlite-sync into main 2026-02-03 22:04:17 +03:00
20056f3593 Embed assets and fix offline/sync/pricing issues 2026-02-03 21:58:02 +03:00
Mikhail Chusavitin
8a37542929 docs: add release notes for v0.2.7 2026-02-03 11:39:23 +03:00
Mikhail Chusavitin
0eb6730a55 fix: Windows compatibility and localhost binding
**Windows compatibility:**
- Added filepath.Join for all template and static paths
- Fixes "path not found" errors on Windows

**Localhost binding:**
- Changed default host from 0.0.0.0 to 127.0.0.1
- Browser always opens on 127.0.0.1 (localhost)
- Setup mode now listens on 127.0.0.1:8080
- Updated config.example.yaml with comment about 0.0.0.0

This ensures the app works correctly on Windows and opens
browser on the correct localhost address.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 11:38:28 +03:00
Mikhail Chusavitin
e2d056e7cb feat: add Windows support to build system
- Add make build-windows for Windows AMD64
- Update make build-all to include Windows
- Update release script to package Windows binary as .zip
- Add Windows installation instructions to docs
- Windows binary: qfs-windows-amd64.exe (~17MB)

All platforms now supported:
- Linux AMD64 (.tar.gz)
- macOS Intel/ARM (.tar.gz)
- Windows AMD64 (.zip)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 11:04:04 +03:00
Mikhail Chusavitin
1bce8086d6 feat: add release build script for multi-platform binaries
- Add scripts/release.sh for automated release builds
- Creates tar.gz packages for Linux and macOS
- Generates SHA256 checksums
- Add 'make release' target
- Add releases/ to .gitignore

Usage:
  make release  # Build and package for all platforms

Output: releases/v0.2.5/*.tar.gz + SHA256SUMS.txt

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:58:41 +03:00
Mikhail Chusavitin
0bdd163728 feat: add version flag and Makefile for release builds
- Add -version flag to show build version
- Add Makefile with build targets:
  - make build-release: optimized build with version
  - make build-all: cross-compile for Linux/macOS
  - make run/test/clean: dev commands
- Update documentation with build commands
- Version is embedded via ldflags during build

Usage:
  make build-release  # Build with version
  ./bin/qfs -version  # Show version

Version format: v0.2.5-1-gfa0f5e3 (tag-commits-hash)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:57:22 +03:00
Mikhail Chusavitin
fa0f5e321d refactor: rename binary from quoteforge to qfs
- Rename cmd/server to cmd/qfs for shorter binary name
- Update all documentation references (README, CLAUDE.md, etc.)
- Update build commands to output bin/qfs
- Binary name now matches directory name

Usage:
  go run ./cmd/qfs              # Development
  go build -o bin/qfs ./cmd/qfs # Production

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:55:14 +03:00
36 changed files with 1253 additions and 357 deletions

4
.gitignore vendored
View File

@@ -13,6 +13,9 @@ config.yaml
/cron
/bin/
# Local Go build cache used in sandboxed runs
.gocache/
# ---> macOS
# General
.DS_Store
@@ -41,3 +44,4 @@ Network Trash Folder
Temporary Items
.apdisk
releases/

View File

@@ -134,9 +134,22 @@ Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind
## Commands
```bash
go run ./cmd/server # Dev server
go run ./cmd/cron -job=X # cleanup-pricelists | update-prices | update-popularity
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
# Development
go run ./cmd/qfs # Dev server
make run # Dev server (via Makefile)
# Production build
make build-release # Optimized build with version (recommended)
VERSION=$(git describe --tags --always --dirty)
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
# Cron jobs
go run ./cmd/cron -job=cleanup-pricelists # Remove old unused pricelists
go run ./cmd/cron -job=update-prices # Recalculate all prices
go run ./cmd/cron -job=update-popularity # Update popularity scores
# Check version
./bin/qfs -version
```
## Code Style

View File

@@ -60,7 +60,7 @@ localConfigService := services.NewLocalConfigurationService(
### Шаг 1: Обновить main.go
```go
// В cmd/server/main.go
// В cmd/qfs/main.go
syncService := sync.NewService(pricelistRepo, configRepo, local)
// Создать isOnline функцию
@@ -165,7 +165,7 @@ type PendingChange struct {
```bash
# Compile
go build ./cmd/server
go build ./cmd/qfs
# Run
./quoteforge

View File

@@ -89,7 +89,7 @@ mysql -u user -p RFQ_LOG < migrations/004_add_price_updated_at.sql
- `internal/models/configuration.go` - добавлено поле `PriceUpdatedAt`
- `internal/services/configuration.go` - добавлен метод `RefreshPrices()`
- `internal/handlers/configuration.go` - добавлен обработчик `RefreshPrices()`
- `cmd/server/main.go` - добавлен маршрут `/api/configs/:uuid/refresh-prices`
- `cmd/qfs/main.go` - добавлен маршрут `/api/configs/:uuid/refresh-prices`
- `web/templates/index.html` - добавлена кнопка и JavaScript функции
- `migrations/004_add_price_updated_at.sql` - SQL миграция
- `CLAUDE.md` - обновлена документация

97
Makefile Normal file
View File

@@ -0,0 +1,97 @@
.PHONY: build build-release clean test run version
# Get version from git
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
LDFLAGS := -s -w -X main.Version=$(VERSION)
# Binary name
BINARY := qfs
# Build for development (with debug info)
build:
go build -o bin/$(BINARY) ./cmd/qfs
# Build for release (optimized, with version)
build-release:
@echo "Building $(BINARY) version $(VERSION)..."
CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY) ./cmd/qfs
@echo "✓ Built: bin/$(BINARY)"
@./bin/$(BINARY) -version
# Build release for Linux (cross-compile)
build-linux:
@echo "Building $(BINARY) for Linux..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-linux-amd64 ./cmd/qfs
@echo "✓ Built: bin/$(BINARY)-linux-amd64"
# Build release for macOS (cross-compile)
build-macos:
@echo "Building $(BINARY) for macOS..."
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-darwin-amd64 ./cmd/qfs
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-darwin-arm64 ./cmd/qfs
@echo "✓ Built: bin/$(BINARY)-darwin-amd64"
@echo "✓ Built: bin/$(BINARY)-darwin-arm64"
# Build release for Windows (cross-compile)
build-windows:
@echo "Building $(BINARY) for Windows..."
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-windows-amd64.exe ./cmd/qfs
@echo "✓ Built: bin/$(BINARY)-windows-amd64.exe"
# Build all platforms
build-all: build-release build-linux build-macos build-windows
# Create release packages for all platforms
release:
@./scripts/release.sh
# Show version
version:
@echo "Version: $(VERSION)"
# Clean build artifacts
clean:
rm -rf bin/
rm -f $(BINARY)
# Run tests
test:
go test -v ./...
# Run development server
run:
go run ./cmd/qfs
# Run with auto-restart (requires entr: brew install entr)
watch:
find . -name '*.go' | entr -r go run ./cmd/qfs
# Install dependencies
deps:
go mod download
go mod tidy
# Help
help:
@echo "QuoteForge Server (qfs) - Build Commands"
@echo ""
@echo "Usage: make [target]"
@echo ""
@echo "Targets:"
@echo " build Build for development (with debug info)"
@echo " build-release Build optimized release (default)"
@echo " build-linux Cross-compile for Linux"
@echo " build-macos Cross-compile for macOS (Intel + Apple Silicon)"
@echo " build-windows Cross-compile for Windows"
@echo " build-all Build for all platforms"
@echo " release Create release packages for all platforms"
@echo " version Show current version"
@echo " clean Remove build artifacts"
@echo " test Run tests"
@echo " run Run development server"
@echo " watch Run with auto-restart (requires entr)"
@echo " deps Install/update dependencies"
@echo " help Show this help"
@echo ""
@echo "Current version: $(VERSION)"

View File

@@ -82,7 +82,7 @@ auth:
### 3. Миграции базы данных
```bash
go run ./cmd/server -migrate
go run ./cmd/qfs -migrate
```
### 4. Импорт метаданных компонентов
@@ -95,15 +95,47 @@ go run ./cmd/importer
```bash
# Development
go run ./cmd/server
go run ./cmd/qfs
# Production
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
./bin/quoteforge
# Production (with Makefile - recommended)
make build-release # Builds with version info
./bin/qfs -version # Check version
# Production (manual)
VERSION=$(git describe --tags --always --dirty)
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
./bin/qfs -version
```
**Makefile команды:**
```bash
make build-release # Оптимизированная сборка с версией
make build-all # Сборка для всех платформ (Linux, macOS, Windows)
make build-windows # Только для Windows
make run # Запуск dev сервера
make test # Запуск тестов
make clean # Очистка bin/
make help # Показать все команды
```
Приложение будет доступно по адресу: http://localhost:8080
### Локальная SQLite база (state)
Локальная база приложения хранится в профиле пользователя и не зависит от расположения бинарника.
Имя файла: `qfs.db`.
- macOS: `~/Library/Application Support/QuoteForge/qfs.db`
- Linux: `$XDG_STATE_HOME/quoteforge/qfs.db` (или `~/.local/state/quoteforge/qfs.db`)
- Windows: `%LOCALAPPDATA%\\QuoteForge\\qfs.db`
Можно переопределить путь через `-localdb` или переменную окружения `QFS_DB_PATH`.
### Локальный config.yaml
По умолчанию `qfs` ищет `config.yaml` в той же user-state папке, где лежит `qfs.db` (а не рядом с бинарником).
Можно переопределить путь через `-config` или `QFS_CONFIG_PATH`.
## Docker
```bash
@@ -209,13 +241,13 @@ go run ./cmd/cron -job=update-popularity
```bash
# Запуск в режиме разработки (hot reload)
go run ./cmd/server
go run ./cmd/qfs
# Запуск тестов
go test ./...
# Сборка для Linux
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/qfs ./cmd/qfs
```
## Переменные окружения
@@ -229,6 +261,9 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
| `QF_DB_PASSWORD` | Пароль БД | — |
| `QF_JWT_SECRET` | Секрет для JWT | — |
| `QF_SERVER_PORT` | Порт сервера | 8080 |
| `QFS_DB_PATH` | Полный путь к локальной SQLite БД | OS-specific user state dir |
| `QFS_STATE_DIR` | Каталог state (если `QFS_DB_PATH` не задан) | OS-specific user state dir |
| `QFS_CONFIG_PATH` | Полный путь к `config.yaml` | OS-specific user state dir |
## Интеграция с существующей БД

21
assets_embed.go Normal file
View File

@@ -0,0 +1,21 @@
package quoteforge
import (
"embed"
"io/fs"
)
// TemplatesFS contains HTML templates embedded into the binary.
//
//go:embed web/templates/*.html web/templates/partials/*.html
var TemplatesFS embed.FS
// StaticFiles contains static assets (CSS, JS, etc.) embedded into the binary.
//
//go:embed web/static/*
var StaticFiles embed.FS
// StaticFS returns a filesystem rooted at web/static for serving static assets.
func StaticFS() (fs.FS, error) {
return fs.Sub(StaticFiles, "web/static")
}

View File

@@ -6,6 +6,7 @@ import (
"log"
"time"
"git.mchus.pro/mchus/quoteforge/internal/appstate"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
@@ -16,7 +17,11 @@ import (
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
localDBPath := flag.String("localdb", "./data/settings.db", "path to local SQLite database")
defaultLocalDBPath, err := appstate.ResolveDBPath("")
if err != nil {
log.Fatalf("Failed to resolve default local SQLite path: %v", err)
}
localDBPath := flag.String("localdb", defaultLocalDBPath, "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
dryRun := flag.Bool("dry-run", false, "show what would be migrated without actually doing it")
flag.Parse()
@@ -72,10 +77,13 @@ func main() {
if *dryRun {
log.Println("\n[DRY RUN] Would migrate the following configurations:")
for _, c := range configs {
userName := "unknown"
if c.User != nil {
userName := c.OwnerUsername
if userName == "" && c.User != nil {
userName = c.User.Username
}
if userName == "" {
userName = "unknown"
}
log.Printf(" - %s (UUID: %s, User: %s, Items: %d)", c.Name, c.UUID, userName, len(c.Items))
}
log.Printf("\nTotal: %d configurations", len(configs))
@@ -110,20 +118,25 @@ func main() {
// Create local configuration
now := time.Now()
localConfig := &localdb.LocalConfiguration{
UUID: c.UUID,
ServerID: &c.ID,
Name: c.Name,
Items: localItems,
TotalPrice: c.TotalPrice,
CustomPrice: c.CustomPrice,
Notes: c.Notes,
IsTemplate: c.IsTemplate,
ServerCount: c.ServerCount,
CreatedAt: c.CreatedAt,
UpdatedAt: now,
SyncedAt: &now,
SyncStatus: "synced",
OriginalUserID: c.UserID,
UUID: c.UUID,
ServerID: &c.ID,
Name: c.Name,
Items: localItems,
TotalPrice: c.TotalPrice,
CustomPrice: c.CustomPrice,
Notes: c.Notes,
IsTemplate: c.IsTemplate,
ServerCount: c.ServerCount,
CreatedAt: c.CreatedAt,
UpdatedAt: now,
SyncedAt: &now,
SyncStatus: "synced",
OriginalUserID: c.UserID,
OriginalUsername: c.OwnerUsername,
}
if localConfig.OriginalUsername == "" && c.User != nil {
localConfig.OriginalUsername = c.User.Username
}
if err := local.SaveConfiguration(localConfig); err != nil {

View File

@@ -2,17 +2,23 @@ package main
import (
"context"
"errors"
"flag"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strconv"
"syscall"
"time"
"github.com/gin-gonic/gin"
qfassets "git.mchus.pro/mchus/quoteforge"
"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"
@@ -25,22 +31,69 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const (
localDBPath = "./data/settings.db"
)
// Version is set via ldflags during build
var Version = "dev"
func main() {
configPath := flag.String("config", "config.yaml", "path to config file (optional, for server settings)")
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)
resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath)
if err != nil {
slog.Error("failed to resolve config path", "error", err)
os.Exit(1)
}
resolvedLocalDBPath, err := appstate.ResolveDBPath(*localDBPath)
if err != nil {
slog.Error("failed to resolve local database path", "error", err)
os.Exit(1)
}
// Migrate legacy project-local config path to the user state directory when using defaults.
if *configPath == "" && os.Getenv("QFS_CONFIG_PATH") == "" {
migratedFrom, migrateErr := appstate.MigrateLegacyFile(resolvedConfigPath, []string{"config.yaml"})
if migrateErr != nil {
slog.Warn("failed to migrate legacy config file", "error", migrateErr)
} else if migratedFrom != "" {
slog.Info("migrated legacy config file", "from", migratedFrom, "to", resolvedConfigPath)
}
}
// Migrate legacy project-local DB path to the user state directory when using defaults.
if *localDBPath == "" && os.Getenv("QFS_DB_PATH") == "" {
legacyPaths := []string{
filepath.Join("data", "settings.db"),
filepath.Join("data", "qfs.db"),
}
migratedFrom, migrateErr := appstate.MigrateLegacyDB(resolvedLocalDBPath, legacyPaths)
if migrateErr != nil {
slog.Warn("failed to migrate legacy local database", "error", migrateErr)
} else if migratedFrom != "" {
slog.Info("migrated legacy local database", "from", migratedFrom, "to", resolvedLocalDBPath)
}
}
// Initialize local SQLite database (always used)
local, err := localdb.New(localDBPath)
local, err := localdb.New(resolvedLocalDBPath)
if err != nil {
slog.Error("failed to initialize local database", "error", err)
os.Exit(1)
@@ -54,13 +107,19 @@ func main() {
}
// Load config for server settings (optional)
cfg, err := config.Load(*configPath)
cfg, err := config.Load(resolvedConfigPath)
if err != nil {
// Use defaults if config file doesn't exist
slog.Info("config file not found, using defaults", "path", *configPath)
cfg = &config.Config{}
if errors.Is(err, fs.ErrNotExist) {
// Use defaults if config file doesn't exist
slog.Info("config file not found, using defaults", "path", resolvedConfigPath)
cfg = &config.Config{}
} else {
slog.Error("failed to load config", "path", resolvedConfigPath, "error", err)
os.Exit(1)
}
}
setConfigDefaults(cfg)
slog.Info("resolved runtime files", "config_path", resolvedConfigPath, "localdb_path", resolvedLocalDBPath)
setupLogger(cfg.Logging)
@@ -68,7 +127,6 @@ func main() {
connMgr := db.NewConnectionManager(local)
dbUser := local.GetDBUser()
dbUserID := uint(1)
// Try to connect to MariaDB on startup
mariaDB, err := connMgr.GetDB()
@@ -77,19 +135,13 @@ func main() {
mariaDB = nil
} else {
slog.Info("successfully connected to MariaDB on startup")
// Ensure DB user exists and get their ID
if dbUserID, err = models.EnsureDBUser(mariaDB, dbUser); err != nil {
slog.Error("failed to ensure DB user", "error", err)
// Continue with default ID
dbUserID = uint(1)
}
}
slog.Info("starting QuoteForge server",
"version", Version,
"host", cfg.Server.Host,
"port", cfg.Server.Port,
"db_user", dbUser,
"db_user_id", dbUserID,
"online", mariaDB != nil,
)
@@ -111,7 +163,7 @@ func main() {
}
gin.SetMode(cfg.Server.Mode)
router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUserID)
router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUser)
if err != nil {
slog.Error("failed to setup router", "error", err)
os.Exit(1)
@@ -139,6 +191,18 @@ func main() {
}
}()
// 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)
<-quit
@@ -162,7 +226,7 @@ func main() {
func setConfigDefaults(cfg *config.Config) {
if cfg.Server.Host == "" {
cfg.Server.Host = "0.0.0.0"
cfg.Server.Host = "127.0.0.1"
}
if cfg.Server.Port == 0 {
cfg.Server.Port = 8080
@@ -201,7 +265,8 @@ 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)
setupHandler, err := handlers.NewSetupHandler(local, nil, "web/templates", restartSig)
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)
@@ -211,7 +276,12 @@ func runSetupMode(local *localdb.LocalDB) {
router := gin.New()
router.Use(gin.Recovery())
router.Static("/static", "web/static")
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) {
@@ -230,9 +300,8 @@ func runSetupMode(local *localdb.LocalDB) {
})
})
addr := ":8080"
slog.Info("starting setup mode server", "address", addr)
slog.Info("open http://localhost:8080/setup to configure database connection")
addr := "127.0.0.1:8080"
slog.Info("starting setup mode server", "address", addr, "version", Version)
srv := &http.Server{
Addr: addr,
@@ -246,6 +315,17 @@ func runSetupMode(local *localdb.LocalDB) {
}
}()
// 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)
@@ -312,7 +392,7 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
return db, nil
}
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUserID uint) (*gin.Engine, *sync.Service, error) {
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUsername string) (*gin.Engine, *sync.Service, error) {
// mariaDB may be nil if we're in offline mode
// Repositories
@@ -373,25 +453,28 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// Local-first configuration service (replaces old ConfigurationService)
configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline)
// Use filepath.Join for cross-platform path compatibility
templatesPath := filepath.Join("web", "templates")
// Handlers
componentHandler := handlers.NewComponentHandler(componentService, local)
quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo)
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, "web/templates")
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath)
if err != nil {
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
}
// Setup handler (for reconfiguration) - no restart signal in normal mode
setupHandler, err := handlers.NewSetupHandler(local, connMgr, "web/templates", nil)
setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, nil)
if err != nil {
return nil, nil, fmt.Errorf("creating setup handler: %w", err)
}
// Web handler (templates)
webHandler, err := handlers.NewWebHandler("web/templates", componentService)
webHandler, err := handlers.NewWebHandler(templatesPath, componentService)
if err != nil {
return nil, nil, err
}
@@ -403,8 +486,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
router.Use(middleware.CORS())
router.Use(middleware.OfflineDetector(connMgr, local))
// Static files
router.Static("/static", "web/static")
// 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) {
@@ -414,6 +502,18 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
})
})
// 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
@@ -544,6 +644,19 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
})
})
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 {
@@ -551,7 +664,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
return
}
config, err := configService.Create(dbUserID, &req) // use DB user ID
config, err := configService.Create(dbUsername, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -625,7 +738,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
return
}
config, err := configService.CloneNoAuth(uuid, req.Name, dbUserID)
config, err := configService.CloneNoAuth(uuid, req.Name, dbUsername)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -698,6 +811,25 @@ func restartProcess() {
}
}
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()

View File

@@ -2,7 +2,7 @@
# Copy this file to config.yaml and update values
server:
host: "0.0.0.0"
host: "127.0.0.1" # Use 0.0.0.0 to listen on all interfaces
port: 8080
mode: "release" # debug | release
read_timeout: "30s"

197
internal/appstate/path.go Normal file
View File

@@ -0,0 +1,197 @@
package appstate
import (
"fmt"
"io"
"os"
"path/filepath"
"runtime"
)
const (
appDirName = "QuoteForge"
defaultDB = "qfs.db"
defaultCfg = "config.yaml"
envDBPath = "QFS_DB_PATH"
envStateDir = "QFS_STATE_DIR"
envCfgPath = "QFS_CONFIG_PATH"
)
// ResolveDBPath returns the local SQLite path using priority:
// explicit CLI path > QFS_DB_PATH > OS-specific user state directory.
func ResolveDBPath(explicitPath string) (string, error) {
if explicitPath != "" {
return filepath.Clean(explicitPath), nil
}
if fromEnv := os.Getenv(envDBPath); fromEnv != "" {
return filepath.Clean(fromEnv), nil
}
dir, err := defaultStateDir()
if err != nil {
return "", err
}
return filepath.Join(dir, defaultDB), nil
}
// ResolveConfigPath returns the config path using priority:
// explicit CLI path > QFS_CONFIG_PATH > OS-specific user state directory.
func ResolveConfigPath(explicitPath string) (string, error) {
if explicitPath != "" {
return filepath.Clean(explicitPath), nil
}
if fromEnv := os.Getenv(envCfgPath); fromEnv != "" {
return filepath.Clean(fromEnv), nil
}
dir, err := defaultStateDir()
if err != nil {
return "", err
}
return filepath.Join(dir, defaultCfg), nil
}
// MigrateLegacyDB copies an existing legacy DB (and optional SQLite sidecars)
// to targetPath if targetPath does not already exist.
// Returns source path if migration happened.
func MigrateLegacyDB(targetPath string, legacyPaths []string) (string, error) {
if targetPath == "" {
return "", nil
}
if exists(targetPath) {
return "", nil
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return "", fmt.Errorf("creating target db directory: %w", err)
}
for _, src := range legacyPaths {
if src == "" {
continue
}
src = filepath.Clean(src)
if src == targetPath || !exists(src) {
continue
}
if err := copyFile(src, targetPath); err != nil {
return "", fmt.Errorf("migrating legacy db from %s: %w", src, err)
}
// Optional SQLite sidecar files.
_ = copyIfExists(src+"-wal", targetPath+"-wal")
_ = copyIfExists(src+"-shm", targetPath+"-shm")
return src, nil
}
return "", nil
}
// MigrateLegacyFile copies an existing legacy file to targetPath
// if targetPath does not already exist.
func MigrateLegacyFile(targetPath string, legacyPaths []string) (string, error) {
if targetPath == "" {
return "", nil
}
if exists(targetPath) {
return "", nil
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return "", fmt.Errorf("creating target directory: %w", err)
}
for _, src := range legacyPaths {
if src == "" {
continue
}
src = filepath.Clean(src)
if src == targetPath || !exists(src) {
continue
}
if err := copyFile(src, targetPath); err != nil {
return "", fmt.Errorf("migrating legacy file from %s: %w", src, err)
}
return src, nil
}
return "", nil
}
func defaultStateDir() (string, error) {
if override := os.Getenv(envStateDir); override != "" {
return filepath.Clean(override), nil
}
switch runtime.GOOS {
case "darwin":
base, err := os.UserConfigDir() // ~/Library/Application Support
if err != nil {
return "", fmt.Errorf("resolving user config dir: %w", err)
}
return filepath.Join(base, appDirName), nil
case "windows":
if local := os.Getenv("LOCALAPPDATA"); local != "" {
return filepath.Join(local, appDirName), nil
}
base, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("resolving user config dir: %w", err)
}
return filepath.Join(base, appDirName), nil
default:
if xdgState := os.Getenv("XDG_STATE_HOME"); xdgState != "" {
return filepath.Join(xdgState, "quoteforge"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolving user home dir: %w", err)
}
return filepath.Join(home, ".local", "state", "quoteforge"), nil
}
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func copyIfExists(src, dst string) error {
if !exists(src) {
return nil
}
return copyFile(src, dst)
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}

View File

@@ -106,7 +106,7 @@ func Load(path string) (*Config, error) {
func (c *Config) setDefaults() {
if c.Server.Host == "" {
c.Server.Host = "0.0.0.0"
c.Server.Host = "127.0.0.1"
}
if c.Server.Port == 0 {
c.Server.Port = 8080

View File

@@ -32,14 +32,14 @@ type ConnectionStatus struct {
// ConnectionManager manages database connections with thread-safety and connection pooling
type ConnectionManager struct {
localDB *localdb.LocalDB // for getting DSN from settings
mu sync.RWMutex // protects db and state
db *gorm.DB // current connection (nil if not connected)
lastError error // last connection error
lastCheck time.Time // time of last check/attempt
connectTimeout time.Duration // timeout for connection (default: 5s)
pingInterval time.Duration // minimum interval between pings (default: 30s)
reconnectCooldown time.Duration // pause after failed attempt (default: 10s)
localDB *localdb.LocalDB // for getting DSN from settings
mu sync.RWMutex // protects db and state
db *gorm.DB // current connection (nil if not connected)
lastError error // last connection error
lastCheck time.Time // time of last check/attempt
connectTimeout time.Duration // timeout for connection (default: 5s)
pingInterval time.Duration // minimum interval between pings (default: 30s)
reconnectCooldown time.Duration // pause after failed attempt (default: 10s)
}
// NewConnectionManager creates a new ConnectionManager instance
@@ -94,6 +94,8 @@ func (cm *ConnectionManager) GetDB() (*gorm.DB, error) {
// Attempt to connect
err := cm.connect()
if err != nil {
// Drop stale handle so callers don't treat it as an active connection.
cm.db = nil
cm.lastError = err
cm.lastCheck = time.Now()
return nil, err
@@ -147,23 +149,27 @@ func (cm *ConnectionManager) connect() error {
return nil
}
// IsOnline checks if the database is currently connected and responsive
// Does not attempt to reconnect, only checks current state with caching
// IsOnline checks if the database is currently connected and responsive.
// If disconnected, it tries to reconnect (respecting cooldowns in GetDB).
func (cm *ConnectionManager) IsOnline() bool {
cm.mu.RLock()
if cm.db == nil {
cm.mu.RUnlock()
return false
}
// If we've checked recently, return cached result
if time.Since(cm.lastCheck) < cm.pingInterval {
cm.mu.RUnlock()
return true
}
isDisconnected := cm.db == nil
lastErr := cm.lastError
checkedRecently := time.Since(cm.lastCheck) < cm.pingInterval
cm.mu.RUnlock()
// Need to perform actual ping
// Try reconnect in disconnected state.
if isDisconnected {
_, err := cm.GetDB()
return err == nil
}
// If we've checked recently, return cached result.
if checkedRecently {
return lastErr == nil
}
// Need to perform actual ping.
cm.mu.Lock()
defer cm.mu.Unlock()
@@ -282,7 +288,7 @@ func extractHostFromDSN(dsn string) string {
}
if parenEnd != -1 {
// Extract host:port part between tcp( and )
hostPort := dsn[tcpStart+1:parenEnd]
hostPort := dsn[tcpStart+1 : parenEnd]
return hostPort
}
}
@@ -317,7 +323,7 @@ func extractHostFromDSN(dsn string) string {
}
if parenEnd != -1 {
hostPort := dsn[parenStart+1:parenEnd]
hostPort := dsn[parenStart+1 : parenEnd]
return hostPort
}
}
@@ -325,4 +331,4 @@ func extractHostFromDSN(dsn string) string {
// If we can't parse it, return empty string
return ""
}
}

View File

@@ -4,10 +4,10 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
)
type ComponentHandler struct {
@@ -40,7 +40,13 @@ func (h *ComponentHandler) List(c *gin.Context) {
}
// If offline mode (empty result), fallback to local components
if result.Total == 0 && h.localDB != nil {
isOffline := false
if v, ok := c.Get("is_offline"); ok {
if b, ok := v.(bool); ok {
isOffline = b
}
}
if isOffline && result.Total == 0 && h.localDB != nil {
localFilter := localdb.ComponentFilter{
Category: filter.Category,
Search: filter.Search,

View File

@@ -1,13 +1,12 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
)
type ConfigurationHandler struct {
@@ -26,11 +25,11 @@ func NewConfigurationHandler(
}
func (h *ConfigurationHandler) List(c *gin.Context) {
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
configs, total, err := h.configService.ListByUser(userID, page, perPage)
configs, total, err := h.configService.ListByUser(username, page, perPage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -45,7 +44,7 @@ func (h *ConfigurationHandler) List(c *gin.Context) {
}
func (h *ConfigurationHandler) Create(c *gin.Context) {
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -53,7 +52,7 @@ func (h *ConfigurationHandler) Create(c *gin.Context) {
return
}
config, err := h.configService.Create(userID, &req)
config, err := h.configService.Create(username, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -63,10 +62,10 @@ func (h *ConfigurationHandler) Create(c *gin.Context) {
}
func (h *ConfigurationHandler) Get(c *gin.Context) {
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
config, err := h.configService.GetByUUID(uuid, userID)
config, err := h.configService.GetByUUID(uuid, username)
if err != nil {
status := http.StatusNotFound
if err == services.ErrConfigForbidden {
@@ -80,7 +79,7 @@ func (h *ConfigurationHandler) Get(c *gin.Context) {
}
func (h *ConfigurationHandler) Update(c *gin.Context) {
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
var req services.CreateConfigRequest
@@ -89,7 +88,7 @@ func (h *ConfigurationHandler) Update(c *gin.Context) {
return
}
config, err := h.configService.Update(uuid, userID, &req)
config, err := h.configService.Update(uuid, username, &req)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
@@ -105,10 +104,10 @@ func (h *ConfigurationHandler) Update(c *gin.Context) {
}
func (h *ConfigurationHandler) Delete(c *gin.Context) {
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
err := h.configService.Delete(uuid, userID)
err := h.configService.Delete(uuid, username)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
@@ -128,7 +127,7 @@ type RenameConfigRequest struct {
}
func (h *ConfigurationHandler) Rename(c *gin.Context) {
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
var req RenameConfigRequest
@@ -137,7 +136,7 @@ func (h *ConfigurationHandler) Rename(c *gin.Context) {
return
}
config, err := h.configService.Rename(uuid, userID, req.Name)
config, err := h.configService.Rename(uuid, username, req.Name)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
@@ -157,7 +156,7 @@ type CloneConfigRequest struct {
}
func (h *ConfigurationHandler) Clone(c *gin.Context) {
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
var req CloneConfigRequest
@@ -166,7 +165,7 @@ func (h *ConfigurationHandler) Clone(c *gin.Context) {
return
}
config, err := h.configService.Clone(uuid, userID, req.Name)
config, err := h.configService.Clone(uuid, username, req.Name)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
@@ -182,10 +181,10 @@ func (h *ConfigurationHandler) Clone(c *gin.Context) {
}
func (h *ConfigurationHandler) RefreshPrices(c *gin.Context) {
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
config, err := h.configService.RefreshPrices(uuid, userID)
config, err := h.configService.RefreshPrices(uuid, username)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {

View File

@@ -5,9 +5,9 @@ import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
)
type ExportHandler struct {
@@ -98,10 +98,10 @@ func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData
}
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
config, err := h.configService.GetByUUID(uuid, userID)
config, err := h.configService.GetByUUID(uuid, username)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return

View File

@@ -5,13 +5,15 @@ import (
"html/template"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/gin-gonic/gin"
qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -34,7 +36,13 @@ func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, te
// Load setup template (standalone, no base needed)
setupPath := filepath.Join(templatesPath, "setup.html")
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(setupPath)
var tmpl *template.Template
var err error
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(setupPath)
} else {
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
}
if err != nil {
return nil, fmt.Errorf("parsing setup template: %w", err)
}
@@ -195,10 +203,11 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
}
// Always restart to properly initialize all services with the new connection
restartRequired := h.restartSig == nil
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Settings saved. Please restart the application to apply changes.",
"restart_required": true,
"success": true,
"message": "Settings saved.",
"restart_required": restartRequired,
})
// Signal restart after response is sent (if restart signal is configured)

View File

@@ -4,13 +4,15 @@ import (
"html/template"
"log/slog"
"net/http"
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin"
)
// SyncHandler handles sync API endpoints
@@ -25,7 +27,13 @@ type SyncHandler struct {
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string) (*SyncHandler, error) {
// Load sync_status partial template
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
tmpl, err := template.ParseFiles(partialPath)
var tmpl *template.Template
var err error
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
tmpl, err = template.ParseFiles(partialPath)
} else {
tmpl, err = template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
}
if err != nil {
return nil, err
}
@@ -40,14 +48,14 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
// SyncStatusResponse represents the sync status
type SyncStatusResponse struct {
LastComponentSync *time.Time `json:"last_component_sync"`
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
IsOnline bool `json:"is_online"`
ComponentsCount int64 `json:"components_count"`
PricelistsCount int64 `json:"pricelists_count"`
ServerPricelists int `json:"server_pricelists"`
NeedComponentSync bool `json:"need_component_sync"`
NeedPricelistSync bool `json:"need_pricelist_sync"`
LastComponentSync *time.Time `json:"last_component_sync"`
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
IsOnline bool `json:"is_online"`
ComponentsCount int64 `json:"components_count"`
PricelistsCount int64 `json:"pricelists_count"`
ServerPricelists int `json:"server_pricelists"`
NeedComponentSync bool `json:"need_component_sync"`
NeedPricelistSync bool `json:"need_pricelist_sync"`
}
// GetStatus returns current sync status
@@ -79,14 +87,14 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
needComponentSync := h.localDB.NeedComponentSync(24)
c.JSON(http.StatusOK, SyncStatusResponse{
LastComponentSync: lastComponentSync,
LastPricelistSync: lastPricelistSync,
IsOnline: isOnline,
ComponentsCount: componentsCount,
PricelistsCount: pricelistsCount,
ServerPricelists: serverPricelists,
NeedComponentSync: needComponentSync,
NeedPricelistSync: needPricelistSync,
LastComponentSync: lastComponentSync,
LastPricelistSync: lastPricelistSync,
IsOnline: isOnline,
ComponentsCount: componentsCount,
PricelistsCount: pricelistsCount,
ServerPricelists: serverPricelists,
NeedComponentSync: needComponentSync,
NeedPricelistSync: needPricelistSync,
})
}
@@ -169,11 +177,11 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
// SyncAllResponse represents result of full sync
type SyncAllResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
ComponentsSynced int `json:"components_synced"`
PricelistsSynced int `json:"pricelists_synced"`
Duration string `json:"duration"`
Success bool `json:"success"`
Message string `json:"message"`
ComponentsSynced int `json:"components_synced"`
PricelistsSynced int `json:"pricelists_synced"`
Duration string `json:"duration"`
}
// SyncAll syncs both components and pricelists
@@ -216,8 +224,8 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
if err != nil {
slog.Error("pricelist sync failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Pricelist sync failed: " + err.Error(),
"success": false,
"error": "Pricelist sync failed: " + err.Error(),
"components_synced": componentsSynced,
})
return
@@ -294,9 +302,9 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
// SyncInfoResponse represents sync information
type SyncInfoResponse struct {
LastSyncAt *time.Time `json:"last_sync_at"`
IsOnline bool `json:"is_online"`
ErrorCount int `json:"error_count"`
LastSyncAt *time.Time `json:"last_sync_at"`
IsOnline bool `json:"is_online"`
ErrorCount int `json:"error_count"`
Errors []SyncError `json:"errors,omitempty"`
}

View File

@@ -2,12 +2,14 @@ package handlers
import (
"html/template"
"os"
"path/filepath"
"strconv"
"github.com/gin-gonic/gin"
qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
)
type WebHandler struct {
@@ -59,12 +61,26 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
templates := make(map[string]*template.Template)
basePath := filepath.Join(templatesPath, "base.html")
useDisk := false
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
useDisk = true
}
// Load each page template with base
simplePages := []string{"login.html", "configs.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"}
for _, page := range simplePages {
pagePath := filepath.Join(templatesPath, page)
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
var tmpl *template.Template
var err error
if useDisk {
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
} else {
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/base.html",
"web/templates/"+page,
)
}
if err != nil {
return nil, err
}
@@ -74,7 +90,18 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
// Index page needs components_list.html as well
indexPath := filepath.Join(templatesPath, "index.html")
componentsListPath := filepath.Join(templatesPath, "components_list.html")
indexTmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
var indexTmpl *template.Template
var err error
if useDisk {
indexTmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
} else {
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/base.html",
"web/templates/index.html",
"web/templates/components_list.html",
)
}
if err != nil {
return nil, err
}
@@ -84,7 +111,16 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
partials := []string{"components_list.html"}
for _, partial := range partials {
partialPath := filepath.Join(templatesPath, partial)
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(partialPath)
var tmpl *template.Template
var err error
if useDisk {
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(partialPath)
} else {
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/"+partial,
)
}
if err != nil {
return nil, err
}

View File

@@ -18,19 +18,24 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
}
local := &LocalConfiguration{
UUID: cfg.UUID,
Name: cfg.Name,
Items: items,
TotalPrice: cfg.TotalPrice,
CustomPrice: cfg.CustomPrice,
Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
PriceUpdatedAt: cfg.PriceUpdatedAt,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
SyncStatus: "pending",
OriginalUserID: cfg.UserID,
UUID: cfg.UUID,
Name: cfg.Name,
Items: items,
TotalPrice: cfg.TotalPrice,
CustomPrice: cfg.CustomPrice,
Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
PriceUpdatedAt: cfg.PriceUpdatedAt,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
SyncStatus: "pending",
OriginalUserID: cfg.UserID,
OriginalUsername: cfg.OwnerUsername,
}
if local.OriginalUsername == "" && cfg.User != nil {
local.OriginalUsername = cfg.User.Username
}
if cfg.ID > 0 {
@@ -55,6 +60,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
cfg := &models.Configuration{
UUID: local.UUID,
UserID: local.OriginalUserID,
OwnerUsername: local.OriginalUsername,
Name: local.Name,
Items: items,
TotalPrice: local.TotalPrice,

View File

@@ -59,22 +59,23 @@ func (c LocalConfigItems) Total() float64 {
// LocalConfiguration stores configurations in local SQLite
type LocalConfiguration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
Name string `gorm:"not null" json:"name"`
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
TotalPrice *float64 `json:"total_price"`
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SyncedAt *time.Time `json:"synced_at"`
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
Name string `gorm:"not null" json:"name"`
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
TotalPrice *float64 `json:"total_price"`
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SyncedAt *time.Time `json:"synced_at"`
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
}
func (LocalConfiguration) TableName() string {
@@ -83,13 +84,13 @@ func (LocalConfiguration) TableName() string {
// LocalPricelist stores cached pricelists from server
type LocalPricelist struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
ServerID uint `gorm:"not null" json:"server_id"` // ID on MariaDB server
Version string `gorm:"uniqueIndex;not null" json:"version"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
SyncedAt time.Time `json:"synced_at"`
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
ServerID uint `gorm:"not null" json:"server_id"` // ID on MariaDB server
Version string `gorm:"uniqueIndex;not null" json:"version"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
SyncedAt time.Time `json:"synced_at"`
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
}
func (LocalPricelist) TableName() string {

View File

@@ -4,9 +4,9 @@ import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
)
const (
@@ -99,3 +99,12 @@ func GetUserID(c *gin.Context) uint {
}
return claims.UserID
}
// GetUsername extracts username from context
func GetUsername(c *gin.Context) string {
claims := GetClaims(c)
if claims == nil {
return ""
}
return claims.Username
}

View File

@@ -40,18 +40,19 @@ func (c ConfigItems) Total() float64 {
}
type Configuration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
UserID uint `gorm:"not null" json:"user_id"`
Name string `gorm:"size:200;not null" json:"name"`
Items ConfigItems `gorm:"type:json;not null" json:"items"`
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
Notes string `gorm:"type:text" json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
UserID uint `gorm:"not null" json:"user_id"` // Legacy owner field (kept for backward compatibility)
OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"`
Name string `gorm:"size:200;not null" json:"name"`
Items ConfigItems `gorm:"type:json;not null" json:"items"`
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
Notes string `gorm:"type:text" json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}

View File

@@ -43,13 +43,16 @@ func (r *ConfigurationRepository) Delete(id uint) error {
return r.db.Delete(&models.Configuration{}, id).Error
}
func (r *ConfigurationRepository) ListByUser(userID uint, offset, limit int) ([]models.Configuration, int64, error) {
func (r *ConfigurationRepository) ListByUser(ownerUsername string, offset, limit int) ([]models.Configuration, int64, error) {
var configs []models.Configuration
var total int64
r.db.Model(&models.Configuration{}).Where("user_id = ?", userID).Count(&total)
ownerScope := "owner_username = ? OR (COALESCE(owner_username, '') = '' AND user_id IN (SELECT id FROM qt_users WHERE username = ?))"
r.db.Model(&models.Configuration{}).Where(ownerScope, ownerUsername, ownerUsername).Count(&total)
err := r.db.
Where("user_id = ?", userID).
Preload("User").
Where(ownerScope, ownerUsername, ownerUsername).
Order("created_at DESC").
Offset(offset).
Limit(limit).
@@ -81,6 +84,7 @@ func (r *ConfigurationRepository) ListAll(offset, limit int) ([]models.Configura
r.db.Model(&models.Configuration{}).Count(&total)
err := r.db.
Preload("User").
Order("created_at DESC").
Offset(offset).
Limit(limit).

View File

@@ -29,6 +29,34 @@ func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary
return nil, 0, fmt.Errorf("listing pricelists: %w", err)
}
return r.toSummaries(pricelists), total, nil
}
// ListActive returns active pricelists with pagination.
func (r *PricelistRepository) ListActive(offset, limit int) ([]models.PricelistSummary, int64, error) {
var total int64
if err := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true).Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("counting active pricelists: %w", err)
}
var pricelists []models.Pricelist
if err := r.db.Where("is_active = ?", true).Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
}
return r.toSummaries(pricelists), total, nil
}
// CountActive returns the number of active pricelists.
func (r *PricelistRepository) CountActive() (int64, error) {
var total int64
if err := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true).Count(&total).Error; err != nil {
return 0, fmt.Errorf("counting active pricelists: %w", err)
}
return total, nil
}
func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []models.PricelistSummary {
// Get item counts for each pricelist
summaries := make([]models.PricelistSummary, len(pricelists))
for i, pl := range pricelists {
@@ -48,7 +76,7 @@ func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary
}
}
return summaries, total, nil
return summaries
}
// GetByID returns a pricelist by ID

View File

@@ -19,7 +19,7 @@ type DataSource interface {
// Configurations
SaveConfiguration(cfg *models.Configuration) error
GetConfigurations(userID uint) ([]models.Configuration, error)
GetConfigurations(ownerUsername string) ([]models.Configuration, error)
GetConfigurationByUUID(uuid string) (*models.Configuration, error)
DeleteConfiguration(uuid string) error
@@ -159,16 +159,17 @@ func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
// Offline: save to local SQLite and queue for sync
localCfg := &localdb.LocalConfiguration{
UUID: cfg.UUID,
Name: cfg.Name,
TotalPrice: cfg.TotalPrice,
CustomPrice: cfg.CustomPrice,
Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
SyncStatus: "pending",
UUID: cfg.UUID,
Name: cfg.Name,
TotalPrice: cfg.TotalPrice,
CustomPrice: cfg.CustomPrice,
Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
SyncStatus: "pending",
OriginalUsername: cfg.OwnerUsername,
}
// Convert items
@@ -196,10 +197,10 @@ func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
}
// GetConfigurations returns all configurations for a user
func (r *UnifiedRepo) GetConfigurations(userID uint) ([]models.Configuration, error) {
func (r *UnifiedRepo) GetConfigurations(ownerUsername string) ([]models.Configuration, error) {
if r.isOnline {
repo := NewConfigurationRepository(r.mariaDB)
configs, _, err := repo.ListByUser(userID, 0, 1000)
configs, _, err := repo.ListByUser(ownerUsername, 0, 1000)
return configs, err
}
@@ -222,15 +223,16 @@ func (r *UnifiedRepo) GetConfigurations(userID uint) ([]models.Configuration, er
}
result[i] = models.Configuration{
UUID: lc.UUID,
Name: lc.Name,
Items: items,
TotalPrice: lc.TotalPrice,
CustomPrice: lc.CustomPrice,
Notes: lc.Notes,
IsTemplate: lc.IsTemplate,
ServerCount: lc.ServerCount,
CreatedAt: lc.CreatedAt,
UUID: lc.UUID,
OwnerUsername: lc.OriginalUsername,
Name: lc.Name,
Items: items,
TotalPrice: lc.TotalPrice,
CustomPrice: lc.CustomPrice,
Notes: lc.Notes,
IsTemplate: lc.IsTemplate,
ServerCount: lc.ServerCount,
CreatedAt: lc.CreatedAt,
}
}

View File

@@ -4,20 +4,20 @@ import (
"errors"
"time"
"github.com/google/uuid"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/google/uuid"
)
var (
ErrConfigNotFound = errors.New("configuration not found")
ErrConfigForbidden = errors.New("access to configuration forbidden")
ErrConfigNotFound = errors.New("configuration not found")
ErrConfigForbidden = errors.New("access to configuration forbidden")
)
// ConfigurationGetter is an interface for services that can retrieve configurations
// Used by handlers to work with both ConfigurationService and LocalConfigurationService
type ConfigurationGetter interface {
GetByUUID(uuid string, userID uint) (*models.Configuration, error)
GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
}
type ConfigurationService struct {
@@ -39,15 +39,15 @@ func NewConfigurationService(
}
type CreateConfigRequest struct {
Name string `json:"name"`
Items models.ConfigItems `json:"items"`
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
Name string `json:"name"`
Items models.ConfigItems `json:"items"`
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
}
func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
total := req.Items.Total()
// If server count is greater than 1, multiply the total by server count
@@ -56,15 +56,15 @@ func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*m
}
config := &models.Configuration{
UUID: uuid.New().String(),
UserID: userID,
Name: req.Name,
Items: req.Items,
TotalPrice: &total,
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
Name: req.Name,
Items: req.Items,
TotalPrice: &total,
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
}
if err := s.configRepo.Create(config); err != nil {
@@ -77,27 +77,27 @@ func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*m
return config, nil
}
func (s *ConfigurationService) GetByUUID(uuid string, userID uint) (*models.Configuration, error) {
func (s *ConfigurationService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
// Allow access if user owns config or it's a template
if config.UserID != userID && !config.IsTemplate {
if !s.isOwner(config, ownerUsername) && !config.IsTemplate {
return nil, ErrConfigForbidden
}
return config, nil
}
func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if config.UserID != userID {
if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden
}
@@ -123,26 +123,26 @@ func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfi
return config, nil
}
func (s *ConfigurationService) Delete(uuid string, userID uint) error {
func (s *ConfigurationService) Delete(uuid string, ownerUsername string) error {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return ErrConfigNotFound
}
if config.UserID != userID {
if !s.isOwner(config, ownerUsername) {
return ErrConfigForbidden
}
return s.configRepo.Delete(config.ID)
}
func (s *ConfigurationService) Rename(uuid string, userID uint, newName string) (*models.Configuration, error) {
func (s *ConfigurationService) Rename(uuid string, ownerUsername string, newName string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if config.UserID != userID {
if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden
}
@@ -155,8 +155,8 @@ func (s *ConfigurationService) Rename(uuid string, userID uint, newName string)
return config, nil
}
func (s *ConfigurationService) Clone(configUUID string, userID uint, newName string) (*models.Configuration, error) {
original, err := s.GetByUUID(configUUID, userID)
func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
original, err := s.GetByUUID(configUUID, ownerUsername)
if err != nil {
return nil, err
}
@@ -170,15 +170,15 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
}
clone := &models.Configuration{
UUID: uuid.New().String(),
UserID: userID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false, // Clone is never a template
ServerCount: original.ServerCount,
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false, // Clone is never a template
ServerCount: original.ServerCount,
}
if err := s.configRepo.Create(clone); err != nil {
@@ -188,7 +188,7 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
return clone, nil
}
func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]models.Configuration, int64, error) {
func (s *ConfigurationService) ListByUser(ownerUsername string, page, perPage int) ([]models.Configuration, int64, error) {
if page < 1 {
page = 1
}
@@ -197,7 +197,7 @@ func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]mod
}
offset := (page - 1) * perPage
return s.configRepo.ListByUser(userID, offset, perPage)
return s.configRepo.ListByUser(ownerUsername, offset, perPage)
}
// ListAll returns all configurations without user filter (for use when auth is disabled)
@@ -274,7 +274,7 @@ func (s *ConfigurationService) RenameNoAuth(uuid string, newName string) (*model
}
// CloneNoAuth clones configuration without ownership check
func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, userID uint) (*models.Configuration, error) {
func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) {
original, err := s.configRepo.GetByUUID(configUUID)
if err != nil {
return nil, ErrConfigNotFound
@@ -286,15 +286,15 @@ func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, us
}
clone := &models.Configuration{
UUID: uuid.New().String(),
UserID: userID, // Use provided user ID
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
}
if err := s.configRepo.Create(clone); err != nil {
@@ -356,13 +356,13 @@ func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Config
}
// RefreshPrices updates all component prices in the configuration with current prices
func (s *ConfigurationService) RefreshPrices(uuid string, userID uint) (*models.Configuration, error) {
func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if config.UserID != userID {
if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden
}
@@ -407,6 +407,19 @@ func (s *ConfigurationService) RefreshPrices(uuid string, userID uint) (*models.
return config, nil
}
func (s *ConfigurationService) isOwner(config *models.Configuration, ownerUsername string) bool {
if config == nil || ownerUsername == "" {
return false
}
if config.OwnerUsername != "" {
return config.OwnerUsername == ownerUsername
}
if config.User != nil {
return config.User.Username == ownerUsername
}
return false
}
// // Export configuration as JSON
// type ConfigExport struct {
// Name string `json:"name"`

View File

@@ -4,10 +4,10 @@ import (
"encoding/json"
"time"
"github.com/google/uuid"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/google/uuid"
)
// LocalConfigurationService handles configurations in local-first mode
@@ -35,7 +35,7 @@ func NewLocalConfigurationService(
}
// Create creates a new configuration in local SQLite and queues it for sync
func (s *LocalConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
// If online, check for new pricelists first
if s.isOnline() {
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
@@ -49,16 +49,16 @@ func (s *LocalConfigurationService) Create(userID uint, req *CreateConfigRequest
}
cfg := &models.Configuration{
UUID: uuid.New().String(),
UserID: userID,
Name: req.Name,
Items: req.Items,
TotalPrice: &total,
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
CreatedAt: time.Now(),
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
Name: req.Name,
Items: req.Items,
TotalPrice: &total,
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
CreatedAt: time.Now(),
}
// Convert to local model
@@ -85,7 +85,7 @@ func (s *LocalConfigurationService) Create(userID uint, req *CreateConfigRequest
}
// GetByUUID returns a configuration from local SQLite
func (s *LocalConfigurationService) GetByUUID(uuid string, userID uint) (*models.Configuration, error) {
func (s *LocalConfigurationService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
@@ -95,7 +95,7 @@ func (s *LocalConfigurationService) GetByUUID(uuid string, userID uint) (*models
cfg := localdb.LocalToConfiguration(localCfg)
// Allow access if user owns config or it's a template
if cfg.UserID != userID && !cfg.IsTemplate {
if !s.isOwner(localCfg, ownerUsername) && !cfg.IsTemplate {
return nil, ErrConfigForbidden
}
@@ -103,13 +103,13 @@ func (s *LocalConfigurationService) GetByUUID(uuid string, userID uint) (*models
}
// Update updates a configuration in local SQLite and queues it for sync
func (s *LocalConfigurationService) Update(uuid string, userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if localCfg.OriginalUserID != userID {
if !s.isOwner(localCfg, ownerUsername) {
return nil, ErrConfigForbidden
}
@@ -155,13 +155,13 @@ func (s *LocalConfigurationService) Update(uuid string, userID uint, req *Create
}
// Delete deletes a configuration from local SQLite and queues it for sync
func (s *LocalConfigurationService) Delete(uuid string, userID uint) error {
func (s *LocalConfigurationService) Delete(uuid string, ownerUsername string) error {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return ErrConfigNotFound
}
if localCfg.OriginalUserID != userID {
if !s.isOwner(localCfg, ownerUsername) {
return ErrConfigForbidden
}
@@ -179,13 +179,13 @@ func (s *LocalConfigurationService) Delete(uuid string, userID uint) error {
}
// Rename renames a configuration
func (s *LocalConfigurationService) Rename(uuid string, userID uint, newName string) (*models.Configuration, error) {
func (s *LocalConfigurationService) Rename(uuid string, ownerUsername string, newName string) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if localCfg.OriginalUserID != userID {
if !s.isOwner(localCfg, ownerUsername) {
return nil, ErrConfigForbidden
}
@@ -211,8 +211,8 @@ func (s *LocalConfigurationService) Rename(uuid string, userID uint, newName str
}
// Clone clones a configuration
func (s *LocalConfigurationService) Clone(configUUID string, userID uint, newName string) (*models.Configuration, error) {
original, err := s.GetByUUID(configUUID, userID)
func (s *LocalConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
original, err := s.GetByUUID(configUUID, ownerUsername)
if err != nil {
return nil, err
}
@@ -223,16 +223,16 @@ func (s *LocalConfigurationService) Clone(configUUID string, userID uint, newNam
}
clone := &models.Configuration{
UUID: uuid.New().String(),
UserID: userID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
CreatedAt: time.Now(),
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
CreatedAt: time.Now(),
}
localCfg := localdb.ConfigurationToLocal(clone)
@@ -253,7 +253,7 @@ func (s *LocalConfigurationService) Clone(configUUID string, userID uint, newNam
}
// ListByUser returns all configurations for a user from local SQLite
func (s *LocalConfigurationService) ListByUser(userID uint, page, perPage int) ([]models.Configuration, int64, error) {
func (s *LocalConfigurationService) ListByUser(ownerUsername string, page, perPage int) ([]models.Configuration, int64, error) {
// Get all local configurations
localConfigs, err := s.localDB.GetConfigurations()
if err != nil {
@@ -263,7 +263,7 @@ func (s *LocalConfigurationService) ListByUser(userID uint, page, perPage int) (
// Filter by user
var userConfigs []models.Configuration
for _, lc := range localConfigs {
if lc.OriginalUserID == userID || lc.IsTemplate {
if (lc.OriginalUsername == ownerUsername) || lc.IsTemplate {
userConfigs = append(userConfigs, *localdb.LocalToConfiguration(&lc))
}
}
@@ -292,7 +292,7 @@ func (s *LocalConfigurationService) ListByUser(userID uint, page, perPage int) (
}
// RefreshPrices updates all component prices in the configuration from local cache
func (s *LocalConfigurationService) RefreshPrices(uuid string, userID uint) (*models.Configuration, error) {
func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername string) (*models.Configuration, error) {
// Get configuration from local SQLite
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
@@ -300,7 +300,7 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, userID uint) (*mo
}
// Check ownership
if localCfg.OriginalUserID != userID {
if !s.isOwner(localCfg, ownerUsername) {
return nil, ErrConfigForbidden
}
@@ -448,7 +448,7 @@ func (s *LocalConfigurationService) RenameNoAuth(uuid string, newName string) (*
}
// CloneNoAuth clones configuration without ownership check
func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName string, userID uint) (*models.Configuration, error) {
func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) {
original, err := s.GetByUUIDNoAuth(configUUID)
if err != nil {
return nil, err
@@ -460,16 +460,16 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
}
clone := &models.Configuration{
UUID: uuid.New().String(),
UserID: userID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
CreatedAt: time.Now(),
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
CreatedAt: time.Now(),
}
localCfg := localdb.ConfigurationToLocal(clone)
@@ -621,3 +621,18 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
return cfg, nil
}
// ImportFromServer imports configurations from MariaDB to local SQLite cache.
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
return s.syncService.ImportConfigurationsToLocal()
}
func (s *LocalConfigurationService) isOwner(cfg *localdb.LocalConfiguration, ownerUsername string) bool {
if cfg == nil || ownerUsername == "" {
return false
}
if cfg.OriginalUsername != "" {
return cfg.OriginalUsername == ownerUsername
}
return false
}

View File

@@ -68,7 +68,7 @@ func (s *Service) CalculatePrice(lotName string, method models.PriceMethod, peri
case models.PriceMethodAverage:
return CalculateAverage(prices), nil
case models.PriceMethodWeightedMedian:
return CalculateWeightedMedian(points, s.config.DefaultPeriodDays), nil
return CalculateWeightedMedian(points, periodDays), nil
case models.PriceMethodMedian:
fallthrough
default:
@@ -149,17 +149,17 @@ func (s *Service) GetPriceStats(lotName string, periodDays int) (*PriceStats, er
}
return &PriceStats{
QuoteCount: len(points),
MinPrice: CalculatePercentile(prices, 0),
MaxPrice: CalculatePercentile(prices, 100),
MedianPrice: CalculateMedian(prices),
AveragePrice: CalculateAverage(prices),
StdDeviation: CalculateStdDev(prices),
LatestPrice: points[0].Price,
LatestDate: points[0].Date,
OldestDate: points[len(points)-1].Date,
Percentile25: CalculatePercentile(prices, 25),
Percentile75: CalculatePercentile(prices, 75),
QuoteCount: len(points),
MinPrice: CalculatePercentile(prices, 0),
MaxPrice: CalculatePercentile(prices, 100),
MedianPrice: CalculateMedian(prices),
AveragePrice: CalculateAverage(prices),
StdDeviation: CalculateStdDev(prices),
LatestPrice: points[0].Price,
LatestDate: points[0].Date,
OldestDate: points[len(points)-1].Date,
Percentile25: CalculatePercentile(prices, 25),
Percentile75: CalculatePercentile(prices, 75),
}, nil
}

View File

@@ -9,14 +9,14 @@ import (
)
var (
ErrEmptyQuote = errors.New("quote cannot be empty")
ErrEmptyQuote = errors.New("quote cannot be empty")
ErrComponentNotFound = errors.New("component not found")
ErrNoPriceAvailable = errors.New("no price available for component")
)
type QuoteService struct {
componentRepo *repository.ComponentRepository
statsRepo *repository.StatsRepository
componentRepo *repository.ComponentRepository
statsRepo *repository.StatsRepository
pricingService *pricing.Service
}
@@ -43,11 +43,11 @@ type QuoteItem struct {
}
type QuoteValidationResult struct {
Valid bool `json:"valid"`
Items []QuoteItem `json:"items"`
Errors []string `json:"errors"`
Warnings []string `json:"warnings"`
Total float64 `json:"total"`
Valid bool `json:"valid"`
Items []QuoteItem `json:"items"`
Errors []string `json:"errors"`
Warnings []string `json:"warnings"`
Total float64 `json:"total"`
}
type QuoteRequest struct {
@@ -61,6 +61,9 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
if len(req.Items) == 0 {
return nil, ErrEmptyQuote
}
if s.componentRepo == nil || s.pricingService == nil {
return nil, errors.New("offline mode: quote calculation not available")
}
result := &QuoteValidationResult{
Valid: true,
@@ -129,6 +132,11 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
// RecordUsage records that components were used in a quote
func (s *QuoteService) RecordUsage(items []models.ConfigItem) error {
if s.statsRepo == nil {
// Offline mode: usage stats are unavailable and should not block config saves.
return nil
}
for _, item := range items {
revenue := item.UnitPrice * float64(item.Quantity)
if err := s.statsRepo.IncrementUsage(item.LotName, item.Quantity, revenue); err != nil {

View File

@@ -2,6 +2,7 @@ package sync
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
@@ -10,8 +11,11 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"gorm.io/gorm"
)
var ErrOffline = errors.New("database is offline")
// Service handles synchronization between MariaDB and local SQLite
type Service struct {
connMgr *db.ConnectionManager
@@ -34,6 +38,71 @@ type SyncStatus struct {
NeedsSync bool `json:"needs_sync"`
}
// ConfigImportResult represents server->local configuration import stats.
type ConfigImportResult struct {
Imported int `json:"imported"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
}
// ImportConfigurationsToLocal imports configurations from MariaDB into local SQLite.
// Existing local configs with pending local changes are skipped to avoid data loss.
func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
mariaDB, err := s.connMgr.GetDB()
if err != nil {
return nil, ErrOffline
}
configRepo := repository.NewConfigurationRepository(mariaDB)
result := &ConfigImportResult{}
offset := 0
const limit = 200
for {
serverConfigs, _, err := configRepo.ListAll(offset, limit)
if err != nil {
return nil, fmt.Errorf("listing server configurations: %w", err)
}
if len(serverConfigs) == 0 {
break
}
for i := range serverConfigs {
cfg := serverConfigs[i]
existing, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("getting local configuration %s: %w", cfg.UUID, err)
}
if existing != nil && err == nil && existing.SyncStatus == "pending" {
result.Skipped++
continue
}
localCfg := localdb.ConfigurationToLocal(&cfg)
now := time.Now()
localCfg.SyncedAt = &now
localCfg.SyncStatus = "synced"
localCfg.UpdatedAt = now
if existing != nil && err == nil {
localCfg.ID = existing.ID
result.Updated++
} else {
result.Imported++
}
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, fmt.Errorf("saving local configuration %s: %w", cfg.UUID, err)
}
}
offset += len(serverConfigs)
}
return result, nil
}
// GetStatus returns the current sync status
func (s *Service) GetStatus() (*SyncStatus, error) {
lastSync := s.localDB.GetLastSyncTime()
@@ -44,9 +113,9 @@ func (s *Service) GetStatus() (*SyncStatus, error) {
if connStatus.IsConnected {
if mariaDB, err := s.connMgr.GetDB(); err == nil && mariaDB != nil {
pricelistRepo := repository.NewPricelistRepository(mariaDB)
serverPricelists, _, err := pricelistRepo.List(0, 1)
activeCount, err := pricelistRepo.CountActive()
if err == nil {
serverCount = len(serverPricelists)
serverCount = int(activeCount)
}
}
}
@@ -126,20 +195,24 @@ func (s *Service) SyncPricelists() (int, error) {
// Create repository
pricelistRepo := repository.NewPricelistRepository(mariaDB)
// Get all active pricelists from server (up to 100)
serverPricelists, _, err := pricelistRepo.List(0, 100)
// Get active pricelists from server (up to 100)
serverPricelists, _, err := pricelistRepo.ListActive(0, 100)
if err != nil {
return 0, fmt.Errorf("getting server pricelists: %w", err)
return 0, fmt.Errorf("getting active server pricelists: %w", err)
}
synced := 0
var latestLocalID uint
var latestServerID uint
for _, pl := range serverPricelists {
// Check if pricelist already exists locally
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
if existing != nil {
// Already synced, track latest
latestLocalID = existing.ID
// Already synced, track latest by server ID
if pl.ID > latestServerID {
latestServerID = pl.ID
latestLocalID = existing.ID
}
continue
}
@@ -167,7 +240,10 @@ func (s *Service) SyncPricelists() (int, error) {
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
}
latestLocalID = localPL.ID
if pl.ID > latestServerID {
latestServerID = pl.ID
latestLocalID = localPL.ID
}
synced++
}

View File

@@ -0,0 +1,10 @@
-- Store configuration owner as username (instead of relying on numeric user_id)
ALTER TABLE qt_configurations
ADD COLUMN owner_username VARCHAR(100) NOT NULL DEFAULT '' AFTER user_id,
ADD INDEX idx_qt_configurations_owner_username (owner_username);
-- Backfill owner_username from qt_users for existing rows
UPDATE qt_configurations c
LEFT JOIN qt_users u ON u.id = c.user_id
SET c.owner_username = COALESCE(u.username, c.owner_username)
WHERE c.owner_username = '';

100
scripts/release.sh Executable file
View File

@@ -0,0 +1,100 @@
#!/bin/bash
set -e
# QuoteForge Release Build Script
# Creates binaries for all platforms and packages them for release
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Get version from git
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
if [[ $VERSION == *"dirty"* ]]; then
echo -e "${RED}✗ Error: Working directory has uncommitted changes${NC}"
echo " Commit your changes first"
exit 1
fi
echo -e "${GREEN}Building QuoteForge version: ${VERSION}${NC}"
echo ""
# Create release directory
RELEASE_DIR="releases/${VERSION}"
mkdir -p "${RELEASE_DIR}"
# Build for all platforms
echo -e "${YELLOW}→ Building binaries...${NC}"
make build-all
# Package binaries with checksums
echo ""
echo -e "${YELLOW}→ Creating release packages...${NC}"
# Linux AMD64
if [ -f "bin/qfs-linux-amd64" ]; then
cd bin
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-linux-amd64.tar.gz" qfs-linux-amd64
cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-linux-amd64.tar.gz${NC}"
fi
# macOS Intel
if [ -f "bin/qfs-darwin-amd64" ]; then
cd bin
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-amd64.tar.gz" qfs-darwin-amd64
cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-amd64.tar.gz${NC}"
fi
# macOS Apple Silicon
if [ -f "bin/qfs-darwin-arm64" ]; then
cd bin
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-arm64.tar.gz" qfs-darwin-arm64
cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-arm64.tar.gz${NC}"
fi
# Windows AMD64
if [ -f "bin/qfs-windows-amd64.exe" ]; then
cd bin
zip -q "../${RELEASE_DIR}/qfs-${VERSION}-windows-amd64.zip" qfs-windows-amd64.exe
cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-windows-amd64.zip${NC}"
fi
# Generate checksums
echo ""
echo -e "${YELLOW}→ Generating checksums...${NC}"
cd "${RELEASE_DIR}"
shasum -a 256 *.tar.gz *.zip > SHA256SUMS.txt 2>/dev/null || shasum -a 256 * | grep -v SHA256SUMS > SHA256SUMS.txt
cd ../..
echo -e "${GREEN} ✓ SHA256SUMS.txt${NC}"
# List release files
echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN}Release ${VERSION} built successfully!${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo "Files in ${RELEASE_DIR}:"
ls -lh "${RELEASE_DIR}"
echo ""
# Show next steps
echo -e "${YELLOW}Next steps:${NC}"
echo " 1. Create git tag:"
echo " git tag -a ${VERSION} -m \"Release ${VERSION}\""
echo ""
echo " 2. Push tag to remote:"
echo " git push origin ${VERSION}"
echo ""
echo " 3. Create release on git.mchus.pro:"
echo " - Go to: https://git.mchus.pro/mchus/QuoteForge/releases"
echo " - Click 'New Release'"
echo " - Select tag: ${VERSION}"
echo " - Upload files from: ${RELEASE_DIR}/"
echo ""
echo -e "${GREEN}Done!${NC}"

View File

@@ -861,7 +861,7 @@ function renderAllConfigs(configs) {
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
const serverCount = c.server_count ? c.server_count : 1;
const username = c.user ? c.user.username : '—';
const username = c.owner_username || (c.user ? c.user.username : '—');
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-3 py-2 text-sm text-gray-500">' + date + '</td>';

View File

@@ -4,10 +4,13 @@
<div class="space-y-4">
<h1 class="text-2xl font-bold">Мои конфигурации</h1>
<div class="mt-4">
<button onclick="openCreateModal()" class="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
<div class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
<button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
+ Создать новую конфигурацию
</button>
<button id="import-configs-btn" onclick="importConfigsFromServer()" class="py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium">
Импорт с сервера
</button>
</div>
<div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden">
@@ -124,6 +127,7 @@ function renderConfigs(configs) {
html += '<thead class="bg-gray-50"><tr>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Название</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
@@ -134,6 +138,7 @@ function renderConfigs(configs) {
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
const serverCount = c.server_count ? c.server_count : 1;
const author = c.owner_username || (c.user && c.user.username) || '—';
// Calculate price per unit (total / server count)
let pricePerUnit = '—';
@@ -145,6 +150,7 @@ function renderConfigs(configs) {
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>';
@@ -407,6 +413,38 @@ async function loadConfigs() {
}
}
async function importConfigsFromServer() {
const button = document.getElementById('import-configs-btn');
const originalText = button.textContent;
button.disabled = true;
button.textContent = 'Импорт...';
try {
const resp = await fetch('/api/configs/import', { method: 'POST' });
const data = await resp.json();
if (!resp.ok) {
alert('Ошибка импорта: ' + (data.error || 'неизвестная ошибка'));
return;
}
alert(
'Импорт завершен:\n' +
'- Новых: ' + (data.imported || 0) + '\n' +
'- Обновлено: ' + (data.updated || 0) + '\n' +
'- Пропущено (локальные изменения): ' + (data.skipped || 0)
);
currentPage = 1;
await loadConfigs();
} catch (e) {
alert('Ошибка импорта с сервера');
} finally {
button.disabled = false;
button.textContent = originalText;
}
}
document.addEventListener('DOMContentLoaded', function() {
loadConfigs();

View File

@@ -158,6 +158,16 @@
}, 1000); // Check every second
}
async function requestRestartAndWait() {
showStatus('Перезапуск приложения...', 'info');
try {
await fetch('/api/restart', { method: 'POST' });
} catch (e) {
// Ignore network errors here: restart may break connection immediately.
}
checkServerReady();
}
document.getElementById('setup-form').addEventListener('submit', async function(e) {
e.preventDefault();
showStatus('Сохранение настроек...', 'info');
@@ -176,9 +186,8 @@
// Check if restart is required
if (data.restart_required) {
// In normal mode, restart must be done manually
setTimeout(() => {
showStatus('⚠️ Пожалуйста, перезапустите приложение вручную для применения изменений', 'warning');
requestRestartAndWait();
}, 2000);
} else {
// In setup mode, auto-restart is happening