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
This commit is contained in:
Antoni Nuñez Romeu
2026-07-16 12:26:54 +02:00
parent 837f2cd4f3
commit b34657c78f
15 changed files with 1129 additions and 27 deletions
+13
View File
@@ -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
+22
View File
@@ -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"]
+120
View File
@@ -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`
+26
View File
@@ -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"
}
}
+22
View File
@@ -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' },
],
};
+172
View File
@@ -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;
@@ -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;
+130
View File
@@ -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;