diff --git a/apps/backend/.env.example b/apps/backend/.env.example index b681f3f..7c4aba2 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -20,9 +20,5 @@ VAPID_SUBJECT=mailto:admin@example.com # https://expo.dev/accounts/[username]/settings/access-tokens EXPO_ACCESS_TOKEN= -# Open Food Facts -# Register at: https://world.openfoodfacts.org/ -# User-Agent is REQUIRED per OFF docs (format: AppName/Version (ContactEmail)) -OFF_USER_AGENT=FarmaFinder/1.0 (https://github.com/farmafinder) -OFF_USERNAME= -OFF_PASSWORD= +# Parapharmacy API +PARAPHARMACY_API_URL=http://localhost:3002 diff --git a/apps/backend/__tests__/off-service.test.js b/apps/backend/__tests__/off-service.test.js deleted file mode 100644 index 3bae2e9..0000000 --- a/apps/backend/__tests__/off-service.test.js +++ /dev/null @@ -1,107 +0,0 @@ -import { jest } from '@jest/globals' - -jest.unstable_mockModule('../redis-client.js', () => ({ - default: { - get: jest.fn(async () => null), - setEx: jest.fn(async () => 'OK'), - }, -})) - -jest.unstable_mockModule('axios', () => ({ - default: { - get: jest.fn(async () => ({ data: { products: [] } })), - }, -})) - -const { transformOFFProduct, searchBabyProducts, getBabyProductDetails } = await import('../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/__tests__/server.test.js b/apps/backend/__tests__/server.test.js index 6e38f9e..c252983 100644 --- a/apps/backend/__tests__/server.test.js +++ b/apps/backend/__tests__/server.test.js @@ -6,11 +6,6 @@ jest.unstable_mockModule('../cima-service.js', () => ({ 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', () => ({ runFarmaciaWebhookImport: jest.fn(async () => ({})), DEFAULT_FARMACIAS_WEBHOOK: '', diff --git a/apps/backend/off-service.js b/apps/backend/off-service.js deleted file mode 100644 index 5beb71d..0000000 --- a/apps/backend/off-service.js +++ /dev/null @@ -1,206 +0,0 @@ -import axios from 'axios'; -import redisClient from './redis-client.js'; - -// OFF API v3 (recommended) with fallback to v2 for search -const OFF_API_BASE = 'https://world.openfoodfacts.org'; -const CACHE_TTL = 3600; -const MAX_RETRIES = 2; -const RETRY_DELAY_MS = 1000; - -// User-Agent is REQUIRED per OFF docs: AppName/Version (ContactEmail) -const OFF_USER_AGENT = process.env.OFF_USER_AGENT || 'FarmaFinder/1.0 (https://github.com/farmafinder)'; - -// Open Food Facts authentication (optional, for write operations) -const OFF_USERNAME = process.env.OFF_USERNAME; -const OFF_PASSWORD = process.env.OFF_PASSWORD; - -function getOffHeaders() { - return { - 'User-Agent': OFF_USER_AGENT, - }; -} - -function getOffAuth() { - if (OFF_USERNAME && OFF_PASSWORD) { - return { - username: OFF_USERNAME, - password: OFF_PASSWORD, - }; - } - return null; -} - -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -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 headers = getOffHeaders(); - console.log(`[OFF] User-Agent: ${headers['User-Agent']}`); - console.log(`[OFF] URL: ${OFF_API_BASE}/api/v2/search?brands_tags=${searchTerm}`); - - let lastError; - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - try { - // Use v2 structured search with brand filter (more reliable than /cgi/search.pl) - // READ operations don't require auth per OFF docs, only User-Agent - const response = await axios.get(`${OFF_API_BASE}/api/v2/search`, { - params: { - brands_tags: searchTerm, - page_size: 20, - }, - headers, - timeout: 10000, - validateStatus: (status) => status === 200, - }); - - // Check if response is actually JSON (OFF sometimes returns HTML on error) - if (typeof response.data === 'string' || !response.data.products) { - console.warn(`[OFF] Invalid response for "${searchTerm}" (attempt ${attempt + 1}): API may be down`); - if (attempt < MAX_RETRIES) { - await sleep(RETRY_DELAY_MS * (attempt + 1)); - continue; - } - return []; - } - - const products = response.data.products - .map(transformOFFProduct) - .filter(Boolean); - - // Only cache non-empty results to avoid caching API failures - if (products.length > 0) { - 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 - } - } else { - console.log(`[OFF] No products found for "${searchTerm}", not caching empty result`); - } - return products; - } catch (err) { - lastError = err; - console.warn(`[OFF] Request failed for "${searchTerm}" (attempt ${attempt + 1}): ${err.message}`); - if (attempt < MAX_RETRIES) { - await sleep(RETRY_DELAY_MS * (attempt + 1)); - } - } - } - - console.error(`[OFF] All retries failed for "${searchTerm}": ${lastError?.message}`); - 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 headers = getOffHeaders(); - // Use v3 API (recommended) for product details - // READ operations don't require auth per OFF docs, only User-Agent - const response = await axios.get(`${OFF_API_BASE}/api/v3/product/${barcode}.json`, { - headers, - timeout: 10000, - validateStatus: (status) => status === 200, - }); - - if (response.data && response.data.product) { - const product = transformOFFProduct(response.data.product); - - if (product) { - 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; - } - } - - 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; - } -} diff --git a/apps/backend/server.js b/apps/backend/server.js index 1968843..f2e5c7c 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -23,7 +23,6 @@ import pino from 'pino'; import pinoHttp from 'pino-http'; import multer from 'multer'; 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'; @@ -680,7 +679,7 @@ app.get('/api/medicines/:medicineId', async (req, res) => { } }); -// ========== UNIFIED PRODUCT SEARCH ========== +// ========== OTC PRODUCT SEARCH (CIMA only) ========== app.get('/api/products/search', searchLimiter, async (req, res) => { try { @@ -689,18 +688,11 @@ app.get('/api/products/search', searchLimiter, async (req, res) => { 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)); + const cimaProducts = await searchOTC(searchTerm); res.json({ - results: allProducts, - total: allProducts.length, - sources: { cima: cimaProducts.length, openfoodfacts: offProducts.length } + results: cimaProducts, + total: cimaProducts.length, + sources: { cima: cimaProducts.length } }); } catch (err) { console.error('[Products] Search error:', err); @@ -716,11 +708,6 @@ app.get('/api/products/:source/:id', async (req, res) => { 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); @@ -733,7 +720,7 @@ app.get('/api/products/:source/:productId/pharmacies', async (req, res) => { try { const { source, productId } = req.params; - if (source !== 'cima' && source !== 'openfoodfacts') { + if (source !== 'cima') { return res.status(400).json({ error: 'Invalid source' }); } @@ -2137,8 +2124,8 @@ app.post('/api/admin/pharmacy-products', requireAdmin, async (req, res) => { return res.status(400).json({ error: 'pharmacy_id, product_source, and product_off_id are required' }); } - if (product_source !== 'cima' && product_source !== 'openfoodfacts') { - return res.status(400).json({ error: 'product_source must be "cima" or "openfoodfacts"' }); + if (product_source !== 'cima') { + return res.status(400).json({ error: 'product_source must be "cima"' }); } const existing = await userDbGet( diff --git a/apps/frontend-mobile/services/products.ts b/apps/frontend-mobile/services/products.ts index 078a386..43f85ac 100644 --- a/apps/frontend-mobile/services/products.ts +++ b/apps/frontend-mobile/services/products.ts @@ -3,7 +3,7 @@ import api from './api'; export interface Product { id: string; _id?: string; - source: 'cima' | 'openfoodfacts' | 'promofarma' | 'pharmarket' | 'docmorris' | '1001farma' | 'primor' | 'mifarma'; + source: 'cima' | 'promofarma' | 'pharmarket' | 'docmorris' | '1001farma' | 'primor' | 'mifarma'; name: string; brand: string; category: string; @@ -22,10 +22,6 @@ export interface Product { 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 { @@ -35,7 +31,6 @@ export interface ProductSearchResponse { pages?: number; sources?: { cima: number; - openfoodfacts: number; }; }