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) }