Implement local DB migrations and archived configuration lifecycle
This commit is contained in:
45
README.md
45
README.md
@@ -131,6 +131,36 @@ make help # Показать все команды
|
|||||||
|
|
||||||
Можно переопределить путь через `-localdb` или переменную окружения `QFS_DB_PATH`.
|
Можно переопределить путь через `-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
|
### Локальный config.yaml
|
||||||
|
|
||||||
По умолчанию `qfs` ищет `config.yaml` в той же user-state папке, где лежит `qfs.db` (а не рядом с бинарником).
|
По умолчанию `qfs` ищет `config.yaml` в той же user-state папке, где лежит `qfs.db` (а не рядом с бинарником).
|
||||||
@@ -191,8 +221,23 @@ GET /api/components # Список компонентов
|
|||||||
POST /api/quote/calculate # Расчёт цены
|
POST /api/quote/calculate # Расчёт цены
|
||||||
POST /api/export/xlsx # Экспорт в Excel
|
POST /api/export/xlsx # Экспорт в Excel
|
||||||
GET /api/configs # Сохранённые конфигурации
|
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
|
## 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.
|
QuoteForge now includes automated cron jobs for maintenance tasks. These can be run using the built-in cron functionality in the Docker container.
|
||||||
|
|||||||
127
cmd/qfs/main.go
127
cmd/qfs/main.go
@@ -629,8 +629,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
configs.GET("", func(c *gin.Context) {
|
configs.GET("", func(c *gin.Context) {
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -641,6 +646,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": perPage,
|
"per_page": perPage,
|
||||||
|
"status": status,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -706,7 +712,20 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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) {
|
configs.PATCH("/:uuid/rename", func(c *gin.Context) {
|
||||||
@@ -756,6 +775,110 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, config)
|
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)
|
// Pricing admin (public - RBAC disabled)
|
||||||
|
|||||||
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
|||||||
|
|
||||||
local := &LocalConfiguration{
|
local := &LocalConfiguration{
|
||||||
UUID: cfg.UUID,
|
UUID: cfg.UUID,
|
||||||
|
IsActive: true,
|
||||||
Name: cfg.Name,
|
Name: cfg.Name,
|
||||||
Items: items,
|
Items: items,
|
||||||
TotalPrice: cfg.TotalPrice,
|
TotalPrice: cfg.TotalPrice,
|
||||||
|
|||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
|
uuidpkg "github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
@@ -52,6 +53,7 @@ func New(dbPath string) (*LocalDB, error) {
|
|||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&ConnectionSettings{},
|
&ConnectionSettings{},
|
||||||
&LocalConfiguration{},
|
&LocalConfiguration{},
|
||||||
|
&LocalConfigurationVersion{},
|
||||||
&LocalPricelist{},
|
&LocalPricelist{},
|
||||||
&LocalPricelistItem{},
|
&LocalPricelistItem{},
|
||||||
&LocalComponent{},
|
&LocalComponent{},
|
||||||
@@ -60,6 +62,9 @@ func New(dbPath string) (*LocalDB, error) {
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("migrating sqlite database: %w", err)
|
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)
|
slog.Info("local SQLite database initialized", "path", dbPath)
|
||||||
|
|
||||||
@@ -193,7 +198,59 @@ func (l *LocalDB) GetConfigurationByUUID(uuid string) (*LocalConfiguration, erro
|
|||||||
|
|
||||||
// DeleteConfiguration deletes a configuration by UUID
|
// DeleteConfiguration deletes a configuration by UUID
|
||||||
func (l *LocalDB) DeleteConfiguration(uuid string) error {
|
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,
|
||||||
|
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
|
// 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
|
||||||
|
}
|
||||||
140
internal/localdb/migrations.go
Normal file
140
internal/localdb/migrations.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
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,
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -62,6 +62,8 @@ type LocalConfiguration struct {
|
|||||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||||
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
|
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"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
|
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
|
||||||
TotalPrice *float64 `json:"total_price"`
|
TotalPrice *float64 `json:"total_price"`
|
||||||
@@ -76,12 +78,30 @@ type LocalConfiguration struct {
|
|||||||
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
|
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
|
||||||
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
||||||
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
|
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 {
|
func (LocalConfiguration) TableName() string {
|
||||||
return "local_configurations"
|
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"`
|
||||||
|
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
|
// LocalPricelist stores cached pricelists from server
|
||||||
type LocalPricelist struct {
|
type LocalPricelist struct {
|
||||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
@@ -128,7 +148,7 @@ type PendingChange struct {
|
|||||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
EntityType string `gorm:"not null;index" json:"entity_type"` // "configuration", "project", "specification"
|
EntityType string `gorm:"not null;index" json:"entity_type"` // "configuration", "project", "specification"
|
||||||
EntityUUID string `gorm:"not null;index" json:"entity_uuid"`
|
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
|
Payload string `gorm:"type:text" json:"payload"` // JSON snapshot of the entity
|
||||||
CreatedAt time.Time `gorm:"not null" json:"created_at"`
|
CreatedAt time.Time `gorm:"not null" json:"created_at"`
|
||||||
Attempts int `gorm:"default:0" json:"attempts"` // Retry count for sync
|
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
|
||||||
|
}
|
||||||
@@ -2,12 +2,23 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
"github.com/google/uuid"
|
"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
|
// LocalConfigurationService handles configurations in local-first mode
|
||||||
@@ -64,18 +75,8 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
|||||||
// Convert to local model
|
// Convert to local model
|
||||||
localCfg := localdb.ConfigurationToLocal(cfg)
|
localCfg := localdb.ConfigurationToLocal(cfg)
|
||||||
|
|
||||||
// Save to local SQLite
|
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
|
||||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
return nil, fmt.Errorf("create configuration with version: %w", err)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record usage stats
|
// Record usage stats
|
||||||
@@ -90,6 +91,9 @@ func (s *LocalConfigurationService) GetByUUID(uuid string, ownerUsername string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrConfigNotFound
|
return nil, ErrConfigNotFound
|
||||||
}
|
}
|
||||||
|
if !localCfg.IsActive {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to models.Configuration
|
// Convert to models.Configuration
|
||||||
cfg := localdb.LocalToConfiguration(localCfg)
|
cfg := localdb.LocalToConfiguration(localCfg)
|
||||||
@@ -136,19 +140,9 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
|||||||
localCfg.UpdatedAt = time.Now()
|
localCfg.UpdatedAt = time.Now()
|
||||||
localCfg.SyncStatus = "pending"
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
// Save to local SQLite
|
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("update configuration with version: %w", err)
|
||||||
}
|
|
||||||
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
@@ -165,17 +159,30 @@ func (s *LocalConfigurationService) Delete(uuid string, ownerUsername string) er
|
|||||||
return ErrConfigForbidden
|
return ErrConfigForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete from local SQLite
|
localCfg.IsActive = false
|
||||||
if err := s.localDB.DeleteConfiguration(uuid); err != nil {
|
localCfg.UpdatedAt = time.Now()
|
||||||
|
localCfg.SyncStatus = "pending"
|
||||||
|
_, err = s.saveWithVersionAndPending(localCfg, "deactivate", ownerUsername)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to pending sync queue
|
// Reactivate restores an archived configuration and creates a new version.
|
||||||
if err := s.localDB.AddPendingChange("configuration", uuid, "delete", ""); err != nil {
|
func (s *LocalConfigurationService) Reactivate(uuid string, ownerUsername string) (*models.Configuration, error) {
|
||||||
return err
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
localCfg.IsActive = true
|
||||||
|
localCfg.UpdatedAt = time.Now()
|
||||||
|
localCfg.SyncStatus = "pending"
|
||||||
|
return s.saveWithVersionAndPending(localCfg, "reactivate", ownerUsername)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename renames a configuration
|
// Rename renames a configuration
|
||||||
@@ -193,20 +200,10 @@ func (s *LocalConfigurationService) Rename(uuid string, ownerUsername string, ne
|
|||||||
localCfg.UpdatedAt = time.Now()
|
localCfg.UpdatedAt = time.Now()
|
||||||
localCfg.SyncStatus = "pending"
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to pending sync queue
|
|
||||||
cfg := localdb.LocalToConfiguration(localCfg)
|
|
||||||
payload, err := json.Marshal(cfg)
|
|
||||||
if err != nil {
|
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
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,17 +233,8 @@ func (s *LocalConfigurationService) Clone(configUUID string, ownerUsername strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
localCfg := localdb.ConfigurationToLocal(clone)
|
localCfg := localdb.ConfigurationToLocal(clone)
|
||||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("clone configuration with version: %w", 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return clone, nil
|
return clone, nil
|
||||||
@@ -254,6 +242,10 @@ func (s *LocalConfigurationService) Clone(configUUID string, ownerUsername strin
|
|||||||
|
|
||||||
// ListByUser returns all configurations for a user from local SQLite
|
// ListByUser returns all configurations for a user from local SQLite
|
||||||
func (s *LocalConfigurationService) ListByUser(ownerUsername string, 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
|
// Get all local configurations
|
||||||
localConfigs, err := s.localDB.GetConfigurations()
|
localConfigs, err := s.localDB.GetConfigurations()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -263,6 +255,9 @@ func (s *LocalConfigurationService) ListByUser(ownerUsername string, page, perPa
|
|||||||
// Filter by user
|
// Filter by user
|
||||||
var userConfigs []models.Configuration
|
var userConfigs []models.Configuration
|
||||||
for _, lc := range localConfigs {
|
for _, lc := range localConfigs {
|
||||||
|
if !matchesConfigStatus(lc.IsActive, status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (lc.OriginalUsername == ownerUsername) || lc.IsTemplate {
|
if (lc.OriginalUsername == ownerUsername) || lc.IsTemplate {
|
||||||
userConfigs = append(userConfigs, *localdb.LocalToConfiguration(&lc))
|
userConfigs = append(userConfigs, *localdb.LocalToConfiguration(&lc))
|
||||||
}
|
}
|
||||||
@@ -340,21 +335,10 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
localCfg.UpdatedAt = now
|
localCfg.UpdatedAt = now
|
||||||
localCfg.SyncStatus = "pending"
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
// Save to local SQLite
|
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
|
||||||
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)
|
|
||||||
if err != nil {
|
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
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,6 +348,9 @@ func (s *LocalConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Config
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrConfigNotFound
|
return nil, ErrConfigNotFound
|
||||||
}
|
}
|
||||||
|
if !localCfg.IsActive {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
return localdb.LocalToConfiguration(localCfg), nil
|
return localdb.LocalToConfiguration(localCfg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,28 +383,40 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
|||||||
localCfg.UpdatedAt = time.Now()
|
localCfg.UpdatedAt = time.Now()
|
||||||
localCfg.SyncStatus = "pending"
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := localdb.LocalToConfiguration(localCfg)
|
|
||||||
payload, err := json.Marshal(cfg)
|
|
||||||
if err != nil {
|
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
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteNoAuth deletes configuration without ownership check
|
// DeleteNoAuth deletes configuration without ownership check
|
||||||
func (s *LocalConfigurationService) DeleteNoAuth(uuid string) error {
|
func (s *LocalConfigurationService) DeleteNoAuth(uuid string) error {
|
||||||
if err := s.localDB.DeleteConfiguration(uuid); err != nil {
|
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return ErrConfigNotFound
|
||||||
|
}
|
||||||
|
localCfg.IsActive = false
|
||||||
|
localCfg.UpdatedAt = time.Now()
|
||||||
|
localCfg.SyncStatus = "pending"
|
||||||
|
_, err = s.saveWithVersionAndPending(localCfg, "deactivate", "")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.localDB.AddPendingChange("configuration", uuid, "delete", "")
|
|
||||||
|
// 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
|
// RenameNoAuth renames configuration without ownership check
|
||||||
@@ -431,19 +430,10 @@ func (s *LocalConfigurationService) RenameNoAuth(uuid string, newName string) (*
|
|||||||
localCfg.UpdatedAt = time.Now()
|
localCfg.UpdatedAt = time.Now()
|
||||||
localCfg.SyncStatus = "pending"
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := localdb.LocalToConfiguration(localCfg)
|
|
||||||
payload, err := json.Marshal(cfg)
|
|
||||||
if err != nil {
|
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
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,16 +463,8 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
localCfg := localdb.ConfigurationToLocal(clone)
|
localCfg := localdb.ConfigurationToLocal(clone)
|
||||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("clone configuration without auth with version: %w", 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return clone, nil
|
return clone, nil
|
||||||
@@ -490,14 +472,23 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
|
|||||||
|
|
||||||
// ListAll returns all configurations without user filter
|
// ListAll returns all configurations without user filter
|
||||||
func (s *LocalConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) {
|
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()
|
localConfigs, err := s.localDB.GetConfigurations()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
configs := make([]models.Configuration, len(localConfigs))
|
configs := make([]models.Configuration, len(localConfigs))
|
||||||
for i, lc := range localConfigs {
|
configs = configs[:0]
|
||||||
configs[i] = *localdb.LocalToConfiguration(&lc)
|
for _, lc := range localConfigs {
|
||||||
|
if !matchesConfigStatus(lc.IsActive, status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
configs = append(configs, *localdb.LocalToConfiguration(&lc))
|
||||||
}
|
}
|
||||||
|
|
||||||
total := int64(len(configs))
|
total := int64(len(configs))
|
||||||
@@ -532,6 +523,9 @@ func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.C
|
|||||||
|
|
||||||
var templates []models.Configuration
|
var templates []models.Configuration
|
||||||
for _, lc := range localConfigs {
|
for _, lc := range localConfigs {
|
||||||
|
if !lc.IsActive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if lc.IsTemplate {
|
if lc.IsTemplate {
|
||||||
templates = append(templates, *localdb.LocalToConfiguration(&lc))
|
templates = append(templates, *localdb.LocalToConfiguration(&lc))
|
||||||
}
|
}
|
||||||
@@ -604,21 +598,10 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
localCfg.UpdatedAt = now
|
localCfg.UpdatedAt = now
|
||||||
localCfg.SyncStatus = "pending"
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
// Save to local SQLite
|
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("refresh prices without auth with version: %w", err)
|
||||||
}
|
}
|
||||||
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,6 +610,102 @@ func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult
|
|||||||
return s.syncService.ImportConfigurationsToLocal()
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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 {
|
func (s *LocalConfigurationService) isOwner(cfg *localdb.LocalConfiguration, ownerUsername string) bool {
|
||||||
if cfg == nil || ownerUsername == "" {
|
if cfg == nil || ownerUsername == "" {
|
||||||
return false
|
return false
|
||||||
@@ -636,3 +715,298 @@ func (s *LocalConfigurationService) isOwner(cfg *localdb.LocalConfiguration, own
|
|||||||
}
|
}
|
||||||
return false
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,21 @@ type ConfigImportResult struct {
|
|||||||
Skipped int `json:"skipped"`
|
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.
|
// ImportConfigurationsToLocal imports configurations from MariaDB into local SQLite.
|
||||||
// Existing local configs with pending local changes are skipped to avoid data loss.
|
// Existing local configs with pending local changes are skipped to avoid data loss.
|
||||||
func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
|
func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
|
||||||
@@ -78,6 +93,11 @@ func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
|
|||||||
result.Skipped++
|
result.Skipped++
|
||||||
continue
|
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)
|
localCfg := localdb.ConfigurationToLocal(&cfg)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -432,6 +452,12 @@ func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
|
|||||||
return s.pushConfigurationCreate(change)
|
return s.pushConfigurationCreate(change)
|
||||||
case "update":
|
case "update":
|
||||||
return s.pushConfigurationUpdate(change)
|
return s.pushConfigurationUpdate(change)
|
||||||
|
case "rollback":
|
||||||
|
return s.pushConfigurationRollback(change)
|
||||||
|
case "deactivate":
|
||||||
|
return s.pushConfigurationDeactivate(change)
|
||||||
|
case "reactivate":
|
||||||
|
return s.pushConfigurationReactivate(change)
|
||||||
case "delete":
|
case "delete":
|
||||||
return s.pushConfigurationDelete(change)
|
return s.pushConfigurationDelete(change)
|
||||||
default:
|
default:
|
||||||
@@ -441,9 +467,13 @@ func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
|
|||||||
|
|
||||||
// pushConfigurationCreate creates a configuration on the server
|
// pushConfigurationCreate creates a configuration on the server
|
||||||
func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
||||||
var cfg models.Configuration
|
payload, cfg, isStale, err := s.resolveConfigurationPayloadForPush(change)
|
||||||
if err := json.Unmarshal([]byte(change.Payload), &cfg); err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unmarshaling configuration: %w", err)
|
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
|
// Get database connection
|
||||||
@@ -457,8 +487,16 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
|||||||
|
|
||||||
// Create on server
|
// Create on server
|
||||||
if err := configRepo.Create(&cfg); err != nil {
|
if err := configRepo.Create(&cfg); err != nil {
|
||||||
|
// 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)
|
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
|
// Update local configuration with server ID
|
||||||
localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
|
localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
|
||||||
@@ -469,15 +507,25 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
|||||||
s.localDB.SaveConfiguration(localCfg)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// pushConfigurationUpdate updates a configuration on the server
|
// pushConfigurationUpdate updates a configuration on the server
|
||||||
func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||||
var cfg models.Configuration
|
payload, cfg, isStale, err := s.resolveConfigurationPayloadForPush(change)
|
||||||
if err := json.Unmarshal([]byte(change.Payload), &cfg); err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unmarshaling configuration: %w", err)
|
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
|
// Get database connection
|
||||||
@@ -526,10 +574,149 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
|||||||
s.localDB.SaveConfiguration(localCfg)
|
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
|
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 {
|
||||||
|
// 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
|
// pushConfigurationDelete deletes a configuration from the server
|
||||||
func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
||||||
// Get database connection
|
// Get database connection
|
||||||
@@ -554,6 +741,6 @@ func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
|||||||
return fmt.Errorf("deleting configuration from server: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
76
migrations/006_add_local_configuration_versions.sql
Normal file
76
migrations/006_add_local_configuration_versions.sql
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
-- 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,
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
) AS data,
|
||||||
|
'Initial snapshot backfill (v1)' AS change_note,
|
||||||
|
NULL AS created_by,
|
||||||
|
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;
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h1 class="text-2xl font-bold">Мои конфигурации</h1>
|
<h1 class="text-2xl font-bold">Мои конфигурации</h1>
|
||||||
|
|
||||||
<div class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<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 onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||||
+ Создать новую конфигурацию
|
+ Создать новую конфигурацию
|
||||||
</button>
|
</button>
|
||||||
@@ -13,6 +13,15 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden">
|
||||||
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
|
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
|
||||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -115,11 +124,15 @@
|
|||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let totalPages = 1;
|
let totalPages = 1;
|
||||||
let perPage = 20;
|
let perPage = 20;
|
||||||
|
let configStatusMode = 'active';
|
||||||
|
|
||||||
function renderConfigs(configs) {
|
function renderConfigs(configs) {
|
||||||
|
const emptyText = configStatusMode === 'archived'
|
||||||
|
? 'Архив пуст'
|
||||||
|
: 'Нет сохраненных конфигураций';
|
||||||
if (configs.length === 0) {
|
if (configs.length === 0) {
|
||||||
document.getElementById('configs-list').innerHTML =
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,12 +162,23 @@ function renderConfigs(configs) {
|
|||||||
|
|
||||||
html += '<tr class="hover:bg-gray-50">';
|
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 text-gray-500">' + date + '</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 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">' + 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">' + 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-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">' + total + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
||||||
|
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 += '<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 += '<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 += '<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>';
|
||||||
@@ -165,11 +189,12 @@ function renderConfigs(configs) {
|
|||||||
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 += '<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 += '</svg>';
|
||||||
html += '</button>';
|
html += '</button>';
|
||||||
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="Удалить">';
|
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 += '<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 += '<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 += '</svg>';
|
||||||
html += '</button>';
|
html += '</button>';
|
||||||
|
}
|
||||||
html += '</td></tr>';
|
html += '</td></tr>';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,13 +209,25 @@ function escapeHtml(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteConfig(uuid) {
|
async function deleteConfig(uuid) {
|
||||||
if (!confirm('Удалить?')) return;
|
if (!confirm('Переместить конфигурацию в архив?')) return;
|
||||||
await fetch('/api/configs/' + uuid, {
|
await fetch('/api/configs/' + uuid, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
loadConfigs();
|
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) {
|
function openRenameModal(uuid, currentName) {
|
||||||
document.getElementById('rename-uuid').value = uuid;
|
document.getElementById('rename-uuid').value = uuid;
|
||||||
document.getElementById('rename-input').value = currentName;
|
document.getElementById('rename-input').value = currentName;
|
||||||
@@ -385,18 +422,46 @@ function nextPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updatePagination(total) {
|
function updatePagination(total) {
|
||||||
totalPages = Math.ceil(total / perPage);
|
totalPages = Math.max(1, Math.ceil(total / perPage));
|
||||||
document.getElementById('page-info').textContent =
|
document.getElementById('page-info').textContent =
|
||||||
'Страница ' + currentPage + ' из ' + totalPages + ' (всего: ' + total + ')';
|
'Страница ' + currentPage + ' из ' + totalPages + ' (всего: ' + total + ')';
|
||||||
document.getElementById('btn-prev').disabled = currentPage <= 1;
|
document.getElementById('btn-prev').disabled = currentPage <= 1;
|
||||||
document.getElementById('btn-next').disabled = currentPage >= totalPages;
|
document.getElementById('btn-next').disabled = currentPage >= totalPages;
|
||||||
|
if (total <= perPage) {
|
||||||
|
document.getElementById('pagination').classList.add('hidden');
|
||||||
|
} else {
|
||||||
document.getElementById('pagination').classList.remove('hidden');
|
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
|
// Load configs with pagination
|
||||||
async function loadConfigs() {
|
async function loadConfigs() {
|
||||||
try {
|
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) {
|
if (!resp.ok) {
|
||||||
document.getElementById('configs-list').innerHTML =
|
document.getElementById('configs-list').innerHTML =
|
||||||
@@ -446,6 +511,7 @@ async function importConfigsFromServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
applyStatusModeUI();
|
||||||
loadConfigs();
|
loadConfigs();
|
||||||
|
|
||||||
// Load latest pricelist version for badge
|
// Load latest pricelist version for badge
|
||||||
|
|||||||
Reference in New Issue
Block a user