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:
@@ -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 ==========
|
// ========== AUTHENTICATION MIDDLEWARE ==========
|
||||||
|
|
||||||
// Middleware to check if user is authenticated
|
// Middleware to check if user is authenticated
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useDebounce } from '../../hooks/useDebounce';
|
|||||||
import { useAuth } from '../../hooks/useAuth';
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
import { useRecentSearches } from '../../hooks/useRecentSearches';
|
import { useRecentSearches } from '../../hooks/useRecentSearches';
|
||||||
import { searchMedicines } from '../../services/medicines';
|
import { searchMedicines } from '../../services/medicines';
|
||||||
import { searchProducts, Product } from '../../services/products';
|
import { searchParapharmacy, Product } from '../../services/products';
|
||||||
import { useThemeContext } from '../../components/ThemeProvider';
|
import { useThemeContext } from '../../components/ThemeProvider';
|
||||||
import { spacing, borderRadius } from '../../constants/theme';
|
import { spacing, borderRadius } from '../../constants/theme';
|
||||||
import { Medicine } from '../../types';
|
import { Medicine } from '../../types';
|
||||||
@@ -78,10 +78,10 @@ export default function SearchScreen() {
|
|||||||
|
|
||||||
const fetchProducts = async () => {
|
const fetchProducts = async () => {
|
||||||
try {
|
try {
|
||||||
const productResults = await searchProducts(debouncedQuery);
|
const response = await searchParapharmacy(debouncedQuery);
|
||||||
setProducts(productResults);
|
setProducts(response.results || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Product search error:', err);
|
console.error('Parapharmacy search error:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,19 @@ import api from './api';
|
|||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
source: 'cima' | 'openfoodfacts';
|
_id?: string;
|
||||||
|
source: 'cima' | 'openfoodfacts' | 'promofarma' | 'pharmarket' | 'docmorris' | '1001farma' | 'primor' | 'mifarma';
|
||||||
name: string;
|
name: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
subcategory?: string;
|
||||||
image_url: string | null;
|
image_url: string | null;
|
||||||
|
source_url?: string;
|
||||||
|
price?: number;
|
||||||
|
original_price?: number;
|
||||||
|
available?: boolean;
|
||||||
|
rating?: number;
|
||||||
|
review_count?: number;
|
||||||
active_ingredient?: string;
|
active_ingredient?: string;
|
||||||
dosage?: string;
|
dosage?: string;
|
||||||
form?: string;
|
form?: string;
|
||||||
@@ -23,7 +31,9 @@ export interface Product {
|
|||||||
export interface ProductSearchResponse {
|
export interface ProductSearchResponse {
|
||||||
results: Product[];
|
results: Product[];
|
||||||
total: number;
|
total: number;
|
||||||
sources: {
|
page?: number;
|
||||||
|
pages?: number;
|
||||||
|
sources?: {
|
||||||
cima: number;
|
cima: number;
|
||||||
openfoodfacts: number;
|
openfoodfacts: number;
|
||||||
};
|
};
|
||||||
@@ -42,6 +52,62 @@ export async function searchProducts(query: string): Promise<Product[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function searchParapharmacy(query: string, options?: {
|
||||||
|
category?: string;
|
||||||
|
brand?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<ProductSearchResponse> {
|
||||||
|
if (!query || query.trim().length < 2) {
|
||||||
|
return { results: [], total: 0 };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const params: Record<string, string | number> = { 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<ProductSearchResponse>('/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<Product | null> {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<Product>(`/products/parapharmacy/${id}`);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Parapharmacy] Detail error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getParapharmacyCategories(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<string[]>('/products/parapharmacy/categories');
|
||||||
|
return data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Parapharmacy] Categories error:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getParapharmacyBrands(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<string[]>('/products/parapharmacy/brands');
|
||||||
|
return data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Parapharmacy] Brands error:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getProduct(source: string, id: string): Promise<Product | null> {
|
export async function getProduct(source: string, id: string): Promise<Product | null> {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get<Product>(`/products/${source}/${id}`);
|
const { data } = await api.get<Product>(`/products/${source}/${id}`);
|
||||||
|
|||||||
@@ -55,21 +55,32 @@ function PublicView({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Only search medications from CIMA
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeoutId = setTimeout(searchMedicines, 300);
|
const timeoutId = setTimeout(searchMedicines, 300);
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [searchQuery]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const fetchPharmacies = async () => {
|
const fetchPharmacies = async () => {
|
||||||
if (!selectedMedicine) {
|
if (!selectedMedicine) {
|
||||||
|
|||||||
@@ -88,24 +88,16 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
|
|||||||
const query = searchQuery.trim();
|
const query = searchQuery.trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Run both searches in parallel for speed
|
// Only search medications from CIMA
|
||||||
const [medicinesRes, productsRes] = await Promise.allSettled([
|
const medicinesRes = await fetch(`/api/medicines/search?q=${encodeURIComponent(query)}`);
|
||||||
fetch(`/api/medicines/search?q=${encodeURIComponent(query)}`),
|
|
||||||
fetch(`/api/products/search?q=${encodeURIComponent(query)}`),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Only update if this search is still the current one
|
// Only update if this search is still the current one
|
||||||
if (query !== searchQuery.trim()) return;
|
if (query !== searchQuery.trim()) return;
|
||||||
|
|
||||||
if (medicinesRes.status === 'fulfilled' && medicinesRes.value.ok) {
|
if (medicinesRes.ok) {
|
||||||
const medicinesData = await medicinesRes.value.json();
|
const medicinesData = await medicinesRes.json();
|
||||||
setMedicines(medicinesData);
|
setMedicines(medicinesData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (productsRes.status === 'fulfilled' && productsRes.value.ok) {
|
|
||||||
const productsData = await productsRes.value.json();
|
|
||||||
setProducts(productsData.results || []);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search error:', error);
|
console.error('Search error:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -116,6 +108,26 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
|
|||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [searchQuery]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const fetchPharmacies = async () => {
|
const fetchPharmacies = async () => {
|
||||||
if (!selectedMedicine) {
|
if (!selectedMedicine) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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`
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -84,6 +84,62 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- 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:
|
volumes:
|
||||||
backend_data:
|
backend_data:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
mongodb_data:
|
||||||
|
n8n_data:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
"dev:backend": "turbo run dev --filter=farma-clic-backend",
|
"dev:backend": "turbo run dev --filter=farma-clic-backend",
|
||||||
"dev:frontend": "turbo run dev --filter=farma-clic-frontend",
|
"dev:frontend": "turbo run dev --filter=farma-clic-frontend",
|
||||||
|
"dev:parapharmacy": "turbo run dev --filter=farmafinder-parapharmacy-api",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"build:web": "turbo run build --filter=farma-clic-frontend",
|
"build:web": "turbo run build --filter=farma-clic-frontend",
|
||||||
"test": "turbo run test",
|
"test": "turbo run test",
|
||||||
|
|||||||
Reference in New Issue
Block a user