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