Compare commits
22 Commits
feature/ph
...
1bec110d91
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bec110d91 | ||
|
|
6392e4b4a9 | ||
|
|
8f7defdb8a | ||
|
|
0c190efda4 | ||
|
|
41c0a47f54 | ||
|
|
f4f92dea66 | ||
|
|
f42b850734 | ||
|
|
d094d39427 | ||
|
|
4509e93864 | ||
|
|
e2800b06f9 | ||
|
|
7c606af2bb | ||
|
|
fabd30650d | ||
|
|
40ade651b0 | ||
|
|
1b87c53609 | ||
| a3dc264efd | |||
|
|
8a37542929 | ||
|
|
0eb6730a55 | ||
|
|
e2d056e7cb | ||
|
|
1bce8086d6 | ||
|
|
0bdd163728 | ||
|
|
fa0f5e321d | ||
|
|
502832ac9a |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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/
|
||||
|
||||
19
CLAUDE.md
19
CLAUDE.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
97
Makefile
Normal 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)"
|
||||
118
README.md
118
README.md
@@ -82,9 +82,33 @@ auth:
|
||||
### 3. Миграции базы данных
|
||||
|
||||
```bash
|
||||
go run ./cmd/server -migrate
|
||||
go run ./cmd/qfs -migrate
|
||||
```
|
||||
|
||||
### Минимальные права БД для пользователя квотаций
|
||||
|
||||
Если нужен пользователь, который может создавать/редактировать квотации, но не может управлять ценами:
|
||||
|
||||
```sql
|
||||
DROP USER IF EXISTS 'quote_user'@'%';
|
||||
CREATE USER 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
|
||||
|
||||
-- чтение данных для расчета/просмотра
|
||||
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_users TO 'quote_user'@'%';
|
||||
|
||||
-- работа с квотациями
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
Важно: этот вариант не ограничивает редактирование только своими записями в `qt_configurations`.
|
||||
Если пересоздавать пользователя нельзя, используйте `SHOW GRANTS FOR 'quote_user'@'%';` и сделайте точечные `REVOKE`.
|
||||
|
||||
### 4. Импорт метаданных компонентов
|
||||
|
||||
```bash
|
||||
@@ -95,15 +119,77 @@ 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`.
|
||||
|
||||
### Версионность конфигураций (local-first)
|
||||
|
||||
Для `local_configurations` используется append-only versioning через полные snapshot-версии:
|
||||
|
||||
- таблица: `local_configuration_versions`
|
||||
- для каждого изменения создаётся новая версия (`version_no = max + 1`)
|
||||
- `local_configurations.current_version_id` указывает на активную версию
|
||||
- старые версии не изменяются и не удаляются в обычном потоке
|
||||
- rollback не "перематывает" историю, а создаёт новую версию из выбранного snapshot
|
||||
|
||||
При backfill (миграция `006_add_local_configuration_versions.sql`) для существующих конфигураций создаётся `v1` и проставляется `current_version_id`.
|
||||
|
||||
#### Rollback
|
||||
|
||||
Rollback выполняется API-методом:
|
||||
|
||||
```bash
|
||||
POST /api/configs/:uuid/rollback
|
||||
{
|
||||
"target_version": 3,
|
||||
"note": "optional"
|
||||
}
|
||||
```
|
||||
|
||||
Результат:
|
||||
- создаётся новая версия `vN` с `data` из целевой версии
|
||||
- `change_note = "rollback to v{target_version}"` (+ note, если передан)
|
||||
- `current_version_id` переключается на новую версию
|
||||
- конфигурация уходит в `sync_status = pending`
|
||||
|
||||
### Локальный config.yaml
|
||||
|
||||
По умолчанию `qfs` ищет `config.yaml` в той же user-state папке, где лежит `qfs.db` (а не рядом с бинарником).
|
||||
Можно переопределить путь через `-config` или `QFS_CONFIG_PATH`.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
@@ -159,8 +245,23 @@ GET /api/components # Список компонентов
|
||||
POST /api/quote/calculate # Расчёт цены
|
||||
POST /api/export/xlsx # Экспорт в Excel
|
||||
GET /api/configs # Сохранённые конфигурации
|
||||
GET /api/configs/:uuid/versions # Список версий конфигурации
|
||||
GET /api/configs/:uuid/versions/:version # Получить конкретную версию
|
||||
POST /api/configs/:uuid/rollback # Rollback на указанную версию
|
||||
POST /api/configs/:uuid/reactivate # Вернуть архивную конфигурацию в активные
|
||||
```
|
||||
|
||||
#### Sync payload для versioning
|
||||
|
||||
События в `pending_changes` для конфигураций содержат:
|
||||
- `configuration_uuid`
|
||||
- `operation` (`create` / `update` / `rollback`)
|
||||
- `current_version_id` и `current_version_no`
|
||||
- `snapshot` (текущее состояние конфигурации)
|
||||
- `idempotency_key` и `conflict_policy` (`last_write_wins`)
|
||||
|
||||
Это позволяет push-слою отправлять на сервер актуальное состояние и готовит основу для будущего conflict resolution.
|
||||
|
||||
## Cron Jobs
|
||||
|
||||
QuoteForge now includes automated cron jobs for maintenance tasks. These can be run using the built-in cron functionality in the Docker container.
|
||||
@@ -209,13 +310,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 +330,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 |
|
||||
|
||||
## Интеграция с существующей БД
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -61,7 +66,7 @@ func main() {
|
||||
|
||||
// Get all configurations from MariaDB
|
||||
var configs []models.Configuration
|
||||
if err := mariaDB.Preload("User").Find(&configs).Error; err != nil {
|
||||
if err := mariaDB.Find(&configs).Error; err != nil {
|
||||
log.Fatalf("Failed to fetch configurations: %v", err)
|
||||
}
|
||||
|
||||
@@ -69,12 +74,12 @@ func main() {
|
||||
localCount := local.CountConfigurations()
|
||||
log.Printf("Found %d configurations in local SQLite", localCount)
|
||||
|
||||
if *dryRun {
|
||||
if *dryRun {
|
||||
log.Println("\n[DRY RUN] Would migrate the following configurations:")
|
||||
for _, c := range configs {
|
||||
userName := "unknown"
|
||||
if c.User != nil {
|
||||
userName = c.User.Username
|
||||
userName := c.OwnerUsername
|
||||
if userName == "" {
|
||||
userName = "unknown"
|
||||
}
|
||||
log.Printf(" - %s (UUID: %s, User: %s, Items: %d)", c.Name, c.UUID, userName, len(c.Items))
|
||||
}
|
||||
@@ -110,20 +115,21 @@ 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: derefUint(c.UserID),
|
||||
OriginalUsername: c.OwnerUsername,
|
||||
}
|
||||
|
||||
if err := local.SaveConfiguration(localConfig); err != nil {
|
||||
@@ -160,3 +166,10 @@ func main() {
|
||||
|
||||
fmt.Println("\nDone! You can now run the server with: go run ./cmd/server")
|
||||
}
|
||||
|
||||
func derefUint(v *uint) uint {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
@@ -2,17 +2,24 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/handlers"
|
||||
@@ -31,17 +38,64 @@ import (
|
||||
"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)
|
||||
appmeta.SetVersion(Version)
|
||||
|
||||
resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to resolve config path", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
resolvedLocalDBPath, err := appstate.ResolveDBPath(*localDBPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to resolve local database path", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Migrate legacy project-local config path to the user state directory when using defaults.
|
||||
if *configPath == "" && os.Getenv("QFS_CONFIG_PATH") == "" {
|
||||
migratedFrom, migrateErr := appstate.MigrateLegacyFile(resolvedConfigPath, []string{"config.yaml"})
|
||||
if migrateErr != nil {
|
||||
slog.Warn("failed to migrate legacy config file", "error", migrateErr)
|
||||
} else if migratedFrom != "" {
|
||||
slog.Info("migrated legacy config file", "from", migratedFrom, "to", resolvedConfigPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate legacy project-local DB path to the user state directory when using defaults.
|
||||
if *localDBPath == "" && os.Getenv("QFS_DB_PATH") == "" {
|
||||
legacyPaths := []string{
|
||||
filepath.Join("data", "settings.db"),
|
||||
filepath.Join("data", "qfs.db"),
|
||||
}
|
||||
migratedFrom, migrateErr := appstate.MigrateLegacyDB(resolvedLocalDBPath, legacyPaths)
|
||||
if migrateErr != nil {
|
||||
slog.Warn("failed to migrate legacy local database", "error", migrateErr)
|
||||
} else if migratedFrom != "" {
|
||||
slog.Info("migrated legacy local database", "from", migratedFrom, "to", resolvedLocalDBPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize local SQLite database (always used)
|
||||
local, err := localdb.New(localDBPath)
|
||||
local, err := localdb.New(resolvedLocalDBPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to initialize local database", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -55,18 +109,19 @@ func main() {
|
||||
}
|
||||
|
||||
// Load config for server settings (optional)
|
||||
cfg, err := config.Load(*configPath)
|
||||
cfg, err := config.Load(resolvedConfigPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
// Use defaults if config file doesn't exist
|
||||
slog.Info("config file not found, using defaults", "path", *configPath)
|
||||
slog.Info("config file not found, using defaults", "path", resolvedConfigPath)
|
||||
cfg = &config.Config{}
|
||||
} else {
|
||||
slog.Error("failed to load config", "path", *configPath, "error", err)
|
||||
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)
|
||||
|
||||
@@ -74,7 +129,6 @@ func main() {
|
||||
connMgr := db.NewConnectionManager(local)
|
||||
|
||||
dbUser := local.GetDBUser()
|
||||
dbUserID := uint(1)
|
||||
|
||||
// Try to connect to MariaDB on startup
|
||||
mariaDB, err := connMgr.GetDB()
|
||||
@@ -83,19 +137,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,
|
||||
)
|
||||
|
||||
@@ -117,7 +165,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)
|
||||
@@ -145,6 +193,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
|
||||
@@ -168,7 +228,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
|
||||
@@ -207,7 +267,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)
|
||||
@@ -217,8 +278,9 @@ func runSetupMode(local *localdb.LocalDB) {
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
|
||||
if stat, err := os.Stat("web/static"); err == nil && stat.IsDir() {
|
||||
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))
|
||||
}
|
||||
@@ -240,9 +302,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,
|
||||
@@ -256,6 +317,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)
|
||||
|
||||
@@ -322,7 +394,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
|
||||
@@ -383,25 +455,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
|
||||
}
|
||||
@@ -413,9 +488,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
router.Use(middleware.CORS())
|
||||
router.Use(middleware.OfflineDetector(connMgr, local))
|
||||
|
||||
// Static files
|
||||
if stat, err := os.Stat("web/static"); err == nil && stat.IsDir() {
|
||||
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))
|
||||
}
|
||||
@@ -428,6 +504,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
|
||||
@@ -543,8 +631,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
configs.GET("", func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
status := c.DefaultQuery("status", "active")
|
||||
if status != "active" && status != "archived" && status != "all" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
|
||||
return
|
||||
}
|
||||
|
||||
cfgs, total, err := configService.ListAll(page, perPage)
|
||||
cfgs, total, err := configService.ListAllWithStatus(page, perPage, status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -555,9 +648,23 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"status": status,
|
||||
})
|
||||
})
|
||||
|
||||
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 {
|
||||
@@ -565,7 +672,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
|
||||
@@ -607,7 +714,20 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "archived"})
|
||||
})
|
||||
|
||||
configs.POST("/:uuid/reactivate", func(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
config, err := configService.ReactivateNoAuth(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "reactivated",
|
||||
"config": config,
|
||||
})
|
||||
})
|
||||
|
||||
configs.PATCH("/:uuid/rename", func(c *gin.Context) {
|
||||
@@ -639,7 +759,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
|
||||
@@ -657,6 +777,110 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
}
|
||||
c.JSON(http.StatusOK, config)
|
||||
})
|
||||
|
||||
configs.GET("/:uuid/versions", func(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
limit, err := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
if err != nil || limit <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"})
|
||||
return
|
||||
}
|
||||
offset, err := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
if err != nil || offset < 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid offset"})
|
||||
return
|
||||
}
|
||||
|
||||
versions, err := configService.ListVersions(uuid, limit, offset)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrConfigNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
|
||||
case errors.Is(err, services.ErrInvalidVersionNumber):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid paging params"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"versions": versions,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
})
|
||||
|
||||
configs.GET("/:uuid/versions/:version", func(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
versionNo, err := strconv.Atoi(c.Param("version"))
|
||||
if err != nil || versionNo <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version number"})
|
||||
return
|
||||
}
|
||||
|
||||
version, err := configService.GetVersion(uuid, versionNo)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrInvalidVersionNumber):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version number"})
|
||||
case errors.Is(err, services.ErrConfigVersionNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, version)
|
||||
})
|
||||
|
||||
configs.POST("/:uuid/rollback", func(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
var req struct {
|
||||
TargetVersion int `json:"target_version"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.TargetVersion <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_version"})
|
||||
return
|
||||
}
|
||||
|
||||
config, err := configService.RollbackToVersionWithNote(uuid, req.TargetVersion, dbUsername, req.Note)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrInvalidVersionNumber):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_version"})
|
||||
case errors.Is(err, services.ErrConfigNotFound), errors.Is(err, services.ErrConfigVersionNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
||||
case errors.Is(err, services.ErrVersionConflict):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "version conflict"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
currentVersion, err := configService.GetCurrentVersion(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "rollback applied",
|
||||
"config": config,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "rollback applied",
|
||||
"config": config,
|
||||
"current_version": currentVersion,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Pricing admin (public - RBAC disabled)
|
||||
@@ -712,6 +936,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()
|
||||
173
cmd/qfs/versioning_api_test.go
Normal file
173
cmd/qfs/versioning_api_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
)
|
||||
|
||||
func TestConfigurationVersioningAPI(t *testing.T) {
|
||||
moveToRepoRoot(t)
|
||||
|
||||
local, connMgr, configService := newAPITestStack(t)
|
||||
_ = local
|
||||
|
||||
created, err := configService.Create("tester", &services.CreateConfigRequest{
|
||||
Name: "api-v1",
|
||||
Items: models.ConfigItems{{LotName: "CPU_API", Quantity: 1, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
if _, err := configService.RenameNoAuth(created.UUID, "api-v2"); err != nil {
|
||||
t.Fatalf("rename config: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{}
|
||||
setConfigDefaults(cfg)
|
||||
router, _, err := setupRouter(cfg, local, connMgr, nil, "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("setup router: %v", err)
|
||||
}
|
||||
|
||||
// list versions happy path
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID+"/versions?limit=10&offset=0", nil)
|
||||
listRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(listRec, listReq)
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("list versions status=%d body=%s", listRec.Code, listRec.Body.String())
|
||||
}
|
||||
|
||||
// get version happy path
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID+"/versions/1", nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRec, getReq)
|
||||
if getRec.Code != http.StatusOK {
|
||||
t.Fatalf("get version status=%d body=%s", getRec.Code, getRec.Body.String())
|
||||
}
|
||||
|
||||
// rollback happy path
|
||||
body := []byte(`{"target_version":1,"note":"api rollback"}`)
|
||||
rbReq := httptest.NewRequest(http.MethodPost, "/api/configs/"+created.UUID+"/rollback", bytes.NewReader(body))
|
||||
rbReq.Header.Set("Content-Type", "application/json")
|
||||
rbRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rbRec, rbReq)
|
||||
if rbRec.Code != http.StatusOK {
|
||||
t.Fatalf("rollback status=%d body=%s", rbRec.Code, rbRec.Body.String())
|
||||
}
|
||||
|
||||
var rbResp struct {
|
||||
Message string `json:"message"`
|
||||
CurrentVersion struct {
|
||||
VersionNo int `json:"version_no"`
|
||||
} `json:"current_version"`
|
||||
}
|
||||
if err := json.Unmarshal(rbRec.Body.Bytes(), &rbResp); err != nil {
|
||||
t.Fatalf("unmarshal rollback response: %v", err)
|
||||
}
|
||||
if rbResp.Message == "" || rbResp.CurrentVersion.VersionNo != 3 {
|
||||
t.Fatalf("unexpected rollback response: %+v", rbResp)
|
||||
}
|
||||
|
||||
// 404: version missing
|
||||
notFoundReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID+"/versions/999", nil)
|
||||
notFoundRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(notFoundRec, notFoundReq)
|
||||
if notFoundRec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for missing version, got %d", notFoundRec.Code)
|
||||
}
|
||||
|
||||
// 400: invalid version number
|
||||
invalidReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID+"/versions/abc", nil)
|
||||
invalidRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(invalidRec, invalidReq)
|
||||
if invalidRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid version, got %d", invalidRec.Code)
|
||||
}
|
||||
|
||||
// 400: rollback invalid target_version
|
||||
badRollbackReq := httptest.NewRequest(http.MethodPost, "/api/configs/"+created.UUID+"/rollback", bytes.NewReader([]byte(`{"target_version":0}`)))
|
||||
badRollbackReq.Header.Set("Content-Type", "application/json")
|
||||
badRollbackRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(badRollbackRec, badRollbackReq)
|
||||
if badRollbackRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid rollback target, got %d", badRollbackRec.Code)
|
||||
}
|
||||
|
||||
// archive + reactivate flow
|
||||
delReq := httptest.NewRequest(http.MethodDelete, "/api/configs/"+created.UUID, nil)
|
||||
delRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(delRec, delReq)
|
||||
if delRec.Code != http.StatusOK {
|
||||
t.Fatalf("archive status=%d body=%s", delRec.Code, delRec.Body.String())
|
||||
}
|
||||
|
||||
archivedListReq := httptest.NewRequest(http.MethodGet, "/api/configs?status=archived&page=1&per_page=20", nil)
|
||||
archivedListRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(archivedListRec, archivedListReq)
|
||||
if archivedListRec.Code != http.StatusOK {
|
||||
t.Fatalf("archived list status=%d body=%s", archivedListRec.Code, archivedListRec.Body.String())
|
||||
}
|
||||
|
||||
reactivateReq := httptest.NewRequest(http.MethodPost, "/api/configs/"+created.UUID+"/reactivate", nil)
|
||||
reactivateRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(reactivateRec, reactivateReq)
|
||||
if reactivateRec.Code != http.StatusOK {
|
||||
t.Fatalf("reactivate status=%d body=%s", reactivateRec.Code, reactivateRec.Body.String())
|
||||
}
|
||||
|
||||
activeListReq := httptest.NewRequest(http.MethodGet, "/api/configs?status=active&page=1&per_page=20", nil)
|
||||
activeListRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(activeListRec, activeListReq)
|
||||
if activeListRec.Code != http.StatusOK {
|
||||
t.Fatalf("active list status=%d body=%s", activeListRec.Code, activeListRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func newAPITestStack(t *testing.T) (*localdb.LocalDB, *db.ConnectionManager, *services.LocalConfigurationService) {
|
||||
t.Helper()
|
||||
|
||||
localPath := filepath.Join(t.TempDir(), "api.db")
|
||||
local, err := localdb.New(localPath)
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
connMgr := db.NewConnectionManager(local)
|
||||
syncService := syncsvc.NewService(connMgr, local)
|
||||
configService := services.NewLocalConfigurationService(
|
||||
local,
|
||||
syncService,
|
||||
&services.QuoteService{},
|
||||
func() bool { return false },
|
||||
)
|
||||
return local, connMgr, configService
|
||||
}
|
||||
|
||||
func moveToRepoRoot(t *testing.T) {
|
||||
t.Helper()
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
root := filepath.Clean(filepath.Join(wd, "..", ".."))
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatalf("chdir repo root: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(wd)
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
26
internal/appmeta/version.go
Normal file
26
internal/appmeta/version.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package appmeta
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
var appVersion atomic.Value
|
||||
|
||||
func init() {
|
||||
appVersion.Store("dev")
|
||||
}
|
||||
|
||||
// SetVersion configures the running application version string.
|
||||
func SetVersion(v string) {
|
||||
if v == "" {
|
||||
v = "dev"
|
||||
}
|
||||
appVersion.Store(v)
|
||||
}
|
||||
|
||||
// Version returns the running application version string.
|
||||
func Version() string {
|
||||
if v, ok := appVersion.Load().(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return "dev"
|
||||
}
|
||||
|
||||
197
internal/appstate/path.go
Normal file
197
internal/appstate/path.go
Normal 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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -203,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,
|
||||
"message": "Settings saved.",
|
||||
"restart_required": restartRequired,
|
||||
})
|
||||
|
||||
// Signal restart after response is sent (if restart signal is configured)
|
||||
|
||||
@@ -18,19 +18,25 @@ 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,
|
||||
IsActive: true,
|
||||
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: derefUint(cfg.UserID),
|
||||
OriginalUsername: cfg.OwnerUsername,
|
||||
}
|
||||
|
||||
if local.OriginalUsername == "" && cfg.User != nil {
|
||||
local.OriginalUsername = cfg.User.Username
|
||||
}
|
||||
|
||||
if cfg.ID > 0 {
|
||||
@@ -54,7 +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,
|
||||
@@ -69,10 +75,21 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||
if local.ServerID != nil {
|
||||
cfg.ID = *local.ServerID
|
||||
}
|
||||
if local.OriginalUserID != 0 {
|
||||
userID := local.OriginalUserID
|
||||
cfg.UserID = &userID
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func derefUint(v *uint) uint {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// PricelistToLocal converts models.Pricelist to LocalPricelist
|
||||
func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
|
||||
name := pl.Notification
|
||||
|
||||
72
internal/localdb/local_migrations_test.go
Normal file
72
internal/localdb/local_migrations_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy_local.db")
|
||||
|
||||
local, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
cfg := &LocalConfiguration{
|
||||
UUID: "legacy-cfg",
|
||||
Name: "Legacy",
|
||||
Items: LocalConfigItems{},
|
||||
SyncStatus: "pending",
|
||||
OriginalUsername: "tester",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := local.SaveConfiguration(cfg); err != nil {
|
||||
t.Fatalf("save seed config: %v", err)
|
||||
}
|
||||
if err := local.DB().Where("configuration_uuid = ?", "legacy-cfg").Delete(&LocalConfigurationVersion{}).Error; err != nil {
|
||||
t.Fatalf("delete seed versions: %v", err)
|
||||
}
|
||||
if err := local.DB().Model(&LocalConfiguration{}).
|
||||
Where("uuid = ?", "legacy-cfg").
|
||||
Update("current_version_id", nil).Error; err != nil {
|
||||
t.Fatalf("clear current_version_id: %v", err)
|
||||
}
|
||||
if err := local.DB().Where("1=1").Delete(&LocalSchemaMigration{}).Error; err != nil {
|
||||
t.Fatalf("clear migration records: %v", err)
|
||||
}
|
||||
|
||||
if err := runLocalMigrations(local.DB()); err != nil {
|
||||
t.Fatalf("run local migrations manually: %v", err)
|
||||
}
|
||||
|
||||
migratedCfg, err := local.GetConfigurationByUUID("legacy-cfg")
|
||||
if err != nil {
|
||||
t.Fatalf("get migrated config: %v", err)
|
||||
}
|
||||
if migratedCfg.CurrentVersionID == nil || *migratedCfg.CurrentVersionID == "" {
|
||||
t.Fatalf("expected current_version_id after migration")
|
||||
}
|
||||
if !migratedCfg.IsActive {
|
||||
t.Fatalf("expected migrated config to be active")
|
||||
}
|
||||
|
||||
var versionCount int64
|
||||
if err := local.DB().Model(&LocalConfigurationVersion{}).
|
||||
Where("configuration_uuid = ?", "legacy-cfg").
|
||||
Count(&versionCount).Error; err != nil {
|
||||
t.Fatalf("count versions: %v", err)
|
||||
}
|
||||
if versionCount != 1 {
|
||||
t.Fatalf("expected 1 backfilled version, got %d", versionCount)
|
||||
}
|
||||
|
||||
var migrationCount int64
|
||||
if err := local.DB().Model(&LocalSchemaMigration{}).Count(&migrationCount).Error; err != nil {
|
||||
t.Fatalf("count local migrations: %v", err)
|
||||
}
|
||||
if migrationCount == 0 {
|
||||
t.Fatalf("expected local migrations to be recorded")
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"github.com/glebarez/sqlite"
|
||||
uuidpkg "github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
@@ -52,6 +54,7 @@ func New(dbPath string) (*LocalDB, error) {
|
||||
if err := db.AutoMigrate(
|
||||
&ConnectionSettings{},
|
||||
&LocalConfiguration{},
|
||||
&LocalConfigurationVersion{},
|
||||
&LocalPricelist{},
|
||||
&LocalPricelistItem{},
|
||||
&LocalComponent{},
|
||||
@@ -60,6 +63,9 @@ func New(dbPath string) (*LocalDB, error) {
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("migrating sqlite database: %w", err)
|
||||
}
|
||||
if err := runLocalMigrations(db); err != nil {
|
||||
return nil, fmt.Errorf("running local sqlite migrations: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("local SQLite database initialized", "path", dbPath)
|
||||
|
||||
@@ -193,7 +199,60 @@ func (l *LocalDB) GetConfigurationByUUID(uuid string) (*LocalConfiguration, erro
|
||||
|
||||
// DeleteConfiguration deletes a configuration by UUID
|
||||
func (l *LocalDB) DeleteConfiguration(uuid string) error {
|
||||
return l.db.Where("uuid = ?", uuid).Delete(&LocalConfiguration{}).Error
|
||||
return l.DeactivateConfiguration(uuid)
|
||||
}
|
||||
|
||||
// DeactivateConfiguration marks configuration as inactive and appends one snapshot version.
|
||||
func (l *LocalDB) DeactivateConfiguration(uuid string) error {
|
||||
return l.db.Transaction(func(tx *gorm.DB) error {
|
||||
var cfg LocalConfiguration
|
||||
if err := tx.Where("uuid = ?", uuid).First(&cfg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.IsActive {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg.IsActive = false
|
||||
cfg.UpdatedAt = time.Now()
|
||||
cfg.SyncStatus = "pending"
|
||||
if err := tx.Save(&cfg).Error; err != nil {
|
||||
return fmt.Errorf("save inactive configuration: %w", err)
|
||||
}
|
||||
|
||||
var maxVersion int
|
||||
if err := tx.Model(&LocalConfigurationVersion{}).
|
||||
Where("configuration_uuid = ?", cfg.UUID).
|
||||
Select("COALESCE(MAX(version_no), 0)").
|
||||
Scan(&maxVersion).Error; err != nil {
|
||||
return fmt.Errorf("read max version for deactivate: %w", err)
|
||||
}
|
||||
|
||||
snapshot, err := BuildConfigurationSnapshot(&cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build deactivate snapshot: %w", err)
|
||||
}
|
||||
note := "deactivate via local delete"
|
||||
version := &LocalConfigurationVersion{
|
||||
ID: uuidpkg.NewString(),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
VersionNo: maxVersion + 1,
|
||||
Data: snapshot,
|
||||
ChangeNote: ¬e,
|
||||
AppVersion: appmeta.Version(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := tx.Create(version).Error; err != nil {
|
||||
return fmt.Errorf("insert deactivate version: %w", err)
|
||||
}
|
||||
if err := tx.Model(&LocalConfiguration{}).
|
||||
Where("uuid = ?", cfg.UUID).
|
||||
Update("current_version_id", version.ID).Error; err != nil {
|
||||
return fmt.Errorf("set current version after deactivate: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CountConfigurations returns the number of local configurations
|
||||
|
||||
131
internal/localdb/migration_versioning_test.go
Normal file
131
internal/localdb/migration_versioning_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestMigration006BackfillCreatesV1AndCurrentPointer(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "migration_backfill.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE local_configurations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
server_id INTEGER NULL,
|
||||
name TEXT NOT NULL,
|
||||
items TEXT,
|
||||
total_price REAL,
|
||||
custom_price REAL,
|
||||
notes TEXT,
|
||||
is_template BOOLEAN DEFAULT FALSE,
|
||||
server_count INTEGER DEFAULT 1,
|
||||
price_updated_at DATETIME NULL,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
synced_at DATETIME,
|
||||
sync_status TEXT DEFAULT 'local',
|
||||
original_user_id INTEGER DEFAULT 0,
|
||||
original_username TEXT DEFAULT ''
|
||||
);`).Error; err != nil {
|
||||
t.Fatalf("create pre-migration schema: %v", err)
|
||||
}
|
||||
|
||||
items := `[{"lot_name":"CPU_X","quantity":2,"unit_price":1000}]`
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if err := db.Exec(`
|
||||
INSERT INTO local_configurations
|
||||
(uuid, name, items, total_price, notes, server_count, created_at, updated_at, sync_status, original_username)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"cfg-1", "Cfg One", items, 2000.0, "note", 1, now, now, "pending", "tester",
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seed pre-migration data: %v", err)
|
||||
}
|
||||
|
||||
migrationPath := filepath.Join("..", "..", "migrations", "006_add_local_configuration_versions.sql")
|
||||
sqlBytes, err := os.ReadFile(migrationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read migration file: %v", err)
|
||||
}
|
||||
if err := execSQLScript(db, string(sqlBytes)); err != nil {
|
||||
t.Fatalf("apply migration: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Table("local_configuration_versions").Where("configuration_uuid = ?", "cfg-1").Count(&count).Error; err != nil {
|
||||
t.Fatalf("count versions: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected 1 version, got %d", count)
|
||||
}
|
||||
|
||||
var currentVersionID *string
|
||||
if err := db.Table("local_configurations").Select("current_version_id").Where("uuid = ?", "cfg-1").Scan(¤tVersionID).Error; err != nil {
|
||||
t.Fatalf("read current_version_id: %v", err)
|
||||
}
|
||||
if currentVersionID == nil || *currentVersionID == "" {
|
||||
t.Fatalf("expected current_version_id to be set")
|
||||
}
|
||||
|
||||
var row struct {
|
||||
ID string
|
||||
VersionNo int
|
||||
Data string
|
||||
}
|
||||
if err := db.Table("local_configuration_versions").
|
||||
Select("id, version_no, data").
|
||||
Where("configuration_uuid = ?", "cfg-1").
|
||||
First(&row).Error; err != nil {
|
||||
t.Fatalf("load v1 row: %v", err)
|
||||
}
|
||||
if row.VersionNo != 1 {
|
||||
t.Fatalf("expected version_no=1, got %d", row.VersionNo)
|
||||
}
|
||||
if row.ID != *currentVersionID {
|
||||
t.Fatalf("expected current_version_id=%s, got %s", row.ID, *currentVersionID)
|
||||
}
|
||||
|
||||
var snapshot map[string]any
|
||||
if err := json.Unmarshal([]byte(row.Data), &snapshot); err != nil {
|
||||
t.Fatalf("parse snapshot json: %v", err)
|
||||
}
|
||||
if snapshot["uuid"] != "cfg-1" {
|
||||
t.Fatalf("expected snapshot uuid cfg-1, got %v", snapshot["uuid"])
|
||||
}
|
||||
if snapshot["name"] != "Cfg One" {
|
||||
t.Fatalf("expected snapshot name Cfg One, got %v", snapshot["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func execSQLScript(db *gorm.DB, script string) error {
|
||||
var cleaned []string
|
||||
for _, line := range strings.Split(script, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "--") {
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, line)
|
||||
}
|
||||
|
||||
for _, stmt := range strings.Split(strings.Join(cleaned, "\n"), ";") {
|
||||
sql := strings.TrimSpace(stmt)
|
||||
if sql == "" {
|
||||
continue
|
||||
}
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
141
internal/localdb/migrations.go
Normal file
141
internal/localdb/migrations.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LocalSchemaMigration struct {
|
||||
ID string `gorm:"primaryKey;size:128"`
|
||||
Name string `gorm:"not null;size:255"`
|
||||
AppliedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (LocalSchemaMigration) TableName() string {
|
||||
return "local_schema_migrations"
|
||||
}
|
||||
|
||||
type localMigration struct {
|
||||
id string
|
||||
name string
|
||||
run func(tx *gorm.DB) error
|
||||
}
|
||||
|
||||
var localMigrations = []localMigration{
|
||||
{
|
||||
id: "2026_02_04_versioning_backfill",
|
||||
name: "Ensure configuration versioning data and current pointers",
|
||||
run: backfillConfigurationVersions,
|
||||
},
|
||||
{
|
||||
id: "2026_02_04_is_active_backfill",
|
||||
name: "Ensure is_active defaults to true for existing configurations",
|
||||
run: backfillConfigurationIsActive,
|
||||
},
|
||||
}
|
||||
|
||||
func runLocalMigrations(db *gorm.DB) error {
|
||||
if err := db.AutoMigrate(&LocalSchemaMigration{}); err != nil {
|
||||
return fmt.Errorf("migrate local schema migrations table: %w", err)
|
||||
}
|
||||
|
||||
for _, migration := range localMigrations {
|
||||
var count int64
|
||||
if err := db.Model(&LocalSchemaMigration{}).Where("id = ?", migration.id).Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("check local migration %s: %w", migration.id, err)
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := migration.run(tx); err != nil {
|
||||
return fmt.Errorf("run migration %s: %w", migration.id, err)
|
||||
}
|
||||
|
||||
record := &LocalSchemaMigration{
|
||||
ID: migration.id,
|
||||
Name: migration.name,
|
||||
AppliedAt: time.Now(),
|
||||
}
|
||||
if err := tx.Create(record).Error; err != nil {
|
||||
return fmt.Errorf("insert migration %s record: %w", migration.id, err)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Info("local migration applied", "id", migration.id, "name", migration.name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func backfillConfigurationVersions(tx *gorm.DB) error {
|
||||
var configs []LocalConfiguration
|
||||
if err := tx.Find(&configs).Error; err != nil {
|
||||
return fmt.Errorf("load local configurations for backfill: %w", err)
|
||||
}
|
||||
|
||||
for i := range configs {
|
||||
cfg := configs[i]
|
||||
var versionCount int64
|
||||
if err := tx.Model(&LocalConfigurationVersion{}).
|
||||
Where("configuration_uuid = ?", cfg.UUID).
|
||||
Count(&versionCount).Error; err != nil {
|
||||
return fmt.Errorf("count versions for %s: %w", cfg.UUID, err)
|
||||
}
|
||||
|
||||
if versionCount == 0 {
|
||||
snapshot, err := BuildConfigurationSnapshot(&cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build initial snapshot for %s: %w", cfg.UUID, err)
|
||||
}
|
||||
note := "Initial snapshot backfill (v1)"
|
||||
version := LocalConfigurationVersion{
|
||||
ID: uuid.NewString(),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
VersionNo: 1,
|
||||
Data: snapshot,
|
||||
ChangeNote: ¬e,
|
||||
AppVersion: "backfill",
|
||||
CreatedAt: chooseNonZeroTime(cfg.CreatedAt, time.Now()),
|
||||
}
|
||||
if err := tx.Create(&version).Error; err != nil {
|
||||
return fmt.Errorf("create v1 backfill for %s: %w", cfg.UUID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
|
||||
var latest LocalConfigurationVersion
|
||||
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
|
||||
Order("version_no DESC").
|
||||
First(&latest).Error; err != nil {
|
||||
return fmt.Errorf("load latest version for %s: %w", cfg.UUID, err)
|
||||
}
|
||||
if err := tx.Model(&LocalConfiguration{}).
|
||||
Where("uuid = ?", cfg.UUID).
|
||||
Update("current_version_id", latest.ID).Error; err != nil {
|
||||
return fmt.Errorf("set current version for %s: %w", cfg.UUID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func backfillConfigurationIsActive(tx *gorm.DB) error {
|
||||
return tx.Exec("UPDATE local_configurations SET is_active = 1 WHERE is_active IS NULL").Error
|
||||
}
|
||||
|
||||
func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
|
||||
if candidate.IsZero() {
|
||||
return fallback
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
@@ -59,37 +59,59 @@ 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
|
||||
CurrentVersionID *string `gorm:"index" json:"current_version_id,omitempty"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
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"`
|
||||
CurrentVersion *LocalConfigurationVersion `gorm:"foreignKey:CurrentVersionID;references:ID" json:"current_version,omitempty"`
|
||||
Versions []LocalConfigurationVersion `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"versions,omitempty"`
|
||||
}
|
||||
|
||||
func (LocalConfiguration) TableName() string {
|
||||
return "local_configurations"
|
||||
}
|
||||
|
||||
// LocalConfigurationVersion stores immutable full snapshots for each configuration version
|
||||
type LocalConfigurationVersion struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
ConfigurationUUID string `gorm:"not null;index:idx_lcv_config_created,priority:1;index:idx_lcv_config_version,priority:1;uniqueIndex:idx_lcv_config_version_unique,priority:1" json:"configuration_uuid"`
|
||||
VersionNo int `gorm:"not null;index:idx_lcv_config_version,sort:desc,priority:2;uniqueIndex:idx_lcv_config_version_unique,priority:2" json:"version_no"`
|
||||
Data string `gorm:"type:text;not null" json:"data"`
|
||||
ChangeNote *string `json:"change_note,omitempty"`
|
||||
CreatedBy *string `json:"created_by,omitempty"`
|
||||
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
|
||||
CreatedAt time.Time `gorm:"not null;autoCreateTime;index:idx_lcv_config_created,sort:desc,priority:2" json:"created_at"`
|
||||
Configuration *LocalConfiguration `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"configuration,omitempty"`
|
||||
}
|
||||
|
||||
func (LocalConfigurationVersion) TableName() string {
|
||||
return "local_configuration_versions"
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -127,7 +149,7 @@ type PendingChange struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
EntityType string `gorm:"not null;index" json:"entity_type"` // "configuration", "project", "specification"
|
||||
EntityUUID string `gorm:"not null;index" json:"entity_uuid"`
|
||||
Operation string `gorm:"not null" json:"operation"` // "create", "update", "delete"
|
||||
Operation string `gorm:"not null" json:"operation"` // "create", "update", "rollback", "deactivate", "reactivate", "delete"
|
||||
Payload string `gorm:"type:text" json:"payload"` // JSON snapshot of the entity
|
||||
CreatedAt time.Time `gorm:"not null" json:"created_at"`
|
||||
Attempts int `gorm:"default:0" json:"attempts"` // Retry count for sync
|
||||
|
||||
78
internal/localdb/snapshots.go
Normal file
78
internal/localdb/snapshots.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BuildConfigurationSnapshot serializes the full local configuration state.
|
||||
func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
||||
snapshot := map[string]interface{}{
|
||||
"id": localCfg.ID,
|
||||
"uuid": localCfg.UUID,
|
||||
"server_id": localCfg.ServerID,
|
||||
"current_version_id": localCfg.CurrentVersionID,
|
||||
"is_active": localCfg.IsActive,
|
||||
"name": localCfg.Name,
|
||||
"items": localCfg.Items,
|
||||
"total_price": localCfg.TotalPrice,
|
||||
"custom_price": localCfg.CustomPrice,
|
||||
"notes": localCfg.Notes,
|
||||
"is_template": localCfg.IsTemplate,
|
||||
"server_count": localCfg.ServerCount,
|
||||
"price_updated_at": localCfg.PriceUpdatedAt,
|
||||
"created_at": localCfg.CreatedAt,
|
||||
"updated_at": localCfg.UpdatedAt,
|
||||
"synced_at": localCfg.SyncedAt,
|
||||
"sync_status": localCfg.SyncStatus,
|
||||
"original_user_id": localCfg.OriginalUserID,
|
||||
"original_username": localCfg.OriginalUsername,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal configuration snapshot: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// DecodeConfigurationSnapshot returns editable fields from one saved snapshot.
|
||||
func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
var snapshot struct {
|
||||
IsActive *bool `json:"is_active"`
|
||||
Name string `json:"name"`
|
||||
Items LocalConfigItems `json:"items"`
|
||||
TotalPrice *float64 `json:"total_price"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
ServerCount int `json:"server_count"`
|
||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||
OriginalUserID uint `json:"original_user_id"`
|
||||
OriginalUsername string `json:"original_username"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(data), &snapshot); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal snapshot JSON: %w", err)
|
||||
}
|
||||
|
||||
isActive := true
|
||||
if snapshot.IsActive != nil {
|
||||
isActive = *snapshot.IsActive
|
||||
}
|
||||
|
||||
return &LocalConfiguration{
|
||||
IsActive: isActive,
|
||||
Name: snapshot.Name,
|
||||
Items: snapshot.Items,
|
||||
TotalPrice: snapshot.TotalPrice,
|
||||
CustomPrice: snapshot.CustomPrice,
|
||||
Notes: snapshot.Notes,
|
||||
IsTemplate: snapshot.IsTemplate,
|
||||
ServerCount: snapshot.ServerCount,
|
||||
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||
OriginalUserID: snapshot.OriginalUserID,
|
||||
OriginalUsername: snapshot.OriginalUsername,
|
||||
}, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -40,18 +40,20 @@ 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 `json:"user_id,omitempty"` // Legacy field, no longer required for ownership
|
||||
OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"`
|
||||
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func (r *ConfigurationRepository) Create(config *models.Configuration) error {
|
||||
|
||||
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
|
||||
var config models.Configuration
|
||||
err := r.db.Preload("User").First(&config, id).Error
|
||||
err := r.db.First(&config, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error
|
||||
|
||||
func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration, error) {
|
||||
var config models.Configuration
|
||||
err := r.db.Preload("User").Where("uuid = ?", uuid).First(&config).Error
|
||||
err := r.db.Where("uuid = ?", uuid).First(&config).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -43,13 +43,15 @@ 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 = ?"
|
||||
|
||||
r.db.Model(&models.Configuration{}).Where(ownerScope, ownerUsername).Count(&total)
|
||||
err := r.db.
|
||||
Where("user_id = ?", userID).
|
||||
Where(ownerScope, ownerUsername).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
@@ -64,7 +66,6 @@ func (r *ConfigurationRepository) ListTemplates(offset, limit int) ([]models.Con
|
||||
|
||||
r.db.Model(&models.Configuration{}).Where("is_template = ?", true).Count(&total)
|
||||
err := r.db.
|
||||
Preload("User").
|
||||
Where("is_template = ?", true).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -2,12 +2,24 @@ package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrConfigVersionNotFound = errors.New("configuration version not found")
|
||||
ErrInvalidVersionNumber = errors.New("invalid version number")
|
||||
ErrVersionConflict = errors.New("configuration version conflict")
|
||||
)
|
||||
|
||||
// LocalConfigurationService handles configurations in local-first mode
|
||||
@@ -35,7 +47,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,33 +61,23 @@ 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
|
||||
localCfg := localdb.ConfigurationToLocal(cfg)
|
||||
|
||||
// Save to local SQLite
|
||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add to pending sync queue
|
||||
payload, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.localDB.AddPendingChange("configuration", cfg.UUID, "create", string(payload)); err != nil {
|
||||
return nil, err
|
||||
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
|
||||
return nil, fmt.Errorf("create configuration with version: %w", err)
|
||||
}
|
||||
|
||||
// Record usage stats
|
||||
@@ -85,17 +87,20 @@ 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
|
||||
}
|
||||
if !localCfg.IsActive {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
// Convert to models.Configuration
|
||||
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 +108,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
|
||||
}
|
||||
|
||||
@@ -136,56 +141,59 @@ func (s *LocalConfigurationService) Update(uuid string, userID uint, req *Create
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
// Save to local SQLite
|
||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add to pending sync queue
|
||||
cfg := localdb.LocalToConfiguration(localCfg)
|
||||
payload, err := json.Marshal(cfg)
|
||||
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("update configuration with version: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Delete from local SQLite
|
||||
if err := s.localDB.DeleteConfiguration(uuid); err != nil {
|
||||
return err
|
||||
localCfg.IsActive = false
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
_, err = s.saveWithVersionAndPending(localCfg, "deactivate", ownerUsername)
|
||||
return err
|
||||
}
|
||||
|
||||
// Reactivate restores an archived configuration and creates a new version.
|
||||
func (s *LocalConfigurationService) Reactivate(uuid string, ownerUsername string) (*models.Configuration, error) {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
if !s.isOwner(localCfg, ownerUsername) {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
if localCfg.IsActive {
|
||||
return localdb.LocalToConfiguration(localCfg), nil
|
||||
}
|
||||
|
||||
// Add to pending sync queue
|
||||
if err := s.localDB.AddPendingChange("configuration", uuid, "delete", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
localCfg.IsActive = true
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
return s.saveWithVersionAndPending(localCfg, "reactivate", ownerUsername)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -193,26 +201,16 @@ func (s *LocalConfigurationService) Rename(uuid string, userID uint, newName str
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add to pending sync queue
|
||||
cfg := localdb.LocalToConfiguration(localCfg)
|
||||
payload, err := json.Marshal(cfg)
|
||||
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("rename configuration with version: %w", err)
|
||||
}
|
||||
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// 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,37 +221,32 @@ 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)
|
||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add to pending sync queue
|
||||
payload, err := json.Marshal(clone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.localDB.AddPendingChange("configuration", clone.UUID, "create", string(payload)); err != nil {
|
||||
return nil, err
|
||||
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
|
||||
return nil, fmt.Errorf("clone configuration with version: %w", err)
|
||||
}
|
||||
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return s.listByUserWithStatus(ownerUsername, page, perPage, "active")
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) listByUserWithStatus(ownerUsername string, page, perPage int, status string) ([]models.Configuration, int64, error) {
|
||||
// Get all local configurations
|
||||
localConfigs, err := s.localDB.GetConfigurations()
|
||||
if err != nil {
|
||||
@@ -263,7 +256,10 @@ 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 !matchesConfigStatus(lc.IsActive, status) {
|
||||
continue
|
||||
}
|
||||
if (lc.OriginalUsername == ownerUsername) || lc.IsTemplate {
|
||||
userConfigs = append(userConfigs, *localdb.LocalToConfiguration(&lc))
|
||||
}
|
||||
}
|
||||
@@ -292,7 +288,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 +296,7 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, userID uint) (*mo
|
||||
}
|
||||
|
||||
// Check ownership
|
||||
if localCfg.OriginalUserID != userID {
|
||||
if !s.isOwner(localCfg, ownerUsername) {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
@@ -340,21 +336,10 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, userID uint) (*mo
|
||||
localCfg.UpdatedAt = now
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
// Save to local SQLite
|
||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add to pending sync queue
|
||||
cfg := localdb.LocalToConfiguration(localCfg)
|
||||
payload, err := json.Marshal(cfg)
|
||||
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("refresh prices with version: %w", err)
|
||||
}
|
||||
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -364,6 +349,9 @@ func (s *LocalConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Config
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
if !localCfg.IsActive {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
return localdb.LocalToConfiguration(localCfg), nil
|
||||
}
|
||||
|
||||
@@ -396,28 +384,40 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := localdb.LocalToConfiguration(localCfg)
|
||||
payload, err := json.Marshal(cfg)
|
||||
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("update configuration without auth with version: %w", err)
|
||||
}
|
||||
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// DeleteNoAuth deletes configuration without ownership check
|
||||
func (s *LocalConfigurationService) DeleteNoAuth(uuid string) error {
|
||||
if err := s.localDB.DeleteConfiguration(uuid); err != nil {
|
||||
return err
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return ErrConfigNotFound
|
||||
}
|
||||
return s.localDB.AddPendingChange("configuration", uuid, "delete", "")
|
||||
localCfg.IsActive = false
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
_, err = s.saveWithVersionAndPending(localCfg, "deactivate", "")
|
||||
return err
|
||||
}
|
||||
|
||||
// ReactivateNoAuth restores an archived configuration without ownership check.
|
||||
func (s *LocalConfigurationService) ReactivateNoAuth(uuid string) (*models.Configuration, error) {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
if localCfg.IsActive {
|
||||
return localdb.LocalToConfiguration(localCfg), nil
|
||||
}
|
||||
|
||||
localCfg.IsActive = true
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
return s.saveWithVersionAndPending(localCfg, "reactivate", "")
|
||||
}
|
||||
|
||||
// RenameNoAuth renames configuration without ownership check
|
||||
@@ -431,24 +431,15 @@ func (s *LocalConfigurationService) RenameNoAuth(uuid string, newName string) (*
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := localdb.LocalToConfiguration(localCfg)
|
||||
payload, err := json.Marshal(cfg)
|
||||
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("rename configuration without auth with version: %w", err)
|
||||
}
|
||||
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// 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,29 +451,21 @@ 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)
|
||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(clone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.localDB.AddPendingChange("configuration", clone.UUID, "create", string(payload)); err != nil {
|
||||
return nil, err
|
||||
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
|
||||
return nil, fmt.Errorf("clone configuration without auth with version: %w", err)
|
||||
}
|
||||
|
||||
return clone, nil
|
||||
@@ -490,14 +473,23 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
|
||||
|
||||
// ListAll returns all configurations without user filter
|
||||
func (s *LocalConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) {
|
||||
return s.ListAllWithStatus(page, perPage, "active")
|
||||
}
|
||||
|
||||
// ListAllWithStatus returns configurations filtered by status: active|archived|all.
|
||||
func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status string) ([]models.Configuration, int64, error) {
|
||||
localConfigs, err := s.localDB.GetConfigurations()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
configs := make([]models.Configuration, len(localConfigs))
|
||||
for i, lc := range localConfigs {
|
||||
configs[i] = *localdb.LocalToConfiguration(&lc)
|
||||
configs = configs[:0]
|
||||
for _, lc := range localConfigs {
|
||||
if !matchesConfigStatus(lc.IsActive, status) {
|
||||
continue
|
||||
}
|
||||
configs = append(configs, *localdb.LocalToConfiguration(&lc))
|
||||
}
|
||||
|
||||
total := int64(len(configs))
|
||||
@@ -532,6 +524,9 @@ func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.C
|
||||
|
||||
var templates []models.Configuration
|
||||
for _, lc := range localConfigs {
|
||||
if !lc.IsActive {
|
||||
continue
|
||||
}
|
||||
if lc.IsTemplate {
|
||||
templates = append(templates, *localdb.LocalToConfiguration(&lc))
|
||||
}
|
||||
@@ -604,20 +599,417 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
||||
localCfg.UpdatedAt = now
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
// Save to local SQLite
|
||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||
return nil, err
|
||||
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refresh prices without auth with version: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ImportFromServer imports configurations from MariaDB to local SQLite cache.
|
||||
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
|
||||
return s.syncService.ImportConfigurationsToLocal()
|
||||
}
|
||||
|
||||
// GetCurrentVersion returns the currently active version row for configuration UUID.
|
||||
func (s *LocalConfigurationService) GetCurrentVersion(configurationUUID string) (*localdb.LocalConfigurationVersion, error) {
|
||||
var cfg localdb.LocalConfiguration
|
||||
if err := s.localDB.DB().Where("uuid = ?", configurationUUID).First(&cfg).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("get configuration for current version: %w", err)
|
||||
}
|
||||
|
||||
// Add to pending sync queue
|
||||
cfg := localdb.LocalToConfiguration(localCfg)
|
||||
payload, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var version localdb.LocalConfigurationVersion
|
||||
if cfg.CurrentVersionID != nil && *cfg.CurrentVersionID != "" {
|
||||
if err := s.localDB.DB().
|
||||
Where("id = ? AND configuration_uuid = ?", *cfg.CurrentVersionID, configurationUUID).
|
||||
First(&version).Error; err == nil {
|
||||
return &version, nil
|
||||
}
|
||||
}
|
||||
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
|
||||
|
||||
if err := s.localDB.DB().
|
||||
Where("configuration_uuid = ?", configurationUUID).
|
||||
Order("version_no DESC").
|
||||
First(&version).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrConfigVersionNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("get latest version for current pointer fallback: %w", err)
|
||||
}
|
||||
|
||||
return &version, nil
|
||||
}
|
||||
|
||||
// ListVersions returns versions by configuration UUID in descending order by version number.
|
||||
func (s *LocalConfigurationService) ListVersions(configurationUUID string, limit, offset int) ([]localdb.LocalConfigurationVersion, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
if offset < 0 {
|
||||
return nil, ErrInvalidVersionNumber
|
||||
}
|
||||
|
||||
var cfgCount int64
|
||||
if err := s.localDB.DB().Model(&localdb.LocalConfiguration{}).
|
||||
Where("uuid = ?", configurationUUID).
|
||||
Count(&cfgCount).Error; err != nil {
|
||||
return nil, fmt.Errorf("check configuration before list versions: %w", err)
|
||||
}
|
||||
if cfgCount == 0 {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
var versions []localdb.LocalConfigurationVersion
|
||||
if err := s.localDB.DB().
|
||||
Where("configuration_uuid = ?", configurationUUID).
|
||||
Order("version_no DESC").
|
||||
Limit(limit).
|
||||
Offset(offset).
|
||||
Find(&versions).Error; err != nil {
|
||||
return nil, fmt.Errorf("list versions: %w", err)
|
||||
}
|
||||
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
// GetVersion returns one version by configuration UUID and version number.
|
||||
func (s *LocalConfigurationService) GetVersion(configurationUUID string, versionNo int) (*localdb.LocalConfigurationVersion, error) {
|
||||
if versionNo <= 0 {
|
||||
return nil, ErrInvalidVersionNumber
|
||||
}
|
||||
|
||||
var version localdb.LocalConfigurationVersion
|
||||
if err := s.localDB.DB().
|
||||
Where("configuration_uuid = ? AND version_no = ?", configurationUUID, versionNo).
|
||||
First(&version).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrConfigVersionNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("get version %d for %s: %w", versionNo, configurationUUID, err)
|
||||
}
|
||||
|
||||
return &version, nil
|
||||
}
|
||||
|
||||
// RollbackToVersion creates a new version from target snapshot and marks it current.
|
||||
func (s *LocalConfigurationService) RollbackToVersion(configurationUUID string, targetVersionNo int, userID string) (*models.Configuration, error) {
|
||||
return s.rollbackToVersion(configurationUUID, targetVersionNo, userID, "")
|
||||
}
|
||||
|
||||
// RollbackToVersionWithNote same as RollbackToVersion, with optional user note.
|
||||
func (s *LocalConfigurationService) RollbackToVersionWithNote(configurationUUID string, targetVersionNo int, userID string, note string) (*models.Configuration, error) {
|
||||
return s.rollbackToVersion(configurationUUID, targetVersionNo, userID, note)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalConfiguration, createdBy string) error {
|
||||
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(localCfg).Error; err != nil {
|
||||
return fmt.Errorf("create local configuration: %w", err)
|
||||
}
|
||||
|
||||
version, err := s.appendVersionTx(tx, localCfg, "create", createdBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("append create version: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Model(&localdb.LocalConfiguration{}).
|
||||
Where("uuid = ?", localCfg.UUID).
|
||||
Update("current_version_id", version.ID).Error; err != nil {
|
||||
return fmt.Errorf("set current version id: %w", err)
|
||||
}
|
||||
localCfg.CurrentVersionID = &version.ID
|
||||
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
|
||||
return fmt.Errorf("enqueue create pending change: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.LocalConfiguration, operation string, createdBy string) (*models.Configuration, error) {
|
||||
var cfg *models.Configuration
|
||||
|
||||
err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
var locked localdb.LocalConfiguration
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("uuid = ?", localCfg.UUID).
|
||||
First(&locked).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrConfigNotFound
|
||||
}
|
||||
return fmt.Errorf("lock configuration row: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Save(localCfg).Error; err != nil {
|
||||
return fmt.Errorf("save local configuration: %w", err)
|
||||
}
|
||||
|
||||
version, err := s.appendVersionTx(tx, localCfg, operation, createdBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("append %s version: %w", operation, err)
|
||||
}
|
||||
|
||||
if err := tx.Model(&localdb.LocalConfiguration{}).
|
||||
Where("uuid = ?", localCfg.UUID).
|
||||
Update("current_version_id", version.ID).Error; err != nil {
|
||||
return fmt.Errorf("update current version id: %w", err)
|
||||
}
|
||||
localCfg.CurrentVersionID = &version.ID
|
||||
|
||||
cfg = localdb.LocalToConfiguration(localCfg)
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil {
|
||||
return fmt.Errorf("enqueue %s pending change: %w", operation, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) appendVersionTx(
|
||||
tx *gorm.DB,
|
||||
localCfg *localdb.LocalConfiguration,
|
||||
operation string,
|
||||
createdBy string,
|
||||
) (*localdb.LocalConfigurationVersion, error) {
|
||||
snapshot, err := s.buildConfigurationSnapshot(localCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build snapshot: %w", err)
|
||||
}
|
||||
changeNote := fmt.Sprintf("%s via local-first flow", operation)
|
||||
|
||||
var createdByPtr *string
|
||||
if createdBy != "" {
|
||||
createdByPtr = &createdBy
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
var maxVersion int
|
||||
if err := tx.Model(&localdb.LocalConfigurationVersion{}).
|
||||
Where("configuration_uuid = ?", localCfg.UUID).
|
||||
Select("COALESCE(MAX(version_no), 0)").
|
||||
Scan(&maxVersion).Error; err != nil {
|
||||
return nil, fmt.Errorf("read max version: %w", err)
|
||||
}
|
||||
|
||||
versionID := uuid.New().String()
|
||||
version := &localdb.LocalConfigurationVersion{
|
||||
ID: versionID,
|
||||
ConfigurationUUID: localCfg.UUID,
|
||||
VersionNo: maxVersion + 1,
|
||||
Data: snapshot,
|
||||
ChangeNote: &changeNote,
|
||||
CreatedBy: createdByPtr,
|
||||
AppVersion: appmeta.Version(),
|
||||
}
|
||||
|
||||
if err := tx.Create(version).Error; err != nil {
|
||||
// SQLite equivalent safety: serialized writer tx + UNIQUE(configuration_uuid, version_no) + retry.
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed: local_configuration_versions.configuration_uuid, local_configuration_versions.version_no") {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("insert configuration version: %w", err)
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%w: exceeded retries for %s", ErrVersionConflict, localCfg.UUID)
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) buildConfigurationSnapshot(localCfg *localdb.LocalConfiguration) (string, error) {
|
||||
return localdb.BuildConfigurationSnapshot(localCfg)
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string, targetVersionNo int, userID string, note string) (*models.Configuration, error) {
|
||||
if targetVersionNo <= 0 {
|
||||
return nil, ErrInvalidVersionNumber
|
||||
}
|
||||
|
||||
var resultCfg *models.Configuration
|
||||
err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
var current localdb.LocalConfiguration
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("uuid = ?", configurationUUID).
|
||||
First(¤t).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrConfigNotFound
|
||||
}
|
||||
return fmt.Errorf("lock configuration for rollback: %w", err)
|
||||
}
|
||||
|
||||
var target localdb.LocalConfigurationVersion
|
||||
if err := tx.Where("configuration_uuid = ? AND version_no = ?", configurationUUID, targetVersionNo).
|
||||
First(&target).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrConfigVersionNotFound
|
||||
}
|
||||
return fmt.Errorf("load target rollback version: %w", err)
|
||||
}
|
||||
|
||||
rollbackData, err := s.decodeConfigurationSnapshot(target.Data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode target rollback snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Keep stable identity/sync linkage; restore editable config content from target snapshot.
|
||||
current.Name = rollbackData.Name
|
||||
current.Items = rollbackData.Items
|
||||
current.TotalPrice = rollbackData.TotalPrice
|
||||
current.CustomPrice = rollbackData.CustomPrice
|
||||
current.Notes = rollbackData.Notes
|
||||
current.IsTemplate = rollbackData.IsTemplate
|
||||
current.ServerCount = rollbackData.ServerCount
|
||||
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
|
||||
current.UpdatedAt = time.Now()
|
||||
current.SyncStatus = "pending"
|
||||
current.IsActive = rollbackData.IsActive
|
||||
|
||||
if rollbackData.OriginalUsername != "" {
|
||||
current.OriginalUsername = rollbackData.OriginalUsername
|
||||
}
|
||||
if rollbackData.OriginalUserID != 0 {
|
||||
current.OriginalUserID = rollbackData.OriginalUserID
|
||||
}
|
||||
|
||||
if err := tx.Save(¤t).Error; err != nil {
|
||||
return fmt.Errorf("save rolled back configuration: %w", err)
|
||||
}
|
||||
|
||||
var maxVersion int
|
||||
if err := tx.Model(&localdb.LocalConfigurationVersion{}).
|
||||
Where("configuration_uuid = ?", configurationUUID).
|
||||
Select("COALESCE(MAX(version_no), 0)").
|
||||
Scan(&maxVersion).Error; err != nil {
|
||||
return fmt.Errorf("read max version before rollback append: %w", err)
|
||||
}
|
||||
|
||||
changeNote := fmt.Sprintf("rollback to v%d", targetVersionNo)
|
||||
if trimmed := strings.TrimSpace(note); trimmed != "" {
|
||||
changeNote = fmt.Sprintf("%s (%s)", changeNote, trimmed)
|
||||
}
|
||||
|
||||
version := &localdb.LocalConfigurationVersion{
|
||||
ID: uuid.New().String(),
|
||||
ConfigurationUUID: configurationUUID,
|
||||
VersionNo: maxVersion + 1,
|
||||
Data: target.Data,
|
||||
ChangeNote: &changeNote,
|
||||
CreatedBy: stringPtrOrNil(userID),
|
||||
AppVersion: appmeta.Version(),
|
||||
}
|
||||
|
||||
if err := tx.Create(version).Error; err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed: local_configuration_versions.configuration_uuid, local_configuration_versions.version_no") {
|
||||
return ErrVersionConflict
|
||||
}
|
||||
return fmt.Errorf("create rollback version: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Model(&localdb.LocalConfiguration{}).
|
||||
Where("uuid = ?", configurationUUID).
|
||||
Update("current_version_id", version.ID).Error; err != nil {
|
||||
return fmt.Errorf("update current version after rollback: %w", err)
|
||||
}
|
||||
current.CurrentVersionID = &version.ID
|
||||
|
||||
resultCfg = localdb.LocalToConfiguration(¤t)
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, ¤t, "rollback", version, userID); err != nil {
|
||||
return fmt.Errorf("enqueue rollback pending change: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resultCfg, nil
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) enqueueConfigurationPendingChangeTx(
|
||||
tx *gorm.DB,
|
||||
localCfg *localdb.LocalConfiguration,
|
||||
operation string,
|
||||
version *localdb.LocalConfigurationVersion,
|
||||
createdBy string,
|
||||
) error {
|
||||
cfg := localdb.LocalToConfiguration(localCfg)
|
||||
payload := sync.ConfigurationChangePayload{
|
||||
EventID: uuid.New().String(),
|
||||
IdempotencyKey: fmt.Sprintf("%s:v%d:%s", localCfg.UUID, version.VersionNo, operation),
|
||||
ConfigurationUUID: localCfg.UUID,
|
||||
Operation: operation,
|
||||
CurrentVersionID: version.ID,
|
||||
CurrentVersionNo: version.VersionNo,
|
||||
ConflictPolicy: "last_write_wins",
|
||||
Snapshot: *cfg,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
CreatedBy: stringPtrOrNil(createdBy),
|
||||
}
|
||||
|
||||
rawPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal pending payload: %w", err)
|
||||
}
|
||||
|
||||
change := &localdb.PendingChange{
|
||||
EntityType: "configuration",
|
||||
EntityUUID: localCfg.UUID,
|
||||
Operation: operation,
|
||||
Payload: string(rawPayload),
|
||||
CreatedAt: time.Now(),
|
||||
Attempts: 0,
|
||||
}
|
||||
if err := tx.Create(change).Error; err != nil {
|
||||
return fmt.Errorf("insert pending change: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) decodeConfigurationSnapshot(data string) (*localdb.LocalConfiguration, error) {
|
||||
return localdb.DecodeConfigurationSnapshot(data)
|
||||
}
|
||||
|
||||
func stringPtrOrNil(value string) *string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func matchesConfigStatus(isActive bool, status string) bool {
|
||||
switch status {
|
||||
case "active", "":
|
||||
return isActive
|
||||
case "archived":
|
||||
return !isActive
|
||||
case "all":
|
||||
return true
|
||||
default:
|
||||
return isActive
|
||||
}
|
||||
}
|
||||
|
||||
357
internal/services/local_configuration_versioning_test.go
Normal file
357
internal/services/local_configuration_versioning_test.go
Normal file
@@ -0,0 +1,357 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
)
|
||||
|
||||
func TestSaveCreatesNewVersionAndUpdatesCurrentPointer(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "v1",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.RenameNoAuth(created.UUID, "v2"); err != nil {
|
||||
t.Fatalf("rename config: %v", err)
|
||||
}
|
||||
|
||||
versions := loadVersions(t, local, created.UUID)
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("expected 2 versions, got %d", len(versions))
|
||||
}
|
||||
if versions[0].VersionNo != 1 || versions[1].VersionNo != 2 {
|
||||
t.Fatalf("expected version_no [1,2], got [%d,%d]", versions[0].VersionNo, versions[1].VersionNo)
|
||||
}
|
||||
|
||||
cfg, err := local.GetConfigurationByUUID(created.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("load local config: %v", err)
|
||||
}
|
||||
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID != versions[1].ID {
|
||||
t.Fatalf("current_version_id should point to v2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackCreatesNewVersionWithTargetData(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "base",
|
||||
Items: models.ConfigItems{{LotName: "RAM_A", Quantity: 2, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.RenameNoAuth(created.UUID, "changed"); err != nil {
|
||||
t.Fatalf("rename config: %v", err)
|
||||
}
|
||||
if _, err := service.RollbackToVersionWithNote(created.UUID, 1, "tester", "test rollback"); err != nil {
|
||||
t.Fatalf("rollback to v1: %v", err)
|
||||
}
|
||||
|
||||
versions := loadVersions(t, local, created.UUID)
|
||||
if len(versions) != 3 {
|
||||
t.Fatalf("expected 3 versions, got %d", len(versions))
|
||||
}
|
||||
if versions[2].VersionNo != 3 {
|
||||
t.Fatalf("expected v3 as rollback version, got v%d", versions[2].VersionNo)
|
||||
}
|
||||
if versions[2].Data != versions[0].Data {
|
||||
t.Fatalf("expected rollback snapshot data equal to v1 data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "initial",
|
||||
Items: models.ConfigItems{{LotName: "SSD_A", Quantity: 1, UnitPrice: 300}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
versionsBefore := loadVersions(t, local, created.UUID)
|
||||
if len(versionsBefore) != 1 {
|
||||
t.Fatalf("expected exactly one version after create")
|
||||
}
|
||||
v1Before := versionsBefore[0]
|
||||
|
||||
if _, err := service.RenameNoAuth(created.UUID, "after-rename"); err != nil {
|
||||
t.Fatalf("rename config: %v", err)
|
||||
}
|
||||
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
}
|
||||
|
||||
versionsAfter := loadVersions(t, local, created.UUID)
|
||||
if len(versionsAfter) != 3 {
|
||||
t.Fatalf("expected 3 versions, got %d", len(versionsAfter))
|
||||
}
|
||||
v1After := versionsAfter[0]
|
||||
|
||||
if v1After.ID != v1Before.ID {
|
||||
t.Fatalf("v1 id changed: before=%s after=%s", v1Before.ID, v1After.ID)
|
||||
}
|
||||
if v1After.Data != v1Before.Data {
|
||||
t.Fatalf("v1 data changed")
|
||||
}
|
||||
if !v1After.CreatedAt.Equal(v1Before.CreatedAt) {
|
||||
t.Fatalf("v1 created_at changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentSaveNoDuplicateVersionNumbers(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "base",
|
||||
Items: models.ConfigItems{{LotName: "NIC_A", Quantity: 1, UnitPrice: 150}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
const workers = 8
|
||||
start := make(chan struct{})
|
||||
errCh := make(chan error, workers)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
i := i
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
if err := renameWithRetry(service, created.UUID, fmt.Sprintf("name-%d", i)); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
|
||||
for err := range errCh {
|
||||
if err != nil {
|
||||
t.Fatalf("concurrent save failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type counts struct {
|
||||
Total int64
|
||||
DistinctCount int64
|
||||
Max int
|
||||
}
|
||||
var c counts
|
||||
if err := local.DB().Raw(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(DISTINCT version_no) as distinct_count,
|
||||
COALESCE(MAX(version_no), 0) as max
|
||||
FROM local_configuration_versions
|
||||
WHERE configuration_uuid = ?`, created.UUID).Scan(&c).Error; err != nil {
|
||||
t.Fatalf("query version counts: %v", err)
|
||||
}
|
||||
|
||||
if c.Total != c.DistinctCount {
|
||||
t.Fatalf("duplicate version numbers detected: total=%d distinct=%d", c.Total, c.DistinctCount)
|
||||
}
|
||||
expected := int64(workers + 1) // initial create version + each successful save
|
||||
if c.Total != expected || c.Max != int(expected) {
|
||||
t.Fatalf("expected total=max=%d, got total=%d max=%d", expected, c.Total, c.Max)
|
||||
}
|
||||
}
|
||||
|
||||
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "local.db")
|
||||
local, err := localdb.New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = local.Close()
|
||||
})
|
||||
|
||||
return NewLocalConfigurationService(
|
||||
local,
|
||||
syncsvc.NewService(nil, local),
|
||||
&QuoteService{},
|
||||
func() bool { return false },
|
||||
), local
|
||||
}
|
||||
|
||||
func loadVersions(t *testing.T, local *localdb.LocalDB, configurationUUID string) []localdb.LocalConfigurationVersion {
|
||||
t.Helper()
|
||||
var versions []localdb.LocalConfigurationVersion
|
||||
if err := local.DB().
|
||||
Where("configuration_uuid = ?", configurationUUID).
|
||||
Order("version_no ASC").
|
||||
Find(&versions).Error; err != nil {
|
||||
t.Fatalf("load versions: %v", err)
|
||||
}
|
||||
return versions
|
||||
}
|
||||
|
||||
func renameWithRetry(service *LocalConfigurationService, uuid string, name string) error {
|
||||
var lastErr error
|
||||
for i := 0; i < 6; i++ {
|
||||
_, err := service.RenameNoAuth(uuid, name)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
if errors.Is(err, ErrVersionConflict) || strings.Contains(err.Error(), "database is locked") {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("rename retries exhausted: %w", lastErr)
|
||||
}
|
||||
|
||||
func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "initial",
|
||||
Items: models.ConfigItems{{LotName: "GPU_A", Quantity: 1, UnitPrice: 2000}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
if _, err := service.RenameNoAuth(created.UUID, "second"); err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
}
|
||||
|
||||
versions := loadVersions(t, local, created.UUID)
|
||||
if len(versions) != 3 {
|
||||
t.Fatalf("expected 3 versions")
|
||||
}
|
||||
|
||||
var v1 map[string]any
|
||||
var v3 map[string]any
|
||||
if err := json.Unmarshal([]byte(versions[0].Data), &v1); err != nil {
|
||||
t.Fatalf("unmarshal v1: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(versions[2].Data), &v3); err != nil {
|
||||
t.Fatalf("unmarshal v3: %v", err)
|
||||
}
|
||||
if fmt.Sprintf("%v", v1["name"]) != fmt.Sprintf("%v", v3["name"]) {
|
||||
t.Fatalf("rollback snapshot differs from v1 snapshot by name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMarksInactiveAndCreatesVersion(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "to-archive",
|
||||
Items: models.ConfigItems{{LotName: "CPU_Z", Quantity: 1, UnitPrice: 500}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
if err := service.DeleteNoAuth(created.UUID); err != nil {
|
||||
t.Fatalf("delete no auth: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := local.GetConfigurationByUUID(created.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("load archived config: %v", err)
|
||||
}
|
||||
if cfg.IsActive {
|
||||
t.Fatalf("expected config to be inactive after delete")
|
||||
}
|
||||
|
||||
versions := loadVersions(t, local, created.UUID)
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("expected 2 versions after archive, got %d", len(versions))
|
||||
}
|
||||
if versions[1].VersionNo != 2 {
|
||||
t.Fatalf("expected archive to create version 2, got %d", versions[1].VersionNo)
|
||||
}
|
||||
|
||||
list, total, err := service.ListAll(1, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("list all: %v", err)
|
||||
}
|
||||
if total != int64(len(list)) {
|
||||
t.Fatalf("unexpected total/list mismatch")
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected archived config to be hidden from list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReactivateRestoresArchivedConfigurationAndCreatesVersion(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "to-reactivate",
|
||||
Items: models.ConfigItems{{LotName: "CPU_R", Quantity: 1, UnitPrice: 700}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
if err := service.DeleteNoAuth(created.UUID); err != nil {
|
||||
t.Fatalf("archive config: %v", err)
|
||||
}
|
||||
if _, err := service.ReactivateNoAuth(created.UUID); err != nil {
|
||||
t.Fatalf("reactivate config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := local.GetConfigurationByUUID(created.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("load reactivated config: %v", err)
|
||||
}
|
||||
if !cfg.IsActive {
|
||||
t.Fatalf("expected config to be active after reactivation")
|
||||
}
|
||||
|
||||
versions := loadVersions(t, local, created.UUID)
|
||||
if len(versions) != 3 {
|
||||
t.Fatalf("expected 3 versions after reactivation, got %d", len(versions))
|
||||
}
|
||||
if versions[2].VersionNo != 3 {
|
||||
t.Fatalf("expected reactivation version 3, got %d", versions[2].VersionNo)
|
||||
}
|
||||
|
||||
list, _, err := service.ListAll(1, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("list all after reactivation: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected reactivated config to be visible in list")
|
||||
}
|
||||
}
|
||||
@@ -132,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 {
|
||||
|
||||
@@ -2,16 +2,21 @@ package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"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 +39,91 @@ 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"`
|
||||
}
|
||||
|
||||
// ConfigurationChangePayload is stored in pending_changes.payload for configuration events.
|
||||
// It carries version metadata so sync can push the latest snapshot and prepare for conflict resolution.
|
||||
type ConfigurationChangePayload struct {
|
||||
EventID string `json:"event_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
ConfigurationUUID string `json:"configuration_uuid"`
|
||||
Operation string `json:"operation"` // create/update/rollback/deactivate/reactivate/delete
|
||||
CurrentVersionID string `json:"current_version_id,omitempty"`
|
||||
CurrentVersionNo int `json:"current_version_no,omitempty"`
|
||||
ConflictPolicy string `json:"conflict_policy,omitempty"` // currently: last_write_wins
|
||||
Snapshot models.Configuration `json:"snapshot"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedBy *string `json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
if existing != nil && err == nil && !existing.IsActive {
|
||||
// Keep local deactivation sticky: do not resurrect hidden entries from server pull.
|
||||
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 +134,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,10 +216,10 @@ 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
|
||||
@@ -363,6 +453,12 @@ func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
|
||||
return s.pushConfigurationCreate(change)
|
||||
case "update":
|
||||
return s.pushConfigurationUpdate(change)
|
||||
case "rollback":
|
||||
return s.pushConfigurationRollback(change)
|
||||
case "deactivate":
|
||||
return s.pushConfigurationDeactivate(change)
|
||||
case "reactivate":
|
||||
return s.pushConfigurationReactivate(change)
|
||||
case "delete":
|
||||
return s.pushConfigurationDelete(change)
|
||||
default:
|
||||
@@ -372,9 +468,13 @@ func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
|
||||
|
||||
// pushConfigurationCreate creates a configuration on the server
|
||||
func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
||||
var cfg models.Configuration
|
||||
if err := json.Unmarshal([]byte(change.Payload), &cfg); err != nil {
|
||||
return fmt.Errorf("unmarshaling configuration: %w", err)
|
||||
payload, cfg, isStale, err := s.resolveConfigurationPayloadForPush(change)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isStale {
|
||||
slog.Debug("skipping stale create event, newer version exists", "uuid", payload.ConfigurationUUID, "idempotency_key", payload.IdempotencyKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
@@ -385,10 +485,21 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
||||
|
||||
// Create repository
|
||||
configRepo := repository.NewConfigurationRepository(mariaDB)
|
||||
if err := s.ensureConfigurationOwner(mariaDB, &cfg); err != nil {
|
||||
return fmt.Errorf("resolve configuration owner: %w", err)
|
||||
}
|
||||
|
||||
// Create on server
|
||||
if err := configRepo.Create(&cfg); err != nil {
|
||||
return fmt.Errorf("creating configuration on server: %w", err)
|
||||
// Idempotency fallback: configuration may already be created remotely.
|
||||
serverCfg, getErr := configRepo.GetByUUID(cfg.UUID)
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("creating configuration on server: %w", err)
|
||||
}
|
||||
cfg.ID = serverCfg.ID
|
||||
if updateErr := configRepo.Update(&cfg); updateErr != nil {
|
||||
return fmt.Errorf("create fallback update on server: %w", updateErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Update local configuration with server ID
|
||||
@@ -400,15 +511,25 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
||||
s.localDB.SaveConfiguration(localCfg)
|
||||
}
|
||||
|
||||
slog.Info("configuration created on server", "uuid", cfg.UUID, "server_id", cfg.ID)
|
||||
slog.Info("configuration created on server",
|
||||
"uuid", cfg.UUID,
|
||||
"server_id", cfg.ID,
|
||||
"version_no", payload.CurrentVersionNo,
|
||||
"version_id", payload.CurrentVersionID,
|
||||
"idempotency_key", payload.IdempotencyKey,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushConfigurationUpdate updates a configuration on the server
|
||||
func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||
var cfg models.Configuration
|
||||
if err := json.Unmarshal([]byte(change.Payload), &cfg); err != nil {
|
||||
return fmt.Errorf("unmarshaling configuration: %w", err)
|
||||
payload, cfg, isStale, err := s.resolveConfigurationPayloadForPush(change)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isStale {
|
||||
slog.Debug("skipping stale update event, newer version exists", "uuid", payload.ConfigurationUUID, "idempotency_key", payload.IdempotencyKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
@@ -419,6 +540,9 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||
|
||||
// Create repository
|
||||
configRepo := repository.NewConfigurationRepository(mariaDB)
|
||||
if err := s.ensureConfigurationOwner(mariaDB, &cfg); err != nil {
|
||||
return fmt.Errorf("resolve configuration owner: %w", err)
|
||||
}
|
||||
|
||||
// Ensure we have a server ID before updating
|
||||
// If the payload doesn't have ID, get it from local configuration
|
||||
@@ -457,10 +581,174 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||
s.localDB.SaveConfiguration(localCfg)
|
||||
}
|
||||
|
||||
slog.Info("configuration updated on server", "uuid", cfg.UUID)
|
||||
slog.Info("configuration updated on server",
|
||||
"uuid", cfg.UUID,
|
||||
"version_no", payload.CurrentVersionNo,
|
||||
"version_id", payload.CurrentVersionID,
|
||||
"idempotency_key", payload.IdempotencyKey,
|
||||
"operation", payload.Operation,
|
||||
"conflict_policy", payload.ConflictPolicy,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureConfigurationOwner(mariaDB *gorm.DB, cfg *models.Configuration) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("configuration is nil")
|
||||
}
|
||||
|
||||
ownerUsername := cfg.OwnerUsername
|
||||
if ownerUsername == "" {
|
||||
ownerUsername = s.localDB.GetDBUser()
|
||||
cfg.OwnerUsername = ownerUsername
|
||||
}
|
||||
if ownerUsername == "" {
|
||||
return fmt.Errorf("owner username is empty")
|
||||
}
|
||||
|
||||
// user_id is legacy and no longer used for ownership in local-first mode.
|
||||
// Keep it NULL on writes; ownership is represented by owner_username.
|
||||
cfg.UserID = nil
|
||||
cfg.AppVersion = appmeta.Version()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) pushConfigurationRollback(change *localdb.PendingChange) error {
|
||||
// Last-write-wins for now: rollback is pushed as an update with rollback metadata.
|
||||
return s.pushConfigurationUpdate(change)
|
||||
}
|
||||
|
||||
func (s *Service) pushConfigurationDeactivate(change *localdb.PendingChange) error {
|
||||
// Local deactivate is represented as the latest snapshot push.
|
||||
return s.pushConfigurationUpdate(change)
|
||||
}
|
||||
|
||||
func (s *Service) pushConfigurationReactivate(change *localdb.PendingChange) error {
|
||||
// Local reactivate is represented as the latest snapshot push.
|
||||
return s.pushConfigurationUpdate(change)
|
||||
}
|
||||
|
||||
func (s *Service) resolveConfigurationPayloadForPush(change *localdb.PendingChange) (ConfigurationChangePayload, models.Configuration, bool, error) {
|
||||
payload, err := decodeConfigurationChangePayload(change)
|
||||
if err != nil {
|
||||
return ConfigurationChangePayload{}, models.Configuration{}, false, fmt.Errorf("decode configuration payload: %w", err)
|
||||
}
|
||||
eventVersionNo := payload.CurrentVersionNo
|
||||
|
||||
currentCfg, currentVersionID, currentVersionNo, err := s.loadCurrentConfigurationState(payload.ConfigurationUUID)
|
||||
if err != nil {
|
||||
// Local config may be gone (e.g. stale queue item after delete/cleanup). Treat as no-op.
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return payload, payload.Snapshot, true, nil
|
||||
}
|
||||
// create->deactivate race: config may no longer be active/visible locally, skip stale create.
|
||||
if change.Operation == "create" {
|
||||
return payload, payload.Snapshot, true, nil
|
||||
}
|
||||
return ConfigurationChangePayload{}, models.Configuration{}, false, fmt.Errorf("load current local configuration state: %w", err)
|
||||
}
|
||||
|
||||
if payload.ConflictPolicy == "" {
|
||||
payload.ConflictPolicy = "last_write_wins"
|
||||
}
|
||||
|
||||
if currentCfg.UUID != "" {
|
||||
payload.Snapshot = currentCfg
|
||||
if currentVersionID != "" {
|
||||
payload.CurrentVersionID = currentVersionID
|
||||
}
|
||||
if currentVersionNo > 0 {
|
||||
payload.CurrentVersionNo = currentVersionNo
|
||||
}
|
||||
}
|
||||
|
||||
isStale := false
|
||||
if eventVersionNo > 0 && currentVersionNo > eventVersionNo {
|
||||
// Keep only latest intent in queue; older versions become no-op.
|
||||
isStale = true
|
||||
}
|
||||
if !isStale && change.Operation == "create" {
|
||||
localCfg, getErr := s.localDB.GetConfigurationByUUID(payload.ConfigurationUUID)
|
||||
if getErr == nil && !localCfg.IsActive {
|
||||
isStale = true
|
||||
}
|
||||
}
|
||||
|
||||
return payload, payload.Snapshot, isStale, nil
|
||||
}
|
||||
|
||||
func decodeConfigurationChangePayload(change *localdb.PendingChange) (ConfigurationChangePayload, error) {
|
||||
var payload ConfigurationChangePayload
|
||||
if err := json.Unmarshal([]byte(change.Payload), &payload); err == nil && payload.ConfigurationUUID != "" && payload.Snapshot.UUID != "" {
|
||||
if payload.Operation == "" {
|
||||
payload.Operation = change.Operation
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// Backward compatibility: legacy queue stored raw models.Configuration JSON.
|
||||
var cfg models.Configuration
|
||||
if err := json.Unmarshal([]byte(change.Payload), &cfg); err != nil {
|
||||
return ConfigurationChangePayload{}, fmt.Errorf("unmarshal legacy configuration payload: %w", err)
|
||||
}
|
||||
|
||||
return ConfigurationChangePayload{
|
||||
EventID: "",
|
||||
IdempotencyKey: fmt.Sprintf("%s:%s:legacy", cfg.UUID, change.Operation),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
Operation: change.Operation,
|
||||
ConflictPolicy: "last_write_wins",
|
||||
Snapshot: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadCurrentConfigurationState(configurationUUID string) (models.Configuration, string, int, error) {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(configurationUUID)
|
||||
if err != nil {
|
||||
return models.Configuration{}, "", 0, fmt.Errorf("get local configuration by uuid: %w", err)
|
||||
}
|
||||
|
||||
cfg := *localdb.LocalToConfiguration(localCfg)
|
||||
|
||||
currentVersionID := ""
|
||||
if localCfg.CurrentVersionID != nil {
|
||||
currentVersionID = *localCfg.CurrentVersionID
|
||||
}
|
||||
|
||||
currentVersionNo := 0
|
||||
if currentVersionID != "" {
|
||||
var version localdb.LocalConfigurationVersion
|
||||
err = s.localDB.DB().
|
||||
Where("id = ? AND configuration_uuid = ?", currentVersionID, configurationUUID).
|
||||
First(&version).Error
|
||||
if err == nil {
|
||||
currentVersionNo = version.VersionNo
|
||||
}
|
||||
}
|
||||
|
||||
if currentVersionNo == 0 {
|
||||
var latest localdb.LocalConfigurationVersion
|
||||
err = s.localDB.DB().
|
||||
Where("configuration_uuid = ?", configurationUUID).
|
||||
Order("version_no DESC").
|
||||
First(&latest).Error
|
||||
if err == nil {
|
||||
currentVersionNo = latest.VersionNo
|
||||
currentVersionID = latest.ID
|
||||
}
|
||||
}
|
||||
|
||||
if currentVersionNo == 0 {
|
||||
return models.Configuration{}, "", 0, fmt.Errorf("no local configuration version found for %s", configurationUUID)
|
||||
}
|
||||
|
||||
return cfg, currentVersionID, currentVersionNo, nil
|
||||
}
|
||||
|
||||
// NOTE: prepared for future conflict resolution:
|
||||
// when server starts storing version metadata, we can compare payload.CurrentVersionNo
|
||||
// against remote version and branch into custom strategies. For now use last-write-wins.
|
||||
|
||||
// pushConfigurationDelete deletes a configuration from the server
|
||||
func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
||||
// Get database connection
|
||||
@@ -485,6 +773,6 @@ func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
||||
return fmt.Errorf("deleting configuration from server: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("configuration deleted from server", "uuid", change.EntityUUID)
|
||||
slog.Info("configuration deleted on server", "uuid", change.EntityUUID)
|
||||
return nil
|
||||
}
|
||||
|
||||
10
migrations/005_add_owner_username.sql
Normal file
10
migrations/005_add_owner_username.sql
Normal 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 = '';
|
||||
80
migrations/006_add_local_configuration_versions.sql
Normal file
80
migrations/006_add_local_configuration_versions.sql
Normal file
@@ -0,0 +1,80 @@
|
||||
-- Add full-snapshot versioning for local configurations (SQLite)
|
||||
-- 1) Create local_configuration_versions
|
||||
-- 2) Add current_version_id to local_configurations
|
||||
-- 3) Backfill v1 snapshots from existing local_configurations
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE local_configuration_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
configuration_uuid TEXT NOT NULL,
|
||||
version_no INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
change_note TEXT NULL,
|
||||
created_by TEXT NULL,
|
||||
app_version TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (configuration_uuid) REFERENCES local_configurations(uuid),
|
||||
UNIQUE(configuration_uuid, version_no)
|
||||
);
|
||||
|
||||
ALTER TABLE local_configurations
|
||||
ADD COLUMN current_version_id TEXT NULL;
|
||||
|
||||
CREATE INDEX idx_lcv_config_created
|
||||
ON local_configuration_versions(configuration_uuid, created_at DESC);
|
||||
|
||||
CREATE INDEX idx_lcv_config_version
|
||||
ON local_configuration_versions(configuration_uuid, version_no DESC);
|
||||
|
||||
-- Backfill v1 snapshot for every existing configuration.
|
||||
INSERT INTO local_configuration_versions (
|
||||
id,
|
||||
configuration_uuid,
|
||||
version_no,
|
||||
data,
|
||||
change_note,
|
||||
created_by,
|
||||
app_version,
|
||||
created_at
|
||||
)
|
||||
SELECT
|
||||
uuid || '-v1' AS id,
|
||||
uuid AS configuration_uuid,
|
||||
1 AS version_no,
|
||||
json_object(
|
||||
'uuid', uuid,
|
||||
'server_id', server_id,
|
||||
'name', name,
|
||||
'items', CASE WHEN json_valid(items) THEN json(items) ELSE items END,
|
||||
'total_price', total_price,
|
||||
'custom_price', custom_price,
|
||||
'notes', notes,
|
||||
'is_template', is_template,
|
||||
'server_count', server_count,
|
||||
'price_updated_at', price_updated_at,
|
||||
'created_at', created_at,
|
||||
'updated_at', updated_at,
|
||||
'synced_at', synced_at,
|
||||
'sync_status', sync_status,
|
||||
'original_user_id', original_user_id,
|
||||
'original_username', original_username,
|
||||
'app_version', NULL
|
||||
) AS data,
|
||||
'Initial snapshot backfill (v1)' AS change_note,
|
||||
NULL AS created_by,
|
||||
NULL AS app_version,
|
||||
COALESCE(created_at, CURRENT_TIMESTAMP) AS created_at
|
||||
FROM local_configurations;
|
||||
|
||||
UPDATE local_configurations
|
||||
SET current_version_id = (
|
||||
SELECT lcv.id
|
||||
FROM local_configuration_versions lcv
|
||||
WHERE lcv.configuration_uuid = local_configurations.uuid
|
||||
AND lcv.version_no = 1
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
25
migrations/007_detach_configurations_from_qt_users.sql
Normal file
25
migrations/007_detach_configurations_from_qt_users.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Detach qt_configurations from qt_users (ownership is owner_username text)
|
||||
-- Safe for MySQL 8+/MariaDB 10.2+ via INFORMATION_SCHEMA checks.
|
||||
|
||||
SET @fk_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE CONSTRAINT_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'qt_configurations'
|
||||
AND CONSTRAINT_NAME = 'fk_qt_configurations_user'
|
||||
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||
);
|
||||
|
||||
SET @drop_fk_sql := IF(
|
||||
@fk_exists > 0,
|
||||
'ALTER TABLE qt_configurations DROP FOREIGN KEY fk_qt_configurations_user',
|
||||
'SELECT ''fk_qt_configurations_user not found, skip'' '
|
||||
);
|
||||
PREPARE stmt_drop_fk FROM @drop_fk_sql;
|
||||
EXECUTE stmt_drop_fk;
|
||||
DEALLOCATE PREPARE stmt_drop_fk;
|
||||
|
||||
-- user_id becomes optional legacy column (can stay NULL)
|
||||
ALTER TABLE qt_configurations
|
||||
MODIFY COLUMN user_id BIGINT UNSIGNED NULL;
|
||||
|
||||
4
migrations/008_add_app_version_to_configurations.sql
Normal file
4
migrations/008_add_app_version_to_configurations.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Track application version used for configuration writes (create/update via sync)
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN app_version VARCHAR(64) NULL DEFAULT NULL AFTER owner_username;
|
||||
|
||||
100
scripts/release.sh
Executable file
100
scripts/release.sh
Executable 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}"
|
||||
@@ -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>';
|
||||
|
||||
@@ -4,10 +4,22 @@
|
||||
<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 id="action-buttons" 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 class="mt-4 inline-flex rounded-lg border border-gray-200 overflow-hidden">
|
||||
<button id="status-active-btn" onclick="setConfigStatusMode('active')" class="px-4 py-2 text-sm font-medium bg-blue-600 text-white">
|
||||
Активные
|
||||
</button>
|
||||
<button id="status-archived-btn" onclick="setConfigStatusMode('archived')" class="px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
|
||||
Архив
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden">
|
||||
@@ -112,11 +124,15 @@
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
let perPage = 20;
|
||||
let configStatusMode = 'active';
|
||||
|
||||
function renderConfigs(configs) {
|
||||
const emptyText = configStatusMode === 'archived'
|
||||
? 'Архив пуст'
|
||||
: 'Нет сохраненных конфигураций';
|
||||
if (configs.length === 0) {
|
||||
document.getElementById('configs-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">Нет сохраненных конфигураций</div>';
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">' + emptyText + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -124,6 +140,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 +151,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 = '—';
|
||||
@@ -144,26 +162,39 @@ 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>';
|
||||
if (configStatusMode === 'archived') {
|
||||
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">' + escapeHtml(c.name) + '</td>';
|
||||
} else {
|
||||
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>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
||||
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>';
|
||||
html += '</svg>';
|
||||
html += '</button>';
|
||||
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>';
|
||||
html += '</svg>';
|
||||
html += '</button>';
|
||||
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="Удалить">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>';
|
||||
html += '</svg>';
|
||||
html += '</button>';
|
||||
if (configStatusMode === 'archived') {
|
||||
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>';
|
||||
html += '</svg>';
|
||||
html += '</button>';
|
||||
} else {
|
||||
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>';
|
||||
html += '</svg>';
|
||||
html += '</button>';
|
||||
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>';
|
||||
html += '</svg>';
|
||||
html += '</button>';
|
||||
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="В архив">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>';
|
||||
html += '</svg>';
|
||||
html += '</button>';
|
||||
}
|
||||
html += '</td></tr>';
|
||||
});
|
||||
|
||||
@@ -178,13 +209,25 @@ function escapeHtml(text) {
|
||||
}
|
||||
|
||||
async function deleteConfig(uuid) {
|
||||
if (!confirm('Удалить?')) return;
|
||||
if (!confirm('Переместить конфигурацию в архив?')) return;
|
||||
await fetch('/api/configs/' + uuid, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
loadConfigs();
|
||||
}
|
||||
|
||||
async function reactivateConfig(uuid) {
|
||||
if (!confirm('Восстановить конфигурацию из архива?')) return;
|
||||
const resp = await fetch('/api/configs/' + uuid + '/reactivate', {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось восстановить конфигурацию');
|
||||
return;
|
||||
}
|
||||
loadConfigs();
|
||||
}
|
||||
|
||||
function openRenameModal(uuid, currentName) {
|
||||
document.getElementById('rename-uuid').value = uuid;
|
||||
document.getElementById('rename-input').value = currentName;
|
||||
@@ -379,18 +422,46 @@ function nextPage() {
|
||||
}
|
||||
|
||||
function updatePagination(total) {
|
||||
totalPages = Math.ceil(total / perPage);
|
||||
totalPages = Math.max(1, Math.ceil(total / perPage));
|
||||
document.getElementById('page-info').textContent =
|
||||
'Страница ' + currentPage + ' из ' + totalPages + ' (всего: ' + total + ')';
|
||||
document.getElementById('btn-prev').disabled = currentPage <= 1;
|
||||
document.getElementById('btn-next').disabled = currentPage >= totalPages;
|
||||
document.getElementById('pagination').classList.remove('hidden');
|
||||
if (total <= perPage) {
|
||||
document.getElementById('pagination').classList.add('hidden');
|
||||
} else {
|
||||
document.getElementById('pagination').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function setConfigStatusMode(mode) {
|
||||
if (mode !== 'active' && mode !== 'archived') return;
|
||||
configStatusMode = mode;
|
||||
currentPage = 1;
|
||||
applyStatusModeUI();
|
||||
loadConfigs();
|
||||
}
|
||||
|
||||
function applyStatusModeUI() {
|
||||
const activeBtn = document.getElementById('status-active-btn');
|
||||
const archivedBtn = document.getElementById('status-archived-btn');
|
||||
const actionButtons = document.getElementById('action-buttons');
|
||||
|
||||
if (configStatusMode === 'archived') {
|
||||
activeBtn.className = 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50';
|
||||
archivedBtn.className = 'px-4 py-2 text-sm font-medium bg-blue-600 text-white border-l border-gray-200';
|
||||
actionButtons.classList.add('hidden');
|
||||
} else {
|
||||
activeBtn.className = 'px-4 py-2 text-sm font-medium bg-blue-600 text-white';
|
||||
archivedBtn.className = 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200';
|
||||
actionButtons.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Load configs with pagination
|
||||
async function loadConfigs() {
|
||||
try {
|
||||
const resp = await fetch('/api/configs?page=' + currentPage + '&per_page=' + perPage);
|
||||
const resp = await fetch('/api/configs?page=' + currentPage + '&per_page=' + perPage + '&status=' + configStatusMode);
|
||||
|
||||
if (!resp.ok) {
|
||||
document.getElementById('configs-list').innerHTML =
|
||||
@@ -407,7 +478,40 @@ 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() {
|
||||
applyStatusModeUI();
|
||||
loadConfigs();
|
||||
|
||||
// Load latest pricelist version for badge
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user