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:
@@ -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>}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user