const Property = require('./propertyModel') const Land = require('./landModel') const Building = require('./buildingModel') const AppError = require('../../globalErrorHandler/AppError') const { client } = require('../../setup/typesense') const TsApiFeatures = require('../../utils/TsApiFeatures') const { moveFromTempToMain } = require('../file/fileService') 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') } if (data.documentUrls && Array.isArray(data.documentUrls) && data.documentUrls.length > 0) { data.documentUrls = await moveFromTempToMain(data.documentUrls, 'documents') } 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) } const getProperty = async (propertyId) => { let property = await Property.findById(propertyId, null, null).populate('category').populate('status') if (!property) { throw new AppError('NoPropertyFound', 404, {}) } const documentUrls = await property.getDocumentsSignedUrls() const mainImageUrls = await property.getMainImagedSignedUrl() property = JSON.parse(JSON.stringify(property)) property.signedDocumentUrls = documentUrls property.signedMainImageUrls = mainImageUrls return property } 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 property = await Property.findOneAndUpdate({ _id: propertyId }, data, { new: true }) if (!property) { throw new AppError('NoPropertyFound', 404, {}) } return property } const deleteProperty = async (propertyId) => { const property = await Property.findByIdAndUpdate(propertyId, { isActive: false }, { new: true }) if (!property) { throw new AppError('NoPropertyFound', 404, {}) } return null } const ensureCollectionExists = async (forceRecreate = false) => { try { const collections = await client.collections().retrieve() const collectionExists = collections.some((collection) => collection.name === 'properties') if (collectionExists && forceRecreate) { await client.collections('properties').delete() } if (!collectionExists || forceRecreate) { const schema = getTypeSenseSchema() await client.collections().create(schema) console.log('Collection "properties" created successfully') } } catch (err) { console.error('Error ensuring properties collection exists:', err) } } const settingService = require('../setting/settingService') const mongoose = require('mongoose') const searchPropertiesMongo = async (inputQuery = {}) => { const page = parseInt(inputQuery.page) || 1 const per_page = parseInt(inputQuery.per_page) || parseInt(inputQuery.limit) || 20 const mongoQuery = { isActive: { $ne: false } } const q = (inputQuery.query || inputQuery.search || inputQuery.q || '').trim() if (q && q !== '*') { const searchRegex = { $regex: q, $options: 'i' } const orConditions = [ { name: searchRegex }, { nationalGeoCode: searchRegex }, { uniqueId: searchRegex }, { serialNumber: searchRegex }, { ownerName: searchRegex }, { registrationPlate: searchRegex }, { description: searchRegex }, { address: searchRegex }, { tags: searchRegex }, { relatedProjects: searchRegex }, ] if (!isNaN(q) && q !== '') { orConditions.push({ propertyCode: Number(q) }) } if (mongoose.Types.ObjectId.isValid(q)) { orConditions.push({ _id: q }) } mongoQuery.$or = orConditions } if (inputQuery.category && mongoose.Types.ObjectId.isValid(inputQuery.category)) { mongoQuery.category = inputQuery.category } if (inputQuery.status && mongoose.Types.ObjectId.isValid(inputQuery.status)) { mongoQuery.status = inputQuery.status } let sortObj = { createdAt: -1 } if (inputQuery.sort) { if (typeof inputQuery.sort === 'string') { const parts = inputQuery.sort.split(':') const field = parts[0] const order = parts[1] === 'asc' ? 1 : -1 sortObj = { [field]: order } } else if (typeof inputQuery.sort === 'object') { sortObj = inputQuery.sort } } const totalCount = await Property.countDocuments(mongoQuery) const properties = await Property.find(mongoQuery) .populate('category') .populate('status') .sort(sortObj) .skip((page - 1) * per_page) .limit(per_page) const items = properties.map((prop) => { const combinedTags = Array.isArray(prop.tags) ? prop.tags.join(' ') : (prop.tags || '') return { _id: prop._id.toString(), id: prop.propertyCode ? prop.propertyCode.toString() : prop._id.toString(), propertyCode: prop.propertyCode, name: prop.name || '', category: prop.category?.englishName || (typeof prop.category === 'string' ? prop.category : ''), nativeCategory: prop.category?.nativeName || '', categoryObj: prop.category, status: prop.status, imageUrl: prop.mainImageUrls?.[0] || 'https://cloud.mbmanager.ir/images/item-no-image.png', mainImageUrls: prop.mainImageUrls || [], documents: prop.documentUrls || [], documentUrls: prop.documentUrls || [], description: prop.description || '', nationalGeoCode: prop.nationalGeoCode || '', tags: combinedTags, dong: prop.dong, ownerName: prop.ownerName || '', registrationPlate: prop.registrationPlate || '', uniqueId: prop.uniqueId || '', serialNumber: prop.serialNumber || '', coordinates: prop.location?.coordinates || [0, 0], location: prop.location, createdAt: Math.floor(new Date(prop.createdAt || Date.now()).getTime() / 1000), updatedAt: prop.updatedAt, isActive: prop.isActive !== false, features: prop.features || [], } }) const pagesCount = Math.ceil(totalCount / per_page) || 1 return { facets: [], belongings: items, properties: items, totalCount, pagesCount, searchEngine: 'mongo', } } const searchProperties = async (inputQuery) => { try { const setting = await settingService.getSearchSettingByPage('properties') if (setting && setting.searchMethod === 'mongo') { return searchPropertiesMongo(inputQuery) } } catch (e) { console.warn('Failed to fetch search setting for properties, attempting typesense:', e.message) } try { await ensureCollectionExists(false) const updatedQuery = updateInputQuery({ ...inputQuery }, {}) const tsFeatures = new TsApiFeatures(client, 'properties', updatedQuery).filter().facet().sort().limitFields().paginate().search() let { facets, items, totalCount, pagesCount } = await tsFeatures.execute() return { facets, belongings: items, properties: items, totalCount, pagesCount, searchEngine: 'typesense' } } catch (err) { console.warn('Typesense search failed for properties, falling back to MongoDB:', err.message) return searchPropertiesMongo(inputQuery) } } const updateInputQuery = (inputQuery, extraQueries) => { for (const [key, value] of Object.entries(extraQueries)) { inputQuery[key] = value } inputQuery.query_by = 'name,nationalGeoCode,tags,description,_id' return inputQuery } const indexAllProperties = async () => { const batchSize = 20 const count = await Property.countDocuments({ isActive: true }, null) const rounds = Math.max(1, Math.ceil(count / batchSize)) await ensureCollectionExists(true) for (let i = 1; i <= rounds; i++) { const properties = await Property.find({ isActive: true }, null, null) .populate('category') .skip((i - 1) * batchSize) .limit(batchSize) if (properties.length === 0) break let documents = [] for (const property of properties) { 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] || 'https://cloud.mbmanager.ir/images/item-no-image.png', 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(), } documents.push(data) } if (documents.length > 0) { await client.collections('properties').documents().import(documents, { action: 'emplace' }) } } return { totalCount: count } } const indexOneProperty = async (propertyId) => { await ensureCollectionExists(false) const property = await getProperty(propertyId) console.log(`Property ${property._id} indexing`) 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(), } await client.collections('properties').documents().upsert(data) } const getTypeSenseSchema = () => { return { name: 'properties', enable_nested_fields: true, fields: [ { name: '_id', type: 'string', facet: false, infix: false }, { name: 'name', type: 'string', facet: false, infix: true }, { name: 'category', type: 'string', facet: true, infix: true }, { name: 'nativeCategory', type: 'string', facet: false, infix: true }, { name: 'description', type: 'string', facet: false }, { name: 'nationalGeoCode', type: 'string', facet: false, infix: true }, { name: 'tags', type: 'string', facet: false, infix: true }, { name: 'coordinates', type: 'geopoint' }, { name: 'createdAt', type: 'int64', facet: false }, { name: 'imageUrl', type: 'string', facet: false, infix: true }, ], default_sorting_field: 'createdAt', } } module.exports = { updateProperty, indexAllProperties, getProperty, getAllProperties, createProperty, deleteProperty, searchProperties, searchPropertiesMongo, indexOneProperty, }