feat: /:code/:variant URL для вариантов опти + валидация имени варианта
- Роут GET /:code/:variant → редирект на /projects/:uuid (case-insensitive) - Валидация имени варианта: только URL-безопасные символы [A-Za-z0-9._-] (бэкенд validateProjectVariantName + клиентская проверка в обеих формах) - Подсказки в UI: «Используется в URL: /КОД/Вариант» Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -894,7 +894,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
||||||
router.GET("/partnumber-books", webHandler.PartnumberBooks)
|
router.GET("/partnumber-books", webHandler.PartnumberBooks)
|
||||||
|
|
||||||
// Short project URL: /:code → redirect to /projects/:uuid
|
// Short project URLs: /:code → main variant, /:code/:variant → named variant
|
||||||
router.GET("/:code", func(c *gin.Context) {
|
router.GET("/:code", func(c *gin.Context) {
|
||||||
code := c.Param("code")
|
code := c.Param("code")
|
||||||
project, err := projectService.GetByCode(code)
|
project, err := projectService.GetByCode(code)
|
||||||
@@ -904,6 +904,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
}
|
}
|
||||||
c.Redirect(http.StatusFound, "/projects/"+project.UUID)
|
c.Redirect(http.StatusFound, "/projects/"+project.UUID)
|
||||||
})
|
})
|
||||||
|
router.GET("/:code/:variant", func(c *gin.Context) {
|
||||||
|
code := c.Param("code")
|
||||||
|
variant := c.Param("variant")
|
||||||
|
project, err := projectService.GetByCodeAndVariant(code, variant)
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/projects")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, "/projects/"+project.UUID)
|
||||||
|
})
|
||||||
|
|
||||||
// htmx partials
|
// htmx partials
|
||||||
partials := router.Group("/partials")
|
partials := router.Group("/partials")
|
||||||
@@ -1538,7 +1548,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrReservedMainVariant),
|
case errors.Is(err, services.ErrReservedMainVariant),
|
||||||
errors.Is(err, services.ErrProjectCodeInvalidChars):
|
errors.Is(err, services.ErrProjectCodeInvalidChars),
|
||||||
|
errors.Is(err, services.ErrProjectVariantInvalidChars):
|
||||||
respondError(c, http.StatusBadRequest, "invalid request", err)
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
case errors.Is(err, services.ErrProjectCodeExists):
|
case errors.Is(err, services.ErrProjectCodeExists):
|
||||||
respondError(c, http.StatusConflict, "conflict detected", err)
|
respondError(c, http.StatusConflict, "conflict detected", err)
|
||||||
@@ -1577,7 +1588,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrReservedMainVariant),
|
case errors.Is(err, services.ErrReservedMainVariant),
|
||||||
errors.Is(err, services.ErrCannotRenameMainVariant),
|
errors.Is(err, services.ErrCannotRenameMainVariant),
|
||||||
errors.Is(err, services.ErrProjectCodeInvalidChars):
|
errors.Is(err, services.ErrProjectCodeInvalidChars),
|
||||||
|
errors.Is(err, services.ErrProjectVariantInvalidChars):
|
||||||
respondError(c, http.StatusBadRequest, "invalid request", err)
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
case errors.Is(err, services.ErrProjectCodeExists):
|
case errors.Is(err, services.ErrProjectCodeExists):
|
||||||
respondError(c, http.StatusConflict, "conflict detected", err)
|
respondError(c, http.StatusConflict, "conflict detected", err)
|
||||||
|
|||||||
@@ -700,6 +700,14 @@ func (l *LocalDB) GetProjectByCode(code string) (*LocalProject, error) {
|
|||||||
return &project, nil
|
return &project, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *LocalDB) GetProjectByCodeAndVariant(code, variant string) (*LocalProject, error) {
|
||||||
|
var project LocalProject
|
||||||
|
if err := l.db.Where("LOWER(code) = LOWER(?) AND LOWER(variant) = LOWER(?)", code, variant).First(&project).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &project, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, error) {
|
func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, error) {
|
||||||
var project LocalProject
|
var project LocalProject
|
||||||
if err := l.db.Where("owner_username = ? AND name = ?", ownerUsername, name).First(&project).Error; err != nil {
|
if err := l.db.Where("owner_username = ? AND name = ?", ownerUsername, name).First(&project).Error; err != nil {
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrProjectNotFound = errors.New("project not found")
|
ErrProjectNotFound = errors.New("project not found")
|
||||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||||
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||||
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
||||||
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
||||||
ErrProjectCodeInvalidChars = errors.New("код опти содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
|
ErrProjectCodeInvalidChars = errors.New("код опти содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
|
||||||
|
ErrProjectVariantInvalidChars = errors.New("имя варианта содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
|
||||||
)
|
)
|
||||||
|
|
||||||
// projectCodeRe allows only URL-path-safe characters so project codes can appear directly in URLs.
|
// projectCodeRe allows only URL-path-safe characters so project codes can appear directly in URLs.
|
||||||
@@ -194,6 +195,9 @@ func validateProjectVariantName(variant string) error {
|
|||||||
if normalizeProjectVariant(variant) == "main" {
|
if normalizeProjectVariant(variant) == "main" {
|
||||||
return ErrReservedMainVariant
|
return ErrReservedMainVariant
|
||||||
}
|
}
|
||||||
|
if variant != "" && !projectCodeRe.MatchString(variant) {
|
||||||
|
return ErrProjectVariantInvalidChars
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +306,15 @@ func (s *ProjectService) GetByCode(code string) (*models.Project, error) {
|
|||||||
return localdb.LocalToProject(localProject), nil
|
return localdb.LocalToProject(localProject), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetByCodeAndVariant finds a project by code + variant (both case-insensitive).
|
||||||
|
func (s *ProjectService) GetByCodeAndVariant(code, variant string) (*models.Project, error) {
|
||||||
|
localProject, err := s.localDB.GetProjectByCodeAndVariant(code, variant)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
return localdb.LocalToProject(localProject), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status string) (*ProjectConfigurationsResult, error) {
|
func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status string) (*ProjectConfigurationsResult, error) {
|
||||||
project, err := s.GetByUUID(projectUUID, ownerUsername)
|
project, err := s.GetByUUID(projectUUID, ownerUsername)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -207,9 +207,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="new-variant-value" class="block text-sm font-medium text-gray-700 mb-1">Вариант</label>
|
<label for="new-variant-value" class="block text-sm font-medium text-gray-700 mb-1">Вариант</label>
|
||||||
<input id="new-variant-value" type="text" placeholder="Например: Lenovo"
|
<input id="new-variant-value" type="text" placeholder="Например: B200"
|
||||||
|
pattern="[A-Za-z0-9._-]+"
|
||||||
|
title="Только буквы, цифры, дефис, точка, подчёркивание"
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
<div class="text-xs text-gray-500 mt-1">Оставьте пустым для main нельзя — нужно уникальное значение.</div>
|
<div class="text-xs text-gray-500 mt-1">Буквы, цифры, дефис, точка, подчёркивание. Используется в URL: /КОД/Вариант.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6 flex justify-end gap-2">
|
<div class="mt-6 flex justify-end gap-2">
|
||||||
@@ -842,6 +844,10 @@ async function createNewVariant() {
|
|||||||
showToast('Укажите вариант', 'error');
|
showToast('Укажите вариант', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!/^[A-Za-z0-9._-]+$/.test(variant)) {
|
||||||
|
showToast('Имя варианта содержит недопустимые символы. Разрешены: буквы, цифры, дефис, точка, подчёркивание.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
code: code,
|
code: code,
|
||||||
variant: variant,
|
variant: variant,
|
||||||
|
|||||||
@@ -46,8 +46,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="create-project-variant" class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
|
<label for="create-project-variant" class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
|
||||||
<input id="create-project-variant" type="text" placeholder="Например: Lenovo"
|
<input id="create-project-variant" type="text" placeholder="Например: B200"
|
||||||
|
pattern="[A-Za-z0-9._-]*"
|
||||||
|
title="Только буквы, цифры, дефис, точка, подчёркивание"
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Используется в URL: /КОД/Вариант</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label>
|
<label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label>
|
||||||
@@ -403,6 +406,10 @@ async function createProject() {
|
|||||||
alert('Код проекта содержит недопустимые символы.\nРазрешены: буквы, цифры, дефис, точка, подчёркивание.');
|
alert('Код проекта содержит недопустимые символы.\nРазрешены: буквы, цифры, дефис, точка, подчёркивание.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (variant && !/^[A-Za-z0-9._-]+$/.test(variant)) {
|
||||||
|
alert('Имя варианта содержит недопустимые символы.\nРазрешены: буквы, цифры, дефис, точка, подчёркивание.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const resp = await fetch('/api/projects', {
|
const resp = await fetch('/api/projects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
|||||||
Reference in New Issue
Block a user