import axios from 'axios'; import redisClient from './redis-client.js'; import * as appMetrics from './src/metrics.js'; const CIMA_API_BASE_URL = 'https://cima.aemps.es/cima/rest'; const CACHE_TTL = 3600; /** * Wraps a CIMA API GET with duration + success/error metrics. */ async function trackedCimaGet(url, params) { const start = Date.now(); try { const response = await axios.get(url, { params, timeout: 5000 }); appMetrics.cimaRequestDuration.record(Date.now() - start); appMetrics.cimaRequestsTotal.add(1, { status: 'ok' }); return response; } catch (err) { appMetrics.cimaRequestDuration.record(Date.now() - start); appMetrics.cimaRequestsTotal.add(1, { status: 'error' }); throw err; } } /** * Separa los términos de dosis (ej: "1g", "1000 mg") del nombre del * medicamento, para poder buscar en CIMA solo con el nombre y luego * filtrar por dosis con mayor precisión. */ function parseSearchQuery(query) { const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean); const dosagePattern = /^\d+(g|mg|mcg|µg|ml|ui|%)$/i; const dosageTerms = []; const nameTerms = []; for (const term of terms) { const clean = term.replace(/\s+/g, ''); if (dosagePattern.test(clean)) { dosageTerms.push(clean); } else { nameTerms.push(term); } } return { nameQuery: nameTerms.join(' '), dosageTerms }; } /** * Filtra los resultados de CIMA comprobando que todos los términos del * nombre aparezcan en el nombre comercial o principio activo, y que los * términos de dosis coincidan con el campo dosage normalizado. */ function filterMedicinesByFullQuery(medicines, searchTerm) { const parsed = parseSearchQuery(searchTerm); const nameTerms = parsed.nameQuery.split(/\s+/).filter(Boolean); const dosageTerms = parsed.dosageTerms; if (nameTerms.length === 0 && dosageTerms.length === 0) return medicines; return medicines.filter((m) => { if (nameTerms.length > 0) { const hay = `${m.name || ''} ${m.active_ingredient || ''}`.toLowerCase(); if (!nameTerms.every((term) => hay.includes(term))) return false; } if (dosageTerms.length > 0) { const dosageHay = (m.dosage || '').toLowerCase().replace(/\s+/g, ''); if (!dosageTerms.every((term) => dosageHay.includes(term))) return false; } return true; }); } /** * Busca medicamentos en la API de CIMA con caché de Redis * @param {string} query - Término de búsqueda * @returns {Promise} - Lista de medicamentos encontrados */ export async function searchMedicines(query) { if (!query || query.trim().length < 2) { return []; } const searchTerm = query.trim().toLowerCase(); const cacheKey = `medicines:search:v3:${searchTerm}`; try { appMetrics.medicineSearchesTotal.add(1); // Intentar obtener del caché const cachedData = await redisClient.get(cacheKey); if (cachedData) { console.log(`📦 Cache hit for: ${searchTerm}`); appMetrics.cacheHitsTotal.add(1); return JSON.parse(cachedData); } appMetrics.cacheMissesTotal.add(1); const parsed = parseSearchQuery(searchTerm); const apiSearchTerm = parsed.nameQuery || searchTerm; console.log(`🌐 Fetching from CIMA API: ${apiSearchTerm}`); const response = await trackedCimaGet(`${CIMA_API_BASE_URL}/medicamentos`, { nombre: apiSearchTerm, }); if (response.data && response.data.resultados) { // Transformar los datos de CIMA a nuestro formato const medicines = response.data.resultados.map(med => ({ id: med.nregistro, nregistro: med.nregistro, name: med.nombre, active_ingredient: med.vtm?.nombre || null, dosage: med.dosis || null, form: med.formaFarmaceutica?.nombre || null, formSimplified: med.formaFarmaceuticaSimplificada?.nombre || null, laboratory: med.labtitular, prescription: med.cpresc, commercialized: med.comerc, generic: med.generico, photos: med.fotos || [], docs: med.docs || [] })); const filtered = filterMedicinesByFullQuery(medicines, searchTerm); // Guardar en caché await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(filtered)); console.log(`✅ Cached ${filtered.length} medicines for: ${searchTerm}`); return filtered; } return []; } catch (error) { console.error('Error searching medicines from CIMA:', error.message); // Si falla, intentar devolver datos cacheados aunque hayan expirado try { const staleData = await redisClient.get(cacheKey); if (staleData) { console.log('⚠️ Returning stale cache data due to API error'); return JSON.parse(staleData); } } catch (cacheError) { console.error('Cache fallback also failed:', cacheError); } return []; } } /** * Obtiene detalles de un medicamento específico por su número de registro * @param {string} nregistro - Número de registro del medicamento * @returns {Promise} - Datos del medicamento */ export async function getMedicineDetails(nregistro) { const cacheKey = `medicine:${nregistro}`; try { appMetrics.medicineSearchesTotal.add(1); // Intentar obtener del caché const cachedData = await redisClient.get(cacheKey); if (cachedData) { console.log(`📦 Cache hit for medicine: ${nregistro}`); appMetrics.cacheHitsTotal.add(1); return JSON.parse(cachedData); } appMetrics.cacheMissesTotal.add(1); // Intentar obtener de la API de CIMA (endpoint individual puede no existir) console.log(`🌐 Fetching medicine details from CIMA: ${nregistro}`); try { const response = await trackedCimaGet(`${CIMA_API_BASE_URL}/medicamento/${nregistro}`); if (response.data) { const med = response.data; const medicineDetails = { id: med.nregistro, nregistro: med.nregistro, name: med.nombre, active_ingredient: med.principiosActivos?.[0]?.nombre || med.vtm?.nombre || null, dosage: med.dosis || null, form: med.formaFarmaceutica?.nombre || null, formSimplified: med.formaFarmaceuticaSimplificada?.nombre || null, laboratory: med.labtitular, prescription: med.cpresc, commercialized: med.comerc, generic: med.generico, photos: med.fotos || [], docs: med.docs || [], presentations: med.presentaciones || [] }; await redisClient.setEx(cacheKey, CACHE_TTL * 24, JSON.stringify(medicineDetails)); return medicineDetails; } } catch (apiError) { console.log(`⚠️ Individual medicine endpoint failed, trying search fallback`); } // Fallback: buscar por nregistro usando el endpoint de búsqueda const searchResponse = await trackedCimaGet(`${CIMA_API_BASE_URL}/medicamentos`, { params: { nregistro }, }); if (searchResponse.data?.resultados?.length > 0) { const med = searchResponse.data.resultados[0]; const medicineDetails = { id: med.nregistro, nregistro: med.nregistro, name: med.nombre, active_ingredient: med.vtm?.nombre || null, dosage: med.dosis || null, form: med.formaFarmaceutica?.nombre || null, formSimplified: med.formaFarmaceuticaSimplificada?.nombre || null, laboratory: med.labtitular, prescription: med.cpresc, commercialized: med.comerc, generic: med.generico, photos: med.fotos || [], docs: med.docs || [] }; await redisClient.setEx(cacheKey, CACHE_TTL * 24, JSON.stringify(medicineDetails)); return medicineDetails; } return null; } catch (error) { console.error(`Error fetching medicine ${nregistro} from CIMA:`, error.message); // Intentar devolver datos cacheados aunque hayan expirado try { const staleData = await redisClient.get(cacheKey); if (staleData) { console.log('⚠️ Returning stale cache data due to API error'); return JSON.parse(staleData); } } catch (cacheError) { console.error('Cache fallback also failed:', cacheError); } return null; } } /** * Limpia el caché de búsquedas (útil para testing o mantenimiento) * @param {string} pattern - Patrón de claves a eliminar (ej: 'medicines:*') * @returns {Promise} - Número de claves eliminadas */ export async function clearCache(pattern = 'medicines:*') { try { const keys = await redisClient.keys(pattern); if (keys.length > 0) { await redisClient.del(keys); console.log(`🗑️ Cleared ${keys.length} cache entries`); return keys.length; } return 0; } catch (error) { console.error('Error clearing cache:', error); return 0; } }