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:
@@ -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;
|
||||
Reference in New Issue
Block a user