dana-medical-site/internal/storage/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

169 lines
5.2 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 storage
import (
"context"
"encoding/json"
"errors"
"net/netip"
"time"
"dana/internal/domain"
)
// ErrFormNotFound — формы с таким кодом нет или она выключена.
var ErrFormNotFound = errors.New("form not found")
// FormByCode отдаёт описание формы вместе с полями — фронт по нему строит форму,
// а сервер по нему же проверяет присланное.
func (s *Storage) FormByCode(ctx context.Context, code string) (*domain.Form, error) {
var f domain.Form
err := s.pool.QueryRow(ctx, `
SELECT id, code, title, success_text
FROM forms WHERE code = $1 AND published`, code).
Scan(&f.ID, &f.Code, &f.Title, &f.SuccessText)
if errors.Is(wrapNotFound(err), ErrNotFound) {
return nil, ErrFormNotFound
}
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `
SELECT code, label, type, required, options
FROM form_fields WHERE form_id = $1 ORDER BY sort, id`, f.ID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var ff domain.FormField
if err := rows.Scan(&ff.Code, &ff.Label, &ff.Type, &ff.Required, &ff.Options); err != nil {
return nil, err
}
f.Fields = append(f.Fields, ff)
}
return &f, rows.Err()
}
// Forms отдаёт все опубликованные формы (для админки и для фронта разом).
func (s *Storage) Forms(ctx context.Context) ([]domain.Form, error) {
rows, err := s.pool.Query(ctx, `
SELECT code FROM forms WHERE published ORDER BY id`)
if err != nil {
return nil, err
}
var codes []string
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
rows.Close()
return nil, err
}
codes = append(codes, c)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]domain.Form, 0, len(codes))
for _, c := range codes {
f, err := s.FormByCode(ctx, c)
if err != nil {
return nil, err
}
out = append(out, *f)
}
return out, nil
}
// CreateLead сохраняет заявку с сайта. Согласие на обработку персональных данных
// фиксируется временем записи — без него заявку принимать нельзя (152-ФЗ).
func (s *Storage) CreateLead(ctx context.Context, formID *int64, in domain.LeadInput, ip, userAgent string) (int64, error) {
extra, err := json.Marshal(in.Extra)
if err != nil {
return 0, err
}
utm, err := json.Marshal(in.UTM)
if err != nil {
return 0, err
}
// inet не принимает пустую строку и мусор — кладём NULL, если адрес не разобрался.
var addr any
if parsed, err := netip.ParseAddr(ip); err == nil {
addr = parsed.String()
}
var id int64
err = s.pool.QueryRow(ctx, `
INSERT INTO leads (form_id, name, phone, email, service_id, doctor_id, branch_id,
message, payload, page_url, utm, ip, user_agent, consent_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13, now())
RETURNING id`,
formID, in.Name, in.Phone, in.Email, in.ServiceID, in.DoctorID, in.BranchID,
in.Message, extra, in.PageURL, utm, addr, userAgent).Scan(&id)
return id, err
}
// LeadCRMItem — данные заявки для передачи в CRM.
type LeadCRMItem struct {
ID int64
FormCode string
FormTitle string
Name string
Phone string
Email string
Service string
Doctor string
Branch string
Message string
PageURL string
UTM map[string]string
CreatedAt time.Time
}
// LeadForCRM загружает полную информацию о заявке для передачи в новую CRM.
func (s *Storage) LeadForCRM(ctx context.Context, id int64) (LeadCRMItem, error) {
var (
item LeadCRMItem
utmRaw []byte
)
err := s.pool.QueryRow(ctx, `
SELECT l.id,
COALESCE(f.code, ''), COALESCE(f.title, ''),
l.name, l.phone, l.email,
COALESCE(sv.name, ''),
concat_ws(' ', d.last_name, d.first_name, NULLIF(d.middle_name, '')),
COALESCE(b.address, ''),
l.message, l.page_url, l.utm, l.created_at
FROM leads l
LEFT JOIN forms f ON f.id = l.form_id
LEFT JOIN services sv ON sv.id = l.service_id
LEFT JOIN doctors d ON d.id = l.doctor_id
LEFT JOIN branches b ON b.id = l.branch_id
WHERE l.id = $1`, id).
Scan(&item.ID, &item.FormCode, &item.FormTitle,
&item.Name, &item.Phone, &item.Email,
&item.Service, &item.Doctor, &item.Branch,
&item.Message, &item.PageURL, &utmRaw, &item.CreatedAt)
if err != nil {
return item, wrapNotFound(err)
}
_ = json.Unmarshal(utmRaw, &item.UTM)
return item, nil
}
// SetLeadCrmDeal привязывает ID сделки новой CRM к заявке.
func (s *Storage) SetLeadCrmDeal(ctx context.Context, id int64, dealID string) error {
_, err := s.pool.Exec(ctx, `UPDATE leads SET crm_deal_id = $2, crm_error = '', crm_sent_at = now()
WHERE id = $1`, id, dealID)
return err
}
// SetLeadCrmError сохраняет ошибку отправки заявки в новую CRM.
func (s *Storage) SetLeadCrmError(ctx context.Context, id int64, text string) error {
_, err := s.pool.Exec(ctx, `UPDATE leads SET crm_error = $2 WHERE id = $1`, id, text)
return err
}