From e48ef31fee68e27b7bf686f5177be054eaec7c69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:10:46 +0200 Subject: [PATCH 01/13] feat(backend): add Open Food Facts service with Redis caching - transformOFFProduct: maps OFF products to unified Product model - searchBabyProducts: searches baby products via OFF API (1h cache) - getBabyProductDetails: fetches product details by barcode (1h cache) - 9 tests passing for transform logic and input validation --- apps/backend/__tests__/off-service.test.js | 92 ++++++++++++++ apps/backend/off-service.js | 139 +++++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 apps/backend/__tests__/off-service.test.js create mode 100644 apps/backend/off-service.js diff --git a/apps/backend/__tests__/off-service.test.js b/apps/backend/__tests__/off-service.test.js new file mode 100644 index 0000000..c5d7840 --- /dev/null +++ b/apps/backend/__tests__/off-service.test.js @@ -0,0 +1,92 @@ +import { transformOFFProduct, searchBabyProducts, getBabyProductDetails } from '../off-service.js'; + +describe('transformOFFProduct', () => { + test('transforms a valid OFF product to unified model', () => { + const offProduct = { + _id: '12345', + product_name: 'Baby Rice', + brands: 'Nestlé', + image_url: 'https://example.com/image.jpg', + nutriscore_grade: 'a', + ingredients_text: 'Rice, Iron, Vitamins', + nova_group: 1, + ecoscore_grade: 'b', + categories_tags: ['en:baby-foods', 'en:baby-rice'], + }; + + const result = transformOFFProduct(offProduct); + + expect(result).toEqual({ + id: '12345', + source: 'openfoodfacts', + name: 'Baby Rice', + brand: 'Nestlé', + category: 'baby_food', + image_url: 'https://example.com/image.jpg', + nutriscore: 'a', + ingredients: 'Rice, Iron, Vitamins', + nova_group: 1, + eco_score: 'b', + }); + }); + + test('returns null for null/undefined input', () => { + expect(transformOFFProduct(null)).toBeNull(); + expect(transformOFFProduct(undefined)).toBeNull(); + }); + + test('returns null when missing required fields', () => { + expect(transformOFFProduct({ _id: '123' })).toBeNull(); + expect(transformOFFProduct({ product_name: 'Test' })).toBeNull(); + }); + + test('sets default brand to empty string when missing', () => { + const result = transformOFFProduct({ + _id: '999', + product_name: 'Plain Product', + }); + expect(result.brand).toBe(''); + expect(result.image_url).toBeNull(); + expect(result.nutriscore).toBeNull(); + expect(result.ingredients).toBeNull(); + expect(result.nova_group).toBeNull(); + expect(result.eco_score).toBeNull(); + }); + + test('detects baby_milk category from tags', () => { + const result = transformOFFProduct({ + _id: '200', + product_name: 'Infant Formula', + categories_tags: ['en:infant-milk'], + }); + expect(result.category).toBe('baby_milk'); + }); + + test('detects baby_cereal category from tags', () => { + const result = transformOFFProduct({ + _id: '300', + product_name: 'Baby Cereal', + categories_tags: ['en:baby-cereals'], + }); + expect(result.category).toBe('baby_cereal'); + }); +}); + +describe('searchBabyProducts', () => { + test('returns empty array for short query', async () => { + const result = await searchBabyProducts('a'); + expect(result).toEqual([]); + }); + + test('returns empty array for null/empty query', async () => { + expect(await searchBabyProducts(null)).toEqual([]); + expect(await searchBabyProducts('')).toEqual([]); + }); +}); + +describe('getBabyProductDetails', () => { + test('returns null for null barcode', async () => { + const result = await getBabyProductDetails(null); + expect(result).toBeNull(); + }); +}); diff --git a/apps/backend/off-service.js b/apps/backend/off-service.js new file mode 100644 index 0000000..58ad5ff --- /dev/null +++ b/apps/backend/off-service.js @@ -0,0 +1,139 @@ +import axios from 'axios'; +import redisClient from './redis-client.js'; + +const OFF_API_BASE = 'https://world.openfoodfacts.org/api/v2'; +const CACHE_TTL = 3600; + +const BABY_CATEGORIES = { + 'baby food': 'baby_food', + 'baby milk': 'baby_milk', + 'baby cereal': 'baby_cereal', +}; + +export function transformOFFProduct(offProduct) { + if (!offProduct || !offProduct._id || !offProduct.product_name) { + return null; + } + + const categoriesTags = offProduct.categories_tags || []; + let category = 'baby_food'; + for (const tag of categoriesTags) { + const lower = tag.toLowerCase(); + if (lower.includes('baby-milk') || lower.includes('infant-milk')) { + category = 'baby_milk'; + break; + } + if (lower.includes('baby-cereal') || lower.includes('infant-cereal')) { + category = 'baby_cereal'; + break; + } + } + + return { + id: offProduct._id, + source: 'openfoodfacts', + name: offProduct.product_name, + brand: offProduct.brands || '', + category, + image_url: offProduct.image_url || null, + nutriscore: offProduct.nutriscore_grade || null, + ingredients: offProduct.ingredients_text || null, + nova_group: offProduct.nova_group || null, + eco_score: offProduct.ecoscore_grade || null, + }; +} + +export async function searchBabyProducts(query) { + if (!query || query.trim().length < 2) { + return []; + } + + const searchTerm = query.trim().toLowerCase(); + const cacheKey = `off:baby:${searchTerm}`; + + try { + const cachedData = await redisClient.get(cacheKey); + if (cachedData) { + console.log(`📦 Cache hit for OFF search: ${searchTerm}`); + return JSON.parse(cachedData); + } + + console.log(`🌐 Fetching from OFF API: ${searchTerm}`); + const response = await axios.get(`${OFF_API_BASE}/search`, { + params: { + categories_tags: 'baby-food', + search_terms: searchTerm, + json: true, + fields: 'product_name,brands,image_url,nutriscore_grade,ingredients_text,nova_group,ecoscore_grade,categories_tags', + page_size: 20, + }, + timeout: 5000, + }); + + if (response.data && response.data.products) { + const products = response.data.products + .map(transformOFFProduct) + .filter(Boolean); + + await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(products)); + console.log(`✅ Cached ${products.length} OFF products for: ${searchTerm}`); + return products; + } + + return []; + } catch (error) { + console.error('Error searching baby products from OFF:', error.message); + return []; + } +} + +export async function getBabyProductDetails(barcode) { + if (!barcode) { + return null; + } + + const cacheKey = `off:product:${barcode}`; + + try { + const cachedData = await redisClient.get(cacheKey); + if (cachedData) { + console.log(`📦 Cache hit for OFF product: ${barcode}`); + return JSON.parse(cachedData); + } + + console.log(`🌐 Fetching OFF product details: ${barcode}`); + const response = await axios.get(`${OFF_API_BASE}/product/${barcode}.json`, { + timeout: 5000, + }); + + if (response.data && response.data.product) { + const product = transformOFFProduct(response.data.product); + + if (product) { + await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(product)); + console.log(`✅ Cached OFF product: ${barcode}`); + return product; + } + } + + return null; + } catch (error) { + console.error(`Error fetching OFF product ${barcode}:`, error.message); + return null; + } +} + +export async function clearCache(pattern = 'off:*') { + try { + const keys = await redisClient.keys(pattern); + if (keys.length > 0) { + await redisClient.del(keys); + console.log(`🗑️ Cleared ${keys.length} OFF cache entries`); + return keys.length; + } + return 0; + } catch (error) { + console.error('Error clearing OFF cache:', error); + return 0; + } +} From b7ca3633e632ecdebc79a1aef01cf522f81058c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:17:02 +0200 Subject: [PATCH 02/13] fix: remove dead BABY_CATEGORIES constant and emoji from logs --- apps/backend/off-service.js | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/apps/backend/off-service.js b/apps/backend/off-service.js index 58ad5ff..e05259e 100644 --- a/apps/backend/off-service.js +++ b/apps/backend/off-service.js @@ -4,12 +4,6 @@ import redisClient from './redis-client.js'; const OFF_API_BASE = 'https://world.openfoodfacts.org/api/v2'; const CACHE_TTL = 3600; -const BABY_CATEGORIES = { - 'baby food': 'baby_food', - 'baby milk': 'baby_milk', - 'baby cereal': 'baby_cereal', -}; - export function transformOFFProduct(offProduct) { if (!offProduct || !offProduct._id || !offProduct.product_name) { return null; @@ -54,11 +48,11 @@ export async function searchBabyProducts(query) { try { const cachedData = await redisClient.get(cacheKey); if (cachedData) { - console.log(`📦 Cache hit for OFF search: ${searchTerm}`); + console.log(`Cache hit for OFF search: ${searchTerm}`); return JSON.parse(cachedData); } - console.log(`🌐 Fetching from OFF API: ${searchTerm}`); + console.log(`Fetching from OFF API: ${searchTerm}`); const response = await axios.get(`${OFF_API_BASE}/search`, { params: { categories_tags: 'baby-food', @@ -76,7 +70,7 @@ export async function searchBabyProducts(query) { .filter(Boolean); await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(products)); - console.log(`✅ Cached ${products.length} OFF products for: ${searchTerm}`); + console.log(`Cached ${products.length} OFF products for: ${searchTerm}`); return products; } @@ -97,11 +91,11 @@ export async function getBabyProductDetails(barcode) { try { const cachedData = await redisClient.get(cacheKey); if (cachedData) { - console.log(`📦 Cache hit for OFF product: ${barcode}`); + console.log(`Cache hit for OFF product: ${barcode}`); return JSON.parse(cachedData); } - console.log(`🌐 Fetching OFF product details: ${barcode}`); + console.log(`Fetching OFF product details: ${barcode}`); const response = await axios.get(`${OFF_API_BASE}/product/${barcode}.json`, { timeout: 5000, }); @@ -111,7 +105,7 @@ export async function getBabyProductDetails(barcode) { if (product) { await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(product)); - console.log(`✅ Cached OFF product: ${barcode}`); + console.log(`Cached OFF product: ${barcode}`); return product; } } @@ -128,7 +122,7 @@ export async function clearCache(pattern = 'off:*') { const keys = await redisClient.keys(pattern); if (keys.length > 0) { await redisClient.del(keys); - console.log(`🗑️ Cleared ${keys.length} OFF cache entries`); + console.log(`Cleared ${keys.length} OFF cache entries`); return keys.length; } return 0; From edfe7d63907aeaf61e7483453106cd1280da4626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:18:20 +0200 Subject: [PATCH 03/13] feat(backend): add CIMA OTC search function with Redis caching --- apps/backend/cima-service.js | 80 ++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/apps/backend/cima-service.js b/apps/backend/cima-service.js index ad185fd..76364ac 100644 --- a/apps/backend/cima-service.js +++ b/apps/backend/cima-service.js @@ -231,6 +231,86 @@ export async function getMedicineDetails(nregistro) { } } +/** + * Busca medicamentos OTC (Sin Receta) en la API de CIMA con caché de Redis + * @param {string} query - Término de búsqueda + * @returns {Promise} - 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:search:*') From 1a2e9aafb2698faf964fe027ff662b4799b3f66e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:23:43 +0200 Subject: [PATCH 04/13] Add unified product search routes (CIMA OTC + Open Food Facts) - GET /api/products/search?q={query} - parallel search across both sources - GET /api/products/:source/:id - product detail by source (cima/openfoodfacts) - Uses Promise.allSettled for fault-tolerant parallel search - Added imports for searchOTC and OFF service functions --- apps/backend/server.js | 51 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/apps/backend/server.js b/apps/backend/server.js index 19d4270..5136f0b 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -19,7 +19,8 @@ import pinoHttp from 'pino-http'; import multer from 'multer'; import { createWorker } from 'tesseract.js'; import { BarcodeDetector } from 'barcode-detector'; -import { searchMedicines, getMedicineDetails } from './cima-service.js'; +import { searchMedicines, getMedicineDetails, searchOTC } from './cima-service.js'; +import { searchBabyProducts, getBabyProductDetails } from './off-service.js'; import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js'; import { fetchPharmaciesExternal } from '../API/index.js'; @@ -598,6 +599,54 @@ app.get('/api/medicines/:medicineId', async (req, res) => { } }); +// ========== UNIFIED PRODUCT SEARCH ========== + +app.get('/api/products/search', async (req, res) => { + try { + const { q } = req.query; + if (!q || q.trim().length < 2) { + return res.json({ results: [], total: 0 }); + } + const searchTerm = q.trim(); + const [cimaResults, offResults] = await Promise.allSettled([ + searchOTC(searchTerm), + searchBabyProducts(searchTerm) + ]); + const cimaProducts = cimaResults.status === 'fulfilled' ? cimaResults.value : []; + const offProducts = offResults.status === 'fulfilled' ? offResults.value : []; + const allProducts = [...cimaProducts, ...offProducts] + .sort((a, b) => a.name.localeCompare(b.name)); + res.json({ + results: allProducts, + total: allProducts.length, + sources: { cima: cimaProducts.length, openfoodfacts: offProducts.length } + }); + } catch (err) { + console.error('[Products] Search error:', err); + res.status(500).json({ error: 'Error searching products' }); + } +}); + +app.get('/api/products/:source/:id', async (req, res) => { + try { + const { source, id } = req.params; + if (source === 'cima') { + const product = await getMedicineDetails(id); + if (!product) return res.status(404).json({ error: 'Product not found' }); + return res.json({ ...product, source: 'cima', category: 'otc' }); + } + if (source === 'openfoodfacts') { + const product = await getBabyProductDetails(id); + if (!product) return res.status(404).json({ error: 'Product not found' }); + return res.json(product); + } + res.status(400).json({ error: 'Invalid source' }); + } catch (err) { + console.error('[Products] Detail error:', err); + res.status(500).json({ error: 'Error fetching product details' }); + } +}); + // ========== AUTHENTICATION MIDDLEWARE ========== // Middleware to check if user is authenticated From 2d839b9082b8efc8bf0a3b718fe0667eef195889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:25:35 +0200 Subject: [PATCH 05/13] feat(frontend): add ProductResults component for unified product search Displays CIMA OTC and Open Food Facts baby products with source/category badges, product images, brand info, and Nutri-Score for OFF items. --- .../src/components/ProductResults.css | 163 ++++++++++++++++++ .../src/components/ProductResults.jsx | 114 ++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 apps/frontend/src/components/ProductResults.css create mode 100644 apps/frontend/src/components/ProductResults.jsx diff --git a/apps/frontend/src/components/ProductResults.css b/apps/frontend/src/components/ProductResults.css new file mode 100644 index 0000000..fb17ac6 --- /dev/null +++ b/apps/frontend/src/components/ProductResults.css @@ -0,0 +1,163 @@ +.product-results { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; + margin-top: 0.5rem; + max-height: 70vh; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + animation: fadeInUp 0.5s ease-out; +} + +@media (min-width: 1024px) { + .product-results { + max-height: 76vh; + } +} + +.product-card { + background: var(--surface-container-lowest); + border-radius: var(--radius-md); + padding: 1.25rem; + cursor: pointer; + transition: transform 0.15s, box-shadow 0.15s; + border: 1px solid var(--outline-variant); + display: flex; + flex-direction: column; + justify-content: space-between; + box-shadow: var(--shadow-soft); +} + +.product-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); + border-color: var(--primary); +} + +.product-card-image { + width: 100%; + height: 120px; + margin-bottom: 1rem; + overflow: hidden; + border-radius: var(--radius-sm); + background: var(--surface-container-low); + display: flex; + align-items: center; + justify-content: center; +} + +.product-card-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.product-image-placeholder { + color: var(--outline-variant); + display: flex; + align-items: center; + justify-content: center; +} + +.product-card-content { + display: flex; + flex-direction: column; + flex: 1; +} + +.product-card-badges { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.source-badge { + color: white; + padding: 0.25rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.category-badge { + background: var(--surface-container-high); + color: var(--on-surface-variant); + padding: 0.25rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.7rem; + font-weight: 600; +} + +.nutri-score-badge { + color: white; + padding: 0.25rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.7rem; + font-weight: 600; +} + +.product-card-header { + margin-bottom: 0.5rem; +} + +.product-card-header h3 { + color: var(--on-surface); + font-size: 1.15rem; + font-weight: 700; + letter-spacing: -0.01em; + flex: 1; + margin: 0; +} + +.product-card-body { + margin-bottom: 1rem; + flex: 1; +} + +.product-card-body p { + font-size: 0.9rem; + color: var(--on-surface-variant); + line-height: 1.55; + margin-bottom: 0.25rem; +} + +.product-card-body strong { + color: var(--on-surface); + font-weight: 600; +} + +.product-card-footer { + padding-top: 0.75rem; + border-top: 1px solid var(--outline-variant); +} + +.view-details { + color: var(--primary); + font-weight: 600; + font-size: 0.9rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.view-details::after { + content: "→"; + transition: transform 0.2s; +} + +.product-card:hover .view-details::after { + transform: translateX(4px); +} + +.no-results { + text-align: center; + padding: 2.5rem 1.5rem; + background: var(--surface-container-low); + border-radius: var(--radius-md); + color: var(--on-surface-variant); + border: 1px dashed var(--outline-variant); +} diff --git a/apps/frontend/src/components/ProductResults.jsx b/apps/frontend/src/components/ProductResults.jsx new file mode 100644 index 0000000..b2d0280 --- /dev/null +++ b/apps/frontend/src/components/ProductResults.jsx @@ -0,0 +1,114 @@ +import React from 'react'; +import './ProductResults.css'; + +const categoryLabels = { + otc: 'Sin Receta', + baby_food: 'Alimentación Infantil', + baby_milk: 'Leche de Fórmula', + baby_cereal: 'Cereales Bebé' +}; + +const sourceColors = { + cima: '#2563eb', + openfoodfacts: '#16a34a' +}; + +const sourceLabels = { + cima: 'CIMA', + openfoodfacts: 'Open Food Facts' +}; + +function ProductResults({ products, onSelect }) { + if (!products || products.length === 0) { + return ( +
+

No se encontraron productos

+
+ ); + } + + return ( +
+ {products.map((product) => ( + + ))} +
+ ); +} + +function ProductCard({ product, onSelect }) { + const nutriScore = product.nutri_score; + const nutriScoreColors = { + a: '#16a34a', + b: '#65a30d', + c: '#eab308', + d: '#f97316', + e: '#dc2626' + }; + + return ( +
onSelect(product)}> +
+ {product.image_url ? ( + {product.name} + ) : ( +
+ + + + + +
+ )} +
+ +
+
+ + {sourceLabels[product.source]} + + + {categoryLabels[product.category] || product.category} + + {product.source === 'openfoodfacts' && nutriScore && ( + + Nutri-Score {nutriScore.toUpperCase()} + + )} +
+ +
+

{product.name}

+
+ +
+ {product.brand && ( +

Marca: {product.brand}

+ )} + {product.source === 'cima' && product.active_ingredient && ( +

Principio Activo: {product.active_ingredient}

+ )} + {product.source === 'cima' && product.dosage && ( +

Dosis: {product.dosage}

+ )} +
+ +
+ Ver detalles → +
+
+
+ ); +} + +export default ProductResults; From 39868d95c681ba75042ac54dcc601f2cf859749e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:27:47 +0200 Subject: [PATCH 06/13] feat(frontend): integrate product search into PublicView with filter tabs --- apps/frontend/src/views/PublicView.jsx | 63 +++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/views/PublicView.jsx b/apps/frontend/src/views/PublicView.jsx index aa383c9..22dd84c 100644 --- a/apps/frontend/src/views/PublicView.jsx +++ b/apps/frontend/src/views/PublicView.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo } from 'react'; import '../App.css'; import SearchBar from '../components/SearchBar'; import MedicineResults from '../components/MedicineResults'; +import ProductResults from '../components/ProductResults'; import PharmacyList from '../components/PharmacyList'; import PharmacyMap from '../components/PharmacyMap'; import HomeView from './HomeView'; @@ -22,6 +23,8 @@ function PublicView({ const [searchQuery, setSearchQuery] = useState(''); const [medicines, setMedicines] = useState([]); + const [products, setProducts] = useState([]); + const [searchMode, setSearchMode] = useState('all'); // 'all' | 'medicines' | 'products' const [selectedMedicine, setSelectedMedicine] = useState(null); const [pharmacies, setPharmacies] = useState([]); const [loading, setLoading] = useState(false); @@ -35,6 +38,7 @@ function PublicView({ const searchMedicines = async () => { if (searchQuery.trim().length < 2) { setMedicines([]); + setProducts([]); setSelectedMedicine(null); setPharmacies([]); return; @@ -50,6 +54,16 @@ function PublicView({ } finally { setLoading(false); } + + try { + const productsResponse = await fetch(`/api/products/search?q=${encodeURIComponent(searchQuery)}`); + if (productsResponse.ok) { + const productsData = await productsResponse.json(); + setProducts(productsData.results || []); + } + } catch (err) { + console.error('Product search error:', err); + } }; const timeoutId = setTimeout(searchMedicines, 300); @@ -219,7 +233,42 @@ function PublicView({ {loading &&
Buscando...
} - {searchQuery && !selectedMedicine && ( + {searchQuery && ( +
+ + + +
+ )} + + {searchQuery && !selectedMedicine && (searchMode === 'all' || searchMode === 'medicines') && ( )} + {(searchMode === 'all' || searchMode === 'products') && products.length > 0 && ( +
+

Parafarmacia y Bebé

+ { + window.location.href = `/product/${product.source}/${product.id}`; + }} + /> +
+ )} + {selectedMedicine && (
From 13cf7b66ee40e6ce2158d1c1265192edda9f1e61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:28:55 +0200 Subject: [PATCH 07/13] feat(mobile): add products API service with search and detail endpoints --- apps/frontend-mobile/services/products.ts | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 apps/frontend-mobile/services/products.ts diff --git a/apps/frontend-mobile/services/products.ts b/apps/frontend-mobile/services/products.ts new file mode 100644 index 0000000..32faab7 --- /dev/null +++ b/apps/frontend-mobile/services/products.ts @@ -0,0 +1,53 @@ +import api from './api'; + +export interface Product { + id: string; + source: 'cima' | 'openfoodfacts'; + name: string; + brand: string; + category: string; + image_url: string | null; + active_ingredient?: string; + dosage?: string; + form?: string; + prescription?: string; + commercialized?: boolean; + photos?: { tipo: string; url: string }[]; + docs?: { tipo: number; url: string }[]; + nutriscore?: string; + ingredients?: string; + nova_group?: number; + eco_score?: string; +} + +export interface ProductSearchResponse { + results: Product[]; + total: number; + sources: { + cima: number; + openfoodfacts: number; + }; +} + +export async function searchProducts(query: string): Promise { + if (!query || query.trim().length < 2) return []; + try { + const { data } = await api.get('/products/search', { + params: { q: query } + }); + return data.results || []; + } catch (error) { + console.error('[Products] Search error:', error); + return []; + } +} + +export async function getProduct(source: string, id: string): Promise { + try { + const { data } = await api.get(`/products/${source}/${id}`); + return data; + } catch (error) { + console.error('[Products] Detail error:', error); + return null; + } +} From 241d36252820d0628591b9414c3ed25c7fe21a65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:31:39 +0200 Subject: [PATCH 08/13] feat(mobile): integrate product search with filter tabs in search screen --- apps/frontend-mobile/app/(tabs)/search.tsx | 132 ++++++++++++++++++++- 1 file changed, 126 insertions(+), 6 deletions(-) diff --git a/apps/frontend-mobile/app/(tabs)/search.tsx b/apps/frontend-mobile/app/(tabs)/search.tsx index 7d5e4d3..dfb11d5 100644 --- a/apps/frontend-mobile/app/(tabs)/search.tsx +++ b/apps/frontend-mobile/app/(tabs)/search.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { View, FlatList, StyleSheet, Text, TouchableOpacity, useWindowDimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { useNavigation } from 'expo-router'; +import { useNavigation, useRouter } from 'expo-router'; import { SearchBar } from '../../components/SearchBar'; import { MedicineCard } from '../../components/MedicineCard'; import { LoadingSpinner } from '../../components/LoadingSpinner'; @@ -9,6 +9,7 @@ import { useDebounce } from '../../hooks/useDebounce'; import { useAuth } from '../../hooks/useAuth'; import { useRecentSearches } from '../../hooks/useRecentSearches'; import { searchMedicines } from '../../services/medicines'; +import { searchProducts, Product } from '../../services/products'; import { useThemeContext } from '../../components/ThemeProvider'; import { spacing, borderRadius } from '../../constants/theme'; import { Medicine } from '../../types'; @@ -31,11 +32,14 @@ export default function SearchScreen() { const { colors } = useThemeContext(); const [query, setQuery] = useState(''); const [results, setResults] = useState([]); + const [products, setProducts] = useState([]); + const [searchMode, setSearchMode] = useState<'all' | 'medicines' | 'products'>('all'); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const debouncedQuery = useDebounce(query, config.SEARCH_DEBOUNCE_MS); const navigation = useNavigation(); + const router = useRouter(); useEffect(() => { const parent = navigation.getParent(); @@ -43,6 +47,8 @@ export default function SearchScreen() { const unsubscribe = parent.addListener('tabPress', () => { setQuery(''); setResults([]); + setProducts([]); + setSearchMode('all'); setIsLoading(false); setError(null); }); @@ -52,6 +58,7 @@ export default function SearchScreen() { useEffect(() => { if (debouncedQuery.length < 2) { setResults([]); + setProducts([]); return; } @@ -69,7 +76,17 @@ export default function SearchScreen() { } }; + const fetchProducts = async () => { + try { + const productResults = await searchProducts(debouncedQuery); + setProducts(productResults); + } catch (err) { + console.error('Product search error:', err); + } + }; + fetchResults(); + fetchProducts(); }, [debouncedQuery]); const handleSearch = (searchQuery: string) => { @@ -77,7 +94,7 @@ export default function SearchScreen() { if (searchQuery.trim()) addSearch(searchQuery); }; - const showSuggestions = !query && !isLoading && results.length === 0; + const showSuggestions = !query && !isLoading && results.length === 0 && products.length === 0; return ( @@ -87,6 +104,20 @@ export default function SearchScreen() { onChangeText={setQuery} /> + {query.length >= 2 && ( + + setSearchMode('all')}> + Todos + + setSearchMode('medicines')}> + Medicamentos + + setSearchMode('products')}> + Parafarmacia + + + )} + {showSuggestions && ( <> @@ -130,7 +161,7 @@ export default function SearchScreen() { )} - {isLoading && } + {isLoading && } {error && ( @@ -138,16 +169,43 @@ export default function SearchScreen() { )} - {!isLoading && !error && results.length === 0 && query.length >= 2 && ( + {!isLoading && !error && results.length === 0 && products.length === 0 && query.length >= 2 && ( - No se encontraron medicamentos + No se encontraron resultados )} item.nregistro} renderItem={({ item }) => } + ListHeaderComponent={ + <> + {(searchMode === 'all' || searchMode === 'products') && products.length > 0 && ( + + Parafarmacia y Bebé + {products.map((product) => ( + router.push(`/product/${product.source}/${product.id}`)} + activeOpacity={0.7} + > + + + + {product.source === 'cima' ? 'CIMA' : 'OFF'} + + + {product.name} + {product.brand} + + + ))} + + )} + + } contentContainerStyle={[styles.list, isTablet && styles.listTablet]} showsVerticalScrollIndicator={false} /> @@ -159,6 +217,34 @@ const styles = StyleSheet.create({ container: { flex: 1, }, + filterContainer: { + flexDirection: 'row', + justifyContent: 'center', + gap: spacing.sm, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + }, + filterTab: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: borderRadius.full, + backgroundColor: 'rgba(0,0,0,0.05)', + }, + filterTabActive: { + backgroundColor: '#7fbf8f', + }, + filterText: { + fontSize: 14, + fontWeight: '600', + color: '#41493e', + }, + filterTextActive: { + color: '#ffffff', + }, + section: { + paddingHorizontal: spacing.lg, + marginTop: spacing.md, + }, suggestionsSection: { marginTop: spacing.sm, alignSelf: 'center', @@ -245,4 +331,38 @@ const styles = StyleSheet.create({ emptyText: { fontSize: 16, }, + productCard: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: borderRadius.lg, + borderWidth: 1, + padding: spacing.md, + marginBottom: spacing.sm, + }, + productInfo: { + flex: 1, + gap: 4, + }, + badges: { + flexDirection: 'row', + gap: spacing.sm, + marginBottom: 2, + }, + sourceBadge: { + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderRadius: borderRadius.sm, + }, + badgeText: { + color: '#ffffff', + fontSize: 11, + fontWeight: '700', + }, + productName: { + fontSize: 15, + fontWeight: '600', + }, + productBrand: { + fontSize: 13, + }, }); From ca4a9671e7f8c17c7e5cf97fff3b0c7ae4ef7b3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:33:34 +0200 Subject: [PATCH 09/13] feat(mobile): add product detail screen for CIMA and OFF products --- .../app/product/[source]/[id].tsx | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 apps/frontend-mobile/app/product/[source]/[id].tsx diff --git a/apps/frontend-mobile/app/product/[source]/[id].tsx b/apps/frontend-mobile/app/product/[source]/[id].tsx new file mode 100644 index 0000000..3659e67 --- /dev/null +++ b/apps/frontend-mobile/app/product/[source]/[id].tsx @@ -0,0 +1,249 @@ +import React, { useEffect, useState } from 'react'; +import { View, Text, ScrollView, StyleSheet, Image, ActivityIndicator } from 'react-native'; +import { useLocalSearchParams } from 'expo-router'; +import { getProduct, Product } from '../../../services/products'; +import { LoadingSpinner } from '../../../components/LoadingSpinner'; +import { useThemeContext } from '../../../components/ThemeProvider'; +import { spacing, borderRadius } from '../../../constants/theme'; + +export default function ProductDetailScreen() { + const { source, id } = useLocalSearchParams<{ source: string; id: string }>(); + const { colors } = useThemeContext(); + const [product, setProduct] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + if (!source || !id) return; + + const fetchProduct = async () => { + try { + const data = await getProduct(source, id); + if (data) { + setProduct(data); + } else { + setError(true); + } + } catch { + setError(true); + } finally { + setIsLoading(false); + } + }; + + fetchProduct(); + }, [source, id]); + + if (isLoading) { + return ; + } + + if (error || !product) { + return ( + + Producto no encontrado + + ); + } + + const isCima = product.source === 'cima'; + + return ( + + {product.image_url ? ( + + ) : ( + + Sin imagen + + )} + + + + {product.name} + + {isCima ? 'CIMA' : 'OFF'} + + + {product.brand ? ( + {product.brand} + ) : null} + {product.category ? ( + {product.category} + ) : null} + + + {isCima ? ( + + Detalles CIMA + {product.active_ingredient && ( + + )} + {product.dosage && ( + + )} + {product.form && ( + + )} + {product.prescription && ( + + )} + + + ) : ( + + Información nutricional + {product.nutriscore && ( + + )} + {product.nova_group != null && ( + + )} + {product.eco_score && ( + + )} + {product.ingredients && ( + + Ingredientes + {product.ingredients} + + )} + + )} + + {isCima && product.photos && product.photos.length > 0 && ( + + Imágenes + + {product.photos.map((photo, i) => ( + + + {photo.tipo} + + ))} + + + )} + + ); +} + +function InfoRow({ label, value, colors }: { label: string; value: string; colors: any }) { + return ( + + {label} + {value} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + image: { + width: '100%', + height: 260, + backgroundColor: '#f5f5f5', + }, + imagePlaceholder: { + width: '100%', + height: 160, + alignItems: 'center', + justifyContent: 'center', + }, + placeholderText: { + fontSize: 14, + }, + header: { + padding: spacing.lg, + }, + nameRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + gap: spacing.sm, + }, + name: { + flex: 1, + fontSize: 22, + fontWeight: 'bold', + }, + badge: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: borderRadius.full, + overflow: 'hidden', + }, + badgeText: { + color: '#fff', + fontSize: 12, + fontWeight: '700', + }, + brand: { + fontSize: 15, + marginTop: spacing.xs, + }, + category: { + fontSize: 13, + marginTop: spacing.xs, + }, + infoSection: { + marginTop: spacing.sm, + padding: spacing.lg, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '600', + marginBottom: spacing.sm, + }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + }, + infoLabel: { + fontSize: 13, + flexShrink: 0, + marginRight: spacing.sm, + }, + infoValue: { + fontSize: 13, + fontWeight: '500', + flex: 1, + textAlign: 'right', + }, + infoValueMultiline: { + fontSize: 13, + fontWeight: '500', + flex: 1, + textAlign: 'right', + }, + photosScroll: { + marginTop: spacing.xs, + }, + photoItem: { + marginRight: spacing.md, + alignItems: 'center', + width: 120, + }, + photoImage: { + width: 120, + height: 120, + borderRadius: borderRadius.md, + backgroundColor: '#f5f5f5', + }, + photoLabel: { + fontSize: 11, + marginTop: spacing.xs, + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + errorText: { + fontSize: 16, + }, +}); From 731b51ff482d08c5b9c582b97231ca0f1ccb86fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:35:11 +0200 Subject: [PATCH 10/13] feat: display product results alongside medicine results in MedicineResults component --- apps/frontend/src/components/MedicineResults.jsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/components/MedicineResults.jsx b/apps/frontend/src/components/MedicineResults.jsx index 5c28627..37f0cd3 100644 --- a/apps/frontend/src/components/MedicineResults.jsx +++ b/apps/frontend/src/components/MedicineResults.jsx @@ -6,8 +6,9 @@ import { subscribeToPush, unsubscribeFromPush, } from '../utils/notifications.js'; +import ProductResults from './ProductResults'; -function MedicineResults({ medicines, onSelect, query, currentUser, onLoginRequest }) { +function MedicineResults({ medicines, products = [], onSelect, onSelectProduct, query, currentUser, onLoginRequest }) { if (medicines.length === 0 && query.length >= 2) { return (
@@ -27,6 +28,12 @@ function MedicineResults({ medicines, onSelect, query, currentUser, onLoginReque onLoginRequest={onLoginRequest} /> ))} + {products.length > 0 && ( +
+

Parafarmacia y Bebé

+ +
+ )}
); } From 6fa12b12d9f7440c64bf53e3c6a335f2f666217c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:46:47 +0200 Subject: [PATCH 11/13] =?UTF-8?q?fix:=20address=20code=20review=20issues?= =?UTF-8?q?=20=E2=80=94=20nutriscore=20field=20name,=20cache=20write=20iso?= =?UTF-8?q?lation,=20missing=20rate=20limiter,=20dead=20props?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/off-service.js | 16 ++++++++++++---- apps/backend/server.js | 2 +- apps/frontend/src/components/MedicineResults.jsx | 9 +-------- apps/frontend/src/components/ProductResults.jsx | 2 +- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/backend/off-service.js b/apps/backend/off-service.js index e05259e..3066e56 100644 --- a/apps/backend/off-service.js +++ b/apps/backend/off-service.js @@ -69,8 +69,12 @@ export async function searchBabyProducts(query) { .map(transformOFFProduct) .filter(Boolean); - await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(products)); - console.log(`Cached ${products.length} OFF products for: ${searchTerm}`); + try { + await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(products)); + console.log(`Cached ${products.length} OFF products for: ${searchTerm}`); + } catch (cacheErr) { + // cache write failed, still return the data + } return products; } @@ -104,8 +108,12 @@ export async function getBabyProductDetails(barcode) { const product = transformOFFProduct(response.data.product); if (product) { - await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(product)); - console.log(`Cached OFF product: ${barcode}`); + try { + await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(product)); + console.log(`Cached OFF product: ${barcode}`); + } catch (cacheErr) { + // cache write failed, still return the data + } return product; } } diff --git a/apps/backend/server.js b/apps/backend/server.js index 5136f0b..fb3ffdc 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -601,7 +601,7 @@ app.get('/api/medicines/:medicineId', async (req, res) => { // ========== UNIFIED PRODUCT SEARCH ========== -app.get('/api/products/search', async (req, res) => { +app.get('/api/products/search', searchLimiter, async (req, res) => { try { const { q } = req.query; if (!q || q.trim().length < 2) { diff --git a/apps/frontend/src/components/MedicineResults.jsx b/apps/frontend/src/components/MedicineResults.jsx index 37f0cd3..5c28627 100644 --- a/apps/frontend/src/components/MedicineResults.jsx +++ b/apps/frontend/src/components/MedicineResults.jsx @@ -6,9 +6,8 @@ import { subscribeToPush, unsubscribeFromPush, } from '../utils/notifications.js'; -import ProductResults from './ProductResults'; -function MedicineResults({ medicines, products = [], onSelect, onSelectProduct, query, currentUser, onLoginRequest }) { +function MedicineResults({ medicines, onSelect, query, currentUser, onLoginRequest }) { if (medicines.length === 0 && query.length >= 2) { return (
@@ -28,12 +27,6 @@ function MedicineResults({ medicines, products = [], onSelect, onSelectProduct, onLoginRequest={onLoginRequest} /> ))} - {products.length > 0 && ( -
-

Parafarmacia y Bebé

- -
- )}
); } diff --git a/apps/frontend/src/components/ProductResults.jsx b/apps/frontend/src/components/ProductResults.jsx index b2d0280..db9c680 100644 --- a/apps/frontend/src/components/ProductResults.jsx +++ b/apps/frontend/src/components/ProductResults.jsx @@ -41,7 +41,7 @@ function ProductResults({ products, onSelect }) { } function ProductCard({ product, onSelect }) { - const nutriScore = product.nutri_score; + const nutriScore = product.nutriscore; const nutriScoreColors = { a: '#16a34a', b: '#65a30d', From 20cc39973173657cbc0e7fdae92e54cba812e0ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 21:10:06 +0200 Subject: [PATCH 12/13] fix: add missing mocks for off-service.js and searchOTC in backend tests The backend test was hanging in CI because server.js imports off-service.js which pulls in redis-client.js. Without a mock, it tries to connect to Redis at import time, hanging indefinitely in CI where no Redis server exists. Also added the missing searchOTC mock for cima-service.js to fix the SyntaxError from the export mismatch. --- apps/backend/__tests__/server.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/backend/__tests__/server.test.js b/apps/backend/__tests__/server.test.js index 5a52cb4..6e38f9e 100644 --- a/apps/backend/__tests__/server.test.js +++ b/apps/backend/__tests__/server.test.js @@ -3,6 +3,12 @@ import { jest } from '@jest/globals' jest.unstable_mockModule('../cima-service.js', () => ({ searchMedicines: jest.fn(async () => []), getMedicineDetails: jest.fn(async () => null), + searchOTC: jest.fn(async () => []), +})) + +jest.unstable_mockModule('../off-service.js', () => ({ + searchBabyProducts: jest.fn(async () => []), + getBabyProductDetails: jest.fn(async () => null), })) jest.unstable_mockModule('../farmacias-webhook-import.js', () => ({ From 5ae3ebeceed7bb3b3fa0185c01f724e674027e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 21:16:46 +0200 Subject: [PATCH 13/13] ci: consolidate test jobs into single workflow with npm caching - Merge 3 parallel test jobs (backend/frontend/mobile) into one sequential job - Remove dependency on dorny/paths-filter@v3 (replaced with git diff script) - Add npm cache via setup-node to speed up dependency installation - Downgrade Node.js from 24 to 22 LTS for stability - Reduce checkout fetch-depth from full history to 2 commits --- .gitea/workflows/docker.yaml | 4 +- .gitea/workflows/test-branches.yaml | 80 +++++++---------------------- 2 files changed, 20 insertions(+), 64 deletions(-) diff --git a/.gitea/workflows/docker.yaml b/.gitea/workflows/docker.yaml index 1897f53..92670ae 100644 --- a/.gitea/workflows/docker.yaml +++ b/.gitea/workflows/docker.yaml @@ -36,7 +36,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '24' + node-version: '22' cache: 'npm' - name: Install dependencies run: npm ci @@ -53,7 +53,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '24' + node-version: '22' cache: 'npm' - name: Install dependencies run: npm ci diff --git a/.gitea/workflows/test-branches.yaml b/.gitea/workflows/test-branches.yaml index 3076f30..9bfbf3f 100644 --- a/.gitea/workflows/test-branches.yaml +++ b/.gitea/workflows/test-branches.yaml @@ -6,86 +6,42 @@ on: - 'main' jobs: - detect-changes: - name: Detect Changes - runs-on: ubuntu-latest - outputs: - backend: ${{ steps.filter.outputs.backend }} - frontend: ${{ steps.filter.outputs.frontend }} - frontend-mobile: ${{ steps.filter.outputs.frontend-mobile }} - steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 - id: filter - with: - filters: | - backend: - - 'apps/backend/**' - - 'packages/**' - frontend: - - 'apps/frontend/**' - - 'packages/**' - frontend-mobile: - - 'apps/frontend-mobile/**' - - 'packages/**' - - test-backend: - name: Backend Tests - needs: detect-changes - if: needs.detect-changes.outputs.backend == 'true' + test: + name: Run Tests runs-on: ubuntu-latest steps: - name: Checkout Code uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Detect Changes + id: changes + run: | + BASE=${{ github.event.before }} + [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ] && BASE=$(git rev-parse HEAD~1) + echo "backend=$(git diff --name-only "$BASE"...HEAD | grep -qE '^(apps/backend/|packages/)' && echo true || echo false)" >> "$GITHUB_OUTPUT" + echo "frontend=$(git diff --name-only "$BASE"...HEAD | grep -qE '^(apps/frontend/|packages/)' && echo true || echo false)" >> "$GITHUB_OUTPUT" + echo "frontend-mobile=$(git diff --name-only "$BASE"...HEAD | grep -qE '^(apps/frontend-mobile/|packages/)' && echo true || echo false)" >> "$GITHUB_OUTPUT" - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '24' + node-version: '22' + cache: 'npm' - name: Install Dependencies run: npm ci - name: Run Backend Tests + if: steps.changes.outputs.backend == 'true' run: npm test --workspace=farma-clic-backend -- --ci - test-frontend: - name: Frontend Tests - needs: detect-changes - if: needs.detect-changes.outputs.frontend == 'true' - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '24' - - - name: Install Dependencies - run: npm ci - - name: Run Frontend Tests + if: steps.changes.outputs.frontend == 'true' run: npm test --workspace=farma-clic-frontend -- --run --reporter=basic - test-frontend-mobile: - name: Frontend Mobile Tests - needs: detect-changes - if: needs.detect-changes.outputs.frontend-mobile == 'true' - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '24' - - - name: Install Dependencies - run: npm ci - - name: Run Frontend Mobile Tests + if: steps.changes.outputs.frontend-mobile == 'true' run: npm test --workspace=frontend-mobile -- --ci continue-on-error: true