feat: Session persistence with Redis + Draft management fixes

- Implement session management API (/api/v1/session/create, verify, logout)
- Add session restoration from localStorage on page reload
- Fix session_id priority when loading drafts (use current, not old from DB)
- Add unified_id and claim_id to wizard payload sent to n8n
- Add Docker volume for frontend HMR (Hot Module Replacement)
- Add comprehensive session logging for debugging

Components updated:
- backend/app/api/session.py (NEW) - Session management endpoints
- backend/app/main.py - Include session router
- frontend/src/components/form/Step1Phone.tsx v2.0 - Create session after SMS
- frontend/src/pages/ClaimForm.tsx v3.8 - Session restoration & priority fix
- frontend/src/components/form/StepWizardPlan.tsx v1.4 - Add unified_id/claim_id
- docker-compose.yml - Add frontend volume for live reload

Session flow:
1. User verifies phone -> session created in Redis (24h TTL)
2. session_token saved to localStorage
3. Page reload -> session restored automatically
4. Draft selected -> current session_id used (not old from DB)
5. Wizard submit -> unified_id, claim_id, session_id sent to n8n
6. Logout -> session removed from Redis & localStorage

Fixes:
- Session token not persisting after page reload
- unified_id missing in n8n webhook payload
- Old session_id from draft overwriting current session
- Frontend changes requiring container rebuild
This commit is contained in:
AI Assistant
2025-11-20 18:31:42 +03:00
parent 4c8fda5f55
commit 3621ae6021
25 changed files with 3120 additions and 181 deletions

View File

