Profile: редактируемый профиль при verification="0", сохранение через N8N_PROFILE_UPDATE_WEBHOOK

- Backend: config.py — добавлена настройка n8n_profile_update_webhook (читает N8N_PROFILE_UPDATE_WEBHOOK из .env).
- Backend: profile.py — общий хелпер _resolve_profile_identity(), обновлён _fetch_contact(), новый эндпоинт POST /api/v1/profile/contact/update, который отправляет данные профиля в N8N_PROFILE_UPDATE_WEBHOOK.
- Frontend: Profile.tsx — если verification === "0", показывается форма редактирования (все поля, кроме телефона, обязательны к заполнению, телефон только для чтения) и сохранение вызывает /api/v1/profile/contact/update; иначе профиль остаётся только для просмотра.
This commit is contained in:
Fedor
2026-02-27 08:34:27 +03:00
parent 62fc57f108
commit 9c65b6a4ea
4 changed files with 272 additions and 62 deletions

View File

@@ -0,0 +1,5 @@
Profile: редактируемый профиль при verification="0", сохранение через N8N_PROFILE_UPDATE_WEBHOOK
- Backend: config.py — добавлена настройка n8n_profile_update_webhook (читает N8N_PROFILE_UPDATE_WEBHOOK из .env).
- Backend: profile.py — общий хелпер _resolve_profile_identity(), обновлён _fetch_contact(), новый эндпоинт POST /api/v1/profile/contact/update, который отправляет данные профиля в N8N_PROFILE_UPDATE_WEBHOOK.
- Frontend: Profile.tsx — если verification === "0", показывается форма редактирования (все поля, кроме телефона, обязательны к заполнению, телефон только для чтения) и сохранение вызывает /api/v1/profile/contact/update; иначе профиль остаётся только для просмотра.

View File

