Agregar campo username a User entity y DTO

- Columna username (unique, nullable) en auth.users
- Campo username en CreateUserDto y UpdateUserDto

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 11:47:37 -04:00
parent 6e0ad420ab
commit 8b6483aa7d
971 changed files with 16339 additions and 752 deletions

1409
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-polly": "^3.1009.0",
"@aws-sdk/client-s3": "^3.835.0",
"@aws-sdk/s3-request-presigner": "^3.835.0",
"@nestjs/common": "^11.1.3",
@@ -33,6 +34,8 @@
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.0",
"@sendgrid/mail": "^8.1.5",
"@types/passport-google-oauth20": "^2.0.17",
"apple-signin-auth": "^2.0.0",
"axios": "^1.10.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
@@ -40,7 +43,9 @@
"joi": "^17.13.3",
"multer": "^2.0.1",
"multer-s3": "^3.0.1",
"openai": "^6.27.0",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.16.2",

175
scripts/fetch-images-wikimedia.js Executable file
View File

@@ -0,0 +1,175 @@
#!/usr/bin/env node
/**
* Script para obtener imágenes de Wikimedia Commons para monumentos
* Usa la API de Wikimedia Commons (gratuita, sin API key)
*/
const { Client } = require('pg');
const https = require('https');
// Configuración
const config = {
db: {
host: 'localhost',
port: 5432,
user: 'karibeo',
password: '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',
database: 'karibeo_db',
},
};
// Buscar imágenes en Wikimedia Commons
async function searchWikimediaImages(query, limit = 3) {
return new Promise((resolve, reject) => {
// Primero buscar en Wikipedia para obtener la página del lugar
const searchUrl = `https://commons.wikimedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(query)}&srnamespace=6&srlimit=${limit}&format=json`;
https.get(searchUrl, { headers: { 'User-Agent': 'KaribeoAI/1.0 (contact@karibeo.ai)' } }, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const json = JSON.parse(data);
if (json.query && json.query.search && json.query.search.length > 0) {
// Obtener URLs de las imágenes
const titles = json.query.search.map(s => s.title).join('|');
getImageUrls(titles).then(resolve).catch(reject);
} else {
resolve([]);
}
} catch (e) {
reject(e);
}
});
}).on('error', reject);
});
}
// Obtener URLs directas de las imágenes
async function getImageUrls(titles) {
return new Promise((resolve, reject) => {
const url = `https://commons.wikimedia.org/w/api.php?action=query&titles=${encodeURIComponent(titles)}&prop=imageinfo&iiprop=url|size&format=json`;
https.get(url, { headers: { 'User-Agent': 'KaribeoAI/1.0 (contact@karibeo.ai)' } }, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const json = JSON.parse(data);
const urls = [];
if (json.query && json.query.pages) {
for (const pageId in json.query.pages) {
const page = json.query.pages[pageId];
if (page.imageinfo && page.imageinfo[0]) {
const info = page.imageinfo[0];
// Solo incluir imágenes de tamaño razonable (> 100KB y < 10MB)
if (info.size > 100000 && info.size < 10000000) {
urls.push(info.url);
}
}
}
}
resolve(urls);
} catch (e) {
reject(e);
}
});
}).on('error', reject);
});
}
// Buscar con términos alternativos
async function searchWithAlternatives(name, country) {
const countryName = country === 'DO' ? 'Dominican Republic' : 'Puerto Rico';
const countryNameEs = country === 'DO' ? 'República Dominicana' : 'Puerto Rico';
// Lista de búsquedas a intentar
const searches = [
`${name} ${countryNameEs}`,
`${name} ${countryName}`,
name,
`${name} Caribbean`,
];
for (const query of searches) {
console.log(` Buscando: "${query}"`);
const images = await searchWikimediaImages(query, 5);
if (images.length > 0) {
return images.slice(0, 3); // Máximo 3 imágenes
}
await delay(500); // Rate limit
}
return [];
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function main() {
console.log('🖼️ Buscador de Imágenes - Wikimedia Commons');
console.log('============================================\n');
// Conectar a la DB
const client = new Client(config.db);
await client.connect();
console.log('✅ Conectado a la base de datos\n');
// Obtener monumentos sin imágenes válidas
const query = `
SELECT id, name, country, slug, images
FROM tourism.places_of_interest
WHERE active = true
AND (images IS NULL OR images::text = '[]' OR images::text LIKE '%example.com%')
ORDER BY name
`;
const result = await client.query(query);
const places = result.rows;
console.log(`📍 Encontrados ${places.length} lugares sin imágenes válidas\n`);
let found = 0;
let notFound = 0;
for (let i = 0; i < places.length; i++) {
const place = places[i];
console.log(`[${i + 1}/${places.length}] ${place.name} (${place.country})`);
try {
const images = await searchWithAlternatives(place.name, place.country);
if (images.length > 0) {
// Guardar en la DB
await client.query(
`UPDATE tourism.places_of_interest SET images = $1 WHERE id = $2`,
[JSON.stringify(images), place.id]
);
console.log(`${images.length} imágenes encontradas`);
found++;
} else {
console.log(` ⚠️ No se encontraron imágenes`);
notFound++;
}
} catch (error) {
console.log(` ❌ Error: ${error.message}`);
notFound++;
}
// Rate limit para la API de Wikimedia
await delay(1000);
}
await client.end();
console.log('\n============================================');
console.log('📊 RESUMEN');
console.log(` ✅ Con imágenes: ${found}`);
console.log(` ⚠️ Sin imágenes: ${notFound}`);
console.log('============================================\n');
}
main().catch(err => {
console.error('Error fatal:', err);
process.exit(1);
});

213
scripts/generate-audios.js Executable file
View File

@@ -0,0 +1,213 @@
#!/usr/bin/env node
/**
* Script standalone para generar audios TTS para todos los monumentos
* Usa Piper TTS directamente sin pasar por el API
*/
const { Client } = require('pg');
const { exec } = require('child_process');
const { promisify } = require('util');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const execAsync = promisify(exec);
// Configuración
const config = {
db: {
host: 'localhost',
port: 5432,
user: 'karibeo',
password: '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',
database: 'karibeo_db',
},
tts: {
piperPath: '/home/karibeo-api/karibeo-api/tts/piper/piper',
voicesPath: '/home/karibeo-api/karibeo-api/tts/voices',
cachePath: '/home/karibeo-api/karibeo-api/tts/cache',
baseUrl: 'https://api.karibeo.ai:8443',
},
voiceMap: {
es: 'es_ES-davefx-medium',
en: 'en_US-amy-medium',
fr: 'fr_FR-siwis-medium',
it: 'it_IT-riccardo-x_low',
de: 'de_DE-thorsten-medium',
},
languages: ['es', 'en', 'fr', 'it', 'de'],
};
// Limpiar texto para TTS
function cleanTextForSpeech(text) {
return text
.replace(/[\u4E00-\u9FFF]/g, '')
.replace(/[\u3400-\u4DBF]/g, '')
.replace(/[\u3040-\u309F]/g, '')
.replace(/[\u30A0-\u30FF]/g, '')
.replace(/[\uAC00-\uD7AF]/g, '')
.replace(/[\u{1F300}-\u{1F9FF}]/gu, '')
.replace(/[\u{2600}-\u{26FF}]/gu, '')
.replace(/[\u{2700}-\u{27BF}]/gu, '')
.replace(/[\u{1F600}-\u{1F64F}]/gu, '')
.replace(/[\u{1F680}-\u{1F6FF}]/gu, '')
.replace(/[\u{1F1E0}-\u{1F1FF}]/gu, '')
.replace(/[★☆✓✗✔✘●○◆◇▪▫►◄→←↑↓⇒⇐⇑⇓♠♣♥♦]/g, '')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/__([^_]+)__/g, '$1')
.replace(/_([^_]+)_/g, '$1')
.replace(/\s+/g, ' ')
.trim();
}
function getAudioHash(text, language) {
const content = `${language}:${text}`;
return crypto.createHash('md5').update(content).digest('hex');
}
async function generateAudio(text, language) {
const voice = config.voiceMap[language] || config.voiceMap['en'];
const voiceModel = path.join(config.tts.voicesPath, `${voice}.onnx`);
if (!fs.existsSync(voiceModel)) {
console.log(` ⚠️ Modelo de voz no encontrado: ${voiceModel}`);
return null;
}
const cleanText = cleanTextForSpeech(text)
.replace(/[\n\r]/g, ' ')
.replace(/"/g, "'")
.substring(0, 3000);
const hash = getAudioHash(cleanText, language);
const fileName = `${hash}.wav`;
const filePath = path.join(config.tts.cachePath, fileName);
// Si ya existe, retornar URL
if (fs.existsSync(filePath)) {
console.log(` 📦 Cache hit: ${fileName}`);
return `${config.tts.baseUrl}/api/v1/tts/audio/${fileName}`;
}
// Crear directorio si no existe
if (!fs.existsSync(config.tts.cachePath)) {
fs.mkdirSync(config.tts.cachePath, { recursive: true });
}
// Guardar texto en archivo temporal
const tempTextFile = path.join(config.tts.cachePath, `${hash}.txt`);
fs.writeFileSync(tempTextFile, cleanText, 'utf-8');
try {
const command = `cat "${tempTextFile}" | "${config.tts.piperPath}" --model "${voiceModel}" --output_file "${filePath}" 2>/dev/null`;
await execAsync(command, { timeout: 120000 });
// Limpiar archivo temporal
if (fs.existsSync(tempTextFile)) {
fs.unlinkSync(tempTextFile);
}
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath);
console.log(` ✅ Audio generado: ${fileName} (${Math.round(stats.size/1024)}KB)`);
return `${config.tts.baseUrl}/api/v1/tts/audio/${fileName}`;
}
return null;
} catch (error) {
console.log(` ❌ Error TTS: ${error.message}`);
if (fs.existsSync(tempTextFile)) {
fs.unlinkSync(tempTextFile);
}
return null;
}
}
async function main() {
console.log('🎙️ Generador de Audios TTS para Monumentos');
console.log('==========================================\n');
// Verificar Piper
if (!fs.existsSync(config.tts.piperPath)) {
console.error(`❌ Piper no encontrado en: ${config.tts.piperPath}`);
process.exit(1);
}
console.log('✅ Piper TTS encontrado\n');
// Conectar a la DB
const client = new Client(config.db);
await client.connect();
console.log('✅ Conectado a la base de datos\n');
// Obtener lugares con descripción
const query = `
SELECT id, name, slug,
description_es, description_en, description_fr, description_it, description_de,
audio_url_es, audio_url_en, audio_url_fr, audio_url_it, audio_url_de
FROM tourism.places_of_interest
WHERE active = true
ORDER BY id
`;
const result = await client.query(query);
const places = result.rows;
console.log(`📍 Encontrados ${places.length} lugares\n`);
let totalGenerated = 0;
let totalSkipped = 0;
let totalFailed = 0;
for (let i = 0; i < places.length; i++) {
const place = places[i];
console.log(`\n[${i + 1}/${places.length}] ${place.name}`);
for (const lang of config.languages) {
const descCol = `description_${lang}`;
const audioCol = `audio_url_${lang}`;
const description = place[descCol];
const existingAudio = place[audioCol];
if (!description) {
continue;
}
if (existingAudio) {
totalSkipped++;
continue;
}
console.log(` 🔊 Generando audio (${lang})...`);
const audioUrl = await generateAudio(description, lang);
if (audioUrl) {
// Actualizar en DB
await client.query(
`UPDATE tourism.places_of_interest SET ${audioCol} = $1 WHERE id = $2`,
[audioUrl, place.id]
);
totalGenerated++;
} else {
totalFailed++;
}
// Pequeña pausa
await new Promise(r => setTimeout(r, 100));
}
}
await client.end();
console.log('\n==========================================');
console.log('📊 RESUMEN');
console.log(` ✅ Generados: ${totalGenerated}`);
console.log(` ⏭️ Omitidos (ya existían): ${totalSkipped}`);
console.log(` ❌ Fallidos: ${totalFailed}`);
console.log('==========================================\n');
}
main().catch(err => {
console.error('Error fatal:', err);
process.exit(1);
});

332
scripts/generate-missing.js Executable file
View File

