# Parafarmacia + Productos de Bebé — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Extend FarmaFinder search to include OTC medications from CIMA and baby products from Open Food Facts in a unified results view. **Architecture:** Backend orchestrates parallel searches to CIMA (OTC filter) and Open Food Facts (baby-food categories), merges results into a unified Product model, caches in Redis. Frontend displays mixed results with source/category badges. **Tech Stack:** Node.js/Express, Axios, Redis, React (web), React Native/Expo (mobile) --- ## File Structure ``` apps/backend/ ├── off-service.js (NEW) Open Food Facts API client ├── cima-service.js (MODIFY) Add searchOTC() function ├── server.js (MODIFY) Add /api/products/* routes └── tests/ └── off-service.test.js (NEW) Tests for OFF service apps/frontend/src/ ├── components/ │ └── ProductResults.jsx (NEW) Unified product results component ├── views/ │ └── PublicView.jsx (MODIFY) Use /api/products/search └── components/medicine/ └── MedicineResults.jsx (MODIFY) Accept mixed product types apps/frontend-mobile/ ├── services/ │ └── products.ts (NEW) Product API client ├── app/(tabs)/ │ └── search.tsx (MODIFY) Add product search └── app/product/ └── [source]/[id].tsx (NEW) Product detail screen ``` --- ### Task 1: Create Open Food Facts Service (Backend) **Files:** - Create: `apps/backend/off-service.js` - Create: `apps/backend/tests/off-service.test.js` - [ ] **Step 1: Write the failing test** ```javascript // apps/backend/tests/off-service.test.js const { searchBabyProducts, getBabyProductDetails, transformOFFProduct } = require('../off-service'); describe('off-service', () => { describe('transformOFFProduct', () => { it('transforms OFF product to unified Product model', () => { const offProduct = { _id: '12345', product_name: 'Nidal 1 Leche', brands: 'Nestlé', image_url: 'https://images.openfoodfacts.org/12345.jpg', nutriscore_grade: 'b', ingredients_text: 'Leche desnatada, lactosa, aceites vegetales...', nova_group: 3, ecoscore_grade: 'c', categories_tags: ['en:baby-milks', 'en:baby-foods'] }; const result = transformOFFProduct(offProduct); expect(result).toEqual({ id: '12345', source: 'openfoodfacts', name: 'Nidal 1 Leche', brand: 'Nestlé', category: 'baby_milk', image_url: 'https://images.openfoodfacts.org/12345.jpg', nutriscore: 'b', ingredients: 'Leche desnatada, lactosa, aceites vegetales...', nova_group: 3, eco_score: 'c' }); }); it('returns null for invalid product', () => { expect(transformOFFProduct(null)).toBeNull(); expect(transformOFFProduct({})).toBeNull(); }); }); describe('searchBabyProducts', () => { it('returns array of Product objects', async () => { const results = await searchBabyProducts('leche'); expect(Array.isArray(results)).toBe(true); if (results.length > 0) { expect(results[0]).toHaveProperty('source', 'openfoodfacts'); expect(results[0]).toHaveProperty('id'); expect(results[0]).toHaveProperty('name'); } }); }); describe('getBabyProductDetails', () => { it('returns Product object for valid barcode', async () => { // Use a known OFF product barcode const result = await getBabyProductDetails('3017620422003'); if (result) { expect(result).toHaveProperty('source', 'openfoodfacts'); expect(result).toHaveProperty('name'); } }); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd apps/backend && npm test -- tests/off-service.test.js` Expected: FAIL with "Cannot find module '../off-service'" - [ ] **Step 3: Write minimal implementation** ```javascript // apps/backend/off-service.js const axios = require('axios'); const redisClient = require('./redis-client'); const OFF_API_BASE = 'https://world.openfoodfacts.org/api/v2'; const CACHE_TTL = 3600; // 1 hour function transformOFFProduct(offProduct) { if (!offProduct || !offProduct._id || !offProduct.product_name) { return null; } // Determine category from tags let category = 'baby_food'; const tags = offProduct.categories_tags || []; if (tags.some(t => t.includes('baby-milk'))) { category = 'baby_milk'; } else if (tags.some(t => t.includes('cereals-for-babies'))) { category = 'baby_cereal'; } 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 }; } async function searchBabyProducts(query) { const cacheKey = `off:baby:${query.toLowerCase().trim()}`; // Check cache try { const cached = await redisClient.get(cacheKey); if (cached) return JSON.parse(cached); } catch (err) { // Cache miss, continue } try { const url = `${OFF_API_BASE}/search`; const { data } = await axios.get(url, { params: { categories_tags: 'baby-food', search_terms: query, json: 'true', fields: 'product_name,brands,image_url,nutriscore_grade,ingredients_text,nova_group,ecoscore_grade,categories_tags', page_size: 20 }, timeout: 5000 }); const products = (data.products || []) .map(transformOFFProduct) .filter(Boolean); // Cache results try { await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(products)); } catch (err) { // Cache write failed, continue } return products; } catch (err) { console.error('[OFF] Search error:', err.message); return []; } } async function getBabyProductDetails(barcode) { const cacheKey = `off:product:${barcode}`; // Check cache try { const cached = await redisClient.get(cacheKey); if (cached) return JSON.parse(cached); } catch (err) { // Cache miss, continue } try { const url = `${OFF_API_BASE}/product/${barcode}.json`; const { data } = await axios.get(url, { timeout: 5000 }); if (!data || !data.product) return null; const product = transformOFFProduct(data.product); // Cache result try { await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(product)); } catch (err) { // Cache write failed, continue } return product; } catch (err) { console.error('[OFF] Detail error:', err.message); return null; } } module.exports = { searchBabyProducts, getBabyProductDetails, transformOFFProduct }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd apps/backend && npm test -- tests/off-service.test.js` Expected: PASS (Note: searchBabyProducts and getBabyProductDetails tests require network/Redis; they may be skipped in CI) - [ ] **Step 5: Commit** ```bash git add apps/backend/off-service.js apps/backend/tests/off-service.test.js git commit -m "feat(backend): add Open Food Facts service for baby products" ``` --- ### Task 2: Add CIMA OTC Search Function (Backend) **Files:** - Modify: `apps/backend/cima-service.js` - [ ] **Step 1: Add searchOTC function** Read `apps/backend/cima-service.js` to understand existing `searchMedicines` function structure. Add new function after it: ```javascript // Add after searchMedicines function (around line 130) async function searchOTC(query) { const searchTerm = query.toLowerCase().trim(); if (!searchTerm) return []; const cacheKey = `cima:otc:${searchTerm}`; // Check cache try { const cached = await redisClient.get(cacheKey); if (cached) return JSON.parse(cached); } catch (err) { // Cache miss } try { const { data } = await axios.get(`${CIMA_API_BASE_URL}/medicamentos`, { params: { nombre: searchTerm, cpresc: 'Sin Receta', pagina: 1, tamanioPagina: 20 }, timeout: 5000 }); if (!data || !data.resultados) return []; const products = 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 || [] })); // Cache results try { await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(products)); } catch (err) { // Cache write failed } return products; } catch (err) { console.error('[CIMA] OTC search error:', err.message); return []; } } ``` - [ ] **Step 2: Export the new function** Add `searchOTC` to the module.exports at the bottom of the file: ```javascript module.exports = { searchMedicines, getMedicineDetails, searchOTC }; ``` - [ ] **Step 3: Run existing tests to verify no regression** Run: `cd apps/backend && npm test` Expected: All existing tests still pass - [ ] **Step 4: Commit** ```bash git add apps/backend/cima-service.js git commit -m "feat(backend): add searchOTC function for OTC medications" ``` --- ### Task 3: Add Unified Product Search Routes (Backend) **Files:** - Modify: `apps/backend/server.js` - [ ] **Step 1: Add imports at top of server.js** After the existing CIMA service import, add: ```javascript const { searchBabyProducts, getBabyProductDetails } = require('./off-service'); const { searchOTC } = require('./cima-service'); ``` - [ ] **Step 2: Add unified search route** Find a good location after the existing `/api/medicines/search` route (around line 520) and add: ```javascript // Unified product search (OTC + Baby) 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(); // Launch parallel searches const [cimaResults, offResults] = await Promise.allSettled([ searchOTC(searchTerm), searchBabyProducts(searchTerm) ]); const cimaProducts = cimaResults.status === 'fulfilled' ? cimaResults.value : []; const offProducts = offResults.status === 'fulfilled' ? offResults.value : []; // Merge and sort by name 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' }); } }); ``` - [ ] **Step 3: Add product detail route** After the search route: ```javascript // Product detail by source and ID app.get('/api/products/:source/:id', async (req, res) => { try { const { source, id } = req.params; if (source === 'cima') { const { getMedicineDetails } = require('./cima-service'); 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' }); } }); ``` - [ ] **Step 4: Test the endpoints manually** Start the backend server and test: ```bash curl "http://localhost:3001/api/products/search?q=leche" curl "http://localhost:3001/api/products/search?q=paracetamol" curl "http://localhost:3001/api/products/cima/70483" ``` - [ ] **Step 5: Commit** ```bash git add apps/backend/server.js git commit -m "feat(backend): add unified product search routes" ``` --- ### Task 4: Create ProductResults Component (Frontend Web) **Files:** - Create: `apps/frontend/src/components/ProductResults.jsx` - [ ] **Step 1: Create the component** ```jsx // apps/frontend/src/components/ProductResults.jsx import React from 'react'; const categoryLabels = { otc: 'Sin Receta', baby_food: 'Alimentación Infantil', baby_milk: 'Leche de Fórmula', baby_cereal: 'Cereales Bebé' }; const sourceLabels = { cima: 'CIMA', openfoodfacts: 'Open Food Facts' }; const sourceColors = { cima: '#2563eb', openfoodfacts: '#16a34a' }; export default function ProductResults({ products, onSelect }) { if (!products || products.length === 0) { return (
No se encontraron productos
); } return (
{products.map((product) => (
onSelect(product)} >
{/* Product Image */}
{product.image_url ? ( {product.name} { e.target.style.display = 'none'; }} /> ) : (
Sin imagen
)}
{/* Product Info */}
{sourceLabels[product.source]} {categoryLabels[product.category] || product.category}

{product.name}

{product.brand}

{/* CIMA-specific fields */} {product.source === 'cima' && product.active_ingredient && (

Principio activo: {product.active_ingredient} {product.dosage && ` - ${product.dosage}`}

)} {/* OFF-specific fields */} {product.source === 'openfoodfacts' && product.nutriscore && (
Nutriscore: {product.nutriscore.toUpperCase()}
)}
))}
); } ``` - [ ] **Step 2: Commit** ```bash git add apps/frontend/src/components/ProductResults.jsx git commit -m "feat(frontend): add ProductResults component for unified search" ``` --- ### Task 5: Integrate Product Search in PublicView (Frontend Web) **Files:** - Modify: `apps/frontend/src/views/PublicView.jsx` - [ ] **Step 1: Add import for ProductResults** Read the file first, then add the import near other component imports: ```javascript import ProductResults from '../components/ProductResults'; ``` - [ ] **Step 2: Add state for products** Find the existing search state variables and add: ```javascript const [products, setProducts] = useState([]); const [searchMode, setSearchMode] = useState('all'); // 'all' | 'medicines' | 'products' ``` - [ ] **Step 3: Add product search to the search handler** Find the existing search function (likely `handleSearch` or similar) and modify it to also call the products endpoint: ```javascript // After existing medicine search call, add: try { const productsResponse = await fetch(`/api/products/search?q=${encodeURIComponent(query)}`); if (productsResponse.ok) { const productsData = await productsResponse.json(); setProducts(productsData.results || []); } } catch (err) { console.error('Product search error:', err); } ``` - [ ] **Step 4: Add filter tabs above results** Add filter tabs to let users switch between medicine and product results: ```jsx {/* Add before the results section */}
``` - [ ] **Step 5: Render ProductResults conditionally** In the results section, add: ```jsx {(searchMode === 'all' || searchMode === 'products') && products.length > 0 && (

Parafarmacia y Bebé

{ window.location.href = `/product/${product.source}/${product.id}`; }} />
)} ``` - [ ] **Step 6: Test in browser** Start the frontend dev server and verify: 1. Search "leche" — should show both CIMA OTC and OFF baby products 2. Search "paracetamol" — should show CIMA OTC results 3. Filter tabs work correctly - [ ] **Step 7: Commit** ```bash git add apps/frontend/src/views/PublicView.jsx git commit -m "feat(frontend): integrate product search in PublicView" ``` --- ### Task 6: Create Product API Service (Mobile) **Files:** - Create: `apps/frontend-mobile/services/products.ts` - [ ] **Step 1: Create the service file** ```typescript // apps/frontend-mobile/services/products.ts import api from './api'; export interface Product { id: string; source: 'cima' | 'openfoodfacts'; name: string; brand: string; category: string; image_url: string | null; // CIMA-specific active_ingredient?: string; dosage?: string; form?: string; prescription?: string; commercialized?: boolean; photos?: { tipo: string; url: string }[]; docs?: { tipo: number; url: string }[]; // OFF-specific 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; } } ``` - [ ] **Step 2: Commit** ```bash git add apps/frontend-mobile/services/products.ts git commit -m "feat(mobile): add product API service" ``` --- ### Task 7: Integrate Product Search in Mobile Search Screen **Files:** - Modify: `apps/frontend-mobile/app/(tabs)/search.tsx` - [ ] **Step 1: Add import for products service** Read the file first, then add: ```typescript import { searchProducts, Product } from '../../services/products'; ``` - [ ] **Step 2: Add state for products** Find existing state variables and add: ```typescript const [products, setProducts] = useState([]); ``` - [ ] **Step 3: Add product search to search effect** Find the existing search debounced effect and add product search: ```typescript // After existing medicine search, add: try { const productResults = await searchProducts(debouncedQuery); setProducts(productResults); } catch (err) { console.error('Product search error:', err); } ``` - [ ] **Step 4: Add filter tabs in the UI** Add tabs to filter between medicines and products. Find the results section and add: ```tsx {/* Filter tabs */} setSearchMode('all')} > Todos setSearchMode('medicines')} > Medicamentos setSearchMode('products')} > Parafarmacia ``` - [ ] **Step 5: Render product cards** Add product cards below the medicine results: ```tsx {(searchMode === 'all' || searchMode === 'products') && products.length > 0 && ( Parafarmacia y Bebé {products.map((product) => ( router.push(`/product/${product.source}/${product.id}`)} > {product.source === 'cima' ? 'CIMA' : 'OFF'} {product.name} {product.brand} ))} )} ``` - [ ] **Step 6: Add styles** Add to the StyleSheet: ```typescript filterContainer: { flexDirection: 'row', gap: 8, marginBottom: 16, }, filterTab: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20, backgroundColor: '#f3f4f6', }, filterTabActive: { backgroundColor: '#2563eb', }, filterText: { fontSize: 14, color: '#6b7280', }, filterTextActive: { color: '#ffffff', }, section: { marginBottom: 24, }, sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12, }, productCard: { backgroundColor: '#ffffff', borderRadius: 12, padding: 16, marginBottom: 8, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 2, elevation: 1, }, productInfo: { flex: 1, }, badges: { flexDirection: 'row', gap: 8, marginBottom: 4, }, sourceBadge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 12, }, badgeText: { fontSize: 10, color: '#ffffff', fontWeight: '600', }, productName: { fontSize: 16, fontWeight: '500', color: '#111827', }, productBrand: { fontSize: 14, color: '#6b7280', marginTop: 2, }, ``` - [ ] **Step 7: Commit** ```bash git add apps/frontend-mobile/app/\(tabs\)/search.tsx git commit -m "feat(mobile): integrate product search in search screen" ``` --- ### Task 8: Create Product Detail Screen (Mobile) **Files:** - Create: `apps/frontend-mobile/app/product/[source]/[id].tsx` - [ ] **Step 1: Create the screen** ```tsx // apps/frontend-mobile/app/product/[source]/[id].tsx import React, { useEffect, useState } from 'react'; import { View, Text, ScrollView, Image, StyleSheet, ActivityIndicator } from 'react-native'; import { useLocalSearchParams } from 'expo-router'; import { getProduct, Product } from '../../../services/products'; export default function ProductDetailScreen() { const { source, id } = useLocalSearchParams<{ source: string; id: string }>(); const [product, setProduct] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { loadProduct(); }, [source, id]); async function loadProduct() { if (!source || !id) return; setLoading(true); const data = await getProduct(source, id); setProduct(data); setLoading(false); } if (loading) { return ( ); } if (!product) { return ( Producto no encontrado ); } return ( {/* Header */} {product.image_url && ( )} {product.source === 'cima' ? 'CIMA' : 'Open Food Facts'} {/* Product Info */} {product.name} {product.brand} {/* CIMA Details */} {product.source === 'cima' && ( {product.active_ingredient && ( )} {product.dosage && ( )} {product.form && ( )} {product.prescription && ( )} )} {/* OFF Details */} {product.source === 'openfoodfacts' && ( {product.nutriscore && ( )} {product.ingredients && ( )} {product.nova_group && ( )} )} ); } function DetailRow({ label, value }: { label: string; value: string }) { return ( {label} {value} ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#ffffff', }, centered: { flex: 1, justifyContent: 'center', alignItems: 'center', }, header: { padding: 16, alignItems: 'center', }, image: { width: 120, height: 120, borderRadius: 12, marginBottom: 12, }, sourceBadge: { paddingHorizontal: 12, paddingVertical: 4, borderRadius: 16, }, badgeText: { color: '#ffffff', fontSize: 12, fontWeight: '600', }, infoSection: { padding: 16, }, name: { fontSize: 24, fontWeight: '700', color: '#111827', marginBottom: 4, }, brand: { fontSize: 16, color: '#6b7280', marginBottom: 16, }, details: { backgroundColor: '#f9fafb', borderRadius: 12, padding: 16, }, detailRow: { marginBottom: 12, }, detailLabel: { fontSize: 12, color: '#9ca3af', textTransform: 'uppercase', marginBottom: 2, }, detailValue: { fontSize: 14, color: '#374151', }, errorText: { fontSize: 16, color: '#6b7280', }, }); ``` - [ ] **Step 2: Commit** ```bash git add apps/frontend-mobile/app/product/\[source\]/\[id\].tsx git commit -m "feat(mobile): add product detail screen" ``` --- ### Task 9: Update MedicineResults for Mixed Products (Frontend Web) **Files:** - Modify: `apps/frontend/src/components/medicine/MedicineResults.jsx` - [ ] **Step 1: Add ProductResults import and merge logic** Read the file first. Add at the top: ```javascript import ProductResults from '../ProductResults'; ``` - [ ] **Step 2: Accept products prop** Modify the component to accept products: ```javascript export default function MedicineResults({ medicines, products = [], onSelectMedicine, onSelectProduct }) { ``` - [ ] **Step 3: Add products section below medicines** After the medicines list, add: ```jsx {products.length > 0 && (

