dana-medical-site/internal/api/admin_promos.go
2026-10-14 13:33:13 +00:00

206 lines
6.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"encoding/json"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"dana/internal/storage"
)
// Адрес: латиница, цифры, дефис и подчёркивание. Хвостовой дефис и «_» встречаются в адресах
// со старого сайта («…v-klinike-dana-», «lech_bol») — их нужно сохранять, иначе запись не сохранить.
var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
var translit = map[rune]string{
'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "e", 'ж': "zh", 'з': "z",
'и': "i", 'й': "y", 'к': "k", 'л': "l", 'м': "m", 'н': "n", 'о': "o", 'п': "p", 'р': "r",
'с': "s", 'т': "t", 'у': "u", 'ф': "f", 'х': "kh", 'ц': "ts", 'ч': "ch", 'ш': "sh", 'щ': "shch",
'ъ': "", 'ы': "y", 'ь': "", 'э': "e", 'ю': "yu", 'я': "ya",
}
// slugFromTitle делает адрес из названия: «Я в сад» → «ya-v-sad».
func slugFromTitle(title string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(title) {
switch {
case r >= 'a' && r <= 'z' || r >= '0' && r <= '9':
b.WriteRune(r)
dash = false
case translit[r] != "" || r == 'ъ' || r == 'ь':
b.WriteString(translit[r])
dash = false
default:
if !dash && b.Len() > 0 {
b.WriteByte('-')
dash = true
}
}
}
s := strings.Trim(b.String(), "-")
if len(s) > 80 {
s = strings.Trim(s[:80], "-")
}
return s
}
// resolveSlug берёт адрес из формы или делает его из названия и проверяет формат.
func resolveSlug(w http.ResponseWriter, raw, title string) (string, bool) {
slug := strings.ToLower(strings.TrimSpace(raw))
if slug == "" {
slug = slugFromTitle(title)
}
if !slugRE.MatchString(slug) || len(slug) > 100 {
fail(w, http.StatusUnprocessableEntity, "validation",
"Адрес — латинские буквы, цифры и дефисы, например ya-v-sad")
return "", false
}
return slug, true
}
func parseDate(raw *string) (*time.Time, error) {
if raw == nil || strings.TrimSpace(*raw) == "" {
return nil, nil
}
t, err := time.Parse("2006-01-02", strings.TrimSpace(*raw))
if err != nil {
return nil, err
}
return &t, nil
}
type promoRequest struct {
Slug string `json:"slug"`
Title string `json:"title"`
Lead string `json:"lead"`
ImageID *int64 `json:"image_id"`
DiscountPercent *int `json:"discount_percent"`
Terms string `json:"terms"`
CheckupID *int64 `json:"checkup_id"`
StartsAt *string `json:"starts_at"`
EndsAt *string `json:"ends_at"`
Published bool `json:"published"`
SEOTitle string `json:"seo_title"`
SEODesc string `json:"seo_description"`
SEOH1 string `json:"seo_h1"`
}
func decodePromo(w http.ResponseWriter, r *http.Request) (storage.PromoInput, bool) {
var req promoRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
return storage.PromoInput{}, false
}
in := storage.PromoInput{
Title: strings.TrimSpace(req.Title),
Lead: strings.TrimSpace(req.Lead),
ImageID: req.ImageID,
DiscountPercent: req.DiscountPercent,
Terms: strings.TrimSpace(req.Terms),
CheckupID: req.CheckupID,
Published: req.Published,
SEOTitle: strings.TrimSpace(req.SEOTitle),
SEODesc: strings.TrimSpace(req.SEODesc),
SEOH1: strings.TrimSpace(req.SEOH1),
}
if in.Title == "" {
fail(w, http.StatusUnprocessableEntity, "validation", "Укажите название акции")
return in, false
}
slug, valid := resolveSlug(w, req.Slug, in.Title)
if !valid {
return in, false
}
in.Slug = slug
if in.DiscountPercent != nil && (*in.DiscountPercent < 1 || *in.DiscountPercent > 99) {
fail(w, http.StatusUnprocessableEntity, "validation", "Скидка — от 1 до 99 %")
return in, false
}
var err error
if in.StartsAt, err = parseDate(req.StartsAt); err != nil {
fail(w, http.StatusUnprocessableEntity, "validation", "Дата начала в формате ГГГГ-ММ-ДД")
return in, false
}
if in.EndsAt, err = parseDate(req.EndsAt); err != nil {
fail(w, http.StatusUnprocessableEntity, "validation", "Дата окончания в формате ГГГГ-ММ-ДД")
return in, false
}
if in.StartsAt != nil && in.EndsAt != nil && in.EndsAt.Before(*in.StartsAt) {
fail(w, http.StatusUnprocessableEntity, "validation", "Окончание акции не может быть раньше начала")
return in, false
}
// Закон «О рекламе» (ст. 9): рекламируя скидку, нужно сообщить её условия и срок.
if in.Published && in.Terms == "" {
fail(w, http.StatusUnprocessableEntity, "validation",
"Чтобы показать акцию на сайте, заполните её условия")
return in, false
}
if in.Published && in.DiscountPercent != nil && in.EndsAt == nil {
fail(w, http.StatusUnprocessableEntity, "validation",
"У акции со скидкой укажите последний день действия")
return in, false
}
return in, true
}
func (a *API) adminPromos(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
list, err := a.store.AdminPromos(r.Context())
if err != nil {
handleErr(w, err)
return
}
ok(w, list, map[string]any{"total": len(list)})
}
func (a *API) adminCreatePromo(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
in, valid := decodePromo(w, r)
if !valid {
return
}
id, err := a.store.AdminCreatePromo(r.Context(), in)
if err != nil {
handleErr(w, err)
return
}
a.store.Audit(r.Context(), u.ID, "create", "promo", id, clientIP(r))
writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id, "slug": in.Slug}})
}
func (a *API) adminUpdatePromo(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
return
}
in, valid := decodePromo(w, r)
if !valid {
return
}
if err := a.store.AdminUpdatePromo(r.Context(), id, in); err != nil {
handleErr(w, err)
return
}
a.store.Audit(r.Context(), u.ID, "update", "promo", id, clientIP(r))
ok(w, map[string]any{"id": id, "slug": in.Slug}, nil)
}
func (a *API) adminDeletePromo(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
return
}
if err := a.store.AdminDeletePromo(r.Context(), id); err != nil {
handleErr(w, err)
return
}
a.store.Audit(r.Context(), u.ID, "delete", "promo", id, clientIP(r))
ok(w, map[string]any{"id": id}, nil)
}