import { env } from '../config/env.js'; import { getUserScenarioPermission, getScenarioById, getScenarioStep, getFirstScenarioStep, createGeneration, updateGeneration, getGenerationByUuid, } from '../repositories/scenario.repository.js'; import { pool } from '../db.js'; // Экспорт для использования в routes export { getScenarioStep }; /** * Валидация данных по JSON Schema (строгая - все required поля) */ function validateInputSchema(inputSchema, data) { const errors = []; if (!inputSchema || inputSchema === '{}') { return { valid: true, errors: [] }; } const schema = typeof inputSchema === 'string' ? JSON.parse(inputSchema) : inputSchema; // Проверка required полей if (schema.required && Array.isArray(schema.required)) { for (const field of schema.required) { if (data[field] === undefined || data[field] === null) { errors.push(`Missing required field: ${field}`); } } } // Проверка типов полей if (schema.properties) { for (const [field, fieldSchema] of Object.entries(schema.properties)) { const value = data[field]; if (value === undefined || value === null) { continue; // already checked in required } if (fieldSchema.type === 'string' && typeof value !== 'string') { errors.push(`Field '${field}' must be a string`); } else if (fieldSchema.type === 'boolean' && typeof value !== 'boolean') { errors.push(`Field '${field}' must be a boolean`); } else if (fieldSchema.type === 'number' && typeof value !== 'number') { errors.push(`Field '${field}' must be a number`); } else if (fieldSchema.type === 'integer' && (!Number.isInteger(value))) { errors.push(`Field '${field}' must be an integer`); } else if (fieldSchema.type === 'array' && !Array.isArray(value)) { errors.push(`Field '${field}' must be an array`); } else if (fieldSchema.type === 'object' && (typeof value !== 'object' || Array.isArray(value))) { errors.push(`Field '${field}' must be an object`); } // Проверка minLength для string if (fieldSchema.type === 'string' && typeof value === 'string' && fieldSchema.minLength !== undefined) { if (value.length < fieldSchema.minLength) { errors.push(`Field '${field}' must have at least ${fieldSchema.minLength} characters`); } } } } return { valid: errors.length === 0, errors, }; } /** * Проверка прав доступа на запуск сценария */ export async function assertUserCanStartScenario({ userId, scenarioId }) { const scenario = await getScenarioById(scenarioId); if (!scenario) { const error = new Error(`Scenario '${scenarioId}' not found`); error.code = 'SCENARIO_NOT_FOUND'; error.status = 404; throw error; } if (scenario.status !== 'active') { const error = new Error(`Scenario '${scenarioId}' is not active (status: ${scenario.status})`); error.code = 'SCENARIO_NOT_ACTIVE'; error.status = 403; throw error; } // Публичные сценарии доступны всем авторизованным пользователям if (!scenario.is_public) { const permission = await getUserScenarioPermission(userId, scenarioId); if (!permission || !permission.can_start) { const error = new Error(`User '${userId}' does not have permission to start scenario '${scenarioId}'`); error.code = 'SCENARIO_ACCESS_DENIED'; error.status = 403; throw error; } } return true; } /** * Проверка прав доступа на выполнение шага */ export async function assertUserCanExecuteStep({ userId, scenarioId, stepId }) { const scenario = await getScenarioById(scenarioId); if (!scenario) { const error = new Error(`Scenario '${scenarioId}' not found`); error.code = 'SCENARIO_NOT_FOUND'; error.status = 404; throw error; } const step = await getScenarioStep(scenarioId, stepId); if (!step) { const error = new Error(`Step '${stepId}' not found in scenario '${scenarioId}'`); error.code = 'STEP_NOT_FOUND'; error.status = 404; throw error; } if (step.status !== 'active') { const error = new Error(`Step '${stepId}' is not active (status: ${step.status})`); error.code = 'STEP_NOT_ACTIVE'; error.status = 403; throw error; } // Публичные сценарии доступны всем авторизованным пользователям if (!scenario.is_public) { const permission = await getUserScenarioPermission(userId, scenarioId); if (!permission || !permission.can_execute) { const error = new Error(`User '${userId}' does not have permission to execute steps in scenario '${scenarioId}'`); error.code = 'SCENARIO_ACCESS_DENIED'; error.status = 403; throw error; } } return true; } /** * Запуск сценария */ export async function startScenario({ userId, scenarioId, input, user }) { // Валидация input для start - body должен быть пустым или минимальным // Для start сценария проверяем, что нет лишних данных (body должен быть пустым {}) if (input && Object.keys(input).length > 0) { const error = new Error('Start scenario body must be empty'); error.code = 'INVALID_START_BODY'; error.status = 400; throw error; } const firstStep = await getFirstScenarioStep(scenarioId); if (!firstStep) { const error = new Error(`No active steps found in scenario '${scenarioId}'`); error.code = 'NO_ACTIVE_STEPS'; error.status = 400; throw error; } // Создаем generation const generation = await createGeneration({ userId, scenarioId, authSessionId: user?.sessionId || null, requestPayload: {}, currentStepId: firstStep.step_id, }); // Формируем payload для n8n // generationUuid НЕ отправляем - n8n должен сгенерировать свой и вернуть в ответе const n8nPayload = { meta: { userId: user.id, userEmail: user.email, sessionId: user.sessionId, scenarioId, }, body: {}, }; // Вызов n8n webhook const n8nUrl = `${env.N8N_BASE_URL}/webhook/scenario/${scenarioId}/start`; const n8nResponse = await callN8nWebhook(n8nUrl, n8nPayload); // Обновляем generation с external_run_id и generation_uuid от n8n const updateData = {}; if (n8nResponse?.runId) { updateData.externalRunId = n8nResponse.runId; } if (n8nResponse?.generationUuid) { // Если n8n вернул свой generationUuid, используем его // Но это маловероятно - скорее всего мы уже сгенерировали свой updateData.generationUuid = n8nResponse.generationUuid; } if (Object.keys(updateData).length > 0) { await updateGeneration(generation.generation_uuid, updateData); } return { generationUuid: generation.generation_uuid, status: generation.status, currentStepId: firstStep.step_id, }; } /** * Выполнение шага сценария * BFF только валидирует входные данные и проксирует запрос в n8n. * n8n отвечает за запись в БД (generation_steps, generations). */ export async function executeStep({ userId, scenarioId, stepId, input, user }) { const step = await getScenarioStep(scenarioId, stepId); // Валидация input по input_schema из БД const validation = validateInputSchema(step.input_schema, input); if (!validation.valid) { const error = new Error(`Invalid input: ${validation.errors.join(', ')}`); error.code = 'INVALID_INPUT'; error.status = 400; error.details = validation.errors; throw error; } // Ищем активную generation для этого пользователя и сценария const query = ` SELECT generation_uuid, current_step_id, status FROM uno_bff.generations WHERE user_id = $1 AND scenario_id = $2 AND status IN ('running', 'waiting_for_input') ORDER BY created_at DESC LIMIT 1 `; const result = await pool.query(query, [userId, scenarioId]); let generation = result.rows[0]; if (!generation) { const error = new Error(`No active generation found for scenario '${scenarioId}'`); error.code = 'GENERATION_NOT_FOUND'; error.status = 400; throw error; } // Формируем payload для n8n const n8nPayload = { meta: { userId: user.id, userEmail: user.email, scenarioId, stepId, generationUuid: generation.generation_uuid, }, body: input, }; // Вызов n8n webhook - n8n сам пишет в БД const n8nUrl = `${env.N8N_BASE_URL}/webhook/scenario/${scenarioId}/step/${stepId}`; const n8nResponse = await callN8nWebhook(n8nUrl, n8nPayload); // Возвращаем ответ от n8n return { generationUuid: generation.generation_uuid, stepState: n8nResponse?.stepState || 'processing', nextStepId: n8nResponse?.nextStepId || stepId, n8nResponse: n8nResponse?.data || {}, }; } /** * Вызов n8n webhook */ async function callN8nWebhook(url, payload) { try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); if (!response.ok) { const errorText = await response.text(); const error = new Error(`n8n webhook failed: ${response.status} ${response.statusText}`); error.code = 'N8N_WEBHOOK_ERROR'; error.status = response.status; error.n8nResponse = errorText; throw error; } const data = await response.json(); return { runId: data.runId || data.externalRunId || null, data, }; } catch (err) { if (err.code === 'N8N_WEBHOOK_ERROR') { throw err; } const error = new Error(`Failed to call n8n webhook: ${err.message}`); error.code = 'N8N_CONNECTION_ERROR'; error.status = 503; throw error; } }