import { env } from '../config/env.js'; import { getGenerationByUuidForUser, getGenerationSteps, updateGenerationResult, getGenerationMetaByUuid, } from '../repositories/scenario.repository.js'; /** * Валидация формата результата от n8n * * Ожидаемый формат body (массив файлов): * { * files: [ * { * contentType: 'image' | 'video', * url: string, // ссылка на файл (GET запрос) * thumbnailUrl?: string, * duration?: number, * width?: number, * height?: number, * size?: number, * format?: string * } * ] * } */ function validateResultBody(body) { const errors = []; if (!body || typeof body !== 'object') { errors.push('Body must be an object'); return { valid: false, errors }; } // files: обязательный массив if (!body.files) { errors.push('files is required'); } else if (!Array.isArray(body.files)) { errors.push('files must be an array'); } else if (body.files.length === 0) { errors.push('files array cannot be empty'); } else { // Валидация каждого файла body.files.forEach((file, index) => { const prefix = `files[${index}]`; // contentType if (!file.contentType) { errors.push(`${prefix}.contentType is required`); } else if (!['image', 'video'].includes(file.contentType)) { errors.push(`${prefix}.contentType must be 'image' or 'video'`); } // url: обязателен if (!file.url) { errors.push(`${prefix}.url is required`); } else if (typeof file.url !== 'string' || file.url.trim() === '') { errors.push(`${prefix}.url must be a non-empty string`); } else { try { new URL(file.url); } catch { errors.push(`${prefix}.url must be a valid URL`); } } // thumbnailUrl: опционален if (file.thumbnailUrl !== undefined && file.thumbnailUrl !== null) { if (typeof file.thumbnailUrl !== 'string') { errors.push(`${prefix}.thumbnailUrl must be a string`); } else { try { new URL(file.thumbnailUrl); } catch { errors.push(`${prefix}.thumbnailUrl must be a valid URL`); } } } // duration: опционален, только для видео if (file.duration !== undefined && file.duration !== null) { if (typeof file.duration !== 'number') { errors.push(`${prefix}.duration must be a number`); } else if (file.duration < 0) { errors.push(`${prefix}.duration must be >= 0`); } } // width/height: опциональны if (file.width !== undefined && file.width !== null) { if (!Number.isInteger(file.width) || file.width <= 0) { errors.push(`${prefix}.width must be a positive integer`); } } if (file.height !== undefined && file.height !== null) { if (!Number.isInteger(file.height) || file.height <= 0) { errors.push(`${prefix}.height must be a positive integer`); } } // size: опционален if (file.size !== undefined && file.size !== null) { if (typeof file.size !== 'number' || file.size < 0) { errors.push(`${prefix}.size must be a non-negative number`); } } // format: опционален if (file.format !== undefined && file.format !== null) { if (typeof file.format !== 'string' || file.format.trim() === '') { errors.push(`${prefix}.format must be a non-empty string`); } } }); } return { valid: errors.length === 0, errors, }; } /** * Получить результат генерации для пользователя * Формат ответа для фронта: * { * generationUuid: string, * scenarioId: string, * scenarioName: string, * status: 'running' | 'completed' | 'failed' | 'waiting_for_input', * files: Array<{ * id: string, * contentType: 'image' | 'video', * url: string, * thumbnailUrl?: string, * duration?: number, * width?: number, * height?: number, * size?: number, * format?: string * }>, * steps: [...], * startedAt: date, * finishedAt: date * } */ export async function getResultForUser({ userId, generationUuid }) { const generation = await getGenerationByUuidForUser(generationUuid, userId); if (!generation) { return null; } const steps = await getGenerationSteps(generationUuid); // Преобразуем result_payload в формат для фронта const files = parseResultPayload(generation.result_payload); return { generationUuid: generation.generation_uuid, scenarioId: generation.scenario_id, scenarioName: generation.scenario_name, status: generation.status, files, steps, startedAt: generation.started_at, finishedAt: generation.finished_at, requestPayload: generation.request_payload, }; } /** * Сохранить результат в БД */ export async function saveResultToDB(generationUuid, files) { const resultPayload = { files }; return updateGenerationResult(generationUuid, resultPayload, 'completed'); } /** * Получить метаданные генерации (только scenarioId и scenarioName) */ export async function getGenerationMeta({ userId, generationUuid }) { const generation = await getGenerationMetaByUuid(generationUuid, userId); if (!generation) { return null; } return { scenarioId: generation.scenario_id, scenarioName: generation.scenario_name, }; } /** * Парсит result_payload в массив файлов */ function parseResultPayload(resultPayload) { if (!resultPayload || !resultPayload.files) { return []; } return resultPayload.files.map((file, index) => ({ id: `file-${index}`, contentType: file.contentType, url: file.url, thumbnailUrl: file.thumbnailUrl, duration: file.duration, width: file.width, height: file.height, size: file.size, format: file.format, })); } /** * Обработать результат от n8n */ export async function processResultFromN8n({ generationUuid, userId, scenarioId, resultBody }) { // Проверяем, что generation существует и принадлежит пользователю const generation = await getGenerationByUuidForUser(generationUuid, userId); if (!generation) { const error = new Error(`Generation '${generationUuid}' not found or does not belong to user '${userId}'`); error.code = 'GENERATION_NOT_FOUND'; error.status = 404; throw error; } // Валидация результата const validation = validateResultBody(resultBody); if (!validation.valid) { const error = new Error(`Invalid result format: ${validation.errors.join(', ')}`); error.code = 'INVALID_RESULT_FORMAT'; error.status = 400; error.details = validation.errors; throw error; } // Сохраняем результат в БД const updatedGeneration = await updateGenerationResult(generationUuid, resultBody, 'completed'); return { generationUuid: updatedGeneration.generation_uuid, status: updatedGeneration.status, files: parseResultPayload(updatedGeneration.result_payload), }; } /** * Вызов n8n webhook для получения результата * stepData - данные из response_payload шага (taskId, recordId и т.д.) */ export async function fetchResultFromN8n({ generationUuid, userId, scenarioId, stepData = {} }) { // Вызываем webhook без generationUuid в пути (передаём в meta/body) const n8nUrl = `${env.N8N_BASE_URL}/webhook/result`; const payload = { meta: { generationUuid, userId, scenarioId, stepData, }, body: stepData, }; console.log('[fetchResultFromN8n] Calling:', n8nUrl); console.log('[fetchResultFromN8n] Payload:', JSON.stringify(payload, null, 2)); try { const response = await fetch(n8nUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); console.log('[fetchResultFromN8n] Response status:', response.status, response.statusText); const responseText = await response.text(); console.log('[fetchResultFromN8n] Response body:', responseText); if (!response.ok) { const error = new Error(`n8n webhook failed: ${response.status} ${response.statusText}`); error.code = 'N8N_WEBHOOK_ERROR'; error.status = response.status; error.n8nResponse = responseText; throw error; } const data = JSON.parse(responseText); console.log('[fetchResultFromN8n] Parsed data:', data); return data; } catch (err) { console.error('[fetchResultFromN8n] Error:', err.message); 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; } }