208 lines
7.1 KiB
Go
208 lines
7.1 KiB
Go
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"log/slog"
|
||
"net/http"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"dana/internal/domain"
|
||
"dana/internal/storage"
|
||
)
|
||
|
||
// В телефоне оставляем только цифры и считаем их: так «+7 (928) 962-00-94»,
|
||
// «8 928 962 00 94» и «79289620094» проходят одинаково.
|
||
var digitsRE = regexp.MustCompile(`\D+`)
|
||
|
||
const maxLeadBody = 64 << 10 // 64 КБ — заявка столько не весит, всё сверху отсекаем
|
||
|
||
func (a *API) forms(w http.ResponseWriter, r *http.Request) {
|
||
list, err := a.store.Forms(r.Context())
|
||
if err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
ok(w, list, map[string]any{"total": len(list)})
|
||
}
|
||
|
||
// fieldRequired сообщает, обязательно ли поле в описании формы.
|
||
func fieldRequired(f *domain.Form, code string) bool {
|
||
if f == nil {
|
||
return false
|
||
}
|
||
for _, fl := range f.Fields {
|
||
if fl.Code == code {
|
||
return fl.Required
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// createLead принимает заявку с сайта: запись на приём, обратный звонок и прочее.
|
||
func (a *API) createLead(w http.ResponseWriter, r *http.Request) {
|
||
// защита от спама: не больше 15 заявок с одного IP за 10 минут
|
||
if a.tooMany(w, "lead:"+clientIP(r), 15, 10*time.Minute) {
|
||
return
|
||
}
|
||
var in domain.LeadInput
|
||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxLeadBody))
|
||
if err := dec.Decode(&in); err != nil {
|
||
fail(w, http.StatusBadRequest, "bad_json", "Не удалось разобрать запрос")
|
||
return
|
||
}
|
||
|
||
in.Name = strings.TrimSpace(in.Name)
|
||
in.Phone = strings.TrimSpace(in.Phone)
|
||
in.Email = strings.TrimSpace(in.Email)
|
||
in.Message = strings.TrimSpace(in.Message)
|
||
|
||
// Не в UTF-8 (например, запрос из консоли Windows) декодер JSON молча превращает
|
||
// кириллицу в «<>», и в базу попадает имя, которое уже не восстановить. Лучше отказать.
|
||
for _, v := range []string{in.Name, in.Phone, in.Email, in.Message} {
|
||
if strings.ContainsRune(v, utf8.RuneError) {
|
||
fail(w, http.StatusBadRequest, "bad_encoding", "Текст заявки пришёл в неверной кодировке — отправьте форму ещё раз")
|
||
return
|
||
}
|
||
}
|
||
|
||
in.UTM = cleanUTM(in.UTM)
|
||
|
||
// Согласие на обработку персональных данных — обязательное условие (152-ФЗ),
|
||
// без него заявку не сохраняем вообще.
|
||
if !in.Consent {
|
||
fail(w, http.StatusBadRequest, "consent_required",
|
||
"Нужно согласие на обработку персональных данных")
|
||
return
|
||
}
|
||
// Телефон нужен всегда: без него с человеком не связаться.
|
||
if len(digitsRE.ReplaceAllString(in.Phone, "")) < 10 {
|
||
fail(w, http.StatusBadRequest, "phone_invalid", "Укажите корректный телефон")
|
||
return
|
||
}
|
||
// не больше 5 заявок в час с одного номера, даже если IP меняются
|
||
if a.tooMany(w, "lead-phone:"+lastDigits(in.Phone, 10), 5, time.Hour) {
|
||
return
|
||
}
|
||
if in.Email != "" && !strings.Contains(in.Email, "@") {
|
||
fail(w, http.StatusBadRequest, "email_invalid", "Укажите корректный e-mail")
|
||
return
|
||
}
|
||
|
||
// Форма необязательна: если код не передан или неизвестен, заявка всё равно
|
||
// сохранится — терять обращение из-за рассинхрона фронта и админки нельзя.
|
||
var form *domain.Form
|
||
if in.FormCode != "" {
|
||
f, err := a.store.FormByCode(r.Context(), in.FormCode)
|
||
switch {
|
||
case err == nil:
|
||
form = f
|
||
case errors.Is(err, storage.ErrFormNotFound):
|
||
slog.Warn("lead with unknown form code", "code", in.FormCode)
|
||
default:
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Какие поля обязательны, решает описание формы: в блоке «Консультация»
|
||
// на главной у обратного звонка есть только телефон.
|
||
if fieldRequired(form, "name") && in.Name == "" {
|
||
fail(w, http.StatusBadRequest, "name_required", "Укажите имя")
|
||
return
|
||
}
|
||
if fieldRequired(form, "message") && in.Message == "" {
|
||
fail(w, http.StatusBadRequest, "message_required", "Заполните текст сообщения")
|
||
return
|
||
}
|
||
if fieldRequired(form, "email") && in.Email == "" {
|
||
fail(w, http.StatusBadRequest, "email_required", "Укажите e-mail")
|
||
return
|
||
}
|
||
|
||
var formID *int64
|
||
successText := ""
|
||
if form != nil {
|
||
formID = &form.ID
|
||
successText = form.SuccessText
|
||
}
|
||
|
||
id, err := a.store.CreateLead(r.Context(), formID, in, clientIP(r), r.UserAgent())
|
||
if err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
|
||
// Отзыв с сайта дополнительно кладём в очередь модерации — иначе он остался бы
|
||
// только среди заявок и на карточку врача не попал бы никогда.
|
||
if form != nil && form.Code == "review" {
|
||
if err := a.store.CreateSiteReview(r.Context(), in.Name, in.Message,
|
||
extraRating(in.Extra), in.DoctorID); err != nil {
|
||
// Заявка уже сохранена, поэтому ответ не роняем — только пишем в лог.
|
||
slog.Error("create review from lead", "lead_id", id, "err", err)
|
||
}
|
||
}
|
||
|
||
a.notifyLead(id)
|
||
|
||
if successText == "" {
|
||
successText = "Спасибо! Мы свяжемся с вами в ближайшее время."
|
||
}
|
||
writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{
|
||
"id": id,
|
||
"message": successText,
|
||
}})
|
||
}
|
||
|
||
// extraRating достаёт оценку 1–5 из дополнительных полей формы отзыва.
|
||
func extraRating(extra map[string]any) *int {
|
||
v, found := extra["rating"]
|
||
if !found {
|
||
return nil
|
||
}
|
||
var n int
|
||
switch x := v.(type) {
|
||
case float64:
|
||
n = int(x)
|
||
case string:
|
||
parsed, err := strconv.Atoi(strings.TrimSpace(x))
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
n = parsed
|
||
default:
|
||
return nil
|
||
}
|
||
if n < 1 || n > 5 {
|
||
return nil
|
||
}
|
||
return &n
|
||
}
|
||
|
||
// utmKeys — что сохраняем об источнике заявки: UTM-метки и внешний сайт, с которого пришли.
|
||
var utmKeys = map[string]bool{
|
||
"utm_source": true, "utm_medium": true, "utm_campaign": true,
|
||
"utm_content": true, "utm_term": true, "referrer": true,
|
||
}
|
||
|
||
// cleanUTM оставляет только известные поля и обрезает длинные значения:
|
||
// это поле приходит из браузера и не должно разрастаться произвольными ключами.
|
||
func cleanUTM(in map[string]string) map[string]string {
|
||
out := map[string]string{}
|
||
for k, v := range in {
|
||
v = strings.TrimSpace(v)
|
||
if !utmKeys[k] || v == "" || strings.ContainsRune(v, utf8.RuneError) {
|
||
continue
|
||
}
|
||
if r := []rune(v); len(r) > 200 {
|
||
v = string(r[:200])
|
||
}
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|