diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index bdc5e13..ec94812 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -894,6 +894,17 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect router.GET("/pricelists/:id", webHandler.PricelistDetail) router.GET("/partnumber-books", webHandler.PartnumberBooks) + // Short project URL: /:code → redirect to /projects/:uuid + router.GET("/:code", func(c *gin.Context) { + code := c.Param("code") + project, err := projectService.GetByCode(code) + if err != nil { + c.Redirect(http.StatusFound, "/projects") + return + } + c.Redirect(http.StatusFound, "/projects/"+project.UUID) + }) + // htmx partials partials := router.Group("/partials") { @@ -1148,6 +1159,15 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect c.JSON(http.StatusOK, config) }) + configs.POST("/:uuid/snapshot", func(c *gin.Context) { + uuid := c.Param("uuid") + if err := configService.SnapshotCurrentState(uuid); err != nil { + respondError(c, http.StatusInternalServerError, "internal server error", err) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + configs.PATCH("/:uuid/project", func(c *gin.Context) { uuid := c.Param("uuid") var req struct { @@ -1517,7 +1537,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect project, err := projectService.Create(dbUsername, &req) if err != nil { switch { - case errors.Is(err, services.ErrReservedMainVariant): + case errors.Is(err, services.ErrReservedMainVariant), + errors.Is(err, services.ErrProjectCodeInvalidChars): respondError(c, http.StatusBadRequest, "invalid request", err) case errors.Is(err, services.ErrProjectCodeExists): respondError(c, http.StatusConflict, "conflict detected", err) @@ -1555,7 +1576,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect if err != nil { switch { case errors.Is(err, services.ErrReservedMainVariant), - errors.Is(err, services.ErrCannotRenameMainVariant): + errors.Is(err, services.ErrCannotRenameMainVariant), + errors.Is(err, services.ErrProjectCodeInvalidChars): respondError(c, http.StatusBadRequest, "invalid request", err) case errors.Is(err, services.ErrProjectCodeExists): respondError(c, http.StatusConflict, "conflict detected", err) diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 2c3bee7..663ce5b 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -692,6 +692,14 @@ func (l *LocalDB) GetProjectByUUID(uuid string) (*LocalProject, error) { return &project, nil } +func (l *LocalDB) GetProjectByCode(code string) (*LocalProject, error) { + var project LocalProject + if err := l.db.Where("LOWER(code) = LOWER(?) AND variant = ''", code).First(&project).Error; err != nil { + return nil, err + } + return &project, nil +} + func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, error) { var project LocalProject if err := l.db.Where("owner_username = ? AND name = ?", ownerUsername, name).First(&project).Error; err != nil { diff --git a/internal/services/local_configuration.go b/internal/services/local_configuration.go index 4f1b102..7cbe966 100644 --- a/internal/services/local_configuration.go +++ b/internal/services/local_configuration.go @@ -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) } diff --git a/internal/services/project.go b/internal/services/project.go index d5f3421..ecf6a86 100644 --- a/internal/services/project.go +++ b/internal/services/project.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/url" + "regexp" "strings" "time" @@ -22,8 +23,12 @@ var ( ErrCannotDeleteMainVariant = errors.New("cannot delete main variant") ErrReservedMainVariant = errors.New("variant name 'main' is reserved") ErrCannotRenameMainVariant = errors.New("cannot rename main variant") + ErrProjectCodeInvalidChars = errors.New("код опти содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)") ) +// projectCodeRe allows only URL-path-safe characters so project codes can appear directly in URLs. +var projectCodeRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + type ProjectService struct { localDB *localdb.LocalDB } @@ -64,6 +69,9 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) if code == "" { return nil, fmt.Errorf("project code is required") } + if !projectCodeRe.MatchString(code) { + return nil, ErrProjectCodeInvalidChars + } variant := strings.TrimSpace(req.Variant) if err := validateProjectVariantName(variant); err != nil { return nil, err @@ -106,6 +114,9 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr if code == "" { return nil, fmt.Errorf("project code is required") } + if !projectCodeRe.MatchString(code) { + return nil, ErrProjectCodeInvalidChars + } localProject.Code = code } if req.Variant != nil { @@ -282,6 +293,15 @@ func (s *ProjectService) GetByUUID(projectUUID, ownerUsername string) (*models.P return localdb.LocalToProject(localProject), nil } +// GetByCode finds the main variant of a project by its code (case-insensitive). +func (s *ProjectService) GetByCode(code string) (*models.Project, error) { + localProject, err := s.localDB.GetProjectByCode(code) + if err != nil { + return nil, ErrProjectNotFound + } + return localdb.LocalToProject(localProject), nil +} + func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status string) (*ProjectConfigurationsResult, error) { project, err := s.GetByUUID(projectUUID, ownerUsername) if err != nil { diff --git a/web/templates/index.html b/web/templates/index.html index 5e9392f..04f7a08 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -2975,6 +2975,15 @@ async function refreshPrices() { } beforeTotal *= serverCount; + // Create a revision of the current state before prices are updated + if (configUUID) { + try { + await fetch('/api/configs/' + configUUID + '/snapshot', { method: 'POST' }); + } catch (e) { + console.warn('pre-refresh snapshot failed', e); + } + } + await saveConfig(false); await refreshPriceLevels({ force: true, noCache: true }); renderTab(); diff --git a/web/templates/projects.html b/web/templates/projects.html index ecd1ea8..17f6615 100644 --- a/web/templates/projects.html +++ b/web/templates/projects.html @@ -39,7 +39,10 @@
Буквы, цифры, дефис, точка, подчёркивание. Код используется в URL.