From b34657c78fbb09b8636c901dfd76acbf3e01d31f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 16 Jul 2026 12:26:54 +0200 Subject: [PATCH] feat: add parapharmacy API with MongoDB and N8N setup - Create parapharmacy-api microservice with Express + Swagger + MongoDB - Add product model with full-text search and deduplication - Add CRUD endpoints and bulk upsert for scrapers - Update docker-compose.yml with MongoDB, N8N, and parapharmacy-api services - Add proxy endpoints in FarmaFinder backend for parapharmacy - Update frontend services to use parapharmacy API - Add Swagger documentation at /api/docs --- apps/backend/server.js | 59 +++ apps/frontend-mobile/app/(tabs)/search.tsx | 8 +- apps/frontend-mobile/services/products.ts | 70 +++- apps/frontend/src/views/PublicView.jsx | 29 +- apps/frontend/src/views/SearchView.jsx | 36 +- apps/parapharmacy-api/.env.example | 13 + apps/parapharmacy-api/Dockerfile | 22 ++ apps/parapharmacy-api/README.md | 120 ++++++ apps/parapharmacy-api/package.json | 26 ++ apps/parapharmacy-api/src/config.js | 22 ++ apps/parapharmacy-api/src/models/Product.js | 172 ++++++++ apps/parapharmacy-api/src/routes/products.js | 392 +++++++++++++++++++ apps/parapharmacy-api/src/server.js | 130 ++++++ docker-compose.yml | 56 +++ package.json | 1 + 15 files changed, 1129 insertions(+), 27 deletions(-) create mode 100644 apps/parapharmacy-api/.env.example create mode 100644 apps/parapharmacy-api/Dockerfile create mode 100644 apps/parapharmacy-api/README.md create mode 100644 apps/parapharmacy-api/package.json create mode 100644 apps/parapharmacy-api/src/config.js create mode 100644 apps/parapharmacy-api/src/models/Product.js create mode 100644 apps/parapharmacy-api/src/routes/products.js create mode 100644 apps/parapharmacy-api/src/server.js diff --git a/apps/backend/server.js b/apps/backend/server.js index 0aff672..1968843 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -778,6 +778,65 @@ app.get('/api/products/:source/:productId/pharmacies', async (req, res) => { } }); +// ========== PARAPHARMACY PROXY ========== + +const PARAPHARMACY_API_URL = process.env.PARAPHARMACY_API_URL || 'http://localhost:3002'; + +// Search parapharmacy products (proxy to parapharmacy-api) +app.get('/api/products/parapharmacy/search', searchLimiter, async (req, res) => { + try { + const { q, category, brand, page = 1, limit = 20 } = req.query; + const params = new URLSearchParams(); + if (q) params.set('q', q); + if (category) params.set('category', category); + if (brand) params.set('brand', brand); + params.set('page', page); + params.set('limit', limit); + + const response = await axios.get(`${PARAPHARMACY_API_URL}/api/products/search?${params}`); + res.json(response.data); + } catch (error) { + console.error('[Parapharmacy] Search error:', error.message); + res.status(500).json({ error: 'Error searching parapharmacy products' }); + } +}); + +// Get parapharmacy product details (proxy to parapharmacy-api) +app.get('/api/products/parapharmacy/:id', async (req, res) => { + try { + const response = await axios.get(`${PARAPHARMACY_API_URL}/api/products/${req.params.id}`); + res.json(response.data); + } catch (error) { + console.error('[Parapharmacy] Detail error:', error.message); + if (error.response?.status === 404) { + return res.status(404).json({ error: 'Product not found' }); + } + res.status(500).json({ error: 'Error fetching product details' }); + } +}); + +// Get parapharmacy categories (proxy to parapharmacy-api) +app.get('/api/products/parapharmacy/categories', async (req, res) => { + try { + const response = await axios.get(`${PARAPHARMACY_API_URL}/api/products/categories`); + res.json(response.data); + } catch (error) { + console.error('[Parapharmacy] Categories error:', error.message); + res.status(500).json({ error: 'Error fetching categories' }); + } +}); + +// Get parapharmacy brands (proxy to parapharmacy-api) +app.get('/api/products/parapharmacy/brands', async (req, res) => { + try { + const response = await axios.get(`${PARAPHARMACY_API_URL}/api/products/brands`); + res.json(response.data); + } catch (error) { + console.error('[Parapharmacy] Brands error:', error.message); + res.status(500).json({ error: 'Error fetching brands' }); + } +}); + // ========== AUTHENTICATION MIDDLEWARE ========== // Middleware to check if user is authenticated diff --git a/apps/frontend-mobile/app/(tabs)/search.tsx b/apps/frontend-mobile/app/(tabs)/search.tsx index dfb11d5..8ce000b 100644 --- a/apps/frontend-mobile/app/(tabs)/search.tsx +++ b/apps/frontend-mobile/app/(tabs)/search.tsx @@ -9,7 +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 { searchParapharmacy, Product } from '../../services/products'; import { useThemeContext } from '../../components/ThemeProvider'; import { spacing, borderRadius } from '../../constants/theme'; import { Medicine } from '../../types'; @@ -78,10 +78,10 @@ export default function SearchScreen() { const fetchProducts = async () => { try { - const productResults = await searchProducts(debouncedQuery); - setProducts(productResults); + const response = await searchParapharmacy(debouncedQuery); + setProducts(response.results || []); } catch (err) { - console.error('Product search error:', err); + console.error('Parapharmacy search error:', err); } }; diff --git a/apps/frontend-mobile/services/products.ts b/apps/frontend-mobile/services/products.ts index 32faab7..078a386 100644 --- a/apps/frontend-mobile/services/products.ts +++ b/apps/frontend-mobile/services/products.ts @@ -2,11 +2,19 @@ import api from './api'; export interface Product { id: string; - source: 'cima' | 'openfoodfacts'; + _id?: string; + source: 'cima' | 'openfoodfacts' | 'promofarma' | 'pharmarket' | 'docmorris' | '1001farma' | 'primor' | 'mifarma'; name: string; brand: string; category: string; + subcategory?: string; image_url: string | null; + source_url?: string; + price?: number; + original_price?: number; + available?: boolean; + rating?: number; + review_count?: number; active_ingredient?: string; dosage?: string; form?: string; @@ -23,7 +31,9 @@ export interface Product { export interface ProductSearchResponse { results: Product[]; total: number; - sources: { + page?: number; + pages?: number; + sources?: { cima: number; openfoodfacts: number; }; @@ -42,6 +52,62 @@ export async function searchProducts(query: string): Promise { } } +export async function searchParapharmacy(query: string, options?: { + category?: string; + brand?: string; + page?: number; + limit?: number; +}): Promise { + if (!query || query.trim().length < 2) { + return { results: [], total: 0 }; + } + try { + const params: Record = { q: query }; + if (options?.category) params.category = options.category; + if (options?.brand) params.brand = options.brand; + if (options?.page) params.page = options.page; + if (options?.limit) params.limit = options.limit; + + const { data } = await api.get('/products/parapharmacy/search', { + params + }); + return data; + } catch (error) { + console.error('[Parapharmacy] Search error:', error); + return { results: [], total: 0 }; + } +} + +export async function getParapharmacyProduct(id: string): Promise { + try { + const { data } = await api.get(`/products/parapharmacy/${id}`); + return data; + } catch (error) { + console.error('[Parapharmacy] Detail error:', error); + return null; + } +} + +export async function getParapharmacyCategories(): Promise { + try { + const { data } = await api.get('/products/parapharmacy/categories'); + return data || []; + } catch (error) { + console.error('[Parapharmacy] Categories error:', error); + return []; + } +} + +export async function getParapharmacyBrands(): Promise { + try { + const { data } = await api.get('/products/parapharmacy/brands'); + return data || []; + } catch (error) { + console.error('[Parapharmacy] Brands error:', error); + return []; + } +} + export async function getProduct(source: string, id: string): Promise { try { const { data } = await api.get(`/products/${source}/${id}`); diff --git a/apps/frontend/src/views/PublicView.jsx b/apps/frontend/src/views/PublicView.jsx index 22dd84c..6be6bc5 100644 --- a/apps/frontend/src/views/PublicView.jsx +++ b/apps/frontend/src/views/PublicView.jsx @@ -55,21 +55,32 @@ function PublicView({ 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); - } + // Only search medications from CIMA }; const timeoutId = setTimeout(searchMedicines, 300); return () => clearTimeout(timeoutId); }, [searchQuery]); + // Search parapharmacy when user switches to Parafarmacia tab + useEffect(() => { + if (searchMode !== 'products' || searchQuery.trim().length < 2) return; + + const searchProducts = async () => { + try { + const response = await fetch(`/api/products/parapharmacy/search?q=${encodeURIComponent(searchQuery)}`); + if (response.ok) { + const data = await response.json(); + setProducts(data.results || []); + } + } catch (err) { + console.error('Parapharmacy search error:', err); + } + }; + + searchProducts(); + }, [searchMode, searchQuery]); + useEffect(() => { const fetchPharmacies = async () => { if (!selectedMedicine) { diff --git a/apps/frontend/src/views/SearchView.jsx b/apps/frontend/src/views/SearchView.jsx index 9aa5f63..d711f51 100644 --- a/apps/frontend/src/views/SearchView.jsx +++ b/apps/frontend/src/views/SearchView.jsx @@ -88,24 +88,16 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate const query = searchQuery.trim(); try { - // Run both searches in parallel for speed - const [medicinesRes, productsRes] = await Promise.allSettled([ - fetch(`/api/medicines/search?q=${encodeURIComponent(query)}`), - fetch(`/api/products/search?q=${encodeURIComponent(query)}`), - ]); + // Only search medications from CIMA + const medicinesRes = await fetch(`/api/medicines/search?q=${encodeURIComponent(query)}`); // Only update if this search is still the current one if (query !== searchQuery.trim()) return; - if (medicinesRes.status === 'fulfilled' && medicinesRes.value.ok) { - const medicinesData = await medicinesRes.value.json(); + if (medicinesRes.ok) { + const medicinesData = await medicinesRes.json(); setMedicines(medicinesData); } - - if (productsRes.status === 'fulfilled' && productsRes.value.ok) { - const productsData = await productsRes.value.json(); - setProducts(productsData.results || []); - } } catch (error) { console.error('Search error:', error); } finally { @@ -116,6 +108,26 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate return () => clearTimeout(timeoutId); }, [searchQuery]); + // Search parapharmacy when user switches to Parafarmacia tab + useEffect(() => { + if (searchMode !== 'products' || searchQuery.trim().length < 2) return; + + const searchProducts = async () => { + const query = searchQuery.trim(); + try { + const response = await fetch(`/api/products/parapharmacy/search?q=${encodeURIComponent(query)}`); + if (response.ok) { + const data = await response.json(); + setProducts(data.results || []); + } + } catch (error) { + console.error('Parapharmacy search error:', error); + } + }; + + searchProducts(); + }, [searchMode, searchQuery]); + useEffect(() => { const fetchPharmacies = async () => { if (!selectedMedicine) { diff --git a/apps/parapharmacy-api/.env.example b/apps/parapharmacy-api/.env.example new file mode 100644 index 0000000..3159bd3 --- /dev/null +++ b/apps/parapharmacy-api/.env.example @@ -0,0 +1,13 @@ +# Parapharmacy API Configuration +PORT=3002 +NODE_ENV=development + +# MongoDB +MONGODB_URI=mongodb://localhost:27017/parapharmacy + +# CORS +CORS_ORIGIN=http://localhost:3000 + +# Rate Limiting +RATE_LIMIT_WINDOW_MS=60000 +RATE_LIMIT_MAX=100 diff --git a/apps/parapharmacy-api/Dockerfile b/apps/parapharmacy-api/Dockerfile new file mode 100644 index 0000000..2a630c0 --- /dev/null +++ b/apps/parapharmacy-api/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production + +# Copy source code +COPY src/ ./src/ + +# Expose port +EXPOSE 3002 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3002/api/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })" + +# Start server +CMD ["node", "--env-file-if-exists=.env", "src/server.js"] diff --git a/apps/parapharmacy-api/README.md b/apps/parapharmacy-api/README.md new file mode 100644 index 0000000..c65cb77 --- /dev/null +++ b/apps/parapharmacy-api/README.md @@ -0,0 +1,120 @@ +# FarmaFinder Parapharmacy API + +Microservicio para productos de parafarmacia de múltiples tiendas españolas. + +## Arquitectura + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ N8N Workflows │────▶│ Parapharmacy │────▶│ MongoDB │ +│ (Scraper) │ │ API (Express) │ │ │ +└─────────────────┘ └────────┬─────────┘ └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ FarmaFinder │ + │ Backend │ + └─────────────────┘ +``` + +## Endpoints + +| Método | Ruta | Descripción | +|--------|------|-------------| +| GET | `/api/products/search?q=term` | Buscar productos | +| GET | `/api/products/:id` | Detalle de producto | +| GET | `/api/products` | Listar productos | +| POST | `/api/products` | Crear producto | +| POST | `/api/products/bulk` | Crear/actualizar múltiples | +| PUT | `/api/products/:id` | Actualizar producto | +| DELETE | `/api/products/:id` | Eliminar producto | +| GET | `/api/products/categories` | Listar categorías | +| GET | `/api/products/brands` | Listar marcas | +| GET | `/api/sources` | Fuentes configuradas | +| GET | `/api/health` | Health check | +| GET | `/api/docs` | Swagger UI | + +## Desarrollo + +### Requisitos + +- Node.js 20+ +- MongoDB 7+ + +### Instalación + +```bash +cd apps/parapharmacy-api +npm install +``` + +### Ejecutar + +```bash +# Desarrollo +npm run dev + +# Producción +npm start +``` + +### Variables de Entorno + +```bash +PORT=3002 +MONGODB_URI=mongodb://localhost:27017/parapharmacy +CORS_ORIGIN=http://localhost:3000 +``` + +## Docker + +```bash +# Construir imagen +docker build -t farmafinder-parapharmacy-api . + +# Ejecutar +docker run -p 3002:3002 -e MONGODB_URI=mongodb://host:27017/parapharmacy farmafinder-parapharmacy-api +``` + +## Fuentes de Scraping + +| Fuente | URL | Estado | +|--------|-----|--------| +| Promofarma | promofarma.com | ✅ | +| Pharmarket | pharmarket.es | 🔄 Pendiente | +| DocMorris | docmorris.es | 🔄 Pendiente | +| 1001Farma | 1001farma.net | 🔄 Pendiente | +| Primor | primor.eu | 🔄 Pendiente | +| MiFarma | mifarma.es | 🔄 Pendiente | + +## Schema MongoDB + +```javascript +{ + name: String, // Nombre del producto + brand: String, // Marca + category: String, // Categoría + subcategory: String, // Subcategoría + description: String, // Descripción + image_url: String, // URL imagen + source_url: String, // URL tienda original + price: Number, // Precio actual + original_price: Number, // Precio anterior + source: String, // Fuente + source_product_id: String, + available: Boolean, + rating: Number, + review_count: Number, + scraped_at: Date +} +``` + +## Tests + +```bash +npm test +``` + +## Swagger + +Documentación disponible en: `http://localhost:3002/api/docs` diff --git a/apps/parapharmacy-api/package.json b/apps/parapharmacy-api/package.json new file mode 100644 index 0000000..7833417 --- /dev/null +++ b/apps/parapharmacy-api/package.json @@ -0,0 +1,26 @@ +{ + "name": "farmafinder-parapharmacy-api", + "version": "1.0.0", + "description": "Parapharmacy products API for FarmaFinder", + "main": "src/server.js", + "type": "module", + "scripts": { + "start": "node --env-file-if-exists=.env src/server.js", + "dev": "node --env-file-if-exists=.env --watch src/server.js", + "test": "NODE_OPTIONS='--experimental-vm-modules' npx jest --ci --forceExit --forceExitTimeout=30000" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.18.2", + "express-rate-limit": "^8.5.2", + "mongoose": "^8.8.0", + "morgan": "^1.10.0", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.0", + "axios": "^1.6.0" + }, + "devDependencies": { + "jest": "^29.7.0", + "supertest": "^7.2.2" + } +} diff --git a/apps/parapharmacy-api/src/config.js b/apps/parapharmacy-api/src/config.js new file mode 100644 index 0000000..4f127be --- /dev/null +++ b/apps/parapharmacy-api/src/config.js @@ -0,0 +1,22 @@ +export const config = { + port: process.env.PORT || 3002, + mongodb: { + uri: process.env.MONGODB_URI || 'mongodb://localhost:27017/parapharmacy', + }, + cors: { + origin: process.env.CORS_ORIGIN || 'http://localhost:3000', + credentials: true, + }, + rateLimit: { + windowMs: 60 * 1000, // 1 minute + max: 100, // limit each IP to 100 requests per windowMs + }, + sources: [ + { id: 'promofarma', name: 'Promofarma', url: 'https://www.promofarma.com' }, + { id: 'pharmarket', name: 'Pharmarket', url: 'https://www.pharmarket.es' }, + { id: 'docmorris', name: 'DocMorris', url: 'https://www.docmorris.es' }, + { id: '1001farma', name: '1001Farma', url: 'https://www.1001farma.net' }, + { id: 'primor', name: 'Primor', url: 'https://www.primor.eu' }, + { id: 'mifarma', name: 'MiFarma', url: 'https://www.mifarma.es' }, + ], +}; diff --git a/apps/parapharmacy-api/src/models/Product.js b/apps/parapharmacy-api/src/models/Product.js new file mode 100644 index 0000000..2386c9c --- /dev/null +++ b/apps/parapharmacy-api/src/models/Product.js @@ -0,0 +1,172 @@ +import mongoose from 'mongoose'; + +const productSchema = new mongoose.Schema({ + // Product data + name: { + type: String, + required: true, + index: true, + }, + brand: { + type: String, + index: true, + }, + category: { + type: String, + index: true, + }, + subcategory: { + type: String, + }, + description: { + type: String, + }, + image_url: { + type: String, + }, + source_url: { + type: String, + required: true, + }, + + // Prices + price: { + type: Number, + required: true, + }, + original_price: { + type: Number, + }, + currency: { + type: String, + default: 'EUR', + }, + + // Source + source: { + type: String, + required: true, + enum: ['promofarma', 'pharmarket', 'docmorris', '1001farma', 'primor', 'mifarma'], + index: true, + }, + source_product_id: { + type: String, + required: true, + }, + + // Metadata + available: { + type: Boolean, + default: true, + }, + rating: { + type: Number, + min: 0, + max: 5, + }, + review_count: { + type: Number, + default: 0, + }, + + // Search terms (for full-text search) + search_terms: [{ + type: String, + }], + + // Timestamps + scraped_at: { + type: Date, + default: Date.now, + }, +}, { + timestamps: true, +}); + +// Compound index for deduplication +productSchema.index({ source: 1, source_product_id: 1 }, { unique: true }); + +// Text index for search +productSchema.index({ + name: 'text', + brand: 'text', + category: 'text', + description: 'text', + search_terms: 'text', +}, { + weights: { + name: 10, + brand: 5, + category: 3, + description: 2, + search_terms: 1, + }, +}); + +// Pre-save hook to populate search_terms +productSchema.pre('save', function (next) { + const terms = new Set(); + + if (this.name) this.name.split(/\s+/).forEach(t => terms.add(t.toLowerCase())); + if (this.brand) this.brand.split(/\s+/).forEach(t => terms.add(t.toLowerCase())); + if (this.category) this.category.split(/\s+/).forEach(t => terms.add(t.toLowerCase())); + if (this.subcategory) this.subcategory.split(/\s+/).forEach(t => terms.add(t.toLowerCase())); + + this.search_terms = Array.from(terms); + next(); +}); + +// Static method to find or create by source +productSchema.statics.findBySource = function (source, sourceProductId) { + return this.findOne({ source, source_product_id: sourceProductId }); +}; + +// Static method to upsert product +productSchema.statics.upsert = async function (data) { + const existing = await this.findBySource(data.source, data.source_product_id); + + if (existing) { + // Update existing product + Object.assign(existing, data, { scraped_at: new Date() }); + return existing.save(); + } + + // Create new product + return this.create(data); +}; + +// Static method to search products +productSchema.statics.search = async function (query, options = {}) { + const { page = 1, limit = 20, category, brand } = options; + + const filter = {}; + + if (category) filter.category = category; + if (brand) filter.brand = brand; + + if (query) { + filter.$text = { $search: query }; + } + + const skip = (page - 1) * limit; + + const [results, total] = await Promise.all([ + this.find(filter) + .sort(query ? { score: { $meta: 'textScore' } } : { scraped_at: -1 }) + .skip(skip) + .limit(limit) + .lean(), + this.countDocuments(filter), + ]); + + return { + results, + total, + page, + pages: Math.ceil(total / limit), + }; +}; + +const Product = mongoose.model('Product', productSchema); + +export default Product; diff --git a/apps/parapharmacy-api/src/routes/products.js b/apps/parapharmacy-api/src/routes/products.js new file mode 100644 index 0000000..05e9a3d --- /dev/null +++ b/apps/parapharmacy-api/src/routes/products.js @@ -0,0 +1,392 @@ +import { Router } from 'express'; +import Product from '../models/Product.js'; + +const router = Router(); + +/** + * @swagger + * /api/products/search: + * get: + * summary: Search parapharmacy products + * tags: [Products] + * parameters: + * - in: query + * name: q + * schema: + * type: string + * description: Search query + * - in: query + * name: category + * schema: + * type: string + * description: Filter by category + * - in: query + * name: brand + * schema: + * type: string + * description: Filter by brand + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 20 + * responses: + * 200: + * description: Search results + */ +router.get('/search', async (req, res) => { + try { + const { q, category, brand, page = 1, limit = 20 } = req.query; + + const result = await Product.search(q, { + page: parseInt(page), + limit: Math.min(parseInt(limit), 50), + category, + brand, + }); + + res.json(result); + } catch (error) { + console.error('[Products] Search error:', error); + res.status(500).json({ error: 'Error searching products' }); + } +}); + +/** + * @swagger + * /api/products/categories: + * get: + * summary: Get all categories + * tags: [Products] + * responses: + * 200: + * description: List of categories + */ +router.get('/categories', async (req, res) => { + try { + const categories = await Product.distinct('category'); + res.json(categories.filter(Boolean).sort()); + } catch (error) { + console.error('[Products] Categories error:', error); + res.status(500).json({ error: 'Error fetching categories' }); + } +}); + +/** + * @swagger + * /api/products/brands: + * get: + * summary: Get all brands + * tags: [Products] + * responses: + * 200: + * description: List of brands + */ +router.get('/brands', async (req, res) => { + try { + const brands = await Product.distinct('brand'); + res.json(brands.filter(Boolean).sort()); + } catch (error) { + console.error('[Products] Brands error:', error); + res.status(500).json({ error: 'Error fetching brands' }); + } +}); + +/** + * @swagger + * /api/products: + * get: + * summary: List all products + * tags: [Products] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 20 + * - in: query + * name: source + * schema: + * type: string + * description: Filter by source + * responses: + * 200: + * description: List of products + */ +router.get('/', async (req, res) => { + try { + const { page = 1, limit = 20, source } = req.query; + + const filter = {}; + if (source) filter.source = source; + + const skip = (parseInt(page) - 1) * parseInt(limit); + + const [products, total] = await Promise.all([ + Product.find(filter) + .sort({ scraped_at: -1 }) + .skip(skip) + .limit(Math.min(parseInt(limit), 50)) + .lean(), + Product.countDocuments(filter), + ]); + + res.json({ + results: products, + total, + page: parseInt(page), + pages: Math.ceil(total / parseInt(limit)), + }); + } catch (error) { + console.error('[Products] List error:', error); + res.status(500).json({ error: 'Error fetching products' }); + } +}); + +/** + * @swagger + * /api/products/{id}: + * get: + * summary: Get product by ID + * tags: [Products] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Product details + * 404: + * description: Product not found + */ +router.get('/:id', async (req, res) => { + try { + const product = await Product.findById(req.params.id).lean(); + + if (!product) { + return res.status(404).json({ error: 'Product not found' }); + } + + res.json(product); + } catch (error) { + console.error('[Products] Get error:', error); + res.status(500).json({ error: 'Error fetching product' }); + } +}); + +/** + * @swagger + * /api/products: + * post: + * summary: Create or update a product (for scraper) + * tags: [Products] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - name + * - price + * - source + * - source_product_id + * - source_url + * responses: + * 201: + * description: Product created/updated + */ +router.post('/', async (req, res) => { + try { + const { + name, + brand, + category, + subcategory, + description, + image_url, + source_url, + price, + original_price, + source, + source_product_id, + available, + rating, + review_count, + } = req.body; + + if (!name || !price || !source || !source_product_id || !source_url) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + const product = await Product.upsert({ + name, + brand, + category, + subcategory, + description, + image_url, + source_url, + price, + original_price, + source, + source_product_id, + available: available !== false, + rating, + review_count, + scraped_at: new Date(), + }); + + res.status(201).json(product); + } catch (error) { + console.error('[Products] Create error:', error); + res.status(500).json({ error: 'Error creating product' }); + } +}); + +/** + * @swagger + * /api/products/bulk: + * post: + * summary: Bulk upsert products (for scraper) + * tags: [Products] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - products + * properties: + * products: + * type: array + * responses: + * 200: + * description: Upsert results + */ +router.post('/bulk', async (req, res) => { + try { + const { products } = req.body; + + if (!Array.isArray(products)) { + return res.status(400).json({ error: 'products must be an array' }); + } + + const results = { + created: 0, + updated: 0, + errors: 0, + }; + + for (const productData of products) { + try { + const existing = await Product.findBySource( + productData.source, + productData.source_product_id + ); + + if (existing) { + Object.assign(existing, productData, { scraped_at: new Date() }); + await existing.save(); + results.updated++; + } else { + await Product.create(productData); + results.created++; + } + } catch (err) { + console.error('[Products] Bulk upsert error:', err.message); + results.errors++; + } + } + + res.json(results); + } catch (error) { + console.error('[Products] Bulk error:', error); + res.status(500).json({ error: 'Error in bulk upsert' }); + } +}); + +/** + * @swagger + * /api/products/{id}: + * put: + * summary: Update a product + * tags: [Products] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Updated product + * 404: + * description: Product not found + */ +router.put('/:id', async (req, res) => { + try { + const product = await Product.findByIdAndUpdate( + req.params.id, + { ...req.body, updated_at: new Date() }, + { new: true } + ); + + if (!product) { + return res.status(404).json({ error: 'Product not found' }); + } + + res.json(product); + } catch (error) { + console.error('[Products] Update error:', error); + res.status(500).json({ error: 'Error updating product' }); + } +}); + +/** + * @swagger + * /api/products/{id}: + * delete: + * summary: Delete a product + * tags: [Products] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 204: + * description: Product deleted + * 404: + * description: Product not found + */ +router.delete('/:id', async (req, res) => { + try { + const product = await Product.findByIdAndDelete(req.params.id); + + if (!product) { + return res.status(404).json({ error: 'Product not found' }); + } + + res.status(204).end(); + } catch (error) { + console.error('[Products] Delete error:', error); + res.status(500).json({ error: 'Error deleting product' }); + } +}); + +export default router; diff --git a/apps/parapharmacy-api/src/server.js b/apps/parapharmacy-api/src/server.js new file mode 100644 index 0000000..92664d4 --- /dev/null +++ b/apps/parapharmacy-api/src/server.js @@ -0,0 +1,130 @@ +import express from 'express'; +import cors from 'cors'; +import mongoose from 'mongoose'; +import morgan from 'morgan'; +import rateLimit from 'express-rate-limit'; +import swaggerJsdoc from 'swagger-jsdoc'; +import swaggerUi from 'swagger-ui-express'; +import { config } from './config.js'; +import productsRouter from './routes/products.js'; + +const app = express(); + +// Swagger configuration +const swaggerOptions = { + definition: { + openapi: '3.0.0', + info: { + title: 'FarmaFinder Parapharmacy API', + version: '1.0.0', + description: 'API for parapharmacy products from multiple Spanish pharmacies', + }, + servers: [ + { + url: `http://localhost:${config.port}`, + description: 'Development server', + }, + ], + components: { + schemas: { + Product: { + type: 'object', + properties: { + _id: { type: 'string' }, + name: { type: 'string' }, + brand: { type: 'string' }, + category: { type: 'string' }, + price: { type: 'number' }, + source: { type: 'string' }, + }, + }, + }, + }, + }, + apis: ['./src/routes/*.js'], +}; + +const swaggerSpec = swaggerJsdoc(swaggerOptions); + +// Middleware +app.use(cors(config.cors)); +app.use(express.json({ limit: '10mb' })); +app.use(morgan('combined')); + +// Rate limiting +const limiter = rateLimit({ + windowMs: config.rateLimit.windowMs, + max: config.rateLimit.max, + standardHeaders: true, + legacyHeaders: false, +}); +app.use(limiter); + +// Swagger UI +app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { + explorer: true, + customCss: '.swagger-ui .topbar { display: none }', + customSiteTitle: 'FarmaFinder Parapharmacy API', +})); + +// Routes +app.use('/api/products', productsRouter); + +// Health check +app.get('/api/health', (req, res) => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + mongodb: mongoose.connection.readyState === 1 ? 'connected' : 'disconnected', + }); +}); + +// Sources info +app.get('/api/sources', (req, res) => { + res.json(config.sources); +}); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error('Unhandled error:', err); + res.status(500).json({ error: 'Internal server error' }); +}); + +// 404 handler +app.use((req, res) => { + res.status(404).json({ error: 'Not found' }); +}); + +// Connect to MongoDB and start server +async function start() { + try { + console.log(`[Parapharmacy API] Connecting to MongoDB: ${config.mongodb.uri}`); + await mongoose.connect(config.mongodb.uri); + console.log('[Parapharmacy API] Connected to MongoDB'); + + app.listen(config.port, () => { + console.log(`[Parapharmacy API] Server running on port ${config.port}`); + console.log(`[Parapharmacy API] Swagger docs: http://localhost:${config.port}/api/docs`); + }); + } catch (error) { + console.error('[Parapharmacy API] Failed to start:', error); + process.exit(1); + } +} + +// Handle graceful shutdown +process.on('SIGTERM', async () => { + console.log('[Parapharmacy API] SIGTERM received, shutting down...'); + await mongoose.disconnect(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + console.log('[Parapharmacy API] SIGINT received, shutting down...'); + await mongoose.disconnect(); + process.exit(0); +}); + +start(); + +export default app; diff --git a/docker-compose.yml b/docker-compose.yml index 380331e..d9ce987 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,6 +84,62 @@ services: depends_on: - postgres + # --- Parapharmacy API --- + parapharmacy-api: + image: git.hacecalor.net/ichitux/farmafinder-parapharmacy-api:latest + build: + context: . + dockerfile: apps/parapharmacy-api/Dockerfile + restart: unless-stopped + ports: + - "3002:3002" + environment: + PORT: "3002" + NODE_ENV: production + MONGODB_URI: mongodb://mongodb:27017/parapharmacy + CORS_ORIGIN: http://localhost:4000 + depends_on: + - mongodb + + # --- MongoDB for Parapharmacy --- + mongodb: + image: mongo:7 + restart: unless-stopped + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db + + # --- N8N Workflow Automation --- + n8n: + image: n8nio/n8n:latest + restart: unless-stopped + ports: + - "5678:5678" + environment: + N8N_BASIC_AUTH_ACTIVE: "true" + N8N_BASIC_AUTH_USER: ${N8N_USER:-admin} + N8N_BASIC_AUTH_PASSWORD: ${N8N_PASSWORD:-change-me} + DB_TYPE: postgresdb + DB_POSTGRESDB_HOST: postgres + DB_POSTGRESDB_DATABASE: farmafinder + DB_POSTGRESDB_USER: farmafinder + DB_POSTGRESDB_PASSWORD: ${PG_PASSWORD:-change-me-in-production} + N8N_HOST: localhost + N8N_PORT: 5678 + N8N_PROTOCOL: http + volumes: + - n8n_data:/home/node/.n8n + depends_on: + - postgres + + # --- Parapharmacy Backend connection --- + backend: + environment: + PARAPHARMACY_API_URL: http://parapharmacy-api:3002 + volumes: backend_data: postgres_data: + mongodb_data: + n8n_data: diff --git a/package.json b/package.json index faaa787..1667d5e 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dev": "turbo run dev", "dev:backend": "turbo run dev --filter=farma-clic-backend", "dev:frontend": "turbo run dev --filter=farma-clic-frontend", + "dev:parapharmacy": "turbo run dev --filter=farmafinder-parapharmacy-api", "build": "turbo run build", "build:web": "turbo run build --filter=farma-clic-frontend", "test": "turbo run test",