package api import ( "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "net/url" "regexp" "strconv" "strings" "time" "unicode/utf8" "dana/internal/crm" "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) a.sendLeadToCRM(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 } // sendLeadToCRM отправляет заявку с сайта в новую CRM в фоновом режиме. func (a *API) sendLeadToCRM(id int64) { if !a.crmEnabled() { return } go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() it, err := a.store.LeadForCRM(ctx, id) if err != nil { slog.Error("crm lead fetch", "lead_id", id, "err", err) return } draft := leadItemToCRMDraft(it) dealID, err := a.crm.CreateLead(ctx, draft, "") if err != nil { slog.Error("crm lead create failed", "lead_id", id, "err", err) _ = a.store.SetLeadCrmError(ctx, id, err.Error()) return } if err := a.store.SetLeadCrmDeal(ctx, id, dealID); err != nil { slog.Error("crm save lead deal id", "lead_id", id, "deal_id", dealID, "err", err) return } slog.Info("crm lead created", "lead_id", id, "deal_id", dealID) }() } func leadItemToCRMDraft(it storage.LeadCRMItem) crm.LeadDraft { phone := it.Phone if n := normalizeRuPhone(it.Phone); n != "" { phone = "+" + n } var commentLines []string if it.FormTitle != "" { commentLines = append(commentLines, "Форма: "+it.FormTitle) } if it.Service != "" { commentLines = append(commentLines, "Услуга: "+it.Service) } if it.Doctor != "" { commentLines = append(commentLines, "Врач: "+it.Doctor) } if it.Branch != "" { commentLines = append(commentLines, "Филиал: "+it.Branch) } if it.Message != "" { commentLines = append(commentLines, "Сообщение: "+it.Message) } sourceLabel := "Заявка с сайта" if it.FormTitle != "" { sourceLabel = "Сайт: " + it.FormTitle } pagePath := "" if it.PageURL != "" { if u, err := url.Parse(it.PageURL); err == nil && u.Path != "" { pagePath = u.Path } } return crm.LeadDraft{ LocalLeadId: fmt.Sprintf("site_lead_%d", it.ID), Name: it.Name, Phone: phone, Email: it.Email, Comment: strings.Join(commentLines, "\n"), SourceKey: "site", SourceLabel: sourceLabel, PagePath: pagePath, PageUrl: it.PageURL, Referer: it.UTM["referrer"], UtmSource: it.UTM["utm_source"], UtmMedium: it.UTM["utm_medium"], UtmCampaign: it.UTM["utm_campaign"], UtmTerm: it.UTM["utm_term"], UtmContent: it.UTM["utm_content"], AttributionTimestamp: &it.CreatedAt, } }