@@ -0,0 +1,332 @@
#!/usr/bin/env node
/**
* Script para generar descripciones y audios FALTANTES
*/
const { Client } = require('pg');
const { exec } = require('child_process');
const { promisify } = require('util');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const https = require('https');
const execAsync = promisify(exec);
// Configuración
const config = {
db: {
host: 'localhost',
port: 5432,
user: 'karibeo',
password: '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',
database: 'karibeo_db',
},
kimi: {
apiKey: process.env.KIMI_API_KEY || '',
baseUrl: 'https://api.moonshot.ai/v1',
model: 'moonshot-v1-128k',
},
tts: {
piperPath: '/home/karibeo-api/karibeo-api/tts/piper/piper',
voicesPath: '/home/karibeo-api/karibeo-api/tts/voices',
cachePath: '/home/karibeo-api/karibeo-api/tts/cache',
baseUrl: 'https://api.karibeo.ai:8443',
},
voiceMap: {
es: 'es_ES-davefx-medium',
en: 'en_US-amy-medium',
fr: 'fr_FR-siwis-medium',
it: 'it_IT-riccardo-x_low',
de: 'de_DE-thorsten-medium',
},
languages: ['es', 'en', 'fr', 'it', 'de'],
};
// Prompts por idioma
function getPrompt(placeName, country, language) {
const countryName = country === 'DO' ? 'República Dominicana' : 'Puerto Rico';
const countryNameEn = country === 'DO' ? 'Dominican Republic' : 'Puerto Rico';
const prompts = {
es: `Actúa como un guía turístico ${country === 'DO' ? 'dominicano' : 'puertorriqueño'} experto. Dame una descripción completa e interesante de ${placeName} en ${countryName}. Incluye su historia, importancia cultural, arquitectura, datos curiosos, horarios de visita si aplica. Habla en segunda persona directamente al turista usando "tú" (NO uses "vosotros" ni expresiones de España). Usa español latinoamericano natural. Máximo 250 palabras.`,
en: `Act as an expert ${country === 'DO' ? 'Dominican' : 'Puerto Rican'} tour guide. Give me a complete and interesting description of ${placeName} in ${countryNameEn}. Include its history, cultural importance, architecture, fun facts, and visiting hours if applicable. Speak in second person as if you were giving a tour. Maximum 250 words.`,
fr: `Agis comme un guide touristique ${country === 'DO' ? 'dominicain' : 'portoricain'} expert. Donne-moi une description complète et intéressante de ${placeName} en ${countryName}. Inclus son histoire, son importance culturelle, son architecture, des anecdotes et les horaires de visite si applicable. Parle à la deuxième personne comme si tu donnais une visite guidée. Maximum 250 mots.`,
it: `Agisci come una guida turistica ${country === 'DO' ? 'dominicana' : 'portoricana'} esperta. Dammi una descrizione completa e interessante di ${placeName} in ${countryName}. Includi la sua storia, importanza culturale, architettura, curiosità e orari di visita se applicabile. Parla in seconda persona come se stessi facendo un tour. Massimo 250 parole.`,
de: `Handle als erfahrener ${country === 'DO' ? 'dominikanischer' : 'puertoricanischer'} Reiseführer. Gib mir eine vollständige und interessante Beschreibung von ${placeName} in ${countryName}. Füge die Geschichte, kulturelle Bedeutung, Architektur, interessante Fakten und Besuchszeiten falls zutreffend hinzu. Sprich in der zweiten Person, als würdest du eine Tour geben. Maximal 250 Wörter.`,
};
return prompts[language] || prompts['en'];
}
function getLanguageName(code) {
const names = {
es: 'español latinoamericano',
en: 'English',
fr: 'français',
it: 'italiano',
de: 'Deutsch',
};
return names[code] || 'English';
}
// Llamar a Kimi API
async function callKimiAPI(placeName, country, language) {
const prompt = getPrompt(placeName, country, language);
const data = JSON.stringify({
model: config.kimi.model,
messages: [
{
role: 'system',
content: `Eres un guía turístico experto del Caribe. Responde siempre en ${getLanguageName(language)}. Sé informativo, amigable y entusiasta. No uses emojis.`,
},
{
role: 'user',
content: prompt,
},
],
temperature: 0.7,
max_tokens: 1000,
});
return new Promise((resolve, reject) => {
const url = new URL(`${config.kimi.baseUrl}/chat/completions`);
const options = {
hostname: url.hostname,
port: 443,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.kimi.apiKey}`,
'Content-Length': Buffer.byteLength(data),
},
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
try {
const json = JSON.parse(body);
if (json.choices && json.choices[0] && json.choices[0].message) {
resolve(json.choices[0].message.content);
} else {
reject(new Error('Invalid response: ' + body));
}
} catch (e) {
reject(e);
}
});
});
req.on('error', reject);
req.setTimeout(60000, () => {
req.destroy();
reject(new Error('Request timeout'));
});
req.write(data);
req.end();
});
}
// Limpiar texto para TTS
function cleanTextForSpeech(text) {
return text
.replace(/[\u4E00-\u9FFF]/g, '')
.replace(/[\u3400-\u4DBF]/g, '')
.replace(/[\u3040-\u309F]/g, '')
.replace(/[\u30A0-\u30FF]/g, '')
.replace(/[\uAC00-\uD7AF]/g, '')
.replace(/[\u{1F300}-\u{1F9FF}]/gu, '')
.replace(/[\u{2600}-\u{26FF}]/gu, '')
.replace(/[\u{2700}-\u{27BF}]/gu, '')
.replace(/[\u{1F600}-\u{1F64F}]/gu, '')
.replace(/[\u{1F680}-\u{1F6FF}]/gu, '')
.replace(/[\u{1F1E0}-\u{1F1FF}]/gu, '')
.replace(/[★☆✓✗✔✘●○◆◇▪▫►◄→←↑↓⇒⇐⇑⇓♠♣♥♦]/g, '')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/__([^_]+)__/g, '$1')
.replace(/_([^_]+)_/g, '$1')
.replace(/\s+/g, ' ')
.trim();
}
function getAudioHash(text, language) {
const content = `${language}:${text}`;
return crypto.createHash('md5').update(content).digest('hex');
}
async function generateAudio(text, language) {
const voice = config.voiceMap[language] || config.voiceMap['en'];
const voiceModel = path.join(config.tts.voicesPath, `${voice}.onnx`);
if (!fs.existsSync(voiceModel)) {
console.log(` ⚠️ Modelo de voz no encontrado: ${voiceModel}`);
return null;
}
const cleanText = cleanTextForSpeech(text)
.replace(/[\n\r]/g, ' ')
.replace(/"/g, "'")
.substring(0, 3000);
const hash = getAudioHash(cleanText, language);
const fileName = `${hash}.wav`;
const filePath = path.join(config.tts.cachePath, fileName);
if (fs.existsSync(filePath)) {
console.log(` 📦 Cache hit: ${fileName}`);
return `${config.tts.baseUrl}/api/v1/tts/audio/${fileName}`;
}
if (!fs.existsSync(config.tts.cachePath)) {
fs.mkdirSync(config.tts.cachePath, { recursive: true });
}
const tempTextFile = path.join(config.tts.cachePath, `${hash}.txt`);
fs.writeFileSync(tempTextFile, cleanText, 'utf-8');
try {
const command = `cat "${tempTextFile}" | "${config.tts.piperPath}" --model "${voiceModel}" --output_file "${filePath}" 2>/dev/null`;
await execAsync(command, { timeout: 120000 });
if (fs.existsSync(tempTextFile)) {
fs.unlinkSync(tempTextFile);
}
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath);
console.log(` ✅ Audio generado: ${fileName} (${Math.round(stats.size/1024)}KB)`);
return `${config.tts.baseUrl}/api/v1/tts/audio/${fileName}`;
}
return null;
} catch (error) {
console.log(` ❌ Error TTS: ${error.message}`);
if (fs.existsSync(tempTextFile)) {
fs.unlinkSync(tempTextFile);
}
return null;
}
}
async function main() {
console.log('🔧 Generador de Contenido Faltante');
console.log('===================================\n');
// Verificar API key
if (!config.kimi.apiKey) {
// Leer del .env
const envPath = '/home/karibeo-api/karibeo-api/.env';
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf-8');
const match = envContent.match(/KIMI_API_KEY=(.+)/);
if (match) {
config.kimi.apiKey = match[1].trim();
}
}
}
if (!config.kimi.apiKey) {
console.error('❌ KIMI_API_KEY no encontrada');
process.exit(1);
}
console.log('✅ Kimi API Key encontrada\n');
// Conectar a la DB
const client = new Client(config.db);
await client.connect();
console.log('✅ Conectado a la base de datos\n');
// Obtener lugares con contenido faltante
const query = `
SELECT id, name, country,
description_es, description_en, description_fr, description_it, description_de,
audio_url_es, audio_url_en, audio_url_fr, audio_url_it, audio_url_de
FROM tourism.places_of_interest
WHERE active = true
AND (
description_es IS NULL OR description_en IS NULL OR description_fr IS NULL OR description_it IS NULL OR description_de IS NULL
OR audio_url_es IS NULL OR audio_url_en IS NULL OR audio_url_fr IS NULL OR audio_url_it IS NULL OR audio_url_de IS NULL
)
ORDER BY name
`;
const result = await client.query(query);
const places = result.rows;
console.log(`📍 Encontrados ${places.length} lugares con contenido faltante\n`);
let descGenerated = 0;
let audioGenerated = 0;
let errors = 0;
for (let i = 0; i < places.length; i++) {
const place = places[i];
console.log(`\n[${i + 1}/${places.length}] ${place.name}`);
for (const lang of config.languages) {
const descCol = `description_${lang}`;
const audioCol = `audio_url_${lang}`;
let description = place[descCol];
let audioUrl = place[audioCol];
// Generar descripción si falta
if (!description) {
console.log(` 📝 Generando descripción (${lang})...`);
try {
description = await callKimiAPI(place.name, place.country || 'DO', lang);
if (description) {
await client.query(
`UPDATE tourism.places_of_interest SET ${descCol} = $1 WHERE id = $2`,
[description, place.id]
);
console.log(` ✅ Descripción generada (${lang})`);
descGenerated++;
}
} catch (error) {
console.log(` ❌ Error descripción (${lang}): ${error.message}`);
errors++;
}
await new Promise(r => setTimeout(r, 1000)); // Rate limit
}
// Generar audio si falta y tiene descripción
if (description && !audioUrl) {
console.log(` 🔊 Generando audio (${lang})...`);
audioUrl = await generateAudio(description, lang);
if (audioUrl) {
await client.query(
`UPDATE tourism.places_of_interest SET ${audioCol} = $1 WHERE id = $2`,
[audioUrl, place.id]
);
audioGenerated++;
} else {
errors++;
}
await new Promise(r => setTimeout(r, 100));
}
}
}
await client.end();
console.log('\n===================================');
console.log('📊 RESUMEN');
console.log(` 📝 Descripciones generadas: ${descGenerated}`);
console.log(` 🔊 Audios generados: ${audioGenerated}`);
console.log(` ❌ Errores: ${errors}`);
console.log('===================================\n');
}
main().catch(err => {
console.error('Error fatal:', err);
process.exit(1);
});

View File

@@ -0,0 +1,226 @@
-- Script para poblar monumentos adicionales de RD y Puerto Rico (Parte 2)
-- Coordenadas en formato PostgreSQL point: (longitude, latitude)
-- ==========================================
-- REPÚBLICA DOMINICANA - ZONA COLONIAL (Adicionales)
-- ==========================================
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Casa de la Moneda', 'casa-de-la-moneda', 'museum', point(-69.88282, 18.47353), 'DO', 'Calle Las Damas, Zona Colonial, Santo Domingo', false, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Reloj de Sol', 'reloj-de-sol', 'monument', point(-69.88235, 18.47481), 'DO', 'Calle Las Damas, Zona Colonial, Santo Domingo', false, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Casa del Cordón', 'casa-del-cordon', 'monument', point(-69.88312, 18.47594), 'DO', 'Calle Isabel la Católica, Zona Colonial, Santo Domingo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Casa de Tostado', 'casa-de-tostado', 'museum', point(-69.88264, 18.47167), 'DO', 'Calle Padre Billini, Zona Colonial, Santo Domingo', false, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Iglesia de la Merced', 'iglesia-de-la-merced', 'church', point(-69.88724, 18.47398), 'DO', 'Calle Las Mercedes, Zona Colonial, Santo Domingo', false, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Iglesia de Regina Angelorum', 'iglesia-regina-angelorum', 'church', point(-69.88602, 18.47055), 'DO', 'Calle Padre Billini, Zona Colonial, Santo Domingo', false, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Ruinas del Monasterio de San Francisco', 'ruinas-monasterio-san-francisco', 'ruins', point(-69.88478, 18.47648), 'DO', 'Calle Hostos, Zona Colonial, Santo Domingo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Casa de las Gárgolas', 'casa-de-las-gargolas', 'monument', point(-69.88272, 18.47504), 'DO', 'Calle Las Damas, Zona Colonial, Santo Domingo', false, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Museo del Ámbar', 'museo-del-ambar', 'museum', point(-69.88651, 18.47402), 'DO', 'Calle Arzobispo Meriño, Zona Colonial, Santo Domingo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Museo de Larimar', 'museo-de-larimar', 'museum', point(-69.88295, 18.47185), 'DO', 'Calle Isabel la Católica, Zona Colonial, Santo Domingo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- ==========================================
-- SANTO DOMINGO (Fuera de Zona Colonial)
-- ==========================================
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Malecón de Santo Domingo', 'malecon-santo-domingo', 'promenade', point(-69.89381, 18.46351), 'DO', 'Av. George Washington, Santo Domingo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Plaza de la Cultura', 'plaza-de-la-cultura', 'plaza', point(-69.91102, 18.47141), 'DO', 'Av. Máximo Gómez, Santo Domingo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Palacio de Bellas Artes', 'palacio-bellas-artes', 'museum', point(-69.90956, 18.46654), 'DO', 'Av. Máximo Gómez, Santo Domingo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Teatro Nacional Eduardo Brito', 'teatro-nacional', 'theater', point(-69.91008, 18.47172), 'DO', 'Plaza de la Cultura, Santo Domingo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Centro Olímpico Juan Pablo Duarte', 'centro-olimpico', 'sports', point(-69.91605, 18.48002), 'DO', 'Av. 27 de Febrero, Santo Domingo', false, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- ==========================================
-- RD - NORTE (Puerto Plata)
-- ==========================================
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('27 Charcos de Damajagua', '27-charcos-damajagua', 'natural', point(-70.82471, 19.72885), 'DO', 'Imbert, Puerto Plata', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Ocean World Adventure Park', 'ocean-world', 'attraction', point(-70.73155, 19.83152), 'DO', 'Cofresí, Puerto Plata', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Playa Dorada', 'playa-dorada', 'beach', point(-70.64402, 19.76801), 'DO', 'Puerto Plata', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- ==========================================
-- RD - ESTE (Punta Cana, Hato Mayor)
-- ==========================================
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Cueva Fun Fun', 'cueva-fun-fun', 'natural', point(-69.44405, 19.04802), 'DO', 'Hato Mayor', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Hoyo Azul', 'hoyo-azul', 'natural', point(-68.45502, 18.44855), 'DO', 'Scape Park, Cap Cana, Punta Cana', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Indigenous Eyes Ecological Park', 'indigenous-eyes', 'natural', point(-68.37504, 18.51301), 'DO', 'Puntacana Resort & Club', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Playa Bávaro', 'playa-bavaro', 'beach', point(-68.41805, 18.68002), 'DO', 'Bávaro, Punta Cana', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Parque Nacional Los Haitises', 'parque-los-haitises', 'natural', point(-69.51672, 19.06671), 'DO', 'Sabana de la Mar, Hato Mayor', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- ==========================================
-- RD - SUROESTE (Barahona, Pedernales)
-- ==========================================
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Lago Enriquillo', 'lago-enriquillo', 'natural', point(-71.65002, 18.48405), 'DO', 'Independencia / Bahoruco', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Isla Cabritos', 'isla-cabritos', 'natural', point(-71.68504, 18.48802), 'DO', 'Lago Enriquillo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Bahía de las Águilas', 'bahia-de-las-aguilas', 'beach', point(-71.64205, 17.86402), 'DO', 'Parque Nacional Jaragua, Pedernales', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Parque Nacional Jaragua', 'parque-jaragua', 'natural', point(-71.50004, 17.85002), 'DO', 'Oviedo, Pedernales', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- ==========================================
-- RD - MONTAÑAS (Constanza, Jarabacoa)
-- ==========================================
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Aguas Blancas', 'aguas-blancas', 'natural', point(-70.67505, 18.85002), 'DO', 'Constanza, La Vega', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Piedra Letrada', 'piedra-letrada', 'natural', point(-70.76204, 18.78852), 'DO', 'Constanza, La Vega', false, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- ==========================================
-- PUERTO RICO - SAN JUAN (Adicionales)
-- ==========================================
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Museo de Arte de Puerto Rico', 'museo-arte-puerto-rico', 'museum', point(-66.06652, 18.44825), 'PR', 'Av. De Diego 299, Santurce, San Juan', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Fuerte San Juan de la Cruz (El Cañuelo)', 'fuerte-el-canuelo', 'fortress', point(-66.13601, 18.47352), 'PR', 'Isla de Cabras, Toa Baja', false, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Plaza de la Rogativa', 'plaza-de-la-rogativa', 'plaza', point(-66.11972, 18.46685), 'PR', 'Caleta de las Monjas, Viejo San Juan', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Fuente Raíces', 'fuente-raices', 'monument', point(-66.11865, 18.46382), 'PR', 'Paseo de la Princesa, Viejo San Juan', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- ==========================================
-- PUERTO RICO - COSTA NORTE
-- ==========================================
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Observatorio de Arecibo', 'observatorio-arecibo', 'museum', point(-66.75282, 18.34421), 'PR', 'Carretera 625, Arecibo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Playa Crash Boat', 'playa-crash-boat', 'beach', point(-67.16301, 18.45802), 'PR', 'Aguadilla', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Parque de las Cavernas del Río Camuy', 'cavernas-rio-camuy', 'natural', point(-66.82405, 18.34702), 'PR', 'Carretera 129, Camuy', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- ==========================================
-- PUERTO RICO - COSTA SUR
-- ==========================================
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Porta Coeli', 'porta-coeli', 'church', point(-67.04021, 18.08182), 'PR', 'San Germán', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('La Parguera', 'la-parguera', 'natural', point(-67.04505, 17.97202), 'PR', 'Lajas', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Bosque Seco de Guánica', 'bosque-seco-guanica', 'natural', point(-66.86554, 17.97152), 'PR', 'Guánica', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- ==========================================
-- PUERTO RICO - CENTRO / MONTAÑAS
-- ==========================================
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Toro Verde Adventure Park', 'toro-verde', 'attraction', point(-66.39101, 18.25202), 'PR', 'Orocovis', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Hacienda Buena Vista', 'hacienda-buena-vista', 'museum', point(-66.65481, 18.08442), 'PR', 'Ponce', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Bosque Estatal de Toro Negro', 'bosque-toro-negro', 'natural', point(-66.58784, 18.17252), 'PR', 'Jayuya/Ciales', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- ==========================================
-- PUERTO RICO - ISLAS
-- ==========================================
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Faro de Culebrita', 'faro-culebrita', 'monument', point(-65.23004, 18.31502), 'PR', 'Isla Culebrita, Culebra', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Sun Bay Beach', 'sun-bay-beach', 'beach', point(-65.46205, 18.10002), 'PR', 'Vieques', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- Mostrar resultado
SELECT 'Parte 2 completada!' as status, COUNT(*) as total_places FROM tourism.places_of_interest WHERE active = true;
SELECT country, COUNT(*) as cantidad FROM tourism.places_of_interest WHERE active = true GROUP BY country ORDER BY country;

226
scripts/seed-monuments.sql Normal file
View File

@@ -0,0 +1,226 @@
-- Script para poblar monumentos de RD y Puerto Rico
-- Coordenadas en formato PostgreSQL point: (longitude, latitude)
-- Agregar unique constraint en slug si no existe
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'places_of_interest_slug_key') THEN
ALTER TABLE tourism.places_of_interest ADD CONSTRAINT places_of_interest_slug_key UNIQUE (slug);
END IF;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- REPÚBLICA DOMINICANA - ZONA COLONIAL
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Catedral Primada de América', 'catedral-primada-de-america', 'monument', point(-69.884250, 18.472988), 'DO', 'Calle Arzobispo Meriño, Zona Colonial, Santo Domingo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Fortaleza Ozama', 'fortaleza-ozama', 'fortress', point(-69.8817, 18.4732), 'DO', 'Calle Las Damas, Zona Colonial, Santo Domingo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Alcázar de Colón', 'alcazar-de-colon', 'palace', point(-69.8832, 18.4775), 'DO', 'Plaza de España, Zona Colonial, Santo Domingo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Faro a Colón', 'faro-a-colon', 'monument', point(-69.8682, 18.4786), 'DO', 'Av. España, Santo Domingo Este', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Panteón Nacional', 'panteon-nacional', 'monument', point(-69.8845, 18.4735), 'DO', 'Calle Las Damas, Zona Colonial, Santo Domingo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Parque Colón', 'parque-colon', 'park', point(-69.8842, 18.4730), 'DO', 'Calle El Conde, Zona Colonial, Santo Domingo', false, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Calle Las Damas', 'calle-las-damas', 'street', point(-69.8820, 18.4738), 'DO', 'Zona Colonial, Santo Domingo', false, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Museo de las Casas Reales', 'museo-casas-reales', 'museum', point(-69.8825, 18.4742), 'DO', 'Calle Las Damas, Zona Colonial, Santo Domingo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Puerta del Conde', 'puerta-del-conde', 'monument', point(-69.8955, 18.4692), 'DO', 'Parque Independencia, Santo Domingo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Parque Independencia', 'parque-independencia', 'park', point(-69.8960, 18.4688), 'DO', 'Av. Bolívar, Santo Domingo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Los Tres Ojos', 'los-tres-ojos', 'natural', point(-69.8545, 18.4695), 'DO', 'Parque Mirador del Este, Santo Domingo Este', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Jardín Botánico Nacional', 'jardin-botanico-nacional', 'park', point(-69.9467, 18.4950), 'DO', 'Av. República de Colombia, Santo Domingo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Palacio Nacional', 'palacio-nacional', 'palace', point(-69.9140, 18.4750), 'DO', 'Av. México, Santo Domingo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Ruinas del Hospital San Nicolás de Bari', 'ruinas-hospital-san-nicolas', 'ruins', point(-69.8852, 18.4722), 'DO', 'Calle Hostos, Zona Colonial, Santo Domingo', false, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Iglesia de Santa Bárbara', 'iglesia-santa-barbara', 'church', point(-69.8812, 18.4755), 'DO', 'Calle Isabel la Católica, Zona Colonial, Santo Domingo', false, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Convento de los Dominicos', 'convento-dominicos', 'monument', point(-69.8868, 18.4725), 'DO', 'Calle Padre Billini, Zona Colonial, Santo Domingo', false, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Plaza de España', 'plaza-de-espana', 'plaza', point(-69.8828, 18.4770), 'DO', 'Zona Colonial, Santo Domingo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- RD - SANTIAGO
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Monumento a los Héroes de la Restauración', 'monumento-heroes-restauracion', 'monument', point(-70.6931, 19.4792), 'DO', 'Centro de Santiago de los Caballeros', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- RD - PUERTO PLATA
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Fortaleza San Felipe', 'fortaleza-san-felipe', 'fortress', point(-70.6940, 19.7982), 'DO', 'Puerto Plata', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Teleférico de Puerto Plata', 'teleferico-puerto-plata', 'attraction', point(-70.6875, 19.7842), 'DO', 'Monte Isabel de Torres, Puerto Plata', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- RD - LA ROMANA
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Altos de Chavón', 'altos-de-chavon', 'village', point(-68.9620, 18.4270), 'DO', 'Casa de Campo, La Romana', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- RD - SAMANÁ
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Salto El Limón', 'salto-el-limon', 'natural', point(-69.4420, 19.2850), 'DO', 'El Limón, Samaná', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Playa Rincón', 'playa-rincon', 'beach', point(-69.2385, 19.2120), 'DO', 'Samaná', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Cayo Levantado', 'cayo-levantado', 'island', point(-69.3840, 19.1750), 'DO', 'Bahía de Samaná', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- RD - HIGÜEY
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Basílica Nuestra Señora de la Altagracia', 'basilica-altagracia', 'church', point(-68.7108, 18.6155), 'DO', 'Higüey, La Altagracia', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- RD - CONSTANZA
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Valle Nuevo', 'valle-nuevo', 'natural', point(-70.6020, 18.7880), 'DO', 'Constanza, La Vega', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- RD - JARABACOA
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Salto de Jimenoa', 'salto-jimenoa', 'natural', point(-70.6210, 19.1320), 'DO', 'Jarabacoa, La Vega', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Pico Duarte', 'pico-duarte', 'natural', point(-70.9893, 19.0291), 'DO', 'Cordillera Central', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- RD - BAYAHIBE
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Isla Saona', 'isla-saona', 'island', point(-68.7280, 18.1550), 'DO', 'Parque Nacional del Este', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- ==========================================
-- PUERTO RICO - VIEJO SAN JUAN
-- ==========================================
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Castillo San Felipe del Morro', 'castillo-san-felipe-del-morro', 'fortress', point(-66.1212, 18.4693), 'PR', '501 Calle Norzagaray, San Juan', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Castillo San Cristóbal', 'castillo-san-cristobal', 'fortress', point(-66.1067, 18.4670), 'PR', 'Calle Norzagaray, San Juan', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('La Fortaleza', 'la-fortaleza', 'palace', point(-66.1190, 18.4641), 'PR', '63 Calle de la Fortaleza, San Juan', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Catedral de San Juan Bautista', 'catedral-san-juan-bautista', 'church', point(-66.1170, 18.4660), 'PR', '153 Calle del Cristo, San Juan', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Capilla del Cristo', 'capilla-del-cristo', 'church', point(-66.1195, 18.4636), 'PR', 'Calle del Cristo, San Juan', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Casa Blanca', 'casa-blanca-pr', 'museum', point(-66.1185, 18.4685), 'PR', '1 Calle San Sebastián, San Juan', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Paseo de la Princesa', 'paseo-de-la-princesa', 'promenade', point(-66.1175, 18.4620), 'PR', 'Paseo de la Princesa, San Juan', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Puerta de San Juan', 'puerta-de-san-juan', 'monument', point(-66.1200, 18.4645), 'PR', 'Recinto Sur, San Juan', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Cementerio Santa María Magdalena de Pazzis', 'cementerio-santa-maria-pazzis', 'cemetery', point(-66.1225, 18.4705), 'PR', 'Calle Norzagaray, San Juan', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Plaza de Armas', 'plaza-de-armas-pr', 'plaza', point(-66.1155, 18.4655), 'PR', 'Calle San Francisco, San Juan', false, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Parque de las Palomas', 'parque-de-las-palomas', 'park', point(-66.1190, 18.4635), 'PR', 'Final Calle del Cristo, San Juan', false, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- PUERTO RICO - PONCE
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Parque de Bombas', 'parque-de-bombas', 'museum', point(-66.6142, 18.0125), 'PR', 'Plaza Las Delicias, Ponce', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Catedral Nuestra Señora de Guadalupe', 'catedral-ponce', 'church', point(-66.6138, 18.0128), 'PR', 'Plaza Las Delicias, Ponce', false, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Castillo Serrallés', 'castillo-serralles', 'palace', point(-66.6285, 18.0205), 'PR', 'El Vigía, Ponce', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- PUERTO RICO - NATURALEZA
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('El Yunque National Forest', 'el-yunque', 'natural', point(-65.7847, 18.3154), 'PR', 'Río Grande', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Bahía Bioluminiscente de Vieques', 'bahia-bioluminiscente-vieques', 'natural', point(-65.4775, 18.0965), 'PR', 'Mosquito Bay, Vieques', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Cueva del Indio', 'cueva-del-indio', 'natural', point(-66.6375, 18.4835), 'PR', 'Arecibo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Flamenco Beach', 'flamenco-beach', 'beach', point(-65.3180, 18.3290), 'PR', 'Culebra', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Bahía Bioluminiscente de Fajardo', 'laguna-grande-fajardo', 'natural', point(-65.6350, 18.3640), 'PR', 'Las Croabas, Fajardo', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
INSERT INTO tourism.places_of_interest (name, slug, category, coordinates, country, address, featured, active, created_at, updated_at)
VALUES ('Cañón San Cristóbal', 'canon-san-cristobal', 'natural', point(-66.3510, 18.1890), 'PR', 'Barranquitas/Aibonito', true, true, NOW(), NOW())
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, coordinates = EXCLUDED.coordinates, address = EXCLUDED.address, updated_at = NOW();
-- Mostrar resultado
SELECT 'Seeding completado!' as status, COUNT(*) as total_places FROM tourism.places_of_interest WHERE active = true;
SELECT country, COUNT(*) as cantidad FROM tourism.places_of_interest WHERE active = true GROUP BY country;

View File

@@ -13,6 +13,7 @@ import appConfig from './config/app.config';
import stripeConfig from './config/integrations/stripe.config';
import awsConfig from './config/integrations/aws.config';
import communicationConfig from './config/integrations/communication.config';
import kimiConfig from './config/integrations/kimi.config';
// Entity imports
import { User } from './entities/user.entity';
@@ -76,6 +77,10 @@ import { Listing } from './entities/listing.entity';
import { Vehicle } from './entities/vehicle.entity';
import { Flight } from './entities/flight.entity';
import { Availability } from './entities/availability.entity';
import { UserFavorite } from './entities/user-favorite.entity';
import { UserCollection, CollectionItem } from './entities/user-collection.entity';
import { UserTrip, TripDay, TripActivity } from './entities/user-trip.entity';
import { TravelQuizResponse } from './entities/travel-quiz.entity';
// Module imports
@@ -92,6 +97,7 @@ import { CommunicationModule } from './modules/communication/communication.modul
import { RestaurantModule } from './modules/restaurant/restaurant.module';
import { HotelModule } from './modules/hotel/hotel.module';
import { AIGuideModule } from './modules/ai-guide/ai-guide.module';
import { KimiModule } from './modules/kimi/kimi.module';
import { GeolocationModule } from './modules/geolocation/geolocation.module';
import { ReviewsModule } from './modules/reviews/reviews.module';
import { AIGeneratorModule } from './modules/ai-generator/ai-generator.module';
@@ -105,7 +111,14 @@ import { ChannelManagementModule } from './modules/channel-management/channel-ma
import { ListingsModule } from './modules/listings/listings.module';
import { VehicleManagementModule } from './modules/vehicle-management/vehicle-management.module';
import { FlightManagementModule } from './modules/flight-management/flight-management.module';
import { BookingModule } from './modules/booking/booking.module';
import { UnifiedSearchModule } from './modules/unified-search/unified-search.module';
import { AvailabilityManagementModule } from './modules/availability-management/availability-management.module';
import { FavoritesModule } from './modules/favorites/favorites.module';
import { CollectionsModule } from './modules/collections/collections.module';
import { TripsModule } from './modules/trips/trips.module';
import { QuizModule } from './modules/quiz/quiz.module';
import { ContentGeneratorModule } from './modules/content-generator/content-generator.module';
@Module({
@@ -120,6 +133,7 @@ import { AvailabilityManagementModule } from './modules/availability-management/
stripeConfig,
awsConfig,
communicationConfig,
kimiConfig,
],
envFilePath: '.env',
}),
@@ -157,7 +171,7 @@ import { AvailabilityManagementModule } from './modules/availability-management/
// Finance entities
CommissionRate, AdminTransaction, Settlement,
// NUEVAS Entidades
Channel, Listing, Vehicle, Flight, Availability,
Channel, Listing, Vehicle, Flight, Availability, UserFavorite, UserCollection, CollectionItem, UserTrip, TripDay, TripActivity, TravelQuizResponse,
],
}),
inject: [ConfigService],
@@ -200,6 +214,11 @@ import { AvailabilityManagementModule } from './modules/availability-management/
ChannelManagementModule,
ListingsModule,
AvailabilityManagementModule,
FavoritesModule,
CollectionsModule,
TripsModule,
QuizModule,
ContentGeneratorModule,
// Integration modules (3)
PaymentsModule,
@@ -207,6 +226,7 @@ import { AvailabilityManagementModule } from './modules/availability-management/
CommunicationModule,
// Advanced features modules (3)
KimiModule,
AIGuideModule,
GeolocationModule,
ReviewsModule,
@@ -214,6 +234,8 @@ import { AvailabilityManagementModule } from './modules/availability-management/
// Logistics & Booking Modules (2) - Nueva categoría
VehicleManagementModule,
FlightManagementModule,
BookingModule,
UnifiedSearchModule,
// Innovation 2025 modules (5)
AIGeneratorModule,

View File

@@ -0,0 +1,20 @@
import { registerAs } from '@nestjs/config';
export default registerAs('booking', () => ({
// Booking.com Demand API credentials
affiliateId: process.env.BOOKING_AFFILIATE_ID || '',
apiKey: process.env.BOOKING_API_KEY || '',
// API Base URL
baseUrl: process.env.BOOKING_BASE_URL || 'https://demandapi.booking.com/3.1',
// Environment: 'sandbox' or 'production'
environment: process.env.BOOKING_ENVIRONMENT || 'sandbox',
// Default settings
defaultCurrency: process.env.BOOKING_DEFAULT_CURRENCY || 'USD',
defaultLanguage: process.env.BOOKING_DEFAULT_LANGUAGE || 'es',
// Rate limiting
maxRequestsPerSecond: parseInt(process.env.BOOKING_MAX_REQUESTS_PER_SECOND || '5', 10),
}));

View File

@@ -0,0 +1,11 @@
import { registerAs } from '@nestjs/config';
export default registerAs('kimi', () => ({
apiKey: process.env.KIMI_API_KEY,
baseUrl: process.env.KIMI_BASE_URL || 'https://api.moonshot.ai/v1',
model: process.env.KIMI_MODEL || 'kimi-k2.5',
mode: process.env.KIMI_MODE || 'instant',
temperature: parseFloat(process.env.KIMI_TEMPERATURE || '0.6'),
maxTokens: parseInt(process.env.KIMI_MAX_TOKENS || '4096', 10),
topP: parseFloat(process.env.KIMI_TOP_P || '0.95'),
}));

View File

@@ -1,80 +1,138 @@
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { BaseEntity } from './base.entity';
import { Destination } from './destination.entity';
@Entity({ name: 'places_of_interest', schema: 'tourism' })
export class PlaceOfInterest extends BaseEntity {
@ApiProperty({ description: 'Destination ID', example: 1 })
@Column({ name: 'destination_id', nullable: true })
destinationId: number;
@ApiProperty({ description: 'Place name', example: 'Alcázar de Colón' })
@Column({ length: 255 })
name: string;
@ApiProperty({ description: 'Place description' })
@Column({ type: 'text', nullable: true })
description: string;
@ApiProperty({ description: 'Category', example: 'monument' })
@Column({ length: 50, nullable: true })
category: string;
@ApiProperty({ description: 'Coordinates (lat, lng)' })
@Column({ type: 'point' })
coordinates: string;
@ApiProperty({ description: 'Address', example: 'Plaza de Armas, Santo Domingo' })
@Column({ type: 'text', nullable: true })
address: string;
@ApiProperty({ description: 'Phone number', example: '+1809555XXXX' })
@Column({ length: 20, nullable: true })
phone: string;
@ApiProperty({ description: 'Website URL' })
@Column({ length: 255, nullable: true })
website: string;
@ApiProperty({ description: 'Opening hours' })
@Column({ name: 'opening_hours', type: 'jsonb', nullable: true })
openingHours: Record<string, any>;
@ApiProperty({ description: 'Entrance fee', example: 25.00 })
@Column({ name: 'entrance_fee', type: 'decimal', precision: 10, scale: 2, nullable: true })
entranceFee: number;
@ApiProperty({ description: 'Images' })
@Column({ type: 'jsonb', nullable: true })
images: Record<string, any>;
@ApiProperty({ description: 'Historical information' })
@Column({ name: 'historical_info', type: 'text', nullable: true })
historicalInfo: string;
@ApiProperty({ description: 'AR content' })
@Column({ name: 'ar_content', type: 'jsonb', nullable: true })
arContent: Record<string, any>;
@ApiProperty({ description: 'Audio guide URL' })
@Column({ name: 'audio_guide_url', type: 'text', nullable: true })
audioGuideUrl: string;
@ApiProperty({ description: 'Average rating', example: 4.5 })
@Column({ type: 'decimal', precision: 3, scale: 2, nullable: true })
rating: number;
@ApiProperty({ description: 'Total reviews', example: 150 })
@Column({ name: 'total_reviews', default: 0 })
totalReviews: number;
@ApiProperty({ description: 'Active status', example: true })
@Column({ default: true })
active: boolean;
// Relations
@ManyToOne(() => Destination)
@JoinColumn({ name: 'destination_id' })
destination: Destination;
}
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { BaseEntity } from './base.entity';
import { Destination } from './destination.entity';
@Entity({ name: 'places_of_interest', schema: 'tourism' })
export class PlaceOfInterest extends BaseEntity {
@ApiProperty({ description: 'Destination ID', example: 1 })
@Column({ name: 'destination_id', nullable: true })
destinationId: number;
@ApiProperty({ description: 'Place name', example: 'Alcázar de Colón' })
@Column({ length: 255 })
name: string;
@ApiProperty({ description: 'URL-friendly slug', example: 'alcazar-de-colon' })
@Column({ length: 255, nullable: true })
slug: string;
@ApiProperty({ description: 'Place description (legacy)' })
@Column({ type: 'text', nullable: true })
description: string;
// Multi-language descriptions
@ApiProperty({ description: 'Description in Spanish' })
@Column({ name: 'description_es', type: 'text', nullable: true })
descriptionEs: string;
@ApiProperty({ description: 'Description in English' })
@Column({ name: 'description_en', type: 'text', nullable: true })
descriptionEn: string;
@ApiProperty({ description: 'Description in French' })
@Column({ name: 'description_fr', type: 'text', nullable: true })
descriptionFr: string;
@ApiProperty({ description: 'Description in Italian' })
@Column({ name: 'description_it', type: 'text', nullable: true })
descriptionIt: string;
@ApiProperty({ description: 'Description in German' })
@Column({ name: 'description_de', type: 'text', nullable: true })
descriptionDe: string;
// Multi-language audio URLs
@ApiProperty({ description: 'Audio URL in Spanish' })
@Column({ name: 'audio_url_es', type: 'text', nullable: true })
audioUrlEs: string;
@ApiProperty({ description: 'Audio URL in English' })
@Column({ name: 'audio_url_en', type: 'text', nullable: true })
audioUrlEn: string;
@ApiProperty({ description: 'Audio URL in French' })
@Column({ name: 'audio_url_fr', type: 'text', nullable: true })
audioUrlFr: string;
@ApiProperty({ description: 'Audio URL in Italian' })
@Column({ name: 'audio_url_it', type: 'text', nullable: true })
audioUrlIt: string;
@ApiProperty({ description: 'Audio URL in German' })
@Column({ name: 'audio_url_de', type: 'text', nullable: true })
audioUrlDe: string;
@ApiProperty({ description: 'Voice ID for TTS consistency' })
@Column({ name: 'voice_id', length: 50, nullable: true })
voiceId: string;
@ApiProperty({ description: 'Country code (DO, PR)', example: 'DO' })
@Column({ length: 2, default: 'DO' })
country: string;
@ApiProperty({ description: 'Featured place', example: false })
@Column({ default: false })
featured: boolean;
@ApiProperty({ description: 'Category', example: 'monument' })
@Column({ length: 50, nullable: true })
category: string;
@ApiProperty({ description: 'Coordinates (lat, lng)' })
@Column({ type: 'point' })
coordinates: string;
@ApiProperty({ description: 'Address', example: 'Plaza de Armas, Santo Domingo' })
@Column({ type: 'text', nullable: true })
address: string;
@ApiProperty({ description: 'Phone number', example: '+1809555XXXX' })
@Column({ length: 20, nullable: true })
phone: string;
@ApiProperty({ description: 'Website URL' })
@Column({ length: 255, nullable: true })
website: string;
@ApiProperty({ description: 'Opening hours' })
@Column({ name: 'opening_hours', type: 'jsonb', nullable: true })
openingHours: Record<string, any>;
@ApiProperty({ description: 'Entrance fee', example: 25.00 })
@Column({ name: 'entrance_fee', type: 'decimal', precision: 10, scale: 2, nullable: true })
entranceFee: number;
@ApiProperty({ description: 'Images' })
@Column({ type: 'jsonb', nullable: true })
images: Record<string, any>;
@ApiProperty({ description: 'Historical information' })
@Column({ name: 'historical_info', type: 'text', nullable: true })
historicalInfo: string;
@ApiProperty({ description: 'AR content' })
@Column({ name: 'ar_content', type: 'jsonb', nullable: true })
arContent: Record<string, any>;
@ApiProperty({ description: 'Audio guide URL (legacy)' })
@Column({ name: 'audio_guide_url', type: 'text', nullable: true })
audioGuideUrl: string;
@ApiProperty({ description: 'Average rating', example: 4.5 })
@Column({ type: 'decimal', precision: 3, scale: 2, nullable: true })
rating: number;
@ApiProperty({ description: 'Total reviews', example: 150 })
@Column({ name: 'total_reviews', default: 0 })
totalReviews: number;
@ApiProperty({ description: 'Active status', example: true })
@Column({ default: true })
active: boolean;
// Relations
@ManyToOne(() => Destination)
@JoinColumn({ name: 'destination_id' })
destination: Destination;
}

View File

@@ -0,0 +1,69 @@
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { BaseEntity } from './base.entity';
import { User } from './user.entity';
@Entity({ name: 'travel_quiz_responses', schema: 'analytics' })
@Index(['userId'], { unique: true })
export class TravelQuizResponse extends BaseEntity {
@ApiProperty({ description: 'User ID' })
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@ApiProperty({ description: 'Travel styles', type: 'array' })
@Column({ name: 'travel_styles', type: 'text', array: true, default: [] })
travelStyles: string[];
@ApiProperty({ description: 'Preferred activities', type: 'array' })
@Column({ name: 'preferred_activities', type: 'text', array: true, default: [] })
preferredActivities: string[];
@ApiProperty({ description: 'Accommodation preferences', type: 'array' })
@Column({ name: 'accommodation_preferences', type: 'text', array: true, default: [] })
accommodationPreferences: string[];
@ApiProperty({ description: 'Budget range' })
@Column({ name: 'budget_range', length: 50, nullable: true })
budgetRange: string;
@ApiProperty({ description: 'Trip duration preference' })
@Column({ name: 'trip_duration', length: 50, nullable: true })
tripDuration: string;
@ApiProperty({ description: 'Group type', example: 'solo' })
@Column({ name: 'group_type', length: 50, nullable: true })
groupType: string;
@ApiProperty({ description: 'Cuisine preferences', type: 'array' })
@Column({ name: 'cuisine_preferences', type: 'text', array: true, default: [] })
cuisinePreferences: string[];
@ApiProperty({ description: 'Interests', type: 'array' })
@Column({ type: 'text', array: true, default: [] })
interests: string[];
@ApiProperty({ description: 'Accessibility needs', type: 'array' })
@Column({ name: 'accessibility_needs', type: 'text', array: true, default: [] })
accessibilityNeeds: string[];
@ApiPropertyOptional({ description: 'AI-generated travel persona' })
@Column({ name: 'travel_persona', length: 100, nullable: true })
travelPersona: string;
@ApiPropertyOptional({ description: 'Persona description' })
@Column({ name: 'persona_description', type: 'text', nullable: true })
personaDescription: string;
@ApiProperty({ description: 'Quiz completed', default: false })
@Column({ name: 'is_completed', default: false })
isCompleted: boolean;
@ApiPropertyOptional({ description: 'Completed at' })
@Column({ name: 'completed_at', type: 'timestamp', nullable: true })
completedAt: Date;
// Relations
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
}

View File

@@ -0,0 +1,85 @@
import { Entity, Column, ManyToOne, OneToMany, JoinColumn, Index } from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { BaseEntity } from './base.entity';
import { User } from './user.entity';
@Entity({ name: 'user_collections', schema: 'tourism' })
@Index(['userId', 'name'], { unique: true })
export class UserCollection extends BaseEntity {
@ApiProperty({ description: 'User ID' })
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@ApiProperty({ description: 'Collection name', example: 'My Beach Trip' })
@Column({ length: 100 })
name: string;
@ApiPropertyOptional({ description: 'Collection description' })
@Column({ type: 'text', nullable: true })
description: string;
@ApiPropertyOptional({ description: 'Cover image URL' })
@Column({ name: 'cover_image_url', type: 'text', nullable: true })
coverImageUrl: string;
@ApiPropertyOptional({ description: 'Collection color theme', example: '#FF5722' })
@Column({ length: 20, nullable: true })
color: string;
@ApiPropertyOptional({ description: 'Collection icon', example: 'beach' })
@Column({ length: 50, nullable: true })
icon: string;
@ApiProperty({ description: 'Is public collection', default: false })
@Column({ name: 'is_public', default: false })
isPublic: boolean;
@ApiProperty({ description: 'Sort order for display', default: 0 })
@Column({ name: 'sort_order', default: 0 })
sortOrder: number;
// Relations
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@OneToMany(() => CollectionItem, item => item.collection)
items: CollectionItem[];
}
@Entity({ name: 'collection_items', schema: 'tourism' })
@Index(['collectionId', 'itemId', 'itemType'], { unique: true })
export class CollectionItem extends BaseEntity {
@ApiProperty({ description: 'Collection ID' })
@Column({ name: 'collection_id', type: 'uuid' })
collectionId: string;
@ApiProperty({ description: 'Item ID' })
@Column({ name: 'item_id', type: 'uuid' })
itemId: string;
@ApiProperty({ description: 'Item type', example: 'place' })
@Column({ name: 'item_type', length: 50 })
itemType: string;
@ApiPropertyOptional({ description: 'Item name (cached)' })
@Column({ name: 'item_name', length: 255, nullable: true })
itemName: string;
@ApiPropertyOptional({ description: 'Item image URL (cached)' })
@Column({ name: 'item_image_url', type: 'text', nullable: true })
itemImageUrl: string;
@ApiPropertyOptional({ description: 'User notes for this item in the collection' })
@Column({ type: 'text', nullable: true })
notes: string;
@ApiProperty({ description: 'Sort order within collection', default: 0 })
@Column({ name: 'sort_order', default: 0 })
sortOrder: number;
// Relations
@ManyToOne(() => UserCollection, collection => collection.items, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'collection_id' })
collection: UserCollection;
}

View File

@@ -0,0 +1,51 @@
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { BaseEntity } from './base.entity';
import { User } from './user.entity';
export enum FavoriteItemType {
PLACE = 'place',
RESTAURANT = 'restaurant',
HOTEL = 'hotel',
ATTRACTION = 'attraction',
TOUR = 'tour',
EXPERIENCE = 'experience',
PRODUCT = 'product',
}
@Entity({ name: 'user_favorites', schema: 'tourism' })
@Index(['userId', 'itemId', 'itemType'], { unique: true })
export class UserFavorite extends BaseEntity {
@ApiProperty({ description: 'User ID' })
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@ApiProperty({ description: 'Item ID (can be place, hotel, restaurant, etc.)' })
@Column({ name: 'item_id', type: 'uuid' })
itemId: string;
@ApiProperty({ description: 'Type of favorited item', enum: FavoriteItemType })
@Column({ name: 'item_type', type: 'enum', enum: FavoriteItemType })
itemType: FavoriteItemType;
@ApiPropertyOptional({ description: 'Item name (cached for display)' })
@Column({ name: 'item_name', length: 255, nullable: true })
itemName: string;
@ApiPropertyOptional({ description: 'Item image URL (cached for display)' })
@Column({ name: 'item_image_url', type: 'text', nullable: true })
itemImageUrl: string;
@ApiPropertyOptional({ description: 'Item metadata (cached for display)' })
@Column({ name: 'item_metadata', type: 'jsonb', nullable: true })
itemMetadata: Record<string, any>;
@ApiPropertyOptional({ description: 'User notes about this favorite' })
@Column({ type: 'text', nullable: true })
notes: string;
// Relations
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
}

View File

@@ -0,0 +1,193 @@
import { Entity, Column, ManyToOne, OneToMany, JoinColumn, Index } from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { BaseEntity } from './base.entity';
import { User } from './user.entity';
export enum TripStatus {
PLANNING = 'planning',
UPCOMING = 'upcoming',
IN_PROGRESS = 'in_progress',
COMPLETED = 'completed',
CANCELLED = 'cancelled',
}
@Entity({ name: 'user_trips', schema: 'tourism' })
@Index(['userId', 'status'])
export class UserTrip extends BaseEntity {
@ApiProperty({ description: 'User ID' })
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@ApiProperty({ description: 'Trip name', example: 'Summer Vacation in DR' })
@Column({ length: 150 })
name: string;
@ApiPropertyOptional({ description: 'Trip description' })
@Column({ type: 'text', nullable: true })
description: string;
@ApiPropertyOptional({ description: 'Cover image URL' })
@Column({ name: 'cover_image_url', type: 'text', nullable: true })
coverImageUrl: string;
@ApiPropertyOptional({ description: 'Start date' })
@Column({ name: 'start_date', type: 'date', nullable: true })
startDate: Date;
@ApiPropertyOptional({ description: 'End date' })
@Column({ name: 'end_date', type: 'date', nullable: true })
endDate: Date;
@ApiProperty({ description: 'Trip status', enum: TripStatus, default: TripStatus.PLANNING })
@Column({ type: 'enum', enum: TripStatus, default: TripStatus.PLANNING })
status: TripStatus;
@ApiPropertyOptional({ description: 'Destination city/region' })
@Column({ length: 100, nullable: true })
destination: string;
@ApiPropertyOptional({ description: 'Number of travelers', default: 1 })
@Column({ name: 'travelers_count', default: 1 })
travelersCount: number;
@ApiPropertyOptional({ description: 'Estimated budget' })
@Column({ name: 'estimated_budget', type: 'decimal', precision: 10, scale: 2, nullable: true })
estimatedBudget: number;
@ApiPropertyOptional({ description: 'Budget currency', default: 'USD' })
@Column({ name: 'budget_currency', length: 3, default: 'USD' })
budgetCurrency: string;
@ApiPropertyOptional({ description: 'Trip tags', type: 'array' })
@Column({ type: 'text', array: true, nullable: true })
tags: string[];
@ApiProperty({ description: 'Is public trip', default: false })
@Column({ name: 'is_public', default: false })
isPublic: boolean;
@ApiPropertyOptional({ description: 'Trip notes/comments' })
@Column({ type: 'text', nullable: true })
notes: string;
@ApiPropertyOptional({ description: 'AI-generated summary' })
@Column({ name: 'ai_summary', type: 'text', nullable: true })
aiSummary: string;
// Relations
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@OneToMany(() => TripDay, day => day.trip)
days: TripDay[];
}
@Entity({ name: 'trip_days', schema: 'tourism' })
@Index(['tripId', 'dayNumber'])
export class TripDay extends BaseEntity {
@ApiProperty({ description: 'Trip ID' })
@Column({ name: 'trip_id', type: 'uuid' })
tripId: string;
@ApiProperty({ description: 'Day number', example: 1 })
@Column({ name: 'day_number' })
dayNumber: number;
@ApiPropertyOptional({ description: 'Date for this day' })
@Column({ type: 'date', nullable: true })
date: Date;
@ApiPropertyOptional({ description: 'Day title', example: 'Exploring Santo Domingo' })
@Column({ length: 150, nullable: true })
title: string;
@ApiPropertyOptional({ description: 'Day notes' })
@Column({ type: 'text', nullable: true })
notes: string;
// Relations
@ManyToOne(() => UserTrip, trip => trip.days, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'trip_id' })
trip: UserTrip;
@OneToMany(() => TripActivity, activity => activity.day)
activities: TripActivity[];
}
@Entity({ name: 'trip_activities', schema: 'tourism' })
@Index(['dayId', 'sortOrder'])
export class TripActivity extends BaseEntity {
@ApiProperty({ description: 'Day ID' })
@Column({ name: 'day_id', type: 'uuid' })
dayId: string;
@ApiPropertyOptional({ description: 'Reference to place/item ID' })
@Column({ name: 'item_id', type: 'uuid', nullable: true })
itemId: string;
@ApiPropertyOptional({ description: 'Item type', example: 'place' })
@Column({ name: 'item_type', length: 50, nullable: true })
itemType: string;
@ApiProperty({ description: 'Activity title', example: 'Visit Zona Colonial' })
@Column({ length: 200 })
title: string;
@ApiPropertyOptional({ description: 'Activity description' })
@Column({ type: 'text', nullable: true })
description: string;
@ApiPropertyOptional({ description: 'Start time', example: '09:00' })
@Column({ name: 'start_time', length: 10, nullable: true })
startTime: string;
@ApiPropertyOptional({ description: 'End time', example: '12:00' })
@Column({ name: 'end_time', length: 10, nullable: true })
endTime: string;
@ApiPropertyOptional({ description: 'Duration in minutes' })
@Column({ name: 'duration_minutes', nullable: true })
durationMinutes: number;
@ApiPropertyOptional({ description: 'Location name' })
@Column({ name: 'location_name', length: 255, nullable: true })
locationName: string;
@ApiPropertyOptional({ description: 'Location address' })
@Column({ name: 'location_address', type: 'text', nullable: true })
locationAddress: string;
@ApiPropertyOptional({ description: 'Location coordinates' })
@Column({ name: 'location_coords', type: 'jsonb', nullable: true })
locationCoords: { lat: number; lng: number };
@ApiPropertyOptional({ description: 'Estimated cost' })
@Column({ name: 'estimated_cost', type: 'decimal', precision: 10, scale: 2, nullable: true })
estimatedCost: number;
@ApiPropertyOptional({ description: 'Activity image URL' })
@Column({ name: 'image_url', type: 'text', nullable: true })
imageUrl: string;
@ApiPropertyOptional({ description: 'Activity notes' })
@Column({ type: 'text', nullable: true })
notes: string;
@ApiProperty({ description: 'Is confirmed/booked', default: false })
@Column({ name: 'is_booked', default: false })
isBooked: boolean;
@ApiPropertyOptional({ description: 'Booking reference' })
@Column({ name: 'booking_reference', length: 100, nullable: true })
bookingReference: string;
@ApiProperty({ description: 'Sort order', default: 0 })
@Column({ name: 'sort_order', default: 0 })
sortOrder: number;
// Relations
@ManyToOne(() => TripDay, day => day.activities, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'day_id' })
day: TripDay;
}

View File

@@ -25,6 +25,7 @@ export class User extends BaseEntity {
@ApiProperty({ description: 'Last name', example: 'Doe' })
@Column({ name: 'last_name', length: 100 })
lastName: string;
@ApiProperty({ description: 'Username', example: 'johndoe' }) @Column({ unique: true, nullable: true, length: 50 }) username: string;
@ApiProperty({ description: 'Phone number', example: '+1234567890' })
@Column({ nullable: true, length: 20 })

View File

@@ -0,0 +1,35 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ContentGeneratorService } from './modules/content-generator/content-generator.service';
async function bootstrap() {
const app = await NestFactory.createApplicationContext(AppModule);
const generator = app.get(ContentGeneratorService);
console.log('📊 Estadísticas actuales:');
const stats = await generator.getStats();
console.log(stats);
console.log('\n🚀 Iniciando generación COMPLETA de descripciones...');
console.log('📝 101 lugares x 5 idiomas = 505 descripciones');
console.log('⏱️ Tiempo estimado: ~40 minutos\n');
const results = await generator.generateAllDescriptions({
limit: 200, // Todos los lugares
languages: ['es', 'en', 'fr', 'it', 'de'], // Los 5 idiomas
onlyMissing: true,
});
console.log('\n✅ Resultados FINALES:');
console.log(`Total procesados: ${results.length}`);
console.log(`Exitosos: ${results.filter(r => r.success).length}`);
console.log(`Fallidos: ${results.filter(r => !r.success).length}`);
console.log('\n📊 Estadísticas finales:');
const finalStats = await generator.getStats();
console.log(finalStats);
await app.close();
}
bootstrap().catch(console.error);

28
src/generate-content.ts Normal file
View File

@@ -0,0 +1,28 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ContentGeneratorService } from './modules/content-generator/content-generator.service';
async function bootstrap() {
const app = await NestFactory.createApplicationContext(AppModule);
const generator = app.get(ContentGeneratorService);
console.log('📊 Estadísticas actuales:');
const stats = await generator.getStats();
console.log(stats);
console.log('\n🚀 Iniciando generación de descripciones...');
const results = await generator.generateAllDescriptions({
limit: 5, // Solo 5 para probar
languages: ['es', 'en'], // Solo ES y EN primero
onlyMissing: true,
});
console.log('\n✅ Resultados:');
console.log(`Total procesados: ${results.length}`);
console.log(`Exitosos: ${results.filter(r => r.success).length}`);
console.log(`Fallidos: ${results.filter(r => !r.success).length}`);
await app.close();
}
bootstrap().catch(console.error);

View File

@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AIGuideService } from './ai-guide.service';
import { AIGuideController } from './ai-guide.controller';
import { AIGuideService } from './ai-guide.service';
import { TTSService } from './tts.service';
import { TTSController } from './tts.controller';
import { AIGuideInteraction } from '../../entities/ai-guide-interaction.entity';
import { ARContent } from '../../entities/ar-content.entity';
import { PlaceOfInterest } from '../../entities/place-of-interest.entity';
import { KimiModule } from '../kimi/kimi.module';
@Module({
imports: [
@@ -13,9 +16,10 @@ import { PlaceOfInterest } from '../../entities/place-of-interest.entity';
ARContent,
PlaceOfInterest,
]),
KimiModule,
],
controllers: [AIGuideController],
providers: [AIGuideService],
exports: [AIGuideService],
controllers: [AIGuideController, TTSController],
providers: [AIGuideService, TTSService],
exports: [AIGuideService, TTSService],
})
export class AIGuideModule {}

View File

@@ -1,8 +1,9 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { KimiService, KimiMessage } from '../kimi/kimi.service';
import { TTSService } from './tts.service';
import { AIGuideInteraction } from '../../entities/ai-guide-interaction.entity';
import { ARContent } from '../../entities/ar-content.entity';
import { PlaceOfInterest } from '../../entities/place-of-interest.entity';
@@ -12,6 +13,11 @@ import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class AIGuideService {
private readonly logger = new Logger(AIGuideService.name);
// Cache de conversaciones por sesión
private conversationCache: Map<string, KimiMessage[]> = new Map();
constructor(
@InjectRepository(AIGuideInteraction)
private readonly interactionRepository: Repository<AIGuideInteraction>,
@@ -20,6 +26,8 @@ export class AIGuideService {
@InjectRepository(PlaceOfInterest)
private readonly placeRepository: Repository<PlaceOfInterest>,
private readonly configService: ConfigService,
private readonly kimiService: KimiService,
private readonly ttsService: TTSService, // OpenAI TTS Service
) {}
async processAIQuery(queryDto: AIQueryDto, userId: string): Promise<{
@@ -48,6 +56,7 @@ export class AIGuideService {
break;
case InteractionType.GENERAL_QUESTION:
// ✅ ACTUALIZADO: Usar Kimi para preguntas generales
aiResponse = await this.processGeneralQuestion(queryDto, sessionId);
suggestions = await this.generateSuggestions(queryDto.query, queryDto.language);
break;
@@ -56,7 +65,9 @@ export class AIGuideService {
if (queryDto.latitude && queryDto.longitude) {
arContent = await this.findNearbyARContent(queryDto.latitude, queryDto.longitude);
}
aiResponse = `Found ${arContent.length} AR experiences near your location. Touch any item to activate the augmented reality experience.`;
aiResponse = queryDto.language === 'es'
? `Encontré ${arContent.length} experiencias AR cerca de tu ubicación. Toca cualquier elemento para activar la experiencia de realidad aumentada.`
: `Found ${arContent.length} AR experiences near your location. Touch any item to activate the augmented reality experience.`;
break;
case InteractionType.AUDIO_GUIDE:
@@ -73,7 +84,7 @@ export class AIGuideService {
case InteractionType.RECOMMENDATIONS:
nearbyPlaces = await this.getPersonalizedRecommendations(userId, queryDto);
aiResponse = this.formatRecommendations(nearbyPlaces);
aiResponse = await this.formatRecommendationsWithAI(nearbyPlaces, queryDto.language);
break;
default:
@@ -86,8 +97,8 @@ export class AIGuideService {
placeId: queryDto.placeId,
userQuery: queryDto.query,
aiResponse,
userLocation: queryDto.latitude && queryDto.longitude ?
`POINT(${queryDto.longitude} ${queryDto.latitude})` : undefined,
userLocation: queryDto.latitude && queryDto.longitude ?
`(${queryDto.longitude}, ${queryDto.latitude})` : undefined,
interactionType: queryDto.interactionType,
language: queryDto.language || 'en',
sessionId,
@@ -104,27 +115,102 @@ export class AIGuideService {
};
} catch (error) {
this.logger.error(`Error processing AI query: ${error.message}`);
throw new BadRequestException(`AI processing failed: ${error.message}`);
}
}
// ✅ ACTUALIZADO: Usar Kimi para preguntas generales
private async processGeneralQuestion(queryDto: AIQueryDto, sessionId: string): Promise<string> {
const language = queryDto.language || 'es';
// Obtener historial de conversación de la sesión
let conversationHistory = this.conversationCache.get(sessionId) || [];
// Agregar contexto de ubicación REAL si está disponible
let contextualQuery = queryDto.query;
if (queryDto.latitude && queryDto.longitude) {
// Determinar ciudad basada en coordenadas
const userCity = this.getCityFromCoordinates(queryDto.latitude, queryDto.longitude);
// Agregar ubicación REAL al contexto para que la IA responda correctamente
const locationContext = language === 'es'
? `\n\n[UBICACIÓN ACTUAL DEL USUARIO: ${userCity} (Coordenadas: ${queryDto.latitude}, ${queryDto.longitude}). IMPORTANTE: Solo recomienda lugares en ${userCity} o a máximo 30 minutos de distancia. NO menciones lugares a horas de distancia como ${userCity === 'Santo Domingo' ? 'Punta Cana, Samaná, Puerto Plata' : 'otras ciudades lejanas'}.]`
: `\n\n[USER CURRENT LOCATION: ${userCity} (Coordinates: ${queryDto.latitude}, ${queryDto.longitude}). IMPORTANT: Only recommend places in ${userCity} or within 30 minutes distance. DO NOT mention places hours away.]`;
contextualQuery = queryDto.query + locationContext;
}
try {
// ✅ Usar Kimi para generar la respuesta
const response = await this.kimiService.chatWithTravelAssistant(
contextualQuery,
conversationHistory,
language,
);
// Actualizar historial de conversación
conversationHistory.push(
{ role: 'user', content: queryDto.query },
{ role: 'assistant', content: response }
);
// Mantener solo las últimas 10 interacciones
if (conversationHistory.length > 20) {
conversationHistory = conversationHistory.slice(-20);
}
this.conversationCache.set(sessionId, conversationHistory);
return response;
} catch (error) {
this.logger.error(`Kimi error: ${error.message}, using fallback`);
return this.kimiService['getFallbackResponse']([
{ role: 'user', content: queryDto.query }
]);
}
}
// ✅ ACTUALIZADO: Usar Kimi para reconocimiento de monumentos
private async recognizeMonument(queryDto: AIQueryDto): Promise<{
response: string;
arContent: ARContent[];
suggestions: string[];
}> {
// Simulate monument recognition using image analysis
// In production, this would use Google Vision API, AWS Rekognition, or custom ML model
const language = queryDto.language || 'es';
// Si hay imagen, usar Kimi para procesarla
if (queryDto.imageUrl) {
try {
const imageAnalysis = await this.kimiService.processImage(
queryDto.imageUrl,
language === 'es'
? 'Identifica este monumento o lugar turístico. Proporciona su nombre, historia breve y datos interesantes.'
: 'Identify this monument or tourist place. Provide its name, brief history and interesting facts.',
language
);
return {
response: imageAnalysis,
arContent: [],
suggestions: language === 'es'
? ['Cuéntame más sobre su historia', 'Qué puedo hacer aquí', 'Restaurantes cercanos']
: ['Tell me more about its history', 'What can I do here', 'Nearby restaurants'],
};
} catch (error) {
this.logger.error(`Image processing error: ${error.message}`);
}
}
// Fallback: buscar por ubicación
let recognizedPlace: PlaceOfInterest | null = null;
if (queryDto.latitude && queryDto.longitude) {
// Find nearby monuments
const nearbyPlaces = await this.placeRepository
.createQueryBuilder('place')
.where('place.active = :active', { active: true })
.andWhere('place.category IN (:...categories)', {
categories: ['monument', 'historic-site', 'museum', 'landmark']
.andWhere('place.category IN (:...categories)', {
categories: ['monument', 'historic-site', 'museum', 'landmark']
})
.orderBy('place.rating', 'DESC')
.limit(1)
@@ -135,13 +221,13 @@ export class AIGuideService {
if (!recognizedPlace) {
return {
response: "I can see this is a beautiful location, but I need more information to identify it precisely. Could you tell me where you are or provide more details?",
response: language === 'es'
? "Puedo ver que es un lugar hermoso, pero necesito más información para identificarlo. ¿Podrías decirme dónde estás o proporcionar más detalles?"
: "I can see this is a beautiful location, but I need more information to identify it precisely. Could you tell me where you are or provide more details?",
arContent: [],
suggestions: [
"Tell me your current location",
"What type of building is this?",
"Show me nearby attractions"
]
suggestions: language === 'es'
? ['Dime tu ubicación actual', '¿Qué tipo de edificio es?', 'Mostrar atracciones cercanas']
: ['Tell me your current location', 'What type of building is this?', 'Show me nearby attractions'],
};
}
@@ -151,67 +237,28 @@ export class AIGuideService {
order: { viewsCount: 'DESC' },
});
const response = this.generateMonumentDescription(recognizedPlace);
// ✅ Usar Kimi para generar descripción rica del monumento
const response = await this.kimiService.chatWithTravelAssistant(
language === 'es'
? `Dame una descripción detallada y atractiva de ${recognizedPlace.name}. Incluye historia, datos interesantes y tips para visitantes.`
: `Give me a detailed and engaging description of ${recognizedPlace.name}. Include history, interesting facts and tips for visitors.`,
[],
language
);
return {
response,
arContent,
suggestions: [
"Tell me more about its history",
"What can I do here?",
"Show me AR experience",
"Find nearby restaurants"
]
suggestions: language === 'es'
? ['Cuéntame más sobre su historia', 'Qué puedo hacer aquí', 'Ver experiencia AR', 'Restaurantes cercanos']
: ['Tell me more about its history', 'What can I do here', 'Show AR experience', 'Find nearby restaurants'],
};
}
private async processGeneralQuestion(queryDto: AIQueryDto, sessionId: string): Promise<string> {
// This would integrate with OpenAI GPT, Google Bard, or custom NLP model
// For now, we'll simulate responses based on common tourism questions
const query = queryDto.query.toLowerCase();
const language = queryDto.language || 'en';
// Predefined responses for common questions
const responses = {
en: {
weather: "The Dominican Republic has a tropical climate with warm temperatures year-round. The dry season (December-April) is ideal for visiting, with less humidity and minimal rainfall.",
food: "Dominican cuisine features delicious dishes like mofongo, sancocho, and fresh seafood. Don't miss trying local fruits like mangoes and passion fruit!",
safety: "Tourist areas in the DR are generally safe. Stay in well-lit areas, use official taxis, and keep your belongings secure. POLITUR officers are available to help tourists.",
currency: "The Dominican Peso (DOP) is the local currency, but US dollars are widely accepted in tourist areas. Credit cards are accepted at most hotels and restaurants.",
language: "Spanish is the official language, but English is commonly spoken in tourist areas. Learning basic Spanish phrases is always appreciated!",
default: "I'm here to help you explore the beautiful Dominican Republic and Puerto Rico! Ask me about attractions, restaurants, safety tips, or anything else you'd like to know."
},
es: {
weather: "La República Dominicana tiene un clima tropical con temperaturas cálidas todo el año. La temporada seca (diciembre-abril) es ideal para visitar.",
food: "La cocina dominicana incluye platos deliciosos como mofongo, sancocho y mariscos frescos. ¡No te pierdas las frutas tropicales!",
safety: "Las áreas turísticas en RD son generalmente seguras. Mantente en áreas bien iluminadas y usa taxis oficiales.",
currency: "El peso dominicano (DOP) es la moneda local, pero los dólares estadounidenses son ampliamente aceptados.",
language: "El español es el idioma oficial. ¡Aprender algunas frases básicas siempre es apreciado!",
default: "¡Estoy aquí para ayudarte a explorar la hermosa República Dominicana y Puerto Rico! Pregúntame sobre atracciones, restaurantes o cualquier cosa."
}
};
const langResponses = responses[language] || responses.en;
// Simple keyword matching (in production, use proper NLP)
if (query.includes('weather') || query.includes('clima')) {
return langResponses.weather;
} else if (query.includes('food') || query.includes('comida') || query.includes('restaurant')) {
return langResponses.food;
} else if (query.includes('safe') || query.includes('segur')) {
return langResponses.safety;
} else if (query.includes('money') || query.includes('currency') || query.includes('dinero')) {
return langResponses.currency;
} else if (query.includes('language') || query.includes('idioma')) {
return langResponses.language;
}
return langResponses.default;
}
// ✅ ACTUALIZADO: Usar Kimi para generar sugerencias contextuales
private async generateSuggestions(query: string, language: string = 'en'): Promise<string[]> {
const suggestions = {
// Sugerencias base según idioma
const baseSuggestions = {
en: [
"What are the best beaches to visit?",
"Show me historic sites nearby",
@@ -230,21 +277,41 @@ export class AIGuideService {
]
};
return suggestions[language] || suggestions.en;
// Por ahora usar sugerencias base, en el futuro Kimi puede generar contextuales
return baseSuggestions[language] || baseSuggestions.en;
}
private generateMonumentDescription(place: PlaceOfInterest): string {
return `This is ${place.name}, ${place.description || 'a significant landmark in the Dominican Republic'}.
Built in the ${place.historicalInfo ? 'historic period' : '16th century'}, this site represents an important part of Caribbean colonial history.
// ✅ NUEVO: Formatear recomendaciones usando Kimi
private async formatRecommendationsWithAI(places: PlaceOfInterest[], language: string = 'es'): Promise<string> {
if (places.length === 0) {
return language === 'es'
? "No tengo recomendaciones específicas para esta área ahora mismo, ¡pero estaría encantado de ayudarte a explorar lo que hay cerca!"
: "I don't have specific recommendations for this area right now, but I'd be happy to help you explore what's nearby!";
}
Rating: ${place.rating}/5 (${place.totalReviews} reviews)
const placesInfo = places.map((place, index) =>
`${index + 1}. ${place.name} - ${place.category} - Rating: ${place.rating}/5 - ${place.description?.substring(0, 150)}`
).join('\n');
Would you like to explore AR content, hear an audio guide, or learn more about nearby attractions?`;
const prompt = language === 'es'
? `Basándote en estos lugares, genera una recomendación atractiva y personalizada:\n\n${placesInfo}\n\nFormato: Lista con emojis, breve y atractivo.`
: `Based on these places, generate an attractive personalized recommendation:\n\n${placesInfo}\n\nFormat: List with emojis, brief and attractive.`;
try {
return await this.kimiService.chatWithTravelAssistant(prompt, [], language);
} catch (error) {
// Fallback simple
const formatted = places.map((place, index) =>
`${index + 1}. **${place.name}** (${place.rating}/5) - ${place.description?.substring(0, 100)}...`
).join('\n\n');
return language === 'es'
? `Basado en tu ubicación y preferencias, aquí están mis recomendaciones:\n\n${formatted}`
: `Based on your location and preferences, here are my top recommendations:\n\n${formatted}`;
}
}
private async findNearbyARContent(latitude: number, longitude: number, radius: number = 100): Promise<ARContent[]> {
// In production, use PostGIS for accurate distance calculations
return this.arContentRepository.find({
where: { isActive: true },
order: { viewsCount: 'DESC' },
@@ -252,17 +319,86 @@ Would you like to explore AR content, hear an audio guide, or learn more about n
});
}
// ✅ ACTUALIZADO: Usar OpenAI TTS para generar audio real
private async generateAudioGuide(queryDto: AIQueryDto): Promise<{ transcript: string; audioUrl: string }> {
// This would integrate with text-to-speech services like AWS Polly, Google TTS, or Azure Speech
const transcript = "Welcome to this historic location. Let me tell you about its fascinating history...";
const audioUrl = "https://karibeo-audio-guides.s3.amazonaws.com/generated-audio-guide.mp3";
return { transcript, audioUrl };
const language = queryDto.language || 'es';
const placeName = queryDto.placeName || queryDto.placeId || 'la Zona Colonial de Santo Domingo';
// Prompts en cada idioma para mejor calidad
const prompts: Record<string, string> = {
es: `Genera un guión para un audio tour de 1-2 minutos sobre ${placeName} en República Dominicana.
Hazlo informativo pero entretenido, como si fueras un guía turístico experto.
Incluye historia, datos curiosos y tips para visitantes.
Habla en segunda persona directamente al turista.`,
en: `Generate a 1-2 minute audio tour script about ${placeName} in the Dominican Republic.
Make it informative but entertaining, as if you were an expert tour guide.
Include history, fun facts and tips for visitors.
Speak directly to the tourist in second person.`,
fr: `Génère un script de visite audio de 1-2 minutes sur ${placeName} en République Dominicaine.
Rends-le informatif mais divertissant, comme si tu étais un guide touristique expert.
Inclus l'histoire, des anecdotes et des conseils pour les visiteurs.`,
it: `Genera uno script per un tour audio di 1-2 minuti su ${placeName} nella Repubblica Dominicana.
Rendilo informativo ma divertente, come se fossi una guida turistica esperta.
Includi storia, curiosità e consigli per i visitatori.`,
de: `Erstelle ein 1-2 minütiges Audio-Tour-Skript über ${placeName} in der Dominikanischen Republik.
Mache es informativ aber unterhaltsam, als wärst du ein erfahrener Reiseführer.
Füge Geschichte, interessante Fakten und Tipps für Besucher hinzu.`,
};
const prompt = prompts[language] || prompts['en'];
try {
// 1. Generar el script con Kimi AI
this.logger.log(`Generating audio guide script for: ${placeName} (${language})`);
const transcript = await this.kimiService.chatWithTravelAssistant(prompt, [], language);
// 2. Convertir a audio con OpenAI TTS
let audioUrl = '';
if (this.ttsService.isAvailable()) {
this.logger.log('Converting transcript to speech with OpenAI TTS...');
const generatedUrl = await this.ttsService.generateSpeech(transcript, language);
if (generatedUrl) {
audioUrl = generatedUrl;
this.logger.log(`Audio generated successfully: ${audioUrl.substring(0, 50)}...`);
} else {
this.logger.warn('TTS generation returned null, using placeholder');
audioUrl = 'https://karibeo-audio-guides.s3.amazonaws.com/placeholder-audio.mp3';
}
} else {
this.logger.warn('TTS service not available, using placeholder');
audioUrl = 'https://karibeo-audio-guides.s3.amazonaws.com/placeholder-audio.mp3';
}
return { transcript, audioUrl };
} catch (error) {
this.logger.error(`Audio guide generation failed: ${error.message}`);
// Fallback response
const fallbackTexts: Record<string, string> = {
es: 'Bienvenido a este histórico lugar. Permíteme contarte sobre su fascinante historia...',
en: 'Welcome to this historic location. Let me tell you about its fascinating history...',
fr: 'Bienvenue dans ce lieu historique. Permettez-moi de vous raconter son histoire fascinante...',
it: 'Benvenuto in questo luogo storico. Lascia che ti racconti la sua affascinante storia...',
de: 'Willkommen an diesem historischen Ort. Lassen Sie mich Ihnen seine faszinierende Geschichte erzählen...',
};
return {
transcript: fallbackTexts[language] || fallbackTexts['en'],
audioUrl: 'https://karibeo-audio-guides.s3.amazonaws.com/placeholder-audio.mp3'
};
}
}
private async getSmartDirections(queryDto: AIQueryDto): Promise<{ instructions: string; waypoints: PlaceOfInterest[] }> {
// Integrate with Google Maps Directions API for optimal routing
const instructions = "Head north for 200 meters, then turn right at the historic plaza. You'll pass several interesting landmarks along the way.";
const language = queryDto.language || 'es';
const instructions = language === 'es'
? "Dirígete hacia el norte por 200 metros, luego gira a la derecha en la plaza histórica. Pasarás por varios puntos de interés en el camino."
: "Head north for 200 meters, then turn right at the historic plaza. You'll pass several interesting landmarks along the way.";
const waypoints = await this.placeRepository.find({
where: { active: true },
take: 3,
@@ -272,7 +408,6 @@ Would you like to explore AR content, hear an audio guide, or learn more about n
}
private async getPersonalizedRecommendations(userId: string, queryDto: AIQueryDto): Promise<PlaceOfInterest[]> {
// This would use ML algorithms to analyze user preferences, past visits, and ratings
return this.placeRepository.find({
where: { active: true },
order: { rating: 'DESC' },
@@ -280,18 +415,6 @@ Would you like to explore AR content, hear an audio guide, or learn more about n
});
}
private formatRecommendations(places: PlaceOfInterest[]): string {
if (places.length === 0) {
return "I don't have specific recommendations for this area right now, but I'd be happy to help you explore what's nearby!";
}
const formatted = places.map((place, index) =>
`${index + 1}. ${place.name} (${place.rating}/5) - ${place.description?.substring(0, 100)}...`
).join('\n\n');
return `Based on your location and preferences, here are my top recommendations:\n\n${formatted}`;
}
private async saveInteraction(interactionData: Partial<AIGuideInteraction>): Promise<void> {
const interaction = this.interactionRepository.create(interactionData);
await this.interactionRepository.save(interaction);
@@ -299,7 +422,6 @@ Would you like to explore AR content, hear an audio guide, or learn more about n
// AR CONTENT MANAGEMENT
async getNearbyARContent(queryDto: ARContentQueryDto): Promise<ARContent[]> {
// In production, use PostGIS for accurate geospatial queries
const query = this.arContentRepository.createQueryBuilder('ar')
.leftJoinAndSelect('ar.place', 'place')
.where('ar.isActive = :active', { active: true });
@@ -322,6 +444,43 @@ Would you like to explore AR content, hear an audio guide, or learn more about n
await this.arContentRepository.increment({ id: arContentId }, 'viewsCount', 1);
}
// ✅ NUEVO: Generar itinerario completo con Kimi
async generateItinerary(params: {
userId: string;
destination: string;
days: number;
interests: string[];
budget: string;
language: string;
}): Promise<string> {
const { userId, destination, days, interests, budget, language } = params;
try {
const itinerary = await this.kimiService.generateItinerary({
destination,
days,
interests,
budget,
language,
});
// Guardar la interacción
await this.saveInteraction({
userId,
userQuery: `Generate itinerary: ${destination}, ${days} days`,
aiResponse: itinerary,
interactionType: 'itinerary-generation' as any,
language,
metadata: { destination, days, interests, budget },
});
return itinerary;
} catch (error) {
this.logger.error(`Itinerary generation error: ${error.message}`);
throw new BadRequestException('Failed to generate itinerary');
}
}
// ANALYTICS
async getAIUsageStats(): Promise<{
totalInteractions: number;
@@ -351,7 +510,6 @@ Would you like to explore AR content, hear an audio guide, or learn more about n
.getRawOne(),
]);
// Get popular queries (simplified version)
const popularQueries = await this.interactionRepository
.createQueryBuilder('interaction')
.select('interaction.userQuery', 'query')
@@ -369,4 +527,41 @@ Would you like to explore AR content, hear an audio guide, or learn more about n
popularQueries: popularQueries.map(item => ({ query: item.query, count: parseInt(item.count) })),
};
}
// ✅ NUEVO: Limpiar cache de conversación
clearConversationCache(sessionId: string): void {
this.conversationCache.delete(sessionId);
}
/**
* Determinar ciudad aproximada basada en coordenadas
*/
private getCityFromCoordinates(lat: number, lng: number): string {
// Santo Domingo area: lat 18.4-18.6, lng -70.0 to -69.8
if (lat >= 18.4 && lat <= 18.6 && lng >= -70.0 && lng <= -69.8) {
return 'Santo Domingo';
}
// Punta Cana area: lat 18.5-18.7, lng -68.5 to -68.3
if (lat >= 18.5 && lat <= 18.7 && lng >= -68.5 && lng <= -68.3) {
return 'Punta Cana';
}
// Santiago area: lat 19.4-19.5, lng -70.7 to -70.6
if (lat >= 19.4 && lat <= 19.5 && lng >= -70.7 && lng <= -70.6) {
return 'Santiago';
}
// Puerto Plata area: lat 19.7-19.9, lng -70.7 to -70.6
if (lat >= 19.7 && lat <= 19.9 && lng >= -70.7 && lng <= -70.6) {
return 'Puerto Plata';
}
// Samaná area: lat 19.2-19.3, lng -69.4 to -69.2
if (lat >= 19.2 && lat <= 19.3 && lng >= -69.4 && lng <= -69.2) {
return 'Samaná';
}
// La Romana area
if (lat >= 18.4 && lat <= 18.5 && lng >= -69.0 && lng <= -68.9) {
return 'La Romana';
}
return 'República Dominicana';
}
}

View File

@@ -0,0 +1,372 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { AIGuideInteraction } from '../../entities/ai-guide-interaction.entity';
import { ARContent } from '../../entities/ar-content.entity';
import { PlaceOfInterest } from '../../entities/place-of-interest.entity';
import { AIQueryDto, InteractionType } from './dto/ai-query.dto';
import { ARContentQueryDto } from './dto/ar-content-query.dto';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class AIGuideService {
constructor(
@InjectRepository(AIGuideInteraction)
private readonly interactionRepository: Repository<AIGuideInteraction>,
@InjectRepository(ARContent)
private readonly arContentRepository: Repository<ARContent>,
@InjectRepository(PlaceOfInterest)
private readonly placeRepository: Repository<PlaceOfInterest>,
private readonly configService: ConfigService,
) {}
async processAIQuery(queryDto: AIQueryDto, userId: string): Promise<{
response: string;
suggestions: string[];
arContent?: ARContent[];
nearbyPlaces?: PlaceOfInterest[];
audioGuideUrl?: string;
sessionId: string;
}> {
const sessionId = queryDto.sessionId || uuidv4();
let aiResponse = '';
let suggestions: string[] = [];
let arContent: ARContent[] = [];
let nearbyPlaces: PlaceOfInterest[] = [];
let audioGuideUrl: string = '';
try {
switch (queryDto.interactionType) {
case InteractionType.MONUMENT_RECOGNITION:
const recognitionResult = await this.recognizeMonument(queryDto);
aiResponse = recognitionResult.response;
arContent = recognitionResult.arContent;
suggestions = recognitionResult.suggestions;
break;
case InteractionType.GENERAL_QUESTION:
aiResponse = await this.processGeneralQuestion(queryDto, sessionId);
suggestions = await this.generateSuggestions(queryDto.query, queryDto.language);
break;
case InteractionType.AR_CONTENT:
if (queryDto.latitude && queryDto.longitude) {
arContent = await this.findNearbyARContent(queryDto.latitude, queryDto.longitude);
}
aiResponse = `Found ${arContent.length} AR experiences near your location. Touch any item to activate the augmented reality experience.`;
break;
case InteractionType.AUDIO_GUIDE:
const audioResult = await this.generateAudioGuide(queryDto);
aiResponse = audioResult.transcript;
audioGuideUrl = audioResult.audioUrl;
break;
case InteractionType.DIRECTIONS:
const directionsResult = await this.getSmartDirections(queryDto);
aiResponse = directionsResult.instructions;
nearbyPlaces = directionsResult.waypoints;
break;
case InteractionType.RECOMMENDATIONS:
nearbyPlaces = await this.getPersonalizedRecommendations(userId, queryDto);
aiResponse = this.formatRecommendations(nearbyPlaces);
break;
default:
aiResponse = await this.processGeneralQuestion(queryDto, sessionId);
}
// Save interaction
await this.saveInteraction({
userId,
placeId: queryDto.placeId,
userQuery: queryDto.query,
aiResponse,
userLocation: queryDto.latitude && queryDto.longitude ?
`POINT(${queryDto.longitude} ${queryDto.latitude})` : undefined,
interactionType: queryDto.interactionType,
language: queryDto.language || 'en',
sessionId,
metadata: queryDto.metadata,
});
return {
response: aiResponse,
suggestions,
arContent,
nearbyPlaces,
audioGuideUrl,
sessionId,
};
} catch (error) {
throw new BadRequestException(`AI processing failed: ${error.message}`);
}
}
private async recognizeMonument(queryDto: AIQueryDto): Promise<{
response: string;
arContent: ARContent[];
suggestions: string[];
}> {
// Simulate monument recognition using image analysis
// In production, this would use Google Vision API, AWS Rekognition, or custom ML model
let recognizedPlace: PlaceOfInterest | null = null;
if (queryDto.latitude && queryDto.longitude) {
// Find nearby monuments
const nearbyPlaces = await this.placeRepository
.createQueryBuilder('place')
.where('place.active = :active', { active: true })
.andWhere('place.category IN (:...categories)', {
categories: ['monument', 'historic-site', 'museum', 'landmark']
})
.orderBy('place.rating', 'DESC')
.limit(1)
.getMany();
recognizedPlace = nearbyPlaces[0] || null;
}
if (!recognizedPlace) {
return {
response: "I can see this is a beautiful location, but I need more information to identify it precisely. Could you tell me where you are or provide more details?",
arContent: [],
suggestions: [
"Tell me your current location",
"What type of building is this?",
"Show me nearby attractions"
]
};
}
// Get AR content for recognized place
const arContent = await this.arContentRepository.find({
where: { placeId: recognizedPlace.id, isActive: true },
order: { viewsCount: 'DESC' },
});
const response = this.generateMonumentDescription(recognizedPlace);
return {
response,
arContent,
suggestions: [
"Tell me more about its history",
"What can I do here?",
"Show me AR experience",
"Find nearby restaurants"
]
};
}
private async processGeneralQuestion(queryDto: AIQueryDto, sessionId: string): Promise<string> {
// This would integrate with OpenAI GPT, Google Bard, or custom NLP model
// For now, we'll simulate responses based on common tourism questions
const query = queryDto.query.toLowerCase();
const language = queryDto.language || 'en';
// Predefined responses for common questions
const responses = {
en: {
weather: "The Dominican Republic has a tropical climate with warm temperatures year-round. The dry season (December-April) is ideal for visiting, with less humidity and minimal rainfall.",
food: "Dominican cuisine features delicious dishes like mofongo, sancocho, and fresh seafood. Don't miss trying local fruits like mangoes and passion fruit!",
safety: "Tourist areas in the DR are generally safe. Stay in well-lit areas, use official taxis, and keep your belongings secure. POLITUR officers are available to help tourists.",
currency: "The Dominican Peso (DOP) is the local currency, but US dollars are widely accepted in tourist areas. Credit cards are accepted at most hotels and restaurants.",
language: "Spanish is the official language, but English is commonly spoken in tourist areas. Learning basic Spanish phrases is always appreciated!",
default: "I'm here to help you explore the beautiful Dominican Republic and Puerto Rico! Ask me about attractions, restaurants, safety tips, or anything else you'd like to know."
},
es: {
weather: "La República Dominicana tiene un clima tropical con temperaturas cálidas todo el año. La temporada seca (diciembre-abril) es ideal para visitar.",
food: "La cocina dominicana incluye platos deliciosos como mofongo, sancocho y mariscos frescos. ¡No te pierdas las frutas tropicales!",
safety: "Las áreas turísticas en RD son generalmente seguras. Mantente en áreas bien iluminadas y usa taxis oficiales.",
currency: "El peso dominicano (DOP) es la moneda local, pero los dólares estadounidenses son ampliamente aceptados.",
language: "El español es el idioma oficial. ¡Aprender algunas frases básicas siempre es apreciado!",
default: "¡Estoy aquí para ayudarte a explorar la hermosa República Dominicana y Puerto Rico! Pregúntame sobre atracciones, restaurantes o cualquier cosa."
}
};
const langResponses = responses[language] || responses.en;
// Simple keyword matching (in production, use proper NLP)
if (query.includes('weather') || query.includes('clima')) {
return langResponses.weather;
} else if (query.includes('food') || query.includes('comida') || query.includes('restaurant')) {
return langResponses.food;
} else if (query.includes('safe') || query.includes('segur')) {
return langResponses.safety;
} else if (query.includes('money') || query.includes('currency') || query.includes('dinero')) {
return langResponses.currency;
} else if (query.includes('language') || query.includes('idioma')) {
return langResponses.language;
}
return langResponses.default;
}
private async generateSuggestions(query: string, language: string = 'en'): Promise<string[]> {
const suggestions = {
en: [
"What are the best beaches to visit?",
"Show me historic sites nearby",
"Find restaurants with local cuisine",
"What activities can I do here?",
"Tell me about local culture",
"How do I get to Santo Domingo?"
],
es: [
"¿Cuáles son las mejores playas para visitar?",
"Muéstrame sitios históricos cercanos",
"Encuentra restaurantes con cocina local",
"¿Qué actividades puedo hacer aquí?",
"Háblame sobre la cultura local",
"¿Cómo llego a Santo Domingo?"
]
};
return suggestions[language] || suggestions.en;
}
private generateMonumentDescription(place: PlaceOfInterest): string {
return `This is ${place.name}, ${place.description || 'a significant landmark in the Dominican Republic'}.
Built in the ${place.historicalInfo ? 'historic period' : '16th century'}, this site represents an important part of Caribbean colonial history.
Rating: ${place.rating}/5 (${place.totalReviews} reviews)
Would you like to explore AR content, hear an audio guide, or learn more about nearby attractions?`;
}
private async findNearbyARContent(latitude: number, longitude: number, radius: number = 100): Promise<ARContent[]> {
// In production, use PostGIS for accurate distance calculations
return this.arContentRepository.find({
where: { isActive: true },
order: { viewsCount: 'DESC' },
take: 10,
});
}
private async generateAudioGuide(queryDto: AIQueryDto): Promise<{ transcript: string; audioUrl: string }> {
// This would integrate with text-to-speech services like AWS Polly, Google TTS, or Azure Speech
const transcript = "Welcome to this historic location. Let me tell you about its fascinating history...";
const audioUrl = "https://karibeo-audio-guides.s3.amazonaws.com/generated-audio-guide.mp3";
return { transcript, audioUrl };
}
private async getSmartDirections(queryDto: AIQueryDto): Promise<{ instructions: string; waypoints: PlaceOfInterest[] }> {
// Integrate with Google Maps Directions API for optimal routing
const instructions = "Head north for 200 meters, then turn right at the historic plaza. You'll pass several interesting landmarks along the way.";
const waypoints = await this.placeRepository.find({
where: { active: true },
take: 3,
});
return { instructions, waypoints };
}
private async getPersonalizedRecommendations(userId: string, queryDto: AIQueryDto): Promise<PlaceOfInterest[]> {
// This would use ML algorithms to analyze user preferences, past visits, and ratings
return this.placeRepository.find({
where: { active: true },
order: { rating: 'DESC' },
take: 5,
});
}
private formatRecommendations(places: PlaceOfInterest[]): string {
if (places.length === 0) {
return "I don't have specific recommendations for this area right now, but I'd be happy to help you explore what's nearby!";
}
const formatted = places.map((place, index) =>
`${index + 1}. ${place.name} (${place.rating}/5) - ${place.description?.substring(0, 100)}...`
).join('\n\n');
return `Based on your location and preferences, here are my top recommendations:\n\n${formatted}`;
}
private async saveInteraction(interactionData: Partial<AIGuideInteraction>): Promise<void> {
const interaction = this.interactionRepository.create(interactionData);
await this.interactionRepository.save(interaction);
}
// AR CONTENT MANAGEMENT
async getNearbyARContent(queryDto: ARContentQueryDto): Promise<ARContent[]> {
// In production, use PostGIS for accurate geospatial queries
const query = this.arContentRepository.createQueryBuilder('ar')
.leftJoinAndSelect('ar.place', 'place')
.where('ar.isActive = :active', { active: true });
if (queryDto.contentType) {
query.andWhere('ar.contentType = :contentType', { contentType: queryDto.contentType });
}
if (queryDto.language) {
query.andWhere(':language = ANY(ar.languages)', { language: queryDto.language });
}
return query
.orderBy('ar.viewsCount', 'DESC')
.limit(10)
.getMany();
}
async incrementARViewCount(arContentId: string): Promise<void> {
await this.arContentRepository.increment({ id: arContentId }, 'viewsCount', 1);
}
// ANALYTICS
async getAIUsageStats(): Promise<{
totalInteractions: number;
byType: Array<{ type: string; count: number }>;
byLanguage: Array<{ language: string; count: number }>;
averageRating: number;
popularQueries: Array<{ query: string; count: number }>;
}> {
const [totalInteractions, byType, byLanguage, avgRating] = await Promise.all([
this.interactionRepository.count(),
this.interactionRepository
.createQueryBuilder('interaction')
.select('interaction.interactionType', 'type')
.addSelect('COUNT(*)', 'count')
.groupBy('interaction.interactionType')
.getRawMany(),
this.interactionRepository
.createQueryBuilder('interaction')
.select('interaction.language', 'language')
.addSelect('COUNT(*)', 'count')
.groupBy('interaction.language')
.getRawMany(),
this.interactionRepository
.createQueryBuilder('interaction')
.select('AVG(interaction.rating)', 'average')
.where('interaction.rating IS NOT NULL')
.getRawOne(),
]);
// Get popular queries (simplified version)
const popularQueries = await this.interactionRepository
.createQueryBuilder('interaction')
.select('interaction.userQuery', 'query')
.addSelect('COUNT(*)', 'count')
.groupBy('interaction.userQuery')
.orderBy('count', 'DESC')
.limit(10)
.getRawMany();
return {
totalInteractions,
byType: byType.map(item => ({ type: item.type, count: parseInt(item.count) })),
byLanguage: byLanguage.map(item => ({ language: item.language, count: parseInt(item.count) })),
averageRating: parseFloat(avgRating?.average || '0'),
popularQueries: popularQueries.map(item => ({ query: item.query, count: parseInt(item.count) })),
};
}
}

View File

@@ -0,0 +1,526 @@
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { KimiService, KimiMessage } from '../kimi/kimi.service';
import { AIGuideInteraction } from '../../entities/ai-guide-interaction.entity';
import { ARContent } from '../../entities/ar-content.entity';
import { PlaceOfInterest } from '../../entities/place-of-interest.entity';
import { AIQueryDto, InteractionType } from './dto/ai-query.dto';
import { ARContentQueryDto } from './dto/ar-content-query.dto';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class AIGuideService {
private readonly logger = new Logger(AIGuideService.name);
// Cache de conversaciones por sesión
private conversationCache: Map<string, KimiMessage[]> = new Map();
constructor(
@InjectRepository(AIGuideInteraction)
private readonly interactionRepository: Repository<AIGuideInteraction>,
@InjectRepository(ARContent)
private readonly arContentRepository: Repository<ARContent>,
@InjectRepository(PlaceOfInterest)
private readonly placeRepository: Repository<PlaceOfInterest>,
private readonly configService: ConfigService,
private readonly kimiService: KimiService, // ✅ NUEVO: Inyectar KimiService
) {}
async processAIQuery(queryDto: AIQueryDto, userId: string): Promise<{
response: string;
suggestions: string[];
arContent?: ARContent[];
nearbyPlaces?: PlaceOfInterest[];
audioGuideUrl?: string;
sessionId: string;
}> {
const sessionId = queryDto.sessionId || uuidv4();
let aiResponse = '';
let suggestions: string[] = [];
let arContent: ARContent[] = [];
let nearbyPlaces: PlaceOfInterest[] = [];
let audioGuideUrl: string = '';
try {
switch (queryDto.interactionType) {
case InteractionType.MONUMENT_RECOGNITION:
const recognitionResult = await this.recognizeMonument(queryDto);
aiResponse = recognitionResult.response;
arContent = recognitionResult.arContent;
suggestions = recognitionResult.suggestions;
break;
case InteractionType.GENERAL_QUESTION:
// ✅ ACTUALIZADO: Usar Kimi para preguntas generales
aiResponse = await this.processGeneralQuestion(queryDto, sessionId);
suggestions = await this.generateSuggestions(queryDto.query, queryDto.language);
break;
case InteractionType.AR_CONTENT:
if (queryDto.latitude && queryDto.longitude) {
arContent = await this.findNearbyARContent(queryDto.latitude, queryDto.longitude);
}
aiResponse = queryDto.language === 'es'
? `Encontré ${arContent.length} experiencias AR cerca de tu ubicación. Toca cualquier elemento para activar la experiencia de realidad aumentada.`
: `Found ${arContent.length} AR experiences near your location. Touch any item to activate the augmented reality experience.`;
break;
case InteractionType.AUDIO_GUIDE:
const audioResult = await this.generateAudioGuide(queryDto);
aiResponse = audioResult.transcript;
audioGuideUrl = audioResult.audioUrl;
break;
case InteractionType.DIRECTIONS:
const directionsResult = await this.getSmartDirections(queryDto);
aiResponse = directionsResult.instructions;
nearbyPlaces = directionsResult.waypoints;
break;
case InteractionType.RECOMMENDATIONS:
nearbyPlaces = await this.getPersonalizedRecommendations(userId, queryDto);
aiResponse = await this.formatRecommendationsWithAI(nearbyPlaces, queryDto.language);
break;
default:
aiResponse = await this.processGeneralQuestion(queryDto, sessionId);
}
// Save interaction
await this.saveInteraction({
userId,
placeId: queryDto.placeId,
userQuery: queryDto.query,
aiResponse,
userLocation: queryDto.latitude && queryDto.longitude ?
`(${queryDto.longitude}, ${queryDto.latitude})` : undefined,
interactionType: queryDto.interactionType,
language: queryDto.language || 'en',
sessionId,
metadata: queryDto.metadata,
});
return {
response: aiResponse,
suggestions,
arContent,
nearbyPlaces,
audioGuideUrl,
sessionId,
};
} catch (error) {
this.logger.error(`Error processing AI query: ${error.message}`);
throw new BadRequestException(`AI processing failed: ${error.message}`);
}
}
// ✅ ACTUALIZADO: Usar Kimi para preguntas generales
private async processGeneralQuestion(queryDto: AIQueryDto, sessionId: string): Promise<string> {
const language = queryDto.language || 'es';
// Obtener historial de conversación de la sesión
let conversationHistory = this.conversationCache.get(sessionId) || [];
// Agregar contexto de ubicación si está disponible
let contextualQuery = queryDto.query;
if (queryDto.latitude && queryDto.longitude) {
const nearbyPlaces = await this.placeRepository
.createQueryBuilder('place')
.where('place.active = :active', { active: true })
.orderBy('place.rating', 'DESC')
.limit(5)
.getMany();
if (nearbyPlaces.length > 0) {
const placesContext = nearbyPlaces.map(p =>
`- ${p.name} (${p.category}): ${p.description?.substring(0, 100)}...`
).join('\n');
contextualQuery = language === 'es'
? `${queryDto.query}\n\n[Contexto: El usuario está cerca de estos lugares:\n${placesContext}]`
: `${queryDto.query}\n\n[Context: The user is near these places:\n${placesContext}]`;
}
}
try {
// ✅ Usar Kimi para generar la respuesta
const response = await this.kimiService.chatWithTravelAssistant(
contextualQuery,
conversationHistory,
language,
);
// Actualizar historial de conversación
conversationHistory.push(
{ role: 'user', content: queryDto.query },
{ role: 'assistant', content: response }
);
// Mantener solo las últimas 10 interacciones
if (conversationHistory.length > 20) {
conversationHistory = conversationHistory.slice(-20);
}
this.conversationCache.set(sessionId, conversationHistory);
return response;
} catch (error) {
this.logger.error(`Kimi error: ${error.message}, using fallback`);
return this.kimiService['getFallbackResponse']([
{ role: 'user', content: queryDto.query }
]);
}
}
// ✅ ACTUALIZADO: Usar Kimi para reconocimiento de monumentos
private async recognizeMonument(queryDto: AIQueryDto): Promise<{
response: string;
arContent: ARContent[];
suggestions: string[];
}> {
const language = queryDto.language || 'es';
// Si hay imagen, usar Kimi para procesarla
if (queryDto.imageUrl) {
try {
const imageAnalysis = await this.kimiService.processImage(
queryDto.imageUrl,
language === 'es'
? 'Identifica este monumento o lugar turístico. Proporciona su nombre, historia breve y datos interesantes.'
: 'Identify this monument or tourist place. Provide its name, brief history and interesting facts.',
language
);
return {
response: imageAnalysis,
arContent: [],
suggestions: language === 'es'
? ['Cuéntame más sobre su historia', 'Qué puedo hacer aquí', 'Restaurantes cercanos']
: ['Tell me more about its history', 'What can I do here', 'Nearby restaurants'],
};
} catch (error) {
this.logger.error(`Image processing error: ${error.message}`);
}
}
// Fallback: buscar por ubicación
let recognizedPlace: PlaceOfInterest | null = null;
if (queryDto.latitude && queryDto.longitude) {
const nearbyPlaces = await this.placeRepository
.createQueryBuilder('place')
.where('place.active = :active', { active: true })
.andWhere('place.category IN (:...categories)', {
categories: ['monument', 'historic-site', 'museum', 'landmark']
})
.orderBy('place.rating', 'DESC')
.limit(1)
.getMany();
recognizedPlace = nearbyPlaces[0] || null;
}
if (!recognizedPlace) {
return {
response: language === 'es'
? "Puedo ver que es un lugar hermoso, pero necesito más información para identificarlo. ¿Podrías decirme dónde estás o proporcionar más detalles?"
: "I can see this is a beautiful location, but I need more information to identify it precisely. Could you tell me where you are or provide more details?",
arContent: [],
suggestions: language === 'es'
? ['Dime tu ubicación actual', '¿Qué tipo de edificio es?', 'Mostrar atracciones cercanas']
: ['Tell me your current location', 'What type of building is this?', 'Show me nearby attractions'],
};
}
// Get AR content for recognized place
const arContent = await this.arContentRepository.find({
where: { placeId: recognizedPlace.id, isActive: true },
order: { viewsCount: 'DESC' },
});
// ✅ Usar Kimi para generar descripción rica del monumento
const response = await this.kimiService.chatWithTravelAssistant(
language === 'es'
? `Dame una descripción detallada y atractiva de ${recognizedPlace.name}. Incluye historia, datos interesantes y tips para visitantes.`
: `Give me a detailed and engaging description of ${recognizedPlace.name}. Include history, interesting facts and tips for visitors.`,
[],
language
);
return {
response,
arContent,
suggestions: language === 'es'
? ['Cuéntame más sobre su historia', 'Qué puedo hacer aquí', 'Ver experiencia AR', 'Restaurantes cercanos']
: ['Tell me more about its history', 'What can I do here', 'Show AR experience', 'Find nearby restaurants'],
};
}
// ✅ ACTUALIZADO: Usar Kimi para generar sugerencias contextuales
private async generateSuggestions(query: string, language: string = 'en'): Promise<string[]> {
// Sugerencias base según idioma
const baseSuggestions = {
en: [
"What are the best beaches to visit?",
"Show me historic sites nearby",
"Find restaurants with local cuisine",
"What activities can I do here?",
"Tell me about local culture",
"How do I get to Santo Domingo?"
],
es: [
"¿Cuáles son las mejores playas para visitar?",
"Muéstrame sitios históricos cercanos",
"Encuentra restaurantes con cocina local",
"¿Qué actividades puedo hacer aquí?",
"Háblame sobre la cultura local",
"¿Cómo llego a Santo Domingo?"
]
};
// Por ahora usar sugerencias base, en el futuro Kimi puede generar contextuales
return baseSuggestions[language] || baseSuggestions.en;
}
// ✅ NUEVO: Formatear recomendaciones usando Kimi
private async formatRecommendationsWithAI(places: PlaceOfInterest[], language: string = 'es'): Promise<string> {
if (places.length === 0) {
return language === 'es'
? "No tengo recomendaciones específicas para esta área ahora mismo, ¡pero estaría encantado de ayudarte a explorar lo que hay cerca!"
: "I don't have specific recommendations for this area right now, but I'd be happy to help you explore what's nearby!";
}
const placesInfo = places.map((place, index) =>
`${index + 1}. ${place.name} - ${place.category} - Rating: ${place.rating}/5 - ${place.description?.substring(0, 150)}`
).join('\n');
const prompt = language === 'es'
? `Basándote en estos lugares, genera una recomendación atractiva y personalizada:\n\n${placesInfo}\n\nFormato: Lista con emojis, breve y atractivo.`
: `Based on these places, generate an attractive personalized recommendation:\n\n${placesInfo}\n\nFormat: List with emojis, brief and attractive.`;
try {
return await this.kimiService.chatWithTravelAssistant(prompt, [], language);
} catch (error) {
// Fallback simple
const formatted = places.map((place, index) =>
`${index + 1}. **${place.name}** (${place.rating}/5) - ${place.description?.substring(0, 100)}...`
).join('\n\n');
return language === 'es'
? `Basado en tu ubicación y preferencias, aquí están mis recomendaciones:\n\n${formatted}`
: `Based on your location and preferences, here are my top recommendations:\n\n${formatted}`;
}
}
private async findNearbyARContent(latitude: number, longitude: number, radius: number = 100): Promise<ARContent[]> {
return this.arContentRepository.find({
where: { isActive: true },
order: { viewsCount: 'DESC' },
take: 10,
});
}
// ✅ ACTUALIZADO: Usar Kimi para generar audio guide
private async generateAudioGuide(queryDto: AIQueryDto): Promise<{ transcript: string; audioUrl: string }> {
const language = queryDto.language || 'es';
const prompt = language === 'es'
? `Genera un guión para un audio tour de 2 minutos sobre ${queryDto.placeId ? 'este lugar histórico' : 'la Zona Colonial de Santo Domingo'}.
Hazlo informativo pero entretenido, como si fueras un guía turístico experto.`
: `Generate a 2-minute audio tour script about ${queryDto.placeId ? 'this historic place' : 'the Colonial Zone of Santo Domingo'}.
Make it informative but entertaining, as if you were an expert tour guide.`;
try {
const transcript = await this.kimiService.chatWithTravelAssistant(prompt, [], language);
// TODO: Integrar con servicio de Text-to-Speech (AWS Polly, Google TTS, etc.)
const audioUrl = "https://karibeo-audio-guides.s3.amazonaws.com/placeholder-audio.mp3";
return { transcript, audioUrl };
} catch (error) {
return {
transcript: language === 'es'
? "Bienvenido a este histórico lugar. Permíteme contarte sobre su fascinante historia..."
: "Welcome to this historic location. Let me tell you about its fascinating history...",
audioUrl: "https://karibeo-audio-guides.s3.amazonaws.com/placeholder-audio.mp3"
};
}
}
private async getSmartDirections(queryDto: AIQueryDto): Promise<{ instructions: string; waypoints: PlaceOfInterest[] }> {
const language = queryDto.language || 'es';
const instructions = language === 'es'
? "Dirígete hacia el norte por 200 metros, luego gira a la derecha en la plaza histórica. Pasarás por varios puntos de interés en el camino."
: "Head north for 200 meters, then turn right at the historic plaza. You'll pass several interesting landmarks along the way.";
const waypoints = await this.placeRepository.find({
where: { active: true },
take: 3,
});
return { instructions, waypoints };
}
private async getPersonalizedRecommendations(userId: string, queryDto: AIQueryDto): Promise<PlaceOfInterest[]> {
return this.placeRepository.find({
where: { active: true },
order: { rating: 'DESC' },
take: 5,
});
}
private async saveInteraction(interactionData: Partial<AIGuideInteraction>): Promise<void> {
const interaction = this.interactionRepository.create(interactionData);
await this.interactionRepository.save(interaction);
}
// AR CONTENT MANAGEMENT
async getNearbyARContent(queryDto: ARContentQueryDto): Promise<ARContent[]> {
const query = this.arContentRepository.createQueryBuilder('ar')
.leftJoinAndSelect('ar.place', 'place')
.where('ar.isActive = :active', { active: true });
if (queryDto.contentType) {
query.andWhere('ar.contentType = :contentType', { contentType: queryDto.contentType });
}
if (queryDto.language) {
query.andWhere(':language = ANY(ar.languages)', { language: queryDto.language });
}
return query
.orderBy('ar.viewsCount', 'DESC')
.limit(10)
.getMany();
}
async incrementARViewCount(arContentId: string): Promise<void> {
await this.arContentRepository.increment({ id: arContentId }, 'viewsCount', 1);
}
// ✅ NUEVO: Generar itinerario completo con Kimi
async generateItinerary(params: {
userId: string;
destination: string;
days: number;
interests: string[];
budget: string;
language: string;
}): Promise<string> {
const { userId, destination, days, interests, budget, language } = params;
try {
const itinerary = await this.kimiService.generateItinerary({
destination,
days,
interests,
budget,
language,
});
// Guardar la interacción
await this.saveInteraction({
userId,
userQuery: `Generate itinerary: ${destination}, ${days} days`,
aiResponse: itinerary,
interactionType: 'itinerary-generation' as any,
language,
metadata: { destination, days, interests, budget },
});
return itinerary;
} catch (error) {
this.logger.error(`Itinerary generation error: ${error.message}`);
throw new BadRequestException('Failed to generate itinerary');
}
}
// ANALYTICS
async getAIUsageStats(): Promise<{
totalInteractions: number;
byType: Array<{ type: string; count: number }>;
byLanguage: Array<{ language: string; count: number }>;
averageRating: number;
popularQueries: Array<{ query: string; count: number }>;
}> {
const [totalInteractions, byType, byLanguage, avgRating] = await Promise.all([
this.interactionRepository.count(),
this.interactionRepository
.createQueryBuilder('interaction')
.select('interaction.interactionType', 'type')
.addSelect('COUNT(*)', 'count')
.groupBy('interaction.interactionType')
.getRawMany(),
this.interactionRepository
.createQueryBuilder('interaction')
.select('interaction.language', 'language')
.addSelect('COUNT(*)', 'count')
.groupBy('interaction.language')
.getRawMany(),
this.interactionRepository
.createQueryBuilder('interaction')
.select('AVG(interaction.rating)', 'average')
.where('interaction.rating IS NOT NULL')
.getRawOne(),
]);
const popularQueries = await this.interactionRepository
.createQueryBuilder('interaction')
.select('interaction.userQuery', 'query')
.addSelect('COUNT(*)', 'count')
.groupBy('interaction.userQuery')
.orderBy('count', 'DESC')
.limit(10)
.getRawMany();
return {
totalInteractions,
byType: byType.map(item => ({ type: item.type, count: parseInt(item.count) })),
byLanguage: byLanguage.map(item => ({ language: item.language, count: parseInt(item.count) })),
averageRating: parseFloat(avgRating?.average || '0'),
popularQueries: popularQueries.map(item => ({ query: item.query, count: parseInt(item.count) })),
};
}
// ✅ NUEVO: Limpiar cache de conversación
clearConversationCache(sessionId: string): void {
this.conversationCache.delete(sessionId);
}
/**
* Determinar ciudad aproximada basada en coordenadas
*/
private getCityFromCoordinates(lat: number, lng: number): string {
// Santo Domingo area: lat 18.4-18.6, lng -70.0 to -69.8
if (lat >= 18.4 && lat <= 18.6 && lng >= -70.0 && lng <= -69.8) {
return 'Santo Domingo';
}
// Punta Cana area: lat 18.5-18.7, lng -68.5 to -68.3
if (lat >= 18.5 && lat <= 18.7 && lng >= -68.5 && lng <= -68.3) {
return 'Punta Cana';
}
// Santiago area: lat 19.4-19.5, lng -70.7 to -70.6
if (lat >= 19.4 && lat <= 19.5 && lng >= -70.7 && lng <= -70.6) {
return 'Santiago';
}
// Puerto Plata area: lat 19.7-19.9, lng -70.7 to -70.6
if (lat >= 19.7 && lat <= 19.9 && lng >= -70.7 && lng <= -70.6) {
return 'Puerto Plata';
}
// Samaná area: lat 19.2-19.3, lng -69.4 to -69.2
if (lat >= 19.2 && lat <= 19.3 && lng >= -69.4 && lng <= -69.2) {
return 'Samaná';
}
// La Romana area
if (lat >= 18.4 && lat <= 18.5 && lng >= -69.0 && lng <= -68.9) {
return 'La Romana';
}
return 'República Dominicana';
}
}

View File

@@ -23,6 +23,10 @@ export class AIQueryDto {
@IsOptional()
@IsString()
placeId?: string;
@ApiPropertyOptional({ description: 'Place name for audio guide' })
@IsOptional()
@IsString()
placeName?: string;
@ApiPropertyOptional({ description: 'User latitude' })
@IsOptional()

View File

@@ -0,0 +1,252 @@
import { Controller, Get, Post, Param, Body, Res, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiParam, ApiResponse, ApiBody } from '@nestjs/swagger';
import { Response } from 'express';
import { TTSService } from './tts.service';
import { KimiService } from '../kimi/kimi.service';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
interface AudioGuideCache {
audioUrl: string;
transcript: string;
language: string;
placeName: string;
createdAt: number;
}
@ApiTags('TTS')
@Controller('tts')
export class TTSController {
private readonly logger = new Logger(TTSController.name);
private readonly cachePath: string;
private readonly cacheFile: string;
constructor(
private readonly ttsService: TTSService,
private readonly kimiService: KimiService,
) {
this.cachePath = '/home/karibeo-api/karibeo-api/tts/cache';
this.cacheFile = path.join(this.cachePath, 'audio_guides.json');
}
/**
* Leer cache desde archivo JSON (siempre fresco para PM2 cluster mode)
*/
private readCache(): Map<string, AudioGuideCache> {
try {
if (fs.existsSync(this.cacheFile)) {
const data = fs.readFileSync(this.cacheFile, 'utf-8');
const parsed = JSON.parse(data);
return new Map(Object.entries(parsed));
}
} catch (error) {
this.logger.warn(`Failed to read cache: ${error.message}`);
}
return new Map();
}
/**
* Guardar cache a archivo JSON
*/
private saveCache(cache: Map<string, AudioGuideCache>): void {
try {
const obj: Record<string, AudioGuideCache> = {};
cache.forEach((value, key) => {
obj[key] = value;
});
fs.writeFileSync(this.cacheFile, JSON.stringify(obj, null, 2), 'utf-8');
} catch (error) {
this.logger.error(`Failed to save cache: ${error.message}`);
}
}
/**
* Generar clave de cache: hash de placeName normalizado + language
*/
private getCacheKey(placeName: string, language: string): string {
const normalized = placeName.toLowerCase().trim().replace(/\s+/g, '_');
return crypto.createHash('md5').update(`${normalized}:${language}`).digest('hex');
}
@Get('audio/:fileName')
@ApiOperation({ summary: 'Get generated audio file' })
@ApiParam({ name: 'fileName', description: 'Audio file name (hash.wav)' })
@ApiResponse({ status: 200, description: 'Audio file stream' })
@ApiResponse({ status: 404, description: 'Audio file not found' })
async getAudioFile(
@Param('fileName') fileName: string,
@Res() res: Response,
) {
// Validar nombre de archivo (solo permitir hash.wav)
if (!/^[a-f0-9]{32}\.wav$/.test(fileName)) {
throw new HttpException('Invalid file name', HttpStatus.BAD_REQUEST);
}
const filePath = this.ttsService.getAudioFilePath(fileName);
if (!filePath) {
this.logger.warn(`Audio file not found: ${fileName}`);
throw new HttpException('Audio file not found', HttpStatus.NOT_FOUND);
}
// Obtener tamaño del archivo
const stat = fs.statSync(filePath);
// Configurar headers para audio
res.set({
'Content-Type': 'audio/wav',
'Content-Length': stat.size,
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=86400', // Cache 1 día
});
// Stream del archivo
const readStream = fs.createReadStream(filePath);
readStream.pipe(res);
}
@Get('status')
@ApiOperation({ summary: 'Check TTS service status' })
@ApiResponse({ status: 200, description: 'TTS service status' })
getStatus() {
const cache = this.readCache();
return {
available: this.ttsService.isAvailable(),
message: this.ttsService.isAvailable()
? 'Piper TTS is ready'
: 'Piper TTS is not available',
cachedGuides: cache.size,
};
}
@Post('generate')
@ApiOperation({ summary: 'Generate audio guide for a place (public - no auth required)' })
@ApiBody({
schema: {
type: 'object',
required: ['placeName'],
properties: {
placeName: { type: 'string', example: 'Catedral Primada de América' },
language: { type: 'string', example: 'es', enum: ['es', 'en', 'fr', 'it', 'de'] },
},
},
})
@ApiResponse({
status: 201,
description: 'Audio guide generated',
schema: {
type: 'object',
properties: {
audioUrl: { type: 'string' },
transcript: { type: 'string' },
language: { type: 'string' },
placeName: { type: 'string' },
cached: { type: 'boolean' },
},
},
})
async generateAudioGuide(@Body() body: { placeName: string; language?: string }) {
const { placeName, language = 'es' } = body;
if (!placeName || placeName.trim().length < 3) {
throw new HttpException('Place name is required (min 3 characters)', HttpStatus.BAD_REQUEST);
}
if (!this.ttsService.isAvailable()) {
throw new HttpException('TTS service not available', HttpStatus.SERVICE_UNAVAILABLE);
}
// Leer cache desde archivo (fresco para PM2 cluster mode)
const cache = this.readCache();
const cacheKey = this.getCacheKey(placeName, language);
const cached = cache.get(cacheKey);
if (cached) {
// Verificar que el archivo de audio todavía existe
const audioFileName = cached.audioUrl.split('/').pop();
if (audioFileName && this.ttsService.getAudioFilePath(audioFileName)) {
this.logger.log(`Cache HIT for: ${placeName} (${language})`);
return {
...cached,
cached: true,
};
} else {
// El archivo fue eliminado, remover del cache
cache.delete(cacheKey);
this.saveCache(cache);
this.logger.log(`Cache invalidated (audio missing): ${placeName} (${language})`);
}
}
this.logger.log(`Cache MISS - Generating audio guide for: ${placeName} (${language})`);
try {
// Prompts en español LATINOAMERICANO (con emojis permitidos, el TTS los filtra)
const prompts: Record<string, string> = {
es: `Genera un guion corto (maximo 200 palabras) para un audio tour sobre ${placeName} en Republica Dominicana.
Hazlo informativo pero entretenido, como si fueras un guia turistico dominicano experto.
Incluye historia breve y un dato curioso. Puedes usar emojis para hacerlo mas visual.
Habla en segunda persona directamente al turista usando "tu" (NO uses "vosotros" ni expresiones de Espana).
Usa espanol latinoamericano caribeño, natural y amigable.
IMPORTANTE: NO uses caracteres chinos, japoneses ni de otros idiomas asiaticos.`,
en: `Generate a short script (max 200 words) for an audio tour about ${placeName} in the Dominican Republic.
Make it informative but entertaining, as if you were an expert Dominican tour guide.
Include brief history and a fun fact. You can use emojis to make it more visual.
Speak directly to the tourist in second person.
IMPORTANT: Do NOT use Chinese, Japanese or other Asian language characters.`,
fr: `Genere un script court (max 200 mots) pour une visite audio de ${placeName} en Republique Dominicaine.
Rends-le informatif mais divertissant, comme si tu etais un guide touristique expert.
Inclus une breve histoire et une anecdote. Tu peux utiliser des emojis.
IMPORTANT: N'utilise PAS de caracteres chinois, japonais ou d'autres langues asiatiques.`,
it: `Genera uno script breve (max 200 parole) per un tour audio su ${placeName} nella Repubblica Dominicana.
Rendilo informativo ma divertente, come se fossi una guida turistica esperta.
Includi una breve storia e una curiosita. Puoi usare emoji.
IMPORTANTE: NON usare caratteri cinesi, giapponesi o di altre lingue asiatiche.`,
de: `Erstelle ein kurzes Skript (max 200 Worter) fur eine Audio-Tour uber ${placeName} in der Dominikanischen Republik.
Mache es informativ aber unterhaltsam, als warst du ein erfahrener Reisefuhrer.
Fuge kurze Geschichte und eine interessante Tatsache hinzu. Du kannst Emojis verwenden.
WICHTIG: Verwende KEINE chinesischen, japanischen oder andere asiatische Schriftzeichen.`,
};
const prompt = prompts[language] || prompts['en'];
const transcript = await this.kimiService.chatWithTravelAssistant(prompt, [], language);
// 2. Convert to audio with Piper TTS (el service limpia emojis/CJK)
const audioUrl = await this.ttsService.generateSpeech(transcript, language);
if (!audioUrl) {
throw new HttpException('Failed to generate audio', HttpStatus.INTERNAL_SERVER_ERROR);
}
// 3. Guardar en cache (re-leer para evitar race conditions)
const freshCache = this.readCache();
const cacheEntry: AudioGuideCache = {
audioUrl,
transcript,
language,
placeName,
createdAt: Date.now(),
};
freshCache.set(cacheKey, cacheEntry);
this.saveCache(freshCache);
this.logger.log(`Audio guide cached: ${placeName} (${language})`);
return {
audioUrl,
transcript,
language,
placeName,
cached: false,
};
} catch (error) {
this.logger.error(`Audio guide generation failed: ${error.message}`);
throw new HttpException(
`Failed to generate audio guide: ${error.message}`,
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
}

View File

@@ -0,0 +1,215 @@
import { Injectable, Logger } from '@nestjs/common';
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
const execAsync = promisify(exec);
@Injectable()
export class TTSService {
private readonly logger = new Logger(TTSService.name);
private readonly piperPath: string;
private readonly voicesPath: string;
private readonly cachePath: string;
private readonly baseUrl: string;
// Mapa de idiomas a voces Piper
private readonly voiceMap: Record<string, string> = {
es: 'es_ES-davefx-medium',
en: 'en_US-amy-medium',
fr: 'fr_FR-siwis-medium',
it: 'it_IT-riccardo-x_low',
de: 'de_DE-thorsten-medium',
};
constructor() {
const basePath = '/home/karibeo-api/karibeo-api/tts';
this.piperPath = path.join(basePath, 'piper', 'piper');
this.voicesPath = path.join(basePath, 'voices');
this.cachePath = path.join(basePath, 'cache');
this.baseUrl = process.env.API_BASE_URL || 'https://api.karibeo.ai:8443';
// Crear directorio de cache si no existe
if (!fs.existsSync(this.cachePath)) {
fs.mkdirSync(this.cachePath, { recursive: true });
}
// Verificar que Piper existe
if (fs.existsSync(this.piperPath)) {
this.logger.log('Piper TTS initialized');
} else {
this.logger.error('Piper binary not found at: ' + this.piperPath);
}
}
/**
* Limpiar texto para síntesis de voz:
* - Eliminar emojis
* - Eliminar caracteres chinos/japoneses/coreanos
* - Eliminar markdown
* - Eliminar símbolos especiales
*/
private cleanTextForSpeech(text: string): string {
return text
// Eliminar caracteres CJK (Chino, Japonés, Coreano)
.replace(/[\u4E00-\u9FFF]/g, '') // CJK Unified Ideographs (chino)
.replace(/[\u3400-\u4DBF]/g, '') // CJK Extension A
.replace(/[\u{20000}-\u{2A6DF}]/gu, '') // CJK Extension B
.replace(/[\u3040-\u309F]/g, '') // Hiragana (japonés)
.replace(/[\u30A0-\u30FF]/g, '') // Katakana (japonés)
.replace(/[\uAC00-\uD7AF]/g, '') // Hangul (coreano)
.replace(/[\u1100-\u11FF]/g, '') // Hangul Jamo
// Eliminar emojis Unicode (rangos principales)
.replace(/[\u{1F300}-\u{1F9FF}]/gu, '') // Símbolos misceláneos y pictogramas
.replace(/[\u{2600}-\u{26FF}]/gu, '') // Símbolos misceláneos
.replace(/[\u{2700}-\u{27BF}]/gu, '') // Dingbats
.replace(/[\u{1F600}-\u{1F64F}]/gu, '') // Emoticonos
.replace(/[\u{1F680}-\u{1F6FF}]/gu, '') // Transporte y símbolos de mapa
.replace(/[\u{1F1E0}-\u{1F1FF}]/gu, '') // Banderas
.replace(/[\u{1FA00}-\u{1FAFF}]/gu, '') // Símbolos extendidos
.replace(/[\u{1F900}-\u{1F9FF}]/gu, '') // Emojis suplementarios
.replace(/[\u{231A}-\u{23FF}]/gu, '') // Símbolos técnicos
.replace(/[\u{25A0}-\u{25FF}]/gu, '') // Formas geométricas
// Eliminar otros símbolos que podrían leerse mal
.replace(/[★☆✓✗✔✘●○◆◇▪▫►◄→←↑↓⇒⇐⇑⇓♠♣♥♦]/g, '')
// Limpiar asteriscos de markdown bold/italic
.replace(/\*\*([^*]+)\*\*/g, '$1') // **bold** -> bold
.replace(/\*([^*]+)\*/g, '$1') // *italic* -> italic
.replace(/__([^_]+)__/g, '$1') // __bold__ -> bold
.replace(/_([^_]+)_/g, '$1') // _italic_ -> italic
// Eliminar múltiples espacios creados por eliminación
.replace(/\s+/g, ' ')
.trim();
}
/**
* Genera un hash único para el texto + idioma
*/
private getAudioHash(text: string, language: string): string {
const content = `${language}:${text}`;
return crypto.createHash('md5').update(content).digest('hex');
}
/**
* Obtiene la ruta del modelo de voz para el idioma
*/
private getVoiceModel(language: string): string {
const voiceName = this.voiceMap[language] || this.voiceMap['en'];
return path.join(this.voicesPath, `${voiceName}.onnx`);
}
/**
* Genera speech usando Piper TTS local
* Retorna URL al archivo de audio
*/
async generateSpeech(text: string, language: string = 'es'): Promise<string | null> {
if (!fs.existsSync(this.piperPath)) {
this.logger.error('Piper not available');
return null;
}
try {
// Verificar cache
const hash = this.getAudioHash(text, language);
const fileName = `${hash}.wav`;
const filePath = path.join(this.cachePath, fileName);
// Si ya existe en cache, retornar URL
if (fs.existsSync(filePath)) {
this.logger.log(`Cache hit: ${fileName}`);
return `${this.baseUrl}/api/v1/tts/audio/${fileName}`;
}
// Obtener modelo de voz
const voiceModel = this.getVoiceModel(language);
if (!fs.existsSync(voiceModel)) {
this.logger.error(`Voice model not found: ${voiceModel}`);
return null;
}
// Limpiar texto: quitar emojis, CJK, y formateo markdown para el audio
const cleanText = this.cleanTextForSpeech(text)
.replace(/[\n\r]/g, ' ')
.replace(/"/g, "'")
.substring(0, 3000); // Límite de caracteres
this.logger.log(`Clean text for TTS: ${cleanText.substring(0, 100)}...`);
const tempTextFile = path.join(this.cachePath, `${hash}.txt`);
fs.writeFileSync(tempTextFile, cleanText, 'utf-8');
this.logger.log(`Generating TTS (${language}): ${cleanText.substring(0, 50)}...`);
// Ejecutar Piper usando archivo de texto (timeout 120s para textos largos)
const command = `cat "${tempTextFile}" | "${this.piperPath}" --model "${voiceModel}" --output_file "${filePath}" 2>/dev/null`;
await execAsync(command, { timeout: 120000 });
// Limpiar archivo temporal
if (fs.existsSync(tempTextFile)) {
fs.unlinkSync(tempTextFile);
}
// Verificar que se generó el archivo
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath);
this.logger.log(`Audio generated: ${fileName} (${stats.size} bytes)`);
return `${this.baseUrl}/api/v1/tts/audio/${fileName}`;
}
this.logger.error('Audio file was not created');
return null;
} catch (error) {
this.logger.error(`TTS generation failed: ${error.message}`);
return null;
}
}
/**
* Obtiene la ruta del archivo de audio para servir
*/
getAudioFilePath(fileName: string): string | null {
const filePath = path.join(this.cachePath, fileName);
if (fs.existsSync(filePath)) {
return filePath;
}
return null;
}
/**
* Limpia archivos de cache antiguos (más de 7 días)
*/
async cleanOldCache(): Promise<number> {
let cleaned = 0;
const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 días en ms
const now = Date.now();
try {
const files = fs.readdirSync(this.cachePath);
for (const file of files) {
if (file.endsWith('.txt')) continue; // Skip temp files
const filePath = path.join(this.cachePath, file);
const stats = fs.statSync(filePath);
if (now - stats.mtimeMs > maxAge) {
fs.unlinkSync(filePath);
cleaned++;
}
}
this.logger.log(`Cleaned ${cleaned} old audio files`);
} catch (error) {
this.logger.error(`Cache cleanup failed: ${error.message}`);
}
return cleaned;
}
/**
* Verifica si TTS está disponible
*/
isAvailable(): boolean {
return fs.existsSync(this.piperPath);
}
}

View File

@@ -1,110 +1,202 @@
import { Controller, Post, Body, HttpCode, HttpStatus, UseGuards, Get, Request } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { AuthResponseDto } from './dto/auth-response.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User } from '../../entities/user.entity';
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Register a new user',
description: 'Creates a new user account with tourist role by default'
})
@ApiBody({ type: RegisterDto })
@ApiResponse({
status: 201,
description: 'User successfully registered',
type: AuthResponseDto
})
@ApiResponse({
status: 409,
description: 'User with this email already exists'
})
@ApiResponse({
status: 400,
description: 'Invalid input data'
})
async register(@Body() registerDto: RegisterDto): Promise<AuthResponseDto> {
return this.authService.register(registerDto);
}
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'User login',
description: 'Authenticates user and returns JWT tokens'
})
@ApiBody({ type: LoginDto })
@ApiResponse({
status: 200,
description: 'User successfully authenticated',
type: AuthResponseDto
})
@ApiResponse({
status: 401,
description: 'Invalid credentials or account locked'
})
async login(@Body() loginDto: LoginDto): Promise<AuthResponseDto> {
return this.authService.login(loginDto);
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Refresh access token',
description: 'Refresh JWT access token using refresh token'
})
@ApiBody({
schema: {
type: 'object',
properties: {
refreshToken: {
type: 'string',
description: 'Valid refresh token',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
}
},
required: ['refreshToken']
}
})
@ApiResponse({
status: 200,
description: 'Tokens refreshed successfully',
type: AuthResponseDto
})
@ApiResponse({
status: 401,
description: 'Invalid or expired refresh token'
})
async refresh(@Body() body: { refreshToken: string }): Promise<AuthResponseDto> {
return this.authService.refresh(body.refreshToken);
}
@Get('profile')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Get current user profile',
description: 'Returns the profile of the authenticated user'
})
@ApiResponse({
status: 200,
description: 'User profile retrieved successfully',
type: User
})
@ApiResponse({
status: 401,
description: 'Unauthorized - Invalid or missing token'
})
async getProfile(@Request() req): Promise<User> {
return req.user;
}
}
import { Controller, Post, Body, HttpCode, HttpStatus, UseGuards, Get, Request, Res, Query } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from "@nestjs/swagger";
import { Response } from "express";
import { AuthGuard } from "@nestjs/passport";
import { ConfigService } from "@nestjs/config";
import { AuthService } from "./auth.service";
import { RegisterDto } from "./dto/register.dto";
import { LoginDto } from "./dto/login.dto";
import { AuthResponseDto } from "./dto/auth-response.dto";
import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard";
import { User } from "../../entities/user.entity";
import { AppleStrategy } from "./strategies/apple.strategy";
@ApiTags("Authentication")
@Controller("auth")
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly configService: ConfigService,
private readonly appleStrategy: AppleStrategy,
) {}
private getFrontendUrl(): string {
return this.configService.get<string>("FRONTEND_URL") || "https://karibeo.ai";
}
@Post("register")
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: "Register a new user",
description: "Creates a new user account with tourist role by default"
})
@ApiBody({ type: RegisterDto })
@ApiResponse({
status: 201,
description: "User successfully registered",
type: AuthResponseDto
})
@ApiResponse({
status: 409,
description: "User with this email already exists"
})
@ApiResponse({
status: 400,
description: "Invalid input data"
})
async register(@Body() registerDto: RegisterDto): Promise<AuthResponseDto> {
return this.authService.register(registerDto);
}
@Post("login")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "User login",
description: "Authenticates user and returns JWT tokens"
})
@ApiBody({ type: LoginDto })
@ApiResponse({
status: 200,
description: "User successfully authenticated",
type: AuthResponseDto
})
@ApiResponse({
status: 401,
description: "Invalid credentials or account locked"
})
async login(@Body() loginDto: LoginDto): Promise<AuthResponseDto> {
return this.authService.login(loginDto);
}
@Post("refresh")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Refresh access token",
description: "Refresh JWT access token using refresh token"
})
@ApiBody({
schema: {
type: "object",
properties: {
refreshToken: {
type: "string",
description: "Valid refresh token",
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
},
required: ["refreshToken"]
}
})
@ApiResponse({
status: 200,
description: "Tokens refreshed successfully",
type: AuthResponseDto
})
@ApiResponse({
status: 401,
description: "Invalid or expired refresh token"
})
async refresh(@Body() body: { refreshToken: string }): Promise<AuthResponseDto> {
return this.authService.refresh(body.refreshToken);
}
@Get("profile")
@UseGuards(JwtAuthGuard)
@ApiBearerAuth("JWT-auth")
@ApiOperation({
summary: "Get current user profile",
description: "Returns the profile of the authenticated user"
})
@ApiResponse({
status: 200,
description: "User profile retrieved successfully",
type: User
})
@ApiResponse({
status: 401,
description: "Unauthorized - Invalid or missing token"
})
async getProfile(@Request() req): Promise<User> {
return req.user;
}
// ==================== GOOGLE OAUTH ====================
@Get("google")
@UseGuards(AuthGuard("google"))
@ApiOperation({ summary: "Initiate Google OAuth login" })
async googleAuth() {
// Guard redirects to Google
}
@Get("google/callback")
@UseGuards(AuthGuard("google"))
@ApiOperation({ summary: "Google OAuth callback" })
async googleAuthCallback(@Request() req, @Res() res: Response) {
try {
const result = await this.authService.handleOAuthLogin({
email: req.user.email,
firstName: req.user.firstName,
lastName: req.user.lastName,
profileImageUrl: req.user.profileImageUrl,
provider: "google",
providerId: req.user.providerId,
});
// Redirect to frontend with tokens
const frontendUrl = this.getFrontendUrl();
const redirectUrl = `${frontendUrl}?accessToken=${result.accessToken}&refreshToken=${result.refreshToken}`;
return res.redirect(redirectUrl);
} catch (error) {
const frontendUrl = this.getFrontendUrl();
return res.redirect(`${frontendUrl}?error=oauth_failed`);
}
}
// ==================== APPLE OAUTH ====================
@Get("apple")
@ApiOperation({ summary: "Initiate Apple Sign In" })
async appleAuth(@Res() res: Response) {
const authUrl = this.appleStrategy.getAuthorizationUrl();
return res.redirect(authUrl);
}
@Post("apple/callback")
@ApiOperation({ summary: "Apple Sign In callback" })
async appleAuthCallback(
@Body() body: { id_token: string; code?: string; user?: string },
@Res() res: Response,
) {
try {
const appleUser = await this.appleStrategy.validateToken(body.id_token, body.code);
// Parse user info if provided (only on first login)
let firstName = "Usuario";
let lastName = "Karibeo";
if (body.user) {
try {
const userData = JSON.parse(body.user);
firstName = userData.name?.firstName || firstName;
lastName = userData.name?.lastName || lastName;
} catch {}
}
const result = await this.authService.handleOAuthLogin({
email: appleUser.email,
firstName,
lastName,
provider: "apple",
providerId: appleUser.providerId,
});
// Redirect to frontend with tokens
const frontendUrl = this.getFrontendUrl();
const redirectUrl = `${frontendUrl}?accessToken=${result.accessToken}&refreshToken=${result.refreshToken}`;
return res.redirect(redirectUrl);
} catch (error) {
const frontendUrl = this.getFrontendUrl();
return res.redirect(`${frontendUrl}?error=oauth_failed`);
}
}
}

