From e63ac1ddf407b55668560d9141ae91181464d8cd Mon Sep 17 00:00:00 2001 From: Kavehhn174 Date: Wed, 19 Aug 2026 07:46:08 +0330 Subject: [PATCH] fix(property): optimize save performance, fix 2dsphere index error and make search indexing non-blocking --- components/file/fileService.js | 76 ++++----- components/property/events/createProperty.js | 8 +- components/property/events/updateProperty.js | 12 ++ components/property/propertyController.js | 2 +- components/property/propertyModel.js | 19 ++- components/property/propertyService.js | 156 ++++++++++++++----- events/index.js | 2 + 7 files changed, 193 insertions(+), 82 deletions(-) create mode 100644 components/property/events/updateProperty.js diff --git a/components/file/fileService.js b/components/file/fileService.js index a1faa56..8d17aa7 100644 --- a/components/file/fileService.js +++ b/components/file/fileService.js @@ -174,50 +174,52 @@ const moveFromTempToMain = async (files, targetBucket) => { return [] } - const { ensureBucketsExist, minioClient } = require('../../utils/upload/bucket') - await ensureBucketsExist([tempBucket, targetBucket]).catch(() => {}) + const { minioClient } = require('../../utils/upload/bucket') - const updatedUrls = [] + const updatedUrls = await Promise.all( + files.map(async (item) => { + const rawStr = typeof item === 'object' && item !== null ? (item.url || item.fileName || item.name || '') : String(item || '') + const fileName = getLastUrlSegment(rawStr) - for (const item of files) { - const rawStr = typeof item === 'object' && item !== null ? (item.url || item.fileName || item.name || '') : String(item || '') - const fileName = getLastUrlSegment(rawStr) - - if (!fileName) { - continue - } - - try { - // Check if object exists in temp bucket - const existsInTemp = await minioClient.statObject(tempBucket, fileName).then(() => true).catch(() => false) - if (existsInTemp) { - // Copy object from temp to targetBucket - await copyObject(targetBucket, fileName, `/${tempBucket}/${fileName}`) - // Remove from temp bucket - await removeObject(tempBucket, fileName) - // Update File record in database if exists - await File.findOneAndUpdate({ fileName }, { bucketName: targetBucket }, null).catch(() => {}) + if (!fileName) { + return null } - // Construct new URL for targetBucket - const protocol = process.env.AWS_USE_SSL === 'true' ? 'https://' : 'http://' - const endpoint = process.env.AWS_ENDPOINT || 'localhost' - const hostPort = (endpoint.includes(':') || !process.env.AWS_PORT) ? endpoint : `${endpoint}:${process.env.AWS_PORT}` - const fullHost = hostPort.startsWith('http://') || hostPort.startsWith('https://') ? hostPort : `${protocol}${hostPort}` - const newUrl = `${fullHost.replace(/\/$/, '')}/${targetBucket}/${fileName}` + try { + // Check if object exists in temp bucket + const existsInTemp = await minioClient.statObject(tempBucket, fileName).then(() => true).catch(() => false) + if (existsInTemp) { + // Copy object from temp to targetBucket + await copyObject(targetBucket, fileName, `/${tempBucket}/${fileName}`).catch((err) => { + console.error(`Failed to copy object "${fileName}" to "${targetBucket}":`, err.message) + }) + // Remove from temp bucket + await removeObject(tempBucket, fileName).catch((err) => { + console.error(`Failed to remove object "${fileName}" from "${tempBucket}":`, err.message) + }) + // Update File record in database if exists + await File.findOneAndUpdate({ fileName }, { bucketName: targetBucket }, null).catch(() => {}) + } - updatedUrls.push(newUrl) - } catch (err) { - console.error(`Failed to move file "${fileName}" from ${tempBucket} to ${targetBucket}:`, err) - if (rawStr.includes(`/${tempBucket}/`)) { - updatedUrls.push(rawStr.replace(`/${tempBucket}/`, `/${targetBucket}/`)) - } else { - updatedUrls.push(rawStr) + // Construct new URL for targetBucket + const protocol = process.env.AWS_USE_SSL === 'true' ? 'https://' : 'http://' + const endpoint = process.env.AWS_ENDPOINT || 'localhost' + const hostPort = (endpoint.includes(':') || !process.env.AWS_PORT) ? endpoint : `${endpoint}:${process.env.AWS_PORT}` + const fullHost = hostPort.startsWith('http://') || hostPort.startsWith('https://') ? hostPort : `${protocol}${hostPort}` + const newUrl = `${fullHost.replace(/\/$/, '')}/${targetBucket}/${fileName}` + + return newUrl + } catch (err) { + console.error(`Failed to move file "${fileName}" from ${tempBucket} to ${targetBucket}:`, err.message) + if (rawStr.includes(`/${tempBucket}/`)) { + return rawStr.replace(`/${tempBucket}/`, `/${targetBucket}/`) + } + return rawStr } - } - } + }) + ) - return updatedUrls + return updatedUrls.filter(Boolean) } const getImage = async (bucketName, fileName) => { diff --git a/components/property/events/createProperty.js b/components/property/events/createProperty.js index 56cbb2d..52247d0 100644 --- a/components/property/events/createProperty.js +++ b/components/property/events/createProperty.js @@ -1,6 +1,12 @@ const propertyService = require('../propertyService') module.exports = (eventEmitter) => { eventEmitter.on('createProperty', async (propertyData) => { - await propertyService.indexOneProperty(propertyData._id) + try { + if (propertyData && propertyData._id) { + await propertyService.indexOneProperty(propertyData._id) + } + } catch (err) { + console.warn('Error in createProperty event handler:', err.message) + } }) } diff --git a/components/property/events/updateProperty.js b/components/property/events/updateProperty.js new file mode 100644 index 0000000..cecc40f --- /dev/null +++ b/components/property/events/updateProperty.js @@ -0,0 +1,12 @@ +const propertyService = require('../propertyService') +module.exports = (eventEmitter) => { + eventEmitter.on('updateProperty', async (propertyData) => { + try { + if (propertyData && propertyData._id) { + await propertyService.indexOneProperty(propertyData._id) + } + } catch (err) { + console.warn('Error in updateProperty event handler:', err.message) + } + }) +} diff --git a/components/property/propertyController.js b/components/property/propertyController.js index 5cec38a..cf69d46 100644 --- a/components/property/propertyController.js +++ b/components/property/propertyController.js @@ -77,7 +77,7 @@ exports.getProperty = catchAsync(async (req, res, next) => { exports.updateProperty = catchAsync(async (req, res, next) => { const property = await propertyService.updateProperty(req.params.propertyId, req.body) - await propertyService.indexAllProperties() + eventEmitter.emit('updateProperty', property) res.status(200).json({ status: 'success', diff --git a/components/property/propertyModel.js b/components/property/propertyModel.js index c9134d6..e9de4f7 100644 --- a/components/property/propertyModel.js +++ b/components/property/propertyModel.js @@ -73,6 +73,7 @@ const propertySchema = new mongoose.Schema( ownershipDocs: String, // Property Specifications (مشخصات ملک) + area: Number, // مساحت عرصه (متر مربع) registrationPlate: String, mainNumber: String, subNumber: String, @@ -115,6 +116,22 @@ const propertySchema = new mongoose.Schema( propertySchema.plugin(AutoIncrement, { inc_field: 'propertyCode' }) +// Pre-validate hook: sanitize location for 2dsphere index +propertySchema.pre('validate', function (next) { + if ( + !this.location || + !Array.isArray(this.location.coordinates) || + this.location.coordinates.length !== 2 || + isNaN(this.location.coordinates[0]) || + isNaN(this.location.coordinates[1]) + ) { + this.location = undefined + } else { + this.location.type = 'Point' + } + next() +}) + propertySchema.methods.getDocumentsSignedUrls = function () { return getMultipleSignedUrls(this.documentUrls, 'documents') } @@ -123,7 +140,7 @@ propertySchema.methods.getMainImagedSignedUrl = function () { return getMultipleSignedUrls(this.mainImageUrls, 'properties') } -propertySchema.index({ location: '2dsphere' }) +propertySchema.index({ location: '2dsphere' }, { sparse: true }) const Property = mongoose.model('Property', propertySchema) diff --git a/components/property/propertyService.js b/components/property/propertyService.js index c2f4e08..090a6fa 100644 --- a/components/property/propertyService.js +++ b/components/property/propertyService.js @@ -7,28 +7,96 @@ const TsApiFeatures = require('../../utils/TsApiFeatures') const { moveFromTempToMain, getSignedUrl } = require('../file/fileService') const { normalizeToDate } = require('../../utils/jalali') +const sanitizePropertyData = (data) => { + const cleanData = { ...data } + + // Remove immutable / system fields + delete cleanData._id + delete cleanData.createdAt + delete cleanData.updatedAt + delete cleanData.__v + + // Sanitize category & status ObjectIds + if (cleanData.category && !mongoose.Types.ObjectId.isValid(cleanData.category)) { + cleanData.category = null + } else if (cleanData.category === '') { + cleanData.category = null + } + + if (cleanData.status && !mongoose.Types.ObjectId.isValid(cleanData.status)) { + cleanData.status = null + } else if (cleanData.status === '') { + cleanData.status = null + } + + // Sanitize registrationDate + if (cleanData.registrationDate !== undefined) { + cleanData.registrationDate = normalizeToDate(cleanData.registrationDate) + } + + // Sanitize Location (GeoJSON Point for 2dsphere) + if (cleanData.location) { + const coords = cleanData.location.coordinates + if (Array.isArray(coords) && coords.length === 2 && coords[0] !== null && coords[1] !== null && !isNaN(coords[0]) && !isNaN(coords[1])) { + cleanData.location = { + type: 'Point', + coordinates: [Number(coords[0]), Number(coords[1])] + } + } else { + cleanData.location = undefined + } + } + + // Sanitize Features + if (cleanData.features && Array.isArray(cleanData.features)) { + cleanData.features = cleanData.features.filter( + (f) => f && f.definition && mongoose.Types.ObjectId.isValid(f.definition) + ) + } + + // Sanitize dong + if (cleanData.dong !== undefined && cleanData.dong !== null && cleanData.dong !== '') { + const dongNum = Number(cleanData.dong) + cleanData.dong = isNaN(dongNum) ? 6 : Math.max(1, Math.min(6, dongNum)) + } + + // Sanitize numeric fields + const numericFields = [ + 'area', 'landArea', 'builtArea', 'floorsCount', 'unitsCount', + 'constructionYear', 'streetWidth', 'facadeCount', 'landLength', 'landWidth' + ] + numericFields.forEach((field) => { + if (cleanData[field] === '' || cleanData[field] === undefined) { + cleanData[field] = null + } else if (cleanData[field] !== null) { + const num = Number(cleanData[field]) + cleanData[field] = isNaN(num) ? null : num + } + }) + + return cleanData +} + const getAllProperties = async () => { return Property.find({}, null, null).populate('category').populate('status') } const createProperty = async (data) => { - if (data.mainImageUrls && Array.isArray(data.mainImageUrls) && data.mainImageUrls.length > 0) { - data.mainImageUrls = await moveFromTempToMain(data.mainImageUrls, 'properties') + const cleanData = sanitizePropertyData(data) + + if (cleanData.mainImageUrls && Array.isArray(cleanData.mainImageUrls) && cleanData.mainImageUrls.length > 0) { + cleanData.mainImageUrls = await moveFromTempToMain(cleanData.mainImageUrls, 'properties') } - if (data.documentUrls && Array.isArray(data.documentUrls) && data.documentUrls.length > 0) { - data.documentUrls = await moveFromTempToMain(data.documentUrls, 'documents') + if (cleanData.documentUrls && Array.isArray(cleanData.documentUrls) && cleanData.documentUrls.length > 0) { + cleanData.documentUrls = await moveFromTempToMain(cleanData.documentUrls, 'documents') } - if (data.registrationDate !== undefined) { - data.registrationDate = normalizeToDate(data.registrationDate) + if (cleanData.propertyType === 'Land' || cleanData.propertyType === 'زمین') { + return Land.create(cleanData) + } else if (cleanData.propertyType === 'Building' || cleanData.propertyType === 'ساختمان') { + return Building.create(cleanData) } - - if (data.propertyType === 'Land' || data.propertyType === 'زمین') { - return Land.create(data) - } else if (data.propertyType === 'Building' || data.propertyType === 'ساختمان') { - return Building.create(data) - } - return Property.create(data) + return Property.create(cleanData) } const resolvePropertyFilter = (propertyId) => { @@ -76,19 +144,17 @@ const getProperty = async (propertyId) => { } const updateProperty = async (propertyId, data) => { - if (data.mainImageUrls && Array.isArray(data.mainImageUrls) && data.mainImageUrls.length > 0) { - data.mainImageUrls = await moveFromTempToMain(data.mainImageUrls, 'properties') - } - if (data.documentUrls && Array.isArray(data.documentUrls) && data.documentUrls.length > 0) { - data.documentUrls = await moveFromTempToMain(data.documentUrls, 'documents') - } + const cleanData = sanitizePropertyData(data) - if (data.registrationDate !== undefined) { - data.registrationDate = normalizeToDate(data.registrationDate) + if (cleanData.mainImageUrls && Array.isArray(cleanData.mainImageUrls) && cleanData.mainImageUrls.length > 0) { + cleanData.mainImageUrls = await moveFromTempToMain(cleanData.mainImageUrls, 'properties') + } + if (cleanData.documentUrls && Array.isArray(cleanData.documentUrls) && cleanData.documentUrls.length > 0) { + cleanData.documentUrls = await moveFromTempToMain(cleanData.documentUrls, 'documents') } const filter = resolvePropertyFilter(propertyId) - const property = await Property.findOneAndUpdate(filter, data, { new: true }) + const property = await Property.findOneAndUpdate(filter, cleanData, { new: true }) if (!property) { throw new AppError('NoPropertyFound', 404, {}) @@ -323,30 +389,36 @@ const indexAllProperties = async () => { } const indexOneProperty = async (propertyId) => { - await ensureCollectionExists(false) - const property = await getProperty(propertyId) + try { + await ensureCollectionExists(false) + const property = await getProperty(propertyId) - console.log(`Property ${property._id} indexing`) + if (!property) return - const combinedTags = Array.isArray(property.tags) ? property.tags.join(' ') : (property.tags || '') + const combinedTags = Array.isArray(property.tags) ? property.tags.join(' ') : (property.tags || '') - const data = { - _id: property._id.toString(), - isActive: property.isActive, - name: property.name || '', - category: property.category?.englishName || '', - nativeCategory: property.category?.nativeName || '', - imageUrl: property.mainImageUrls?.[0] || '', - documents: property.documentUrls || [], - description: property.description || '', - nationalGeoCode: property.nationalGeoCode || '', - tags: combinedTags, - coordinates: property.location?.coordinates || [0, 0], - createdAt: Math.floor(new Date(property.createdAt || Date.now()).getTime() / 1000), - id: property.propertyCode ? property.propertyCode.toString() : property._id.toString(), + const data = { + _id: property._id.toString(), + isActive: property.isActive !== false, + name: property.name || '', + category: property.category?.englishName || (typeof property.category === 'string' ? property.category : ''), + nativeCategory: property.category?.nativeName || '', + imageUrl: property.mainImageUrls?.[0] || '', + documents: property.documentUrls || [], + description: property.description || '', + nationalGeoCode: property.nationalGeoCode || '', + tags: combinedTags, + coordinates: (property.location?.coordinates && property.location.coordinates.length === 2 && !isNaN(property.location.coordinates[0]) && !isNaN(property.location.coordinates[1])) + ? [property.location.coordinates[1], property.location.coordinates[0]] // Typesense geopoint is [lat, lng] + : [0, 0], + createdAt: Math.floor(new Date(property.createdAt || Date.now()).getTime() / 1000), + id: property.propertyCode ? property.propertyCode.toString() : property._id.toString(), + } + + await client.collections('properties').documents().upsert(data) + } catch (err) { + console.warn(`Typesense indexing skipped for property ${propertyId}:`, err.message) } - - await client.collections('properties').documents().upsert(data) } const getTypeSenseSchema = () => { diff --git a/events/index.js b/events/index.js index 1a209b5..8710815 100644 --- a/events/index.js +++ b/events/index.js @@ -1,10 +1,12 @@ // events/index.js const { eventEmitter } = require('./eventEmitter') const createProperty = require('../components/property/events/createProperty') +const updateProperty = require('../components/property/events/updateProperty') const deleteProperty = require('../components/property/events/deleteProperty') const registerEventHandlers = () => { // Register all event handlers createProperty(eventEmitter) + updateProperty(eventEmitter) deleteProperty(eventEmitter) // Add additional error monitoring if needed eventEmitter.on('error', (error) => {