@@ -112,6 +112,7 @@ export default function StepWizardPlan({
onPrev,
addDebugEvent,
}: Props) {
console.log('🔥 StepWizardPlan v1.4 - 2025-11-20 15:00 - Add unified_id and claim_id to wizard payload');
const [form] = Form.useForm();
const eventSourceRef = useRef<EventSource | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -146,6 +147,36 @@ export default function StepWizardPlan({
debugLoggerRef.current = addDebugEvent;
}, [addDebugEvent]);
// ✅ Автосохранение прогресса заполнения (debounce 3 секунды)
useEffect(() => {
if (!formData.claim_id || !formValues) return;
const timeoutId = setTimeout(() => {
const answers = form.getFieldsValue(true);
// Сохраняем только если есть хоть какие-то ответы
const hasAnswers = Object.keys(answers).some(key => answers[key] !== undefined && answers[key] !== '');
if (hasAnswers) {
console.log('💾 Автосохранение прогресса:', { claim_id: formData.claim_id, answersCount: Object.keys(answers).length });
// Обновляем formData с текущими ответами
updateFormData({
wizardAnswers: answers,
wizardUploads: {
documents: questionFileBlocks,
custom: customFileBlocks,
},
wizardSkippedDocuments: Array.from(skippedDocuments),
});
addDebugEvent?.('wizard', 'info', '💾 Автосохранение прогресса');
}
}, 3000); // 3 секунды debounce
return () => clearTimeout(timeoutId);
}, [formValues, formData.claim_id]); // Зависимость от formValues, но БЕЗ questionFileBlocks/customFileBlocks/skippedDocuments (они обновляются отдельно)
const questions: WizardQuestion[] = useMemo(() => plan?.questions || [], [plan]);
const documents: WizardDocument[] = plan?.documents || [];
@@ -339,19 +370,19 @@ export default function StepWizardPlan({
}, [formValues, plan, questions, documentGroups, questionFileBlocks, handleDocumentBlocksChange, skippedDocuments]);
useEffect(() => {
if (!isWaiting || !formData.claim_id || plan) {
if (!isWaiting || !formData.session_id || plan) {
return;
}
const claimId = formData.claim_id;
const source = new EventSource(`/events/${claimId}`);
const sessionId = formData.session_id;
const source = new EventSource(`/events/${sessionId}`);
eventSourceRef.current = source;
debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { claim_id: claimId });
debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { session_id: sessionId });
// Таймаут: если план не пришёл за 2 минуты (RAG может работать долго), показываем ошибку
timeoutRef.current = setTimeout(() => {
setConnectionError('План вопросов не получен. Проверьте, что n8n обработал описание проблемы.');
debugLoggerRef.current?.('wizard', 'error', '⏱️ Таймаут ожидания плана вопросов (2 минуты)', { claim_id: claimId });
debugLoggerRef.current?.('wizard', 'error', '⏱️ Таймаут ожидания плана вопросов (2 минуты)', { session_id: sessionId });
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
@@ -360,7 +391,7 @@ export default function StepWizardPlan({
source.onopen = () => {
setConnectionError(null);
debugLoggerRef.current?.('wizard', 'info', '✅ SSE соединение открыто', { claim_id: claimId });
debugLoggerRef.current?.('wizard', 'info', '✅ SSE соединение открыто', { session_id: sessionId });
};
source.onerror = (error) => {
@@ -368,7 +399,7 @@ export default function StepWizardPlan({
setConnectionError('Не удалось получить ответ от AI. Попробуйте ещё раз.');
source.close();
eventSourceRef.current = null;
debugLoggerRef.current?.('wizard', 'error', '❌ SSE ошибка (wizard)', { claim_id: claimId });
debugLoggerRef.current?.('wizard', 'error', '❌ SSE ошибка (wizard)', { session_id: sessionId });
};
const extractWizardPayload = (incoming: any): any => {
@@ -403,7 +434,7 @@ export default function StepWizardPlan({
// Логируем все события для отладки
debugLoggerRef.current?.('wizard', 'info', '📨 Получено SSE событие', {
claim_id: claimId,
session_id: sessionId,
event_type: eventType,
has_wizard_plan: Boolean(extractWizardPayload(payload)),
payload_keys: Object.keys(payload),
@@ -419,7 +450,7 @@ export default function StepWizardPlan({
const coverageReport = wizardPayload?.coverage_report;
debugLoggerRef.current?.('wizard', 'success', '✨ Получен план вопросов', {
claim_id: claimId,
session_id: sessionId,
questions: wizardPlan?.questions?.length || 0,
});
@@ -459,11 +490,11 @@ export default function StepWizardPlan({
eventSourceRef.current = null;
}
};
}, [isWaiting, formData.claim_id, plan, updateFormData]);
}, [isWaiting, formData.session_id, plan, updateFormData]);
const handleRefreshPlan = () => {
if (!formData.claim_id) {
message.error('Не найден claim_id для подписки на события.');
if (!formData.session_id) {
message.error('Не найден session_id для подписки на события.');
return;
}
setIsWaiting(true);
@@ -561,7 +592,7 @@ export default function StepWizardPlan({
try {
setSubmitting(true);
addDebugEvent?.('wizard', 'info', '📤 Отправляем данные визарда в n8n', {
claim_id: formData.claim_id,
session_id: formData.session_id,
});
const formPayload = new FormData();
@@ -570,6 +601,8 @@ export default function StepWizardPlan({
if (formData.session_id) formPayload.append('session_id', formData.session_id);
if (formData.clientIp) formPayload.append('client_ip', formData.clientIp);
if (formData.smsCode) formPayload.append('sms_code', formData.smsCode);
// Добавляем unified_id и claim_id (если есть)
if (formData.unified_id) formPayload.append('unified_id', formData.unified_id);
if (formData.claim_id) formPayload.append('claim_id', formData.claim_id);
if (formData.contact_id) formPayload.append('contact_id', String(formData.contact_id));
if (formData.project_id) formPayload.append('project_id', String(formData.project_id));
@@ -686,6 +719,15 @@ export default function StepWizardPlan({
});
});
// Логируем ключевые поля перед отправкой
console.log('📤 Отправка в n8n:', {
session_id: formData.session_id,
unified_id: formData.unified_id,
claim_id: formData.claim_id,
contact_id: formData.contact_id,
phone: formData.phone,
});
const response = await fetch('/api/v1/claims/wizard', {
method: 'POST',
body: formPayload,
@@ -978,7 +1020,14 @@ export default function StepWizardPlan({
</Card>
);
const renderQuestions = () => (
const renderQuestions = () => {
console.log('🔍 StepWizardPlan renderQuestions:', {
questionsCount: questions.length,
documentsCount: documents.length,
questions: questions.map(q => ({ name: q.name, label: q.label, input_type: q.input_type, required: q.required }))
});
return (
<>
<Card
size="small"
@@ -1001,21 +1050,21 @@ export default function StepWizardPlan({
initialValues={{ ...prefillMap, ...formData.wizardAnswers }}
>
{questions.map((question) => {
// Для условных полей используем dependencies для отслеживания изменений
const dependencies = question.ask_if ? [question.ask_if.field] : undefined;
// Для условных полей используем shouldUpdate для отслеживания изменений
const hasCondition = !!question.ask_if;
return (
<Form.Item
key={question.name}
dependencies={dependencies}
shouldUpdate={dependencies ? (prev, curr) => {
shouldUpdate={hasCondition ? (prev, curr) => {
// Обновляем только если изменилось значение поля, от которого зависит вопрос
return prev[question.ask_if!.field] !== curr[question.ask_if!.field];
} : undefined}
} : true} // ✅ Для безусловных полей shouldUpdate=true, чтобы render function работала
>
{() => {
const values = form.getFieldsValue(true);
if (!evaluateCondition(question.ask_if, values)) {
console.log(`⏭️ Question ${question.name} skipped: condition not met`, question.ask_if, values);
return null;
}
const questionDocs = documentGroups[question.name] || [];
@@ -1045,9 +1094,12 @@ export default function StepWizardPlan({
// (даже если вопрос не связан с documentGroups)
// Загрузка файлов уже реализована через блоки документов (documents)
if (isDocumentUploadQuestion && documents.length > 0) {
console.log(`🚫 Question ${question.name} hidden: isDocumentUploadQuestion=true, documents.length=${documents.length}`);
return null;
}
console.log(`✅ Question ${question.name} will render:`, { input_type: question.input_type, label: question.label, required: question.required });
return (
<>
<Form.Item
@@ -1094,14 +1146,15 @@ export default function StepWizardPlan({
</Form>
{renderCustomUploads()}
</>
);
);
};
if (!formData.claim_id) {
if (!formData.session_id) {
return (
<Result
status="warning"
title="Нет claim_id"
subTitle="Не удалось определить идентификатор заявки. Вернитесь на предыдущий шаг и попробуйте снова."
title="Нет session_id"
subTitle="Не удалось определить идентификатор сессии. Вернитесь на предыдущий шаг и попробуйте снова."
extra={<Button onClick={onPrev}>Вернуться</Button>}
/>
);