Files
FarmaFinder/apps/backend/cima-service.js
T
Ichitux 84bf5f3f79
Run Tests on Branches / Run Tests (push) Failing after 3h11m0s
Merge branch 'main' into feat/observability-metrics-monitoring
2026-07-13 23:08:19 +00:00

354 lines
11 KiB
JavaScript

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<Array>} - 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<Object|null>} - 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;
}
}
/**
* Busca medicamentos OTC (Sin Receta) en la API de CIMA con caché de Redis
* @param {string} query - Término de búsqueda
* @returns {Promise<Array>} - Lista de medicamentos OTC encontrados
*/
export async function searchOTC(query) {
if (!query || query.trim().length < 2) {
return [];
}
const searchTerm = query.trim().toLowerCase();
const cacheKey = `cima:otc:${searchTerm}`;
try {
// Intentar obtener del caché
const cachedData = await redisClient.get(cacheKey);
if (cachedData) {
console.log(`📦 Cache hit for OTC search: ${searchTerm}`);
return JSON.parse(cachedData);
}
const parsed = parseSearchQuery(searchTerm);
const apiSearchTerm = parsed.nameQuery || searchTerm;
console.log(`🌐 Fetching OTC from CIMA API: ${apiSearchTerm}`);
const response = await axios.get(`${CIMA_API_BASE_URL}/medicamentos`, {
params: {
nombre: apiSearchTerm,
cpresc: 'Sin Receta'
},
timeout: 5000
});
if (response.data && response.data.resultados) {
// Transformar los datos de CIMA al modelo unificado Product
const medicines = response.data.resultados.map(med => ({
id: med.nregistro,
source: 'cima',
name: med.nombre,
brand: med.labtitular || '',
category: 'otc',
image_url: med.fotos?.[0]?.url || null,
active_ingredient: med.vtm?.nombre || null,
dosage: med.dosis || null,
form: med.formaFarmaceutica?.nombre || null,
prescription: med.cpresc,
commercialized: med.comerc,
photos: med.fotos || [],
docs: med.docs || []
}));
const filtered = filterMedicinesByFullQuery(medicines, searchTerm);
// Guardar en caché (1h TTL)
await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(filtered));
console.log(`✅ Cached ${filtered.length} OTC medicines for: ${searchTerm}`);
return filtered;
}
return [];
} catch (error) {
console.error('Error searching OTC 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 OTC cache data due to API error');
return JSON.parse(staleData);
}
} catch (cacheError) {
console.error('OTC cache fallback also failed:', cacheError);
}
return [];
}
}
/**
* Limpia el caché de búsquedas (útil para testing o mantenimiento)
* @param {string} pattern - Patrón de claves a eliminar (ej: 'medicines:*')
* @returns {Promise<number>} - 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;
}
}