@@ -35,7 +35,7 @@ unified_id берётся из сессии по session_token или перед
""" """
import logging import logging
from typing import Optional from typing import Optional, Tuple
import httpx import httpx
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
@@ -48,6 +48,59 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/profile", tags=["profile"]) router = APIRouter(prefix="/api/v1/profile", tags=["profile"])
async def _resolve_profile_identity(
session_token: Optional[str] = None,
unified_id: Optional[str] = None,
channel: Optional[str] = None,
channel_user_id: Optional[str] = None,
entry_channel: Optional[str] = None,
chat_id: Optional[str] = None,
) -> Tuple[str, Optional[str], Optional[str], Optional[str]]:
"""Возвращает (unified_id, contact_id, phone, chat_id). При ошибке — HTTPException(401/400)."""
contact_id: Optional[str] = None
phone: Optional[str] = None
if not unified_id and channel and channel_user_id:
try:
from app.api.session import get_session_by_channel_user
session_data = await get_session_by_channel_user(channel.strip(), str(channel_user_id).strip())
if session_data:
unified_id = session_data.get("unified_id")
contact_id = session_data.get("contact_id")
phone = session_data.get("phone")
if chat_id is None:
chat_id = session_data.get("chat_id")
except Exception as e:
logger.warning("Ошибка чтения сессии по channel: %s", e)
if not unified_id:
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
if not unified_id and session_token:
try:
from app.api.session import SessionVerifyRequest, verify_session
verify_res = await verify_session(SessionVerifyRequest(session_token=session_token))
if getattr(verify_res, "valid", False):
unified_id = getattr(verify_res, "unified_id", None)
contact_id = getattr(verify_res, "contact_id", None)
phone = getattr(verify_res, "phone", None)
if chat_id is None:
chat_id = getattr(verify_res, "chat_id", None)
if not unified_id:
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
except HTTPException:
raise
except Exception as e:
logger.warning("Ошибка верификации сессии для профиля: %s", e)
raise HTTPException(status_code=401, detail="Сессия недействительна")
if not unified_id:
raise HTTPException(
status_code=400,
detail="Укажите session_token, (channel + channel_user_id) или unified_id",
)
return unified_id, contact_id, phone, chat_id
class ProfileContactRequest(BaseModel): class ProfileContactRequest(BaseModel):
"""Запрос контактных данных: session_token, (channel + channel_user_id) или unified_id.""" """Запрос контактных данных: session_token, (channel + channel_user_id) или unified_id."""
session_token: Optional[str] = Field(None, description="Токен сессии (unified_id подставится из Redis)") session_token: Optional[str] = Field(None, description="Токен сессии (unified_id подставится из Redis)")
@@ -58,6 +111,23 @@ class ProfileContactRequest(BaseModel):
chat_id: Optional[str] = Field(None, description="Telegram user id или Max user id (для передачи в n8n)") chat_id: Optional[str] = Field(None, description="Telegram user id или Max user id (для передачи в n8n)")
class ProfileContactUpdateRequest(BaseModel):
"""Обновление контакта: session_token обязателен; остальные поля — редактируемые (все обязательны на фронте, кроме phone)."""
session_token: str = Field(..., description="Токен сессии")
entry_channel: Optional[str] = Field("web", description="Канал входа: telegram | max | web")
last_name: str = Field("", description="Фамилия")
first_name: str = Field("", description="Имя")
middle_name: str = Field("", description="Отчество")
birth_date: str = Field("", description="Дата рождения")
birth_place: str = Field("", description="Место рождения")
inn: str = Field("", description="ИНН")
email: str = Field("", description="Email")
registration_address: str = Field("", description="Адрес регистрации")
mailing_address: str = Field("", description="Почтовый адрес")
bank_for_compensation: str = Field("", description="Банк для возмещения")
phone: Optional[str] = Field(None, description="Телефон (read-only на фронте, передаётся в n8n)")
@router.get("/contact") @router.get("/contact")
async def get_profile_contact( async def get_profile_contact(
session_token: Optional[str] = Query(None, description="Токен сессии"), session_token: Optional[str] = Query(None, description="Токен сессии"),
@@ -109,51 +179,15 @@ async def _fetch_contact(
detail="N8N_CONTACT_WEBHOOK не настроен", detail="N8N_CONTACT_WEBHOOK не настроен",
) )
contact_id: Optional[str] = None unified_id, contact_id, phone, chat_id = await _resolve_profile_identity(
phone: Optional[str] = None session_token=session_token,
unified_id=unified_id,
channel=channel,
channel_user_id=channel_user_id,
entry_channel=entry_channel,
chat_id=chat_id,
)
# Сессия по channel + channel_user_id (универсальный auth пишет в Redis по этому ключу)
if not unified_id and channel and channel_user_id:
try:
from app.api.session import get_session_by_channel_user
session_data = await get_session_by_channel_user(channel.strip(), str(channel_user_id).strip())
if session_data:
unified_id = session_data.get("unified_id")
contact_id = session_data.get("contact_id")
phone = session_data.get("phone")
if chat_id is None:
chat_id = session_data.get("chat_id")
except Exception as e:
logger.warning("Ошибка чтения сессии по channel: %s", e)
if not unified_id:
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
# Сессия по session_token
if not unified_id and session_token:
try:
from app.api.session import SessionVerifyRequest, verify_session
verify_res = await verify_session(SessionVerifyRequest(session_token=session_token))
if getattr(verify_res, "valid", False):
unified_id = getattr(verify_res, "unified_id", None)
contact_id = getattr(verify_res, "contact_id", None)
phone = getattr(verify_res, "phone", None)
if chat_id is None:
chat_id = getattr(verify_res, "chat_id", None)
if not unified_id:
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
except HTTPException:
raise
except Exception as e:
logger.warning("Ошибка верификации сессии для профиля: %s", e)
raise HTTPException(status_code=401, detail="Сессия недействительна")
if not unified_id:
raise HTTPException(
status_code=400,
detail="Укажите session_token, (channel + channel_user_id) или unified_id",
)
# В хук уходит всё, что нужно для обработки в n8n: канал, unified_id, chat_id, contact_id, phone, session_token
payload: dict = { payload: dict = {
"unified_id": unified_id, "unified_id": unified_id,
"entry_channel": (entry_channel or "web").strip() or "web", "entry_channel": (entry_channel or "web").strip() or "web",
@@ -190,7 +224,6 @@ async def _fetch_contact(
except Exception: except Exception:
data = response.text or "" data = response.text or ""
# Нормализация ответа n8n в единый формат { "items": [...] }
if isinstance(data, list): if isinstance(data, list):
return {"items": data if data else []} return {"items": data if data else []}
if isinstance(data, dict): if isinstance(data, dict):
@@ -201,8 +234,78 @@ async def _fetch_contact(
return {"items": c if isinstance(c, list) else [c] if c else []} return {"items": c if isinstance(c, list) else [c] if c else []}
if "data" in data and isinstance(data["data"], list): if "data" in data and isinstance(data["data"], list):
return {"items": data["data"]} return {"items": data["data"]}
# Один объект-контакт без обёртки (если есть хоть одно поле контакта — считаем контактом)
if data and isinstance(data, dict): if data and isinstance(data, dict):
return {"items": [data]} return {"items": [data]}
return {"items": []} return {"items": []}
return {"items": []} return {"items": []}
@router.post("/contact/update")
async def post_profile_contact_update(body: ProfileContactUpdateRequest):
"""
Обновить контакт в CRM через N8N_PROFILE_UPDATE_WEBHOOK.
Вызывается с фронта при verification="0". Сессия проверяется по session_token.
"""
webhook_url = (getattr(settings, "n8n_profile_update_webhook", None) or "").strip()
if not webhook_url:
raise HTTPException(
status_code=503,
detail="N8N_PROFILE_UPDATE_WEBHOOK не настроен",
)
unified_id, contact_id, phone, chat_id = await _resolve_profile_identity(
session_token=body.session_token,
entry_channel=body.entry_channel,
chat_id=None,
)
payload: dict = {
"unified_id": unified_id,
"entry_channel": (body.entry_channel or "web").strip() or "web",
"session_token": body.session_token,
"last_name": (body.last_name or "").strip(),
"first_name": (body.first_name or "").strip(),
"middle_name": (body.middle_name or "").strip(),
"birth_date": (body.birth_date or "").strip(),
"birth_place": (body.birth_place or "").strip(),
"inn": (body.inn or "").strip(),
"email": (body.email or "").strip(),
"registration_address": (body.registration_address or "").strip(),
"mailing_address": (body.mailing_address or "").strip(),
"bank_for_compensation": (body.bank_for_compensation or "").strip(),
}
if contact_id is not None:
payload["contact_id"] = contact_id
if body.phone is not None and str(body.phone).strip():
payload["phone"] = str(body.phone).strip()
elif phone is not None:
payload["phone"] = phone
if chat_id is not None and str(chat_id).strip():
payload["chat_id"] = str(chat_id).strip()
try:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
webhook_url,
json=payload,
headers={"Content-Type": "application/json"},
)
except Exception as e:
logger.exception("Ошибка вызова N8N_PROFILE_UPDATE_WEBHOOK: %s", e)
raise HTTPException(status_code=502, detail="Не удалось сохранить профиль, попробуйте позже")
if response.status_code < 200 or response.status_code >= 300:
logger.warning("N8N profile update webhook вернул %s: %s", response.status_code, response.text[:500])
raise HTTPException(
status_code=502,
detail="Не удалось сохранить профиль, попробуйте позже",
)
result: dict = {"success": True}
try:
data = response.json()
if isinstance(data, dict) and data:
result.update(data)
except Exception:
pass
return result

View File

@@ -204,6 +204,7 @@ class Settings(BaseSettings):
# Контактные данные из CRM для раздела «Профиль» (массив или пусто) # Контактные данные из CRM для раздела «Профиль» (массив или пусто)
n8n_contact_webhook: str = "" # N8N_CONTACT_WEBHOOK в .env n8n_contact_webhook: str = "" # N8N_CONTACT_WEBHOOK в .env
n8n_profile_update_webhook: str = "" # N8N_PROFILE_UPDATE_WEBHOOK в .env — обновление профиля (verification=0)
# ============================================ # ============================================
# TELEGRAM BOT # TELEGRAM BOT

View File

@@ -1,23 +1,23 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button, Card, Descriptions, Spin, Typography } from 'antd'; import { Button, Card, Descriptions, Form, Input, Spin, Typography, message } from 'antd';
import { User } from 'lucide-react'; import { User } from 'lucide-react';
import './Profile.css'; import './Profile.css';
const { Title, Text } = Typography; const { Title, Text } = Typography;
/** Поля профиля из CRM (поддержка snake_case и camelCase) */ /** Поля профиля из CRM (поддержка snake_case и camelCase). Все кроме phone редактируемые при verification="0". */
const PROFILE_FIELDS: Array<{ key: string; keys: string[]; label: string }> = [ const PROFILE_FIELDS: Array<{ key: string; keys: string[]; label: string; editable: boolean }> = [
{ key: 'last_name', keys: ['last_name', 'lastName'], label: 'Фамилия' }, { key: 'last_name', keys: ['last_name', 'lastName'], label: 'Фамилия', editable: true },
{ key: 'first_name', keys: ['first_name', 'firstName'], label: 'Имя' }, { key: 'first_name', keys: ['first_name', 'firstName'], label: 'Имя', editable: true },
{ key: 'middle_name', keys: ['middle_name', 'middleName', 'otchestvo'], label: 'Отчество' }, { key: 'middle_name', keys: ['middle_name', 'middleName', 'otchestvo'], label: 'Отчество', editable: true },
{ key: 'birth_date', keys: ['birth_date', 'birthDate', 'birthday'], label: 'Дата рождения' }, { key: 'birth_date', keys: ['birth_date', 'birthDate', 'birthday'], label: 'Дата рождения', editable: true },
{ key: 'birth_place', keys: ['birth_place', 'birthPlace'], label: 'Место рождения' }, { key: 'birth_place', keys: ['birth_place', 'birthPlace'], label: 'Место рождения', editable: true },
{ key: 'inn', keys: ['inn'], label: 'ИНН' }, { key: 'inn', keys: ['inn'], label: 'ИНН', editable: true },
{ key: 'email', keys: ['email'], label: 'Электронная почта' }, { key: 'email', keys: ['email'], label: 'Электронная почта', editable: true },
{ key: 'registration_address', keys: ['registration_address', 'address', 'mailingstreet'], label: 'Адрес регистрации' }, { key: 'registration_address', keys: ['registration_address', 'address', 'mailingstreet'], label: 'Адрес регистрации', editable: true },
{ key: 'mailing_address', keys: ['mailing_address', 'postal_address'], label: 'Почтовый адрес' }, { key: 'mailing_address', keys: ['mailing_address', 'postal_address'], label: 'Почтовый адрес', editable: true },
{ key: 'bank_for_compensation', keys: ['bank_for_compensation', 'bank'], label: 'Банк для получения возмещения' }, { key: 'bank_for_compensation', keys: ['bank_for_compensation', 'bank'], label: 'Банк для получения возмещения', editable: true },
{ key: 'phone', keys: ['phone', 'mobile', 'mobile_phone'], label: 'Мобильный телефон' }, { key: 'phone', keys: ['phone', 'mobile', 'mobile_phone'], label: 'Мобильный телефон', editable: false },
]; ];
function getValue(obj: Record<string, unknown>, keys: string[]): string { function getValue(obj: Record<string, unknown>, keys: string[]): string {
@@ -28,14 +28,22 @@ function getValue(obj: Record<string, unknown>, keys: string[]): string {
return ''; return '';
} }
/** verification === "0" — профиль можно редактировать (ответ n8n). Иначе — только просмотр. */
function canEditProfile(contact: Record<string, unknown>): boolean {
const v = contact?.verification ?? contact?.Verification;
return v === '0' || v === 0;
}
interface ProfileProps { interface ProfileProps {
onNavigate?: (path: string) => void; onNavigate?: (path: string) => void;
} }
export default function Profile({ onNavigate }: ProfileProps) { export default function Profile({ onNavigate }: ProfileProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [contact, setContact] = useState<Record<string, unknown> | null>(null); const [contact, setContact] = useState<Record<string, unknown> | null>(null);
const [form] = Form.useForm();
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -81,6 +89,13 @@ export default function Profile({ onNavigate }: ProfileProps) {
? (items[0] as Record<string, unknown>) ? (items[0] as Record<string, unknown>)
: null; : null;
setContact(first); setContact(first);
if (first && canEditProfile(first)) {
const initial: Record<string, string> = {};
PROFILE_FIELDS.forEach(({ key, keys }) => {
initial[key] = getValue(first, keys) || '';
});
form.setFieldsValue(initial);
}
}) })
.catch((e) => { .catch((e) => {
if (!cancelled) setError(e?.message || 'Не удалось загрузить данные'); if (!cancelled) setError(e?.message || 'Не удалось загрузить данные');
@@ -89,7 +104,61 @@ export default function Profile({ onNavigate }: ProfileProps) {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [onNavigate]); }, [onNavigate, form]);
const handleSave = async () => {
if (!contact || !canEditProfile(contact)) return;
const token = sessionStorage.getItem('session_token') || localStorage.getItem('session_token');
if (!token) {
message.error('Сессия истекла');
onNavigate?.('/hello');
return;
}
const entryChannel =
(typeof window !== 'undefined' && (window as any).Telegram?.WebApp?.initData) ? 'telegram'
: (typeof window !== 'undefined' && (window as any).WebApp?.initData) ? 'max'
: 'web';
let values: Record<string, string>;
try {
values = await form.validateFields();
} catch {
return;
}
setSaving(true);
try {
const res = await fetch('/api/v1/profile/contact/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_token: token,
entry_channel: entryChannel,
last_name: values.last_name ?? '',
first_name: values.first_name ?? '',
middle_name: values.middle_name ?? '',
birth_date: values.birth_date ?? '',
birth_place: values.birth_place ?? '',
inn: values.inn ?? '',
email: values.email ?? '',
registration_address: values.registration_address ?? '',
mailing_address: values.mailing_address ?? '',
bank_for_compensation: values.bank_for_compensation ?? '',
phone: getValue(contact, ['phone', 'mobile', 'mobile_phone']) || undefined,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = typeof data?.detail === 'string' ? data.detail : data?.detail?.[0]?.msg || 'Не удалось сохранить профиль';
message.error(detail);
return;
}
message.success('Профиль сохранён');
setContact({ ...contact, ...values });
} catch (e) {
message.error('Не удалось сохранить профиль, попробуйте позже');
} finally {
setSaving(false);
}
};
if (loading) { if (loading) {
return ( return (
@@ -130,6 +199,38 @@ export default function Profile({ onNavigate }: ProfileProps) {
); );
} }
const canEdit = canEditProfile(contact);
if (canEdit) {
return (
<div className="profile-page">
<Card
className="profile-card"
title={<><User size={20} style={{ marginRight: 8, verticalAlign: 'middle' }} /> Профиль</>}
extra={onNavigate ? <Button type="link" onClick={() => onNavigate('/')}>Домой</Button> : null}
>
<Form form={form} layout="vertical" onFinish={handleSave}>
{PROFILE_FIELDS.map(({ key, keys, label, editable }) => (
<Form.Item
key={key}
name={key}
label={label}
rules={editable ? [{ required: true, message: 'Обязательное поле' }] : undefined}
>
<Input disabled={!editable} placeholder={editable ? undefined : '—'} />
</Form.Item>
))}
<Form.Item>
<Button type="primary" htmlType="submit" loading={saving}>
Сохранить изменения
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
}
const items = PROFILE_FIELDS.map(({ keys, label }) => ({ const items = PROFILE_FIELDS.map(({ keys, label }) => ({
key: keys[0], key: keys[0],
label, label,