dana-medical-site/internal/api/handlers_leads.go
Abdulkadyr Karagishiev 121f286016 feat(crm): integrate website leads and online booking with new CRM and 1C
- Add internal/crm client with CanonicalJSON and HMAC-SHA256 signing for POST /api/public/leads
- Add database migration 0026_crm_deals.sql with crm_deal_id and partial indexes
- Add async lead intake dispatch to new CRM in handlers_leads.go
- Support dual-mode booking confirmation (CRM_MODE=both/crm_only/bitrix_only) in booking_confirm.go
- Add LinkCRM endpoint to bridge_1c.py and onec.Client
- Update config and env examples with CRM_API_URL, CRM_LEAD_INTAKE_SECRET, CRM_MODE
2026-10-16 18:01:49 +03:00

299 lines
9.6 KiB
Go
Raw 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 (
"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,
}
}