diff --git a/COMMIT_MSG_PROFILE_EDIT.txt b/COMMIT_MSG_PROFILE_EDIT.txt new file mode 100644 index 0000000..39b674b --- /dev/null +++ b/COMMIT_MSG_PROFILE_EDIT.txt @@ -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; иначе профиль остаётся только для просмотра. diff --git a/backend/app/api/profile.py b/backend/app/api/profile.py index e1343df..1f9d07f 100644 --- a/backend/app/api/profile.py +++ b/backend/app/api/profile.py @@ -35,7 +35,7 @@ unified_id берётся из сессии по session_token или перед """ import logging -from typing import Optional +from typing import Optional, Tuple import httpx from fastapi import APIRouter, HTTPException, Query @@ -48,6 +48,59 @@ logger = logging.getLogger(__name__) 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): """Запрос контактных данных: session_token, (channel + channel_user_id) или unified_id.""" 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)") +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") async def get_profile_contact( session_token: Optional[str] = Query(None, description="Токен сессии"), @@ -109,51 +179,15 @@ async def _fetch_contact( detail="N8N_CONTACT_WEBHOOK не настроен", ) - contact_id: Optional[str] = None - phone: Optional[str] = None + unified_id, contact_id, phone, chat_id = await _resolve_profile_identity( + 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 = { "unified_id": unified_id, "entry_channel": (entry_channel or "web").strip() or "web", @@ -190,7 +224,6 @@ async def _fetch_contact( except Exception: data = response.text or "" - # Нормализация ответа n8n в единый формат { "items": [...] } if isinstance(data, list): return {"items": data if data else []} if isinstance(data, dict): @@ -201,8 +234,78 @@ async def _fetch_contact( return {"items": c if isinstance(c, list) else [c] if c else []} if "data" in data and isinstance(data["data"], list): return {"items": data["data"]} - # Один объект-контакт без обёртки (если есть хоть одно поле контакта — считаем контактом) if data and isinstance(data, dict): return {"items": [data]} 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 diff --git a/backend/app/config.py b/backend/app/config.py index 0c2330f..9524c1a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -204,6 +204,7 @@ class Settings(BaseSettings): # Контактные данные из CRM для раздела «Профиль» (массив или пусто) n8n_contact_webhook: str = "" # N8N_CONTACT_WEBHOOK в .env + n8n_profile_update_webhook: str = "" # N8N_PROFILE_UPDATE_WEBHOOK в .env — обновление профиля (verification=0) # ============================================ # TELEGRAM BOT diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index fe3cfdf..33798be 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -1,23 +1,23 @@ 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 './Profile.css'; const { Title, Text } = Typography; -/** Поля профиля из CRM (поддержка snake_case и camelCase) */ -const PROFILE_FIELDS: Array<{ key: string; keys: string[]; label: string }> = [ - { key: 'last_name', keys: ['last_name', 'lastName'], label: 'Фамилия' }, - { key: 'first_name', keys: ['first_name', 'firstName'], label: 'Имя' }, - { key: 'middle_name', keys: ['middle_name', 'middleName', 'otchestvo'], label: 'Отчество' }, - { key: 'birth_date', keys: ['birth_date', 'birthDate', 'birthday'], label: 'Дата рождения' }, - { key: 'birth_place', keys: ['birth_place', 'birthPlace'], label: 'Место рождения' }, - { key: 'inn', keys: ['inn'], label: 'ИНН' }, - { key: 'email', keys: ['email'], label: 'Электронная почта' }, - { key: 'registration_address', keys: ['registration_address', 'address', 'mailingstreet'], label: 'Адрес регистрации' }, - { key: 'mailing_address', keys: ['mailing_address', 'postal_address'], label: 'Почтовый адрес' }, - { key: 'bank_for_compensation', keys: ['bank_for_compensation', 'bank'], label: 'Банк для получения возмещения' }, - { key: 'phone', keys: ['phone', 'mobile', 'mobile_phone'], label: 'Мобильный телефон' }, +/** Поля профиля из CRM (поддержка snake_case и camelCase). Все кроме phone редактируемые при verification="0". */ +const PROFILE_FIELDS: Array<{ key: string; keys: string[]; label: string; editable: boolean }> = [ + { key: 'last_name', keys: ['last_name', 'lastName'], label: 'Фамилия', editable: true }, + { key: 'first_name', keys: ['first_name', 'firstName'], label: 'Имя', editable: true }, + { key: 'middle_name', keys: ['middle_name', 'middleName', 'otchestvo'], label: 'Отчество', editable: true }, + { key: 'birth_date', keys: ['birth_date', 'birthDate', 'birthday'], label: 'Дата рождения', editable: true }, + { key: 'birth_place', keys: ['birth_place', 'birthPlace'], label: 'Место рождения', editable: true }, + { key: 'inn', keys: ['inn'], label: 'ИНН', editable: true }, + { key: 'email', keys: ['email'], label: 'Электронная почта', editable: true }, + { key: 'registration_address', keys: ['registration_address', 'address', 'mailingstreet'], label: 'Адрес регистрации', editable: true }, + { key: 'mailing_address', keys: ['mailing_address', 'postal_address'], label: 'Почтовый адрес', editable: true }, + { key: 'bank_for_compensation', keys: ['bank_for_compensation', 'bank'], label: 'Банк для получения возмещения', editable: true }, + { key: 'phone', keys: ['phone', 'mobile', 'mobile_phone'], label: 'Мобильный телефон', editable: false }, ]; function getValue(obj: Record, keys: string[]): string { @@ -28,14 +28,22 @@ function getValue(obj: Record, keys: string[]): string { return ''; } +/** verification === "0" — профиль можно редактировать (ответ n8n). Иначе — только просмотр. */ +function canEditProfile(contact: Record): boolean { + const v = contact?.verification ?? contact?.Verification; + return v === '0' || v === 0; +} + interface ProfileProps { onNavigate?: (path: string) => void; } export default function Profile({ onNavigate }: ProfileProps) { const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [contact, setContact] = useState | null>(null); + const [form] = Form.useForm(); useEffect(() => { let cancelled = false; @@ -81,6 +89,13 @@ export default function Profile({ onNavigate }: ProfileProps) { ? (items[0] as Record) : null; setContact(first); + if (first && canEditProfile(first)) { + const initial: Record = {}; + PROFILE_FIELDS.forEach(({ key, keys }) => { + initial[key] = getValue(first, keys) || ''; + }); + form.setFieldsValue(initial); + } }) .catch((e) => { if (!cancelled) setError(e?.message || 'Не удалось загрузить данные'); @@ -89,7 +104,61 @@ export default function Profile({ onNavigate }: ProfileProps) { if (!cancelled) setLoading(false); }); 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; + 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) { return ( @@ -130,6 +199,38 @@ export default function Profile({ onNavigate }: ProfileProps) { ); } + const canEdit = canEditProfile(contact); + + if (canEdit) { + return ( +
+ Профиль} + extra={onNavigate ? : null} + > +
+ {PROFILE_FIELDS.map(({ key, keys, label, editable }) => ( + + + + ))} + + + +
+
+
+ ); + } + const items = PROFILE_FIELDS.map(({ keys, label }) => ({ key: keys[0], label,