206 lines
6.9 KiB
Go
206 lines
6.9 KiB
Go
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)
|
||
}
|