feat: ревизия до обновления цен + короткие ссылки /:code для опти
- При нажатии «обновить цены» создаётся ревизия текущего состояния («до обновления цен») через новый эндпоинт POST /api/configs/:uuid/snapshot, затем saveConfig создаёт ревизию с новыми ценами - Роут GET /:code → редирект на /projects/:uuid по коду опти (регистронезависимо) - Валидация кода опти: только URL-безопасные символы [A-Za-z0-9._-] (бэкенд + клиентская проверка + подсказка в форме) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -423,6 +423,13 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
}
|
||||
}
|
||||
|
||||
// Capture fingerprint of the current state before any mutations.
|
||||
preRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build pre-refresh fingerprint: %w", err)
|
||||
}
|
||||
preRefreshCfg := *localCfg
|
||||
|
||||
// Update prices for all items from pricelist
|
||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
@@ -462,6 +469,18 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
localCfg.UpdatedAt = now
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
// Before saving the new prices, snapshot the pre-refresh state so the revision
|
||||
// history shows a clear before/after for every price update.
|
||||
postRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build post-refresh fingerprint: %w", err)
|
||||
}
|
||||
if preRefreshFP != postRefreshFP {
|
||||
if err := s.snapshotPreRefreshTx(&preRefreshCfg, ownerUsername); err != nil {
|
||||
return nil, fmt.Errorf("snapshot pre-refresh state: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refresh prices with version: %w", err)
|
||||
@@ -820,6 +839,13 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
|
||||
}
|
||||
}
|
||||
|
||||
// Capture fingerprint of the current state before any mutations.
|
||||
preRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build pre-refresh fingerprint: %w", err)
|
||||
}
|
||||
preRefreshCfg := *localCfg
|
||||
|
||||
// Update prices for all items from pricelist
|
||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
@@ -859,6 +885,18 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
|
||||
localCfg.UpdatedAt = now
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
// Before saving the new prices, snapshot the pre-refresh state so the revision
|
||||
// history shows a clear before/after for every price update.
|
||||
postRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build post-refresh fingerprint: %w", err)
|
||||
}
|
||||
if preRefreshFP != postRefreshFP {
|
||||
if err := s.snapshotPreRefreshTx(&preRefreshCfg, ""); err != nil {
|
||||
return nil, fmt.Errorf("snapshot pre-refresh state: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refresh prices without auth with version: %w", err)
|
||||
@@ -866,6 +904,16 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SnapshotCurrentState creates a revision of the current configuration state without modifying it.
|
||||
// Called before a client-side price refresh so the revision history has a clear before/after.
|
||||
func (s *LocalConfigurationService) SnapshotCurrentState(uuid string) error {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return ErrConfigNotFound
|
||||
}
|
||||
return s.snapshotPreRefreshTx(localCfg, "")
|
||||
}
|
||||
|
||||
// UpdateServerCount updates server count and recalculates total price without creating a new version.
|
||||
func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverCount int) (*models.Configuration, error) {
|
||||
if serverCount < 1 {
|
||||
@@ -1432,12 +1480,25 @@ func (s *LocalConfigurationService) appendVersionTx(
|
||||
localCfg *localdb.LocalConfiguration,
|
||||
operation string,
|
||||
createdBy string,
|
||||
) (*localdb.LocalConfigurationVersion, error) {
|
||||
return s.appendVersionTxNote(tx, localCfg, operation, createdBy, "")
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) appendVersionTxNote(
|
||||
tx *gorm.DB,
|
||||
localCfg *localdb.LocalConfiguration,
|
||||
operation string,
|
||||
createdBy string,
|
||||
noteOverride 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)
|
||||
if noteOverride != "" {
|
||||
changeNote = noteOverride
|
||||
}
|
||||
|
||||
var createdByPtr *string
|
||||
if createdBy != "" {
|
||||
@@ -1478,6 +1539,35 @@ func (s *LocalConfigurationService) appendVersionTx(
|
||||
return nil, fmt.Errorf("%w: exceeded retries for %s", ErrVersionConflict, localCfg.UUID)
|
||||
}
|
||||
|
||||
// snapshotPreRefreshTx creates a revision of the current configuration state before a price
|
||||
// refresh so the history clearly shows what existed before prices were updated.
|
||||
// Called only when prices are about to change (fingerprints differ).
|
||||
func (s *LocalConfigurationService) snapshotPreRefreshTx(localCfg *localdb.LocalConfiguration, createdBy string) error {
|
||||
return 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 row for pre-refresh snapshot: %w", err)
|
||||
}
|
||||
|
||||
version, err := s.appendVersionTxNote(tx, localCfg, "update", createdBy, "до обновления цен")
|
||||
if err != nil {
|
||||
return fmt.Errorf("append pre-refresh 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 for pre-refresh snapshot: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) buildConfigurationSnapshot(localCfg *localdb.LocalConfiguration) (string, error) {
|
||||
return localdb.BuildConfigurationSnapshot(localCfg)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user