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
+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;