- 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
169 lines
5.2 KiB
Go
169 lines
5.2 KiB
Go
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
|
||
}
|