initial commit

This commit is contained in:
root
2026-05-13 14:20:41 +00:00
commit 6e178d2012
6022 changed files with 399872 additions and 0 deletions
+322
View File
@@ -0,0 +1,322 @@
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;
}
// Если фронт передал явный generationUuid — берём его (защита от race condition
// при параллельных submitStep-вызовах от одного пользователя в одном сценарии).
// Иначе fallback на самую свежую активную generation (back-compat).
const explicitUuid = input && typeof input.generationUuid === 'string' ? input.generationUuid : null;
let generation;
if (explicitUuid) {
const r = await pool.query(
`SELECT generation_uuid, current_step_id, status FROM uno_bff.generations WHERE user_id = $1 AND scenario_id = $2 AND generation_uuid = $3::uuid LIMIT 1`,
[userId, scenarioId, explicitUuid],
);
generation = r.rows[0];
if (input && 'generationUuid' in input) { delete input.generationUuid; }
} else {
const r = await pool.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`,
[userId, scenarioId],
);
generation = r.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;
}
}