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:
Mikhail Chusavitin
2026-06-23 11:29:40 +03:00
parent 7cc101d24d
commit 6d4a37df8b
6 changed files with 163 additions and 2 deletions

View File

@@ -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 {