View File

@@ -1,30 +1,33 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { User } from '../../entities/user.entity';
import { Role } from '../../entities/role.entity';
@Module({
imports: [
TypeOrmModule.forFeature([User, Role]),
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.get<string>('JWT_EXPIRES_IN') || '24h',
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtStrategy, PassportModule],
})
export class AuthModule {}
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./strategies/jwt.strategy";
import { GoogleStrategy } from "./strategies/google.strategy";
import { AppleStrategy } from "./strategies/apple.strategy";
import { User } from "../../entities/user.entity";
import { Role } from "../../entities/role.entity";
@Module({
imports: [
ConfigModule,
TypeOrmModule.forFeature([User, Role]),
PassportModule.register({ defaultStrategy: "jwt" }),
JwtModule.registerAsync({
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>("JWT_SECRET"),
signOptions: {
expiresIn: configService.get<string>("JWT_EXPIRES_IN") || "24h",
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, GoogleStrategy, AppleStrategy],
exports: [AuthService, JwtStrategy, PassportModule],
})
export class AuthModule {}

View File

@@ -1,173 +1,210 @@
import { Injectable, UnauthorizedException, ConflictException, InternalServerErrorException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { User } from '../../entities/user.entity';
import { Role } from '../../entities/role.entity';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { AuthResponseDto } from './dto/auth-response.dto';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(Role)
private readonly roleRepository: Repository<Role>,
private readonly jwtService: JwtService,
) {}
async register(registerDto: RegisterDto): Promise<AuthResponseDto> {
const { email, password, ...userData } = registerDto;
// Check if user already exists
const existingUser = await this.userRepository.findOne({ where: { email } });
if (existingUser) {
throw new ConflictException('User with this email already exists');
}
// Hash password
const saltRounds = 12;
const passwordHash = await bcrypt.hash(password, saltRounds);
// Get default tourist role
const defaultRole = await this.roleRepository.findOne({ where: { name: 'tourist' } });
// Create user
const user = this.userRepository.create({
email,
passwordHash,
roleId: defaultRole?.id || 2, // Default to tourist role
...userData,
});
const savedUser = await this.userRepository.save(user);
// Generate tokens
const { accessToken, refreshToken } = await this.generateTokens(savedUser);
// Load user with relations for response
const userWithRelations = await this.userRepository.findOne({
where: { id: savedUser.id },
relations: ['country', 'role', 'preferredLanguageEntity'],
});
if (!userWithRelations) {
throw new InternalServerErrorException('Failed to retrieve user after registration');
}
return {
accessToken,
refreshToken,
user: userWithRelations,
expiresIn: '24h',
};
}
async login(loginDto: LoginDto): Promise<AuthResponseDto> {
const { email, password } = loginDto;
// Find user with relations
const user = await this.userRepository.findOne({
where: { email },
relations: ['country', 'role', 'preferredLanguageEntity'],
});
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
// Check if account is locked
if (user.lockedUntil && new Date() < user.lockedUntil) {
throw new UnauthorizedException('Account is temporarily locked');
}
// Verify password
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
// Increment failed login attempts
await this.handleFailedLogin(user);
throw new UnauthorizedException('Invalid credentials');
}
// Reset failed login attempts on successful login
await this.userRepository.update(user.id, {
failedLoginAttempts: 0,
lockedUntil: undefined,
lastLogin: new Date(),
});
// Generate tokens
const { accessToken, refreshToken } = await this.generateTokens(user);
return {
accessToken,
refreshToken,
user,
expiresIn: '24h',
};
}
async validateUser(userId: string): Promise<User | null> {
return this.userRepository.findOne({
where: { id: userId, isActive: true },
relations: ['country', 'role', 'preferredLanguageEntity'],
});
}
async refresh(refreshToken: string): Promise<AuthResponseDto> {
try {
// Verificar el refresh token
const payload = this.jwtService.verify(refreshToken);
// Buscar el usuario
const user = await this.userRepository.findOne({
where: { id: payload.sub, isActive: true },
relations: ['country', 'role', 'preferredLanguageEntity'],
});
if (!user) {
throw new UnauthorizedException('User not found or inactive');
}
// Generar nuevos tokens
const { accessToken, refreshToken: newRefreshToken } = await this.generateTokens(user);
return {
accessToken,
refreshToken: newRefreshToken,
user,
expiresIn: '24h',
};
} catch (error) {
throw new UnauthorizedException('Invalid or expired refresh token');
}
}
private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> {
const payload = {
sub: user.id,
email: user.email,
role: user.role?.name || 'tourist',
};
const accessToken = this.jwtService.sign(payload);
const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
return { accessToken, refreshToken };
}
private async handleFailedLogin(user: User): Promise<void> {
const failedAttempts = user.failedLoginAttempts + 1;
const updateData: any = { failedLoginAttempts: failedAttempts };
// Lock account after 5 failed attempts for 15 minutes
if (failedAttempts >= 5) {
updateData.lockedUntil = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
}
await this.userRepository.update(user.id, updateData);
}
}
import { Injectable, UnauthorizedException, ConflictException, InternalServerErrorException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { JwtService } from "@nestjs/jwt";
import * as bcrypt from "bcrypt";
import { User } from "../../entities/user.entity";
import { Role } from "../../entities/role.entity";
import { RegisterDto } from "./dto/register.dto";
import { LoginDto } from "./dto/login.dto";
import { AuthResponseDto } from "./dto/auth-response.dto";
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(Role)
private readonly roleRepository: Repository<Role>,
private readonly jwtService: JwtService,
) {}
async register(registerDto: RegisterDto): Promise<AuthResponseDto> {
const { email, password, ...userData } = registerDto;
const existingUser = await this.userRepository.findOne({ where: { email } });
if (existingUser) {
throw new ConflictException("User with this email already exists");
}
const saltRounds = 12;
const passwordHash = await bcrypt.hash(password, saltRounds);
const defaultRole = await this.roleRepository.findOne({ where: { name: "tourist" } });
const user = this.userRepository.create({
email,
passwordHash,
roleId: defaultRole?.id || 2,
...userData,
});
const savedUser = await this.userRepository.save(user);
const { accessToken, refreshToken } = await this.generateTokens(savedUser);
const userWithRelations = await this.userRepository.findOne({
where: { id: savedUser.id },
relations: ["country", "role", "preferredLanguageEntity"],
});
if (!userWithRelations) {
throw new InternalServerErrorException("Failed to retrieve user after registration");
}
return {
accessToken,
refreshToken,
user: userWithRelations,
expiresIn: "24h",
};
}
async login(loginDto: LoginDto): Promise<AuthResponseDto> {
const { email, password } = loginDto;
const user = await this.userRepository.findOne({
where: { email },
relations: ["country", "role", "preferredLanguageEntity"],
});
if (!user) {
throw new UnauthorizedException("Invalid credentials");
}
if (user.lockedUntil && new Date() < user.lockedUntil) {
throw new UnauthorizedException("Account is temporarily locked");
}
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
await this.handleFailedLogin(user);
throw new UnauthorizedException("Invalid credentials");
}
await this.userRepository.update(user.id, {
failedLoginAttempts: 0,
lockedUntil: undefined,
lastLogin: new Date(),
});
const { accessToken, refreshToken } = await this.generateTokens(user);
return {
accessToken,
refreshToken,
user,
expiresIn: "24h",
};
}
async validateUser(userId: string): Promise<User | null> {
return this.userRepository.findOne({
where: { id: userId, isActive: true },
relations: ["country", "role", "preferredLanguageEntity"],
});
}
async refresh(refreshToken: string): Promise<AuthResponseDto> {
try {
const payload = this.jwtService.verify(refreshToken);
const user = await this.userRepository.findOne({
where: { id: payload.sub, isActive: true },
relations: ["country", "role", "preferredLanguageEntity"],
});
if (!user) {
throw new UnauthorizedException("User not found or inactive");
}
const { accessToken, refreshToken: newRefreshToken } = await this.generateTokens(user);
return {
accessToken,
refreshToken: newRefreshToken,
user,
expiresIn: "24h",
};
} catch (error) {
throw new UnauthorizedException("Invalid or expired refresh token");
}
}
async handleOAuthLogin(oauthUser: {
email: string;
firstName?: string;
lastName?: string;
profileImageUrl?: string;
provider: string;
providerId: string;
}): Promise<AuthResponseDto> {
let user = await this.userRepository.findOne({
where: { email: oauthUser.email },
relations: ["country", "role", "preferredLanguageEntity"],
});
if (!user) {
const defaultRole = await this.roleRepository.findOne({ where: { name: "tourist" } });
user = this.userRepository.create({
email: oauthUser.email,
firstName: oauthUser.firstName || "Usuario",
lastName: oauthUser.lastName || "Karibeo",
passwordHash: "",
profileImageUrl: oauthUser.profileImageUrl,
roleId: defaultRole?.id || 2,
isVerified: true,
});
user = await this.userRepository.save(user);
user = await this.userRepository.findOne({
where: { id: user.id },
relations: ["country", "role", "preferredLanguageEntity"],
}) as User;
} else {
if (oauthUser.profileImageUrl && !user.profileImageUrl) {
await this.userRepository.update(user.id, {
profileImageUrl: oauthUser.profileImageUrl,
lastLogin: new Date(),
});
user.profileImageUrl = oauthUser.profileImageUrl;
} else {
await this.userRepository.update(user.id, { lastLogin: new Date() });
}
}
const { accessToken, refreshToken } = await this.generateTokens(user);
return {
accessToken,
refreshToken,
user,
expiresIn: "24h",
};
}
private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> {
const payload = {
sub: user.id,
email: user.email,
role: user.role?.name || "tourist",
};
const accessToken = this.jwtService.sign(payload);
const refreshToken = this.jwtService.sign(payload, { expiresIn: "7d" });
return { accessToken, refreshToken };
}
private async handleFailedLogin(user: User): Promise<void> {
const failedAttempts = user.failedLoginAttempts + 1;
const updateData: any = { failedLoginAttempts: failedAttempts };
if (failedAttempts >= 5) {
updateData.lockedUntil = new Date(Date.now() + 15 * 60 * 1000);
}
await this.userRepository.update(user.id, updateData);
}
}

