initial commit
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user