Proeycto de images-worker creado
This commit is contained in:
15
mvp/image-worker/src/app.module.ts
Normal file
15
mvp/image-worker/src/app.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { WebhookModule } from './webhook/webhook.module';
|
||||
import { PipelineModule } from './pipeline/pipeline.module';
|
||||
import { ReformixModule } from './reformix/reformix.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
WebhookModule,
|
||||
PipelineModule,
|
||||
ReformixModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
20
mvp/image-worker/src/main.ts
Normal file
20
mvp/image-worker/src/main.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['error', 'warn', 'log', 'debug'],
|
||||
});
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }));
|
||||
|
||||
const config = app.get(ConfigService);
|
||||
const port = config.get('PORT', 3001);
|
||||
await app.listen(port);
|
||||
console.log(`[Reformix Image Worker] corriendo en puerto ${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
84
mvp/image-worker/src/pipeline/image-generator.service.ts
Normal file
84
mvp/image-worker/src/pipeline/image-generator.service.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
@Injectable()
|
||||
export class ImageGeneratorService {
|
||||
private readonly logger = new Logger(ImageGeneratorService.name);
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
async generarRender(prompt: string, fotoAntesDataUri: string): Promise<string> {
|
||||
const apiKey = this.config.get<string>('OPENROUTER_API_KEY');
|
||||
const model = this.config.get<string>('OPENROUTER_MODEL_IMAGEN', 'google/gemini-2.0-flash-exp-image-generation');
|
||||
|
||||
const intentosRateLimit = 1;
|
||||
for (let attempt = 0; attempt <= intentosRateLimit; attempt++) {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
OPENROUTER_URL,
|
||||
{
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
{ type: 'image_url', image_url: { url: fotoAntesDataUri } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix Image Worker',
|
||||
},
|
||||
timeout: 60000,
|
||||
},
|
||||
);
|
||||
|
||||
const content = response.data.choices?.[0]?.message?.content;
|
||||
if (!content) throw new Error('OpenRouter no devolvio contenido');
|
||||
|
||||
const imagen = this.extraerImagenDeRespuesta(content, response.data);
|
||||
if (!imagen) throw new Error('No se pudo extraer imagen de la respuesta');
|
||||
|
||||
return imagen;
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 429 && attempt < intentosRateLimit) {
|
||||
this.logger.warn('Rate limit (429), esperando 5s y reintentando...');
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Fallaron todos los intentos de generacion de imagen');
|
||||
}
|
||||
|
||||
private extraerImagenDeRespuesta(content: string, rawResponse?: any): string | null {
|
||||
if (content.startsWith('data:image')) return content;
|
||||
|
||||
const dataUriMatch = content.match(/data:image\/[a-zA-Z]+;base64,[^\s"']+/);
|
||||
if (dataUriMatch) return dataUriMatch[0];
|
||||
|
||||
const urlMatch = content.match(/https?:\/\/[^\s"'()]+\.(png|jpg|jpeg|webp)/i);
|
||||
if (urlMatch) return urlMatch[0];
|
||||
|
||||
const parts = rawResponse?.choices?.[0]?.message?.content;
|
||||
if (Array.isArray(parts)) {
|
||||
for (const part of parts) {
|
||||
if (part.type === 'image_url' && part.image_url?.url) return part.image_url.url;
|
||||
if (part.image_url?.url?.startsWith('data:image')) return part.image_url.url;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
13
mvp/image-worker/src/pipeline/pipeline.module.ts
Normal file
13
mvp/image-worker/src/pipeline/pipeline.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PipelineService } from './pipeline.service';
|
||||
import { PromptBuilderService } from './prompt-builder.service';
|
||||
import { ImageGeneratorService } from './image-generator.service';
|
||||
import { SupervisorService } from './supervisor.service';
|
||||
import { ReformixModule } from '../reformix/reformix.module';
|
||||
|
||||
@Module({
|
||||
imports: [ReformixModule],
|
||||
providers: [PipelineService, PromptBuilderService, ImageGeneratorService, SupervisorService],
|
||||
exports: [PipelineService],
|
||||
})
|
||||
export class PipelineModule {}
|
||||
114
mvp/image-worker/src/pipeline/pipeline.service.ts
Normal file
114
mvp/image-worker/src/pipeline/pipeline.service.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PerfilCompletoDto } from '../webhook/webhook.dto';
|
||||
import { PromptBuilderService } from './prompt-builder.service';
|
||||
import { ImageGeneratorService } from './image-generator.service';
|
||||
import { SupervisorService } from './supervisor.service';
|
||||
import { ReformixService } from '../reformix/reformix.service';
|
||||
|
||||
interface ZonaRender {
|
||||
zona: string;
|
||||
imagen: string;
|
||||
score: number;
|
||||
aprobada: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PipelineService {
|
||||
private readonly logger = new Logger(PipelineService.name);
|
||||
private readonly maxRetries: number;
|
||||
private readonly minScore: number;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly promptBuilder: PromptBuilderService,
|
||||
private readonly imageGenerator: ImageGeneratorService,
|
||||
private readonly supervisor: SupervisorService,
|
||||
private readonly reformix: ReformixService,
|
||||
) {
|
||||
this.maxRetries = this.config.get<number>('MAX_RETRIES', 2);
|
||||
this.minScore = this.config.get<number>('SUPERVISOR_MIN_SCORE', 70);
|
||||
}
|
||||
|
||||
async procesarLead(dto: PerfilCompletoDto): Promise<void> {
|
||||
const { leadId, reforma, zonas } = dto;
|
||||
const zonasConFotos = zonas.filter((z) => z.fotos.antes.length > 0);
|
||||
const zonasSaltadas = zonas.filter((z) => z.fotos.antes.length === 0);
|
||||
|
||||
this.logger.log(`[${leadId}] Iniciando pipeline para ${zonasConFotos.length} zonas`);
|
||||
|
||||
for (const z of zonasSaltadas) {
|
||||
this.logger.log(`[${leadId}] Zona ${z.zona}: sin fotos "antes", saltando`);
|
||||
}
|
||||
|
||||
const renders: ZonaRender[] = [];
|
||||
|
||||
for (const zona of zonasConFotos) {
|
||||
try {
|
||||
const render = await this.procesarZona(leadId, zona.zona, reforma, zona.notas, zona.fotos.antes[0]);
|
||||
renders.push(render);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[${leadId}] Zona ${zona.zona}: error fatal: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (renders.length === 0) {
|
||||
this.logger.warn(`[${leadId}] No se generaron renders para ninguna zona`);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = renders.map((r) => ({
|
||||
zona: r.zona,
|
||||
imagen: r.imagen,
|
||||
}));
|
||||
|
||||
const ok = await this.reformix.entregarRenders(leadId, items);
|
||||
if (ok) {
|
||||
this.logger.log(`[${leadId}] Renders entregados correctamente (${renders.length} zonas)`);
|
||||
} else {
|
||||
this.logger.error(`[${leadId}] Error entregando renders a la app principal`);
|
||||
}
|
||||
}
|
||||
|
||||
private async procesarZona(
|
||||
leadId: string,
|
||||
zona: string,
|
||||
reforma: PerfilCompletoDto['reforma'],
|
||||
notas: string[],
|
||||
fotoAntes: string,
|
||||
): Promise<ZonaRender> {
|
||||
const prompt = await this.promptBuilder.generarPrompt(reforma.tipo, reforma.m2Suelo, reforma.calidad, notas);
|
||||
this.logger.log(`[${leadId}] Zona ${zona}: prompt generado`);
|
||||
|
||||
let ultimaImagen: string | null = null;
|
||||
|
||||
for (let intento = 0; intento <= this.maxRetries; intento++) {
|
||||
if (intento > 0) {
|
||||
this.logger.log(`[${leadId}] Zona ${zona}: reintento ${intento} de ${this.maxRetries}`);
|
||||
}
|
||||
|
||||
const imagen = await this.imageGenerator.generarRender(prompt, fotoAntes);
|
||||
ultimaImagen = imagen;
|
||||
this.logger.log(`[${leadId}] Zona ${zona}: imagen generada`);
|
||||
|
||||
const resultado = await this.supervisor.supervisar(
|
||||
reforma.tipo,
|
||||
reforma.m2Suelo,
|
||||
reforma.calidad,
|
||||
notas,
|
||||
fotoAntes,
|
||||
imagen,
|
||||
);
|
||||
|
||||
const aprobada = resultado.aprobado && resultado.score >= this.minScore;
|
||||
this.logger.log(`[${leadId}] Zona ${zona}: ${aprobada ? 'aprobada' : 'rechazada'} (score: ${resultado.score}) - ${resultado.motivo}`);
|
||||
|
||||
if (aprobada) {
|
||||
return { zona, imagen, score: resultado.score, aprobada: true };
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.warn(`[${leadId}] Zona ${zona}: usando ultimo render pese a no superar validacion`);
|
||||
return { zona, imagen: ultimaImagen!, score: 0, aprobada: false };
|
||||
}
|
||||
}
|
||||
68
mvp/image-worker/src/pipeline/prompt-builder.service.ts
Normal file
68
mvp/image-worker/src/pipeline/prompt-builder.service.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import axios from 'axios';
|
||||
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
@Injectable()
|
||||
export class PromptBuilderService {
|
||||
private readonly logger = new Logger(PromptBuilderService.name);
|
||||
private systemPrompt = '';
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
const ruta = path.join(process.cwd(), 'prompts', 'prompt-builder.txt');
|
||||
if (fs.existsSync(ruta)) {
|
||||
this.systemPrompt = fs.readFileSync(ruta, 'utf-8');
|
||||
} else {
|
||||
this.logger.warn('prompts/prompt-builder.txt no encontrado, usando default');
|
||||
}
|
||||
}
|
||||
|
||||
async generarPrompt(
|
||||
tipoReforma: string,
|
||||
m2Suelo: number | null,
|
||||
calidad: string,
|
||||
notas: string[],
|
||||
): Promise<string> {
|
||||
const apiKey = this.config.get<string>('OPENROUTER_API_KEY');
|
||||
const model = this.config.get<string>('OPENROUTER_MODEL_TEXTO', 'anthropic/claude-3.5-haiku-20241022');
|
||||
|
||||
const userContent = `Generate a render prompt for a ${tipoReforma} renovation.
|
||||
- Area: ${m2Suelo ?? 'unknown'} m²
|
||||
- Quality level: ${calidad}
|
||||
- Client notes: ${notas.join('; ') || 'none'}
|
||||
- Style: modern ${tipoReforma} renovation`;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
OPENROUTER_URL,
|
||||
{
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: this.systemPrompt },
|
||||
{ role: 'user', content: userContent },
|
||||
],
|
||||
max_tokens: 512,
|
||||
temperature: 0.5,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix Image Worker',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const prompt = response.data.choices?.[0]?.message?.content?.trim();
|
||||
if (!prompt) throw new Error('OpenRouter devolvio respuesta vacia');
|
||||
return prompt;
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Error generando prompt: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
109
mvp/image-worker/src/pipeline/supervisor.service.ts
Normal file
109
mvp/image-worker/src/pipeline/supervisor.service.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import axios from 'axios';
|
||||
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
export interface SupervisarResultado {
|
||||
aprobado: boolean;
|
||||
score: number;
|
||||
motivo: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SupervisorService {
|
||||
private readonly logger = new Logger(SupervisorService.name);
|
||||
private systemPrompt = '';
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
const ruta = path.join(process.cwd(), 'prompts', 'supervisor.txt');
|
||||
if (fs.existsSync(ruta)) {
|
||||
this.systemPrompt = fs.readFileSync(ruta, 'utf-8');
|
||||
} else {
|
||||
this.logger.warn('prompts/supervisor.txt no encontrado, usando default');
|
||||
}
|
||||
}
|
||||
|
||||
async supervisar(
|
||||
tipoReforma: string,
|
||||
m2Suelo: number | null,
|
||||
calidad: string,
|
||||
notas: string[],
|
||||
fotoAntes: string,
|
||||
renderDespues: string,
|
||||
): Promise<SupervisarResultado> {
|
||||
const apiKey = this.config.get<string>('OPENROUTER_API_KEY');
|
||||
const model = this.config.get<string>('OPENROUTER_MODEL_TEXTO', 'anthropic/claude-3.5-haiku-20241022');
|
||||
|
||||
const notasTexto = notas.join('; ') || 'sin notas';
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
OPENROUTER_URL,
|
||||
{
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: this.systemPrompt },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Reforma tipo: ${tipoReforma}\nMetros: ${m2Suelo ?? 'desconocido'}\nCalidad: ${calidad}\nNotas del cliente: ${notasTexto}`,
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: fotoAntes },
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: renderDespues },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
max_tokens: 256,
|
||||
temperature: 0.2,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix Image Worker',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const textContent = response.data.choices?.[0]?.message?.content?.trim();
|
||||
if (!textContent) {
|
||||
return { aprobado: false, score: 0, motivo: 'Modelo devolvio respuesta vacia' };
|
||||
}
|
||||
|
||||
return this.parsearRespuesta(textContent);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Error en supervisor: ${err.message}`);
|
||||
return { aprobado: false, score: 0, motivo: `Error del supervisor: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
private parsearRespuesta(texto: string): SupervisarResultado {
|
||||
const jsonMatch = texto.match(/\{[^{}]*"aprobado"[^{}]*\}/i);
|
||||
if (!jsonMatch) {
|
||||
return { aprobado: false, score: 0, motivo: 'Error parseando respuesta del supervisor' };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
return {
|
||||
aprobado: Boolean(parsed.aprobado),
|
||||
score: Math.min(100, Math.max(0, Number(parsed.score) || 0)),
|
||||
motivo: String(parsed.motivo || 'Sin motivo'),
|
||||
};
|
||||
} catch {
|
||||
return { aprobado: false, score: 0, motivo: 'Error parseando respuesta del supervisor' };
|
||||
}
|
||||
}
|
||||
}
|
||||
8
mvp/image-worker/src/reformix/reformix.module.ts
Normal file
8
mvp/image-worker/src/reformix/reformix.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReformixService } from './reformix.service';
|
||||
|
||||
@Module({
|
||||
providers: [ReformixService],
|
||||
exports: [ReformixService],
|
||||
})
|
||||
export class ReformixModule {}
|
||||
87
mvp/image-worker/src/reformix/reformix.service.ts
Normal file
87
mvp/image-worker/src/reformix/reformix.service.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
|
||||
@Injectable()
|
||||
export class ReformixService {
|
||||
private readonly logger = new Logger(ReformixService.name);
|
||||
private readonly baseUrl: string;
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
this.baseUrl = this.config.get<string>('REFORMIX_API_URL', 'http://localhost:3000');
|
||||
this.apiKey = this.config.get<string>('FUNNEL_API_KEY', '');
|
||||
}
|
||||
|
||||
async entregarRenders(
|
||||
leadId: string,
|
||||
items: Array<{ zona: string; imagen: string }>,
|
||||
): Promise<boolean> {
|
||||
const body = {
|
||||
items: items.map((i) => ({
|
||||
tipo: 'foto',
|
||||
zona: i.zona,
|
||||
momento: 'despues',
|
||||
imagen: i.imagen,
|
||||
})),
|
||||
finalizar: true,
|
||||
};
|
||||
|
||||
const maxRetries = 3;
|
||||
const delay = 2000;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.baseUrl}/api/leads/${leadId}/ingesta`,
|
||||
body,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
this.logger.log(`[${leadId}] Renders entregados correctamente`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
this.logger.error(`[${leadId}] Lead no encontrado (404), abandonando`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response.status === 422) {
|
||||
this.logger.error(`[${leadId}] Payload invalido (422): ${JSON.stringify(response.data)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response.status >= 500 && attempt < maxRetries) {
|
||||
this.logger.warn(`[${leadId}] Intento ${attempt}/${maxRetries} fallo (${response.status}), reintentando...`);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
this.logger.error(`[${leadId}] Error inesperado (${response.status}): ${JSON.stringify(response.data)}`);
|
||||
return false;
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 404) {
|
||||
this.logger.error(`[${leadId}] Lead no encontrado (404), abandonando`);
|
||||
return false;
|
||||
}
|
||||
if (attempt < maxRetries) {
|
||||
this.logger.warn(`[${leadId}] Intento ${attempt}/${maxRetries} error de red, reintentando...`);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
} else {
|
||||
this.logger.error(`[${leadId}] Error critico entregando renders tras ${maxRetries} intentos: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
23
mvp/image-worker/src/webhook/webhook.controller.ts
Normal file
23
mvp/image-worker/src/webhook/webhook.controller.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Controller, Post, Body, Logger } from '@nestjs/common';
|
||||
import { PerfilCompletoDto } from './webhook.dto';
|
||||
import { PipelineService } from '../pipeline/pipeline.service';
|
||||
|
||||
@Controller()
|
||||
export class WebhookController {
|
||||
private readonly logger = new Logger(WebhookController.name);
|
||||
|
||||
constructor(private readonly pipelineService: PipelineService) {}
|
||||
|
||||
@Post('perfil-completo')
|
||||
recibirPerfil(@Body() dto: PerfilCompletoDto) {
|
||||
this.logger.log(`[${dto.leadId}] Webhook recibido: ${dto.zonas.length} zonas`);
|
||||
|
||||
setImmediate(() => {
|
||||
this.pipelineService.procesarLead(dto).catch((err) => {
|
||||
this.logger.error(`[${dto.leadId}] Pipeline fallo: ${err.message}`, err.stack);
|
||||
});
|
||||
});
|
||||
|
||||
return { ok: true, message: 'Procesando renders en background...' };
|
||||
}
|
||||
}
|
||||
97
mvp/image-worker/src/webhook/webhook.dto.ts
Normal file
97
mvp/image-worker/src/webhook/webhook.dto.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { IsString, IsOptional, IsNumber, IsBoolean, IsArray, ValidateNested, IsIn, IsUUID } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
class ClienteDto {
|
||||
@IsString()
|
||||
nombre: string;
|
||||
|
||||
@IsString()
|
||||
telefono: string;
|
||||
|
||||
@IsString()
|
||||
email: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
provincia?: string;
|
||||
}
|
||||
|
||||
class ReformaDto {
|
||||
@IsString()
|
||||
@IsIn(['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'])
|
||||
tipo: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
m2Suelo?: number;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['basica', 'media', 'premium'])
|
||||
calidad: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
estructural?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['alta', 'media', 'baja'])
|
||||
urgencia?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
presupuestoTarget?: number;
|
||||
}
|
||||
|
||||
class EmpresaDto {
|
||||
@IsUUID()
|
||||
tenantId: string;
|
||||
|
||||
@IsString()
|
||||
nombre: string;
|
||||
}
|
||||
|
||||
class FotosDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
antes: string[];
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
despues: string[];
|
||||
}
|
||||
|
||||
class ZonaDto {
|
||||
@IsString()
|
||||
zona: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
notas: string[];
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => FotosDto)
|
||||
fotos: FotosDto;
|
||||
}
|
||||
|
||||
export class PerfilCompletoDto {
|
||||
@IsUUID()
|
||||
leadId: string;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => ClienteDto)
|
||||
cliente: ClienteDto;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => ReformaDto)
|
||||
reforma: ReformaDto;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => EmpresaDto)
|
||||
empresa: EmpresaDto;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ZonaDto)
|
||||
zonas: ZonaDto[];
|
||||
}
|
||||
9
mvp/image-worker/src/webhook/webhook.module.ts
Normal file
9
mvp/image-worker/src/webhook/webhook.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WebhookController } from './webhook.controller';
|
||||
import { PipelineModule } from '../pipeline/pipeline.module';
|
||||
|
||||
@Module({
|
||||
imports: [PipelineModule],
|
||||
controllers: [WebhookController],
|
||||
})
|
||||
export class WebhookModule {}
|
||||
Reference in New Issue
Block a user