Parafarmacia y Bebé

)} ``` - [ ] **Step 4: Commit** ```bash git add apps/frontend/src/components/medicine/MedicineResults.jsx git commit -m "feat(frontend): update MedicineResults to show mixed products" ``` --- ### Task 10: End-to-End Testing **Files:** None (manual testing) - [ ] **Step 1: Test backend endpoints** ```bash # Start backend cd apps/backend && npm start # Test search curl "http://localhost:3001/api/products/search?q=leche" | jq '.total' curl "http://localhost:3001/api/products/search?q=paracetamol" | jq '.total' curl "http://localhost:3001/api/products/search?q=vitamina" | jq '.total' # Test detail curl "http://localhost:3001/api/products/cima/70483" | jq '.name' curl "http://localhost:3001/api/products/openfoodfacts/3017620422003" | jq '.name' ``` - [ ] **Step 2: Test web frontend** ```bash cd apps/frontend && npm run dev ``` Open browser, test: 1. Search "leche" — verify both CIMA OTC and OFF baby products appear 2. Search "paracetamol" — verify CIMA OTC results 3. Click filter tabs — verify filtering works 4. Click a product — verify detail page loads - [ ] **Step 3: Test mobile app** ```bash cd apps/frontend-mobile && npm start ``` Test on simulator/device: 1. Search "leche" — verify products appear 2. Use filter tabs 3. Tap a product — verify detail screen loads - [ ] **Step 4: Verify error handling** - Disconnect network — verify graceful degradation - Search empty string — verify no errors - Search single character — verify no errors - [ ] **Step 5: Commit final state** ```bash git add -A git commit -m "feat: complete parapharmacy and baby products integration" ``` --- ## Summary | Task | Description | Files | |------|-------------|-------| | 1 | Open Food Facts service | off-service.js, tests | | 2 | CIMA OTC search | cima-service.js | | 3 | Unified search routes | server.js | | 4 | ProductResults component | ProductResults.jsx | | 5 | PublicView integration | PublicView.jsx | | 6 | Mobile product service | products.ts | | 7 | Mobile search integration | search.tsx | | 8 | Mobile product detail | [source]/[id].tsx | | 9 | MedicineResults update | MedicineResults.jsx | | 10 | End-to-end testing | - |