View File

@@ -0,0 +1,52 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import * as appleSignin from "apple-signin-auth";
export interface AppleUser {
email: string;
firstName?: string;
lastName?: string;
provider: string;
providerId: string;
}
@Injectable()
export class AppleStrategy {
constructor(private configService: ConfigService) {}
getAuthorizationUrl(): string {
const clientId = this.configService.get<string>("APPLE_CLIENT_ID") || "";
const redirectUri = this.configService.get<string>("APPLE_CALLBACK_URL") ||
"https://api.karibeo.ai:8443/api/v1/auth/apple/callback";
const scope = "name email";
const responseType = "code id_token";
const responseMode = "form_post";
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: responseType,
scope,
response_mode: responseMode,
});
return `https://appleid.apple.com/auth/authorize?${params.toString()}`;
}
async validateToken(idToken: string, authorizationCode?: string): Promise<AppleUser> {
const clientId = this.configService.get<string>("APPLE_CLIENT_ID");
// Verify the Apple token
const payload = await appleSignin.verifyIdToken(idToken, {
audience: clientId,
ignoreExpiration: false,
});
return {
email: payload.email || "",
provider: "apple",
providerId: payload.sub,
};
}
}

View File

@@ -0,0 +1,35 @@
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy, VerifyCallback, Profile } from "passport-google-oauth20";
import { ConfigService } from "@nestjs/config";
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, "google") {
constructor(configService: ConfigService) {
super({
clientID: configService.get<string>("GOOGLE_CLIENT_ID") || "",
clientSecret: configService.get<string>("GOOGLE_CLIENT_SECRET") || "",
callbackURL: configService.get<string>("GOOGLE_CALLBACK_URL") || "https://api.karibeo.ai:8443/api/v1/auth/google/callback",
scope: ["email", "profile"],
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: Profile,
done: VerifyCallback,
): Promise<void> {
const { name, emails, photos } = profile;
const user = {
email: emails?.[0]?.value,
firstName: name?.givenName || "",
lastName: name?.familyName || "",
profileImageUrl: photos?.[0]?.value,
provider: "google",
providerId: profile.id,
accessToken,
};
done(null, user);
}
}

View File

@@ -0,0 +1,112 @@
import { Controller, Get, Post, Query, Body, Param } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { BookingService } from './booking.service';
import { SearchAccommodationsDto } from './dto/search-accommodations.dto';
import { SearchFlightsDto } from './dto/search-flights.dto';
import { SearchCarsDto } from './dto/search-cars.dto';
@ApiTags('Travel - Booking.com')
@Controller('booking')
export class BookingController {
constructor(private readonly bookingService: BookingService) {}
// ==================== ACCOMMODATIONS ====================
@Get('accommodations/search')
@ApiOperation({ summary: 'Search for hotels and accommodations' })
@ApiResponse({ status: 200, description: 'Accommodation search results' })
async searchAccommodations(@Query() dto: SearchAccommodationsDto) {
return this.bookingService.searchAccommodations(dto);
}
@Post('accommodations/search')
@ApiOperation({ summary: 'Search for hotels and accommodations (POST)' })
async searchAccommodationsPost(@Body() dto: SearchAccommodationsDto) {
return this.bookingService.searchAccommodations(dto);
}
@Get('accommodations/:id')
@ApiOperation({ summary: 'Get accommodation details' })
async getAccommodationDetails(@Param('id') id: string) {
return this.bookingService.getAccommodationDetails(id);
}
@Get('accommodations/:id/availability')
@ApiOperation({ summary: 'Check accommodation availability' })
async checkAvailability(
@Param('id') id: string,
@Query('checkin') checkin: string,
@Query('checkout') checkout: string,
@Query('guests') guests: number,
) {
return this.bookingService.checkAvailability(id, checkin, checkout, guests || 2);
}
// ==================== FLIGHTS ====================
@Get('flights/search')
@ApiOperation({ summary: 'Search for flights' })
@ApiResponse({ status: 200, description: 'Flight search results' })
async searchFlights(@Query() dto: SearchFlightsDto) {
return this.bookingService.searchFlights(dto);
}
@Post('flights/search')
@ApiOperation({ summary: 'Search for flights (POST)' })
async searchFlightsPost(@Body() dto: SearchFlightsDto) {
return this.bookingService.searchFlights(dto);
}
@Get('flights/:id')
@ApiOperation({ summary: 'Get flight details' })
async getFlightDetails(@Param('id') id: string) {
return this.bookingService.getFlightDetails(id);
}
// ==================== CAR RENTALS ====================
@Get('cars/search')
@ApiOperation({ summary: 'Search for car rentals' })
@ApiResponse({ status: 200, description: 'Car rental search results' })
async searchCars(@Query() dto: SearchCarsDto) {
return this.bookingService.searchCars(dto);
}
@Post('cars/search')
@ApiOperation({ summary: 'Search for car rentals (POST)' })
async searchCarsPost(@Body() dto: SearchCarsDto) {
return this.bookingService.searchCars(dto);
}
@Get('cars/:id')
@ApiOperation({ summary: 'Get car rental details' })
async getCarDetails(@Param('id') id: string) {
return this.bookingService.getCarDetails(id);
}
@Get('cars/depots/:location')
@ApiOperation({ summary: 'Get car rental depots for a location' })
async getCarDepots(@Param('location') location: string) {
return this.bookingService.getCarDepots(location);
}
// ==================== LOCATIONS ====================
@Get('locations/search')
@ApiOperation({ summary: 'Search for cities, airports, hotels' })
async searchLocations(
@Query('query') query: string,
@Query('type') type?: 'city' | 'airport' | 'hotel',
) {
return this.bookingService.searchLocations(query, type);
}
// ==================== STATUS ====================
@Get('status')
@ApiOperation({ summary: 'Get Booking.com API status' })
@ApiResponse({ status: 200, description: 'API configuration status' })
getStatus() {
return this.bookingService.getStatus();
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BookingService } from './booking.service';
import { BookingController } from './booking.controller';
import bookingConfig from '../../config/integrations/booking.config';
@Module({
imports: [
ConfigModule.forFeature(bookingConfig),
],
controllers: [BookingController],
providers: [BookingService],
exports: [BookingService],
})
export class BookingModule {}

View File

@@ -0,0 +1,236 @@
import { Injectable, Logger, HttpException, HttpStatus, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SearchAccommodationsDto } from './dto/search-accommodations.dto';
import { SearchFlightsDto, TripType } from './dto/search-flights.dto';
import { SearchCarsDto } from './dto/search-cars.dto';
interface BookingConfig {
affiliateId: string;
apiKey: string;
baseUrl: string;
environment: string;
defaultCurrency: string;
defaultLanguage: string;
}
@Injectable()
export class BookingService implements OnModuleInit {
private readonly logger = new Logger(BookingService.name);
private config: BookingConfig;
constructor(private readonly configService: ConfigService) {}
onModuleInit() {
this.config = {
affiliateId: this.configService.get<string>('booking.affiliateId') || '',
apiKey: this.configService.get<string>('booking.apiKey') || '',
baseUrl: this.configService.get<string>('booking.baseUrl') || 'https://demandapi.booking.com/3.1',
environment: this.configService.get<string>('booking.environment') || 'sandbox',
defaultCurrency: this.configService.get<string>('booking.defaultCurrency') || 'USD',
defaultLanguage: this.configService.get<string>('booking.defaultLanguage') || 'es',
};
this.logger.log('Booking.com service initialized');
this.logger.log('Environment: ' + this.config.environment);
this.logger.log('Configured: ' + (this.isConfigured() ? 'Yes' : 'No - add credentials to .env'));
}
private isConfigured(): boolean {
return !!(this.config.affiliateId && this.config.apiKey);
}
private getAuthHeaders(): Record<string, string> {
return {
'Authorization': 'Basic ' + Buffer.from(this.config.affiliateId + ':' + this.config.apiKey).toString('base64'),
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Affiliate-Id': this.config.affiliateId,
};
}
private async makeRequest<T>(endpoint: string, method: string = 'GET', body?: any): Promise<T> {
if (!this.isConfigured()) {
throw new HttpException(
'Booking.com API not configured. Add BOOKING_AFFILIATE_ID and BOOKING_API_KEY to .env',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
const url = this.config.baseUrl + endpoint;
this.logger.debug('Making request to: ' + url);
try {
const options: RequestInit = {
method,
headers: this.getAuthHeaders(),
};
if (body && method !== 'GET') {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
this.logger.error('Booking.com API error: ' + JSON.stringify(errorData));
throw new HttpException(
errorData.message || 'Booking.com API request failed',
response.status,
);
}
return response.json();
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error('Booking.com request error: ' + error.message);
throw new HttpException(
'Failed to communicate with Booking.com API',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
// ==================== ACCOMMODATIONS ====================
async searchAccommodations(dto: SearchAccommodationsDto): Promise<any> {
this.logger.log('Searching accommodations in: ' + dto.city);
const params: any = {
city: dto.city,
checkin: dto.checkin,
checkout: dto.checkout,
guest_qty: dto.adults || 2,
room_qty: dto.rooms || 1,
currency: dto.currency || this.config.defaultCurrency,
language: this.config.defaultLanguage,
rows: dto.limit || 20,
page: dto.page || 1,
};
if (dto.countryCode) params.country = dto.countryCode;
if (dto.children) params.children_qty = dto.children;
if (dto.minStars) params.min_review_score = dto.minStars * 2; // Booking uses 1-10 scale
if (dto.latitude && dto.longitude) {
params.latitude = dto.latitude;
params.longitude = dto.longitude;
if (dto.radius) params.radius = dto.radius;
}
if (dto.sortBy) params.order_by = dto.sortBy;
const queryString = new URLSearchParams(params).toString();
return this.makeRequest('/accommodations/search?' + queryString);
}
async getAccommodationDetails(hotelId: string): Promise<any> {
this.logger.log('Getting accommodation details: ' + hotelId);
return this.makeRequest('/accommodations/' + hotelId);
}
async checkAvailability(hotelId: string, checkin: string, checkout: string, guests: number): Promise<any> {
this.logger.log('Checking availability for: ' + hotelId);
const params = new URLSearchParams({
checkin,
checkout,
guest_qty: guests.toString(),
currency: this.config.defaultCurrency,
});
return this.makeRequest('/accommodations/' + hotelId + '/availability?' + params);
}
// ==================== FLIGHTS ====================
async searchFlights(dto: SearchFlightsDto): Promise<any> {
this.logger.log('Searching flights: ' + dto.origin + ' -> ' + dto.destination);
const body: any = {
origin: dto.origin.toUpperCase(),
destination: dto.destination.toUpperCase(),
departure_date: dto.departureDate,
adults: dto.adults || 1,
currency: dto.currency || this.config.defaultCurrency,
};
if (dto.returnDate) body.return_date = dto.returnDate;
if (dto.children) body.children = dto.children;
if (dto.infants) body.infants = dto.infants;
if (dto.cabinClass) body.cabin_class = dto.cabinClass;
if (dto.directOnly) body.direct_only = dto.directOnly;
if (dto.tripType === TripType.ONEWAY) body.trip_type = 'oneway';
return this.makeRequest('/flights/search', 'POST', body);
}
async getFlightDetails(flightId: string): Promise<any> {
this.logger.log('Getting flight details: ' + flightId);
return this.makeRequest('/flights/' + flightId);
}
// ==================== CAR RENTALS ====================
async searchCars(dto: SearchCarsDto): Promise<any> {
this.logger.log('Searching cars at: ' + dto.pickupLocation);
const params: any = {
pickup_location: dto.pickupLocation.toUpperCase(),
dropoff_location: (dto.dropoffLocation || dto.pickupLocation).toUpperCase(),
pickup_date: dto.pickupDate,
pickup_time: dto.pickupTime,
dropoff_date: dto.dropoffDate,
dropoff_time: dto.dropoffTime,
currency: dto.currency || this.config.defaultCurrency,
driver_age: dto.driverAge || 30,
};
if (dto.category) params.car_type = dto.category;
if (dto.transmission) params.transmission = dto.transmission;
if (dto.airConditioning) params.air_conditioning = dto.airConditioning;
if (dto.sortBy) params.order_by = dto.sortBy;
if (dto.limit) params.rows = dto.limit;
const queryString = new URLSearchParams(params).toString();
return this.makeRequest('/car-rentals/search?' + queryString);
}
async getCarDetails(carId: string): Promise<any> {
this.logger.log('Getting car details: ' + carId);
return this.makeRequest('/car-rentals/' + carId);
}
async getCarDepots(locationCode: string): Promise<any> {
this.logger.log('Getting car depots for: ' + locationCode);
return this.makeRequest('/car-rentals/depots?location=' + locationCode);
}
// ==================== LOCATIONS ====================
async searchLocations(query: string, type?: 'city' | 'airport' | 'hotel'): Promise<any> {
this.logger.log('Searching locations: ' + query);
const params: any = { text: query, language: this.config.defaultLanguage };
if (type) params.type = type;
const queryString = new URLSearchParams(params).toString();
return this.makeRequest('/locations/search?' + queryString);
}
// ==================== STATUS ====================
getStatus(): any {
return {
provider: 'Booking.com',
environment: this.config.environment,
baseUrl: this.config.baseUrl,
defaultCurrency: this.config.defaultCurrency,
defaultLanguage: this.config.defaultLanguage,
isConfigured: this.isConfigured(),
services: {
accommodations: true,
flights: true,
carRentals: true,
taxis: true,
},
message: this.isConfigured()
? 'Booking.com API ready'
: 'Add BOOKING_AFFILIATE_ID and BOOKING_API_KEY to .env',
};
}
}

View File

@@ -0,0 +1,97 @@
import { IsString, IsDateString, IsOptional, IsInt, Min, Max, IsNumber, IsArray, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class SearchAccommodationsDto {
@ApiProperty({ description: 'City or location name', example: 'Santo Domingo' })
@IsString()
city: string;
@ApiPropertyOptional({ description: 'Country code (ISO 3166-1 alpha-2)', example: 'DO' })
@IsOptional()
@IsString()
countryCode?: string;
@ApiProperty({ description: 'Check-in date (YYYY-MM-DD)', example: '2025-06-15' })
@IsDateString()
checkin: string;
@ApiProperty({ description: 'Check-out date (YYYY-MM-DD)', example: '2025-06-22' })
@IsDateString()
checkout: string;
@ApiProperty({ description: 'Number of adults', example: 2, default: 2 })
@IsInt()
@Min(1)
@Max(30)
adults: number;
@ApiPropertyOptional({ description: 'Number of children', example: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Max(10)
children?: number;
@ApiPropertyOptional({ description: 'Number of rooms', example: 1 })
@IsOptional()
@IsInt()
@Min(1)
@Max(30)
rooms?: number;
@ApiPropertyOptional({ description: 'Currency code', example: 'USD' })
@IsOptional()
@IsString()
currency?: string;
@ApiPropertyOptional({ description: 'Minimum star rating (1-5)', example: 3 })
@IsOptional()
@IsInt()
@Min(1)
@Max(5)
minStars?: number;
@ApiPropertyOptional({ description: 'Maximum price per night', example: 200 })
@IsOptional()
@IsNumber()
@Type(() => Number)
maxPrice?: number;
@ApiPropertyOptional({ description: 'Latitude for geo search', example: 18.4861 })
@IsOptional()
@IsNumber()
@Type(() => Number)
latitude?: number;
@ApiPropertyOptional({ description: 'Longitude for geo search', example: -69.9312 })
@IsOptional()
@IsNumber()
@Type(() => Number)
longitude?: number;
@ApiPropertyOptional({ description: 'Search radius in km', example: 10 })
@IsOptional()
@IsInt()
@Min(1)
@Max(50)
radius?: number;
@ApiPropertyOptional({ description: 'Sort by: price, popularity, rating, distance', example: 'price' })
@IsOptional()
@IsString()
sortBy?: string;
@ApiPropertyOptional({ description: 'Number of results (max 100)', example: 20 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
limit?: number;
@ApiPropertyOptional({ description: 'Page number for pagination', example: 1 })
@IsOptional()
@IsInt()
@Min(1)
page?: number;
}

View File

@@ -0,0 +1,92 @@
import { IsString, IsDateString, IsOptional, IsInt, Min, IsEnum, IsBoolean, IsNumber, Max } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export enum CarCategory {
MINI = 'MINI',
ECONOMY = 'ECONOMY',
COMPACT = 'COMPACT',
MIDSIZE = 'MIDSIZE',
STANDARD = 'STANDARD',
FULLSIZE = 'FULLSIZE',
PREMIUM = 'PREMIUM',
LUXURY = 'LUXURY',
SUV = 'SUV',
VAN = 'VAN',
}
export enum TransmissionType {
AUTOMATIC = 'AUTOMATIC',
MANUAL = 'MANUAL',
}
export class SearchCarsDto {
@ApiProperty({ description: 'Pick-up location (city or airport code)', example: 'SDQ' })
@IsString()
pickupLocation: string;
@ApiPropertyOptional({ description: 'Drop-off location if different', example: 'PUJ' })
@IsOptional()
@IsString()
dropoffLocation?: string;
@ApiProperty({ description: 'Pick-up date (YYYY-MM-DD)', example: '2025-06-15' })
@IsDateString()
pickupDate: string;
@ApiProperty({ description: 'Pick-up time (HH:MM)', example: '10:00' })
@IsString()
pickupTime: string;
@ApiProperty({ description: 'Drop-off date (YYYY-MM-DD)', example: '2025-06-22' })
@IsDateString()
dropoffDate: string;
@ApiProperty({ description: 'Drop-off time (HH:MM)', example: '10:00' })
@IsString()
dropoffTime: string;
@ApiPropertyOptional({ description: 'Currency for prices', example: 'USD' })
@IsOptional()
@IsString()
currency?: string;
@ApiPropertyOptional({ description: 'Driver age', example: 30 })
@IsOptional()
@IsInt()
@Min(18)
driverAge?: number;
@ApiPropertyOptional({ description: 'Car category', enum: CarCategory })
@IsOptional()
@IsEnum(CarCategory)
category?: CarCategory;
@ApiPropertyOptional({ description: 'Transmission type', enum: TransmissionType })
@IsOptional()
@IsEnum(TransmissionType)
transmission?: TransmissionType;
@ApiPropertyOptional({ description: 'Air conditioning required', example: true })
@IsOptional()
@IsBoolean()
airConditioning?: boolean;
@ApiPropertyOptional({ description: 'Max price per day', example: 100 })
@IsOptional()
@IsNumber()
@Type(() => Number)
maxPricePerDay?: number;
@ApiPropertyOptional({ description: 'Sort by: price, rating', example: 'price' })
@IsOptional()
@IsString()
sortBy?: string;
@ApiPropertyOptional({ description: 'Max results', example: 20 })
@IsOptional()
@IsInt()
@Min(1)
@Max(50)
limit?: number;
}

View File

@@ -0,0 +1,80 @@
import { IsString, IsDateString, IsOptional, IsInt, Min, Max, IsEnum, IsBoolean } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum CabinClass {
ECONOMY = 'ECONOMY',
PREMIUM_ECONOMY = 'PREMIUM_ECONOMY',
BUSINESS = 'BUSINESS',
FIRST = 'FIRST',
}
export enum TripType {
ONEWAY = 'ONEWAY',
ROUNDTRIP = 'ROUNDTRIP',
}
export class SearchFlightsDto {
@ApiProperty({ description: 'Origin airport IATA code', example: 'SDQ' })
@IsString()
origin: string;
@ApiProperty({ description: 'Destination airport IATA code', example: 'MIA' })
@IsString()
destination: string;
@ApiProperty({ description: 'Departure date (YYYY-MM-DD)', example: '2025-06-15' })
@IsDateString()
departureDate: string;
@ApiPropertyOptional({ description: 'Return date for round trip (YYYY-MM-DD)', example: '2025-06-22' })
@IsOptional()
@IsDateString()
returnDate?: string;
@ApiProperty({ description: 'Trip type', enum: TripType, default: TripType.ROUNDTRIP })
@IsOptional()
@IsEnum(TripType)
tripType?: TripType;
@ApiProperty({ description: 'Number of adults', example: 1, default: 1 })
@IsInt()
@Min(1)
@Max(9)
adults: number;
@ApiPropertyOptional({ description: 'Number of children (2-11)', example: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Max(8)
children?: number;
@ApiPropertyOptional({ description: 'Number of infants (0-2)', example: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Max(4)
infants?: number;
@ApiPropertyOptional({ description: 'Cabin class', enum: CabinClass })
@IsOptional()
@IsEnum(CabinClass)
cabinClass?: CabinClass;
@ApiPropertyOptional({ description: 'Direct flights only', example: false })
@IsOptional()
@IsBoolean()
directOnly?: boolean;
@ApiPropertyOptional({ description: 'Currency for prices', example: 'USD' })
@IsOptional()
@IsString()
currency?: string;
@ApiPropertyOptional({ description: 'Max results', example: 20 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}

View File

@@ -0,0 +1,6 @@
export * from './booking.module';
export * from './booking.service';
export * from './booking.controller';
export * from './dto/search-accommodations.dto';
export * from './dto/search-flights.dto';
export * from './dto/search-cars.dto';

View File

@@ -0,0 +1,125 @@
import {
Controller, Get, Post, Body, Patch, Param, Delete, Query, UseGuards, Request,
} from '@nestjs/common';
import {
ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam,
} from '@nestjs/swagger';
import { CollectionsService } from './collections.service';
import { CreateCollectionDto, AddCollectionItemDto } from './dto/create-collection.dto';
import { UpdateCollectionDto } from './dto/update-collection.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { UserCollection, CollectionItem } from '../../entities/user-collection.entity';
@ApiTags('Collections')
@Controller('collections')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
export class CollectionsController {
constructor(private readonly collectionsService: CollectionsService) {}
@Post()
@ApiOperation({ summary: 'Create a new collection' })
@ApiResponse({ status: 201, description: 'Collection created', type: UserCollection })
@ApiResponse({ status: 409, description: 'Collection name already exists' })
create(@Body() createDto: CreateCollectionDto, @Request() req) {
return this.collectionsService.create(req.user.id, createDto);
}
@Get('my')
@ApiOperation({ summary: 'Get my collections' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiResponse({ status: 200, description: 'User collections' })
findMyCollections(
@Request() req,
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.collectionsService.findAllByUser(req.user.id, false, page, limit);
}
@Get('my/stats')
@ApiOperation({ summary: 'Get collection statistics' })
@ApiResponse({ status: 200, description: 'Collection stats' })
getStats(@Request() req) {
return this.collectionsService.getCollectionStats(req.user.id);
}
@Get(':id')
@ApiOperation({ summary: 'Get collection by ID' })
@ApiParam({ name: 'id', type: 'string' })
@ApiResponse({ status: 200, type: UserCollection })
@ApiResponse({ status: 404, description: 'Collection not found' })
findOne(@Param('id') id: string, @Request() req) {
return this.collectionsService.findOne(req.user.id, id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update collection' })
@ApiParam({ name: 'id', type: 'string' })
@ApiResponse({ status: 200, type: UserCollection })
update(
@Param('id') id: string,
@Body() updateDto: UpdateCollectionDto,
@Request() req,
) {
return this.collectionsService.update(req.user.id, id, updateDto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete collection' })
@ApiParam({ name: 'id', type: 'string' })
@ApiResponse({ status: 200, description: 'Collection deleted' })
remove(@Param('id') id: string, @Request() req) {
return this.collectionsService.remove(req.user.id, id);
}
// Collection Items
@Post(':id/items')
@ApiOperation({ summary: 'Add item to collection' })
@ApiParam({ name: 'id', type: 'string', description: 'Collection ID' })
@ApiResponse({ status: 201, type: CollectionItem })
@ApiResponse({ status: 409, description: 'Item already in collection' })
addItem(
@Param('id') id: string,
@Body() addItemDto: AddCollectionItemDto,
@Request() req,
) {
return this.collectionsService.addItem(req.user.id, id, addItemDto);
}
@Delete(':id/items/:itemId')
@ApiOperation({ summary: 'Remove item from collection' })
@ApiParam({ name: 'id', type: 'string', description: 'Collection ID' })
@ApiParam({ name: 'itemId', type: 'string', description: 'Item ID' })
@ApiResponse({ status: 200, description: 'Item removed' })
removeItem(
@Param('id') id: string,
@Param('itemId') itemId: string,
@Request() req,
) {
return this.collectionsService.removeItem(req.user.id, id, itemId);
}
@Patch(':id/items/order')
@ApiOperation({ summary: 'Reorder items in collection' })
@ApiParam({ name: 'id', type: 'string' })
@ApiResponse({ status: 200, description: 'Order updated' })
updateItemOrder(
@Param('id') id: string,
@Body() body: { itemIds: string[] },
@Request() req,
) {
return this.collectionsService.updateItemOrder(req.user.id, id, body.itemIds);
}
@Patch('order')
@ApiOperation({ summary: 'Reorder collections' })
@ApiResponse({ status: 200, description: 'Order updated' })
updateCollectionOrder(
@Body() body: { collectionIds: string[] },
@Request() req,
) {
return this.collectionsService.updateCollectionOrder(req.user.id, body.collectionIds);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CollectionsService } from './collections.service';
import { CollectionsController } from './collections.controller';
import { UserCollection, CollectionItem } from '../../entities/user-collection.entity';
@Module({
imports: [TypeOrmModule.forFeature([UserCollection, CollectionItem])],
controllers: [CollectionsController],
providers: [CollectionsService],
exports: [CollectionsService],
})
export class CollectionsModule {}

View File

@@ -0,0 +1,205 @@
import { Injectable, NotFoundException, ConflictException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserCollection, CollectionItem } from '../../entities/user-collection.entity';
import { CreateCollectionDto, AddCollectionItemDto } from './dto/create-collection.dto';
import { UpdateCollectionDto } from './dto/update-collection.dto';
@Injectable()
export class CollectionsService {
constructor(
@InjectRepository(UserCollection)
private readonly collectionRepository: Repository<UserCollection>,
@InjectRepository(CollectionItem)
private readonly itemRepository: Repository<CollectionItem>,
) {}
async create(userId: string, createDto: CreateCollectionDto): Promise<UserCollection> {
// Check for duplicate name
const existing = await this.collectionRepository.findOne({
where: { userId, name: createDto.name },
});
if (existing) {
throw new ConflictException('Collection with this name already exists');
}
// Get max sort order
const maxOrder = await this.collectionRepository
.createQueryBuilder('c')
.select('MAX(c.sort_order)', 'max')
.where('c.user_id = :userId', { userId })
.getRawOne();
const collection = this.collectionRepository.create({
...createDto,
userId,
sortOrder: (maxOrder?.max || 0) + 1,
});
return this.collectionRepository.save(collection);
}
async findAllByUser(
userId: string,
includePublic = false,
page = 1,
limit = 20,
): Promise<{ data: UserCollection[]; total: number }> {
const queryBuilder = this.collectionRepository
.createQueryBuilder('c')
.leftJoinAndSelect('c.items', 'items')
.where('c.user_id = :userId', { userId });
if (includePublic) {
queryBuilder.orWhere('c.is_public = true');
}
const [data, total] = await queryBuilder
.orderBy('c.sort_order', 'ASC')
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
return { data, total };
}
async findOne(userId: string, id: string): Promise<UserCollection> {
const collection = await this.collectionRepository.findOne({
where: { id },
relations: ['items'],
});
if (!collection) {
throw new NotFoundException('Collection not found');
}
// Check ownership or public access
if (collection.userId !== userId && !collection.isPublic) {
throw new ForbiddenException('Access denied');
}
return collection;
}
async update(userId: string, id: string, updateDto: UpdateCollectionDto): Promise<UserCollection> {
const collection = await this.collectionRepository.findOne({
where: { id, userId },
});
if (!collection) {
throw new NotFoundException('Collection not found');
}
Object.assign(collection, updateDto);
return this.collectionRepository.save(collection);
}
async remove(userId: string, id: string): Promise<void> {
const collection = await this.collectionRepository.findOne({
where: { id, userId },
});
if (!collection) {
throw new NotFoundException('Collection not found');
}
await this.collectionRepository.remove(collection);
}
// Collection Items
async addItem(userId: string, collectionId: string, addItemDto: AddCollectionItemDto): Promise<CollectionItem> {
const collection = await this.collectionRepository.findOne({
where: { id: collectionId, userId },
});
if (!collection) {
throw new NotFoundException('Collection not found');
}
// Check if item already in collection
const existing = await this.itemRepository.findOne({
where: {
collectionId,
itemId: addItemDto.itemId,
itemType: addItemDto.itemType,
},
});
if (existing) {
throw new ConflictException('Item already in collection');
}
// Get max sort order
const maxOrder = await this.itemRepository
.createQueryBuilder('i')
.select('MAX(i.sort_order)', 'max')
.where('i.collection_id = :collectionId', { collectionId })
.getRawOne();
const item = this.itemRepository.create({
...addItemDto,
collectionId,
sortOrder: (maxOrder?.max || 0) + 1,
});
return this.itemRepository.save(item);
}
async removeItem(userId: string, collectionId: string, itemId: string): Promise<void> {
const collection = await this.collectionRepository.findOne({
where: { id: collectionId, userId },
});
if (!collection) {
throw new NotFoundException('Collection not found');
}
const item = await this.itemRepository.findOne({
where: { id: itemId, collectionId },
});
if (!item) {
throw new NotFoundException('Item not found in collection');
}
await this.itemRepository.remove(item);
}
async updateItemOrder(userId: string, collectionId: string, itemIds: string[]): Promise<void> {
const collection = await this.collectionRepository.findOne({
where: { id: collectionId, userId },
});
if (!collection) {
throw new NotFoundException('Collection not found');
}
await Promise.all(
itemIds.map((id, index) =>
this.itemRepository.update({ id, collectionId }, { sortOrder: index })
)
);
}
async updateCollectionOrder(userId: string, collectionIds: string[]): Promise<void> {
await Promise.all(
collectionIds.map((id, index) =>
this.collectionRepository.update({ id, userId }, { sortOrder: index })
)
);
}
async getCollectionStats(userId: string): Promise<{ total: number; publicCount: number; itemsCount: number }> {
const collections = await this.collectionRepository.find({
where: { userId },
relations: ['items'],
});
return {
total: collections.length,
publicCount: collections.filter(c => c.isPublic).length,
itemsCount: collections.reduce((sum, c) => sum + (c.items?.length || 0), 0),
};
}
}

View File

@@ -0,0 +1,59 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsBoolean, MaxLength, IsHexColor } from 'class-validator';
export class CreateCollectionDto {
@ApiProperty({ description: 'Collection name', example: 'My Beach Trip' })
@IsString()
@MaxLength(100)
name: string;
@ApiPropertyOptional({ description: 'Collection description' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'Cover image URL' })
@IsOptional()
@IsString()
coverImageUrl?: string;
@ApiPropertyOptional({ description: 'Collection color theme', example: '#FF5722' })
@IsOptional()
@IsString()
color?: string;
@ApiPropertyOptional({ description: 'Collection icon', example: 'beach' })
@IsOptional()
@IsString()
icon?: string;
@ApiPropertyOptional({ description: 'Is public collection', default: false })
@IsOptional()
@IsBoolean()
isPublic?: boolean;
}
export class AddCollectionItemDto {
@ApiProperty({ description: 'Item ID' })
@IsString()
itemId: string;
@ApiProperty({ description: 'Item type', example: 'place' })
@IsString()
itemType: string;
@ApiPropertyOptional({ description: 'Item name' })
@IsOptional()
@IsString()
itemName?: string;
@ApiPropertyOptional({ description: 'Item image URL' })
@IsOptional()
@IsString()
itemImageUrl?: string;
@ApiPropertyOptional({ description: 'Notes' })
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,2 @@
export * from './create-collection.dto';
export * from './update-collection.dto';

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateCollectionDto } from './create-collection.dto';
export class UpdateCollectionDto extends PartialType(CreateCollectionDto) {}

View File

@@ -0,0 +1,100 @@
import {
Controller,
Post,
Get,
Query,
UseGuards,
Body,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { ContentGeneratorService, GenerationResult } from './content-generator.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RolesGuard } from '../../common/guards/roles.guard';
import { Roles } from '../../common/decorators/roles.decorator';
@ApiTags('Content Generator')
@Controller('content-generator')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
@ApiBearerAuth('JWT-auth')
export class ContentGeneratorController {
constructor(private readonly contentGeneratorService: ContentGeneratorService) {}
@Get('stats')
@ApiOperation({ summary: 'Get content generation statistics' })
@ApiResponse({ status: 200, description: 'Statistics retrieved successfully' })
async getStats() {
return this.contentGeneratorService.getStats();
}
@Post('generate-descriptions')
@ApiOperation({ summary: 'Generate descriptions for monuments (Admin only)' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Max places to process (default: 10)' })
@ApiQuery({ name: 'languages', required: false, type: String, description: 'Comma-separated language codes (default: es,en,fr,it,de)' })
@ApiQuery({ name: 'onlyMissing', required: false, type: Boolean, description: 'Only process places without description (default: true)' })
@ApiResponse({ status: 200, description: 'Descriptions generated successfully' })
async generateDescriptions(
@Query('limit') limit?: number,
@Query('languages') languages?: string,
@Query('onlyMissing') onlyMissing?: boolean,
) {
const languageList = languages ? languages.split(',').map(l => l.trim()) : undefined;
const results = await this.contentGeneratorService.generateAllDescriptions({
limit: limit || 10,
languages: languageList,
onlyMissing: onlyMissing !== false,
});
return {
success: true,
processed: results.length,
successful: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length,
results,
};
}
@Post('generate-audios')
@ApiOperation({ summary: 'Generate audio files for monuments (Admin only)' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Max places to process (default: 10)' })
@ApiQuery({ name: 'languages', required: false, type: String, description: 'Comma-separated language codes (default: es,en,fr,it,de)' })
@ApiResponse({ status: 200, description: 'Audios generated successfully' })
async generateAudios(
@Query('limit') limit?: number,
@Query('languages') languages?: string,
) {
const languageList = languages ? languages.split(',').map(l => l.trim()) : undefined;
const results = await this.contentGeneratorService.generateAllAudios({
limit: limit || 10,
languages: languageList,
});
return {
success: true,
processed: results.length,
successful: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length,
results,
};
}
@Post('generate-single')
@ApiOperation({ summary: 'Generate content for a single place' })
async generateSingle(
@Body() body: { placeId: number; languages?: string[] },
) {
// This would be implemented to generate content for a specific place
return {
message: 'Single place generation not yet implemented',
placeId: body.placeId,
};
}
}

View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ContentGeneratorService } from './content-generator.service';
import { ContentGeneratorController } from './content-generator.controller';
import { PlaceOfInterest } from '../../entities/place-of-interest.entity';
import { KimiModule } from '../kimi/kimi.module';
import { AIGuideModule } from '../ai-guide/ai-guide.module';
@Module({
imports: [
TypeOrmModule.forFeature([PlaceOfInterest]),
KimiModule,
AIGuideModule,
],
controllers: [ContentGeneratorController],
providers: [ContentGeneratorService],
exports: [ContentGeneratorService],
})
export class ContentGeneratorModule {}

View File

@@ -0,0 +1,287 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PlaceOfInterest } from '../../entities/place-of-interest.entity';
import { KimiService } from '../kimi/kimi.service';
import { TTSService } from '../ai-guide/tts.service';
export interface GenerationResult {
placeId: string;
placeName: string;
language: string;
success: boolean;
audioUrl?: string;
error?: string;
}
@Injectable()
export class ContentGeneratorService {
private readonly logger = new Logger(ContentGeneratorService.name);
private readonly languages = ['es', 'en', 'fr', 'it', 'de'];
constructor(
@InjectRepository(PlaceOfInterest)
private readonly placeRepository: Repository<PlaceOfInterest>,
private readonly kimiService: KimiService,
private readonly ttsService: TTSService,
) {}
private getPrompt(placeName: string, country: string, language: string): string {
const countryName = country === 'DO' ? 'República Dominicana' : 'Puerto Rico';
const countryNameEn = country === 'DO' ? 'Dominican Republic' : 'Puerto Rico';
const prompts: Record<string, string> = {
es: `Actúa como un guía turístico ${country === 'DO' ? 'dominicano' : 'puertorriqueño'} experto. Dame una descripción completa e interesante de ${placeName} en ${countryName}. Incluye su historia, importancia cultural, arquitectura, datos curiosos, horarios de visita si aplica. Habla en segunda persona directamente al turista usando "tú" (NO uses "vosotros" ni expresiones de España). Usa español latinoamericano natural. Máximo 250 palabras.`,
en: `Act as an expert ${country === 'DO' ? 'Dominican' : 'Puerto Rican'} tour guide. Give me a complete and interesting description of ${placeName} in ${countryNameEn}. Include its history, cultural importance, architecture, fun facts, and visiting hours if applicable. Speak in second person as if you were giving a tour. Maximum 250 words.`,
fr: `Agis comme un guide touristique ${country === 'DO' ? 'dominicain' : 'portoricain'} expert. Donne-moi une description complète et intéressante de ${placeName} en ${countryName}. Inclus son histoire, son importance culturelle, son architecture, des anecdotes et les horaires de visite si applicable. Parle à la deuxième personne comme si tu donnais une visite guidée. Maximum 250 mots.`,
it: `Agisci come una guida turistica ${country === 'DO' ? 'dominicana' : 'portoricana'} esperta. Dammi una descrizione completa e interessante di ${placeName} in ${countryName}. Includi la sua storia, importanza culturale, architettura, curiosità e orari di visita se applicabile. Parla in seconda persona come se stessi facendo un tour. Massimo 250 parole.`,
de: `Handle als erfahrener ${country === 'DO' ? 'dominikanischer' : 'puertoricanischer'} Reiseführer. Gib mir eine vollständige und interessante Beschreibung von ${placeName} in ${countryName}. Füge die Geschichte, kulturelle Bedeutung, Architektur, interessante Fakten und Besuchszeiten falls zutreffend hinzu. Sprich in der zweiten Person, als würdest du eine Tour geben. Maximal 250 Wörter.`,
};
return prompts[language] || prompts['en'];
}
private getLanguageName(code: string): string {
const names: Record<string, string> = {
es: 'español latinoamericano',
en: 'English',
fr: 'français',
it: 'italiano',
de: 'Deutsch',
};
return names[code] || 'English';
}
async generateDescription(place: PlaceOfInterest, language: string): Promise<string | null> {
const country = place.country || 'DO';
const prompt = this.getPrompt(place.name, country, language);
try {
const response = await this.kimiService.chat([
{
role: 'system',
content: `Eres un guía turístico experto del Caribe. Responde siempre en ${this.getLanguageName(language)}. Sé informativo, amigable y entusiasta. No uses emojis.`,
},
{
role: 'user',
content: prompt,
},
], {
temperature: 0.7,
maxTokens: 1000,
});
return response;
} catch (error: any) {
this.logger.error(`Error generando descripción para ${place.name} (${language}): ${error.message}`);
return null;
}
}
async generateAllDescriptions(options?: {
limit?: number;
languages?: string[];
onlyMissing?: boolean;
}): Promise<GenerationResult[]> {
const limit = options?.limit || 10;
const languages = options?.languages || this.languages;
const onlyMissing = options?.onlyMissing !== false;
this.logger.log(`Iniciando generación de descripciones...`);
this.logger.log(`Límite: ${limit} lugares, Idiomas: ${languages.join(', ')}`);
let places: PlaceOfInterest[];
if (onlyMissing) {
places = await this.placeRepository.find({
where: { active: true },
take: limit,
});
places = places.filter(p => !p.descriptionEs);
} else {
places = await this.placeRepository.find({
where: { active: true },
take: limit,
});
}
this.logger.log(`Encontrados ${places.length} lugares para procesar`);
const results: GenerationResult[] = [];
let processed = 0;
const total = places.length * languages.length;
for (const place of places) {
for (const lang of languages) {
processed++;
this.logger.log(`[${processed}/${total}] Generando ${lang} para: ${place.name}`);
const description = await this.generateDescription(place, lang);
if (description) {
const propName = `description${lang.charAt(0).toUpperCase() + lang.slice(1)}` as keyof PlaceOfInterest;
await this.placeRepository
.createQueryBuilder()
.update()
.set({ [propName]: description } as any)
.where('id = :id', { id: place.id })
.execute();
results.push({
placeId: place.id,
placeName: place.name,
language: lang,
success: true,
});
this.logger.log(`OK: ${place.name} (${lang})`);
} else {
results.push({
placeId: place.id,
placeName: place.name,
language: lang,
success: false,
error: 'No response from Kimi',
});
this.logger.warn(`FAILED: ${place.name} (${lang})`);
}
await this.delay(500);
}
}
const successful = results.filter(r => r.success).length;
this.logger.log(`Generación completada: ${successful}/${total} exitosos`);
return results;
}
async generateAllAudios(options?: {
limit?: number;
languages?: string[];
}): Promise<GenerationResult[]> {
const limit = options?.limit || 100;
const languages = options?.languages || this.languages;
this.logger.log(`Iniciando generación de audios...`);
this.logger.log(`Límite: ${limit} lugares, Idiomas: ${languages.join(', ')}`);
if (!this.ttsService.isAvailable()) {
this.logger.error('TTS Service (Piper) no está disponible');
return [];
}
const places = await this.placeRepository.find({
where: { active: true },
take: limit,
});
this.logger.log(`Encontrados ${places.length} lugares`);
const results: GenerationResult[] = [];
let processed = 0;
let skipped = 0;
for (const place of places) {
for (const lang of languages) {
const descProp = `description${lang.charAt(0).toUpperCase() + lang.slice(1)}` as keyof PlaceOfInterest;
const audioProp = `audioUrl${lang.charAt(0).toUpperCase() + lang.slice(1)}` as keyof PlaceOfInterest;
const description = place[descProp] as string;
const existingAudio = place[audioProp] as string;
if (!description) {
skipped++;
continue;
}
if (existingAudio) {
skipped++;
continue;
}
processed++;
this.logger.log(`[${processed}] Generando audio ${lang} para: ${place.name}`);
try {
const audioUrl = await this.ttsService.generateSpeech(description, lang);
if (audioUrl) {
await this.placeRepository
.createQueryBuilder()
.update()
.set({ [audioProp]: audioUrl } as any)
.where('id = :id', { id: place.id })
.execute();
results.push({
placeId: place.id,
placeName: place.name,
language: lang,
success: true,
audioUrl,
});
this.logger.log(`OK: Audio ${place.name} (${lang})`);
} else {
results.push({
placeId: place.id,
placeName: place.name,
language: lang,
success: false,
error: 'TTS generation returned null',
});
this.logger.warn(`FAILED: Audio ${place.name} (${lang})`);
}
} catch (error: any) {
results.push({
placeId: place.id,
placeName: place.name,
language: lang,
success: false,
error: error.message,
});
this.logger.error(`ERROR: Audio ${place.name} (${lang}): ${error.message}`);
}
await this.delay(100);
}
}
const successful = results.filter(r => r.success).length;
this.logger.log(`Generación de audios completada: ${successful} exitosos, ${skipped} omitidos`);
return results;
}
async getStats(): Promise<{
total: number;
withDescriptionEs: number;
withDescriptionEn: number;
withAudioEs: number;
withAudioEn: number;
pending: number;
}> {
const all = await this.placeRepository.find({ where: { active: true } });
return {
total: all.length,
withDescriptionEs: all.filter(p => p.descriptionEs).length,
withDescriptionEn: all.filter(p => p.descriptionEn).length,
withAudioEs: all.filter(p => p.audioUrlEs).length,
withAudioEn: all.filter(p => p.audioUrlEn).length,
pending: all.filter(p => !p.descriptionEs).length,
};
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,3 @@
export * from './content-generator.module';
export * from './content-generator.service';
export * from './content-generator.controller';

View File

@@ -0,0 +1,33 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsUUID, IsString, IsOptional, IsObject } from 'class-validator';
import { FavoriteItemType } from '../../../entities/user-favorite.entity';
export class CreateFavoriteDto {
@ApiProperty({ description: 'Item ID to favorite', example: 'f47ac10b-58cc-4372-a567-0e02b2c3d479' })
@IsUUID()
itemId: string;
@ApiProperty({ description: 'Type of item', enum: FavoriteItemType, example: FavoriteItemType.PLACE })
@IsEnum(FavoriteItemType)
itemType: FavoriteItemType;
@ApiPropertyOptional({ description: 'Item name for display', example: 'Zona Colonial' })
@IsOptional()
@IsString()
itemName?: string;
@ApiPropertyOptional({ description: 'Item image URL', example: 'https://example.com/image.jpg' })
@IsOptional()
@IsString()
itemImageUrl?: string;
@ApiPropertyOptional({ description: 'Additional item metadata' })
@IsOptional()
@IsObject()
itemMetadata?: Record<string, any>;
@ApiPropertyOptional({ description: 'User notes', example: 'Must visit this place!' })
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,2 @@
export * from './create-favorite.dto';
export * from './update-favorite.dto';

View File

@@ -0,0 +1,14 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsObject } from 'class-validator';
export class UpdateFavoriteDto {
@ApiPropertyOptional({ description: 'User notes', example: 'Updated notes' })
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional({ description: 'Additional item metadata' })
@IsOptional()
@IsObject()
itemMetadata?: Record<string, any>;
}

View File

@@ -0,0 +1,125 @@
import {
Controller, Get, Post, Body, Patch, Param, Delete, Query, UseGuards, Request,
} from '@nestjs/common';
import {
ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam,
} from '@nestjs/swagger';
import { FavoritesService } from './favorites.service';
import { CreateFavoriteDto } from './dto/create-favorite.dto';
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { UserFavorite, FavoriteItemType } from '../../entities/user-favorite.entity';
@ApiTags('Favorites')
@Controller('favorites')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
export class FavoritesController {
constructor(private readonly favoritesService: FavoritesService) {}
@Post()
@ApiOperation({ summary: 'Add item to favorites' })
@ApiResponse({ status: 201, description: 'Item added to favorites', type: UserFavorite })
@ApiResponse({ status: 409, description: 'Item already in favorites' })
create(@Body() createFavoriteDto: CreateFavoriteDto, @Request() req) {
return this.favoritesService.create(req.user.id, createFavoriteDto);
}
@Get('my')
@ApiOperation({ summary: 'Get current user favorites' })
@ApiQuery({ name: 'type', required: false, enum: FavoriteItemType, description: 'Filter by item type' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiResponse({ status: 200, description: 'User favorites retrieved' })
findMyFavorites(
@Request() req,
@Query('type') type?: FavoriteItemType,
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.favoritesService.findAllByUser(req.user.id, type, page, limit);
}
@Get('my/counts')
@ApiOperation({ summary: 'Get favorites count by type' })
@ApiResponse({ status: 200, description: 'Favorites counts by type' })
getFavoritesCounts(@Request() req) {
return this.favoritesService.getFavoritesCount(req.user.id);
}
@Get('check/:itemType/:itemId')
@ApiOperation({ summary: 'Check if item is favorited' })
@ApiParam({ name: 'itemType', enum: FavoriteItemType })
@ApiParam({ name: 'itemId', type: 'string' })
@ApiResponse({ status: 200, schema: { type: 'object', properties: { isFavorited: { type: 'boolean' } } } })
checkFavorite(
@Request() req,
@Param('itemType') itemType: FavoriteItemType,
@Param('itemId') itemId: string,
) {
return this.favoritesService.isFavorited(req.user.id, itemId, itemType).then(isFavorited => ({ isFavorited }));
}
@Post('toggle')
@ApiOperation({ summary: 'Toggle favorite status for an item' })
@ApiResponse({ status: 200, description: 'Favorite toggled' })
toggleFavorite(
@Request() req,
@Body() body: {
itemId: string;
itemType: FavoriteItemType;
name?: string;
imageUrl?: string;
metadata?: Record<string, any>;
},
) {
return this.favoritesService.toggleFavorite(
req.user.id,
body.itemId,
body.itemType,
{ name: body.name, imageUrl: body.imageUrl, metadata: body.metadata }
);
}
@Get(':id')
@ApiOperation({ summary: 'Get favorite by ID' })
@ApiParam({ name: 'id', type: 'string' })
@ApiResponse({ status: 200, type: UserFavorite })
@ApiResponse({ status: 404, description: 'Favorite not found' })
findOne(@Param('id') id: string, @Request() req) {
return this.favoritesService.findOne(req.user.id, id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update favorite (notes, metadata)' })
@ApiParam({ name: 'id', type: 'string' })
@ApiResponse({ status: 200, type: UserFavorite })
update(
@Param('id') id: string,
@Body() updateFavoriteDto: UpdateFavoriteDto,
@Request() req,
) {
return this.favoritesService.update(req.user.id, id, updateFavoriteDto);
}
@Delete(':id')
@ApiOperation({ summary: 'Remove from favorites' })
@ApiParam({ name: 'id', type: 'string' })
@ApiResponse({ status: 200, description: 'Removed from favorites' })
remove(@Param('id') id: string, @Request() req) {
return this.favoritesService.remove(req.user.id, id);
}
@Delete('item/:itemType/:itemId')
@ApiOperation({ summary: 'Remove from favorites by item ID and type' })
@ApiParam({ name: 'itemType', enum: FavoriteItemType })
@ApiParam({ name: 'itemId', type: 'string' })
@ApiResponse({ status: 200, description: 'Removed from favorites' })
removeByItem(
@Param('itemType') itemType: FavoriteItemType,
@Param('itemId') itemId: string,
@Request() req,
) {
return this.favoritesService.removeByItem(req.user.id, itemId, itemType);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FavoritesService } from './favorites.service';
import { FavoritesController } from './favorites.controller';
import { UserFavorite } from '../../entities/user-favorite.entity';
@Module({
imports: [TypeOrmModule.forFeature([UserFavorite])],
controllers: [FavoritesController],
providers: [FavoritesService],
exports: [FavoritesService],
})
export class FavoritesModule {}

View File

@@ -0,0 +1,149 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserFavorite, FavoriteItemType } from '../../entities/user-favorite.entity';
import { CreateFavoriteDto } from './dto/create-favorite.dto';
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
@Injectable()
export class FavoritesService {
constructor(
@InjectRepository(UserFavorite)
private readonly favoriteRepository: Repository<UserFavorite>,
) {}
async create(userId: string, createFavoriteDto: CreateFavoriteDto): Promise<UserFavorite> {
// Check if already favorited
const existing = await this.favoriteRepository.findOne({
where: {
userId,
itemId: createFavoriteDto.itemId,
itemType: createFavoriteDto.itemType,
},
});
if (existing) {
throw new ConflictException('Item already in favorites');
}
const favorite = this.favoriteRepository.create({
userId,
...createFavoriteDto,
});
return this.favoriteRepository.save(favorite);
}
async findAllByUser(
userId: string,
itemType?: FavoriteItemType,
page = 1,
limit = 20,
): Promise<{ data: UserFavorite[]; total: number; page: number; limit: number }> {
const where: any = { userId };
if (itemType) {
where.itemType = itemType;
}
const [data, total] = await this.favoriteRepository.findAndCount({
where,
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
return { data, total, page, limit };
}
async findOne(userId: string, id: string): Promise<UserFavorite> {
const favorite = await this.favoriteRepository.findOne({
where: { id, userId },
});
if (!favorite) {
throw new NotFoundException('Favorite not found');
}
return favorite;
}
async update(userId: string, id: string, updateFavoriteDto: UpdateFavoriteDto): Promise<UserFavorite> {
const favorite = await this.findOne(userId, id);
Object.assign(favorite, updateFavoriteDto);
return this.favoriteRepository.save(favorite);
}
async remove(userId: string, id: string): Promise<void> {
const favorite = await this.findOne(userId, id);
await this.favoriteRepository.remove(favorite);
}
async removeByItem(userId: string, itemId: string, itemType: FavoriteItemType): Promise<void> {
const favorite = await this.favoriteRepository.findOne({
where: { userId, itemId, itemType },
});
if (favorite) {
await this.favoriteRepository.remove(favorite);
}
}
async isFavorited(userId: string, itemId: string, itemType: FavoriteItemType): Promise<boolean> {
const count = await this.favoriteRepository.count({
where: { userId, itemId, itemType },
});
return count > 0;
}
async getFavoritesCount(userId: string): Promise<Record<FavoriteItemType, number>> {
const counts = await this.favoriteRepository
.createQueryBuilder('favorite')
.select('favorite.item_type', 'itemType')
.addSelect('COUNT(*)', 'count')
.where('favorite.user_id = :userId', { userId })
.groupBy('favorite.item_type')
.getRawMany();
const result: Record<string, number> = {};
Object.values(FavoriteItemType).forEach(type => {
result[type] = 0;
});
counts.forEach(c => {
result[c.itemType] = parseInt(c.count, 10);
});
return result as Record<FavoriteItemType, number>;
}
async toggleFavorite(
userId: string,
itemId: string,
itemType: FavoriteItemType,
itemData?: { name?: string; imageUrl?: string; metadata?: Record<string, any> }
): Promise<{ isFavorited: boolean; favorite?: UserFavorite }> {
const existing = await this.favoriteRepository.findOne({
where: { userId, itemId, itemType },
});
if (existing) {
await this.favoriteRepository.remove(existing);
return { isFavorited: false };
}
const favorite = this.favoriteRepository.create({
userId,
itemId,
itemType,
itemName: itemData?.name,
itemImageUrl: itemData?.imageUrl,
itemMetadata: itemData?.metadata,
});
const saved = await this.favoriteRepository.save(favorite);
return { isFavorited: true, favorite: saved };
}
}

View File

@@ -88,16 +88,23 @@ export class GeolocationController {
}
@Get('nearby/attractions')
@ApiOperation({ summary: 'Get nearby attractions' })
@ApiOperation({ summary: 'Get nearby attractions with multi-language support' })
@ApiQuery({ name: 'latitude', type: Number })
@ApiQuery({ name: 'longitude', type: Number })
@ApiQuery({ name: 'radius', required: false, type: Number, description: 'Radius in meters' })
@ApiQuery({ name: 'radius', required: false, type: Number, description: 'Radius in meters (default: 1000)' })
@ApiQuery({ name: 'language', required: false, type: String, description: 'Language code (es, en, fr, it, de)' })
async getNearbyAttractions(
@Query('latitude') latitude: number,
@Query('longitude') longitude: number,
@Query('radius') radius?: number,
@Query('language') language?: string,
) {
return this.geolocationService['getNearbyAttractions'](latitude, longitude, radius);
return this.geolocationService.getNearbyAttractions(
latitude,
longitude,
radius || 1000,
language || 'es',
);
}
// SMART SUGGESTIONS (REQUIERE USUARIO)

View File

@@ -180,16 +180,72 @@ export class GeolocationService {
};
}
private async getNearbyAttractions(
// NEARBY ATTRACTIONS WITH DISTANCE FILTERING AND MULTI-LANGUAGE SUPPORT
async getNearbyAttractions(
latitude: number,
longitude: number,
radiusMeters: number = 1000,
): Promise<PlaceOfInterest[]> {
// In production, use PostGIS ST_DWithin for accurate distance queries
return this.placeRepository.find({
language: string = 'es',
): Promise<any[]> {
// Get all active places
const allPlaces = await this.placeRepository.find({
where: { active: true },
order: { rating: 'DESC' },
take: 10,
});
// Filter by actual distance and add distance info
const nearbyPlaces = allPlaces
.map(place => {
const placeLat = this.extractLatFromPoint(place.coordinates);
const placeLng = this.extractLngFromPoint(place.coordinates);
const distance = this.calculateDistance(latitude, longitude, placeLat, placeLng);
return {
...place,
distance,
distanceFormatted: distance < 1000
? `${Math.round(distance)}m`
: `${(distance / 1000).toFixed(1)}km`,
};
})
.filter(place => place.distance <= radiusMeters)
.sort((a, b) => a.distance - b.distance);
// Language mapping for camelCase property names (matching TypeORM entity)
const langMap: Record<string, { descKey: string; audioKey: string }> = {
es: { descKey: 'descriptionEs', audioKey: 'audioUrlEs' },
en: { descKey: 'descriptionEn', audioKey: 'audioUrlEn' },
fr: { descKey: 'descriptionFr', audioKey: 'audioUrlFr' },
it: { descKey: 'descriptionIt', audioKey: 'audioUrlIt' },
de: { descKey: 'descriptionDe', audioKey: 'audioUrlDe' },
};
const lang = langMap[language] || langMap['es'];
// Map to response with language-specific content
return nearbyPlaces.slice(0, 10).map(place => {
const description = (place as any)[lang.descKey] || place.descriptionEs || null;
const audioUrl = (place as any)[lang.audioKey] || null;
return {
id: place.id,
name: place.name,
slug: place.slug,
category: place.category,
country: place.country,
address: place.address,
coordinates: {
latitude: this.extractLatFromPoint(place.coordinates),
longitude: this.extractLngFromPoint(place.coordinates),
},
distance: place.distance,
distanceFormatted: place.distanceFormatted,
description,
audioUrl,
voiceId: place.voiceId || 'es_DO-keira-medium',
rating: place.rating,
featured: place.featured || false,
images: place.images,
};
});
}

View File

@@ -0,0 +1,445 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { Geofence } from '../../entities/geofence.entity';
import { LocationTracking } from '../../entities/location-tracking.entity';
import { PlaceOfInterest } from '../../entities/place-of-interest.entity';
import { CreateGeofenceDto } from './dto/geofence.dto';
import { LocationUpdateDto } from './dto/location-update.dto';
import { NotificationsService } from '../notifications/notifications.service';
import { SecurityService } from '../security/security.service';
import { NotificationType, NotificationCategory } from '../notifications/dto/create-notification.dto';
@Injectable()
export class GeolocationService {
constructor(
@InjectRepository(Geofence)
private readonly geofenceRepository: Repository<Geofence>,
@InjectRepository(LocationTracking)
private readonly locationTrackingRepository: Repository<LocationTracking>,
@InjectRepository(PlaceOfInterest)
private readonly placeRepository: Repository<PlaceOfInterest>,
private readonly configService: ConfigService,
private readonly notificationsService: NotificationsService,
private readonly securityService: SecurityService,
) { }
// GEOFENCING MANAGEMENT
async createGeofence(createGeofenceDto: CreateGeofenceDto): Promise<Geofence> {
const geofence = this.geofenceRepository.create({
...createGeofenceDto,
centerCoordinates: `(${createGeofenceDto.longitude},${createGeofenceDto.latitude})`,
});
return this.geofenceRepository.save(geofence);
}
async getActiveGeofences(): Promise<Geofence[]> {
return this.geofenceRepository.find({
where: { isActive: true },
order: { createdAt: 'DESC' },
});
}
async checkGeofenceEntry(
userId: string,
latitude: number,
longitude: number,
): Promise<{
triggeredGeofences: Geofence[];
alerts: Array<{ type: string; message: string; geofence: Geofence }>;
}> {
// In production, use PostGIS for accurate geospatial calculations
// For now, simulate geofence detection
const activeGeofences = await this.getActiveGeofences();
const triggeredGeofences: Geofence[] = [];
const alerts: Array<{ type: string; message: string; geofence: Geofence }> = [];
for (const geofence of activeGeofences) {
const distance = this.calculateDistance(
latitude,
longitude,
this.extractLatFromPoint(geofence.centerCoordinates),
this.extractLngFromPoint(geofence.centerCoordinates)
);
if (distance <= geofence.radius) {
triggeredGeofences.push(geofence);
// Increment entry count
await this.geofenceRepository.increment({ id: geofence.id }, 'entryCount', 1);
// Handle different geofence types
switch (geofence.type) {
case 'safety-alert':
alerts.push({
type: 'warning',
message: geofence.entryMessage || 'You have entered a safety alert zone. Please be cautious.',
geofence,
});
break;
case 'tourist-zone':
alerts.push({
type: 'info',
message: geofence.entryMessage || `Welcome to ${geofence.name}! Explore the attractions around you.`,
geofence,
});
break;
case 'attraction':
alerts.push({
type: 'attraction',
message: geofence.entryMessage || `You're near ${geofence.name}. Check out what's available!`,
geofence,
});
break;
case 'restricted-area':
alerts.push({
type: 'restriction',
message: geofence.entryMessage || 'You have entered a restricted area. Please respect local regulations.',
geofence,
});
break;
case 'pickup-zone':
alerts.push({
type: 'service',
message: geofence.entryMessage || 'You are in a designated pickup zone for taxis and tours.',
geofence,
});
break;
}
// Send notification
await this.notificationsService.createNotification({
userId,
type: NotificationType.PUSH,
category: NotificationCategory.SECURITY,
title: `📍 ${geofence.name}`,
message: alerts[alerts.length - 1]?.message || 'Location alert',
data: { geofenceId: geofence.id, type: geofence.type },
});
}
}
return { triggeredGeofences, alerts };
}
// LOCATION TRACKING
async updateUserLocation(
userId: string,
locationDto: LocationUpdateDto,
): Promise<{
success: boolean;
geofenceAlerts: any[];
nearbyAttractions: PlaceOfInterest[];
smartSuggestions: string[];
}> {
// Save location tracking
const tracking = this.locationTrackingRepository.create({
userId,
coordinates: `(${locationDto.longitude},${locationDto.latitude})`,
accuracy: locationDto.accuracy,
speed: locationDto.speed,
heading: locationDto.heading,
activity: locationDto.activity,
});
await this.locationTrackingRepository.save(tracking);
// Check geofences
const geofenceCheck = await this.checkGeofenceEntry(
userId,
locationDto.latitude,
locationDto.longitude,
);
// Get nearby attractions
const nearbyAttractions = await this.getNearbyAttractions(
locationDto.latitude,
locationDto.longitude,
1000, // 1km radius
);
// Generate smart suggestions based on location and activity
const smartSuggestions = await this.generateSmartSuggestions(
userId,
locationDto,
nearbyAttractions,
);
return {
success: true,
geofenceAlerts: geofenceCheck.alerts,
nearbyAttractions,
smartSuggestions,
};
}
// NEARBY ATTRACTIONS WITH DISTANCE FILTERING AND MULTI-LANGUAGE SUPPORT
async getNearbyAttractions(
latitude: number,
longitude: number,
radiusMeters: number = 1000,
language: string = 'es',
): Promise<any[]> {
// Get all active places
const allPlaces = await this.placeRepository.find({
where: { active: true },
});
// Filter by actual distance and add distance info
const nearbyPlaces = allPlaces
.map(place => {
const placeLat = this.extractLatFromPoint(place.coordinates);
const placeLng = this.extractLngFromPoint(place.coordinates);
const distance = this.calculateDistance(latitude, longitude, placeLat, placeLng);
return {
...place,
distance,
distanceFormatted: distance < 1000
? `${Math.round(distance)}m`
: `${(distance / 1000).toFixed(1)}km`,
};
})
.filter(place => place.distance <= radiusMeters)
.sort((a, b) => a.distance - b.distance);
// Map to response with language-specific content
return nearbyPlaces.slice(0, 10).map(place => {
const descriptionKey = `description_${language}` as keyof typeof place;
const audioKey = `audio_url_${language}` as keyof typeof place;
return {
id: place.id,
name: place.name,
slug: (place as any).slug,
category: place.category,
country: (place as any).country,
address: place.address,
coordinates: {
latitude: this.extractLatFromPoint(place.coordinates),
longitude: this.extractLngFromPoint(place.coordinates),
},
distance: place.distance,
distanceFormatted: place.distanceFormatted,
description: (place as any)[descriptionKey] || (place as any).description_es || null,
audioUrl: (place as any)[audioKey] || null,
voiceId: (place as any).voice_id || 'es_DO-keira-medium',
rating: place.rating,
featured: (place as any).featured || false,
images: place.images,
};
});
}
private async generateSmartSuggestions(
userId: string,
location: LocationUpdateDto,
nearbyAttractions: PlaceOfInterest[],
): Promise<string[]> {
const suggestions: string[] = [];
const currentHour = new Date().getHours();
// Time-based suggestions
if (currentHour >= 6 && currentHour <= 10) {
suggestions.push("Good morning! Start your day with a visit to a nearby historic site.");
} else if (currentHour >= 11 && currentHour <= 14) {
suggestions.push("It's lunch time! Find authentic Dominican restaurants nearby.");
} else if (currentHour >= 15 && currentHour <= 18) {
suggestions.push("Perfect time for sightseeing! Explore attractions around you.");
} else if (currentHour >= 19 && currentHour <= 23) {
suggestions.push("Evening entertainment awaits! Check out local restaurants and nightlife.");
}
// Activity-based suggestions
if (location.activity === 'walking') {
suggestions.push("Great walking weather! Discover hidden gems on foot.");
} else if (location.activity === 'stationary') {
suggestions.push("Take a moment to explore what's around you.");
}
// Speed-based suggestions
if (location.speed && location.speed > 30) {
suggestions.push("Traveling fast? Don't miss scenic viewpoints along your route.");
}
// Attraction-based suggestions
if (nearbyAttractions.length > 0) {
const topAttraction = nearbyAttractions[0];
suggestions.push(`${topAttraction.name} is nearby (${topAttraction.rating}/5 stars). Worth a visit!`);
}
return suggestions.slice(0, 3); // Return top 3 suggestions
}
// SMART NAVIGATION
async getOptimizedRoute(
startLat: number,
startLng: number,
endLat: number,
endLng: number,
travelMode: string = 'walking',
includeAttractions: boolean = true,
): Promise<{
route: any;
duration: number;
distance: number;
waypoints: PlaceOfInterest[];
weatherInfo: any;
safetyTips: string[];
}> {
// In production, integrate with Google Maps Directions API
const mockRoute = {
steps: [
{ instruction: 'Head north on Calle Las Damas', distance: '200m', duration: '3 min' },
{ instruction: 'Turn right at Plaza de Armas', distance: '150m', duration: '2 min' },
{ instruction: 'Continue straight to destination', distance: '100m', duration: '1 min' },
],
};
const waypoints = includeAttractions
? await this.getWaypointAttractions(startLat, startLng, endLat, endLng)
: [];
const weatherInfo = await this.getRouteWeatherInfo(startLat, startLng);
const safetyTips = this.generateRouteSafetyTips(travelMode, new Date().getHours());
return {
route: mockRoute,
duration: 6, // minutes
distance: 450, // meters
waypoints,
weatherInfo,
safetyTips,
};
}
private async getWaypointAttractions(
startLat: number,
startLng: number,
endLat: number,
endLng: number,
): Promise<PlaceOfInterest[]> {
// Find attractions along the route
return this.placeRepository.find({
where: { active: true },
order: { rating: 'DESC' },
take: 3,
});
}
private async getRouteWeatherInfo(lat: number, lng: number): Promise<any> {
// In production, integrate with weather API
return {
temperature: 28,
condition: 'sunny',
humidity: 75,
recommendation: 'Perfect weather for walking! Stay hydrated.',
};
}
private generateRouteSafetyTips(travelMode: string, hour: number): string[] {
const tips: string[] = [
'Stay aware of your surroundings',
'Keep your belongings secure',
];
if (travelMode === 'walking') {
tips.push('Use sidewalks and well-lit paths');
if (hour >= 20 || hour <= 6) {
tips.push('Consider using a taxi for night travel');
}
}
if (travelMode === 'driving') {
tips.push('Follow local traffic laws');
tips.push('Use GPS navigation');
}
return tips;
}
// ANALYTICS
async getLocationAnalytics(timeframe: string = '7d'): Promise<{
totalUsers: number;
activeUsers: number;
popularZones: Array<{ name: string; visits: number }>;
geofenceStats: Array<{ name: string; entries: number; type: string }>;
heatmapData: Array<{ lat: number; lng: number; intensity: number }>;
}> {
const days = timeframe === '7d' ? 7 : timeframe === '30d' ? 30 : 90;
const totalUsers = await this.locationTrackingRepository
.createQueryBuilder('tracking')
.select('COUNT(DISTINCT tracking.userId)', 'count')
.where(`tracking.createdAt >= NOW() - INTERVAL '${days} days'`)
.getRawOne();
const activeUsers = await this.locationTrackingRepository
.createQueryBuilder('tracking')
.select('COUNT(DISTINCT tracking.userId)', 'count')
.where(`tracking.createdAt >= NOW() - INTERVAL '1 days'`)
.getRawOne();
const geofenceStats = await this.geofenceRepository.find({
where: { isActive: true },
order: { entryCount: 'DESC' },
take: 10,
});
// Simplified heatmap data (in production, aggregate actual location data)
const heatmapData = [
{ lat: 18.4861, lng: -69.9312, intensity: 0.8 }, // Santo Domingo
{ lat: 18.5204, lng: -68.7340, intensity: 0.6 }, // Punta Cana
{ lat: 19.7933, lng: -70.6928, intensity: 0.5 }, // Puerto Plata
];
return {
totalUsers: parseInt(totalUsers.count),
activeUsers: parseInt(activeUsers.count),
popularZones: [], // TODO: Implement zone analysis
geofenceStats: geofenceStats.map(g => ({
name: g.name,
entries: g.entryCount,
type: g.type,
})),
heatmapData,
};
}
// UTILITY METHODS
private calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371000; // Earth's radius in meters
const dLat = this.toRadians(lat2 - lat1);
const dLng = this.toRadians(lng2 - lng1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
private toRadians(degrees: number): number {
return degrees * (Math.PI / 180);
}
private extractLatFromPoint(point: any): number {
if (point && typeof point === 'object' && point.y !== undefined) {
return point.y; // latitude
}
return 0;
}
private extractLngFromPoint(point: any): number {
if (point && typeof point === 'object' && point.x !== undefined) {
return point.x; // longitude
}
return 0;
}
}

View File

@@ -0,0 +1,400 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { Geofence } from '../../entities/geofence.entity';
import { LocationTracking } from '../../entities/location-tracking.entity';
import { PlaceOfInterest } from '../../entities/place-of-interest.entity';
import { CreateGeofenceDto } from './dto/geofence.dto';
import { LocationUpdateDto } from './dto/location-update.dto';
import { NotificationsService } from '../notifications/notifications.service';
import { SecurityService } from '../security/security.service';
import { NotificationType, NotificationCategory } from '../notifications/dto/create-notification.dto';
@Injectable()
export class GeolocationService {
constructor(
@InjectRepository(Geofence)
private readonly geofenceRepository: Repository<Geofence>,
@InjectRepository(LocationTracking)
private readonly locationTrackingRepository: Repository<LocationTracking>,
@InjectRepository(PlaceOfInterest)
private readonly placeRepository: Repository<PlaceOfInterest>,
private readonly configService: ConfigService,
private readonly notificationsService: NotificationsService,
private readonly securityService: SecurityService,
) { }
// GEOFENCING MANAGEMENT
async createGeofence(createGeofenceDto: CreateGeofenceDto): Promise<Geofence> {
const geofence = this.geofenceRepository.create({
...createGeofenceDto,
centerCoordinates: `(${createGeofenceDto.longitude},${createGeofenceDto.latitude})`,
});
return this.geofenceRepository.save(geofence);
}
async getActiveGeofences(): Promise<Geofence[]> {
return this.geofenceRepository.find({
where: { isActive: true },
order: { createdAt: 'DESC' },
});
}
async checkGeofenceEntry(
userId: string,
latitude: number,
longitude: number,
): Promise<{
triggeredGeofences: Geofence[];
alerts: Array<{ type: string; message: string; geofence: Geofence }>;
}> {
// In production, use PostGIS for accurate geospatial calculations
// For now, simulate geofence detection
const activeGeofences = await this.getActiveGeofences();
const triggeredGeofences: Geofence[] = [];
const alerts: Array<{ type: string; message: string; geofence: Geofence }> = [];
for (const geofence of activeGeofences) {
const distance = this.calculateDistance(
latitude,
longitude,
this.extractLatFromPoint(geofence.centerCoordinates),
this.extractLngFromPoint(geofence.centerCoordinates)
);
if (distance <= geofence.radius) {
triggeredGeofences.push(geofence);
// Increment entry count
await this.geofenceRepository.increment({ id: geofence.id }, 'entryCount', 1);
// Handle different geofence types
switch (geofence.type) {
case 'safety-alert':
alerts.push({
type: 'warning',
message: geofence.entryMessage || 'You have entered a safety alert zone. Please be cautious.',
geofence,
});
break;
case 'tourist-zone':
alerts.push({
type: 'info',
message: geofence.entryMessage || `Welcome to ${geofence.name}! Explore the attractions around you.`,
geofence,
});
break;
case 'attraction':
alerts.push({
type: 'attraction',
message: geofence.entryMessage || `You're near ${geofence.name}. Check out what's available!`,
geofence,
});
break;
case 'restricted-area':
alerts.push({
type: 'restriction',
message: geofence.entryMessage || 'You have entered a restricted area. Please respect local regulations.',
geofence,
});
break;
case 'pickup-zone':
alerts.push({
type: 'service',
message: geofence.entryMessage || 'You are in a designated pickup zone for taxis and tours.',
geofence,
});
break;
}
// Send notification
await this.notificationsService.createNotification({
userId,
type: NotificationType.PUSH,
category: NotificationCategory.SECURITY,
title: `📍 ${geofence.name}`,
message: alerts[alerts.length - 1]?.message || 'Location alert',
data: { geofenceId: geofence.id, type: geofence.type },
});
}
}
return { triggeredGeofences, alerts };
}
// LOCATION TRACKING
async updateUserLocation(
userId: string,
locationDto: LocationUpdateDto,
): Promise<{
success: boolean;
geofenceAlerts: any[];
nearbyAttractions: PlaceOfInterest[];
smartSuggestions: string[];
}> {
// Save location tracking
const tracking = this.locationTrackingRepository.create({
userId,
coordinates: `(${locationDto.longitude},${locationDto.latitude})`,
accuracy: locationDto.accuracy,
speed: locationDto.speed,
heading: locationDto.heading,
activity: locationDto.activity,
});
await this.locationTrackingRepository.save(tracking);
// Check geofences
const geofenceCheck = await this.checkGeofenceEntry(
userId,
locationDto.latitude,
locationDto.longitude,
);
// Get nearby attractions
const nearbyAttractions = await this.getNearbyAttractions(
locationDto.latitude,
locationDto.longitude,
1000, // 1km radius
);
// Generate smart suggestions based on location and activity
const smartSuggestions = await this.generateSmartSuggestions(
userId,
locationDto,
nearbyAttractions,
);
return {
success: true,
geofenceAlerts: geofenceCheck.alerts,
nearbyAttractions,
smartSuggestions,
};
}
private async getNearbyAttractions(
latitude: number,
longitude: number,
radiusMeters: number = 1000,
): Promise<PlaceOfInterest[]> {
// In production, use PostGIS ST_DWithin for accurate distance queries
return this.placeRepository.find({
where: { active: true },
order: { rating: 'DESC' },
take: 10,
});
}
private async generateSmartSuggestions(
userId: string,
location: LocationUpdateDto,
nearbyAttractions: PlaceOfInterest[],
): Promise<string[]> {
const suggestions: string[] = [];
const currentHour = new Date().getHours();
// Time-based suggestions
if (currentHour >= 6 && currentHour <= 10) {
suggestions.push("Good morning! Start your day with a visit to a nearby historic site.");
} else if (currentHour >= 11 && currentHour <= 14) {
suggestions.push("It's lunch time! Find authentic Dominican restaurants nearby.");
} else if (currentHour >= 15 && currentHour <= 18) {
suggestions.push("Perfect time for sightseeing! Explore attractions around you.");
} else if (currentHour >= 19 && currentHour <= 23) {
suggestions.push("Evening entertainment awaits! Check out local restaurants and nightlife.");
}
// Activity-based suggestions
if (location.activity === 'walking') {
suggestions.push("Great walking weather! Discover hidden gems on foot.");
} else if (location.activity === 'stationary') {
suggestions.push("Take a moment to explore what's around you.");
}
// Speed-based suggestions
if (location.speed && location.speed > 30) {
suggestions.push("Traveling fast? Don't miss scenic viewpoints along your route.");
}
// Attraction-based suggestions
if (nearbyAttractions.length > 0) {
const topAttraction = nearbyAttractions[0];
suggestions.push(`${topAttraction.name} is nearby (${topAttraction.rating}/5 stars). Worth a visit!`);
}
return suggestions.slice(0, 3); // Return top 3 suggestions
}
// SMART NAVIGATION
async getOptimizedRoute(
startLat: number,
startLng: number,
endLat: number,
endLng: number,
travelMode: string = 'walking',
includeAttractions: boolean = true,
): Promise<{
route: any;
duration: number;
distance: number;
waypoints: PlaceOfInterest[];
weatherInfo: any;
safetyTips: string[];
}> {
// In production, integrate with Google Maps Directions API
const mockRoute = {
steps: [
{ instruction: 'Head north on Calle Las Damas', distance: '200m', duration: '3 min' },
{ instruction: 'Turn right at Plaza de Armas', distance: '150m', duration: '2 min' },
{ instruction: 'Continue straight to destination', distance: '100m', duration: '1 min' },
],
};
const waypoints = includeAttractions
? await this.getWaypointAttractions(startLat, startLng, endLat, endLng)
: [];
const weatherInfo = await this.getRouteWeatherInfo(startLat, startLng);
const safetyTips = this.generateRouteSafetyTips(travelMode, new Date().getHours());
return {
route: mockRoute,
duration: 6, // minutes
distance: 450, // meters
waypoints,
weatherInfo,
safetyTips,
};
}
private async getWaypointAttractions(
startLat: number,
startLng: number,
endLat: number,
endLng: number,
): Promise<PlaceOfInterest[]> {
// Find attractions along the route
return this.placeRepository.find({
where: { active: true },
order: { rating: 'DESC' },
take: 3,
});
}
private async getRouteWeatherInfo(lat: number, lng: number): Promise<any> {
// In production, integrate with weather API
return {
temperature: 28,
condition: 'sunny',
humidity: 75,
recommendation: 'Perfect weather for walking! Stay hydrated.',
};
}
private generateRouteSafetyTips(travelMode: string, hour: number): string[] {
const tips: string[] = [
'Stay aware of your surroundings',
'Keep your belongings secure',
];
if (travelMode === 'walking') {
tips.push('Use sidewalks and well-lit paths');
if (hour >= 20 || hour <= 6) {
tips.push('Consider using a taxi for night travel');
}
}
if (travelMode === 'driving') {
tips.push('Follow local traffic laws');
tips.push('Use GPS navigation');
}
return tips;
}
// ANALYTICS
async getLocationAnalytics(timeframe: string = '7d'): Promise<{
totalUsers: number;
activeUsers: number;
popularZones: Array<{ name: string; visits: number }>;
geofenceStats: Array<{ name: string; entries: number; type: string }>;
heatmapData: Array<{ lat: number; lng: number; intensity: number }>;
}> {
const days = timeframe === '7d' ? 7 : timeframe === '30d' ? 30 : 90;
const totalUsers = await this.locationTrackingRepository
.createQueryBuilder('tracking')
.select('COUNT(DISTINCT tracking.userId)', 'count')
.where(`tracking.createdAt >= NOW() - INTERVAL '${days} days'`)
.getRawOne();
const activeUsers = await this.locationTrackingRepository
.createQueryBuilder('tracking')
.select('COUNT(DISTINCT tracking.userId)', 'count')
.where(`tracking.createdAt >= NOW() - INTERVAL '1 days'`)
.getRawOne();
const geofenceStats = await this.geofenceRepository.find({
where: { isActive: true },
order: { entryCount: 'DESC' },
take: 10,
});
// Simplified heatmap data (in production, aggregate actual location data)
const heatmapData = [
{ lat: 18.4861, lng: -69.9312, intensity: 0.8 }, // Santo Domingo
{ lat: 18.5204, lng: -68.7340, intensity: 0.6 }, // Punta Cana
{ lat: 19.7933, lng: -70.6928, intensity: 0.5 }, // Puerto Plata
];
return {
totalUsers: parseInt(totalUsers.count),
activeUsers: parseInt(activeUsers.count),
popularZones: [], // TODO: Implement zone analysis
geofenceStats: geofenceStats.map(g => ({
name: g.name,
entries: g.entryCount,
type: g.type,
})),
heatmapData,
};
}
// UTILITY METHODS
private calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371000; // Earth's radius in meters
const dLat = this.toRadians(lat2 - lat1);
const dLng = this.toRadians(lng2 - lng1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
private toRadians(degrees: number): number {
return degrees * (Math.PI / 180);
}
private extractLatFromPoint(point: any): number {
if (point && typeof point === 'object' && point.y !== undefined) {
return point.y; // latitude
}
return 0;
}
private extractLngFromPoint(point: any): number {
if (point && typeof point === 'object' && point.x !== undefined) {
return point.x; // longitude
}
return 0;
}
}

View File

@@ -0,0 +1,68 @@
private async getNearbyAttractions(
latitude: number,
longitude: number,
radiusMeters: number = 500,
language: string = 'es',
): Promise<any[]> {
// Calcular bounding box para filtrar aproximadamente
const latDelta = radiusMeters / 111320; // 1 grado ~ 111km
const lngDelta = radiusMeters / (111320 * Math.cos(latitude * Math.PI / 180));
const places = await this.placeRepository
.createQueryBuilder('place')
.where('place.active = :active', { active: true })
.andWhere('place.coordinates IS NOT NULL')
.orderBy('place.featured', 'DESC')
.addOrderBy('place.rating', 'DESC')
.take(20)
.getMany();
// Filtrar y calcular distancia real
const nearbyPlaces = places
.map(place => {
const coords = this.extractCoordsFromPoint(place.coordinates as any);
if (!coords) return null;
const distance = this.calculateDistance(latitude, longitude, coords.lat, coords.lng);
if (distance > radiusMeters) return null;
// Seleccionar descripción según idioma
const descriptionKey = `description_${language}` as keyof typeof place;
const description = (place as any)[descriptionKey] || place.description || '';
return {
id: place.id,
name: place.name,
slug: (place as any).slug,
category: place.category,
distance: Math.round(distance),
coordinates: coords,
thumbnail: place.images?.[0] || null,
shortDescription: description.substring(0, 200),
fullDescription: description,
address: place.address,
rating: place.rating,
country: (place as any).country,
featured: (place as any).featured,
};
})
.filter(p => p !== null)
.sort((a, b) => a!.distance - b!.distance);
return nearbyPlaces;
}
private extractCoordsFromPoint(point: any): { lat: number; lng: number } | null {
if (!point) return null;
// PostgreSQL point format: (x,y) donde x=lng, y=lat
if (typeof point === 'string') {
const match = point.match(/\(([^,]+),([^)]+)\)/);
if (match) {
return { lng: parseFloat(match[1]), lat: parseFloat(match[2]) };
}
}
if (point.x !== undefined && point.y !== undefined) {
return { lng: point.x, lat: point.y };
}
return null;
}

View File

@@ -0,0 +1,2 @@
export * from "./kimi.module";
export * from "./kimi.service";

View File

@@ -0,0 +1,12 @@
import { Module, Global } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { KimiService } from "./kimi.service";
import kimiConfig from "../../config/integrations/kimi.config";
@Global()
@Module({
imports: [ConfigModule.forFeature(kimiConfig)],
providers: [KimiService],
exports: [KimiService],
})
export class KimiModule {}

View File

@@ -0,0 +1,469 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import OpenAI from 'openai';
export interface KimiMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface KimiChatOptions {
temperature?: number;
maxTokens?: number;
topP?: number;
stream?: boolean;
}
@Injectable()
export class KimiService implements OnModuleInit {
private readonly logger = new Logger(KimiService.name);
private client: OpenAI | null = null;
private model: string;
private defaultTemperature: number;
private defaultMaxTokens: number;
private defaultTopP: number;
private isConfigured: boolean = false;
constructor(private readonly configService: ConfigService) {
this.model = this.configService.get<string>('kimi.model') || 'kimi-k2.5';
this.defaultTemperature = this.configService.get<number>('kimi.temperature') || 0.6;
this.defaultMaxTokens = this.configService.get<number>('kimi.maxTokens') || 4096;
this.defaultTopP = this.configService.get<number>('kimi.topP') || 0.95;
}
async onModuleInit() {
await this.initialize();
}
private async initialize(): Promise<void> {
const apiKey = this.configService.get<string>('kimi.apiKey');
const baseUrl = this.configService.get<string>('kimi.baseUrl');
if (!apiKey) {
this.logger.warn('⚠️ KIMI_API_KEY no está configurada. El servicio de IA usará respuestas de fallback.');
this.isConfigured = false;
return;
}
try {
this.client = new OpenAI({
apiKey,
baseURL: baseUrl || 'https://api.moonshot.ai/v1',
});
// Test de conexión
this.logger.log('🔧 Inicializando Kimi Service...');
this.logger.log(`📌 Base URL: ${baseUrl}`);
this.logger.log(`📌 Model: ${this.model}`);
// Verificar conexión con un mensaje simple
const testResponse = await this.client.chat.completions.create({
model: this.model,
messages: [{ role: 'user', content: 'Hello' }],
max_tokens: 10,
});
if (testResponse.choices?.[0]?.message?.content) {
this.logger.log('✅ Kimi Service inicializado correctamente');
this.isConfigured = true;
}
} catch (error: any) {
this.logger.error(`❌ Error inicializando Kimi: ${error.message}`);
this.isConfigured = false;
}
}
/**
* Verifica si el servicio está configurado y funcionando
*/
isReady(): boolean {
return this.isConfigured && this.client !== null;
}
/**
* Envía un mensaje al modelo Kimi y obtiene una respuesta
*/
async chat(
messages: KimiMessage[],
options?: KimiChatOptions,
): Promise<string> {
if (!this.isReady()) {
this.logger.warn('⚠️ Kimi no está disponible, usando fallback');
return this.getFallbackResponse(messages);
}
try {
const response = await this.client!.chat.completions.create({
model: this.model,
messages: messages as any,
temperature: options?.temperature ?? this.defaultTemperature,
max_tokens: options?.maxTokens ?? this.defaultMaxTokens,
top_p: options?.topP ?? this.defaultTopP,
});
const content = response.choices?.[0]?.message?.content;
if (!content) {
throw new Error('Respuesta vacía de Kimi');
}
return content;
} catch (error: any) {
this.logger.error(`❌ Error en chat Kimi: ${error.message}`);
// Manejar errores específicos
if (error.status === 429) {
throw new Error('Límite de rate alcanzado. Intenta de nuevo en unos segundos.');
}
if (error.status === 401) {
throw new Error('API Key inválida o expirada.');
}
// Fallback para otros errores
return this.getFallbackResponse(messages);
}
}
/**
* Chat con system prompt predefinido para el asistente de viajes
*/
async chatWithTravelAssistant(
userMessage: string,
conversationHistory: KimiMessage[] = [],
language: string = 'es',
): Promise<string> {
const systemPrompt = this.getTravelAssistantPrompt(language);
const messages: KimiMessage[] = [
{ role: 'system', content: systemPrompt },
...conversationHistory,
{ role: 'user', content: userMessage },
];
return this.chat(messages);
}
/**
* Genera un itinerario personalizado
*/
async generateItinerary(params: {
destination: string;
days: number;
interests: string[];
budget: string;
language: string;
}): Promise<string> {
const { destination, days, interests, budget, language } = params;
const prompt = language === 'es'
? `Crea un itinerario detallado de ${days} días para ${destination}.
Intereses del viajero: ${interests.join(', ')}
Presupuesto: ${budget}
El itinerario debe incluir:
- Actividades por día con horarios específicos
- Restaurantes recomendados con precios aproximados
- Tips de transporte
- Presupuesto estimado por día
Formato con emojis y estructura clara por días.`
: `Create a detailed ${days}-day itinerary for ${destination}.
Traveler interests: ${interests.join(', ')}
Budget: ${budget}
The itinerary should include:
- Daily activities with specific times
- Recommended restaurants with approximate prices
- Transport tips
- Estimated daily budget
Format with emojis and clear day-by-day structure.`;
const systemPrompt = this.getTravelAssistantPrompt(language);
return this.chat([
{ role: 'system', content: systemPrompt },
{ role: 'user', content: prompt },
], {
maxTokens: 8192, // Itinerarios necesitan más tokens
});
}
/**
* Procesa una imagen para reconocimiento de monumentos
* Nota: Kimi 2.5 es multimodal, puede procesar imágenes
*/
async processImage(
imageUrl: string,
query: string = 'What is this monument or place?',
language: string = 'en',
): Promise<string> {
if (!this.isReady()) {
return language === 'es'
? 'Lo siento, el servicio de reconocimiento de imágenes no está disponible en este momento.'
: 'Sorry, the image recognition service is not available at the moment.';
}
try {
const response = await this.client!.chat.completions.create({
model: this.model,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: query },
{ type: 'image_url', image_url: { url: imageUrl } },
],
},
] as any,
max_tokens: 2048,
});
return response.choices?.[0]?.message?.content || 'No se pudo procesar la imagen.';
} catch (error: any) {
this.logger.error(`❌ Error procesando imagen: ${error.message}`);
return language === 'es'
? 'Hubo un error al procesar la imagen. Por favor, intenta de nuevo.'
: 'There was an error processing the image. Please try again.';
}
}
/**
* System prompt para el asistente de viajes - CON UBICACIÓN
*/
private getTravelAssistantPrompt(language: string = 'es'): string {
const isSpanish = language === 'es';
return isSpanish ? `Eres "Karibeo AI", un asistente experto y amigable especializado en viajes a República Dominicana y el Caribe.
**REGLA CRÍTICA DE UBICACIÓN:**
- Si el usuario proporciona coordenadas GPS o ubicación, SOLO recomienda lugares CERCANOS a esa ubicación (menos de 30 minutos en carro)
- NO recomiendes lugares a horas de distancia
- Ejemplo: Si el usuario está en Santo Domingo, NO menciones Punta Cana, Bávaro, Samaná o Puerto Plata (están a 2-4 horas)
- Ejemplo: Si el usuario está en Santo Domingo, recomienda: Zona Colonial, Malecón, Los Tres Ojos, Boca Chica, Juan Dolio
- SIEMPRE indica la distancia aproximada desde la ubicación del usuario
**REGLA DE CONVERSACIÓN NATURAL:**
- Para saludos simples (Hola, Hi, Buenos días, etc.) responde con un saludo breve y amigable, NO con una lista de recomendaciones
- Ejemplo: "Hola" → "¡Hola! Soy Karibeo AI, tu guía de viajes en República Dominicana. ¿En qué puedo ayudarte hoy?"
- Solo da recomendaciones detalladas cuando el usuario PREGUNTE específicamente por lugares, hoteles, restaurantes, etc.
- Mantén respuestas cortas para preguntas simples
- Da respuestas detalladas solo cuando se pida información específica
REGLAS DE IDIOMA:
- Responde SIEMPRE en el mismo idioma que el usuario
- Si el usuario escribe en español → responde en español
- Si el usuario escribe en inglés → responde en inglés
CONOCIMIENTO POR ZONA:
**SANTO DOMINGO Y ALREDEDORES (para usuarios en Santo Domingo):**
- Zona Colonial: Catedral Primada, Alcázar de Colón, Calle Las Damas, Parque Colón
- Malecón de Santo Domingo
- Los Tres Ojos (cuevas)
- Faro a Colón
- Jardín Botánico Nacional
- Playas cercanas: Boca Chica (30 min), Juan Dolio (45 min), Guayacanes (40 min)
- Gastronomía: Pat'e Palo, Mesón de Bari, Adrian Tropical, El Conuco
**PUNTA CANA / BÁVARO (solo si el usuario está ahí):**
- Playa Bávaro, Playa Macao
- Isla Saona, Isla Catalina
- Cap Cana, Hoyo Azul
**SAMANÁ (solo si el usuario está ahí):**
- Playa Rincón, El Limón, Cayo Levantado
- Observación de ballenas (temporada)
**NORTE - Puerto Plata (solo si el usuario está ahí):**
- Teleférico, Playa Dorada
- 27 Charcos de Damajagua
**JARABACOA/CONSTANZA (solo si el usuario está ahí):**
- Pico Duarte, rafting, Salto de Jimenoa
CAPACIDADES:
1. Crear itinerarios personalizados RESPETANDO la ubicación del usuario
2. Recomendar restaurantes cercanos con precios específicos
3. Calcular presupuestos de viaje
4. Dar tips de seguridad y transporte local
5. Sugerir experiencias locales auténticas CERCANAS
PERSONALIDAD:
- Entusiasta y apasionado por el Caribe
- Usa emojis relevantes: 🏖️ 🥥 💃 🎵 🏛️ 🍽️
- Da recomendaciones específicas con nombres reales
- SIEMPRE indica distancia y tiempo de viaje desde la ubicación del usuario
- Menciona precios en USD y RD$ cuando sea relevante
FORMATO DE RESPUESTAS:
- Usa listas con emojis para organizar información
- Incluye horarios y precios cuando sea posible
- Da tips prácticos de transporte
- Indica tiempo de llegada desde la ubicación del usuario
RESTRICCIONES:
- Solo responde sobre viajes, turismo y destinos
- Si preguntan sobre otros temas, redirige amablemente a viajes
- NO recomiendes lugares lejanos sin mencionar que están lejos
- Promueve turismo sostenible y respeto por la cultura local`
: `You are "Karibeo AI", a friendly expert travel assistant specialized in the Dominican Republic and the Caribbean.
**CRITICAL LOCATION RULE:**
- If the user provides GPS coordinates or location, ONLY recommend places NEARBY to that location (less than 30 minutes by car)
- DO NOT recommend places hours away
- Example: If user is in Santo Domingo, DO NOT mention Punta Cana, Bávaro, Samaná or Puerto Plata (they are 2-4 hours away)
- Example: If user is in Santo Domingo, recommend: Colonial Zone, Malecón, Los Tres Ojos, Boca Chica, Juan Dolio
- ALWAYS indicate approximate distance from the user's location
**NATURAL CONVERSATION RULE:**
- For simple greetings (Hello, Hi, Good morning, etc.) respond with a brief friendly greeting, NOT a list of recommendations
- Example: "Hello" → "Hi! I'm Karibeo AI, your travel guide for the Dominican Republic. How can I help you today?"
- Only give detailed recommendations when the user SPECIFICALLY asks for places, hotels, restaurants, etc.
- Keep responses short for simple questions
- Give detailed responses only when specific information is requested
LANGUAGE RULES:
- ALWAYS respond in the same language as the user
- If user writes in Spanish → respond in Spanish
- If user writes in English → respond in English
KNOWLEDGE BY ZONE:
**SANTO DOMINGO AND SURROUNDINGS (for users in Santo Domingo):**
- Colonial Zone: Primera Cathedral, Alcázar de Colón, Las Damas Street, Colón Park
- Santo Domingo Malecón
- Los Tres Ojos (caves)
- Columbus Lighthouse
- National Botanical Garden
- Nearby beaches: Boca Chica (30 min), Juan Dolio (45 min), Guayacanes (40 min)
- Gastronomy: Pat'e Palo, Mesón de Bari, Adrian Tropical, El Conuco
**PUNTA CANA / BÁVARO (only if user is there):**
- Bávaro Beach, Macao Beach
- Saona Island, Catalina Island
- Cap Cana, Hoyo Azul
**SAMANÁ (only if user is there):**
- Rincón Beach, El Limón, Cayo Levantado
- Whale watching (season)
**NORTH - Puerto Plata (only if user is there):**
- Cable car, Playa Dorada
- 27 Waterfalls of Damajagua
**JARABACOA/CONSTANZA (only if user is there):**
- Pico Duarte, rafting, Jimenoa Waterfall
CAPABILITIES:
1. Create personalized itineraries RESPECTING the user's location
2. Recommend nearby restaurants with specific prices
3. Calculate travel budgets
4. Provide safety and local transport tips
5. Suggest NEARBY authentic local experiences
PERSONALITY:
- Enthusiastic and passionate about the Caribbean
- Use relevant emojis: 🏖️ 🥥 💃 🎵 🏛️ 🍽️
- Give specific recommendations with real names
- ALWAYS indicate distance and travel time from user's location
- Mention prices in USD and RD$ when relevant
RESPONSE FORMAT:
- Use lists with emojis to organize information
- Include schedules and prices when possible
- Give practical transport tips
- Indicate arrival time from user's location
RESTRICTIONS:
- Only respond about travel, tourism and destinations
- If asked about other topics, kindly redirect to travel
- DO NOT recommend distant places without mentioning they are far
- Promote sustainable tourism and respect for local culture`;
}
/**
* Respuesta de fallback cuando Kimi no está disponible
*/
private getFallbackResponse(messages: KimiMessage[]): string {
const lastUserMessage = messages.filter(m => m.role === 'user').pop()?.content.toLowerCase() || '';
// Detectar idioma
const isSpanish = /[áéíóúñ¿¡]/.test(lastUserMessage) ||
lastUserMessage.includes('hola') ||
lastUserMessage.includes('quiero') ||
lastUserMessage.includes('donde');
if (isSpanish) {
if (lastUserMessage.includes('itinerario') || lastUserMessage.includes('plan') || lastUserMessage.includes('días')) {
return `¡Perfecto! Me encantaría ayudarte a planificar tu viaje
Para crear el mejor itinerario, cuéntame:
📅 ¿Cuántos días estarás?
💰 ¿Cuál es tu presupuesto aproximado?
🎯 ¿Qué te interesa más: cultura, playa, gastronomía o aventura?
Mientras tanto, aquí tienes algunas ideas:
**Santo Domingo** (Zona Colonial)
- Catedral Primada de América ⭐ 4.9/5
- Alcázar de Colón ⭐ 4.8/5
- Restaurantes: Pat'e Palo, Mesón de Bari
**Punta Cana** (Playas)
- Playa Bávaro - Las mejores playas del Caribe
- Excursión a Isla Saona
¡Cuéntame más y te preparo algo increíble! 🌴`;
}
if (lastUserMessage.includes('restaurante') || lastUserMessage.includes('comer') || lastUserMessage.includes('comida')) {
return `¡Aquí están mis recomendaciones gastronómicas! 🍽️
**ZONA COLONIAL**
📍 Pat'e Palo European Brasserie ⭐ 4.6/5
💰 $55 USD promedio | Fine dining
📍 Mesón de Bari ⭐ 4.7/5
💰 $30 USD promedio | Español-Dominicano
**MALECÓN**
📍 Adrian Tropical ⭐ 4.4/5
💰 $25 USD promedio | Mariscos frescos
**COCINA DOMINICANA AUTÉNTICA**
📍 El Conuco ⭐ 4.5/5
💰 $35 USD promedio | Mofongo increíble
¿Qué tipo de comida prefieres? 😊`;
}
return `¡Hola! Soy Karibeo AI ✨
Soy tu guía experto para República Dominicana y el Caribe. Puedo ayudarte con:
📋 **Crear itinerarios** - "Créame un plan de 3 días"
🍽️ **Restaurantes** - "Dónde comer en Santo Domingo"
🏖️ **Playas** - "Mejores playas cerca de la capital"
💰 **Presupuestos** - "Cuánto cuesta visitar Punta Cana"
🏨 **Hoteles** - "Hoteles recomendados en Zona Colonial"
¿En qué puedo ayudarte hoy?`;
}
// English fallback
return `Hello! I'm Karibeo AI ✨
I'm your expert guide for the Dominican Republic and the Caribbean. I can help you with:
📋 **Itineraries** - "Create a 3-day plan"
🍽️ **Restaurants** - "Where to eat in Santo Domingo"
🏖️ **Beaches** - "Best beaches near the capital"
💰 **Budgets** - "How much to visit Punta Cana"
🏨 **Hotels** - "Recommended hotels in Colonial Zone"
How can I help you today?`;
}
}

View File

@@ -0,0 +1,399 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import OpenAI from 'openai';
export interface KimiMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface KimiChatOptions {
temperature?: number;
maxTokens?: number;
topP?: number;
stream?: boolean;
}
@Injectable()
export class KimiService implements OnModuleInit {
private readonly logger = new Logger(KimiService.name);
private client: OpenAI | null = null;
private model: string;
private defaultTemperature: number;
private defaultMaxTokens: number;
private defaultTopP: number;
private isConfigured: boolean = false;
constructor(private readonly configService: ConfigService) {
this.model = this.configService.get<string>('kimi.model') || 'kimi-k2.5';
this.defaultTemperature = this.configService.get<number>('kimi.temperature') || 0.6;
this.defaultMaxTokens = this.configService.get<number>('kimi.maxTokens') || 4096;
this.defaultTopP = this.configService.get<number>('kimi.topP') || 0.95;
}
async onModuleInit() {
await this.initialize();
}
private async initialize(): Promise<void> {
const apiKey = this.configService.get<string>('kimi.apiKey');
const baseUrl = this.configService.get<string>('kimi.baseUrl');
if (!apiKey) {
this.logger.warn('⚠️ KIMI_API_KEY no está configurada. El servicio de IA usará respuestas de fallback.');
this.isConfigured = false;
return;
}
try {
this.client = new OpenAI({
apiKey,
baseURL: baseUrl || 'https://api.moonshot.ai/v1',
});
// Test de conexión
this.logger.log('🔧 Inicializando Kimi Service...');
this.logger.log(`📌 Base URL: ${baseUrl}`);
this.logger.log(`📌 Model: ${this.model}`);
// Verificar conexión con un mensaje simple
const testResponse = await this.client.chat.completions.create({
model: this.model,
messages: [{ role: 'user', content: 'Hello' }],
max_tokens: 10,
});
if (testResponse.choices?.[0]?.message?.content) {
this.logger.log('✅ Kimi Service inicializado correctamente');
this.isConfigured = true;
}
} catch (error: any) {
this.logger.error(`❌ Error inicializando Kimi: ${error.message}`);
this.isConfigured = false;
}
}
/**
* Verifica si el servicio está configurado y funcionando
*/
isReady(): boolean {
return this.isConfigured && this.client !== null;
}
/**
* Envía un mensaje al modelo Kimi y obtiene una respuesta
*/
async chat(
messages: KimiMessage[],
options?: KimiChatOptions,
): Promise<string> {
if (!this.isReady()) {
this.logger.warn('⚠️ Kimi no está disponible, usando fallback');
return this.getFallbackResponse(messages);
}
try {
const response = await this.client!.chat.completions.create({
model: this.model,
messages: messages as any,
temperature: options?.temperature ?? this.defaultTemperature,
max_tokens: options?.maxTokens ?? this.defaultMaxTokens,
top_p: options?.topP ?? this.defaultTopP,
});
const content = response.choices?.[0]?.message?.content;
if (!content) {
throw new Error('Respuesta vacía de Kimi');
}
return content;
} catch (error: any) {
this.logger.error(`❌ Error en chat Kimi: ${error.message}`);
// Manejar errores específicos
if (error.status === 429) {
throw new Error('Límite de rate alcanzado. Intenta de nuevo en unos segundos.');
}
if (error.status === 401) {
throw new Error('API Key inválida o expirada.');
}
// Fallback para otros errores
return this.getFallbackResponse(messages);
}
}
/**
* Chat con system prompt predefinido para el asistente de viajes
*/
async chatWithTravelAssistant(
userMessage: string,
conversationHistory: KimiMessage[] = [],
language: string = 'es',
): Promise<string> {
const systemPrompt = this.getTravelAssistantPrompt(language);
const messages: KimiMessage[] = [
{ role: 'system', content: systemPrompt },
...conversationHistory,
{ role: 'user', content: userMessage },
];
return this.chat(messages);
}
/**
* Genera un itinerario personalizado
*/
async generateItinerary(params: {
destination: string;
days: number;
interests: string[];
budget: string;
language: string;
}): Promise<string> {
const { destination, days, interests, budget, language } = params;
const prompt = language === 'es'
? `Crea un itinerario detallado de ${days} días para ${destination}.
Intereses del viajero: ${interests.join(', ')}
Presupuesto: ${budget}
El itinerario debe incluir:
- Actividades por día con horarios específicos
- Restaurantes recomendados con precios aproximados
- Tips de transporte
- Presupuesto estimado por día
Formato con emojis y estructura clara por días.`
: `Create a detailed ${days}-day itinerary for ${destination}.
Traveler interests: ${interests.join(', ')}
Budget: ${budget}
The itinerary should include:
- Daily activities with specific times
- Recommended restaurants with approximate prices
- Transport tips
- Estimated daily budget
Format with emojis and clear day-by-day structure.`;
const systemPrompt = this.getTravelAssistantPrompt(language);
return this.chat([
{ role: 'system', content: systemPrompt },
{ role: 'user', content: prompt },
], {
maxTokens: 8192, // Itinerarios necesitan más tokens
});
}
/**
* Procesa una imagen para reconocimiento de monumentos
* Nota: Kimi 2.5 es multimodal, puede procesar imágenes
*/
async processImage(
imageUrl: string,
query: string = 'What is this monument or place?',
language: string = 'en',
): Promise<string> {
if (!this.isReady()) {
return language === 'es'
? 'Lo siento, el servicio de reconocimiento de imágenes no está disponible en este momento.'
: 'Sorry, the image recognition service is not available at the moment.';
}
try {
const response = await this.client!.chat.completions.create({
model: this.model,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: query },
{ type: 'image_url', image_url: { url: imageUrl } },
],
},
] as any,
max_tokens: 2048,
});
return response.choices?.[0]?.message?.content || 'No se pudo procesar la imagen.';
} catch (error: any) {
this.logger.error(`❌ Error procesando imagen: ${error.message}`);
return language === 'es'
? 'Hubo un error al procesar la imagen. Por favor, intenta de nuevo.'
: 'There was an error processing the image. Please try again.';
}
}
/**
* System prompt para el asistente de viajes
*/
private getTravelAssistantPrompt(language: string = 'es'): string {
const isSpanish = language === 'es';
return isSpanish ? `Eres "Karibeo AI", un asistente experto y amigable especializado en viajes a República Dominicana y el Caribe, con énfasis en Santo Domingo.
REGLAS DE IDIOMA:
- Responde SIEMPRE en el mismo idioma que el usuario
- Si el usuario escribe en español → responde en español
- Si el usuario escribe en inglés → responde en inglés
BASE DE CONOCIMIENTO:
- Especialista en República Dominicana: Zona Colonial, Malecón, Punta Cana, Samaná, Puerto Plata, Jarabacoa
- Conocimiento profundo de gastronomía dominicana: mangú, sancocho, mofongo, chimichurri dominicano
- Cultura: merengue, bachata, carnaval, historia colonial
- Playas: Playa Bávaro, Playa Rincón, Bahía de las Águilas, Cayo Arena
CAPACIDADES:
1. Crear itinerarios personalizados día a día
2. Recomendar restaurantes con precios específicos
3. Calcular presupuestos de viaje
4. Dar tips de seguridad y transporte
5. Sugerir experiencias locales auténticas
PERSONALIDAD:
- Entusiasta y apasionado por el Caribe
- Usa emojis relevantes: 🏖️ 🥥 💃 🎵 🏛️ 🍽️
- Da recomendaciones específicas con nombres reales
- Incluye tips locales que solo los dominicanos conocen
- Menciona precios en USD y RD$ cuando sea relevante
FORMATO DE RESPUESTAS:
- Usa listas con emojis para organizar información
- Incluye horarios y precios cuando sea posible
- Da tips prácticos de transporte
- Sugiere el mejor momento para visitar cada lugar
RESTRICCIONES:
- Solo responde sobre viajes, turismo y destinos
- Si preguntan sobre otros temas, redirige amablemente a viajes
- Sé honesto si no tienes información específica
- Promueve turismo sostenible y respeto por la cultura local`
: `You are "Karibeo AI", a friendly expert travel assistant specialized in the Dominican Republic and the Caribbean, with emphasis on Santo Domingo.
LANGUAGE RULES:
- ALWAYS respond in the same language as the user
- If user writes in Spanish → respond in Spanish
- If user writes in English → respond in English
KNOWLEDGE BASE:
- Dominican Republic specialist: Colonial Zone, Malecón, Punta Cana, Samaná, Puerto Plata, Jarabacoa
- Deep knowledge of Dominican cuisine: mangú, sancocho, mofongo, Dominican chimichurri
- Culture: merengue, bachata, carnival, colonial history
- Beaches: Playa Bávaro, Playa Rincón, Bahía de las Águilas, Cayo Arena
CAPABILITIES:
1. Create personalized day-by-day itineraries
2. Recommend restaurants with specific prices
3. Calculate travel budgets
4. Provide safety and transport tips
5. Suggest authentic local experiences
PERSONALITY:
- Enthusiastic and passionate about the Caribbean
- Use relevant emojis: 🏖️ 🥥 💃 🎵 🏛️ 🍽️
- Give specific recommendations with real names
- Include local tips only Dominicans know
- Mention prices in USD and RD$ when relevant
RESPONSE FORMAT:
- Use lists with emojis to organize information
- Include schedules and prices when possible
- Give practical transport tips
- Suggest the best time to visit each place
RESTRICTIONS:
- Only respond about travel, tourism and destinations
- If asked about other topics, kindly redirect to travel
- Be honest if you don't have specific information
- Promote sustainable tourism and respect for local culture`;
}
/**
* Respuesta de fallback cuando Kimi no está disponible
*/
private getFallbackResponse(messages: KimiMessage[]): string {
const lastUserMessage = messages.filter(m => m.role === 'user').pop()?.content.toLowerCase() || '';
// Detectar idioma
const isSpanish = /[áéíóúñ¿¡]/.test(lastUserMessage) ||
lastUserMessage.includes('hola') ||
lastUserMessage.includes('quiero') ||
lastUserMessage.includes('donde');
if (isSpanish) {
if (lastUserMessage.includes('itinerario') || lastUserMessage.includes('plan') || lastUserMessage.includes('días')) {
return `¡Perfecto! Me encantaría ayudarte a planificar tu viaje 🇩🇴
Para crear el mejor itinerario, cuéntame:
📅 ¿Cuántos días estarás?
💰 ¿Cuál es tu presupuesto aproximado?
🎯 ¿Qué te interesa más: cultura, playa, gastronomía o aventura?
Mientras tanto, aquí tienes algunas ideas:
**Santo Domingo** (Zona Colonial)
- Catedral Primada de América ⭐ 4.9/5
- Alcázar de Colón ⭐ 4.8/5
- Restaurantes: Pat'e Palo, Mesón de Bari
**Punta Cana** (Playas)
- Playa Bávaro - Las mejores playas del Caribe
- Excursión a Isla Saona
¡Cuéntame más y te preparo algo increíble! 🌴`;
}
if (lastUserMessage.includes('restaurante') || lastUserMessage.includes('comer') || lastUserMessage.includes('comida')) {
return `¡Aquí están mis recomendaciones gastronómicas! 🍽️🇩🇴
**ZONA COLONIAL**
📍 Pat'e Palo European Brasserie ⭐ 4.6/5
💰 $55 USD promedio | Fine dining
📍 Mesón de Bari ⭐ 4.7/5
💰 $30 USD promedio | Español-Dominicano
**MALECÓN**
📍 Adrian Tropical ⭐ 4.4/5
💰 $25 USD promedio | Mariscos frescos
**COCINA DOMINICANA AUTÉNTICA**
📍 El Conuco ⭐ 4.5/5
💰 $35 USD promedio | Mofongo increíble
¿Qué tipo de comida prefieres? 😊`;
}
return `¡Hola! Soy Karibeo AI 🇩🇴✨
Soy tu guía experto para República Dominicana y el Caribe. Puedo ayudarte con:
📋 **Crear itinerarios** - "Créame un plan de 3 días"
🍽️ **Restaurantes** - "Dónde comer en Santo Domingo"
🏖️ **Playas** - "Mejores playas cerca de la capital"
💰 **Presupuestos** - "Cuánto cuesta visitar Punta Cana"
🏨 **Hoteles** - "Hoteles recomendados en Zona Colonial"
¿En qué puedo ayudarte hoy?`;
}
// English fallback
return `Hello! I'm Karibeo AI 🇩🇴✨
I'm your expert guide for the Dominican Republic and the Caribbean. I can help you with:
📋 **Itineraries** - "Create a 3-day plan"
🍽️ **Restaurants** - "Where to eat in Santo Domingo"
🏖️ **Beaches** - "Best beaches near the capital"
💰 **Budgets** - "How much to visit Punta Cana"
🏨 **Hotels** - "Recommended hotels in Colonial Zone"
How can I help you today?`;
}
}

View File

@@ -0,0 +1,71 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsOptional, IsString, IsBoolean } from 'class-validator';
export class SubmitQuizDto {
@ApiPropertyOptional({ description: 'Travel styles', example: ['adventure', 'cultural'] })
@IsOptional()
@IsArray()
travelStyles?: string[];
@ApiPropertyOptional({ description: 'Preferred activities', example: ['hiking', 'beaches'] })
@IsOptional()
@IsArray()
preferredActivities?: string[];
@ApiPropertyOptional({ description: 'Accommodation preferences', example: ['hotel', 'resort'] })
@IsOptional()
@IsArray()
accommodationPreferences?: string[];
@ApiPropertyOptional({ description: 'Budget range', example: 'mid-range' })
@IsOptional()
@IsString()
budgetRange?: string;
@ApiPropertyOptional({ description: 'Trip duration preference', example: 'week' })
@IsOptional()
@IsString()
tripDuration?: string;
@ApiPropertyOptional({ description: 'Group type', example: 'couple' })
@IsOptional()
@IsString()
groupType?: string;
@ApiPropertyOptional({ description: 'Cuisine preferences', example: ['local', 'seafood'] })
@IsOptional()
@IsArray()
cuisinePreferences?: string[];
@ApiPropertyOptional({ description: 'Interests', example: ['history', 'nature'] })
@IsOptional()
@IsArray()
interests?: string[];
@ApiPropertyOptional({ description: 'Accessibility needs' })
@IsOptional()
@IsArray()
accessibilityNeeds?: string[];
@ApiPropertyOptional({ description: 'Mark quiz as completed', default: false })
@IsOptional()
@IsBoolean()
isCompleted?: boolean;
}
export class QuizQuestionsDto {
@ApiProperty({ description: 'Question ID' })
id: string;
@ApiProperty({ description: 'Question text' })
question: string;
@ApiProperty({ description: 'Question type', example: 'multi-select' })
type: 'single-select' | 'multi-select' | 'text';
@ApiProperty({ description: 'Available options' })
options: Array<{ value: string; label: string; icon?: string }>;
@ApiPropertyOptional({ description: 'Maximum selections for multi-select' })
maxSelections?: number;
}

View File

@@ -0,0 +1,42 @@
import { Controller, Get, Post, Body, Delete, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { QuizService } from './quiz.service';
import { SubmitQuizDto } from './dto/quiz.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { TravelQuizResponse } from '../../entities/travel-quiz.entity';
@ApiTags('Quiz')
@Controller('quiz')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
export class QuizController {
constructor(private readonly quizService: QuizService) {}
@Get('questions')
@ApiOperation({ summary: 'Get quiz questions' })
@ApiResponse({ status: 200, description: 'Quiz questions' })
getQuestions() {
return this.quizService.getQuestions();
}
@Get('my')
@ApiOperation({ summary: 'Get my quiz response' })
@ApiResponse({ status: 200, type: TravelQuizResponse })
getMyResponse(@Request() req) {
return this.quizService.getMyQuizResponse(req.user.id);
}
@Post('submit')
@ApiOperation({ summary: 'Submit quiz answers' })
@ApiResponse({ status: 201, type: TravelQuizResponse })
submitQuiz(@Body() submitDto: SubmitQuizDto, @Request() req) {
return this.quizService.submitQuiz(req.user.id, submitDto);
}
@Delete('reset')
@ApiOperation({ summary: 'Reset quiz' })
@ApiResponse({ status: 200, description: 'Quiz reset' })
resetQuiz(@Request() req) {
return this.quizService.resetQuiz(req.user.id);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { QuizService } from './quiz.service';
import { QuizController } from './quiz.controller';
import { TravelQuizResponse } from '../../entities/travel-quiz.entity';
@Module({
imports: [TypeOrmModule.forFeature([TravelQuizResponse])],
controllers: [QuizController],
providers: [QuizService],
exports: [QuizService],
})
export class QuizModule {}

View File

@@ -0,0 +1,135 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TravelQuizResponse } from '../../entities/travel-quiz.entity';
import { SubmitQuizDto } from './dto/quiz.dto';
@Injectable()
export class QuizService {
constructor(
@InjectRepository(TravelQuizResponse)
private readonly quizRepository: Repository<TravelQuizResponse>,
) {}
getQuestions() {
return [
{
id: 'travel_styles',
question: 'What type of traveler are you?',
questionEs: 'Que tipo de viajero eres?',
type: 'multi-select',
maxSelections: 3,
options: [
{ value: 'adventure', label: 'Adventure Seeker', icon: 'mountain' },
{ value: 'cultural', label: 'Culture Explorer', icon: 'landmark' },
{ value: 'beach', label: 'Beach Lover', icon: 'umbrella-beach' },
{ value: 'luxury', label: 'Luxury Traveler', icon: 'gem' },
{ value: 'foodie', label: 'Food Enthusiast', icon: 'utensils' },
{ value: 'nature', label: 'Nature Lover', icon: 'leaf' },
],
},
{
id: 'activities',
question: 'What activities interest you?',
type: 'multi-select',
maxSelections: 5,
options: [
{ value: 'hiking', label: 'Hiking', icon: 'hiking' },
{ value: 'diving', label: 'Diving/Snorkeling', icon: 'water' },
{ value: 'nightlife', label: 'Nightlife', icon: 'music' },
{ value: 'shopping', label: 'Shopping', icon: 'shopping-bag' },
{ value: 'spa', label: 'Spa & Wellness', icon: 'spa' },
{ value: 'photography', label: 'Photography', icon: 'camera' },
{ value: 'museums', label: 'Museums', icon: 'museum' },
],
},
{
id: 'budget',
question: 'What is your typical daily budget?',
type: 'single-select',
options: [
{ value: 'budget', label: 'Budget ($50-100/day)', icon: 'dollar-sign' },
{ value: 'mid', label: 'Mid-Range ($100-200/day)', icon: 'coins' },
{ value: 'high', label: 'Upscale ($200-400/day)', icon: 'gem' },
{ value: 'luxury', label: 'Luxury ($400+/day)', icon: 'crown' },
],
},
{
id: 'group_type',
question: 'Who do you usually travel with?',
type: 'single-select',
options: [
{ value: 'solo', label: 'Solo', icon: 'user' },
{ value: 'couple', label: 'Partner', icon: 'heart' },
{ value: 'family', label: 'Family', icon: 'users' },
{ value: 'friends', label: 'Friends', icon: 'users' },
],
},
];
}
async getMyQuizResponse(userId: string): Promise<TravelQuizResponse | null> {
return this.quizRepository.findOne({ where: { userId } });
}
async submitQuiz(userId: string, submitDto: SubmitQuizDto): Promise<TravelQuizResponse> {
let response = await this.quizRepository.findOne({ where: { userId } });
if (!response) {
response = this.quizRepository.create({ userId });
}
if (submitDto.travelStyles) response.travelStyles = submitDto.travelStyles;
if (submitDto.preferredActivities) response.preferredActivities = submitDto.preferredActivities;
if (submitDto.accommodationPreferences) response.accommodationPreferences = submitDto.accommodationPreferences;
if (submitDto.budgetRange) response.budgetRange = submitDto.budgetRange;
if (submitDto.tripDuration) response.tripDuration = submitDto.tripDuration;
if (submitDto.groupType) response.groupType = submitDto.groupType;
if (submitDto.cuisinePreferences) response.cuisinePreferences = submitDto.cuisinePreferences;
if (submitDto.interests) response.interests = submitDto.interests;
if (submitDto.accessibilityNeeds) response.accessibilityNeeds = submitDto.accessibilityNeeds;
if (submitDto.isCompleted) {
response.isCompleted = true;
response.completedAt = new Date();
const persona = this.generatePersona(response);
response.travelPersona = persona.type;
response.personaDescription = persona.description;
}
return this.quizRepository.save(response);
}
private generatePersona(response: TravelQuizResponse): { type: string; description: string } {
const styles = response.travelStyles || [];
const activities = response.preferredActivities || [];
const budget = response.budgetRange;
if (styles.includes('adventure') && activities.some(a => ['hiking', 'diving'].includes(a))) {
return { type: 'Adventure Explorer', description: 'You thrive on adrenaline and new experiences.' };
}
if (styles.includes('luxury') || budget === 'luxury') {
return { type: 'Luxury Connoisseur', description: 'You appreciate the finer things in life.' };
}
if (styles.includes('cultural') && activities.includes('museums')) {
return { type: 'Culture Enthusiast', description: 'History, art, and local traditions fascinate you.' };
}
if (styles.includes('foodie')) {
return { type: 'Culinary Explorer', description: 'Food is your passport to new experiences.' };
}
if (styles.includes('beach') && styles.includes('nature')) {
return { type: 'Nature Seeker', description: 'Natural wonders call to you.' };
}
if (response.groupType === 'family') {
return { type: 'Family Explorer', description: 'Creating memories with loved ones is your priority.' };
}
return { type: 'Curious Traveler', description: 'You are open to all kinds of experiences.' };
}
async resetQuiz(userId: string): Promise<void> {
const response = await this.quizRepository.findOne({ where: { userId } });
if (response) {
await this.quizRepository.remove(response);
}
}
}

View File

@@ -0,0 +1,147 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsBoolean, IsNumber, IsDateString, IsArray, IsEnum, MaxLength } from 'class-validator';
import { TripStatus } from '../../../entities/user-trip.entity';
export class CreateTripDto {
@ApiProperty({ description: 'Trip name', example: 'Summer Vacation in DR' })
@IsString()
@MaxLength(150)
name: string;
@ApiPropertyOptional({ description: 'Trip description' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'Cover image URL' })
@IsOptional()
@IsString()
coverImageUrl?: string;
@ApiPropertyOptional({ description: 'Start date', example: '2026-06-01' })
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({ description: 'End date', example: '2026-06-07' })
@IsOptional()
@IsDateString()
endDate?: string;
@ApiPropertyOptional({ description: 'Destination' })
@IsOptional()
@IsString()
destination?: string;
@ApiPropertyOptional({ description: 'Number of travelers', default: 1 })
@IsOptional()
@IsNumber()
travelersCount?: number;
@ApiPropertyOptional({ description: 'Estimated budget' })
@IsOptional()
@IsNumber()
estimatedBudget?: number;
@ApiPropertyOptional({ description: 'Budget currency', default: 'USD' })
@IsOptional()
@IsString()
budgetCurrency?: string;
@ApiPropertyOptional({ description: 'Trip tags' })
@IsOptional()
@IsArray()
tags?: string[];
@ApiPropertyOptional({ description: 'Is public trip', default: false })
@IsOptional()
@IsBoolean()
isPublic?: boolean;
}
export class CreateTripDayDto {
@ApiProperty({ description: 'Day number', example: 1 })
@IsNumber()
dayNumber: number;
@ApiPropertyOptional({ description: 'Date', example: '2026-06-01' })
@IsOptional()
@IsDateString()
date?: string;
@ApiPropertyOptional({ description: 'Day title' })
@IsOptional()
@IsString()
title?: string;
@ApiPropertyOptional({ description: 'Notes' })
@IsOptional()
@IsString()
notes?: string;
}
export class CreateTripActivityDto {
@ApiPropertyOptional({ description: 'Reference item ID' })
@IsOptional()
@IsString()
itemId?: string;
@ApiPropertyOptional({ description: 'Item type' })
@IsOptional()
@IsString()
itemType?: string;
@ApiProperty({ description: 'Activity title' })
@IsString()
@MaxLength(200)
title: string;
@ApiPropertyOptional({ description: 'Description' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'Start time', example: '09:00' })
@IsOptional()
@IsString()
startTime?: string;
@ApiPropertyOptional({ description: 'End time', example: '12:00' })
@IsOptional()
@IsString()
endTime?: string;
@ApiPropertyOptional({ description: 'Duration in minutes' })
@IsOptional()
@IsNumber()
durationMinutes?: number;
@ApiPropertyOptional({ description: 'Location name' })
@IsOptional()
@IsString()
locationName?: string;
@ApiPropertyOptional({ description: 'Location address' })
@IsOptional()
@IsString()
locationAddress?: string;
@ApiPropertyOptional({ description: 'Location coordinates' })
@IsOptional()
locationCoords?: { lat: number; lng: number };
@ApiPropertyOptional({ description: 'Estimated cost' })
@IsOptional()
@IsNumber()
estimatedCost?: number;
@ApiPropertyOptional({ description: 'Image URL' })
@IsOptional()
@IsString()
imageUrl?: string;
@ApiPropertyOptional({ description: 'Notes' })
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,2 @@
export * from './create-trip.dto';
export * from './update-trip.dto';

View File

@@ -0,0 +1,16 @@
import { PartialType } from '@nestjs/swagger';
import { CreateTripDto, CreateTripDayDto, CreateTripActivityDto } from './create-trip.dto';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { TripStatus } from '../../../entities/user-trip.entity';
export class UpdateTripDto extends PartialType(CreateTripDto) {
@ApiPropertyOptional({ description: 'Trip status', enum: TripStatus })
@IsOptional()
@IsEnum(TripStatus)
status?: TripStatus;
}
export class UpdateTripDayDto extends PartialType(CreateTripDayDto) {}
export class UpdateTripActivityDto extends PartialType(CreateTripActivityDto) {}

View File

@@ -0,0 +1,145 @@
import {
Controller, Get, Post, Body, Patch, Param, Delete, Query, UseGuards, Request,
} from '@nestjs/common';
import {
ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam,
} from '@nestjs/swagger';
import { TripsService } from './trips.service';
import { CreateTripDto, CreateTripDayDto, CreateTripActivityDto } from './dto/create-trip.dto';
import { UpdateTripDto, UpdateTripDayDto, UpdateTripActivityDto } from './dto/update-trip.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { UserTrip, TripDay, TripActivity, TripStatus } from '../../entities/user-trip.entity';
@ApiTags('Trips')
@Controller('trips')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
export class TripsController {
constructor(private readonly tripsService: TripsService) {}
@Post()
@ApiOperation({ summary: 'Create a new trip' })
@ApiResponse({ status: 201, type: UserTrip })
create(@Body() createDto: CreateTripDto, @Request() req) {
return this.tripsService.create(req.user.id, createDto);
}
@Get('my')
@ApiOperation({ summary: 'Get my trips' })
@ApiQuery({ name: 'status', required: false, enum: TripStatus })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
findMyTrips(
@Request() req,
@Query('status') status?: TripStatus,
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.tripsService.findAllByUser(req.user.id, status, page, limit);
}
@Get('my/stats')
@ApiOperation({ summary: 'Get trip statistics' })
getStats(@Request() req) {
return this.tripsService.getTripStats(req.user.id);
}
@Get(':id')
@ApiOperation({ summary: 'Get trip by ID' })
@ApiParam({ name: 'id', type: 'string' })
findOne(@Param('id') id: string, @Request() req) {
return this.tripsService.findOne(req.user.id, id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update trip' })
@ApiParam({ name: 'id', type: 'string' })
update(@Param('id') id: string, @Body() updateDto: UpdateTripDto, @Request() req) {
return this.tripsService.update(req.user.id, id, updateDto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete trip' })
@ApiParam({ name: 'id', type: 'string' })
remove(@Param('id') id: string, @Request() req) {
return this.tripsService.remove(req.user.id, id);
}
// Days
@Post(':tripId/days')
@ApiOperation({ summary: 'Add day to trip' })
addDay(
@Param('tripId') tripId: string,
@Body() createDto: CreateTripDayDto,
@Request() req,
) {
return this.tripsService.addDay(req.user.id, tripId, createDto);
}
@Patch(':tripId/days/:dayId')
@ApiOperation({ summary: 'Update day' })
updateDay(
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Body() updateDto: UpdateTripDayDto,
@Request() req,
) {
return this.tripsService.updateDay(req.user.id, tripId, dayId, updateDto);
}
@Delete(':tripId/days/:dayId')
@ApiOperation({ summary: 'Remove day' })
removeDay(
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Request() req,
) {
return this.tripsService.removeDay(req.user.id, tripId, dayId);
}
// Activities
@Post(':tripId/days/:dayId/activities')
@ApiOperation({ summary: 'Add activity to day' })
addActivity(
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Body() createDto: CreateTripActivityDto,
@Request() req,
) {
return this.tripsService.addActivity(req.user.id, tripId, dayId, createDto);
}
@Patch(':tripId/days/:dayId/activities/:activityId')
@ApiOperation({ summary: 'Update activity' })
updateActivity(
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Param('activityId') activityId: string,
@Body() updateDto: UpdateTripActivityDto,
@Request() req,
) {
return this.tripsService.updateActivity(req.user.id, tripId, dayId, activityId, updateDto);
}
@Delete(':tripId/days/:dayId/activities/:activityId')
@ApiOperation({ summary: 'Remove activity' })
removeActivity(
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Param('activityId') activityId: string,
@Request() req,
) {
return this.tripsService.removeActivity(req.user.id, tripId, dayId, activityId);
}
@Patch(':tripId/days/:dayId/activities/order')
@ApiOperation({ summary: 'Reorder activities in day' })
reorderActivities(
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Body() body: { activityIds: string[] },
@Request() req,
) {
return this.tripsService.reorderActivities(req.user.id, tripId, dayId, body.activityIds);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TripsService } from './trips.service';
import { TripsController } from './trips.controller';
import { UserTrip, TripDay, TripActivity } from '../../entities/user-trip.entity';
@Module({
imports: [TypeOrmModule.forFeature([UserTrip, TripDay, TripActivity])],
controllers: [TripsController],
providers: [TripsService],
exports: [TripsService],
})
export class TripsModule {}

View File

@@ -0,0 +1,181 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserTrip, TripDay, TripActivity, TripStatus } from '../../entities/user-trip.entity';
import { CreateTripDto, CreateTripDayDto, CreateTripActivityDto } from './dto/create-trip.dto';
import { UpdateTripDto, UpdateTripDayDto, UpdateTripActivityDto } from './dto/update-trip.dto';
@Injectable()
export class TripsService {
constructor(
@InjectRepository(UserTrip)
private readonly tripRepository: Repository<UserTrip>,
@InjectRepository(TripDay)
private readonly dayRepository: Repository<TripDay>,
@InjectRepository(TripActivity)
private readonly activityRepository: Repository<TripActivity>,
) {}
async create(userId: string, createDto: CreateTripDto): Promise<UserTrip> {
const trip = this.tripRepository.create({
...createDto,
userId,
status: TripStatus.PLANNING,
});
return this.tripRepository.save(trip);
}
async findAllByUser(
userId: string,
status?: TripStatus,
page = 1,
limit = 20,
): Promise<{ data: UserTrip[]; total: number }> {
const where: any = { userId };
if (status) where.status = status;
const [data, total] = await this.tripRepository.findAndCount({
where,
relations: ['days', 'days.activities'],
order: { updatedAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
return { data, total };
}
async findOne(userId: string, id: string): Promise<UserTrip> {
const trip = await this.tripRepository.findOne({
where: { id },
relations: ['days', 'days.activities'],
});
if (!trip) throw new NotFoundException('Trip not found');
if (trip.userId !== userId && !trip.isPublic) {
throw new ForbiddenException('Access denied');
}
if (trip.days) {
trip.days.sort((a, b) => a.dayNumber - b.dayNumber);
trip.days.forEach(day => {
if (day.activities) {
day.activities.sort((a, b) => a.sortOrder - b.sortOrder);
}
});
}
return trip;
}
async update(userId: string, id: string, updateDto: UpdateTripDto): Promise<UserTrip> {
const trip = await this.tripRepository.findOne({ where: { id, userId } });
if (!trip) throw new NotFoundException('Trip not found');
Object.assign(trip, updateDto);
return this.tripRepository.save(trip);
}
async remove(userId: string, id: string): Promise<void> {
const trip = await this.tripRepository.findOne({ where: { id, userId } });
if (!trip) throw new NotFoundException('Trip not found');
await this.tripRepository.remove(trip);
}
async addDay(userId: string, tripId: string, createDto: CreateTripDayDto): Promise<TripDay> {
const trip = await this.tripRepository.findOne({ where: { id: tripId, userId } });
if (!trip) throw new NotFoundException('Trip not found');
const day = this.dayRepository.create({ ...createDto, tripId });
return this.dayRepository.save(day);
}
async updateDay(userId: string, tripId: string, dayId: string, updateDto: UpdateTripDayDto): Promise<TripDay> {
const trip = await this.tripRepository.findOne({ where: { id: tripId, userId } });
if (!trip) throw new NotFoundException('Trip not found');
const day = await this.dayRepository.findOne({ where: { id: dayId, tripId } });
if (!day) throw new NotFoundException('Day not found');
Object.assign(day, updateDto);
return this.dayRepository.save(day);
}
async removeDay(userId: string, tripId: string, dayId: string): Promise<void> {
const trip = await this.tripRepository.findOne({ where: { id: tripId, userId } });
if (!trip) throw new NotFoundException('Trip not found');
const day = await this.dayRepository.findOne({ where: { id: dayId, tripId } });
if (!day) throw new NotFoundException('Day not found');
await this.dayRepository.remove(day);
}
async addActivity(userId: string, tripId: string, dayId: string, createDto: CreateTripActivityDto): Promise<TripActivity> {
const trip = await this.tripRepository.findOne({ where: { id: tripId, userId } });
if (!trip) throw new NotFoundException('Trip not found');
const day = await this.dayRepository.findOne({ where: { id: dayId, tripId } });
if (!day) throw new NotFoundException('Day not found');
const maxOrder = await this.activityRepository
.createQueryBuilder('a')
.select('MAX(a.sort_order)', 'max')
.where('a.day_id = :dayId', { dayId })
.getRawOne();
const activity = this.activityRepository.create({
...createDto,
dayId,
sortOrder: (maxOrder?.max || 0) + 1,
});
return this.activityRepository.save(activity);
}
async updateActivity(userId: string, tripId: string, dayId: string, activityId: string, updateDto: UpdateTripActivityDto): Promise<TripActivity> {
const trip = await this.tripRepository.findOne({ where: { id: tripId, userId } });
if (!trip) throw new NotFoundException('Trip not found');
const activity = await this.activityRepository.findOne({ where: { id: activityId, dayId } });
if (!activity) throw new NotFoundException('Activity not found');
Object.assign(activity, updateDto);
return this.activityRepository.save(activity);
}
async removeActivity(userId: string, tripId: string, dayId: string, activityId: string): Promise<void> {
const trip = await this.tripRepository.findOne({ where: { id: tripId, userId } });
if (!trip) throw new NotFoundException('Trip not found');
const activity = await this.activityRepository.findOne({ where: { id: activityId, dayId } });
if (!activity) throw new NotFoundException('Activity not found');
await this.activityRepository.remove(activity);
}
async reorderActivities(userId: string, tripId: string, dayId: string, activityIds: string[]): Promise<void> {
const trip = await this.tripRepository.findOne({ where: { id: tripId, userId } });
if (!trip) throw new NotFoundException('Trip not found');
await Promise.all(
activityIds.map((id, index) =>
this.activityRepository.update({ id, dayId }, { sortOrder: index })
)
);
}
async getTripStats(userId: string): Promise<Record<TripStatus, number>> {
const counts = await this.tripRepository
.createQueryBuilder('t')
.select('t.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('t.user_id = :userId', { userId })
.groupBy('t.status')
.getRawMany();
const result: Record<string, number> = {};
Object.values(TripStatus).forEach(s => (result[s] = 0));
counts.forEach(c => (result[c.status] = parseInt(c.count, 10)));
return result as Record<TripStatus, number>;
}
}

View File

@@ -0,0 +1,3 @@
export * from './unified-search.module';
export * from './unified-search.service';
export * from './unified-search.controller';

View File

@@ -0,0 +1,111 @@
import { Controller, Get, Post, Body, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { UnifiedSearchService } from './unified-search.service';
@ApiTags('Unified Search')
@Controller({ path: 'search', version: '1' })
export class UnifiedSearchController {
constructor(private readonly unifiedSearchService: UnifiedSearchService) {}
@Get('accommodations')
@ApiOperation({
summary: 'Search accommodations (Karibeo priority, Booking.com complement)',
description: 'Searches accommodations in Karibeo affiliates FIRST, then complements with Booking.com'
})
async searchAccommodations(
@Query('city') city: string,
@Query('checkin') checkin: string,
@Query('checkout') checkout: string,
@Query('adults') adults: number,
@Query('children') children?: number,
@Query('rooms') rooms?: number,
@Query('includeExternal') includeExternal?: boolean,
) {
return this.unifiedSearchService.searchAccommodations({
city,
checkin,
checkout,
adults: Number(adults) || 2,
children: children ? Number(children) : undefined,
rooms: rooms ? Number(rooms) : undefined,
includeExternal: includeExternal !== false,
});
}
@Post('accommodations')
@ApiOperation({ summary: 'Search accommodations (POST)' })
async searchAccommodationsPost(@Body() body: any) {
return this.unifiedSearchService.searchAccommodations({
...body,
includeExternal: body.includeExternal !== false,
});
}
@Get('flights')
@ApiOperation({
summary: 'Search flights (Karibeo priority, Booking.com complement)',
description: 'Searches flights in Karibeo affiliates FIRST, then complements with Booking.com'
})
async searchFlights(
@Query('origin') origin: string,
@Query('destination') destination: string,
@Query('departureDate') departureDate: string,
@Query('returnDate') returnDate?: string,
@Query('adults') adults?: number,
@Query('cabinClass') cabinClass?: string,
@Query('includeExternal') includeExternal?: boolean,
) {
return this.unifiedSearchService.searchFlights({
origin,
destination,
departureDate,
returnDate,
adults: Number(adults) || 1,
cabinClass,
includeExternal: includeExternal !== false,
});
}
@Post('flights')
@ApiOperation({ summary: 'Search flights (POST)' })
async searchFlightsPost(@Body() body: any) {
return this.unifiedSearchService.searchFlights({
...body,
includeExternal: body.includeExternal !== false,
});
}
@Get('vehicles')
@ApiOperation({
summary: 'Search vehicles/cars (Karibeo priority, Booking.com complement)',
description: 'Searches vehicles in Karibeo affiliates FIRST, then complements with Booking.com'
})
async searchVehicles(
@Query('pickupLocation') pickupLocation: string,
@Query('dropoffLocation') dropoffLocation?: string,
@Query('pickupDate') pickupDate?: string,
@Query('pickupTime') pickupTime?: string,
@Query('dropoffDate') dropoffDate?: string,
@Query('dropoffTime') dropoffTime?: string,
@Query('includeExternal') includeExternal?: boolean,
) {
return this.unifiedSearchService.searchVehicles({
pickupLocation,
dropoffLocation,
pickupDate: pickupDate || new Date().toISOString().split('T')[0],
pickupTime: pickupTime || '10:00',
dropoffDate: dropoffDate || new Date().toISOString().split('T')[0],
dropoffTime: dropoffTime || '10:00',
includeExternal: includeExternal !== false,
});
}
@Post('vehicles')
@ApiOperation({ summary: 'Search vehicles (POST)' })
async searchVehiclesPost(@Body() body: any) {
return this.unifiedSearchService.searchVehicles({
...body,
includeExternal: body.includeExternal !== false,
});
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UnifiedSearchService } from './unified-search.service';
import { UnifiedSearchController } from './unified-search.controller';
import { HotelRoom } from '../../entities/hotel-room.entity';
import { Flight } from '../../entities/flight.entity';
import { Vehicle } from '../../entities/vehicle.entity';
import { Listing } from '../../entities/listing.entity';
import { BookingModule } from '../booking/booking.module';
@Module({
imports: [
TypeOrmModule.forFeature([HotelRoom, Flight, Vehicle, Listing]),
BookingModule,
],
controllers: [UnifiedSearchController],
providers: [UnifiedSearchService],
exports: [UnifiedSearchService],
})
export class UnifiedSearchModule {}

View File

@@ -0,0 +1,251 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { HotelRoom } from '../../entities/hotel-room.entity';
import { Flight } from '../../entities/flight.entity';
import { Vehicle } from '../../entities/vehicle.entity';
import { Listing } from '../../entities/listing.entity';
import { BookingService } from '../booking/booking.service';
export interface UnifiedSearchResult<T> {
karibeo: T[]; // Resultados de negocios afiliados (PRIORIDAD)
external: T[]; // Resultados de Booking.com (COMPLEMENTO)
source: 'karibeo' | 'booking' | 'mixed';
totalKaribeo: number;
totalExternal: number;
}
@Injectable()
export class UnifiedSearchService {
private readonly logger = new Logger(UnifiedSearchService.name);
constructor(
@InjectRepository(HotelRoom)
private readonly hotelRoomRepository: Repository<HotelRoom>,
@InjectRepository(Flight)
private readonly flightRepository: Repository<Flight>,
@InjectRepository(Vehicle)
private readonly vehicleRepository: Repository<Vehicle>,
@InjectRepository(Listing)
private readonly listingRepository: Repository<Listing>,
private readonly bookingService: BookingService,
) {}
/**
* Busca alojamientos: PRIMERO Karibeo, SEGUNDO Booking.com
*/
async searchAccommodations(params: {
city: string;
checkin: string;
checkout: string;
adults: number;
children?: number;
rooms?: number;
includeExternal?: boolean;
}): Promise<UnifiedSearchResult<any>> {
this.logger.log('Searching accommodations - Priority: Karibeo affiliates');
// 1. PRIORIDAD: Buscar en Karibeo (listings + hotel rooms)
const karibeoResults = await this.searchKaribeoAccommodations(params);
let externalResults: any[] = [];
// 2. COMPLEMENTO: Booking.com (solo si se solicita o hay pocos resultados)
if (params.includeExternal !== false) {
try {
const bookingResponse = await this.bookingService.searchAccommodations({
city: params.city,
checkin: params.checkin,
checkout: params.checkout,
adults: params.adults,
children: params.children,
rooms: params.rooms,
});
if (bookingResponse && Array.isArray(bookingResponse.results)) {
externalResults = bookingResponse.results.map((item: any) => ({
...item,
source: 'booking',
isExternal: true,
}));
}
} catch (error) {
this.logger.warn('Booking.com search failed or not configured: ' + error.message);
}
}
return {
karibeo: karibeoResults,
external: externalResults,
source: karibeoResults.length > 0 ? (externalResults.length > 0 ? 'mixed' : 'karibeo') : 'booking',
totalKaribeo: karibeoResults.length,
totalExternal: externalResults.length,
};
}
/**
* Busca vuelos: PRIMERO Karibeo, SEGUNDO Booking.com
*/
async searchFlights(params: {
origin: string;
destination: string;
departureDate: string;
returnDate?: string;
adults: number;
cabinClass?: string;
includeExternal?: boolean;
}): Promise<UnifiedSearchResult<any>> {
this.logger.log('Searching flights - Priority: Karibeo affiliates');
// 1. PRIORIDAD: Buscar vuelos en Karibeo
const karibeoResults = await this.searchKaribeoFlights(params);
let externalResults: any[] = [];
// 2. COMPLEMENTO: Booking.com flights
if (params.includeExternal !== false) {
try {
const bookingResponse = await this.bookingService.searchFlights({
origin: params.origin,
destination: params.destination,
departureDate: params.departureDate,
returnDate: params.returnDate,
adults: params.adults,
cabinClass: params.cabinClass as any,
});
if (bookingResponse && Array.isArray(bookingResponse.results)) {
externalResults = bookingResponse.results.map((item: any) => ({
...item,
source: 'booking',
isExternal: true,
}));
}
} catch (error) {
this.logger.warn('Booking.com flights search failed: ' + error.message);
}
}
return {
karibeo: karibeoResults,
external: externalResults,
source: karibeoResults.length > 0 ? (externalResults.length > 0 ? 'mixed' : 'karibeo') : 'booking',
totalKaribeo: karibeoResults.length,
totalExternal: externalResults.length,
};
}
/**
* Busca vehículos: PRIMERO Karibeo, SEGUNDO Booking.com
*/
async searchVehicles(params: {
pickupLocation: string;
dropoffLocation?: string;
pickupDate: string;
pickupTime: string;
dropoffDate: string;
dropoffTime: string;
includeExternal?: boolean;
}): Promise<UnifiedSearchResult<any>> {
this.logger.log('Searching vehicles - Priority: Karibeo affiliates');
// 1. PRIORIDAD: Buscar vehículos en Karibeo
const karibeoResults = await this.searchKaribeoVehicles(params);
let externalResults: any[] = [];
// 2. COMPLEMENTO: Booking.com cars
if (params.includeExternal !== false) {
try {
const bookingResponse = await this.bookingService.searchCars({
pickupLocation: params.pickupLocation,
dropoffLocation: params.dropoffLocation,
pickupDate: params.pickupDate,
pickupTime: params.pickupTime,
dropoffDate: params.dropoffDate,
dropoffTime: params.dropoffTime,
});
if (bookingResponse && Array.isArray(bookingResponse.results)) {
externalResults = bookingResponse.results.map((item: any) => ({
...item,
source: 'booking',
isExternal: true,
}));
}
} catch (error) {
this.logger.warn('Booking.com cars search failed: ' + error.message);
}
}
return {
karibeo: karibeoResults,
external: externalResults,
source: karibeoResults.length > 0 ? (externalResults.length > 0 ? 'mixed' : 'karibeo') : 'booking',
totalKaribeo: karibeoResults.length,
totalExternal: externalResults.length,
};
}
// ==================== KARIBEO INTERNAL SEARCHES ====================
private async searchKaribeoAccommodations(params: any): Promise<any[]> {
const results: any[] = [];
// Buscar en listings (apartamentos, etc.)
try {
const listings = await this.listingRepository
.createQueryBuilder('listing')
.where('listing.status = :status', { status: 'active' })
.andWhere('listing.listingType IN (:...types)', { types: ['apartment', 'hotel', 'villa', 'room'] })
.andWhere('LOWER(listing.title) LIKE :city OR LOWER(listing.description) LIKE :city',
{ city: '%' + params.city.toLowerCase() + '%' })
.getMany();
results.push(...listings.map(l => ({ ...l, source: 'karibeo', type: 'listing', isExternal: false })));
} catch (error) {
this.logger.warn('Listings search failed: ' + error.message);
}
// Buscar habitaciones de hotel
try {
const hotelRooms = await this.hotelRoomRepository
.createQueryBuilder('room')
.where('room.isAvailable = :available', { available: true })
.getMany();
results.push(...hotelRooms.map(r => ({ ...r, source: 'karibeo', type: 'hotelRoom', isExternal: false })));
} catch (error) {
this.logger.warn('Hotel rooms search failed: ' + error.message);
}
return results;
}
private async searchKaribeoFlights(params: any): Promise<any[]> {
try {
const flights = await this.flightRepository
.createQueryBuilder('flight')
.where('flight.originCode = :origin', { origin: params.origin.toUpperCase() })
.andWhere('flight.destinationCode = :destination', { destination: params.destination.toUpperCase() })
.andWhere('DATE(flight.departureTime) = :date', { date: params.departureDate })
.getMany();
return flights.map(f => ({ ...f, source: 'karibeo', isExternal: false }));
} catch (error) {
this.logger.warn('Karibeo flights search failed: ' + error.message);
return [];
}
}
private async searchKaribeoVehicles(params: any): Promise<any[]> {
try {
const vehicles = await this.vehicleRepository
.createQueryBuilder('vehicle')
.where('vehicle.isAvailable = :available', { available: true })
.andWhere('vehicle.isVerified = :verified', { verified: true })
.getMany();
return vehicles.map(v => ({ ...v, source: 'karibeo', isExternal: false }));
} catch (error) {
this.logger.warn('Karibeo vehicles search failed: ' + error.message);
return [];
}
}
}

View File

@@ -18,6 +18,7 @@ export class CreateUserDto {
@ApiProperty({ description: 'Last name', example: 'Doe' })
@IsString()
lastName: string;
@ApiPropertyOptional({ description: 'Username', example: 'johndoe' }) @IsOptional() @IsString() username?: string;
@ApiPropertyOptional({ description: 'Phone number', example: '+1234567890' })
@IsOptional()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More