diff --git a/apps/backend/__tests__/server.test.js b/apps/backend/__tests__/server.test.js index 5a52cb4..c252983 100644 --- a/apps/backend/__tests__/server.test.js +++ b/apps/backend/__tests__/server.test.js @@ -3,6 +3,7 @@ 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('../farmacias-webhook-import.js', () => ({ diff --git a/docs/superpowers/plans/2026-07-13-parapharmacy-baby-products.md b/docs/superpowers/plans/2026-07-13-parapharmacy-baby-products.md new file mode 100644 index 0000000..d22c534 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-parapharmacy-baby-products.md @@ -0,0 +1,1250 @@ +# 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 | - | diff --git a/docs/superpowers/specs/2026-07-13-parapharmacy-baby-products-design.md b/docs/superpowers/specs/2026-07-13-parapharmacy-baby-products-design.md new file mode 100644 index 0000000..6231d4c --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-parapharmacy-baby-products-design.md @@ -0,0 +1,226 @@ +# Parafarmacia + Productos de Bebé — Design Spec + +**Date:** 2026-07-13 +**Status:** Approved +**Author:** MiMoCode + +## Problem + +FarmaFinder currently only searches medications via the CIMA API. Users need to find parapharmacy products (OTC medications, vitamins, topical treatments) and baby products (formula milk, baby food, cereals) — categories that pharmacies sell but CIMA doesn't cover comprehensively. + +## Goal + +Extend FarmaFinder's search to include: +1. **OTC medications** from CIMA (filtered by `cpresc = "Sin Receta"`) +2. **Baby products** (formula milk, baby food, cereals) from Open Food Facts API + +Results should appear in a unified search alongside existing prescription medication results. + +## Data Sources + +### CIMA API (existing, extended) + +- **Base URL:** `https://cima.aemps.es/cima/rest` +- **No authentication required** +- **New filter:** `cpresc=Sin+Receta` to get only OTC products +- **Endpoints used:** + - `GET /medicamentos?nombre={query}&cpresc=Sin+Receta` — search OTC products + - `GET /medicamento/{nregistro}` — get OTC product details +- **Rate limits:** None documented; existing 5s timeout per request +- **Data fields:** nregistro, nombre, labtitular, cpresc, formaFarmaceutica, vtm, dosis, fotos, docs + +### Open Food Facts API (new) + +- **Base URL:** `https://world.openfoodfacts.org/api/v2` +- **No authentication required** +- **Endpoints used:** + - `GET /search?categories_tags=baby-food&search_terms={query}&json=true` — search baby products + - `GET /product/{barcode}.json` — get product details +- **Rate limits:** Be polite (< 10 req/s) +- **Data fields:** product_name, brands, image_url, nutriscore, ingredients_text, categories_tags +- **Product categories to search:** + - `baby-foods` — general baby food + - `baby-milks` — formula milk + - `cereals-for-babies` — baby cereals + - `snacks-and-desserts-for-babies` — baby snacks + +## Architecture + +### Data Flow + +``` +User searches "leche" + → Frontend: GET /api/products/search?q=leche + → Backend orchestrator: + 1. cimaService.searchOTC('leche') — CIMA OTC search + 2. offService.searchBaby('leche') — Open Food Facts search + 3. Merge results with source tag + 4. Cache in Redis (1h TTL) + → Return unified result list +``` + +### Unified Product Model + +```typescript +interface Product { + id: string; // nregistro (CIMA) or _id (OFF) + source: 'cima' | 'openfoodfacts'; + name: string; + brand: string; // labtitular (CIMA) or brands (OFF) + category: 'otc' | 'baby_food' | 'baby_milk' | 'baby_cereal'; + image_url: string | null; // fotos[0].url (CIMA) or image_url (OFF) + + // CIMA-specific fields + active_ingredient?: string; // vtm.nombre + dosage?: string; // dosis + form?: string; // formaFarmaceutica.nombre + prescription?: string; // cpresc + commercialized?: boolean; // comerc + photos?: string[]; + docs?: { tipo: number; url: string }[]; + + // OFF-specific fields + nutriscore?: string; // nutriscore_grade + ingredients?: string; // ingredients_text + nova_group?: number; // nova_group + eco_score?: string; // ecoscore_grade +} +``` + +## Backend Changes + +### 1. New file: `apps/backend/off-service.js` + +Open Food Facts API client: + +```javascript +const OFF_API_BASE = 'https://world.openfoodfacts.org/api/v2'; + +async function searchBabyProducts(query) { + // Search in baby-food categories + const url = `${OFF_API_BASE}/search?categories_tags=baby-food&search_terms=${encodeURIComponent(query)}&json=true&fields=product_name,brands,image_url,nutriscore_grade,ingredients_text,nova_group,ecoscore_grade,categories_tags&page_size=20`; + // Cache key: off:baby:{query}, TTL: 1 hour + // Transform to unified Product model +} + +async function getBabyProductDetails(barcode) { + const url = `${OFF_API_BASE}/product/${barcode}.json`; + // Cache key: off:product:{barcode}, TTL: 24 hours + // Transform to unified Product model +} +``` + +### 2. Modify: `apps/backend/cima-service.js` + +Add OTC-specific search function: + +```javascript +async function searchOTC(query) { + // Same as searchMedicines but adds cpresc=Sin+Receta filter + // Cache key: cima:otc:{query}, TTL: 1 hour + // Transform to unified Product model (add source: 'cima', category: 'otc') +} +``` + +### 3. Modify: `apps/backend/server.js` + +New unified search route: + +```javascript +app.get('/api/products/search', async (req, res) => { + const { q } = req.query; + // Launch parallel searches: + const [cimaResults, offResults] = await Promise.allSettled([ + searchOTC(q), + searchBabyProducts(q) + ]); + // Merge, deduplicate, sort by relevance + // Return unified results +}); + +app.get('/api/products/:source/:id', async (req, res) => { + const { source, id } = req.params; + if (source === 'cima') return getMedicineDetails(id); + if (source === 'openfoodfacts') return getBabyProductDetails(id); +}); +``` + +### 4. Redis Cache Strategy + +| Key Pattern | TTL | Source | +|-------------|-----|--------| +| `products:search:{query}` | 1 hour | Merged results | +| `cima:otc:{query}` | 1 hour | CIMA OTC only | +| `off:baby:{query}` | 1 hour | OFF baby only | +| `off:product:{barcode}` | 24 hours | OFF product detail | + +## Frontend Changes + +### 1. New component: `ProductResults.jsx` + +Displays unified search results with: +- Product card with image, name, brand +- Source badge: "CIMA" or "Open Food Facts" +- Category badge: "OTC", "Baby Food", "Formula" +- Click navigates to `/product/{source}/{id}` + +### 2. Modify: `PublicView.jsx` + +- Update search to call `/api/products/search?q=` +- Show unified results alongside existing medicine results +- Add category filter tabs: "All" | "Medications" | "OTC" | "Baby" + +### 3. Modify: `MedicineResults.jsx` + +- Accept mixed product types +- Conditionally render fields based on `source` +- Show CIMA-specific fields (active ingredient, dosage) for CIMA products +- Show OFF-specific fields (nutriscore, ingredients) for OFF products + +### 4. New route: `/product/:source/:id` + +- Detail page for any product type +- CIMA products: show full medication info + pharmacies +- OFF products: show nutritional info + "Available at pharmacies" section + +## Mobile Changes + +### 1. `apps/frontend-mobile/services/products.ts` + +```typescript +export async function searchProducts(query: string): Promise { + const { data } = await api.get('/products/search', { params: { q: query } }); + return data.results; +} +``` + +### 2. `apps/frontend-mobile/app/(tabs)/search.tsx` + +- Add product search alongside medicine search +- Display unified results with source/category badges + +### 3. `apps/frontend-mobile/app/product/[source]/[id].tsx` + +- New detail screen for non-medication products +- Adapt layout based on product source + +## Error Handling + +- CIMA API down: return OFF results only (graceful degradation) +- OFF API down: return CIMA results only +- Both down: return cached results if available, else empty +- OFF rate limit: implement 100ms delay between requests + +## Testing + +1. **Unit tests:** off-service.js search/transform functions +2. **Integration tests:** `/api/products/search` endpoint with mocked APIs +3. **Manual test:** Search "leche" and verify both CIMA OTC + OFF baby results appear +4. **Edge cases:** Empty results, API timeout, malformed OFF responses + +## Out of Scope + +- Homeopathy (not reliably available in either API) +- Commercial parapharmacy APIs (paid, not needed for OTC + baby) +- Product price/stock for OFF products (pharmacies don't stock these in the current model) +- User reviews/ratings for products